diff --git a/src/main.js b/src/main.js index dc55494..abae119 100644 --- a/src/main.js +++ b/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: () =>