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