From 4282dbdd07f090a883abad574d5e4ee38e246bba Mon Sep 17 00:00:00 2001 From: Daddy32 Date: Sat, 13 Dec 2025 13:47:19 +0100 Subject: [PATCH] Add win condition UI and zero-G grid level --- index.html | 17 +++ main.js | 160 +++++++++++++++++++- scenes.js | 127 +++++++++++++++- styles.css | 428 +++++++++++++++++++++++++++++++---------------------- ui.js | 40 +++++ 5 files changed, 590 insertions(+), 182 deletions(-) diff --git a/index.html b/index.html index 6db5ab3..1089d60 100644 --- a/index.html +++ b/index.html @@ -34,6 +34,16 @@ +
+
+
Level complete!
+
+
+ + +
+
+
@@ -56,6 +66,13 @@
High score 0
+
+ Goal + +
+
+
+
Scene diff --git a/main.js b/main.js index 7d8cd03..bc395c0 100644 --- a/main.js +++ b/main.js @@ -17,6 +17,7 @@ const config = { gravity: 1, spawnIntervalMs: 520, + autoSpawn: true, minChain: 3, palette: ["#ff595e", "#ffca3a", "#8ac926", "#1982c4", "#6a4c93"], ballRadius: 18, @@ -96,8 +97,10 @@ let spawnTimer = null; let score = 0; let highScore = 0; + let clearedCount = 0; let gameOver = false; let isPaused = false; + let levelWon = false; const makeStorageKey = (sceneId) => `physilinks-highscore-${sceneId}`; @@ -128,6 +131,8 @@ config.link = { ...next.config.link }; updateBallRadius(prevRadius); engine.gravity.y = config.gravity; + clearedCount = 0; + levelWon = false; highScore = loadHighScore(next.id); rebuildSceneBodies(); buildLegend(); @@ -135,6 +140,14 @@ updateHud(); }; + const getNextSceneId = () => { + const winCond = currentScene?.config?.winCondition; + if (winCond?.nextSceneId) return winCond.nextSceneId; + const idx = scenes.findIndex((s) => s.id === currentScene?.id); + if (idx >= 0 && idx < scenes.length - 1) return scenes[idx + 1].id; + return scenes[0]?.id || defaultSceneId; + }; + const chain = { active: false, color: null, @@ -152,6 +165,9 @@ return mult * config.ballRadius; }; + const isGridScene = () => + currentScene && currentScene.config && currentScene.config.gridLayouts; + const spawnBall = () => { if (gameOver) return; const color = @@ -184,6 +200,8 @@ }; const startSpawner = () => { + if (isGridScene()) return; + if (currentScene?.config?.autoSpawn === false) return; if (spawnTimer) clearInterval(spawnTimer); spawnTimer = setInterval(spawnBall, config.spawnIntervalMs); }; @@ -202,6 +220,60 @@ } }; + const getGridDimensions = () => { + const padding = currentScene?.config?.gridPadding ?? 0.08; + const usableW = width * (1 - padding * 2); + const usableH = height * (1 - padding * 2); + const gridSize = Math.min(usableW, usableH); + const cellSize = gridSize / 8; + const startX = (width - gridSize) / 2 + cellSize / 2; + const startY = (height - gridSize) / 2 + cellSize / 2; + return { cellSize, startX, startY }; + }; + + const getGridColor = (letter) => { + if (!letter || letter === "." || letter === " ") return null; + const legend = currentScene?.config?.gridLegend || {}; + if (legend[letter]) return legend[letter]; + const paletteToUse = currentScene?.config?.palette || config.palette; + const index = + (letter.toUpperCase().charCodeAt(0) - 65) % paletteToUse.length; + return paletteToUse[index]; + }; + + const spawnGridBalls = () => { + const layouts = currentScene?.config?.gridLayouts || []; + if (!layouts.length) return; + const layout = layouts[Math.floor(Math.random() * layouts.length)]; + if (!Array.isArray(layout)) return; + const { cellSize, startX, startY } = getGridDimensions(); + const radius = computeBallRadius(); + config.ballRadius = radius; + layout.forEach((row, rowIdx) => { + if (typeof row !== "string") return; + for (let colIdx = 0; colIdx < 8; colIdx += 1) { + const letter = row[colIdx] || "."; + const color = getGridColor(letter); + if (!color) continue; + const x = startX + colIdx * cellSize; + const y = startY + rowIdx * cellSize; + const ball = Bodies.circle(x, y, radius, { + restitution: 0.12, + friction: 0.01, + frictionAir: 0.01, + render: { + fillStyle: color, + strokeStyle: "#0b1222", + lineWidth: 2, + }, + }); + ball.plugin = { color, hasEntered: true, entryCheckId: null }; + balls.push(ball); + World.add(world, ball); + } + }); + }; + const triggerGameOver = () => { if (gameOver) return; gameOver = true; @@ -215,9 +287,12 @@ }; const restartGame = () => { + stopSpawner(); gameOver = false; isPaused = false; + levelWon = false; score = 0; + clearedCount = 0; resetChainVisuals(); balls.forEach((ball) => { cleanupBall(ball); @@ -225,11 +300,16 @@ }); balls.length = 0; ui.hideGameOver(); + ui.hideWin(); ui.setPauseState(false); engine.timing.timeScale = 1; startRunner(); updateHud(); - startSpawner(); + if (isGridScene()) { + spawnGridBalls(); + } else { + startSpawner(); + } }; const setHighlight = (body, on) => { @@ -238,7 +318,7 @@ }; const setPaused = (state) => { - if (gameOver) return; + if (gameOver || levelWon) return; if (state === isPaused) return; isPaused = state; ui.setPauseState(isPaused); @@ -265,6 +345,28 @@ updateHud(); }; + const applyWinEffects = () => { + const winCond = currentScene?.config?.winCondition; + if (!winCond || !winCond.onWin) return; + if (typeof winCond.onWin.setGravity === "number") { + engine.gravity.y = winCond.onWin.setGravity; + } + }; + + const checkWinCondition = () => { + if (levelWon) return; + const goal = getGoalState(); + ui.setGoal(goal || { label: "—", progress: 0 }); + if (!goal || !goal.met) return; + applyWinEffects(); + levelWon = true; + stopSpawner(); + engine.timing.timeScale = 1; + startRunner(); + ui.setPauseState(false); + ui.showWin(goal.label.replace("left", "done")); + }; + const removeLastFromChain = () => { const removedConstraint = chain.constraints.pop(); if (removedConstraint) { @@ -309,6 +411,7 @@ if (chain.bodies.length >= config.minChain) { const gain = 10 * Math.pow(chain.bodies.length, 2); score += gain; + clearedCount += chain.bodies.length; if (score > highScore) { highScore = score; saveHighScore(); @@ -335,6 +438,7 @@ chain.constraints = []; chain.pointer = null; updateHud(); + checkWinCondition(); }; const pickBody = (point) => { @@ -353,7 +457,7 @@ }; const handlePointerDown = (evt) => { - if (gameOver || isPaused) return; + if (gameOver || isPaused || levelWon) return; const point = getPointerPosition(evt); const body = pickBody(point); if (!body) return; @@ -368,7 +472,7 @@ const handlePointerMove = (evt) => { if (!chain.active) return; - if (gameOver || isPaused) return; + if (gameOver || isPaused || levelWon) return; const point = getPointerPosition(evt); chain.pointer = point; const body = pickBody(point); @@ -429,6 +533,8 @@ highScore, activeColor: chain.color, }); + const goal = getGoalState(); + ui.setGoal(goal || { label: "—", progress: 0 }); }; const buildLegend = () => { @@ -436,6 +542,16 @@ }; const computeBallRadius = () => { + if (isGridScene()) { + const padding = currentScene?.config?.gridPadding ?? 0.08; + const usableW = width * (1 - padding * 2); + const usableH = height * (1 - padding * 2); + const gridSize = Math.min(usableW, usableH); + const cellSize = gridSize / 8; + const scale = currentScene?.config?.gridBallScale ?? 0.36; + const scaled = cellSize * scale; + return Math.round(Math.max(10, Math.min(60, scaled))); + } const baseRadius = (currentScene && currentScene.config && currentScene.config.ballRadius) || config.ballRadius || @@ -461,6 +577,41 @@ config.ballRadius = nextRadius; }; + const getGoalState = () => { + const winCond = currentScene?.config?.winCondition; + if (!winCond) return null; + if (winCond.type === "clearCount") { + const target = winCond.target ?? 0; + const remaining = Math.max(0, target - clearedCount); + return { + label: `Clear ${target} balls (${remaining} left)`, + progress: target > 0 ? (100 * clearedCount) / target : 0, + met: clearedCount >= target, + }; + } + if (winCond.type === "score") { + const target = winCond.target ?? 0; + const remaining = Math.max(0, target - score); + return { + label: `Score ${target} (${remaining} left)`, + progress: target > 0 ? (100 * score) / target : 0, + met: score >= target, + }; + } + if (winCond.type === "colorClear" && Array.isArray(winCond.targets)) { + const target = winCond.targets.reduce( + (sum, t) => sum + (t.count || 0), + 0, + ); + return { + label: "Clear target colors", + progress: target > 0 ? (100 * clearedCount) / target : 0, + met: false, + }; + } + return null; + }; + const clampBodiesIntoView = (prevWidth, prevHeight) => { const scaleX = width / (prevWidth || width); const scaleY = height / (prevHeight || height); @@ -591,6 +742,7 @@ onPauseToggle: () => setPaused(!isPaused), onRestart: restartGame, onSceneChange: (id) => applyScene(id), + onWinNext: () => applyScene(getNextSceneId()), }); ui.setSceneOptions( scenes, diff --git a/scenes.js b/scenes.js index ee40299..75dba41 100644 --- a/scenes.js +++ b/scenes.js @@ -2,9 +2,132 @@ const { Bodies } = Matter; const scenes = [ + { + id: "scene-grid", + name: "Zero-G Grid (default)", + config: { + gravity: 0, + spawnIntervalMs: 0, + autoSpawn: false, + minChain: 3, + palette: ["#38bdf8", "#f472b6", "#facc15", "#34d399", "#a78bfa"], + ballRadius: 24, + gridPadding: 0.08, // percent of viewport padding applied to both axes + gridBallScale: 0.38, // percent of cell size used as radius + gridLegend: { + A: "#38bdf8", + B: "#f472b6", + C: "#facc15", + D: "#34d399", + E: "#a78bfa", + }, + winCondition: { + type: "clearCount", + target: 35, + nextSceneId: "scene1", + onWin: { setGravity: 0.88 }, + }, + gridLayouts: [ + [ + "AABBBBAA", + "ACCCBCCA", + "ACDDDDCA", + "ACEEEECA", + "ACEEEECA", + "ACDDDDCA", + "ACCCBCCA", + "AABBBBAA", + ], + [ + "AAAABBBA", + "ABBBCCCA", + "ABCDDDCA", + "ABCEEECA", + "ABCEEECA", + "ABCDDDCA", + "ABBBCCCA", + "AAAABBBA", + ], + [ + "AABBCCDD", + "ABBCCDEE", + "ABCCDEEA", + "ACCDDEEA", + "ACCDDEEA", + "ABCCDEEA", + "ABBCCDEE", + "AABBCCDD", + ], + ], + link: { + stiffness: 0.82, + lengthScale: 1.05, + damping: 0.06, + lineWidth: 3, + rope: true, + renderType: "line", + maxLengthMultiplier: 3.8, + }, + }, + createBodies: (w, h) => { + const pad = 0.08; + const usableW = w * (1 - pad * 2); + const usableH = h * (1 - pad * 2); + const gridSize = Math.min(usableW, usableH); + const gridX = (w - gridSize) / 2; + const gridY = (h - gridSize) / 2; + const wallThickness = Math.max(18, gridSize * 0.045); + const innerW = gridSize; + const innerH = gridSize; + const cx = gridX + innerW / 2; + const cy = gridY + innerH / 2; + return [ + Bodies.rectangle( + cx, + gridY - wallThickness / 2, + innerW + wallThickness * 2, + wallThickness, + { + isStatic: true, + render: { fillStyle: "#0b1222", strokeStyle: "#0b1222" }, + }, + ), + Bodies.rectangle( + cx, + gridY + innerH + wallThickness / 2, + innerW + wallThickness * 2, + wallThickness, + { + isStatic: true, + render: { fillStyle: "#0b1222", strokeStyle: "#0b1222" }, + }, + ), + Bodies.rectangle( + gridX - wallThickness / 2, + cy, + wallThickness, + innerH + wallThickness * 2, + { + isStatic: true, + render: { fillStyle: "#0b1222", strokeStyle: "#0b1222" }, + }, + ), + Bodies.rectangle( + gridX + innerW + wallThickness / 2, + cy, + wallThickness, + innerH + wallThickness * 2, + { + isStatic: true, + render: { fillStyle: "#0b1222", strokeStyle: "#0b1222" }, + }, + ), + ]; + }, + }, { id: "scene1", - name: "Balanced (default)", + name: "Balanced", config: { gravity: 0.88, spawnIntervalMs: 520, @@ -266,6 +389,6 @@ window.PhysilinksScenes = { scenes, - defaultSceneId: scenes[0]?.id || "scene1", + defaultSceneId: scenes[0]?.id || "scene-grid", }; })(); diff --git a/styles.css b/styles.css index 5fb5c5b..58e9191 100644 --- a/styles.css +++ b/styles.css @@ -1,238 +1,314 @@ :root { - --bg: #0f172a; - --panel: #111827; - --accent: #22d3ee; - --text: #e2e8f0; - --muted: #94a3b8; + --bg: #0f172a; + --panel: #111827; + --accent: #22d3ee; + --text: #e2e8f0; + --muted: #94a3b8; +} +* { + box-sizing: border-box; } -* { box-sizing: border-box; } body { - margin: 0; - font-family: 'Manrope', system-ui, -apple-system, sans-serif; - background: radial-gradient(circle at 25% 20%, rgba(56,189,248,0.12), transparent 25%), - radial-gradient(circle at 80% 10%, rgba(167,139,250,0.16), transparent 30%), - radial-gradient(circle at 40% 80%, rgba(52,211,153,0.12), transparent 25%), - var(--bg); - color: var(--text); - min-height: 100vh; - display: flex; - align-items: center; - justify-content: center; - padding: 20px; + margin: 0; + font-family: + "Manrope", + system-ui, + -apple-system, + sans-serif; + background: + radial-gradient( + circle at 25% 20%, + rgba(56, 189, 248, 0.12), + transparent 25% + ), + radial-gradient( + circle at 80% 10%, + rgba(167, 139, 250, 0.16), + transparent 30% + ), + radial-gradient( + circle at 40% 80%, + rgba(52, 211, 153, 0.12), + transparent 25% + ), + var(--bg); + color: var(--text); + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; } .shell { - width: min(1100px, 100%); - background: rgba(17, 24, 39, 0.72); - border: 1px solid rgba(148, 163, 184, 0.1); - box-shadow: 0 20px 60px rgba(0,0,0,0.35); - border-radius: 18px; - overflow: hidden; - position: relative; - backdrop-filter: blur(10px); + width: min(1100px, 100%); + background: rgba(17, 24, 39, 0.72); + border: 1px solid rgba(148, 163, 184, 0.1); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35); + border-radius: 18px; + overflow: hidden; + position: relative; + backdrop-filter: blur(10px); } header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 14px 18px; - background: rgba(255, 255, 255, 0.02); - border-bottom: 1px solid rgba(148, 163, 184, 0.08); + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 18px; + background: rgba(255, 255, 255, 0.02); + border-bottom: 1px solid rgba(148, 163, 184, 0.08); } header h1 { - font-size: 20px; - margin: 0; - letter-spacing: 0.5px; + font-size: 20px; + margin: 0; + letter-spacing: 0.5px; } header .meta { - display: flex; - gap: 14px; - align-items: center; - font-size: 13px; - color: var(--muted); + display: flex; + gap: 14px; + align-items: center; + font-size: 13px; + color: var(--muted); } header .pill { - padding: 6px 10px; - border-radius: 8px; - background: rgba(34, 211, 238, 0.12); - color: #67e8f9; - border: 1px solid rgba(34, 211, 238, 0.35); - font-weight: 700; - font-size: 12px; - letter-spacing: 0.6px; - text-transform: uppercase; + padding: 6px 10px; + border-radius: 8px; + background: rgba(34, 211, 238, 0.12); + color: #67e8f9; + border: 1px solid rgba(34, 211, 238, 0.35); + font-weight: 700; + font-size: 12px; + letter-spacing: 0.6px; + text-transform: uppercase; } .pause-btn { - background: rgba(34, 211, 238, 0.14); - color: #67e8f9; - border: 1px solid rgba(34, 211, 238, 0.4); - border-radius: 10px; - padding: 8px 12px; - font-weight: 700; - cursor: pointer; - transition: transform 120ms ease, filter 120ms ease; + background: rgba(34, 211, 238, 0.14); + color: #67e8f9; + border: 1px solid rgba(34, 211, 238, 0.4); + border-radius: 10px; + padding: 8px 12px; + font-weight: 700; + cursor: pointer; + transition: + transform 120ms ease, + filter 120ms ease; } .pause-btn:hover { - filter: brightness(1.05); - transform: translateY(-1px); + filter: brightness(1.05); + transform: translateY(-1px); } .pause-btn:active { - transform: translateY(0); + transform: translateY(0); } #scene-wrapper { - position: relative; - width: 100%; - height: 680px; - background: radial-gradient(circle at 30% 30%, rgba(255,255,255,0.02), transparent 40%), - radial-gradient(circle at 75% 60%, rgba(255,255,255,0.02), transparent 45%), - #0b1222; + position: relative; + width: 100%; + height: 680px; + background: + radial-gradient( + circle at 30% 30%, + rgba(255, 255, 255, 0.02), + transparent 40% + ), + radial-gradient( + circle at 75% 60%, + rgba(255, 255, 255, 0.02), + transparent 45% + ), + #0b1222; } canvas { - display: block; - width: 100%; - height: 100%; + display: block; + width: 100%; + height: 100%; } .hud-bar { - display: flex; - gap: 10px; - flex-wrap: wrap; - padding: 12px 16px; - background: rgba(255, 255, 255, 0.03); - border-top: 1px solid rgba(148, 163, 184, 0.08); + display: flex; + gap: 10px; + flex-wrap: wrap; + padding: 12px 16px; + background: rgba(255, 255, 255, 0.03); + border-top: 1px solid rgba(148, 163, 184, 0.08); } .hud-bar .card { - background: rgba(255, 255, 255, 0.04); - padding: 10px 12px; - border: 1px solid rgba(148, 163, 184, 0.14); - border-radius: 10px; - font-size: 13px; - color: var(--muted); - display: flex; - align-items: center; - gap: 6px; + background: rgba(255, 255, 255, 0.04); + padding: 10px 12px; + border: 1px solid rgba(148, 163, 184, 0.14); + border-radius: 10px; + font-size: 13px; + color: var(--muted); + display: flex; + align-items: center; + gap: 6px; + min-height: 36px; } .hud-bar .card strong { - color: var(--text); - font-size: 13px; + color: var(--text); + font-size: 13px; +} +.progress-card { + flex: 1 1 200px; + max-width: 280px; + flex-direction: column; + align-items: flex-start; + gap: 6px; +} +.progress { + width: 100%; + height: 8px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + overflow: hidden; + border: 1px solid rgba(148, 163, 184, 0.16); +} +.progress__bar { + height: 100%; + width: 0%; + background: linear-gradient(135deg, #22d3ee, #a78bfa); + transition: width 150ms ease; } .selector { - background: rgba(0, 0, 0, 0.25); - color: var(--text); - border: 1px solid rgba(148, 163, 184, 0.3); - border-radius: 8px; - padding: 6px 8px; - font-size: 13px; + background: rgba(0, 0, 0, 0.25); + color: var(--text); + border: 1px solid rgba(148, 163, 184, 0.3); + border-radius: 8px; + padding: 6px 8px; + font-size: 13px; } .legend { - position: absolute; - top: 16px; - right: 16px; - background: rgba(255, 255, 255, 0.04); - padding: 10px 12px; - border: 1px solid rgba(148, 163, 184, 0.14); - border-radius: 10px; - display: flex; - gap: 8px; - flex-wrap: wrap; - align-items: center; - pointer-events: none; + position: absolute; + top: 16px; + right: 16px; + background: rgba(255, 255, 255, 0.04); + padding: 10px 12px; + border: 1px solid rgba(148, 163, 184, 0.14); + border-radius: 10px; + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; + pointer-events: none; } .legend span { - width: 18px; - height: 18px; - border-radius: 50%; - border: 1px solid rgba(255, 255, 255, 0.12); - display: inline-block; + width: 18px; + height: 18px; + border-radius: 50%; + border: 1px solid rgba(255, 255, 255, 0.12); + display: inline-block; } .pause-overlay { - position: absolute; - top: 12px; - left: 50%; - transform: translateX(-50%); - background: rgba(255, 255, 255, 0.08); - border: 1px solid rgba(148, 163, 184, 0.3); - color: #e2e8f0; - padding: 8px 14px; - border-radius: 12px; - font-weight: 800; - letter-spacing: 0.5px; - opacity: 0; - pointer-events: none; - transition: opacity 160ms ease; + position: absolute; + top: 12px; + left: 50%; + transform: translateX(-50%); + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(148, 163, 184, 0.3); + color: #e2e8f0; + padding: 8px 14px; + border-radius: 12px; + font-weight: 800; + letter-spacing: 0.5px; + opacity: 0; + pointer-events: none; + transition: opacity 160ms ease; } .pause-overlay.visible { - opacity: 1; + opacity: 1; } .game-over { - position: absolute; - inset: 0; - background: rgba(10, 13, 25, 0.72); - backdrop-filter: blur(8px); - display: flex; - align-items: center; - justify-content: center; - pointer-events: none; - opacity: 0; - transition: opacity 200ms ease; + position: absolute; + inset: 0; + background: rgba(10, 13, 25, 0.72); + backdrop-filter: blur(8px); + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + opacity: 0; + transition: opacity 200ms ease; } .game-over.visible { - pointer-events: auto; - opacity: 1; + pointer-events: auto; + opacity: 1; } .game-over__card { - background: rgba(255, 255, 255, 0.04); - border: 1px solid rgba(148, 163, 184, 0.18); - border-radius: 14px; - padding: 20px 24px; - text-align: center; - min-width: 240px; - box-shadow: 0 12px 35px rgba(0, 0, 0, 0.35); + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 14px; + padding: 20px 24px; + text-align: center; + min-width: 240px; + box-shadow: 0 12px 35px rgba(0, 0, 0, 0.35); } .game-over__card .title { - font-size: 22px; - font-weight: 700; - margin-bottom: 10px; + font-size: 22px; + font-weight: 700; + margin-bottom: 10px; } .game-over__card .score-line { - font-size: 14px; - color: var(--muted); - margin-bottom: 14px; + font-size: 14px; + color: var(--muted); + margin-bottom: 14px; } .game-over__card button { - background: linear-gradient(135deg, #22d3ee, #0ea5e9); - border: none; - color: #0b1222; - font-weight: 700; - padding: 10px 16px; - border-radius: 10px; - cursor: pointer; - font-size: 14px; + background: linear-gradient(135deg, #22d3ee, #0ea5e9); + border: none; + color: #0b1222; + font-weight: 700; + padding: 10px 16px; + border-radius: 10px; + cursor: pointer; + font-size: 14px; } .game-over__card button:hover { - filter: brightness(1.08); + filter: brightness(1.08); +} +.win-overlay .game-over__card { + min-width: 260px; +} +.win-actions { + display: flex; + gap: 10px; + justify-content: center; +} +.win-actions button:nth-child(2) { + background: linear-gradient(135deg, #a855f7, #22d3ee); } .floating-score { - position: absolute; - color: #e0f2fe; - font-weight: 800; - font-size: 18px; - pointer-events: none; - text-shadow: 0 8px 20px rgba(0, 0, 0, 0.35); - animation: floatUp 900ms ease-out forwards; + position: absolute; + color: #e0f2fe; + font-weight: 800; + font-size: 18px; + pointer-events: none; + text-shadow: 0 8px 20px rgba(0, 0, 0, 0.35); + animation: floatUp 900ms ease-out forwards; } @keyframes floatUp { - 0% { opacity: 1; transform: translate(-50%, 0); } - 60% { opacity: 0.9; transform: translate(-50%, -22px); } - 100% { opacity: 0; transform: translate(-50%, -38px); } + 0% { + opacity: 1; + transform: translate(-50%, 0); + } + 60% { + opacity: 0.9; + transform: translate(-50%, -22px); + } + 100% { + opacity: 0; + transform: translate(-50%, -38px); + } } .instructions { - padding: 14px 18px; - font-size: 14px; - color: var(--muted); - background: rgba(255, 255, 255, 0.03); - border-top: 1px solid rgba(148, 163, 184, 0.08); - line-height: 1.6; + padding: 14px 18px; + font-size: 14px; + color: var(--muted); + background: rgba(255, 255, 255, 0.03); + border-top: 1px solid rgba(148, 163, 184, 0.08); + line-height: 1.6; } @media (max-width: 800px) { - #scene-wrapper { height: 520px; } - header h1 { font-size: 18px; } + #scene-wrapper { + height: 520px; + } + header h1 { + font-size: 18px; + } } diff --git a/ui.js b/ui.js index 90bfcd2..c8dd594 100644 --- a/ui.js +++ b/ui.js @@ -14,11 +14,18 @@ const restartBtn = document.getElementById("restart-btn"); const pauseBtn = document.getElementById("pause-btn"); const pauseOverlay = document.getElementById("pause-overlay"); + const goalLabelEl = document.getElementById("goal-label"); + const goalProgressEl = document.getElementById("goal-progress"); + const winEl = document.getElementById("win-overlay"); + const winMessageEl = document.getElementById("win-message"); + const winNextBtn = document.getElementById("win-next"); + const winRestartBtn = document.getElementById("win-restart"); const handlers = { onPauseToggle: null, onRestart: null, onSceneChange: null, + onWinNext: null, }; if (pauseBtn) { @@ -39,6 +46,18 @@ }); } + if (winNextBtn) { + winNextBtn.addEventListener("click", () => { + if (handlers.onWinNext) handlers.onWinNext(); + }); + } + + if (winRestartBtn) { + winRestartBtn.addEventListener("click", () => { + if (handlers.onRestart) handlers.onRestart(); + }); + } + window.addEventListener("keydown", (e) => { if (e.key === "Escape" && handlers.onPauseToggle) { handlers.onPauseToggle(); @@ -78,6 +97,24 @@ } }; + const setGoal = ({ label, progress }) => { + if (goalLabelEl) goalLabelEl.textContent = label || "—"; + if (goalProgressEl) + goalProgressEl.style.width = `${Math.max( + 0, + Math.min(100, progress ?? 0), + )}%`; + }; + + const showWin = (message) => { + if (winMessageEl) winMessageEl.textContent = message || "You win!"; + if (winEl) winEl.classList.add("visible"); + }; + + const hideWin = () => { + if (winEl) winEl.classList.remove("visible"); + }; + const showGameOver = (score) => { if (finalScoreEl) finalScoreEl.textContent = score; if (gameOverEl) gameOverEl.classList.add("visible"); @@ -146,9 +183,12 @@ setPauseState, showGameOver, hideGameOver, + showWin, + hideWin, setSceneOptions, setSceneSelection, setHandlers, + setGoal, }; };