Add packed stack blocks and chain timer loss

This commit is contained in:
Daddy32
2025-12-26 22:52:22 +01:00
parent b2ffe3413b
commit 76ffee449d
10 changed files with 566 additions and 5 deletions

View File

@@ -38,3 +38,9 @@ Physilinks is a browser-based physics linking game built with Matter.js. Match a
- No build step. Open `index.html` directly in the browser. - No build step. Open `index.html` directly in the browser.
- Key files: `index.html` (layout), `styles.css` (styling), `src/ui.js` (DOM/HUD), `src/main.js` (physics/game logic), `src/scenes/index.js` (scene registration). - Key files: `index.html` (layout), `styles.css` (styling), `src/ui.js` (DOM/HUD), `src/main.js` (physics/game logic), `src/scenes/index.js` (scene registration).
- Adjust or add scenes by extending the files in `src/scenes/` with config and a `createBodies(width, height)` function. - Adjust or add scenes by extending the files in `src/scenes/` with config and a `createBodies(width, height)` function.
## Adding a new scene
- Create `src/scenes/scene-<your-id>.js` based on `src/scenes/scene-template.js` or an existing scene, and keep the `id` aligned to the filename suffix.
- Add the script tag to `index.html` with the other scene files so it loads in the browser.
- Register the scene id in `src/scenes/index.js` (append to `desiredOrder` or rely on the unordered fallback).
- If the scene uses new DOM ids/classes or storage keys, document them in your commit notes per the repo guidelines.

View File

@@ -57,6 +57,16 @@
<div class="progress__bar" id="goal-progress"></div> <div class="progress__bar" id="goal-progress"></div>
</div> </div>
</div> </div>
<div class="card progress-card timer-card" id="chain-timer-card">
<strong>Chain timer</strong>
<span id="chain-timer-label"></span>
<div class="progress">
<div
class="progress__bar timer-bar"
id="chain-timer-progress"
></div>
</div>
</div>
<div class="card"> <div class="card">
<strong>Score</strong> <span id="score">0</span> <strong>Score</strong> <span id="score">0</span>
</div> </div>
@@ -104,6 +114,7 @@
<script src="./src/scenes/scene-storm-grid.js"></script> <script src="./src/scenes/scene-storm-grid.js"></script>
<script src="./src/scenes/scene-stack-blocks-chaos.js"></script> <script src="./src/scenes/scene-stack-blocks-chaos.js"></script>
<script src="./src/scenes/scene-stack-blocks.js"></script> <script src="./src/scenes/scene-stack-blocks.js"></script>
<script src="./src/scenes/scene-stack-blocks-packed.js"></script>
<script src="./src/scenes/index.js"></script> <script src="./src/scenes/index.js"></script>
<script src="./src/config.js"></script> <script src="./src/config.js"></script>
<script src="./src/engine.js"></script> <script src="./src/engine.js"></script>

View File

