From 90a0de0429d51332c29b336bf09b7f7c97bfe10d Mon Sep 17 00:00:00 2001 From: Sysy's Date: Tue, 31 Mar 2026 14:10:37 +0200 Subject: [PATCH] Add level selection menu and persistence Introduce a level selection UI and persistence for unlocked levels. Adds CSS styles and HTML markup for a floating level menu, and JS to load/save highest unlocked level to localStorage (key: mirror-game-highest-unlocked-level). Implements functions to render/toggle the menu, unlock levels on finish, and navigate to arbitrary levels via goToLevel. nextLevel now delegates to goToLevel, and setup calls loadUnlockedLevels() and setupLevelMenu() so the menu reflects progress immediately. --- web/assets/css/game.css | 65 +++++++++++++++++++ web/assets/js/game.js | 122 +++++++++++++++++++++++++++++++++-- web/templates/view/game.html | 10 +++ 3 files changed, 190 insertions(+), 7 deletions(-) diff --git a/web/assets/css/game.css b/web/assets/css/game.css index cfb9c32..ff2335d 100644 --- a/web/assets/css/game.css +++ b/web/assets/css/game.css @@ -20,6 +20,71 @@ body { user-select: none; } +.level-menu-shell { + position: fixed; + top: 24px; + right: 24px; + z-index: 1100; + display: flex; + align-items: flex-start; + gap: 10px; +} + +.level-menu-toggle { + border: none; + border-radius: 999px; + background: #223; + color: #fff; + padding: 12px 18px; + font-size: 0.95rem; + font-weight: 700; + cursor: pointer; + box-shadow: 0 8px 20px rgba(34, 51, 68, 0.18); +} + +.level-menu-panel { + min-width: 180px; + background: rgba(255, 255, 255, 0.96); + border-radius: 18px; + padding: 14px; + box-shadow: 0 18px 40px rgba(34, 51, 68, 0.18); + border: 1px solid rgba(34, 51, 68, 0.08); +} + +.level-menu-panel h2 { + font-size: 0.95rem; + color: #223; + margin-bottom: 10px; +} + +.level-menu-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.level-menu-item { + border: none; + border-radius: 10px; + padding: 10px 12px; + text-align: left; + background: #dfe5f8; + color: #223; + font-weight: 700; + cursor: pointer; +} + +.level-menu-item.is-current { + background: #223; + color: #fff; +} + +.level-menu-item:disabled { + background: #eceff7; + color: #8a92a3; + cursor: not-allowed; +} + main { display: flex; flex-direction: column; diff --git a/web/assets/js/game.js b/web/assets/js/game.js index c5668f2..ad0ce73 100644 --- a/web/assets/js/game.js +++ b/web/assets/js/game.js @@ -162,6 +162,8 @@ let rotatorIntervals = {}; let toggledDoors = {}; let poweredCaptors = {}; let draggedGlassColor = null; +let highestUnlockedLevelIndex = 0; +const unlockedLevelsStorageKey = "mirror-game-highest-unlocked-level"; function getCurrentLevel() { return levels[currentLevelIndex]; @@ -171,6 +173,107 @@ function getCurrentLevelConfig(configByLevel) { return configByLevel[currentLevelIndex] || {}; } +function loadUnlockedLevels() { + const savedValue = window.localStorage.getItem(unlockedLevelsStorageKey); + const parsedValue = Number.parseInt(savedValue || "0", 10); + + if (Number.isNaN(parsedValue)) { + highestUnlockedLevelIndex = 0; + return; + } + + highestUnlockedLevelIndex = Math.max(0, Math.min(parsedValue, levels.length - 1)); +} + +function saveUnlockedLevels() { + window.localStorage.setItem(unlockedLevelsStorageKey, String(highestUnlockedLevelIndex)); +} + +function unlockLevel(levelIndex) { + if (levelIndex <= highestUnlockedLevelIndex || levelIndex >= levels.length) { + return; + } + + highestUnlockedLevelIndex = levelIndex; + saveUnlockedLevels(); + renderLevelMenu(); +} + +function toggleLevelMenu() { + const levelMenuPanel = document.getElementById("level-menu-panel"); + const levelMenuToggle = document.getElementById("level-menu-toggle"); + + if (!levelMenuPanel || !levelMenuToggle) { + return; + } + + const isOpening = levelMenuPanel.hasAttribute("hidden"); + + if (isOpening) { + levelMenuPanel.removeAttribute("hidden"); + } else { + levelMenuPanel.setAttribute("hidden", ""); + } + + levelMenuToggle.setAttribute("aria-expanded", String(isOpening)); +} + +function renderLevelMenu() { + const levelMenuList = document.getElementById("level-menu-list"); + + if (!levelMenuList) { + return; + } + + levelMenuList.innerHTML = ""; + + for (let levelIndex = 0; levelIndex < levels.length; levelIndex++) { + const levelButton = document.createElement("button"); + const isUnlocked = levelIndex <= highestUnlockedLevelIndex; + const isCurrentLevel = levelIndex === currentLevelIndex; + + levelButton.type = "button"; + levelButton.classList.add("level-menu-item"); + levelButton.textContent = isUnlocked ? `Level ${levelIndex + 1}` : `Level ${levelIndex + 1} - Locked`; + levelButton.disabled = !isUnlocked; + + if (isCurrentLevel) { + levelButton.classList.add("is-current"); + } + + levelButton.addEventListener("click", () => { + if (!isUnlocked) { + return; + } + + goToLevel(levelIndex); + const levelMenuPanel = document.getElementById("level-menu-panel"); + const levelMenuToggle = document.getElementById("level-menu-toggle"); + + if (levelMenuPanel) { + levelMenuPanel.setAttribute("hidden", ""); + } + + if (levelMenuToggle) { + levelMenuToggle.setAttribute("aria-expanded", "false"); + } + }); + + levelMenuList.appendChild(levelButton); + } +} + +function setupLevelMenu() { + const levelMenuToggle = document.getElementById("level-menu-toggle"); + + if (!levelMenuToggle) { + return; + } + + levelMenuToggle.addEventListener("click", toggleLevelMenu); + renderLevelMenu(); +} + function getCurrentGlassOptions() { return glassOptions[currentLevelIndex] || []; } @@ -892,22 +995,19 @@ function traceLaser() { } function finish() { + unlockLevel(currentLevelIndex + 1); + setTimeout(() => { const winOverlay = document.querySelector(".win-overlay"); winOverlay.style.visibility = "visible"; }, 100); } -function nextLevel() { - currentLevelIndex++; - +function goToLevel(levelIndex) { + currentLevelIndex = levelIndex; isLevelFinished = false; stopAllRotatorButtons(); - if (currentLevelIndex >= levels.length) { - currentLevelIndex = 0; - } - initializeMirrorOrientations(); glassPlacements = {}; resetGlassInventory(); @@ -923,10 +1023,18 @@ function nextLevel() { const winOverlay = document.querySelector(".win-overlay"); winOverlay.style.visibility = "hidden"; + renderLevelMenu(); } +function nextLevel() { + const nextLevelIndex = currentLevelIndex + 1 >= levels.length ? 0 : currentLevelIndex + 1; + goToLevel(nextLevelIndex); +} + +loadUnlockedLevels(); resetGlassInventory(); createPalette(); initializeMirrorOrientations(); blockBrowserDrop(); +setupLevelMenu(); traceLaser(); diff --git a/web/templates/view/game.html b/web/templates/view/game.html index 8ee4b57..a0351a0 100644 --- a/web/templates/view/game.html +++ b/web/templates/view/game.html @@ -15,6 +15,16 @@ + +