From 317803a4a3390f8c3cb77d6e06791f175d3536f6 Mon Sep 17 00:00:00 2001 From: Daddy32 Date: Sat, 13 Dec 2025 20:31:40 +0100 Subject: [PATCH] Add relax timer scene and color collection goal --- index.html | 1 + main.js | 164 +++++++++++++++++++++++++++------------ scenes/index.js | 1 + scenes/scene-fastdrop.js | 7 +- scenes/scene-relax.js | 90 +++++++++++++++++++++ 5 files changed, 214 insertions(+), 49 deletions(-) create mode 100644 scenes/scene-relax.js diff --git a/index.html b/index.html index 884c8c4..4986279 100644 --- a/index.html +++ b/index.html @@ -99,6 +99,7 @@ + diff --git a/main.js b/main.js index ff8f7fd..de3e0ea 100644 --- a/main.js +++ b/main.js @@ -7,6 +7,7 @@ Body, Bodies, Constraint, + Composites, Events, Query, Vector, @@ -105,6 +106,8 @@ let gameOver = false; let isPaused = false; let levelWon = false; + let timerEndMs = null; + let lastTimerDisplay = null; const makeStorageKey = (sceneId) => `physilinks-highscore-${sceneId}`; @@ -199,23 +202,51 @@ const y = spawnFromBottom ? height + config.ballRadius * 2 : -config.ballRadius * 2; - const ball = createBallBody(x, y, color); - ball.plugin = { - color, - hasEntered: false, - entryCheckId: null, - squishX: 1, - squishY: 1, - }; - balls.push(ball); - World.add(world, ball); - ball.plugin.entryCheckId = setTimeout(() => { - ball.plugin.entryCheckId = null; - if (gameOver) return; - if (!ball.plugin.hasEntered && Math.abs(ball.velocity.y) < 0.2) { - triggerGameOver(); + const batchMin = currentScene?.config?.spawnBatchMin ?? 1; + const batchMax = currentScene?.config?.spawnBatchMax ?? 1; + const batchCount = + batchMin === batchMax + ? batchMin + : Math.max( + batchMin, + Math.floor(Math.random() * (batchMax - batchMin + 1)) + batchMin, + ); + for (let i = 0; i < batchCount; i += 1) { + const blob = createBallBodies( + Math.min( + Math.max( + config.ballRadius + 10, + x + (i - batchCount / 2) * config.ballRadius * 1.5, + ), + width - config.ballRadius - 10, + ), + y + + i * + (spawnFromBottom + ? -config.ballRadius * 0.5 + : config.ballRadius * 0.5), + color, + ); + if (blob.constraints.length > 0 && blob.blobId) { + blobConstraints.set(blob.blobId, blob.constraints); } - }, 1500); + blob.bodies.forEach((body) => { + balls.push(body); + World.add(world, body); + if (!currentScene?.config?.noGameOver) { + body.plugin.entryCheckId = setTimeout(() => { + body.plugin.entryCheckId = null; + if (gameOver) return; + if (!body.plugin.hasEntered && Math.abs(body.velocity.y) < 0.2) { + triggerGameOver(); + } + }, 1500); + } + }); + if (blob.constraints.length > 0) { + World.add(world, blob.constraints); + } + } }; const startSpawner = () => { @@ -294,6 +325,7 @@ }; const triggerGameOver = () => { + if (currentScene?.config?.noGameOver) return; if (gameOver) return; gameOver = true; isPaused = false; @@ -313,6 +345,15 @@ score = 0; clearedCount = 0; clearedByColor = {}; + const winCond = currentScene?.config?.winCondition; + if (winCond?.type === "timer") { + const duration = winCond.durationSec ?? 120; + timerEndMs = Date.now() + duration * 1000; + lastTimerDisplay = null; + } else { + timerEndMs = null; + lastTimerDisplay = null; + } resetChainVisuals(); balls.forEach((ball) => { cleanupBall(ball); @@ -654,11 +695,11 @@ config.ballRadius = nextRadius; }; - const createBallBody = (x, y, color) => { + const createBallBodies = (x, y, color) => { const commonOpts = { restitution: 0.72, friction: 0.01, - frictionAir: 0.015, + frictionAir: 0.012, render: { fillStyle: color, strokeStyle: "#0b1222", @@ -666,20 +707,38 @@ }, }; if (currentScene?.config?.blobBalls) { - const points = []; - const segments = 12; - for (let i = 0; i < segments; i += 1) { - const angle = (i / segments) * Math.PI * 2; - const variance = 0.75 + Math.random() * 0.35; - const r = config.ballRadius * variance; - points.push({ - x: x + Math.cos(angle) * r, - y: y + Math.sin(angle) * r, - }); - } - return Bodies.fromVertices(x, y, [points], commonOpts); + const cols = 3; + const rows = 2; + const radius = Math.max(10, config.ballRadius * 0.55); + const soft = Composites.softBody( + x - cols * radius * 1.2, + y - rows * radius * 1.2, + cols, + rows, + 0, + 0, + true, + radius, + commonOpts, + ); + const blobId = `blob-${Date.now()}-${Math.random().toString(16).slice(2)}`; + soft.bodies.forEach((b) => { + b.plugin = { + color, + hasEntered: false, + entryCheckId: null, + blobId, + }; + }); + soft.constraints.forEach((c) => { + c.plugin = { blobId, blobConstraint: true }; + c.render = c.render || {}; + c.render.type = "line"; + }); + return { bodies: soft.bodies, constraints: soft.constraints, blobId }; } - return Bodies.circle(x, y, config.ballRadius, commonOpts); + const body = Bodies.circle(x, y, config.ballRadius, commonOpts); + return { bodies: [body], constraints: [], blobId: null }; }; const normalizeColor = (c) => (c || "").trim().toLowerCase(); @@ -687,6 +746,19 @@ 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, + }; + } if (winCond.type === "clearCount") { const target = winCond.target ?? 0; const remaining = Math.max(0, target - clearedCount); @@ -850,23 +922,19 @@ Body.setPosition(b, target); Body.setVelocity(b, { x: 0, y: 0 }); }); - if (currentScene?.config?.blobBalls) { - balls.forEach((ball) => { - if (!ball.plugin) return; - const speed = Vector.magnitude(ball.velocity || { x: 0, y: 0 }); - const squeeze = Math.min(0.22, speed / 20); - const targetX = 1 + squeeze; - const targetY = Math.max(0.7, 1 - squeeze); - const currX = ball.plugin.squishX || 1; - const currY = ball.plugin.squishY || 1; - const factorX = targetX / currX; - const factorY = targetY / currY; - if (Math.abs(factorX - 1) > 0.02 || Math.abs(factorY - 1) > 0.02) { - Body.scale(ball, factorX, factorY); - ball.plugin.squishX = targetX; - ball.plugin.squishY = targetY; - } - }); + if (timerEndMs) { + const winCond = currentScene?.config?.winCondition; + const duration = winCond?.durationSec ?? 120; + const now = Date.now(); + const remainingMs = Math.max(0, timerEndMs - now); + const remainingSec = Math.ceil(remainingMs / 1000); + if (lastTimerDisplay !== remainingSec) { + lastTimerDisplay = remainingSec; + updateHud(); + } + if (remainingMs <= 0 && !levelWon) { + checkWinCondition(); + } } }); diff --git a/scenes/index.js b/scenes/index.js index 2bf22cf..b9132b2 100644 --- a/scenes/index.js +++ b/scenes/index.js @@ -6,6 +6,7 @@ "fast-drop-maze", "balanced", "scene-lava", + "relax", ]; const orderedScenes = desiredOrder .map((id) => scenes.find((s) => s.id === id)) diff --git a/scenes/scene-fastdrop.js b/scenes/scene-fastdrop.js index 106b893..a9862c7 100644 --- a/scenes/scene-fastdrop.js +++ b/scenes/scene-fastdrop.js @@ -7,11 +7,16 @@ id: "fast-drop-maze", name: "Fast drop maze", config: { - gravity: 1.25, + gravity: 1.2, spawnIntervalMs: 220, minChain: 3, palette: ["#e879f9", "#38bdf8", "#f97316", "#22c55e"], ballRadius: 16, + winCondition: { + type: "colorClear", + targets: [{ color: "#e879f9", count: 100 }], + onWin: { setGravity: -0.5, swirlBalls: true }, + }, link: { stiffness: 1, lengthScale: 0.85, diff --git a/scenes/scene-relax.js b/scenes/scene-relax.js new file mode 100644 index 0000000..01476b0 --- /dev/null +++ b/scenes/scene-relax.js @@ -0,0 +1,90 @@ +(() => { + const { Bodies } = Matter; + const scenes = (window.PhysilinksSceneDefs = + window.PhysilinksSceneDefs || []); + + scenes.push({ + id: "relax", + name: "Relax drift", + config: { + gravity: 0.08, + spawnIntervalMs: 850, + spawnBatchMin: 3, + spawnBatchMax: 5, + spawnFrom: "bottom", + autoSpawn: true, + minChain: 2, + palette: ["#38bdf8", "#f472b6", "#fbbf24", "#22c55e", "#a855f7"], + ballRadius: 20, + blobBalls: true, + noGameOver: true, + winCondition: { + type: "timer", + durationSec: 120, + onWin: { setGravity: -0.4, swirlBalls: true }, + }, + link: { + stiffness: 0.6, + lengthScale: 1.1, + damping: 0.1, + lineWidth: 3, + rope: true, + renderType: "line", + maxLengthMultiplier: 3.8, + }, + }, + createBodies: (w, h) => { + const wallThickness = Math.max(30, w * 0.04); + const wallHeight = h + wallThickness * 2; + const floorHeight = Math.max(40, h * 0.08); + const bumperRadius = Math.max(30, Math.min(w, h) * 0.04); + return [ + Bodies.rectangle( + w / 2, + h + floorHeight / 2, + w + wallThickness * 2, + floorHeight, + { + isStatic: true, + restitution: 0.8, + render: { fillStyle: "#0ea5e9", strokeStyle: "#0ea5e9" }, + }, + ), + Bodies.rectangle(w / 2, -wallThickness / 2, w + wallThickness * 2, wallThickness, { + isStatic: true, + render: { fillStyle: "#0ea5e9", strokeStyle: "#0ea5e9" }, + }), + Bodies.rectangle( + -wallThickness / 2, + h / 2, + wallThickness, + wallHeight, + { + isStatic: true, + render: { fillStyle: "#7c3aed", strokeStyle: "#7c3aed" }, + }, + ), + Bodies.rectangle( + w + wallThickness / 2, + h / 2, + wallThickness, + wallHeight, + { + isStatic: true, + render: { fillStyle: "#7c3aed", strokeStyle: "#7c3aed" }, + }, + ), + Bodies.circle(w * 0.35, h * 0.35, bumperRadius, { + isStatic: true, + restitution: 1.05, + render: { fillStyle: "#fbbf24", strokeStyle: "#fbbf24" }, + }), + Bodies.circle(w * 0.65, h * 0.55, bumperRadius * 0.9, { + isStatic: true, + restitution: 1.05, + render: { fillStyle: "#22c55e", strokeStyle: "#22c55e" }, + }), + ]; + }, + }); +})();