Centralize mutable game state

This commit is contained in:
Daddy32
2025-12-15 21:25:30 +01:00
parent 6410a98f5d
commit edf1f8d2bc

View File

@@ -77,16 +77,34 @@
const ui = createUI(); const ui = createUI();
const { sceneEl } = ui; const { sceneEl } = ui;
let width = sceneEl.clientWidth; const state = {
let height = sceneEl.clientHeight; width: sceneEl.clientWidth,
height: sceneEl.clientHeight,
boundaries: [],
rotators: [],
oscillators: [],
balls: [],
blobConstraints: new Map(),
score: 0,
highScore: 0,
longestChainRecord: 0,
clearedCount: 0,
clearedByColor: {},
gameOver: false,
paused: false,
levelWon: false,
timerEndMs: null,
lastTimerDisplay: null,
};
const BALL_BASELINE = 680; // reference height used for relative ball sizing const BALL_BASELINE = 680; // reference height used for relative ball sizing
const createEngine = getFactory("PhysilinksEngine"); const createEngine = getFactory("PhysilinksEngine");
const { engine, render, runner, startRunner, stopRunner, setRenderSize } = const { engine, render, runner, startRunner, stopRunner, setRenderSize } =
createEngine({ createEngine({
sceneEl, sceneEl,
width, width: state.width,
height, height: state.height,
background: "transparent", background: "transparent",
wireframes: false, wireframes: false,
showAngleIndicator: false, showAngleIndicator: false,
@@ -96,10 +114,6 @@
engine.gravity.y = config.gravity; engine.gravity.y = config.gravity;
const world = engine.world; const world = engine.world;
// Static boundaries and scene-specific obstacles.
let boundaries = [];
let rotators = [];
let oscillators = [];
const initialSceneId = const initialSceneId =
(getSceneIdFromUrl && getSceneIdFromUrl(scenes)) || (getSceneIdFromUrl && getSceneIdFromUrl(scenes)) ||
(getSceneById && getSceneById(scenes, defaultSceneId) (getSceneById && getSceneById(scenes, defaultSceneId)
@@ -114,27 +128,18 @@
} }
const rebuildSceneBodies = () => { const rebuildSceneBodies = () => {
boundaries.forEach((b) => World.remove(world, b)); state.boundaries.forEach((b) => World.remove(world, b));
const nextBoundaries = currentScene.createBodies(width, height); const nextBoundaries = currentScene.createBodies(state.width, state.height);
boundaries.length = 0; state.boundaries.length = 0;
boundaries.push(...nextBoundaries); state.boundaries.push(...nextBoundaries);
rotators = boundaries.filter((b) => b.plugin && b.plugin.rotSpeed); state.rotators = state.boundaries.filter(
oscillators = boundaries.filter((b) => b.plugin && b.plugin.oscillate); (b) => b.plugin && b.plugin.rotSpeed,
World.add(world, boundaries); );
state.oscillators = state.boundaries.filter(
(b) => b.plugin && b.plugin.oscillate,
);
World.add(world, state.boundaries);
}; };
const balls = [];
const blobConstraints = new Map();
let score = 0;
let highScore = 0;
let longestChainRecord = 0;
let clearedCount = 0;
let clearedByColor = {};
let gameOver = false;
let isPaused = false;
let levelWon = false;
let timerEndMs = null;
let lastTimerDisplay = null;
let spawnSystem = null; let spawnSystem = null;
let input = null; let input = null;
@@ -184,11 +189,11 @@
setConfigForScene(next.config); setConfigForScene(next.config);
ui.setMessageDefaults(config.messages); ui.setMessageDefaults(config.messages);
resetEngineForScene(next.config, { prevRadius }); resetEngineForScene(next.config, { prevRadius });
clearedCount = 0; state.clearedCount = 0;
levelWon = false; state.levelWon = false;
clearedByColor = {}; state.clearedByColor = {};
highScore = loadHighScore(next.id); state.highScore = loadHighScore(next.id);
longestChainRecord = loadLongestChain(next.id); state.longestChainRecord = loadLongestChain(next.id);
rebuildSceneBodies(); rebuildSceneBodies();
buildLegend(); buildLegend();
restartGame(); restartGame();
@@ -217,68 +222,68 @@
const triggerGameOver = () => { const triggerGameOver = () => {
if (currentScene?.config?.noGameOver) return; if (currentScene?.config?.noGameOver) return;
if (gameOver) return; if (state.gameOver) return;
gameOver = true; state.gameOver = true;
isPaused = false; state.paused = false;
resetChainVisuals(); resetChainVisuals();
spawnSystem?.stopSpawner(); spawnSystem?.stopSpawner();
stopRunner(); stopRunner();
engine.timing.timeScale = 0; engine.timing.timeScale = 0;
ui.setPauseState(false); ui.setPauseState(false);
ui.showGameOver(score); ui.showGameOver(state.score);
}; };
const createSpawn = getFactory("PhysilinksSpawn"); const createSpawn = getFactory("PhysilinksSpawn");
spawnSystem = createSpawn({ spawnSystem = createSpawn({
config, config,
world, world,
balls, balls: state.balls,
blobConstraints, blobConstraints: state.blobConstraints,
getCurrentScene: () => currentScene, getCurrentScene: () => currentScene,
getDimensions: () => ({ width, height }), getDimensions: () => ({ width: state.width, height: state.height }),
isGridScene, isGridScene,
triggerGameOver, triggerGameOver,
isGameOver: () => gameOver, isGameOver: () => state.gameOver,
ballBaseline: BALL_BASELINE, ballBaseline: BALL_BASELINE,
}); });
const createGoals = getFactory("PhysilinksGoals"); const createGoals = getFactory("PhysilinksGoals");
const goals = createGoals({ const goals = createGoals({
config, config,
getCurrentScene: () => currentScene, getCurrentScene: () => currentScene,
getScore: () => score, getScore: () => state.score,
getClearedCount: () => clearedCount, getClearedCount: () => state.clearedCount,
getClearedByColor: () => clearedByColor, getClearedByColor: () => state.clearedByColor,
getTimerEndMs: () => timerEndMs, getTimerEndMs: () => state.timerEndMs,
ui, ui,
}); });
const normalizeColor = goals.normalizeColor; const normalizeColor = goals.normalizeColor;
const restartGame = () => { const restartGame = () => {
spawnSystem.stopSpawner(); spawnSystem.stopSpawner();
gameOver = false; state.gameOver = false;
isPaused = false; state.paused = false;
levelWon = false; state.levelWon = false;
spawnSystem.resetSpawnState(); spawnSystem.resetSpawnState();
score = 0; state.score = 0;
clearedCount = 0; state.clearedCount = 0;
clearedByColor = {}; state.clearedByColor = {};
goals.resetMilestones(); goals.resetMilestones();
input?.endDrag(); input?.endDrag();
const winCond = currentScene?.config?.winCondition; const winCond = currentScene?.config?.winCondition;
if (winCond?.type === "timer") { if (winCond?.type === "timer") {
const duration = winCond.durationSec ?? 120; const duration = winCond.durationSec ?? 120;
timerEndMs = Date.now() + duration * 1000; state.timerEndMs = Date.now() + duration * 1000;
lastTimerDisplay = null; state.lastTimerDisplay = null;
} else { } else {
timerEndMs = null; state.timerEndMs = null;
lastTimerDisplay = null; state.lastTimerDisplay = null;
} }
resetChainVisuals(); resetChainVisuals();
balls.forEach((ball) => { state.balls.forEach((ball) => {
spawnSystem.cleanupBall(ball); spawnSystem.cleanupBall(ball);
World.remove(world, ball); World.remove(world, ball);
}); });
balls.length = 0; state.balls.length = 0;
ui.hideGameOver(); ui.hideGameOver();
ui.hideWin(); ui.hideWin();
ui.setPauseState(false); ui.setPauseState(false);
@@ -305,12 +310,12 @@
body.render.strokeStyle = on ? "#f8fafc" : "#0b1222"; body.render.strokeStyle = on ? "#f8fafc" : "#0b1222";
}; };
const setPaused = (state) => { const setPaused = (nextState) => {
if (gameOver || levelWon) return; if (state.gameOver || state.levelWon) return;
if (state === isPaused) return; if (nextState === state.paused) return;
isPaused = state; state.paused = nextState;
ui.setPauseState(isPaused); ui.setPauseState(state.paused);
if (isPaused) { if (state.paused) {
resetChainVisuals(); resetChainVisuals();
spawnSystem.stopSpawner(); spawnSystem.stopSpawner();
stopRunner(); stopRunner();
@@ -345,7 +350,7 @@
engine.gravity.y = winCond.onWin.setGravity; engine.gravity.y = winCond.onWin.setGravity;
} }
if (winCond.onWin.shoveBalls) { if (winCond.onWin.shoveBalls) {
balls.forEach((ball) => { state.balls.forEach((ball) => {
const angle = Math.random() * Math.PI * 2; const angle = Math.random() * Math.PI * 2;
const magnitude = 12 + Math.random() * 10; const magnitude = 12 + Math.random() * 10;
const force = { const force = {
@@ -356,7 +361,7 @@
}); });
} }
if (winCond.onWin.swirlBalls) { if (winCond.onWin.swirlBalls) {
balls.forEach((ball) => { state.balls.forEach((ball) => {
const angle = Math.random() * Math.PI * 2; const angle = Math.random() * Math.PI * 2;
const mag = 0.06; const mag = 0.06;
Body.applyForce(ball, ball.position, { Body.applyForce(ball, ball.position, {
@@ -368,27 +373,31 @@
} }
if (winCond.onWin.removeCurves) { if (winCond.onWin.removeCurves) {
const remaining = []; const remaining = [];
boundaries.forEach((b) => { state.boundaries.forEach((b) => {
if (b.plugin && b.plugin.curve) { if (b.plugin && b.plugin.curve) {
World.remove(world, b); World.remove(world, b);
} else { } else {
remaining.push(b); remaining.push(b);
} }
}); });
boundaries.length = 0; state.boundaries.length = 0;
boundaries.push(...remaining); state.boundaries.push(...remaining);
rotators = rotators.filter((b) => boundaries.includes(b)); state.rotators = state.rotators.filter((b) =>
oscillators = oscillators.filter((b) => boundaries.includes(b)); state.boundaries.includes(b),
);
state.oscillators = state.oscillators.filter((b) =>
state.boundaries.includes(b),
);
} }
}; };
const checkWinCondition = () => { const checkWinCondition = () => {
if (levelWon) return; if (state.levelWon) return;
const goal = goals.getGoalState(); const goal = goals.getGoalState();
ui.setGoal(goal || { label: "—", progress: 0 }); ui.setGoal(goal || { label: "—", progress: 0 });
if (!goal || !goal.met) return; if (!goal || !goal.met) return;
applyWinEffects(); applyWinEffects();
levelWon = true; state.levelWon = true;
spawnSystem.stopSpawner(); spawnSystem.stopSpawner();
engine.timing.timeScale = 1; engine.timing.timeScale = 1;
startRunner(); startRunner();
@@ -436,9 +445,9 @@
}; };
const updateLongestChain = (chainLength) => { const updateLongestChain = (chainLength) => {
if (chainLength <= longestChainRecord) return; if (chainLength <= state.longestChainRecord) return;
longestChainRecord = chainLength; state.longestChainRecord = chainLength;
saveLongestChain(currentScene.id, longestChainRecord); saveLongestChain(currentScene.id, state.longestChainRecord);
console.log( console.log(
"New longest chain record", "New longest chain record",
chainLength, chainLength,
@@ -482,29 +491,30 @@
} else { } else {
if (body.plugin?.color) { if (body.plugin?.color) {
const key = normalizeColor(body.plugin.color); const key = normalizeColor(body.plugin.color);
clearedByColor[key] = (clearedByColor[key] || 0) + 1; state.clearedByColor[key] = (state.clearedByColor[key] || 0) + 1;
} }
spawnSystem.cleanupBall(body); spawnSystem.cleanupBall(body);
World.remove(world, body); World.remove(world, body);
} }
}); });
blobIds.forEach((id) => { blobIds.forEach((id) => {
balls state.balls
.filter((b) => b.plugin?.blobId === id) .filter((b) => b.plugin?.blobId === id)
.forEach((b) => { .forEach((b) => {
if (b.plugin?.color) { if (b.plugin?.color) {
const key = normalizeColor(b.plugin.color); const key = normalizeColor(b.plugin.color);
clearedByColor[key] = (clearedByColor[key] || 0) + 1; state.clearedByColor[key] = (state.clearedByColor[key] || 0) + 1;
} }
}); });
spawnSystem.removeBlob(id); spawnSystem.removeBlob(id);
}); });
for (let i = balls.length - 1; i >= 0; i -= 1) { for (let i = state.balls.length - 1; i >= 0; i -= 1) {
if ( if (
chain.bodies.includes(balls[i]) || chain.bodies.includes(state.balls[i]) ||
(balls[i].plugin?.blobId && blobIds.has(balls[i].plugin?.blobId)) (state.balls[i].plugin?.blobId &&
blobIds.has(state.balls[i].plugin?.blobId))
) { ) {
balls.splice(i, 1); state.balls.splice(i, 1);
} }
} }
}; };
@@ -516,8 +526,8 @@
} }
winCond.targets.forEach((target) => { winCond.targets.forEach((target) => {
const key = normalizeColor(target.color); const key = normalizeColor(target.color);
const current = clearedByColor[key] || 0; const current = state.clearedByColor[key] || 0;
clearedByColor[key] = Math.max(0, current - chainLength); state.clearedByColor[key] = Math.max(0, current - chainLength);
}); });
}; };
@@ -532,16 +542,16 @@
}; };
const finishChain = (releasePoint) => { const finishChain = (releasePoint) => {
if (!chain.active || gameOver || isPaused) return; if (!chain.active || state.gameOver || state.paused) return;
const chainLength = chain.bodies.length; const chainLength = chain.bodies.length;
if (chainLength >= config.minChain) { if (chainLength >= config.minChain) {
updateLongestChain(chainLength); updateLongestChain(chainLength);
const { gain, isNegativeProgress } = getChainScoreState(); const { gain, isNegativeProgress } = getChainScoreState();
score += gain; state.score += gain;
clearedCount += chainLength; state.clearedCount += chainLength;
if (score > highScore) { if (state.score > state.highScore) {
highScore = score; state.highScore = state.score;
saveHighScore(currentScene.id, highScore); saveHighScore(currentScene.id, state.highScore);
} }
ui.spawnScorePopup(releasePoint || chain.pointer, gain, chain.color); ui.spawnScorePopup(releasePoint || chain.pointer, gain, chain.color);
removeChainConstraints(); removeChainConstraints();
@@ -561,8 +571,8 @@
spawnIntervalMs: config.spawnIntervalMs, spawnIntervalMs: config.spawnIntervalMs,
minChain: config.minChain, minChain: config.minChain,
chainLength: chain.bodies.length, chainLength: chain.bodies.length,
score, score: state.score,
highScore, highScore: state.highScore,
activeColor: chain.color, activeColor: chain.color,
}); });
const goal = goals.getGoalState(); const goal = goals.getGoalState();
@@ -574,14 +584,14 @@
input = createInput({ input = createInput({
render, render,
world, world,
balls, balls: state.balls,
boundaries, boundaries: state.boundaries,
chain, chain,
config, config,
getCurrentScene: () => currentScene, getCurrentScene: () => currentScene,
isPaused: () => isPaused, isPaused: () => state.paused,
isLevelWon: () => levelWon, isLevelWon: () => state.levelWon,
isGameOver: () => gameOver, isGameOver: () => state.gameOver,
getMaxLinkDistance, getMaxLinkDistance,
setHighlight, setHighlight,
removeLastFromChain, removeLastFromChain,
@@ -595,17 +605,17 @@
}; };
const clampBodiesIntoView = (prevWidth, prevHeight) => { const clampBodiesIntoView = (prevWidth, prevHeight) => {
const scaleX = width / (prevWidth || width); const scaleX = state.width / (prevWidth || state.width);
const scaleY = height / (prevHeight || height); const scaleY = state.height / (prevHeight || state.height);
const margin = config.ballRadius * 1.2; const margin = config.ballRadius * 1.2;
balls.forEach((ball) => { state.balls.forEach((ball) => {
const nextX = Math.min( const nextX = Math.min(
Math.max(ball.position.x * scaleX, margin), Math.max(ball.position.x * scaleX, margin),
Math.max(margin, width - margin), Math.max(margin, state.width - margin),
); );
const nextY = Math.min( const nextY = Math.min(
ball.position.y * scaleY, ball.position.y * scaleY,
Math.max(height - margin, margin), Math.max(state.height - margin, margin),
); );
Body.setPosition(ball, { x: nextX, y: nextY }); Body.setPosition(ball, { x: nextX, y: nextY });
Body.setVelocity(ball, { x: 0, y: 0 }); Body.setVelocity(ball, { x: 0, y: 0 });
@@ -615,12 +625,12 @@
}; };
const handleResize = () => { const handleResize = () => {
const prevWidth = width; const prevWidth = state.width;
const prevHeight = height; const prevHeight = state.height;
const prevRadius = config.ballRadius; const prevRadius = config.ballRadius;
width = sceneEl.clientWidth; state.width = sceneEl.clientWidth;
height = sceneEl.clientHeight; state.height = sceneEl.clientHeight;
setRenderSize(width, height); setRenderSize(state.width, state.height);
spawnSystem.updateBallRadius(prevRadius); spawnSystem.updateBallRadius(prevRadius);
clampBodiesIntoView(prevWidth, prevHeight); clampBodiesIntoView(prevWidth, prevHeight);
rebuildSceneBodies(); rebuildSceneBodies();
@@ -628,7 +638,7 @@
Events.on(engine, "afterUpdate", () => { Events.on(engine, "afterUpdate", () => {
// Keep stray balls within the play area horizontally. // Keep stray balls within the play area horizontally.
balls.forEach((ball) => { state.balls.forEach((ball) => {
if ( if (
!ball.plugin.hasEntered && !ball.plugin.hasEntered &&
ball.position.y > config.ballRadius * 1.5 ball.position.y > config.ballRadius * 1.5
@@ -637,10 +647,10 @@
} }
if ( if (
ball.position.x < -100 || ball.position.x < -100 ||
ball.position.x > width + 100 || ball.position.x > state.width + 100 ||
(currentScene?.config?.spawnFrom === "bottom" (currentScene?.config?.spawnFrom === "bottom"
? ball.position.y < -500 ? ball.position.y < -500
: ball.position.y > height + 500) : ball.position.y > state.height + 500)
) { ) {
spawnSystem.cleanupBall(ball); spawnSystem.cleanupBall(ball);
ball.plugin.hasEntered = true; ball.plugin.hasEntered = true;
@@ -648,13 +658,13 @@
Matter.Body.setPosition(ball, { Matter.Body.setPosition(ball, {
x: x:
currentScene?.config?.spawnOrigin === "center" currentScene?.config?.spawnOrigin === "center"
? width / 2 ? state.width / 2
: Math.random() * width, : Math.random() * state.width,
y: y:
currentScene?.config?.spawnOrigin === "center" currentScene?.config?.spawnOrigin === "center"
? height / 2 ? state.height / 2
: spawnFromBottom : spawnFromBottom
? height + 40 ? state.height + 40
: -40, : -40,
}); });
Matter.Body.setVelocity(ball, { x: 0, y: 0 }); Matter.Body.setVelocity(ball, { x: 0, y: 0 });
@@ -665,10 +675,14 @@
Events.on(engine, "beforeUpdate", () => { Events.on(engine, "beforeUpdate", () => {
// Rope-like constraint handling: allow shortening without push-back, tension when stretched. // Rope-like constraint handling: allow shortening without push-back, tension when stretched.
if ( if (
!levelWon && !state.levelWon &&
typeof currentScene?.config?.onBeforeUpdate === "function" typeof currentScene?.config?.onBeforeUpdate === "function"
) { ) {
currentScene.config.onBeforeUpdate({ engine, width, height }); currentScene.config.onBeforeUpdate({
engine,
width: state.width,
height: state.height,
});
} }
chain.constraints.forEach((c) => { chain.constraints.forEach((c) => {
if (!c.plugin || !c.plugin.rope) return; if (!c.plugin || !c.plugin.rope) return;
@@ -687,14 +701,14 @@
// Rotate any scene rotators slowly. // Rotate any scene rotators slowly.
const dt = (engine.timing && engine.timing.delta) || 16; const dt = (engine.timing && engine.timing.delta) || 16;
const timeScale = engine.timing?.timeScale ?? 1; const timeScale = engine.timing?.timeScale ?? 1;
if (isPaused || gameOver || timeScale === 0) return; if (state.paused || state.gameOver || timeScale === 0) return;
rotators.forEach((b) => { state.rotators.forEach((b) => {
const speed = b.plugin.rotSpeed || 0; const speed = b.plugin.rotSpeed || 0;
if (speed !== 0) { if (speed !== 0) {
Body.rotate(b, speed * ((dt * timeScale) / 1000)); Body.rotate(b, speed * ((dt * timeScale) / 1000));
} }
}); });
oscillators.forEach((b) => { state.oscillators.forEach((b) => {
const osc = b.plugin.oscillate; const osc = b.plugin.oscillate;
if (!osc) return; if (!osc) return;
if (!osc.base) { if (!osc.base) {
@@ -712,17 +726,17 @@
Body.setPosition(b, target); Body.setPosition(b, target);
Body.setVelocity(b, { x: 0, y: 0 }); Body.setVelocity(b, { x: 0, y: 0 });
}); });
if (timerEndMs) { if (state.timerEndMs) {
const winCond = currentScene?.config?.winCondition; const winCond = currentScene?.config?.winCondition;
const duration = winCond?.durationSec ?? 120; const duration = winCond?.durationSec ?? 120;
const now = Date.now(); const now = Date.now();
const remainingMs = Math.max(0, timerEndMs - now); const remainingMs = Math.max(0, state.timerEndMs - now);
const remainingSec = Math.ceil(remainingMs / 1000); const remainingSec = Math.ceil(remainingMs / 1000);
if (lastTimerDisplay !== remainingSec) { if (state.lastTimerDisplay !== remainingSec) {
lastTimerDisplay = remainingSec; state.lastTimerDisplay = remainingSec;
updateHud(); updateHud();
} }
if (remainingMs <= 0 && !levelWon) { if (remainingMs <= 0 && !state.levelWon) {
checkWinCondition(); checkWinCondition();
} }
} }
@@ -761,7 +775,7 @@
window.addEventListener("resize", handleResize); window.addEventListener("resize", handleResize);
ui.setHandlers({ ui.setHandlers({
onPauseToggle: () => setPaused(!isPaused), onPauseToggle: () => setPaused(!state.paused),
onRestart: restartGame, onRestart: restartGame,
onSceneChange: (id) => applyScene(id), onSceneChange: (id) => applyScene(id),
onWinNext: () => onWinNext: () =>