Extract goals module

This commit is contained in:
Daddy32
2025-12-15 18:14:43 +01:00
parent e76b86f0a2
commit 823746a588
4 changed files with 177 additions and 137 deletions

View File

@@ -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-<sceneId>`; 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.

View File

@@ -108,6 +108,7 @@
<script src="./src/ui.js"></script>
<script src="./src/storage.js"></script>
<script src="./src/spawn.js"></script>
<script src="./src/goals.js"></script>
<script src="./src/main.js"></script>
</body>
</html>

158
src/goals.js Normal file
View File

@@ -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 };
})();

View File

@@ -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);