(() => { 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 createSoftBlob = (x, y, color, 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 }; }; const createJaggedBall = (x, y, color, commonOpts) => { 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 }; }; const createGiftBall = (x, y, color, commonOpts, scene, debugSpawn) => { const size = config.ballRadius * 2; const ribbonSize = Math.max(4, config.ballRadius * 0.35); const ribbonColor = scene?.config?.giftRibbonColor || "#f8fafc"; if (debugSpawn) { console.log("Spawn gift", { sceneId: scene?.id, x, y, color, size, }); } const base = Bodies.rectangle(x, y, size, size, { ...commonOpts, chamfer: { radius: Math.max(2, config.ballRadius * 0.18) }, }); const ribbonRender = { fillStyle: ribbonColor, strokeStyle: "#0b1222", lineWidth: 2, }; const verticalRibbon = Bodies.rectangle(x, y, ribbonSize, size * 1.02, { render: ribbonRender, }); const horizontalRibbon = Bodies.rectangle( x, y, size * 1.02, ribbonSize, { render: ribbonRender, }, ); const bow = Bodies.rectangle( x, y - size * 0.34, ribbonSize * 1.4, ribbonSize * 0.8, { render: ribbonRender, }, ); const body = Body.create({ parts: [base, verticalRibbon, horizontalRibbon, bow], }); Body.setPosition(body, { x, y }); body.restitution = commonOpts.restitution; body.friction = commonOpts.friction; body.frictionAir = commonOpts.frictionAir; body.frictionStatic = commonOpts.frictionStatic; body.density = commonOpts.density; body.render = { ...body.render, ...commonOpts.render, visible: true, }; body.plugin = { color, hasEntered: false, entryCheckId: null, shape: "gift", }; return { bodies: [body], constraints: [], blobId: null }; }; const createRectBall = (x, y, color, commonOpts) => { 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 createCircleBall = (x, y, color, commonOpts) => { 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 ballShapeFactories = { gift: createGiftBall, rect: createRectBall, circle: createCircleBall, }; const createBallBodies = (x, y, color) => { const scene = getCurrentScene(); const ballPhysics = scene?.config?.ballPhysics || {}; const debugSpawn = !!scene?.config?.debugSpawn; const commonOpts = { 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", lineWidth: 2, }, }; if (scene?.config?.blobBalls === "soft") { return createSoftBlob(x, y, color, commonOpts); } if (scene?.config?.blobBalls === "jagged") { return createJaggedBall(x, y, color, commonOpts); } const shape = scene?.config?.ballShape || "circle"; const factory = ballShapeFactories[shape] || createCircleBall; return factory(x, y, color, commonOpts, scene, debugSpawn); }; const spawnBall = () => { if (isGameOver()) return; const scene = getCurrentScene(); const sceneConfig = scene?.config || {}; if (sceneConfig.debugSpawn) { console.log("Spawn tick", { sceneId: scene?.id, ballShape: sceneConfig.ballShape, }); } 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 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(); 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; if (scene?.config?.debugSpawn) { console.log("Initial burst", { sceneId: scene?.id, initialCount, }); } const areaSource = scene?.config?.initialSpawnArea; let area = null; if (typeof areaSource === "function") { try { area = areaSource({ ...getDimensions(), world }); } catch (err) { console.error("initialSpawnArea function failed", err); } } if (scene?.config?.debugSpawn) { console.log("Initial spawn area", { sceneId: scene?.id, area, }); } if (area && Number.isFinite(area.xMin) && Number.isFinite(area.xMax)) { const { width, height } = getDimensions(); const pad = config.ballRadius + 4; const minX = Math.max(pad, Math.min(area.xMin, area.xMax)); const maxX = Math.min(width - pad, Math.max(area.xMin, area.xMax)); const minY = Math.max(pad, Math.min(area.yMin, area.yMax)); const maxY = Math.min(height - pad, Math.max(area.yMin, area.yMax)); if (scene?.config?.debugSpawn) { console.log("Initial spawn bounds", { sceneId: scene?.id, minX, maxX, minY, maxY, }); } for (let i = 0; i < initialCount; i += 1) { const x = minX + Math.random() * Math.max(0, maxX - minX); const y = minY + Math.random() * Math.max(0, maxY - minY); spawnAtPosition({ x, y, markEntered: true }); } 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 removeBlob = (blobId) => { if (!blobId) return; const constraints = blobConstraints.get(blobId) || []; constraints.forEach((c) => World.remove(world, c)); blobConstraints.delete(blobId); for (let i = balls.length - 1; i >= 0; i -= 1) { const ball = balls[i]; if (ball.plugin?.blobId === blobId) { cleanupBall(ball); World.remove(world, ball); balls.splice(i, 1); } } }; const resetSpawnState = () => { spawnCount = 0; }; const spawnRowAtY = ({ y, columns: columnsOverride, jitter = 0, markEntered = false, } = {}) => { const scene = getCurrentScene(); const sceneConfig = scene?.config || {}; const columns = Number.isFinite(columnsOverride) && columnsOverride > 0 ? columnsOverride : sceneConfig.spawnColumns; if (!Number.isFinite(columns) || columns <= 0) return 0; 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 minX = (squareArea ? squareArea.left : 0) + insets.left + config.ballRadius + 2; const maxX = (squareArea ? squareArea.left + squareArea.size : width) - insets.right - config.ballRadius - 2; const baseY = typeof y === "number" ? y : (squareArea ? squareArea.top + squareArea.size : height) - config.ballRadius * 1.1; for (let c = 0; c < columns; c += 1) { const jitterX = (Math.random() - 0.5) * jitter; const x = (squareArea ? squareArea.left : 0) + insets.left + columnWidth * (c + 0.5) + jitterX; const safeX = Math.max(minX, Math.min(maxX, x)); const color = config.palette[Math.floor(Math.random() * config.palette.length)]; const blob = createBallBodies(safeX, baseY, color); 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 (blob.constraints.length > 0) { World.add(world, blob.constraints); } if (blob.constraints.length > 0 && blob.blobId) { blobConstraints.set(blob.blobId, blob.constraints); } } spawnCount += columns; return columns; }; return { startSpawner, stopSpawner, spawnInitialBurst, spawnInitialColumns, spawnGridBalls, updateBallRadius, computeBallRadius, cleanupBall, removeBlob, resetSpawnState, spawnRowAtY, spawnAtPosition, }; }; window.PhysilinksSpawn = { create }; })();