Add win condition UI and zero-G grid level
This commit is contained in:
17
index.html
17
index.html
@@ -34,6 +34,16 @@
|
|||||||
<button id="restart-btn">Restart</button>
|
<button id="restart-btn">Restart</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="game-over win-overlay" id="win-overlay">
|
||||||
|
<div class="game-over__card">
|
||||||
|
<div class="title">Level complete!</div>
|
||||||
|
<div class="score-line" id="win-message"></div>
|
||||||
|
<div class="win-actions">
|
||||||
|
<button id="win-restart">Restart</button>
|
||||||
|
<button id="win-next">Next scene</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hud-bar">
|
<div class="hud-bar">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -56,6 +66,13 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<strong>High score</strong> <span id="high-score">0</span>
|
<strong>High score</strong> <span id="high-score">0</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card progress-card">
|
||||||
|
<strong>Goal</strong>
|
||||||
|
<span id="goal-label">—</span>
|
||||||
|
<div class="progress">
|
||||||
|
<div class="progress__bar" id="goal-progress"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<strong>Scene</strong>
|
<strong>Scene</strong>
|
||||||
<select id="scene-select" class="selector"></select>
|
<select id="scene-select" class="selector"></select>
|
||||||
|
|||||||
160
main.js
160
main.js
@@ -17,6 +17,7 @@
|
|||||||
const config = {
|
const config = {
|
||||||
gravity: 1,
|
gravity: 1,
|
||||||
spawnIntervalMs: 520,
|
spawnIntervalMs: 520,
|
||||||
|
autoSpawn: true,
|
||||||
minChain: 3,
|
minChain: 3,
|
||||||
palette: ["#ff595e", "#ffca3a", "#8ac926", "#1982c4", "#6a4c93"],
|
palette: ["#ff595e", "#ffca3a", "#8ac926", "#1982c4", "#6a4c93"],
|
||||||
ballRadius: 18,
|
ballRadius: 18,
|
||||||
@@ -96,8 +97,10 @@
|
|||||||
let spawnTimer = null;
|
let spawnTimer = null;
|
||||||
let score = 0;
|
let score = 0;
|
||||||
let highScore = 0;
|
let highScore = 0;
|
||||||
|
let clearedCount = 0;
|
||||||
let gameOver = false;
|
let gameOver = false;
|
||||||
let isPaused = false;
|
let isPaused = false;
|
||||||
|
let levelWon = false;
|
||||||
|
|
||||||
const makeStorageKey = (sceneId) => `physilinks-highscore-${sceneId}`;
|
const makeStorageKey = (sceneId) => `physilinks-highscore-${sceneId}`;
|
||||||
|
|
||||||
@@ -128,6 +131,8 @@
|
|||||||
config.link = { ...next.config.link };
|
config.link = { ...next.config.link };
|
||||||
updateBallRadius(prevRadius);
|
updateBallRadius(prevRadius);
|
||||||
engine.gravity.y = config.gravity;
|
engine.gravity.y = config.gravity;
|
||||||
|
clearedCount = 0;
|
||||||
|
levelWon = false;
|
||||||
highScore = loadHighScore(next.id);
|
highScore = loadHighScore(next.id);
|
||||||
rebuildSceneBodies();
|
rebuildSceneBodies();
|
||||||
buildLegend();
|
buildLegend();
|
||||||
@@ -135,6 +140,14 @@
|
|||||||
updateHud();
|
updateHud();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getNextSceneId = () => {
|
||||||
|
const winCond = currentScene?.config?.winCondition;
|
||||||
|
if (winCond?.nextSceneId) return winCond.nextSceneId;
|
||||||
|
const idx = scenes.findIndex((s) => s.id === currentScene?.id);
|
||||||
|
if (idx >= 0 && idx < scenes.length - 1) return scenes[idx + 1].id;
|
||||||
|
return scenes[0]?.id || defaultSceneId;
|
||||||
|
};
|
||||||
|
|
||||||
const chain = {
|
const chain = {
|
||||||
active: false,
|
active: false,
|
||||||
color: null,
|
color: null,
|
||||||
@@ -152,6 +165,9 @@
|
|||||||
return mult * config.ballRadius;
|
return mult * config.ballRadius;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isGridScene = () =>
|
||||||
|
currentScene && currentScene.config && currentScene.config.gridLayouts;
|
||||||
|
|
||||||
const spawnBall = () => {
|
const spawnBall = () => {
|
||||||
if (gameOver) return;
|
if (gameOver) return;
|
||||||
const color =
|
const color =
|
||||||
@@ -184,6 +200,8 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const startSpawner = () => {
|
const startSpawner = () => {
|
||||||
|
if (isGridScene()) return;
|
||||||
|
if (currentScene?.config?.autoSpawn === false) return;
|
||||||
if (spawnTimer) clearInterval(spawnTimer);
|
if (spawnTimer) clearInterval(spawnTimer);
|
||||||
spawnTimer = setInterval(spawnBall, config.spawnIntervalMs);
|
spawnTimer = setInterval(spawnBall, config.spawnIntervalMs);
|
||||||
};
|
};
|
||||||
@@ -202,6 +220,60 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getGridDimensions = () => {
|
||||||
|
const padding = currentScene?.config?.gridPadding ?? 0.08;
|
||||||
|
const usableW = width * (1 - padding * 2);
|
||||||
|
const usableH = height * (1 - padding * 2);
|
||||||
|
const gridSize = Math.min(usableW, usableH);
|
||||||
|
const cellSize = gridSize / 8;
|
||||||
|
const startX = (width - gridSize) / 2 + cellSize / 2;
|
||||||
|
const startY = (height - gridSize) / 2 + cellSize / 2;
|
||||||
|
return { cellSize, startX, startY };
|
||||||
|
};
|
||||||
|
|
||||||
|
const getGridColor = (letter) => {
|
||||||
|
if (!letter || letter === "." || letter === " ") return null;
|
||||||
|
const legend = currentScene?.config?.gridLegend || {};
|
||||||
|
if (legend[letter]) return legend[letter];
|
||||||
|
const paletteToUse = currentScene?.config?.palette || config.palette;
|
||||||
|
const index =
|
||||||
|
(letter.toUpperCase().charCodeAt(0) - 65) % paletteToUse.length;
|
||||||
|
return paletteToUse[index];
|
||||||
|
};
|
||||||
|
|
||||||
|
const spawnGridBalls = () => {
|
||||||
|
const layouts = currentScene?.config?.gridLayouts || [];
|
||||||
|
if (!layouts.length) return;
|
||||||
|
const layout = layouts[Math.floor(Math.random() * layouts.length)];
|
||||||
|
if (!Array.isArray(layout)) return;
|
||||||
|
const { cellSize, startX, startY } = getGridDimensions();
|
||||||
|
const radius = computeBallRadius();
|
||||||
|
config.ballRadius = radius;
|
||||||
|
layout.forEach((row, rowIdx) => {
|
||||||
|
if (typeof row !== "string") return;
|
||||||
|
for (let colIdx = 0; colIdx < 8; colIdx += 1) {
|
||||||
|
const letter = row[colIdx] || ".";
|
||||||
|
const color = getGridColor(letter);
|
||||||
|
if (!color) continue;
|
||||||
|
const x = startX + colIdx * cellSize;
|
||||||
|
const y = startY + rowIdx * cellSize;
|
||||||
|
const ball = Bodies.circle(x, y, radius, {
|
||||||
|
restitution: 0.12,
|
||||||
|
friction: 0.01,
|
||||||
|
frictionAir: 0.01,
|
||||||
|
render: {
|
||||||
|
fillStyle: color,
|
||||||
|
strokeStyle: "#0b1222",
|
||||||
|
lineWidth: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ball.plugin = { color, hasEntered: true, entryCheckId: null };
|
||||||
|
balls.push(ball);
|
||||||
|
World.add(world, ball);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const triggerGameOver = () => {
|
const triggerGameOver = () => {
|
||||||
if (gameOver) return;
|
if (gameOver) return;
|
||||||
gameOver = true;
|
gameOver = true;
|
||||||
@@ -215,9 +287,12 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const restartGame = () => {
|
const restartGame = () => {
|
||||||
|
stopSpawner();
|
||||||
gameOver = false;
|
gameOver = false;
|
||||||
isPaused = false;
|
isPaused = false;
|
||||||
|
levelWon = false;
|
||||||
score = 0;
|
score = 0;
|
||||||
|
clearedCount = 0;
|
||||||
resetChainVisuals();
|
resetChainVisuals();
|
||||||
balls.forEach((ball) => {
|
balls.forEach((ball) => {
|
||||||
cleanupBall(ball);
|
cleanupBall(ball);
|
||||||
@@ -225,11 +300,16 @@
|
|||||||
});
|
});
|
||||||
balls.length = 0;
|
balls.length = 0;
|
||||||
ui.hideGameOver();
|
ui.hideGameOver();
|
||||||
|
ui.hideWin();
|
||||||
ui.setPauseState(false);
|
ui.setPauseState(false);
|
||||||
engine.timing.timeScale = 1;
|
engine.timing.timeScale = 1;
|
||||||
startRunner();
|
startRunner();
|
||||||
updateHud();
|
updateHud();
|
||||||
startSpawner();
|
if (isGridScene()) {
|
||||||
|
spawnGridBalls();
|
||||||
|
} else {
|
||||||
|
startSpawner();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const setHighlight = (body, on) => {
|
const setHighlight = (body, on) => {
|
||||||
@@ -238,7 +318,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const setPaused = (state) => {
|
const setPaused = (state) => {
|
||||||
if (gameOver) return;
|
if (gameOver || levelWon) return;
|
||||||
if (state === isPaused) return;
|
if (state === isPaused) return;
|
||||||
isPaused = state;
|
isPaused = state;
|
||||||
ui.setPauseState(isPaused);
|
ui.setPauseState(isPaused);
|
||||||
@@ -265,6 +345,28 @@
|
|||||||
updateHud();
|
updateHud();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const applyWinEffects = () => {
|
||||||
|
const winCond = currentScene?.config?.winCondition;
|
||||||
|
if (!winCond || !winCond.onWin) return;
|
||||||
|
if (typeof winCond.onWin.setGravity === "number") {
|
||||||
|
engine.gravity.y = winCond.onWin.setGravity;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkWinCondition = () => {
|
||||||
|
if (levelWon) return;
|
||||||
|
const goal = getGoalState();
|
||||||
|
ui.setGoal(goal || { label: "—", progress: 0 });
|
||||||
|
if (!goal || !goal.met) return;
|
||||||
|
applyWinEffects();
|
||||||
|
levelWon = true;
|
||||||
|
stopSpawner();
|
||||||
|
engine.timing.timeScale = 1;
|
||||||
|
startRunner();
|
||||||
|
ui.setPauseState(false);
|
||||||
|
ui.showWin(goal.label.replace("left", "done"));
|
||||||
|
};
|
||||||
|
|
||||||
const removeLastFromChain = () => {
|
const removeLastFromChain = () => {
|
||||||
const removedConstraint = chain.constraints.pop();
|
const removedConstraint = chain.constraints.pop();
|
||||||
if (removedConstraint) {
|
if (removedConstraint) {
|
||||||
@@ -309,6 +411,7 @@
|
|||||||
if (chain.bodies.length >= config.minChain) {
|
if (chain.bodies.length >= config.minChain) {
|
||||||
const gain = 10 * Math.pow(chain.bodies.length, 2);
|
const gain = 10 * Math.pow(chain.bodies.length, 2);
|
||||||
score += gain;
|
score += gain;
|
||||||
|
clearedCount += chain.bodies.length;
|
||||||
if (score > highScore) {
|
if (score > highScore) {
|
||||||
highScore = score;
|
highScore = score;
|
||||||
saveHighScore();
|
saveHighScore();
|
||||||
@@ -335,6 +438,7 @@
|
|||||||
chain.constraints = [];
|
chain.constraints = [];
|
||||||
chain.pointer = null;
|
chain.pointer = null;
|
||||||
updateHud();
|
updateHud();
|
||||||
|
checkWinCondition();
|
||||||
};
|
};
|
||||||
|
|
||||||
const pickBody = (point) => {
|
const pickBody = (point) => {
|
||||||
@@ -353,7 +457,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePointerDown = (evt) => {
|
const handlePointerDown = (evt) => {
|
||||||
if (gameOver || isPaused) return;
|
if (gameOver || isPaused || levelWon) return;
|
||||||
const point = getPointerPosition(evt);
|
const point = getPointerPosition(evt);
|
||||||
const body = pickBody(point);
|
const body = pickBody(point);
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
@@ -368,7 +472,7 @@
|
|||||||
|
|
||||||
const handlePointerMove = (evt) => {
|
const handlePointerMove = (evt) => {
|
||||||
if (!chain.active) return;
|
if (!chain.active) return;
|
||||||
if (gameOver || isPaused) return;
|
if (gameOver || isPaused || levelWon) return;
|
||||||
const point = getPointerPosition(evt);
|
const point = getPointerPosition(evt);
|
||||||
chain.pointer = point;
|
chain.pointer = point;
|
||||||
const body = pickBody(point);
|
const body = pickBody(point);
|
||||||
@@ -429,6 +533,8 @@
|
|||||||
highScore,
|
highScore,
|
||||||
activeColor: chain.color,
|
activeColor: chain.color,
|
||||||
});
|
});
|
||||||
|
const goal = getGoalState();
|
||||||
|
ui.setGoal(goal || { label: "—", progress: 0 });
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildLegend = () => {
|
const buildLegend = () => {
|
||||||
@@ -436,6 +542,16 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const computeBallRadius = () => {
|
const computeBallRadius = () => {
|
||||||
|
if (isGridScene()) {
|
||||||
|
const padding = currentScene?.config?.gridPadding ?? 0.08;
|
||||||
|
const usableW = width * (1 - padding * 2);
|
||||||
|
const usableH = height * (1 - padding * 2);
|
||||||
|
const gridSize = Math.min(usableW, usableH);
|
||||||
|
const cellSize = gridSize / 8;
|
||||||
|
const scale = currentScene?.config?.gridBallScale ?? 0.36;
|
||||||
|
const scaled = cellSize * scale;
|
||||||
|
return Math.round(Math.max(10, Math.min(60, scaled)));
|
||||||
|
}
|
||||||
const baseRadius =
|
const baseRadius =
|
||||||
(currentScene && currentScene.config && currentScene.config.ballRadius) ||
|
(currentScene && currentScene.config && currentScene.config.ballRadius) ||
|
||||||
config.ballRadius ||
|
config.ballRadius ||
|
||||||
@@ -461,6 +577,41 @@
|
|||||||
config.ballRadius = nextRadius;
|
config.ballRadius = nextRadius;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getGoalState = () => {
|
||||||
|
const winCond = currentScene?.config?.winCondition;
|
||||||
|
if (!winCond) return null;
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (winCond.type === "colorClear" && Array.isArray(winCond.targets)) {
|
||||||
|
const target = winCond.targets.reduce(
|
||||||
|
(sum, t) => sum + (t.count || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
label: "Clear target colors",
|
||||||
|
progress: target > 0 ? (100 * clearedCount) / target : 0,
|
||||||
|
met: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
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);
|
||||||
@@ -591,6 +742,7 @@
|
|||||||
onPauseToggle: () => setPaused(!isPaused),
|
onPauseToggle: () => setPaused(!isPaused),
|
||||||
onRestart: restartGame,
|
onRestart: restartGame,
|
||||||
onSceneChange: (id) => applyScene(id),
|
onSceneChange: (id) => applyScene(id),
|
||||||
|
onWinNext: () => applyScene(getNextSceneId()),
|
||||||
});
|
});
|
||||||
ui.setSceneOptions(
|
ui.setSceneOptions(
|
||||||
scenes,
|
scenes,
|
||||||
|
|||||||
127
scenes.js
127
scenes.js
@@ -2,9 +2,132 @@
|
|||||||
const { Bodies } = Matter;
|
const { Bodies } = Matter;
|
||||||
|
|
||||||
const scenes = [
|
const scenes = [
|
||||||
|
{
|
||||||
|
id: "scene-grid",
|
||||||
|
name: "Zero-G Grid (default)",
|
||||||
|
config: {
|
||||||
|
gravity: 0,
|
||||||
|
spawnIntervalMs: 0,
|
||||||
|
autoSpawn: false,
|
||||||
|
minChain: 3,
|
||||||
|
palette: ["#38bdf8", "#f472b6", "#facc15", "#34d399", "#a78bfa"],
|
||||||
|
ballRadius: 24,
|
||||||
|
gridPadding: 0.08, // percent of viewport padding applied to both axes
|
||||||
|
gridBallScale: 0.38, // percent of cell size used as radius
|
||||||
|
gridLegend: {
|
||||||
|
A: "#38bdf8",
|
||||||
|
B: "#f472b6",
|
||||||
|
C: "#facc15",
|
||||||
|
D: "#34d399",
|
||||||
|
E: "#a78bfa",
|
||||||
|
},
|
||||||
|
winCondition: {
|
||||||
|
type: "clearCount",
|
||||||
|
target: 35,
|
||||||
|
nextSceneId: "scene1",
|
||||||
|
onWin: { setGravity: 0.88 },
|
||||||
|
},
|
||||||
|
gridLayouts: [
|
||||||
|
[
|
||||||
|
"AABBBBAA",
|
||||||
|
"ACCCBCCA",
|
||||||
|
"ACDDDDCA",
|
||||||
|
"ACEEEECA",
|
||||||
|
"ACEEEECA",
|
||||||
|
"ACDDDDCA",
|
||||||
|
"ACCCBCCA",
|
||||||
|
"AABBBBAA",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"AAAABBBA",
|
||||||
|
"ABBBCCCA",
|
||||||
|
"ABCDDDCA",
|
||||||
|
"ABCEEECA",
|
||||||
|
"ABCEEECA",
|
||||||
|
"ABCDDDCA",
|
||||||
|
"ABBBCCCA",
|
||||||
|
"AAAABBBA",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"AABBCCDD",
|
||||||
|
"ABBCCDEE",
|
||||||
|
"ABCCDEEA",
|
||||||
|
"ACCDDEEA",
|
||||||
|
"ACCDDEEA",
|
||||||
|
"ABCCDEEA",
|
||||||
|
"ABBCCDEE",
|
||||||
|
"AABBCCDD",
|
||||||
|
],
|
||||||
|
],
|
||||||
|
link: {
|
||||||
|
stiffness: 0.82,
|
||||||
|
lengthScale: 1.05,
|
||||||
|
damping: 0.06,
|
||||||
|
lineWidth: 3,
|
||||||
|
rope: true,
|
||||||
|
renderType: "line",
|
||||||
|
maxLengthMultiplier: 3.8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createBodies: (w, h) => {
|
||||||
|
const pad = 0.08;
|
||||||
|
const usableW = w * (1 - pad * 2);
|
||||||
|
const usableH = h * (1 - pad * 2);
|
||||||
|
const gridSize = Math.min(usableW, usableH);
|
||||||
|
const gridX = (w - gridSize) / 2;
|
||||||
|
const gridY = (h - gridSize) / 2;
|
||||||
|
const wallThickness = Math.max(18, gridSize * 0.045);
|
||||||
|
const innerW = gridSize;
|
||||||
|
const innerH = gridSize;
|
||||||
|
const cx = gridX + innerW / 2;
|
||||||
|
const cy = gridY + innerH / 2;
|
||||||
|
return [
|
||||||
|
Bodies.rectangle(
|
||||||
|
cx,
|
||||||
|
gridY - wallThickness / 2,
|
||||||
|
innerW + wallThickness * 2,
|
||||||
|
wallThickness,
|
||||||
|
{
|
||||||
|
isStatic: true,
|
||||||
|
render: { fillStyle: "#0b1222", strokeStyle: "#0b1222" },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Bodies.rectangle(
|
||||||
|
cx,
|
||||||
|
gridY + innerH + wallThickness / 2,
|
||||||
|
innerW + wallThickness * 2,
|
||||||
|
wallThickness,
|
||||||
|
{
|
||||||
|
isStatic: true,
|
||||||
|
render: { fillStyle: "#0b1222", strokeStyle: "#0b1222" },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Bodies.rectangle(
|
||||||
|
gridX - wallThickness / 2,
|
||||||
|
cy,
|
||||||
|
wallThickness,
|
||||||
|
innerH + wallThickness * 2,
|
||||||
|
{
|
||||||
|
isStatic: true,
|
||||||
|
render: { fillStyle: "#0b1222", strokeStyle: "#0b1222" },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Bodies.rectangle(
|
||||||
|
gridX + innerW + wallThickness / 2,
|
||||||
|
cy,
|
||||||
|
wallThickness,
|
||||||
|
innerH + wallThickness * 2,
|
||||||
|
{
|
||||||
|
isStatic: true,
|
||||||
|
render: { fillStyle: "#0b1222", strokeStyle: "#0b1222" },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "scene1",
|
id: "scene1",
|
||||||
name: "Balanced (default)",
|
name: "Balanced",
|
||||||
config: {
|
config: {
|
||||||
gravity: 0.88,
|
gravity: 0.88,
|
||||||
spawnIntervalMs: 520,
|
spawnIntervalMs: 520,
|
||||||
@@ -266,6 +389,6 @@
|
|||||||
|
|
||||||
window.PhysilinksScenes = {
|
window.PhysilinksScenes = {
|
||||||
scenes,
|
scenes,
|
||||||
defaultSceneId: scenes[0]?.id || "scene1",
|
defaultSceneId: scenes[0]?.id || "scene-grid",
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
428
styles.css
428
styles.css
@@ -1,238 +1,314 @@
|
|||||||
:root {
|
:root {
|
||||||
--bg: #0f172a;
|
--bg: #0f172a;
|
||||||
--panel: #111827;
|
--panel: #111827;
|
||||||
--accent: #22d3ee;
|
--accent: #22d3ee;
|
||||||
--text: #e2e8f0;
|
--text: #e2e8f0;
|
||||||
--muted: #94a3b8;
|
--muted: #94a3b8;
|
||||||
|
}
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: 'Manrope', system-ui, -apple-system, sans-serif;
|
font-family:
|
||||||
background: radial-gradient(circle at 25% 20%, rgba(56,189,248,0.12), transparent 25%),
|
"Manrope",
|
||||||
radial-gradient(circle at 80% 10%, rgba(167,139,250,0.16), transparent 30%),
|
system-ui,
|
||||||
radial-gradient(circle at 40% 80%, rgba(52,211,153,0.12), transparent 25%),
|
-apple-system,
|
||||||
var(--bg);
|
sans-serif;
|
||||||
color: var(--text);
|
background:
|
||||||
min-height: 100vh;
|
radial-gradient(
|
||||||
display: flex;
|
circle at 25% 20%,
|
||||||
align-items: center;
|
rgba(56, 189, 248, 0.12),
|
||||||
justify-content: center;
|
transparent 25%
|
||||||
padding: 20px;
|
),
|
||||||
|
radial-gradient(
|
||||||
|
circle at 80% 10%,
|
||||||
|
rgba(167, 139, 250, 0.16),
|
||||||
|
transparent 30%
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
circle at 40% 80%,
|
||||||
|
rgba(52, 211, 153, 0.12),
|
||||||
|
transparent 25%
|
||||||
|
),
|
||||||
|
var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
}
|
}
|
||||||
.shell {
|
.shell {
|
||||||
width: min(1100px, 100%);
|
width: min(1100px, 100%);
|
||||||
background: rgba(17, 24, 39, 0.72);
|
background: rgba(17, 24, 39, 0.72);
|
||||||
border: 1px solid rgba(148, 163, 184, 0.1);
|
border: 1px solid rgba(148, 163, 184, 0.1);
|
||||||
box-shadow: 0 20px 60px rgba(0,0,0,0.35);
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
}
|
}
|
||||||
header {
|
header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 14px 18px;
|
padding: 14px 18px;
|
||||||
background: rgba(255, 255, 255, 0.02);
|
background: rgba(255, 255, 255, 0.02);
|
||||||
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
|
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
|
||||||
}
|
}
|
||||||
header h1 {
|
header h1 {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
header .meta {
|
header .meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
header .pill {
|
header .pill {
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: rgba(34, 211, 238, 0.12);
|
background: rgba(34, 211, 238, 0.12);
|
||||||
color: #67e8f9;
|
color: #67e8f9;
|
||||||
border: 1px solid rgba(34, 211, 238, 0.35);
|
border: 1px solid rgba(34, 211, 238, 0.35);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
letter-spacing: 0.6px;
|
letter-spacing: 0.6px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
.pause-btn {
|
.pause-btn {
|
||||||
background: rgba(34, 211, 238, 0.14);
|
background: rgba(34, 211, 238, 0.14);
|
||||||
color: #67e8f9;
|
color: #67e8f9;
|
||||||
border: 1px solid rgba(34, 211, 238, 0.4);
|
border: 1px solid rgba(34, 211, 238, 0.4);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 120ms ease, filter 120ms ease;
|
transition:
|
||||||
|
transform 120ms ease,
|
||||||
|
filter 120ms ease;
|
||||||
}
|
}
|
||||||
.pause-btn:hover {
|
.pause-btn:hover {
|
||||||
filter: brightness(1.05);
|
filter: brightness(1.05);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
.pause-btn:active {
|
.pause-btn:active {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
#scene-wrapper {
|
#scene-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 680px;
|
height: 680px;
|
||||||
background: radial-gradient(circle at 30% 30%, rgba(255,255,255,0.02), transparent 40%),
|
background:
|
||||||
radial-gradient(circle at 75% 60%, rgba(255,255,255,0.02), transparent 45%),
|
radial-gradient(
|
||||||
#0b1222;
|
circle at 30% 30%,
|
||||||
|
rgba(255, 255, 255, 0.02),
|
||||||
|
transparent 40%
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
circle at 75% 60%,
|
||||||
|
rgba(255, 255, 255, 0.02),
|
||||||
|
transparent 45%
|
||||||
|
),
|
||||||
|
#0b1222;
|
||||||
}
|
}
|
||||||
canvas {
|
canvas {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
.hud-bar {
|
.hud-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: rgba(255, 255, 255, 0.03);
|
||||||
border-top: 1px solid rgba(148, 163, 184, 0.08);
|
border-top: 1px solid rgba(148, 163, 184, 0.08);
|
||||||
}
|
}
|
||||||
.hud-bar .card {
|
.hud-bar .card {
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: rgba(255, 255, 255, 0.04);
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
min-height: 36px;
|
||||||
}
|
}
|
||||||
.hud-bar .card strong {
|
.hud-bar .card strong {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.progress-card {
|
||||||
|
flex: 1 1 200px;
|
||||||
|
max-width: 280px;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.progress {
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||||
|
}
|
||||||
|
.progress__bar {
|
||||||
|
height: 100%;
|
||||||
|
width: 0%;
|
||||||
|
background: linear-gradient(135deg, #22d3ee, #a78bfa);
|
||||||
|
transition: width 150ms ease;
|
||||||
}
|
}
|
||||||
.selector {
|
.selector {
|
||||||
background: rgba(0, 0, 0, 0.25);
|
background: rgba(0, 0, 0, 0.25);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.legend {
|
.legend {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 16px;
|
top: 16px;
|
||||||
right: 16px;
|
right: 16px;
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: rgba(255, 255, 255, 0.04);
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.legend span {
|
.legend span {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
.pause-overlay {
|
.pause-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 12px;
|
top: 12px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
padding: 8px 14px;
|
padding: 8px 14px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition: opacity 160ms ease;
|
transition: opacity 160ms ease;
|
||||||
}
|
}
|
||||||
.pause-overlay.visible {
|
.pause-overlay.visible {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
.game-over {
|
.game-over {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(10, 13, 25, 0.72);
|
background: rgba(10, 13, 25, 0.72);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 200ms ease;
|
transition: opacity 200ms ease;
|
||||||
}
|
}
|
||||||
.game-over.visible {
|
.game-over.visible {
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
.game-over__card {
|
.game-over__card {
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: rgba(255, 255, 255, 0.04);
|
||||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
padding: 20px 24px;
|
padding: 20px 24px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
min-width: 240px;
|
min-width: 240px;
|
||||||
box-shadow: 0 12px 35px rgba(0, 0, 0, 0.35);
|
box-shadow: 0 12px 35px rgba(0, 0, 0, 0.35);
|
||||||
}
|
}
|
||||||
.game-over__card .title {
|
.game-over__card .title {
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
.game-over__card .score-line {
|
.game-over__card .score-line {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
}
|
}
|
||||||
.game-over__card button {
|
.game-over__card button {
|
||||||
background: linear-gradient(135deg, #22d3ee, #0ea5e9);
|
background: linear-gradient(135deg, #22d3ee, #0ea5e9);
|
||||||
border: none;
|
border: none;
|
||||||
color: #0b1222;
|
color: #0b1222;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
.game-over__card button:hover {
|
.game-over__card button:hover {
|
||||||
filter: brightness(1.08);
|
filter: brightness(1.08);
|
||||||
|
}
|
||||||
|
.win-overlay .game-over__card {
|
||||||
|
min-width: 260px;
|
||||||
|
}
|
||||||
|
.win-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.win-actions button:nth-child(2) {
|
||||||
|
background: linear-gradient(135deg, #a855f7, #22d3ee);
|
||||||
}
|
}
|
||||||
.floating-score {
|
.floating-score {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
color: #e0f2fe;
|
color: #e0f2fe;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
text-shadow: 0 8px 20px rgba(0, 0, 0, 0.35);
|
text-shadow: 0 8px 20px rgba(0, 0, 0, 0.35);
|
||||||
animation: floatUp 900ms ease-out forwards;
|
animation: floatUp 900ms ease-out forwards;
|
||||||
}
|
}
|
||||||
@keyframes floatUp {
|
@keyframes floatUp {
|
||||||
0% { opacity: 1; transform: translate(-50%, 0); }
|
0% {
|
||||||
60% { opacity: 0.9; transform: translate(-50%, -22px); }
|
opacity: 1;
|
||||||
100% { opacity: 0; transform: translate(-50%, -38px); }
|
transform: translate(-50%, 0);
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
opacity: 0.9;
|
||||||
|
transform: translate(-50%, -22px);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, -38px);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.instructions {
|
.instructions {
|
||||||
padding: 14px 18px;
|
padding: 14px 18px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: rgba(255, 255, 255, 0.03);
|
||||||
border-top: 1px solid rgba(148, 163, 184, 0.08);
|
border-top: 1px solid rgba(148, 163, 184, 0.08);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 800px) {
|
||||||
#scene-wrapper { height: 520px; }
|
#scene-wrapper {
|
||||||
header h1 { font-size: 18px; }
|
height: 520px;
|
||||||
|
}
|
||||||
|
header h1 {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
40
ui.js
40
ui.js
@@ -14,11 +14,18 @@
|
|||||||
const restartBtn = document.getElementById("restart-btn");
|
const restartBtn = document.getElementById("restart-btn");
|
||||||
const pauseBtn = document.getElementById("pause-btn");
|
const pauseBtn = document.getElementById("pause-btn");
|
||||||
const pauseOverlay = document.getElementById("pause-overlay");
|
const pauseOverlay = document.getElementById("pause-overlay");
|
||||||
|
const goalLabelEl = document.getElementById("goal-label");
|
||||||
|
const goalProgressEl = document.getElementById("goal-progress");
|
||||||
|
const winEl = document.getElementById("win-overlay");
|
||||||
|
const winMessageEl = document.getElementById("win-message");
|
||||||
|
const winNextBtn = document.getElementById("win-next");
|
||||||
|
const winRestartBtn = document.getElementById("win-restart");
|
||||||
|
|
||||||
const handlers = {
|
const handlers = {
|
||||||
onPauseToggle: null,
|
onPauseToggle: null,
|
||||||
onRestart: null,
|
onRestart: null,
|
||||||
onSceneChange: null,
|
onSceneChange: null,
|
||||||
|
onWinNext: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (pauseBtn) {
|
if (pauseBtn) {
|
||||||
@@ -39,6 +46,18 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (winNextBtn) {
|
||||||
|
winNextBtn.addEventListener("click", () => {
|
||||||
|
if (handlers.onWinNext) handlers.onWinNext();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (winRestartBtn) {
|
||||||
|
winRestartBtn.addEventListener("click", () => {
|
||||||
|
if (handlers.onRestart) handlers.onRestart();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
window.addEventListener("keydown", (e) => {
|
window.addEventListener("keydown", (e) => {
|
||||||
if (e.key === "Escape" && handlers.onPauseToggle) {
|
if (e.key === "Escape" && handlers.onPauseToggle) {
|
||||||
handlers.onPauseToggle();
|
handlers.onPauseToggle();
|
||||||
@@ -78,6 +97,24 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setGoal = ({ label, progress }) => {
|
||||||
|
if (goalLabelEl) goalLabelEl.textContent = label || "—";
|
||||||
|
if (goalProgressEl)
|
||||||
|
goalProgressEl.style.width = `${Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(100, progress ?? 0),
|
||||||
|
)}%`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const showWin = (message) => {
|
||||||
|
if (winMessageEl) winMessageEl.textContent = message || "You win!";
|
||||||
|
if (winEl) winEl.classList.add("visible");
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideWin = () => {
|
||||||
|
if (winEl) winEl.classList.remove("visible");
|
||||||
|
};
|
||||||
|
|
||||||
const showGameOver = (score) => {
|
const showGameOver = (score) => {
|
||||||
if (finalScoreEl) finalScoreEl.textContent = score;
|
if (finalScoreEl) finalScoreEl.textContent = score;
|
||||||
if (gameOverEl) gameOverEl.classList.add("visible");
|
if (gameOverEl) gameOverEl.classList.add("visible");
|
||||||
@@ -146,9 +183,12 @@
|
|||||||
setPauseState,
|
setPauseState,
|
||||||
showGameOver,
|
showGameOver,
|
||||||
hideGameOver,
|
hideGameOver,
|
||||||
|
showWin,
|
||||||
|
hideWin,
|
||||||
setSceneOptions,
|
setSceneOptions,
|
||||||
setSceneSelection,
|
setSceneSelection,
|
||||||
setHandlers,
|
setHandlers,
|
||||||
|
setGoal,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user