Add win condition UI and zero-G grid level

This commit is contained in:
Daddy32
2025-12-13 13:47:19 +01:00
parent dd702e0a2c
commit 4282dbdd07
5 changed files with 590 additions and 182 deletions

View File

@@ -34,6 +34,16 @@
<button id="restart-btn">Restart</button> <button id="restart-btn">Restart</button>
</div> </div>
</div> </div>
<div class="game-over win-overlay" id="win-overlay">
<div class="game-over__card">
<div class="title">Level complete!</div>
<div class="score-line" id="win-message"></div>
<div class="win-actions">
<button id="win-restart">Restart</button>
<button id="win-next">Next scene</button>
</div>
</div>
</div>
</div> </div>
<div class="hud-bar"> <div class="hud-bar">
<div class="card"> <div class="card">
@@ -56,6 +66,13 @@
<div class="card"> <div class="card">
<strong>High score</strong> <span id="high-score">0</span> <strong>High score</strong> <span id="high-score">0</span>
</div> </div>
<div class="card progress-card">
<strong>Goal</strong>
<span id="goal-label"></span>
<div class="progress">
<div class="progress__bar" id="goal-progress"></div>
</div>
</div>
<div class="card"> <div class="card">
<strong>Scene</strong> <strong>Scene</strong>
<select id="scene-select" class="selector"></select> <select id="scene-select" class="selector"></select>

158
main.js
View File

