From 466bed56dd373ee8af1a46cebe1b47da9e42345a Mon Sep 17 00:00:00 2001 From: Daddy32 Date: Mon, 15 Dec 2025 17:55:35 +0100 Subject: [PATCH] Extract spawn module --- index.html | 1 + src/main.js | 462 ++++--------------------------------------------- src/spawn.js | 473 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 504 insertions(+), 432 deletions(-) create mode 100644 src/spawn.js diff --git a/index.html b/index.html index 9fb0488..7fa6c0b 100644 --- a/index.html +++ b/index.html @@ -107,6 +107,7 @@ + diff --git a/src/main.js b/src/main.js index 78a65d0..41579fc 100644 --- a/src/main.js +++ b/src/main.js @@ -5,9 +5,7 @@ Runner, World, Body, - Bodies, Constraint, - Composites, Events, Query, Vector, @@ -135,8 +133,6 @@ const balls = []; const blobConstraints = new Map(); - let spawnTimer = null; - let spawnCount = 0; let score = 0; let highScore = 0; let longestChainRecord = 0; @@ -150,6 +146,7 @@ let timerEndMs = null; let lastTimerDisplay = null; let dragConstraint = null; + let spawnSystem = null; const { loadHighScore = () => 0, @@ -206,7 +203,7 @@ typeof next.config.timeScale === "number" ? next.config.timeScale : defaultTimeScale; - updateBallRadius(prevRadius); + spawnSystem.updateBallRadius(prevRadius); engine.gravity.x = 0; engine.gravity.y = config.gravity; clearedCount = 0; @@ -262,291 +259,38 @@ const isGridScene = () => currentScene && currentScene.config && currentScene.config.gridLayouts; - 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 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 columnCount = currentScene?.config?.spawnColumns; - const insets = getSpawnInsets(); - const squareArea = getSquarePlayArea(); - const playWidth = squareArea ? squareArea.size : width; - const playLeft = squareArea ? squareArea.left : 0; - const usableWidth = Math.max(0, playWidth - insets.left - insets.right); - const minX = playLeft + insets.left + config.ballRadius + 2; - const maxX = playLeft + playWidth - insets.right - config.ballRadius - 2; - let x = - centerSpawn?.x ?? - Math.max( - minX, - Math.min(maxX, playLeft + insets.left + Math.random() * usableWidth), - ); - if (Number.isFinite(columnCount) && columnCount > 0) { - const columnWidth = usableWidth / columnCount; - const colIndex = Math.floor(Math.random() * columnCount); - x = playLeft + insets.left + columnWidth * (colIndex + 0.5); - x = Math.max(minX, Math.min(maxX, x)); - } - const spawnFromBottom = currentScene?.config?.spawnFrom === "bottom"; - const defaultTop = squareArea - ? squareArea.top - config.ballRadius * 2 - : -config.ballRadius * 2; - const y = - centerSpawn?.y ?? - (spawnFromBottom ? height + config.ballRadius * 2 : defaultTop); - 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 targetX = Math.min( - Math.max( - config.ballRadius + 10, - x + (i - batchCount / 2) * config.ballRadius * 1.5, - ), - width - config.ballRadius - 10, - ); - const targetY = - y + - i * - (spawnFromBottom - ? -config.ballRadius * 0.5 - : config.ballRadius * 0.5); - if (currentScene?.config?.requireClearSpawn) { - const halfSize = config.ballRadius * 1.05; - const region = { - min: { x: targetX - halfSize, y: targetY - halfSize }, - max: { x: targetX + halfSize, y: targetY + halfSize }, - }; - const hits = Query.region(balls, region); - if (hits && hits.length > 0) { - triggerGameOver(); - return; - } - } - const blob = createBallBodies(targetX, targetY, color); - if (blob.constraints.length > 0 && blob.blobId) { - blobConstraints.set(blob.blobId, blob.constraints); - } - 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); - } - } - spawnCount += batchCount; - }; - - const startSpawner = () => { - if (isGridScene()) return; - if (currentScene?.config?.autoSpawn === false) return; - if (spawnTimer) clearInterval(spawnTimer); - spawnTimer = setInterval(spawnBall, config.spawnIntervalMs); - }; - - const stopSpawner = () => { - if (spawnTimer) { - clearInterval(spawnTimer); - spawnTimer = null; - } - }; - - const spawnInitialBurst = () => { - const initialCount = currentScene?.config?.initialSpawnCount || 0; - if (!initialCount || initialCount <= 0) return; - for (let i = 0; i < initialCount; i += 1) { - spawnBall(); - } - }; - - const getSquarePlayArea = () => { - if (!currentScene?.config?.squarePlayArea) return null; - const size = Math.min(width, height); - const offset = world?.plugin?.squareOffset || 0; - const left = (width - size) / 2 + offset; - const top = (height - size) / 2 + offset; - return { size, left, top }; - }; - - const getSpawnInsets = () => { - let left = 0; - let right = 0; - const insetVal = currentScene?.config?.spawnInset; - if (Number.isFinite(insetVal)) { - left = insetVal; - right = insetVal; - } - if (typeof currentScene?.config?.spawnInsets === "function") { - try { - const res = currentScene.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 spawnInitialColumns = () => { - const rows = currentScene?.config?.initialRows; - const columns = currentScene?.config?.spawnColumns; - if (!Number.isFinite(rows) || rows <= 0) return false; - if (!Number.isFinite(columns) || columns <= 0) return false; - const insets = getSpawnInsets(); - const squareArea = getSquarePlayArea(); - const usableWidth = Math.max( - 0, - (squareArea ? squareArea.size : width) - insets.left - insets.right, - ); - const columnWidth = usableWidth / columns; - const side = config.ballRadius * 2; - const rowGap = side * (currentScene?.config?.rowGapMultiplier ?? 1.05); - const startY = squareArea - ? squareArea.top + config.ballRadius - : config.ballRadius * 1.5; - for (let r = 0; r < rows; r += 1) { - const y = startY + r * rowGap; - for (let c = 0; c < columns; c += 1) { - const x = - (squareArea ? squareArea.left : 0) + - insets.left + - columnWidth * (c + 0.5); - const color = - config.palette[Math.floor(Math.random() * config.palette.length)]; - const blob = createBallBodies(x, y, color); - blob.bodies.forEach((body) => { - body.plugin = body.plugin || {}; - body.plugin.hasEntered = true; - balls.push(body); - World.add(world, body); - }); - if (blob.constraints.length > 0) { - World.add(world, blob.constraints); - } - if (blob.constraints.length > 0 && blob.blobId) { - blobConstraints.set(blob.blobId, blob.constraints); - } - } - } - spawnCount += rows * columns; - return true; - }; - - const cleanupBall = (ball) => { - if (ball.plugin && ball.plugin.entryCheckId) { - clearTimeout(ball.plugin.entryCheckId); - ball.plugin.entryCheckId = null; - } - }; - - 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 = () => { if (currentScene?.config?.noGameOver) return; if (gameOver) return; gameOver = true; isPaused = false; resetChainVisuals(); - stopSpawner(); + spawnSystem?.stopSpawner(); stopRunner(); engine.timing.timeScale = 0; ui.setPauseState(false); ui.showGameOver(score); }; + spawnSystem = window.PhysilinksSpawn.create({ + config, + world, + balls, + blobConstraints, + getCurrentScene: () => currentScene, + getDimensions: () => ({ width, height }), + isGridScene, + triggerGameOver, + isGameOver: () => gameOver, + ballBaseline: BALL_BASELINE, + }); + const restartGame = () => { - stopSpawner(); + spawnSystem.stopSpawner(); gameOver = false; isPaused = false; levelWon = false; - spawnCount = 0; + spawnSystem.resetSpawnState(); score = 0; clearedCount = 0; clearedByColor = {}; @@ -563,7 +307,7 @@ } resetChainVisuals(); balls.forEach((ball) => { - cleanupBall(ball); + spawnSystem.cleanupBall(ball); World.remove(world, ball); }); balls.length = 0; @@ -580,13 +324,13 @@ startRunner(); updateHud(); if (isGridScene()) { - spawnGridBalls(); + spawnSystem.spawnGridBalls(); } else { - const spawnedGrid = spawnInitialColumns(); + const spawnedGrid = spawnSystem.spawnInitialColumns(); if (!spawnedGrid) { - spawnInitialBurst(); + spawnSystem.spawnInitialBurst(); } - startSpawner(); + spawnSystem.startSpawner(); } showGoalIntro(); }; @@ -603,12 +347,12 @@ ui.setPauseState(isPaused); if (isPaused) { resetChainVisuals(); - stopSpawner(); + spawnSystem.stopSpawner(); stopRunner(); engine.timing.timeScale = 0; } else { startRunner(); - startSpawner(); + spawnSystem.startSpawner(); engine.timing.timeScale = 1; } }; @@ -677,7 +421,7 @@ if (!goal || !goal.met) return; applyWinEffects(); levelWon = true; - stopSpawner(); + spawnSystem.stopSpawner(); engine.timing.timeScale = 1; startRunner(); ui.setPauseState(false); @@ -772,7 +516,7 @@ const key = normalizeColor(body.plugin.color); clearedByColor[key] = (clearedByColor[key] || 0) + 1; } - cleanupBall(body); + spawnSystem.cleanupBall(body); World.remove(world, body); } }); @@ -974,152 +718,6 @@ ui.buildLegend(config.palette); }; - 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))); - } - if ( - currentScene?.config?.sizeFromColumns && - Number.isFinite(currentScene?.config?.spawnColumns) && - currentScene.config.spawnColumns > 0 - ) { - const insets = getSpawnInsets(); - const squareArea = getSquarePlayArea(); - const usableWidth = Math.max( - 0, - (squareArea ? squareArea.size : width) - insets.left - insets.right, - ); - const colWidth = usableWidth / currentScene.config.spawnColumns; - // Fit 10 per side; halve to get radius. Cap to a reasonable range. - return Math.round(Math.max(8, Math.min(60, colWidth / 2))); - } - const baseRadius = - (currentScene && currentScene.config && currentScene.config.ballRadius) || - config.ballRadius || - 18; - const dim = Math.max(1, Math.min(width, height)); - const scaled = (baseRadius * dim) / BALL_BASELINE; - return Math.round(Math.max(12, Math.min(52, scaled))); - }; - - const updateBallRadius = (prevRadius) => { - const nextRadius = computeBallRadius(); - if ( - Number.isFinite(prevRadius) && - prevRadius > 0 && - nextRadius !== prevRadius - ) { - const scale = nextRadius / prevRadius; - balls.forEach((ball) => { - Body.scale(ball, scale, scale); - if (ball.plugin?.shape === "rect") return; - if (ball.plugin?.shape === "jagged") { - // Recompute center to keep shape consistent; vertices are scaled by Body.scale - Body.setPosition(ball, ball.position); - return; - } - if (typeof ball.circleRadius === "number") { - ball.circleRadius = nextRadius; - } - }); - } - config.ballRadius = nextRadius; - }; - - const createBallBodies = (x, y, color) => { - const commonOpts = { - restitution: 0.72, - friction: 0.01, - frictionAir: 0.012, - render: { - fillStyle: color, - strokeStyle: "#0b1222", - lineWidth: 2, - }, - }; - if (currentScene?.config?.blobBalls === "soft") { - 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 }; - } - if (currentScene?.config?.blobBalls === "jagged") { - const points = []; - const segments = 6; - for (let i = 0; i < segments; i += 1) { - const angle = Math.min((i / segments) * Math.PI * 2, Math.PI * 2); - const variance = 0.6 + Math.random() * 0.5; - const r = config.ballRadius * variance; - points.push({ - x: x + Math.cos(angle) * r, - y: y + Math.sin(angle) * r, - }); - } - const body = Bodies.fromVertices(x, y, [points], commonOpts, true); - body.plugin = { - color, - hasEntered: false, - entryCheckId: null, - shape: "jagged", - }; - return { bodies: [body], constraints: [], blobId: null }; - } - if (currentScene?.config?.ballShape === "rect") { - const side = config.ballRadius * 2; - const body = Bodies.rectangle(x, y, side, side, { - ...commonOpts, - chamfer: 0, - }); - body.plugin = { - color, - hasEntered: false, - entryCheckId: null, - shape: "rect", - }; - return { bodies: [body], constraints: [], blobId: null }; - } - const body = Bodies.circle(x, y, config.ballRadius, commonOpts); - body.plugin = { - color, - hasEntered: false, - entryCheckId: null, - shape: "circle", - }; - return { bodies: [body], constraints: [], blobId: null }; - }; - const normalizeColor = (c) => (c || "").trim().toLowerCase(); const getGoalState = () => { @@ -1288,7 +886,7 @@ render.bounds.max.x = width; render.bounds.max.y = height; Render.lookAt(render, render.bounds); - updateBallRadius(prevRadius); + spawnSystem.updateBallRadius(prevRadius); clampBodiesIntoView(prevWidth, prevHeight); rebuildSceneBodies(); }; @@ -1309,7 +907,7 @@ ? ball.position.y < -500 : ball.position.y > height + 500) ) { - cleanupBall(ball); + spawnSystem.cleanupBall(ball); ball.plugin.hasEntered = true; const spawnFromBottom = currentScene?.config?.spawnFrom === "bottom"; Matter.Body.setPosition(ball, { @@ -1437,6 +1035,6 @@ scenes, (currentScene && currentScene.id) || defaultSceneId, ); - updateBallRadius(); + spawnSystem.updateBallRadius(); applyScene((currentScene && currentScene.id) || defaultSceneId); })(); diff --git a/src/spawn.js b/src/spawn.js new file mode 100644 index 0000000..a3fe062 --- /dev/null +++ b/src/spawn.js @@ -0,0 +1,473 @@ +(() => { + const { World, Bodies, Composites, Query, Body } = Matter; + + const create = ({ + config, + world, + balls, + blobConstraints, + getCurrentScene, + getDimensions, + isGridScene, + triggerGameOver, + isGameOver, + ballBaseline = 680, + }) => { + let spawnTimer = null; + let spawnCount = 0; + + const cleanupBall = (ball) => { + if (ball.plugin && ball.plugin.entryCheckId) { + clearTimeout(ball.plugin.entryCheckId); + ball.plugin.entryCheckId = null; + } + }; + + const getSquarePlayArea = () => { + const scene = getCurrentScene(); + if (!scene?.config?.squarePlayArea) return null; + const { width, height } = getDimensions(); + const size = Math.min(width, height); + const offset = world?.plugin?.squareOffset || 0; + const left = (width - size) / 2 + offset; + const top = (height - size) / 2 + offset; + return { size, left, top }; + }; + + const getSpawnInsets = () => { + const scene = getCurrentScene(); + let left = 0; + let right = 0; + const insetVal = scene?.config?.spawnInset; + if (Number.isFinite(insetVal)) { + left = insetVal; + right = insetVal; + } + if (typeof scene?.config?.spawnInsets === "function") { + try { + const res = scene.config.spawnInsets({ + ...getDimensions(), + 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 computeBallRadius = () => { + const scene = getCurrentScene(); + const { width, height } = getDimensions(); + if (isGridScene()) { + const padding = scene?.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 = scene?.config?.gridBallScale ?? 0.36; + const scaled = cellSize * scale; + return Math.round(Math.max(10, Math.min(60, scaled))); + } + if ( + scene?.config?.sizeFromColumns && + Number.isFinite(scene?.config?.spawnColumns) && + scene.config.spawnColumns > 0 + ) { + const insets = getSpawnInsets(); + const squareArea = getSquarePlayArea(); + const usableWidth = Math.max( + 0, + (squareArea ? squareArea.size : width) - insets.left - insets.right, + ); + const colWidth = usableWidth / scene.config.spawnColumns; + return Math.round(Math.max(8, Math.min(60, colWidth / 2))); + } + const baseRadius = + (scene && scene.config && scene.config.ballRadius) || + config.ballRadius || + 18; + const dim = Math.max(1, Math.min(width, height)); + const scaled = (baseRadius * dim) / ballBaseline; + return Math.round(Math.max(12, Math.min(52, scaled))); + }; + + const updateBallRadius = (prevRadius) => { + const nextRadius = computeBallRadius(); + if ( + Number.isFinite(prevRadius) && + prevRadius > 0 && + nextRadius !== prevRadius + ) { + const scale = nextRadius / prevRadius; + balls.forEach((ball) => { + Body.scale(ball, scale, scale); + if (ball.plugin?.shape === "rect") return; + if (ball.plugin?.shape === "jagged") { + Body.setPosition(ball, ball.position); + return; + } + if (typeof ball.circleRadius === "number") { + ball.circleRadius = nextRadius; + } + }); + } + config.ballRadius = nextRadius; + }; + + const createBallBodies = (x, y, color) => { + const scene = getCurrentScene(); + const commonOpts = { + restitution: 0.72, + friction: 0.01, + frictionAir: 0.012, + render: { + fillStyle: color, + strokeStyle: "#0b1222", + lineWidth: 2, + }, + }; + if (scene?.config?.blobBalls === "soft") { + 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 }; + } + if (scene?.config?.blobBalls === "jagged") { + const points = []; + const segments = 6; + for (let i = 0; i < segments; i += 1) { + const angle = Math.min((i / segments) * Math.PI * 2, Math.PI * 2); + const variance = 0.6 + Math.random() * 0.5; + const r = config.ballRadius * variance; + points.push({ + x: x + Math.cos(angle) * r, + y: y + Math.sin(angle) * r, + }); + } + const body = Bodies.fromVertices(x, y, [points], commonOpts, true); + body.plugin = { + color, + hasEntered: false, + entryCheckId: null, + shape: "jagged", + }; + return { bodies: [body], constraints: [], blobId: null }; + } + if (scene?.config?.ballShape === "rect") { + const side = config.ballRadius * 2; + const body = Bodies.rectangle(x, y, side, side, { + ...commonOpts, + chamfer: 0, + }); + body.plugin = { + color, + hasEntered: false, + entryCheckId: null, + shape: "rect", + }; + return { bodies: [body], constraints: [], blobId: null }; + } + const body = Bodies.circle(x, y, config.ballRadius, commonOpts); + body.plugin = { + color, + hasEntered: false, + entryCheckId: null, + shape: "circle", + }; + return { bodies: [body], constraints: [], blobId: null }; + }; + + const spawnBall = () => { + if (isGameOver()) return; + const scene = getCurrentScene(); + const sceneConfig = scene?.config || {}; + const { width, height } = getDimensions(); + const spawnLimit = sceneConfig.spawnLimit; + if (Number.isFinite(spawnLimit) && spawnCount >= spawnLimit) { + stopSpawner(); + return; + } + const color = + config.palette[Math.floor(Math.random() * config.palette.length)]; + const spawnOrigin = sceneConfig.spawnOrigin || "edge"; + const spawnJitter = sceneConfig.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 columnCount = sceneConfig.spawnColumns; + const insets = getSpawnInsets(); + const squareArea = getSquarePlayArea(); + const playWidth = squareArea ? squareArea.size : width; + const playLeft = squareArea ? squareArea.left : 0; + const usableWidth = Math.max(0, playWidth - insets.left - insets.right); + const minX = playLeft + insets.left + config.ballRadius + 2; + const maxX = playLeft + playWidth - insets.right - config.ballRadius - 2; + let x = + centerSpawn?.x ?? + Math.max( + minX, + Math.min(maxX, playLeft + insets.left + Math.random() * usableWidth), + ); + if (Number.isFinite(columnCount) && columnCount > 0) { + const columnWidth = usableWidth / columnCount; + const colIndex = Math.floor(Math.random() * columnCount); + x = playLeft + insets.left + columnWidth * (colIndex + 0.5); + x = Math.max(minX, Math.min(maxX, x)); + } + const spawnFromBottom = sceneConfig.spawnFrom === "bottom"; + const defaultTop = squareArea + ? squareArea.top - config.ballRadius * 2 + : -config.ballRadius * 2; + const y = + centerSpawn?.y ?? + (spawnFromBottom ? height + config.ballRadius * 2 : defaultTop); + const batchMin = sceneConfig.spawnBatchMin ?? 1; + const batchMax = sceneConfig.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 targetX = Math.min( + Math.max( + config.ballRadius + 10, + x + (i - batchCount / 2) * config.ballRadius * 1.5, + ), + width - config.ballRadius - 10, + ); + const targetY = + y + + i * + (spawnFromBottom + ? -config.ballRadius * 0.5 + : config.ballRadius * 0.5); + if (sceneConfig.requireClearSpawn) { + const halfSize = config.ballRadius * 1.05; + const region = { + min: { x: targetX - halfSize, y: targetY - halfSize }, + max: { x: targetX + halfSize, y: targetY + halfSize }, + }; + const hits = Query.region(balls, region); + if (hits && hits.length > 0) { + triggerGameOver(); + return; + } + } + const blob = createBallBodies(targetX, targetY, color); + if (blob.constraints.length > 0 && blob.blobId) { + blobConstraints.set(blob.blobId, blob.constraints); + } + blob.bodies.forEach((body) => { + balls.push(body); + World.add(world, body); + if (!sceneConfig.noGameOver) { + 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 += batchCount; + }; + + const startSpawner = () => { + if (isGridScene()) return; + const scene = getCurrentScene(); + if (scene?.config?.autoSpawn === false) return; + if (spawnTimer) clearInterval(spawnTimer); + spawnTimer = setInterval(spawnBall, config.spawnIntervalMs); + }; + + const stopSpawner = () => { + if (spawnTimer) { + clearInterval(spawnTimer); + spawnTimer = null; + } + }; + + const spawnInitialBurst = () => { + const scene = getCurrentScene(); + const initialCount = scene?.config?.initialSpawnCount || 0; + if (!initialCount || initialCount <= 0) return; + for (let i = 0; i < initialCount; i += 1) { + spawnBall(); + } + }; + + const spawnInitialColumns = () => { + const scene = getCurrentScene(); + const sceneConfig = scene?.config || {}; + const rows = sceneConfig.initialRows; + const columns = sceneConfig.spawnColumns; + if (!Number.isFinite(rows) || rows <= 0) return false; + if (!Number.isFinite(columns) || columns <= 0) return false; + const { width, height } = getDimensions(); + const insets = getSpawnInsets(); + const squareArea = getSquarePlayArea(); + const usableWidth = Math.max( + 0, + (squareArea ? squareArea.size : width) - insets.left - insets.right, + ); + const columnWidth = usableWidth / columns; + const side = config.ballRadius * 2; + const rowGap = side * (sceneConfig.rowGapMultiplier ?? 1.05); + const startY = squareArea + ? squareArea.top + config.ballRadius + : config.ballRadius * 1.5; + for (let r = 0; r < rows; r += 1) { + const y = startY + r * rowGap; + for (let c = 0; c < columns; c += 1) { + const x = + (squareArea ? squareArea.left : 0) + + insets.left + + columnWidth * (c + 0.5); + const color = + config.palette[Math.floor(Math.random() * config.palette.length)]; + const blob = createBallBodies(x, y, color); + blob.bodies.forEach((body) => { + body.plugin = body.plugin || {}; + body.plugin.hasEntered = true; + balls.push(body); + World.add(world, body); + }); + if (blob.constraints.length > 0) { + World.add(world, blob.constraints); + } + if (blob.constraints.length > 0 && blob.blobId) { + blobConstraints.set(blob.blobId, blob.constraints); + } + } + } + spawnCount += rows * columns; + return true; + }; + + const getGridDimensions = () => { + const scene = getCurrentScene(); + const { width, height } = getDimensions(); + const padding = scene?.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) => { + const scene = getCurrentScene(); + const sceneConfig = scene?.config || {}; + if (!letter || letter === "." || letter === " ") return null; + const legend = sceneConfig.gridLegend || {}; + if (legend[letter]) return legend[letter]; + const paletteToUse = sceneConfig.palette || config.palette; + const index = + (letter.toUpperCase().charCodeAt(0) - 65) % paletteToUse.length; + return paletteToUse[index]; + }; + + const spawnGridBalls = () => { + const scene = getCurrentScene(); + const sceneConfig = scene?.config || {}; + const layouts = sceneConfig.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 resetSpawnState = () => { + spawnCount = 0; + }; + + return { + startSpawner, + stopSpawner, + spawnInitialBurst, + spawnInitialColumns, + spawnGridBalls, + updateBallRadius, + computeBallRadius, + cleanupBall, + resetSpawnState, + }; + }; + + window.PhysilinksSpawn = { create }; +})();