diff --git a/README.md b/README.md index ce9f8f0..aa0eeeb 100644 --- a/README.md +++ b/README.md @@ -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. - 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. + +## Adding a new scene +- Create `src/scenes/scene-.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. diff --git a/index.html b/index.html index 09b6258..810dc75 100644 --- a/index.html +++ b/index.html @@ -57,6 +57,16 @@
+
+ Chain timer + +
+
+
+
Score 0
@@ -104,6 +114,7 @@ + diff --git a/src/chain-controller.js b/src/chain-controller.js index 130eaec..c28b115 100644 --- a/src/chain-controller.js +++ b/src/chain-controller.js @@ -243,6 +243,18 @@ updateLongestChain(chainLength); const { gain, isNegativeProgress } = getChainScoreState(); 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(); state.clearedCount += chainLength; if (state.score > state.highScore) { diff --git a/src/loop.js b/src/loop.js index b5bf0a7..d65fb9b 100644 --- a/src/loop.js +++ b/src/loop.js @@ -12,6 +12,7 @@ getMaxLinkDistance, updateHud, checkWinCondition, + triggerGameOver, ui, }) => { 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 currentScene = getCurrentScene(); state.balls.forEach((ball) => { @@ -140,6 +195,7 @@ stepRotators(dt, timeScale); stepOscillators(); stepTimer(); + stepChainLoseTimer(); }; const linkSparkles = []; diff --git a/src/main.js b/src/main.js index 05f8388..47fc273 100644 --- a/src/main.js +++ b/src/main.js @@ -142,6 +142,16 @@ levelWon: false, timerEndMs: 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 @@ -271,11 +281,19 @@ const isGridScene = () => currentScene && currentScene.config && currentScene.config.gridLayouts; - const triggerGameOver = () => { - if (currentScene?.config?.noGameOver) return; + const triggerGameOver = (options = {}) => { + const force = options.force === true; + if (currentScene?.config?.noGameOver && !force) return; if (state.gameOver) return; state.gameOver = true; state.paused = false; + if (state.chainTimerIntroTimeoutId) { + clearTimeout(state.chainTimerIntroTimeoutId); + state.chainTimerIntroTimeoutId = null; + state.chainTimerIntroAtMs = null; + state.chainTimerIntroRemainingMs = null; + state.chainTimerIntroText = null; + } resetChainVisuals(); spawnSystem?.stopSpawner(); stopRunner(); @@ -314,10 +332,23 @@ const createLoop = load("PhysilinksLoop", { create: "create" }); const restartGame = () => { + if (state.chainTimerIntroTimeoutId) { + clearTimeout(state.chainTimerIntroTimeoutId); + state.chainTimerIntroTimeoutId = null; + state.chainTimerIntroAtMs = null; + state.chainTimerIntroRemainingMs = null; + state.chainTimerIntroText = null; + } spawnSystem.stopSpawner(); state.gameOver = false; state.paused = false; + state.pauseStartMs = null; state.levelWon = false; + state.chainTimerFrozen = false; + if (currentScene?.id === "stack-blocks-packed") { + engine.plugin ??= {}; + engine.plugin.stackBlocksPacked = null; + } spawnSystem.resetSpawnState(); state.score = 0; state.clearedCount = 0; @@ -333,6 +364,59 @@ state.timerEndMs = 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(); ui.clearMessages(); state.balls.forEach((ball) => { @@ -367,11 +451,54 @@ state.paused = nextState; ui.setPauseState(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(); spawnSystem.stopSpawner(); stopRunner(); engine.timing.timeScale = 0; } 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(); spawnSystem.startSpawner(); engine.timing.timeScale = 1; @@ -436,6 +563,14 @@ const goal = goals.getGoalState(); ui.setGoal(goal || { label: "—", progress: 0 }, config.goalEffects); 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(); state.levelWon = true; spawnSystem.stopSpawner(); @@ -547,6 +682,7 @@ getMaxLinkDistance, updateHud, checkWinCondition, + triggerGameOver, ui, }); diff --git a/src/scenes/index.js b/src/scenes/index.js index 5fbf24b..eb5682d 100644 --- a/src/scenes/index.js +++ b/src/scenes/index.js @@ -4,6 +4,7 @@ "scene-grid", "balanced", "stack-blocks", + "stack-blocks-packed", "scene-lava", "swirl-arena", "relax", diff --git a/src/scenes/scene-stack-blocks-packed.js b/src/scenes/scene-stack-blocks-packed.js new file mode 100644 index 0000000..7a9abf0 --- /dev/null +++ b/src/scenes/scene-stack-blocks-packed.js @@ -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, + }, + ), + ]; + }, + }); +})(); diff --git a/src/spawn.js b/src/spawn.js index d835c76..f5f0ea0 100644 --- a/src/spawn.js +++ b/src/spawn.js @@ -119,10 +119,13 @@ const createBallBodies = (x, y, color) => { const scene = getCurrentScene(); + const ballPhysics = scene?.config?.ballPhysics || {}; const commonOpts = { - restitution: 0.72, - friction: 0.01, - frictionAir: 0.012, + restitution: ballPhysics.restitution ?? 0.72, + friction: ballPhysics.friction ?? 0.01, + frictionAir: ballPhysics.frictionAir ?? 0.012, + frictionStatic: ballPhysics.frictionStatic ?? 0, + density: ballPhysics.density ?? 0.001, render: { fillStyle: color, strokeStyle: "#0b1222", @@ -320,6 +323,57 @@ 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 = () => { if (isGridScene()) return; const scene = getCurrentScene(); @@ -551,6 +605,7 @@ removeBlob, resetSpawnState, spawnRowAtY, + spawnAtPosition, }; }; diff --git a/src/ui.js b/src/ui.js index d051599..effd582 100644 --- a/src/ui.js +++ b/src/ui.js @@ -17,6 +17,11 @@ const pauseOverlay = document.getElementById("pause-overlay"); const goalLabelEl = document.getElementById("goal-label"); 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 winMessageEl = document.getElementById("win-message"); 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) => { if (winMessageEl) winMessageEl.textContent = message || "You win!"; if (winEl) winEl.classList.add("visible"); @@ -541,6 +562,8 @@ setSceneSelection, setHandlers, setGoal, + setChainTimer, + setChainTimerVisibility, showFloatingMessage, setMessageDefaults, clearMessages, diff --git a/styles.css b/styles.css index cce5565..a787f99 100644 --- a/styles.css +++ b/styles.css @@ -324,6 +324,30 @@ canvas { transform: scale(1.06); 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 { 0% { transform: scaleY(1);