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 @@
+
+