@@ -56,6 +66,13 @@
High score 0
+
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,
};
};