diff --git a/index.html b/index.html index 6ce76ce..cd3583d 100644 --- a/index.html +++ b/index.html @@ -100,6 +100,7 @@ + diff --git a/src/main.js b/src/main.js index f8972d9..a17f888 100644 --- a/src/main.js +++ b/src/main.js @@ -64,6 +64,8 @@ const BALL_BASELINE = 680; // reference height used for relative ball sizing const engine = Engine.create(); + const defaultGravityScale = engine.gravity.scale; + const defaultTimeScale = engine.timing.timeScale || 1; engine.gravity.y = config.gravity; const world = engine.world; @@ -125,6 +127,7 @@ const balls = []; const blobConstraints = new Map(); let spawnTimer = null; + let spawnCount = 0; let score = 0; let highScore = 0; let clearedCount = 0; @@ -165,7 +168,16 @@ const prevRadius = config.ballRadius; Object.assign(config, next.config); config.link = { ...next.config.link }; + engine.gravity.scale = + typeof next.config.gravityScale === "number" + ? next.config.gravityScale + : defaultGravityScale; + engine.timing.timeScale = + typeof next.config.timeScale === "number" + ? next.config.timeScale + : defaultTimeScale; updateBallRadius(prevRadius); + engine.gravity.x = 0; engine.gravity.y = config.gravity; clearedCount = 0; levelWon = false; @@ -221,16 +233,39 @@ const spawnBall = () => { if (gameOver) return; + const spawnLimit = currentScene?.config?.spawnLimit; + if (Number.isFinite(spawnLimit) && spawnCount >= spawnLimit) { + stopSpawner(); + return; + } const color = config.palette[Math.floor(Math.random() * config.palette.length)]; - const x = Math.max( - config.ballRadius + 10, - Math.min(width - config.ballRadius - 10, Math.random() * width), - ); + const spawnOrigin = currentScene?.config?.spawnOrigin || "edge"; + const spawnJitter = + currentScene?.config?.spawnJitter ?? config.ballRadius * 3; + const centerSpawn = + spawnOrigin === "center" + ? { + x: + width / 2 + + (Math.random() - 0.5) * Math.max(spawnJitter, config.ballRadius), + y: + height / 2 + + (Math.random() - 0.5) * Math.max(spawnJitter, config.ballRadius), + } + : null; + const x = + centerSpawn?.x ?? + Math.max( + config.ballRadius + 10, + Math.min(width - config.ballRadius - 10, Math.random() * width), + ); const spawnFromBottom = currentScene?.config?.spawnFrom === "bottom"; - const y = spawnFromBottom - ? height + config.ballRadius * 2 - : -config.ballRadius * 2; + const y = + centerSpawn?.y ?? + (spawnFromBottom + ? height + config.ballRadius * 2 + : -config.ballRadius * 2); const batchMin = currentScene?.config?.spawnBatchMin ?? 1; const batchMax = currentScene?.config?.spawnBatchMax ?? 1; const batchCount = @@ -276,6 +311,7 @@ World.add(world, blob.constraints); } } + spawnCount += batchCount; }; const startSpawner = () => { @@ -292,6 +328,14 @@ } }; + const spawnInitialBurst = () => { + const initialCount = currentScene?.config?.initialSpawnCount || 0; + if (!initialCount || initialCount <= 0) return; + for (let i = 0; i < initialCount; i += 1) { + spawnBall(); + } + }; + const cleanupBall = (ball) => { if (ball.plugin && ball.plugin.entryCheckId) { clearTimeout(ball.plugin.entryCheckId); @@ -371,6 +415,7 @@ gameOver = false; isPaused = false; levelWon = false; + spawnCount = 0; score = 0; clearedCount = 0; clearedByColor = {}; @@ -393,6 +438,7 @@ ui.hideGameOver(); ui.hideWin(); ui.setPauseState(false); + engine.gravity.x = 0; engine.gravity.y = config.gravity; engine.timing.timeScale = 1; startRunner(); @@ -400,6 +446,7 @@ if (isGridScene()) { spawnGridBalls(); } else { + spawnInitialBurst(); startSpawner(); } }; @@ -534,7 +581,20 @@ const finishChain = (releasePoint) => { if (!chain.active || gameOver || isPaused) return; if (chain.bodies.length >= config.minChain) { - const gain = 10 * Math.pow(chain.bodies.length, 2); + const baseGain = 10 * Math.pow(chain.bodies.length, 2); + const negativeColors = ( + currentScene?.config?.negativeScoreColors || [] + ).map(normalizeColor); + const negativeProgressColors = ( + currentScene?.config?.negativeProgressColors || [] + ).map(normalizeColor); + const isNegative = + chain.color && + negativeColors.includes(normalizeColor(chain.color || "")); + const isNegativeProgress = + chain.color && + negativeProgressColors.includes(normalizeColor(chain.color || "")); + const gain = isNegative ? -baseGain : baseGain; score += gain; clearedCount += chain.bodies.length; if (score > highScore) { @@ -576,6 +636,16 @@ balls.splice(i, 1); } } + if (isNegativeProgress) { + const winCond = currentScene?.config?.winCondition; + if (winCond?.type === "colorClear" && Array.isArray(winCond.targets)) { + winCond.targets.forEach((target) => { + const key = normalizeColor(target.color); + const current = clearedByColor[key] || 0; + clearedByColor[key] = Math.max(0, current - chain.bodies.length); + }); + } + } } else { chain.constraints.forEach((c) => World.remove(world, c)); chain.bodies.forEach((b) => setHighlight(b, false)); @@ -886,10 +956,11 @@ const totalTarget = targets.reduce((sum, t) => sum + t.count, 0); let totalAchieved = 0; const parts = targets.map((t) => { - const got = Math.min( - t.count, + 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)`; @@ -972,8 +1043,16 @@ ball.plugin.hasEntered = true; const spawnFromBottom = currentScene?.config?.spawnFrom === "bottom"; Matter.Body.setPosition(ball, { - x: Math.random() * width, - y: spawnFromBottom ? height + 40 : -40, + x: + currentScene?.config?.spawnOrigin === "center" + ? width / 2 + : Math.random() * width, + y: + currentScene?.config?.spawnOrigin === "center" + ? height / 2 + : spawnFromBottom + ? height + 40 + : -40, }); Matter.Body.setVelocity(ball, { x: 0, y: 0 }); } @@ -982,6 +1061,9 @@ Events.on(engine, "beforeUpdate", () => { // Rope-like constraint handling: allow shortening without push-back, tension when stretched. + if (typeof currentScene?.config?.onBeforeUpdate === "function") { + currentScene.config.onBeforeUpdate({ engine, width, height }); + } chain.constraints.forEach((c) => { if (!c.plugin || !c.plugin.rope) return; const current = Vector.magnitude( diff --git a/src/scenes/index.js b/src/scenes/index.js index b9132b2..aba2356 100644 --- a/src/scenes/index.js +++ b/src/scenes/index.js @@ -6,6 +6,7 @@ "fast-drop-maze", "balanced", "scene-lava", + "swirl-arena", "relax", ]; const orderedScenes = desiredOrder diff --git a/src/scenes/scene-swirl-arena.js b/src/scenes/scene-swirl-arena.js new file mode 100644 index 0000000..73e78b1 --- /dev/null +++ b/src/scenes/scene-swirl-arena.js @@ -0,0 +1,88 @@ +(() => { + const { Bodies } = Matter; + const scenes = (window.PhysilinksSceneDefs = + window.PhysilinksSceneDefs || []); + + scenes.push({ + id: "swirl-arena", + name: "Swirl Arena", + config: { + gravity: 0, + gravityScale: 0.0007, + timeScale: 0.9, + spawnIntervalMs: 520, + initialSpawnCount: 90, + spawnLimit: 500, + autoSpawn: true, + minChain: 3, + palette: ["#f472b6", "#22c55e"], + ballRadius: 20, + spawnOrigin: "center", + spawnJitter: 120, + blobBalls: false, + noGameOver: true, + winCondition: { + type: "colorClear", + targets: [{ color: "#22c55e", count: 100 }], + }, + negativeScoreColors: ["#f472b6"], + negativeProgressColors: ["#f472b6"], + link: { + stiffness: 0.82, + lengthScale: 1.08, + damping: 0.06, + lineWidth: 3, + rope: true, + renderType: "line", + maxLengthMultiplier: 3.6, + }, + onBeforeUpdate: ({ engine }) => { + const t = (engine.timing?.timestamp || 0) * 0.0005; + engine.gravity.x = Math.cos(t); + engine.gravity.y = Math.sin(t); + }, + }, + createBodies: (w, h) => { + const wallThickness = Math.max(36, Math.min(w, h) * 0.05); + const innerW = w - wallThickness * 2; + const innerH = h - wallThickness * 2; + const cx = w / 2; + const cy = h / 2; + const wallOptions = { + isStatic: true, + restitution: 0.3, + render: { fillStyle: "#0b1222", strokeStyle: "#0b1222" }, + }; + return [ + Bodies.rectangle( + cx, + cy - innerH / 2 - wallThickness / 2, + innerW + wallThickness * 2, + wallThickness, + wallOptions, + ), + Bodies.rectangle( + cx, + cy + innerH / 2 + wallThickness / 2, + innerW + wallThickness * 2, + wallThickness, + wallOptions, + ), + Bodies.rectangle( + cx - innerW / 2 - wallThickness / 2, + cy, + wallThickness, + innerH + wallThickness * 2, + wallOptions, + ), + Bodies.rectangle( + cx + innerW / 2 + wallThickness / 2, + cy, + wallThickness, + innerH + wallThickness * 2, + wallOptions, + ), + ]; + }, + }); +})(); diff --git a/src/ui.js b/src/ui.js index e4cb4f4..5de8824 100644 --- a/src/ui.js +++ b/src/ui.js @@ -148,7 +148,8 @@ if (!point || !sceneEl) return; const el = document.createElement("div"); el.className = "floating-score"; - el.textContent = `+${amount}`; + const sign = amount > 0 ? "+" : ""; + el.textContent = `${sign}${amount}`; el.style.left = `${point.x}px`; el.style.top = `${point.y}px`; el.style.color = color || "#e0f2fe";