@@ -243,6 +243,18 @@
updateLongestChain(chainLength); updateLongestChain(chainLength);
const { gain, isNegativeProgress } = getChainScoreState(); const { gain, isNegativeProgress } = getChainScoreState();
state.score += gain; state.score += gain;
const chainLose = currentScene?.config?.chainLose;
if (chainLose && Number.isFinite(chainLose.idleSec)) {
const now = Date.now();
const maxMs = chainLose.idleSec * 1000;
const bumpMs = maxMs / 3;
state.chainTimerDurationSec = chainLose.idleSec;
const currentEnd = state.chainTimerEndMs || now + maxMs;
const nextEnd = Math.min(now + maxMs, currentEnd + bumpMs);
state.chainTimerEndMs = nextEnd;
state.chainTimerLastDisplay = null;
state.chainTimerLastWarnSec = null;
}
const clearTargets = collectClearTargets(); const clearTargets = collectClearTargets();
state.clearedCount += chainLength; state.clearedCount += chainLength;
if (state.score > state.highScore) { if (state.score > state.highScore) {

View File

@@ -12,6 +12,7 @@
getMaxLinkDistance, getMaxLinkDistance,
updateHud, updateHud,
checkWinCondition, checkWinCondition,
triggerGameOver,
ui, ui,
}) => { }) => {
const runSceneBeforeUpdateHook = () => { const runSceneBeforeUpdateHook = () => {
@@ -95,6 +96,60 @@
} }
}; };
const stepChainLoseTimer = () => {
const chainLose = getCurrentScene()?.config?.chainLose;
if (!chainLose || !Number.isFinite(chainLose.idleSec)) return;
if (!state.chainTimerEndMs) return;
if (state.chainTimerFrozen) return;
const duration = state.chainTimerDurationSec ?? chainLose.idleSec;
if (!Number.isFinite(duration) || duration <= 0) return;
const now = Date.now();
const remainingMs = Math.max(0, state.chainTimerEndMs - now);
const remainingSec = Math.ceil(remainingMs / 1000);
const progress = (100 * remainingSec) / duration;
const warnAt = chainLose.warnAtSec ?? 3;
const urgent = remainingSec <= warnAt;
if (state.chainTimerLastDisplay !== remainingSec) {
state.chainTimerLastDisplay = remainingSec;
if (ui.setChainTimer) {
ui.setChainTimer({
label: `${remainingSec}s left`,
progress,
urgent,
});
}
if (
urgent &&
remainingSec > 0 &&
state.chainTimerLastWarnSec !== remainingSec
) {
state.chainTimerLastWarnSec = remainingSec;
const text =
chainLose.countdownMessage ||
(remainingSec <= 1 ? "Chain now!" : `${remainingSec}`);
ui.showFloatingMessage(
{ text },
{
durationMs: chainLose.countdownDurationMs ?? 700,
position: chainLose.countdownPosition || {
xPercent: 50,
yPercent: 18,
},
},
);
}
} else if (ui.setChainTimer) {
ui.setChainTimer({
label: `${remainingSec}s left`,
progress,
urgent,
});
}
if (remainingMs <= 0) {
triggerGameOver?.({ force: true });
}
};
const keepBallsInBounds = () => { const keepBallsInBounds = () => {
const currentScene = getCurrentScene(); const currentScene = getCurrentScene();
state.balls.forEach((ball) => { state.balls.forEach((ball) => {
@@ -140,6 +195,7 @@
stepRotators(dt, timeScale); stepRotators(dt, timeScale);
stepOscillators(); stepOscillators();
stepTimer(); stepTimer();
stepChainLoseTimer();
}; };
const linkSparkles = []; const linkSparkles = [];

View File

@@ -142,6 +142,16 @@
levelWon: false, levelWon: false,
timerEndMs: null, timerEndMs: null,
lastTimerDisplay: null, lastTimerDisplay: null,
chainTimerEndMs: null,
chainTimerDurationSec: null,
chainTimerLastDisplay: null,
chainTimerLastWarnSec: null,
chainTimerIntroTimeoutId: null,
chainTimerIntroAtMs: null,
chainTimerIntroRemainingMs: null,
chainTimerIntroText: null,
chainTimerFrozen: false,
pauseStartMs: null,
}; };
const BALL_BASELINE = 680; // reference height used for relative ball sizing const BALL_BASELINE = 680; // reference height used for relative ball sizing
@@ -271,11 +281,19 @@
const isGridScene = () => const isGridScene = () =>
currentScene && currentScene.config && currentScene.config.gridLayouts; currentScene && currentScene.config && currentScene.config.gridLayouts;
const triggerGameOver = () => { const triggerGameOver = (options = {}) => {
if (currentScene?.config?.noGameOver) return; const force = options.force === true;
if (currentScene?.config?.noGameOver && !force) return;
if (state.gameOver) return; if (state.gameOver) return;
state.gameOver = true; state.gameOver = true;
state.paused = false; state.paused = false;
if (state.chainTimerIntroTimeoutId) {
clearTimeout(state.chainTimerIntroTimeoutId);
state.chainTimerIntroTimeoutId = null;
state.chainTimerIntroAtMs = null;
state.chainTimerIntroRemainingMs = null;
state.chainTimerIntroText = null;
}
resetChainVisuals(); resetChainVisuals();
spawnSystem?.stopSpawner(); spawnSystem?.stopSpawner();
stopRunner(); stopRunner();
@@ -314,10 +332,23 @@
const createLoop = load("PhysilinksLoop", { create: "create" }); const createLoop = load("PhysilinksLoop", { create: "create" });
const restartGame = () => { const restartGame = () => {
if (state.chainTimerIntroTimeoutId) {
clearTimeout(state.chainTimerIntroTimeoutId);
state.chainTimerIntroTimeoutId = null;
state.chainTimerIntroAtMs = null;
state.chainTimerIntroRemainingMs = null;
state.chainTimerIntroText = null;
}
spawnSystem.stopSpawner(); spawnSystem.stopSpawner();
state.gameOver = false; state.gameOver = false;
state.paused = false; state.paused = false;
state.pauseStartMs = null;
state.levelWon = false; state.levelWon = false;
state.chainTimerFrozen = false;
if (currentScene?.id === "stack-blocks-packed") {
engine.plugin ??= {};
engine.plugin.stackBlocksPacked = null;
}
spawnSystem.resetSpawnState(); spawnSystem.resetSpawnState();
state.score = 0; state.score = 0;
state.clearedCount = 0; state.clearedCount = 0;
@@ -333,6 +364,59 @@
state.timerEndMs = null; state.timerEndMs = null;
state.lastTimerDisplay = null; state.lastTimerDisplay = null;
} }
const chainLose = currentScene?.config?.chainLose;
if (
chainLose &&
Number.isFinite(chainLose.idleSec) &&
chainLose.idleSec > 0
) {
state.chainTimerDurationSec = chainLose.idleSec;
state.chainTimerEndMs = Date.now() + chainLose.idleSec * 1000;
state.chainTimerLastDisplay = null;
state.chainTimerLastWarnSec = null;
state.chainTimerFrozen = false;
if (ui.setChainTimerVisibility) {
ui.setChainTimerVisibility(true);
}
if (ui.setChainTimer) {
ui.setChainTimer({
label: `${chainLose.idleSec}s left`,
progress: 100,
urgent: false,
});
}
const introDelay = chainLose.introDelayMs ?? 1200;
const introText =
chainLose.introMessage || `Make a chain in ${chainLose.idleSec}s`;
if (introDelay >= 0) {
state.chainTimerIntroText = introText;
state.chainTimerIntroAtMs = Date.now() + introDelay;
state.chainTimerIntroTimeoutId = setTimeout(() => {
state.chainTimerIntroAtMs = null;
state.chainTimerIntroText = null;
ui.showFloatingMessage(
{ text: introText },
{
durationMs: chainLose.introDurationMs ?? 2600,
position: chainLose.introPosition || {
xPercent: 50,
yPercent: 12,
},
},
);
}, introDelay);
}
} else {
state.chainTimerDurationSec = null;
state.chainTimerEndMs = null;
state.chainTimerLastDisplay = null;
state.chainTimerLastWarnSec = null;
state.chainTimerIntroText = null;
state.chainTimerFrozen = false;
if (ui.setChainTimerVisibility) {
ui.setChainTimerVisibility(false);
}
}
resetChainVisuals(); resetChainVisuals();
ui.clearMessages(); ui.clearMessages();
state.balls.forEach((ball) => { state.balls.forEach((ball) => {
@@ -367,11 +451,54 @@
state.paused = nextState; state.paused = nextState;
ui.setPauseState(state.paused); ui.setPauseState(state.paused);
if (state.paused) { if (state.paused) {
state.pauseStartMs = Date.now();
if (state.chainTimerIntroTimeoutId && state.chainTimerIntroAtMs) {
state.chainTimerIntroRemainingMs = Math.max(
0,
state.chainTimerIntroAtMs - Date.now(),
);
clearTimeout(state.chainTimerIntroTimeoutId);
state.chainTimerIntroTimeoutId = null;
state.chainTimerIntroAtMs = null;
}
resetChainVisuals(); resetChainVisuals();
spawnSystem.stopSpawner(); spawnSystem.stopSpawner();
stopRunner(); stopRunner();
engine.timing.timeScale = 0; engine.timing.timeScale = 0;
} else { } else {
if (state.pauseStartMs) {
const pausedFor = Date.now() - state.pauseStartMs;
state.pauseStartMs = null;
if (state.timerEndMs) state.timerEndMs += pausedFor;
if (state.chainTimerEndMs) state.chainTimerEndMs += pausedFor;
}
if (
state.chainTimerIntroRemainingMs &&
state.chainTimerIntroRemainingMs > 0
) {
const introText = state.chainTimerIntroText;
const chainLose = currentScene?.config?.chainLose;
state.chainTimerIntroAtMs =
Date.now() + state.chainTimerIntroRemainingMs;
state.chainTimerIntroTimeoutId = setTimeout(() => {
state.chainTimerIntroAtMs = null;
state.chainTimerIntroRemainingMs = null;
if (introText) {
ui.showFloatingMessage(
{ text: introText },
{
durationMs: chainLose?.introDurationMs ?? 2600,
position: chainLose?.introPosition || {
xPercent: 50,
yPercent: 12,
},
},
);
}
state.chainTimerIntroText = null;
}, state.chainTimerIntroRemainingMs);
state.chainTimerIntroRemainingMs = null;
}
startRunner(); startRunner();
spawnSystem.startSpawner(); spawnSystem.startSpawner();
engine.timing.timeScale = 1; engine.timing.timeScale = 1;
@@ -436,6 +563,14 @@
const goal = goals.getGoalState(); const goal = goals.getGoalState();
ui.setGoal(goal || { label: "—", progress: 0 }, config.goalEffects); ui.setGoal(goal || { label: "—", progress: 0 }, config.goalEffects);
if (!goal || !goal.met) return; if (!goal || !goal.met) return;
if (state.chainTimerIntroTimeoutId) {
clearTimeout(state.chainTimerIntroTimeoutId);
state.chainTimerIntroTimeoutId = null;
state.chainTimerIntroAtMs = null;
state.chainTimerIntroRemainingMs = null;
state.chainTimerIntroText = null;
}
state.chainTimerFrozen = true;
applyWinEffects(); applyWinEffects();
state.levelWon = true; state.levelWon = true;
spawnSystem.stopSpawner(); spawnSystem.stopSpawner();
@@ -547,6 +682,7 @@
getMaxLinkDistance, getMaxLinkDistance,
updateHud, updateHud,
checkWinCondition, checkWinCondition,
triggerGameOver,
ui, ui,
}); });

View File

@@ -4,6 +4,7 @@
"scene-grid", "scene-grid",
"balanced", "balanced",
"stack-blocks", "stack-blocks",
"stack-blocks-packed",
"scene-lava", "scene-lava",
"swirl-arena", "swirl-arena",
"relax", "relax",

View File

@@ -0,0 +1,237 @@
(() => {
const { Bodies, Query } = Matter;
const scenes = (window.PhysilinksSceneDefs =
window.PhysilinksSceneDefs || []);
const getSquareArea = ({ width, height, world }) => {
const size = Math.min(width, height);
const offset = world?.plugin?.squareOffset || 0;
return {
size,
left: (width - size) / 2 + offset,
top: (height - size) / 2 + offset,
};
};
const getSpawnInsets = ({ config, width, height, world }) => {
let left = 0;
let right = 0;
const insetVal = config.spawnInset;
if (Number.isFinite(insetVal)) {
left = insetVal;
right = insetVal;
}
if (typeof config.spawnInsets === "function") {
try {
const res = config.spawnInsets({ width, height, world });
if (res && Number.isFinite(res.left)) left = res.left;
if (res && Number.isFinite(res.right)) right = res.right;
} catch (err) {
console.error("spawnInsets function failed", err);
}
}
return { left, right };
};
const getColumnCenters = ({ config, width, height, world }) => {
const columns = config.spawnColumns || 0;
if (!Number.isFinite(columns) || columns <= 0) return [];
const squareArea = getSquareArea({ width, height, world });
const insets = getSpawnInsets({ config, width, height, world });
const usableWidth = Math.max(
0,
squareArea.size - insets.left - insets.right,
);
const columnWidth = usableWidth / columns;
const minX = squareArea.left + insets.left + config.ballRadius + 2;
const maxX =
squareArea.left +
squareArea.size -
insets.right -
config.ballRadius -
2;
return Array.from({ length: columns }, (_, index) => {
const x =
squareArea.left +
insets.left +
columnWidth * (index + 0.5);
return Math.max(minX, Math.min(maxX, x));
});
};
const getRowCount = ({ config, width, height, world }) => {
const squareArea = getSquareArea({ width, height, world });
const side = config.ballRadius * 2;
const rowGap = side * (config.rowGapMultiplier ?? 1);
const usableHeight = squareArea.size - config.ballRadius * 2;
return Math.max(1, Math.floor(usableHeight / rowGap) + 1);
};
const findOpenTopSlot = ({ config, width, height, world, balls }) => {
const squareArea = getSquareArea({ width, height, world });
const centers = getColumnCenters({ config, width, height, world });
const y = squareArea.top + config.ballRadius;
const halfSize = config.ballRadius * 1.05;
for (let i = 0; i < centers.length; i += 1) {
const x = centers[i];
const region = {
min: { x: x - halfSize, y: y - halfSize },
max: { x: x + halfSize, y: y + halfSize },
};
const hits = Query.region(balls, region);
if (!hits || hits.length === 0) {
return { x, y };
}
}
return null;
};
scenes.push({
id: "stack-blocks-packed",
name: "Stack Blocks Packed",
config: {
gravity: 1,
spawnIntervalMs: 900,
autoSpawn: false,
minChain: 3,
palette: ["#38bdf8", "#f97316", "#facc15", "#22c55e"],
ballRadius: 18,
ballShape: "rect",
spawnColumns: 10,
sizeFromColumns: true,
initialRows: null,
requireClearSpawn: false,
squarePlayArea: true,
rowGapMultiplier: 1,
noGameOver: true,
chainLose: {
idleSec: 15,
warnAtSec: 3,
introDelayMs: 1200,
},
ballPhysics: {
restitution: 0.35,
friction: 0.18,
frictionAir: 0.02,
frictionStatic: 0.45,
density: 0.003,
},
spawnInsets: ({ width }) => {
const wallThickness = Math.max(20, width * 0.02);
return { left: 0, right: 0 };
},
winCondition: {
type: "score",
target: 30000,
},
link: {
stiffness: 0.85,
lengthScale: 1.15,
damping: 0.08,
lineWidth: 3,
rope: true,
renderType: "line",
maxLengthMultiplier: 2.5,
clearAnimation: {
type: "shatter",
durationMs: 380,
sizeScale: 2.2,
},
},
onBeforeUpdate: ({
engine,
width,
height,
state,
spawnSystem,
config,
}) => {
if (!state || !spawnSystem || state.paused || state.levelWon) return;
engine.plugin ??= {};
const packedState = engine.plugin.stackBlocksPacked || {};
if (packedState.sceneId !== "stack-blocks-packed") {
packedState.sceneId = "stack-blocks-packed";
packedState.seeded = false;
}
if (!packedState.seeded) {
const rows = getRowCount({
config,
width,
height,
world: engine.world,
});
const squareArea = getSquareArea({
width,
height,
world: engine.world,
});
const rowGap = config.ballRadius * 2 * (config.rowGapMultiplier ?? 1);
for (let r = 0; r < rows; r += 1) {
const y = squareArea.top + config.ballRadius + r * rowGap;
spawnSystem.spawnRowAtY({ y, markEntered: true });
}
packedState.seeded = true;
engine.plugin.stackBlocksPacked = packedState;
}
if (state.gameOver) return;
const openSlot = findOpenTopSlot({
config,
width,
height,
world: engine.world,
balls: state.balls,
});
if (openSlot) {
spawnSystem.spawnAtPosition({
x: openSlot.x,
y: openSlot.y,
});
}
},
},
createBodies: (w, h) => {
const squareSize = Math.min(w, h);
const left = (w - squareSize) / 2;
const wallThickness = Math.max(20, w * 0.02);
const floorHeight = Math.max(30, squareSize * 0.06);
const wallRender = {
fillStyle: "#1e293b",
strokeStyle: "#94a3b8",
lineWidth: 2,
};
return [
Bodies.rectangle(
left - wallThickness / 2,
h / 2,
wallThickness,
h + wallThickness * 2,
{
isStatic: true,
render: wallRender,
},
),
Bodies.rectangle(
left + squareSize + wallThickness / 2,
h / 2,
wallThickness,
h + wallThickness * 2,
{
isStatic: true,
render: wallRender,
},
),
Bodies.rectangle(
w / 2,
h + floorHeight / 2,
w + wallThickness * 2,
floorHeight,
{
isStatic: true,
restitution: 0.2,
render: wallRender,
},
),
];
},
});
})();

View File

@@ -119,10 +119,13 @@
const createBallBodies = (x, y, color) => { const createBallBodies = (x, y, color) => {
const scene = getCurrentScene(); const scene = getCurrentScene();
const ballPhysics = scene?.config?.ballPhysics || {};
const commonOpts = { const commonOpts = {
restitution: 0.72, restitution: ballPhysics.restitution ?? 0.72,
friction: 0.01, friction: ballPhysics.friction ?? 0.01,
frictionAir: 0.012, frictionAir: ballPhysics.frictionAir ?? 0.012,
frictionStatic: ballPhysics.frictionStatic ?? 0,
density: ballPhysics.density ?? 0.001,
render: { render: {
fillStyle: color, fillStyle: color,
strokeStyle: "#0b1222", strokeStyle: "#0b1222",
@@ -320,6 +323,57 @@
spawnCount += batchCount; spawnCount += batchCount;
}; };
const spawnAtPosition = ({
x,
y,
color,
markEntered = false,
enforceSpawnLimit = true,
} = {}) => {
if (isGameOver()) return 0;
const scene = getCurrentScene();
const sceneConfig = scene?.config || {};
const spawnLimit = sceneConfig.spawnLimit;
if (
enforceSpawnLimit &&
Number.isFinite(spawnLimit) &&
spawnCount >= spawnLimit
) {
return 0;
}
const spawnColor =
color ??
config.palette[Math.floor(Math.random() * config.palette.length)];
const blob = createBallBodies(x, y, spawnColor);
if (blob.constraints.length > 0 && blob.blobId) {
blobConstraints.set(blob.blobId, blob.constraints);
}
blob.bodies.forEach((body) => {
body.plugin = body.plugin || {};
body.plugin.hasEntered = !!markEntered;
if (body.plugin.entryCheckId) {
clearTimeout(body.plugin.entryCheckId);
body.plugin.entryCheckId = null;
}
balls.push(body);
World.add(world, body);
if (!sceneConfig.noGameOver && !markEntered) {
body.plugin.entryCheckId = setTimeout(() => {
body.plugin.entryCheckId = null;
if (isGameOver()) return;
if (!body.plugin.hasEntered && Math.abs(body.velocity.y) < 0.2) {
triggerGameOver();
}
}, 1500);
}
});
if (blob.constraints.length > 0) {
World.add(world, blob.constraints);
}
spawnCount += blob.bodies.length;
return blob.bodies.length;
};
const startSpawner = () => { const startSpawner = () => {
if (isGridScene()) return; if (isGridScene()) return;
const scene = getCurrentScene(); const scene = getCurrentScene();
@@ -551,6 +605,7 @@
removeBlob, removeBlob,
resetSpawnState, resetSpawnState,
spawnRowAtY, spawnRowAtY,
spawnAtPosition,
}; };
}; };

View File

@@ -17,6 +17,11 @@
const pauseOverlay = document.getElementById("pause-overlay"); const pauseOverlay = document.getElementById("pause-overlay");
const goalLabelEl = document.getElementById("goal-label"); const goalLabelEl = document.getElementById("goal-label");
const goalProgressEl = document.getElementById("goal-progress"); const goalProgressEl = document.getElementById("goal-progress");
const chainTimerCard = document.getElementById("chain-timer-card");
const chainTimerLabelEl = document.getElementById("chain-timer-label");
const chainTimerProgressEl = document.getElementById(
"chain-timer-progress",
);
const winEl = document.getElementById("win-overlay"); const winEl = document.getElementById("win-overlay");
const winMessageEl = document.getElementById("win-message"); const winMessageEl = document.getElementById("win-message");
const winNextBtn = document.getElementById("win-next"); const winNextBtn = document.getElementById("win-next");
@@ -178,6 +183,22 @@
} }
}; };
const setChainTimerVisibility = (visible) => {
if (!chainTimerCard) return;
chainTimerCard.classList.toggle("hidden", !visible);
};
const setChainTimer = ({ label, progress, urgent } = {}) => {
if (chainTimerLabelEl) {
chainTimerLabelEl.textContent = label || "—";
}
if (chainTimerProgressEl) {
const clamped = Math.max(0, Math.min(100, progress ?? 0));
chainTimerProgressEl.style.width = `${clamped}%`;
chainTimerProgressEl.classList.toggle("timer-urgent", !!urgent);
}
};
const showWin = (message) => { const showWin = (message) => {
if (winMessageEl) winMessageEl.textContent = message || "You win!"; if (winMessageEl) winMessageEl.textContent = message || "You win!";
if (winEl) winEl.classList.add("visible"); if (winEl) winEl.classList.add("visible");
@@ -541,6 +562,8 @@
setSceneSelection, setSceneSelection,
setHandlers, setHandlers,
setGoal, setGoal,
setChainTimer,
setChainTimerVisibility,
showFloatingMessage, showFloatingMessage,
setMessageDefaults, setMessageDefaults,
clearMessages, clearMessages,

View File

@@ -324,6 +324,30 @@ canvas {
transform: scale(1.06); transform: scale(1.06);
animation: goalAura 760ms ease-in-out infinite; animation: goalAura 760ms ease-in-out infinite;
} }
.timer-card.hidden {
display: none;
}
.timer-bar {
background: linear-gradient(135deg, #f97316, #ef4444);
}
.timer-bar.timer-urgent {
animation: timerPulse 600ms ease-in-out infinite;
box-shadow: 0 0 12px rgba(248, 113, 113, 0.5);
}
@keyframes timerPulse {
0% {
transform: scaleY(1);
filter: brightness(1);
}
50% {
transform: scaleY(1.25);
filter: brightness(1.2);
}
100% {
transform: scaleY(1);
filter: brightness(1);
}
}
@keyframes goalPulse { @keyframes goalPulse {
0% { 0% {
transform: scaleY(1); transform: scaleY(1);