@@ -17,6 +17,7 @@
const config = { const config = {
gravity: 1, gravity: 1,
spawnIntervalMs: 520, spawnIntervalMs: 520,
autoSpawn: true,
minChain: 3, minChain: 3,
palette: ["#ff595e", "#ffca3a", "#8ac926", "#1982c4", "#6a4c93"], palette: ["#ff595e", "#ffca3a", "#8ac926", "#1982c4", "#6a4c93"],
ballRadius: 18, ballRadius: 18,
@@ -96,8 +97,10 @@
let spawnTimer = null; let spawnTimer = null;
let score = 0; let score = 0;
let highScore = 0; let highScore = 0;
let clearedCount = 0;
let gameOver = false; let gameOver = false;
let isPaused = false; let isPaused = false;
let levelWon = false;
const makeStorageKey = (sceneId) => `physilinks-highscore-${sceneId}`; const makeStorageKey = (sceneId) => `physilinks-highscore-${sceneId}`;
@@ -128,6 +131,8 @@
config.link = { ...next.config.link }; config.link = { ...next.config.link };
updateBallRadius(prevRadius); updateBallRadius(prevRadius);
engine.gravity.y = config.gravity; engine.gravity.y = config.gravity;
clearedCount = 0;
levelWon = false;
highScore = loadHighScore(next.id); highScore = loadHighScore(next.id);
rebuildSceneBodies(); rebuildSceneBodies();
buildLegend(); buildLegend();
@@ -135,6 +140,14 @@
updateHud(); 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 = { const chain = {
active: false, active: false,
color: null, color: null,
@@ -152,6 +165,9 @@
return mult * config.ballRadius; return mult * config.ballRadius;
}; };
const isGridScene = () =>
currentScene && currentScene.config && currentScene.config.gridLayouts;
const spawnBall = () => { const spawnBall = () => {
if (gameOver) return; if (gameOver) return;
const color = const color =
@@ -184,6 +200,8 @@
}; };
const startSpawner = () => { const startSpawner = () => {
if (isGridScene()) return;
if (currentScene?.config?.autoSpawn === false) return;
if (spawnTimer) clearInterval(spawnTimer); if (spawnTimer) clearInterval(spawnTimer);
spawnTimer = setInterval(spawnBall, config.spawnIntervalMs); 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 = () => { const triggerGameOver = () => {
if (gameOver) return; if (gameOver) return;
gameOver = true; gameOver = true;
@@ -215,9 +287,12 @@
}; };
const restartGame = () => { const restartGame = () => {
stopSpawner();
gameOver = false; gameOver = false;
isPaused = false; isPaused = false;
levelWon = false;
score = 0; score = 0;
clearedCount = 0;
resetChainVisuals(); resetChainVisuals();
balls.forEach((ball) => { balls.forEach((ball) => {
cleanupBall(ball); cleanupBall(ball);
@@ -225,11 +300,16 @@
}); });
balls.length = 0; balls.length = 0;
ui.hideGameOver(); ui.hideGameOver();
ui.hideWin();
ui.setPauseState(false); ui.setPauseState(false);
engine.timing.timeScale = 1; engine.timing.timeScale = 1;
startRunner(); startRunner();
updateHud(); updateHud();
if (isGridScene()) {
spawnGridBalls();
} else {
startSpawner(); startSpawner();
}
}; };
const setHighlight = (body, on) => { const setHighlight = (body, on) => {
@@ -238,7 +318,7 @@
}; };
const setPaused = (state) => { const setPaused = (state) => {
if (gameOver) return; if (gameOver || levelWon) return;
if (state === isPaused) return; if (state === isPaused) return;
isPaused = state; isPaused = state;
ui.setPauseState(isPaused); ui.setPauseState(isPaused);
@@ -265,6 +345,28 @@
updateHud(); 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 removeLastFromChain = () => {
const removedConstraint = chain.constraints.pop(); const removedConstraint = chain.constraints.pop();
if (removedConstraint) { if (removedConstraint) {
@@ -309,6 +411,7 @@
if (chain.bodies.length >= config.minChain) { if (chain.bodies.length >= config.minChain) {
const gain = 10 * Math.pow(chain.bodies.length, 2); const gain = 10 * Math.pow(chain.bodies.length, 2);
score += gain; score += gain;
clearedCount += chain.bodies.length;
if (score > highScore) { if (score > highScore) {
highScore = score; highScore = score;
saveHighScore(); saveHighScore();
@@ -335,6 +438,7 @@
chain.constraints = []; chain.constraints = [];
chain.pointer = null; chain.pointer = null;
updateHud(); updateHud();
checkWinCondition();
}; };
const pickBody = (point) => { const pickBody = (point) => {
@@ -353,7 +457,7 @@
}; };
const handlePointerDown = (evt) => { const handlePointerDown = (evt) => {
if (gameOver || isPaused) return; if (gameOver || isPaused || levelWon) return;
const point = getPointerPosition(evt); const point = getPointerPosition(evt);
const body = pickBody(point); const body = pickBody(point);
if (!body) return; if (!body) return;
@@ -368,7 +472,7 @@
const handlePointerMove = (evt) => { const handlePointerMove = (evt) => {
if (!chain.active) return; if (!chain.active) return;
if (gameOver || isPaused) return; if (gameOver || isPaused || levelWon) return;
const point = getPointerPosition(evt); const point = getPointerPosition(evt);
chain.pointer = point; chain.pointer = point;
const body = pickBody(point); const body = pickBody(point);
@@ -429,6 +533,8 @@
highScore, highScore,
activeColor: chain.color, activeColor: chain.color,
}); });
const goal = getGoalState();
ui.setGoal(goal || { label: "—", progress: 0 });
}; };
const buildLegend = () => { const buildLegend = () => {
@@ -436,6 +542,16 @@
}; };
const computeBallRadius = () => { 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 = const baseRadius =
(currentScene && currentScene.config && currentScene.config.ballRadius) || (currentScene && currentScene.config && currentScene.config.ballRadius) ||
config.ballRadius || config.ballRadius ||
@@ -461,6 +577,41 @@
config.ballRadius = nextRadius; 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 clampBodiesIntoView = (prevWidth, prevHeight) => {
const scaleX = width / (prevWidth || width); const scaleX = width / (prevWidth || width);
const scaleY = height / (prevHeight || height); const scaleY = height / (prevHeight || height);
@@ -591,6 +742,7 @@
onPauseToggle: () => setPaused(!isPaused), onPauseToggle: () => setPaused(!isPaused),
onRestart: restartGame, onRestart: restartGame,
onSceneChange: (id) => applyScene(id), onSceneChange: (id) => applyScene(id),
onWinNext: () => applyScene(getNextSceneId()),
}); });
ui.setSceneOptions( ui.setSceneOptions(
scenes, scenes,

127
scenes.js
View File

@@ -2,9 +2,132 @@
const { Bodies } = Matter; const { Bodies } = Matter;
const scenes = [ 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", id: "scene1",
name: "Balanced (default)", name: "Balanced",
config: { config: {
gravity: 0.88, gravity: 0.88,
spawnIntervalMs: 520, spawnIntervalMs: 520,
@@ -266,6 +389,6 @@
window.PhysilinksScenes = { window.PhysilinksScenes = {
scenes, scenes,
defaultSceneId: scenes[0]?.id || "scene1", defaultSceneId: scenes[0]?.id || "scene-grid",
}; };
})(); })();

View File

@@ -5,13 +5,32 @@
--text: #e2e8f0; --text: #e2e8f0;
--muted: #94a3b8; --muted: #94a3b8;
} }
* { box-sizing: border-box; } * {
box-sizing: border-box;
}
body { body {
margin: 0; margin: 0;
font-family: 'Manrope', system-ui, -apple-system, sans-serif; font-family:
background: radial-gradient(circle at 25% 20%, rgba(56,189,248,0.12), transparent 25%), "Manrope",
radial-gradient(circle at 80% 10%, rgba(167,139,250,0.16), transparent 30%), system-ui,
radial-gradient(circle at 40% 80%, rgba(52,211,153,0.12), transparent 25%), -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); var(--bg);
color: var(--text); color: var(--text);
min-height: 100vh; min-height: 100vh;
@@ -69,7 +88,9 @@ header .pill {
padding: 8px 12px; padding: 8px 12px;
font-weight: 700; font-weight: 700;
cursor: pointer; cursor: pointer;
transition: transform 120ms ease, filter 120ms ease; transition:
transform 120ms ease,
filter 120ms ease;
} }
.pause-btn:hover { .pause-btn:hover {
filter: brightness(1.05); filter: brightness(1.05);
@@ -82,8 +103,17 @@ header .pill {
position: relative; position: relative;
width: 100%; width: 100%;
height: 680px; height: 680px;
background: radial-gradient(circle at 30% 30%, rgba(255,255,255,0.02), transparent 40%), background:
radial-gradient(circle at 75% 60%, rgba(255,255,255,0.02), transparent 45%), 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; #0b1222;
} }
canvas { canvas {
@@ -109,11 +139,33 @@ canvas {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
min-height: 36px;
} }
.hud-bar .card strong { .hud-bar .card strong {
color: var(--text); color: var(--text);
font-size: 13px; 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 { .selector {
background: rgba(0, 0, 0, 0.25); background: rgba(0, 0, 0, 0.25);
color: var(--text); color: var(--text);
@@ -210,6 +262,17 @@ canvas {
.game-over__card button:hover { .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 { .floating-score {
position: absolute; position: absolute;
color: #e0f2fe; color: #e0f2fe;
@@ -220,9 +283,18 @@ canvas {
animation: floatUp 900ms ease-out forwards; animation: floatUp 900ms ease-out forwards;
} }
@keyframes floatUp { @keyframes floatUp {
0% { opacity: 1; transform: translate(-50%, 0); } 0% {
60% { opacity: 0.9; transform: translate(-50%, -22px); } opacity: 1;
100% { opacity: 0; transform: translate(-50%, -38px); } transform: translate(-50%, 0);
}
60% {
opacity: 0.9;
transform: translate(-50%, -22px);
}
100% {
opacity: 0;
transform: translate(-50%, -38px);
}
} }
.instructions { .instructions {
padding: 14px 18px; padding: 14px 18px;
@@ -233,6 +305,10 @@ canvas {
line-height: 1.6; line-height: 1.6;
} }
@media (max-width: 800px) { @media (max-width: 800px) {
#scene-wrapper { height: 520px; } #scene-wrapper {
header h1 { font-size: 18px; } height: 520px;
}
header h1 {
font-size: 18px;
}
} }

40
ui.js
View File

@@ -14,11 +14,18 @@
const restartBtn = document.getElementById("restart-btn"); const restartBtn = document.getElementById("restart-btn");
const pauseBtn = document.getElementById("pause-btn"); const pauseBtn = document.getElementById("pause-btn");
const pauseOverlay = document.getElementById("pause-overlay"); 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 = { const handlers = {
onPauseToggle: null, onPauseToggle: null,
onRestart: null, onRestart: null,
onSceneChange: null, onSceneChange: null,
onWinNext: null,
}; };
if (pauseBtn) { 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) => { window.addEventListener("keydown", (e) => {
if (e.key === "Escape" && handlers.onPauseToggle) { if (e.key === "Escape" && handlers.onPauseToggle) {
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) => { const showGameOver = (score) => {
if (finalScoreEl) finalScoreEl.textContent = score; if (finalScoreEl) finalScoreEl.textContent = score;
if (gameOverEl) gameOverEl.classList.add("visible"); if (gameOverEl) gameOverEl.classList.add("visible");
@@ -146,9 +183,12 @@
setPauseState, setPauseState,
showGameOver, showGameOver,
hideGameOver, hideGameOver,
showWin,
hideWin,
setSceneOptions, setSceneOptions,
setSceneSelection, setSceneSelection,
setHandlers, setHandlers,
setGoal,
}; };
}; };