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

160
main.js
View File

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