Extract goals module
This commit is contained in:
@@ -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.
|
- **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).
|
- **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.
|
- **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.
|
- **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
|
## 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/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/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/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
|
## Development quick start
|
||||||
- No build step. Open `index.html` directly in the browser.
|
- No build step. Open `index.html` directly in the browser.
|
||||||
|
|||||||
@@ -108,6 +108,7 @@
|
|||||||
<script src="./src/ui.js"></script>
|
<script src="./src/ui.js"></script>
|
||||||
<script src="./src/storage.js"></script>
|
<script src="./src/storage.js"></script>
|
||||||
<script src="./src/spawn.js"></script>
|
<script src="./src/spawn.js"></script>
|
||||||
|
<script src="./src/goals.js"></script>
|
||||||
<script src="./src/main.js"></script>
|
<script src="./src/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
158
src/goals.js
Normal file
158
src/goals.js
Normal 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 };
|
||||||
|
})();
|
||||||
150
src/main.js
150
src/main.js
@@ -136,8 +136,6 @@
|
|||||||
let score = 0;
|
let score = 0;
|
||||||
let highScore = 0;
|
let highScore = 0;
|
||||||
let longestChainRecord = 0;
|
let longestChainRecord = 0;
|
||||||
const goalMilestoneThresholds = [0.5, 0.75, 0.9];
|
|
||||||
let announcedGoalMilestones = new Set();
|
|
||||||
let clearedCount = 0;
|
let clearedCount = 0;
|
||||||
let clearedByColor = {};
|
let clearedByColor = {};
|
||||||
let gameOver = false;
|
let gameOver = false;
|
||||||
@@ -284,6 +282,16 @@
|
|||||||
isGameOver: () => gameOver,
|
isGameOver: () => gameOver,
|
||||||
ballBaseline: BALL_BASELINE,
|
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 = () => {
|
const restartGame = () => {
|
||||||
spawnSystem.stopSpawner();
|
spawnSystem.stopSpawner();
|
||||||
@@ -294,7 +302,7 @@
|
|||||||
score = 0;
|
score = 0;
|
||||||
clearedCount = 0;
|
clearedCount = 0;
|
||||||
clearedByColor = {};
|
clearedByColor = {};
|
||||||
announcedGoalMilestones.clear();
|
goals.resetMilestones();
|
||||||
endDrag();
|
endDrag();
|
||||||
const winCond = currentScene?.config?.winCondition;
|
const winCond = currentScene?.config?.winCondition;
|
||||||
if (winCond?.type === "timer") {
|
if (winCond?.type === "timer") {
|
||||||
@@ -332,7 +340,7 @@
|
|||||||
}
|
}
|
||||||
spawnSystem.startSpawner();
|
spawnSystem.startSpawner();
|
||||||
}
|
}
|
||||||
showGoalIntro();
|
goals.showGoalIntro();
|
||||||
};
|
};
|
||||||
|
|
||||||
const setHighlight = (body, on) => {
|
const setHighlight = (body, on) => {
|
||||||
@@ -416,7 +424,7 @@
|
|||||||
|
|
||||||
const checkWinCondition = () => {
|
const checkWinCondition = () => {
|
||||||
if (levelWon) return;
|
if (levelWon) return;
|
||||||
const goal = getGoalState();
|
const goal = goals.getGoalState();
|
||||||
ui.setGoal(goal || { label: "—", progress: 0 });
|
ui.setGoal(goal || { label: "—", progress: 0 });
|
||||||
if (!goal || !goal.met) return;
|
if (!goal || !goal.met) return;
|
||||||
applyWinEffects();
|
applyWinEffects();
|
||||||
@@ -709,143 +717,15 @@
|
|||||||
highScore,
|
highScore,
|
||||||
activeColor: chain.color,
|
activeColor: chain.color,
|
||||||
});
|
});
|
||||||
const goal = getGoalState();
|
const goal = goals.getGoalState();
|
||||||
ui.setGoal(goal || { label: "—", progress: 0 });
|
ui.setGoal(goal || { label: "—", progress: 0 });
|
||||||
maybeAnnounceGoalProgress(goal);
|
goals.maybeAnnounceGoalProgress(goal);
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildLegend = () => {
|
const buildLegend = () => {
|
||||||
ui.buildLegend(config.palette);
|
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 clampBodiesIntoView = (prevWidth, prevHeight) => {
|
||||||
const scaleX = width / (prevWidth || width);
|
const scaleX = width / (prevWidth || width);
|
||||||
const scaleY = height / (prevHeight || height);
|
const scaleY = height / (prevHeight || height);
|
||||||
|
|||||||
Reference in New Issue
Block a user