Centralize mutable game state
This commit is contained in:
264
src/main.js
264
src/main.js
@@ -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: () =>
|
||||
|
||||
Reference in New Issue
Block a user