From 823746a5884befcc667aeb06bcb8eb36492024a6 Mon Sep 17 00:00:00 2001 From: Daddy32 Date: Mon, 15 Dec 2025 18:14:43 +0100 Subject: [PATCH] Extract goals module --- README.md | 5 +- index.html | 1 + src/goals.js | 158 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.js | 150 +++++------------------------------------------- 4 files changed, 177 insertions(+), 137 deletions(-) create mode 100644 src/goals.js diff --git a/README.md b/README.md index 69a79bb..1049764 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Physilinks is a browser-based physics linking game built with Matter.js. Match a - **Input**: Pointer/touch events mapped to scene coords; chain state tracks bodies and a dashed preview line to the pointer. Undo by dragging back to the previous node. - **Scoring**: `10 × length²` per cleared chain. Score popup rendered as DOM element near release point (via UI module). - **Persistence**: Per-scene high score stored in `localStorage` under `physilinks-highscore-`; loaded on scene change; HUD shows current scene's best. -- **Game loop**: Single Matter runner controlled in `main.js`, with spawning handled by `src/spawn.js`. Pause/game over stop the runner and spawner and zero `engine.timing.timeScale` so physics and rotating obstacles freeze; resume restarts the runner and spawner. +- **Game loop**: Single Matter runner controlled in `main.js`, with spawning handled by `src/spawn.js` and goal messaging handled by `src/goals.js`. Pause/game over stop the runner and spawner and zero `engine.timing.timeScale` so physics and rotating obstacles freeze; resume restarts the runner and spawner. - **Lose detection**: Spawned balls monitor entry; if they remain near the spawn zone with negligible velocity after a short delay, the run is over. ## File structure @@ -28,7 +28,8 @@ Physilinks is a browser-based physics linking game built with Matter.js. Match a - `src/decomp-setup.js`: Registers `poly-decomp` with Matter to allow concave shapes (stars, blobs) built via `Bodies.fromVertices`. - `src/ui.js`: DOM access, HUD updates, overlays, popups, and control/selector wiring. - `src/spawn.js`: Spawner utilities (intervals, batch/column/grid spawns), ball creation (shapes/blobs), radius scaling, and blob cleanup. -- `src/main.js`: Physics setup, state machine, chain interaction, scene application, and pause/restart logic; delegates spawn duties to `src/spawn.js`. +- `src/goals.js`: Goal computation and messaging (timer/score/clear/color goals, milestone announcements, intro message). +- `src/main.js`: Physics setup, state machine, chain interaction, scene application, and pause/restart logic; delegates spawn duties to `src/spawn.js` and goal handling to `src/goals.js`. ## Development quick start - No build step. Open `index.html` directly in the browser. diff --git a/index.html b/index.html index 7fa6c0b..7e6b777 100644 --- a/index.html +++ b/index.html @@ -108,6 +108,7 @@ + diff --git a/src/goals.js b/src/goals.js new file mode 100644 index 0000000..81152d3 --- /dev/null +++ b/src/goals.js @@ -0,0 +1,158 @@ +(() => { + const normalizeColor = (c) => (c || "").trim().toLowerCase(); + + const create = ({ + config, + getCurrentScene, + getScore, + getClearedCount, + getClearedByColor, + getTimerEndMs, + ui, + }) => { + const goalMilestoneThresholds = [0.5, 0.75, 0.9]; + let announcedGoalMilestones = new Set(); + + const getGoalState = () => { + const scene = getCurrentScene(); + const winCond = scene?.config?.winCondition; + if (!winCond) return null; + if (winCond.type === "timer") { + const duration = winCond.durationSec ?? 120; + const now = Date.now(); + const end = getTimerEndMs() || now + duration * 1000; + const remainingMs = Math.max(0, end - now); + const remainingSec = Math.ceil(remainingMs / 1000); + const elapsed = Math.max(0, duration - remainingSec); + return { + label: `${String(Math.floor(remainingSec / 60)).padStart(2, "0")}:${String(remainingSec % 60).padStart(2, "0")}`, + progress: duration > 0 ? (100 * elapsed) / duration : 0, + met: remainingMs <= 0, + hint: "Survive until the timer ends", + summary: `Survive for ${duration} seconds`, + }; + } + if (winCond.type === "clearCount") { + const target = winCond.target ?? 0; + const cleared = getClearedCount(); + const remaining = Math.max(0, target - cleared); + return { + label: `Clear ${target} balls (${remaining} left)`, + progress: target > 0 ? (100 * cleared) / target : 0, + met: cleared >= target, + hint: "Clear the required balls", + summary: `Clear ${target} balls`, + }; + } + if (winCond.type === "score") { + const target = winCond.target ?? 0; + const currentScore = getScore(); + const remaining = Math.max(0, target - currentScore); + return { + label: `Score ${target} (${remaining} left)`, + progress: target > 0 ? (100 * currentScore) / target : 0, + met: currentScore >= target, + hint: "Reach the score target", + summary: `Score ${target} points`, + }; + } + if (winCond.type === "colorClear" && Array.isArray(winCond.targets)) { + const clearedByColor = getClearedByColor(); + const targets = winCond.targets.map((t) => ({ + color: normalizeColor(t.color), + count: t.count || 0, + })); + const totalTarget = targets.reduce((sum, t) => sum + t.count, 0); + let totalAchieved = 0; + const parts = targets.map((t) => { + const achieved = Math.max(0, clearedByColor[normalizeColor(t.color)] || 0); + const got = Math.min(t.count, achieved); + totalAchieved += got; + const remaining = Math.max(0, t.count - got); + return `${got}/${t.count} (${remaining} left)`; + }); + return { + label: parts.join(" • "), + progress: totalTarget > 0 ? (100 * totalAchieved) / totalTarget : 0, + met: targets.every( + (t) => + (clearedByColor[normalizeColor(t.color)] || 0) >= (t.count || 0), + ), + colors: targets.map((t) => t.color), + hint: "Clear the target colors", + summary: `Clear target colors`, + }; + } + return null; + }; + + const formatGoalMessage = (goal, { includeProgress = true } = {}) => { + if (!goal) return null; + const title = goal.summary || goal.hint || goal.label || "Goal"; + const pieces = [`Goal: ${title}`]; + if (includeProgress && Number.isFinite(goal.progress)) { + const pct = Math.max(0, Math.min(100, Math.round(goal.progress))); + pieces.push(`${pct}% complete`); + } + return pieces.join(" • "); + }; + + const maybeAnnounceGoalProgress = (goal) => { + if (!goal || !Number.isFinite(goal.progress)) return; + const fraction = Math.max(0, Math.min(1, goal.progress / 100)); + for (const threshold of goalMilestoneThresholds) { + if (fraction >= threshold && !announcedGoalMilestones.has(threshold)) { + announcedGoalMilestones.add(threshold); + const text = config.messages?.text || formatGoalMessage(goal); + if (!text) return; + const colors = + (Array.isArray(config.messages?.colors) && config.messages.colors) || + goal?.colors || + null; + ui.showFloatingMessage( + { text, colors }, + { + durationMs: config.messages.durationMs, + position: config.messages.position, + }, + ); + break; + } + } + }; + + const showGoalIntro = () => { + const goal = getGoalState(); + const text = + config.messages?.text || + formatGoalMessage(goal, { includeProgress: false }); + if (!text) return; + const colors = + (Array.isArray(config.messages?.colors) && config.messages.colors) || + goal?.colors || + null; + ui.showFloatingMessage( + { text, colors }, + { + durationMs: config.messages.durationMs, + position: config.messages.position, + }, + ); + }; + + const resetMilestones = () => { + announcedGoalMilestones = new Set(); + }; + + return { + getGoalState, + formatGoalMessage, + maybeAnnounceGoalProgress, + showGoalIntro, + resetMilestones, + normalizeColor, + }; + }; + + window.PhysilinksGoals = { create }; +})(); diff --git a/src/main.js b/src/main.js index 778449f..8f41dc5 100644 --- a/src/main.js +++ b/src/main.js @@ -136,8 +136,6 @@ let score = 0; let highScore = 0; let longestChainRecord = 0; - const goalMilestoneThresholds = [0.5, 0.75, 0.9]; - let announcedGoalMilestones = new Set(); let clearedCount = 0; let clearedByColor = {}; let gameOver = false; @@ -284,6 +282,16 @@ isGameOver: () => gameOver, ballBaseline: BALL_BASELINE, }); + const goals = window.PhysilinksGoals.create({ + config, + getCurrentScene: () => currentScene, + getScore: () => score, + getClearedCount: () => clearedCount, + getClearedByColor: () => clearedByColor, + getTimerEndMs: () => timerEndMs, + ui, + }); + const normalizeColor = goals.normalizeColor; const restartGame = () => { spawnSystem.stopSpawner(); @@ -294,7 +302,7 @@ score = 0; clearedCount = 0; clearedByColor = {}; - announcedGoalMilestones.clear(); + goals.resetMilestones(); endDrag(); const winCond = currentScene?.config?.winCondition; if (winCond?.type === "timer") { @@ -332,7 +340,7 @@ } spawnSystem.startSpawner(); } - showGoalIntro(); + goals.showGoalIntro(); }; const setHighlight = (body, on) => { @@ -416,7 +424,7 @@ const checkWinCondition = () => { if (levelWon) return; - const goal = getGoalState(); + const goal = goals.getGoalState(); ui.setGoal(goal || { label: "—", progress: 0 }); if (!goal || !goal.met) return; applyWinEffects(); @@ -709,143 +717,15 @@ highScore, activeColor: chain.color, }); - const goal = getGoalState(); + const goal = goals.getGoalState(); ui.setGoal(goal || { label: "—", progress: 0 }); - maybeAnnounceGoalProgress(goal); + goals.maybeAnnounceGoalProgress(goal); }; const buildLegend = () => { ui.buildLegend(config.palette); }; - const normalizeColor = (c) => (c || "").trim().toLowerCase(); - - const getGoalState = () => { - const winCond = currentScene?.config?.winCondition; - if (!winCond) return null; - if (winCond.type === "timer") { - const duration = winCond.durationSec ?? 120; - const now = Date.now(); - const end = timerEndMs || now + duration * 1000; - const remainingMs = Math.max(0, end - now); - const remainingSec = Math.ceil(remainingMs / 1000); - const elapsed = Math.max(0, duration - remainingSec); - return { - label: `${String(Math.floor(remainingSec / 60)).padStart(2, "0")}:${String(remainingSec % 60).padStart(2, "0")}`, - progress: duration > 0 ? (100 * elapsed) / duration : 0, - met: remainingMs <= 0, - hint: "Survive until the timer ends", - summary: `Survive for ${duration} seconds`, - }; - } - 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, - hint: "Clear the required balls", - summary: `Clear ${target} balls`, - }; - } - 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, - hint: "Reach the score target", - summary: `Score ${target} points`, - }; - } - if (winCond.type === "colorClear" && Array.isArray(winCond.targets)) { - const targets = winCond.targets.map((t) => ({ - color: normalizeColor(t.color), - count: t.count || 0, - })); - const totalTarget = targets.reduce((sum, t) => sum + t.count, 0); - let totalAchieved = 0; - const parts = targets.map((t) => { - const achieved = Math.max( - 0, - clearedByColor[normalizeColor(t.color)] || 0, - ); - const got = Math.min(t.count, achieved); - totalAchieved += got; - const remaining = Math.max(0, t.count - got); - return `${got}/${t.count} (${remaining} left)`; - }); - return { - label: parts.join(" • "), - progress: totalTarget > 0 ? (100 * totalAchieved) / totalTarget : 0, - met: targets.every( - (t) => - (clearedByColor[normalizeColor(t.color)] || 0) >= (t.count || 0), - ), - colors: targets.map((t) => t.color), - hint: "Clear the target colors", - summary: `Clear target colors`, - }; - } - return null; - }; - - const formatGoalMessage = (goal, { includeProgress = true } = {}) => { - if (!goal) return null; - const title = goal.summary || goal.hint || goal.label || "Goal"; - const pieces = [`Goal: ${title}`]; - if (includeProgress && Number.isFinite(goal.progress)) { - const pct = Math.max(0, Math.min(100, Math.round(goal.progress))); - pieces.push(`${pct}% complete`); - } - return pieces.join(" • "); - }; - - const maybeAnnounceGoalProgress = (goal) => { - if (!goal || !Number.isFinite(goal.progress)) return; - const fraction = Math.max(0, Math.min(1, goal.progress / 100)); - for (const threshold of goalMilestoneThresholds) { - if (fraction >= threshold && !announcedGoalMilestones.has(threshold)) { - announcedGoalMilestones.add(threshold); - const text = config.messages?.text || formatGoalMessage(goal); - if (!text) return; - const colors = - (Array.isArray(config.messages?.colors) && config.messages.colors) || - goal?.colors || - null; - ui.showFloatingMessage( - { text, colors }, - { - durationMs: config.messages.durationMs, - position: config.messages.position, - }, - ); - break; - } - } - }; - - const showGoalIntro = () => { - const goal = getGoalState(); - const text = - config.messages?.text || - formatGoalMessage(goal, { includeProgress: false }); - if (!text) return; - const colors = - (Array.isArray(config.messages?.colors) && config.messages.colors) || - goal?.colors || - null; - ui.showFloatingMessage( - { text, colors }, - { - durationMs: config.messages.durationMs, - position: config.messages.position, - }, - ); - }; - const clampBodiesIntoView = (prevWidth, prevHeight) => { const scaleX = width / (prevWidth || width); const scaleY = height / (prevHeight || height);