(() => { const { Engine, Render, Runner, World, Body, Bodies, Constraint, Composites, Events, Query, Vector, } = Matter; const { scenes = [], defaultSceneId } = window.PhysilinksScenes || {}; const getSceneById = (sceneId) => scenes.find((candidate) => candidate.id === sceneId) || null; const getSceneIdFromUrl = () => { try { const params = new URLSearchParams(window.location.search); const urlScene = params.get("scene"); return getSceneById(urlScene) ? urlScene : null; } catch (err) { return null; } }; const setSceneIdInUrl = (sceneId) => { if (!sceneId) return; try { const url = new URL(window.location.href); url.searchParams.set("scene", sceneId); history.replaceState({}, "", `${url.pathname}${url.search}${url.hash}`); } catch (err) { // ignore history failures (blocked or unsupported) } }; const defaultMessageConfig = { durationMs: 4200, position: { xPercent: 50, yPercent: 10 }, text: null, colors: null, }; const config = { gravity: 1, spawnIntervalMs: 520, autoSpawn: true, minChain: 3, palette: ["#ff595e", "#ffca3a", "#8ac926", "#1982c4", "#6a4c93"], ballRadius: 18, link: { stiffness: 0.85, lengthScale: 1.05, // max stretch factor; slack below this damping: 0.08, lineWidth: 3, rope: true, renderType: "line", maxLengthMultiplier: 3.1, }, messages: { ...defaultMessageConfig }, }; const ui = window.PhysilinksUI.create(); const { sceneEl } = ui; let width = sceneEl.clientWidth; let height = sceneEl.clientHeight; 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; const render = Render.create({ element: sceneEl, engine, options: { width, height, wireframes: false, background: "transparent", showAngleIndicator: false, pixelRatio: window.devicePixelRatio || 1, }, }); Render.run(render); const runner = Runner.create(); Runner.run(runner, engine); let runnerActive = true; const startRunner = () => { if (!runnerActive) { Runner.run(runner, engine); runnerActive = true; } }; const stopRunner = () => { if (runnerActive) { Runner.stop(runner); runnerActive = false; } }; // Static boundaries and scene-specific obstacles. let boundaries = []; let rotators = []; let oscillators = []; const initialSceneId = getSceneIdFromUrl() || (getSceneById(defaultSceneId) ? defaultSceneId : null) || scenes[0]?.id; let currentScene = getSceneById(initialSceneId) || scenes[0] || null; if (currentScene && currentScene.config) { Object.assign(config, currentScene.config); config.link = { ...currentScene.config.link }; } const rebuildSceneBodies = () => { boundaries.forEach((b) => World.remove(world, b)); boundaries = currentScene.createBodies(width, height); rotators = boundaries.filter((b) => b.plugin && b.plugin.rotSpeed); oscillators = boundaries.filter((b) => b.plugin && b.plugin.oscillate); World.add(world, boundaries); }; const balls = []; const blobConstraints = new Map(); let spawnTimer = null; let spawnCount = 0; let score = 0; let highScore = 0; let longestChainRecord = 0; let clearedCount = 0; let clearedByColor = {}; let gameOver = false; let isPaused = false; let levelWon = false; let timerEndMs = null; let lastTimerDisplay = null; let dragConstraint = null; const makeStorageKey = (sceneId) => `physilinks-highscore-${sceneId}`; const makeChainKey = (sceneId) => `physilinks-longestchain-${sceneId}`; const loadHighScore = (sceneId) => { try { const raw = localStorage.getItem(makeStorageKey(sceneId)); const parsed = parseInt(raw, 10); return Number.isFinite(parsed) ? parsed : 0; } catch (err) { return 0; } }; const loadLongestChain = (sceneId) => { try { const raw = localStorage.getItem(makeChainKey(sceneId)); const parsed = parseInt(raw, 10); return Number.isFinite(parsed) ? parsed : 0; } catch (err) { return 0; } }; const saveHighScore = () => { try { localStorage.setItem(makeStorageKey(currentScene.id), String(highScore)); } catch (err) { // ignore write failures (private mode or blocked storage) } }; const saveLongestChain = () => { try { localStorage.setItem( makeChainKey(currentScene.id), String(longestChainRecord), ); } catch (err) { // ignore write failures } }; const applyScene = (sceneId) => { const next = getSceneById(sceneId) || scenes[0]; if (!next) return; currentScene = next; ui.setSceneSelection(next.id); setSceneIdInUrl(next.id); const prevRadius = config.ballRadius; Object.assign(config, next.config); config.link = { ...(next.config?.link || {}) }; const sceneMessages = next.config?.messages || {}; config.messages = { durationMs: Number.isFinite(sceneMessages.durationMs) ? sceneMessages.durationMs : defaultMessageConfig.durationMs, text: typeof sceneMessages.text === "string" && sceneMessages.text.trim() ? sceneMessages.text.trim() : defaultMessageConfig.text, position: { xPercent: typeof sceneMessages.position?.xPercent === "number" ? sceneMessages.position.xPercent : defaultMessageConfig.position.xPercent, yPercent: typeof sceneMessages.position?.yPercent === "number" ? sceneMessages.position.yPercent : defaultMessageConfig.position.yPercent, }, colors: Array.isArray(sceneMessages.colors) ? sceneMessages.colors : defaultMessageConfig.colors, }; ui.setMessageDefaults(config.messages); 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; clearedByColor = {}; highScore = loadHighScore(next.id); longestChainRecord = loadLongestChain(next.id); rebuildSceneBodies(); buildLegend(); restartGame(); updateHud(); }; const getNextSceneId = () => { const order = window.PhysilinksScenes?.order || []; const currentId = currentScene?.id; const orderedExisting = order.filter((id) => scenes.some((s) => s.id === id), ); if (orderedExisting.length > 0 && currentId) { const idx = orderedExisting.indexOf(currentId); if (idx >= 0 && idx < orderedExisting.length - 1) { return orderedExisting[idx + 1]; } if (idx === orderedExisting.length - 1) { return orderedExisting[0]; } } const fallbackIdx = scenes.findIndex((s) => s.id === currentId); if (fallbackIdx >= 0 && fallbackIdx < scenes.length - 1) { return scenes[fallbackIdx + 1].id; } return scenes[0]?.id || defaultSceneId; }; const chain = { active: false, color: null, bodies: [], constraints: [], pointer: null, }; const getMaxLinkDistance = () => { const linkCfg = config.link || {}; if (Number.isFinite(linkCfg.maxLinkLength)) { return linkCfg.maxLinkLength; } const mult = linkCfg.maxLengthMultiplier ?? 3; return mult * config.ballRadius; }; 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 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 = centerSpawn?.y ?? (spawnFromBottom ? height + config.ballRadius * 2 : -config.ballRadius * 2); 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); } 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 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(); stopRunner(); engine.timing.timeScale = 0; ui.setPauseState(false); ui.showGameOver(score); }; const restartGame = () => { stopSpawner(); gameOver = false; isPaused = false; levelWon = false; spawnCount = 0; score = 0; clearedCount = 0; clearedByColor = {}; endDrag(); 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); World.remove(world, ball); }); balls.length = 0; ui.hideGameOver(); ui.hideWin(); ui.setPauseState(false); engine.gravity.scale = typeof currentScene?.config?.gravityScale === "number" ? currentScene.config.gravityScale : defaultGravityScale; engine.gravity.x = 0; engine.gravity.y = config.gravity; engine.timing.timeScale = 1; startRunner(); updateHud(); if (isGridScene()) { spawnGridBalls(); } else { spawnInitialBurst(); startSpawner(); } announceGoalMessage(); }; const setHighlight = (body, on) => { body.render.lineWidth = on ? 4 : 2; body.render.strokeStyle = on ? "#f8fafc" : "#0b1222"; }; const setPaused = (state) => { if (gameOver || levelWon) return; if (state === isPaused) return; isPaused = state; ui.setPauseState(isPaused); if (isPaused) { resetChainVisuals(); stopSpawner(); stopRunner(); engine.timing.timeScale = 0; } else { startRunner(); startSpawner(); engine.timing.timeScale = 1; } }; const resetChainVisuals = () => { chain.bodies.forEach((b) => setHighlight(b, false)); chain.constraints.forEach((c) => World.remove(world, c)); chain.active = false; chain.color = null; chain.bodies = []; chain.constraints = []; chain.pointer = null; updateHud(); }; const applyWinEffects = () => { const winCond = currentScene?.config?.winCondition; if (!winCond || !winCond.onWin) return; if (winCond.onWin.shoveBalls || winCond.onWin.disableGravity) { engine.gravity.x = 0; engine.gravity.y = 0; engine.gravity.scale = 0; } if (typeof winCond.onWin.setGravity === "number") { engine.gravity.y = winCond.onWin.setGravity; } if (winCond.onWin.shoveBalls) { balls.forEach((ball) => { const angle = Math.random() * Math.PI * 2; const magnitude = 12 + Math.random() * 10; const force = { x: Math.cos(angle) * magnitude, y: Math.sin(angle) * magnitude, }; Body.applyForce(ball, ball.position, force); }); } if (winCond.onWin.swirlBalls) { balls.forEach((ball) => { const angle = Math.random() * Math.PI * 2; const mag = 0.06; Body.applyForce(ball, ball.position, { x: Math.cos(angle) * mag, y: -Math.abs(Math.sin(angle)) * mag * 1.5, }); Body.setAngularVelocity(ball, (Math.random() - 0.5) * 0.3); }); } if (winCond.onWin.removeCurves) { const remaining = []; boundaries.forEach((b) => { if (b.plugin && b.plugin.curve) { World.remove(world, b); } else { remaining.push(b); } }); boundaries = remaining; } }; const checkWinCondition = () => { if (levelWon) return; const goal = getGoalState(); ui.setGoal(goal || { label: "—", progress: 0 }); if (!goal || !goal.met) return; applyWinEffects(); levelWon = true; stopSpawner(); engine.timing.timeScale = 1; startRunner(); ui.setPauseState(false); ui.showWin(goal.label.replace("left", "done")); }; const removeLastFromChain = () => { const removedConstraint = chain.constraints.pop(); if (removedConstraint) { World.remove(world, removedConstraint); } const removedBody = chain.bodies.pop(); if (removedBody) setHighlight(removedBody, false); updateHud(); }; const addToChain = (body) => { const last = chain.bodies[chain.bodies.length - 1]; const dist = Vector.magnitude(Vector.sub(last.position, body.position)); const linkCfg = config.link || {}; const constraint = Constraint.create({ bodyA: last, bodyB: body, length: dist * (linkCfg.lengthScale ?? 1), stiffness: linkCfg.stiffness ?? 0.9, damping: linkCfg.damping ?? 0, render: { strokeStyle: chain.color, lineWidth: linkCfg.lineWidth ?? 3, type: linkCfg.renderType || "line", }, }); constraint.plugin = { restLength: dist * (linkCfg.lengthScale ?? 1), rope: linkCfg.rope ?? false, baseStiffness: linkCfg.stiffness ?? 0.9, maxLength: dist * (linkCfg.lengthScale ?? 1), }; chain.constraints.push(constraint); chain.bodies.push(body); setHighlight(body, true); World.add(world, constraint); updateHud(); }; const finishChain = (releasePoint) => { if (!chain.active || gameOver || isPaused) return; if (chain.bodies.length >= config.minChain) { const chainLength = chain.bodies.length; if (chainLength > longestChainRecord) { longestChainRecord = chainLength; saveLongestChain(); ui.showFloatingMessage(`New chain record: ${chainLength}`, { durationMs: 3600, position: config.messages.position, }); } 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) { highScore = score; saveHighScore(); } ui.spawnScorePopup(releasePoint || chain.pointer, gain, chain.color); chain.constraints.forEach((c) => World.remove(world, c)); const blobIds = new Set(); chain.bodies.forEach((body) => { if (body.plugin?.blobId) { blobIds.add(body.plugin.blobId); } else { if (body.plugin?.color) { const key = normalizeColor(body.plugin.color); clearedByColor[key] = (clearedByColor[key] || 0) + 1; } cleanupBall(body); World.remove(world, body); } }); blobIds.forEach((id) => { balls .filter((b) => b.plugin?.blobId === id) .forEach((b) => { if (b.plugin?.color) { const key = normalizeColor(b.plugin.color); clearedByColor[key] = (clearedByColor[key] || 0) + 1; } }); removeBlob(id); }); // Remove cleared balls from tracking list. for (let i = balls.length - 1; i >= 0; i -= 1) { if ( chain.bodies.includes(balls[i]) || (balls[i].plugin?.blobId && blobIds.has(balls[i].plugin?.blobId)) ) { 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)); } chain.active = false; chain.color = null; chain.bodies = []; chain.constraints = []; chain.pointer = null; updateHud(); checkWinCondition(); }; const pickBody = (point) => { const hits = Query.point(balls, point); return hits[0]; }; const getDraggableBody = (point) => { const draggables = [ ...boundaries.filter((b) => b.plugin?.draggable), ...balls.filter((b) => b.plugin?.draggable), ]; const hits = Query.point(draggables, point); return hits[0]; }; const startDrag = (body, point) => { endDrag(); dragConstraint = Constraint.create({ pointA: point, bodyB: body, stiffness: 0.2, damping: 0.3, render: { visible: false }, }); World.add(world, dragConstraint); }; const updateDrag = (point) => { if (!dragConstraint) return; dragConstraint.pointA = point; }; const endDrag = () => { if (dragConstraint) { World.remove(world, dragConstraint); dragConstraint = null; } }; const getPointerPosition = (evt) => { const rect = render.canvas.getBoundingClientRect(); const clientX = evt.touches ? evt.touches[0].clientX : evt.clientX; const clientY = evt.touches ? evt.touches[0].clientY : evt.clientY; return { x: clientX - rect.left, y: clientY - rect.top, }; }; const handlePointerDown = (evt) => { if (gameOver || isPaused || levelWon) return; const point = getPointerPosition(evt); const dragTarget = getDraggableBody(point); if (dragTarget) { startDrag(dragTarget, point); return; } const body = pickBody(point); if (!body) return; if (!currentScene?.config?.relaxMode) { // Only allow linking same colors unless relax mode explicitly opts out. chain.color = body.plugin.color; } else { chain.color = body.plugin.color; } chain.active = true; chain.bodies = [body]; chain.constraints = []; chain.pointer = point; setHighlight(body, true); updateHud(); }; const handlePointerMove = (evt) => { if (dragConstraint) { updateDrag(getPointerPosition(evt)); return; } if (!chain.active) return; if (gameOver || isPaused || levelWon) return; const point = getPointerPosition(evt); chain.pointer = point; const body = pickBody(point); if (!body) return; const alreadyInChain = chain.bodies.includes(body); if (alreadyInChain) { const targetIndex = chain.bodies.indexOf(body); if (chain.bodies.length > 1 && targetIndex === chain.bodies.length - 2) { removeLastFromChain(); } return; } if (!currentScene?.config?.relaxMode && body.plugin.color !== chain.color) return; const maxLinkDist = getMaxLinkDistance(); const dist = Vector.magnitude( Vector.sub(chain.bodies[chain.bodies.length - 1].position, body.position), ); if (dist > maxLinkDist) return; addToChain(body); }; const handlePointerUp = () => { if (dragConstraint) { endDrag(); return; } finishChain(chain.pointer); }; render.canvas.addEventListener("mousedown", handlePointerDown); render.canvas.addEventListener("mousemove", handlePointerMove); window.addEventListener("mouseup", handlePointerUp); render.canvas.addEventListener( "touchstart", (e) => { e.preventDefault(); handlePointerDown(e); }, { passive: false }, ); render.canvas.addEventListener( "touchmove", (e) => { e.preventDefault(); handlePointerMove(e); }, { passive: false }, ); render.canvas.addEventListener( "touchend", (e) => { e.preventDefault(); handlePointerUp(e); }, { passive: false }, ); const updateHud = () => { ui.updateHud({ spawnIntervalMs: config.spawnIntervalMs, minChain: config.minChain, chainLength: chain.bodies.length, score, highScore, activeColor: chain.color, }); const goal = getGoalState(); ui.setGoal(goal || { label: "—", progress: 0 }); }; const buildLegend = () => { 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))); } 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); 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 }; return { bodies: [body], constraints: [], blobId: null }; } const body = Bodies.circle(x, y, config.ballRadius, commonOpts); body.plugin = { color, hasEntered: false, entryCheckId: null }; return { bodies: [body], constraints: [], blobId: null }; }; const normalizeColor = (c) => (c || "").trim().toLowerCase(); 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); return { label: `Clear ${target} balls (${remaining} left)`, progress: target > 0 ? (100 * clearedCount) / target : 0, met: clearedCount >= target, }; } if (winCond.type === "score") { const target = winCond.target ?? 0; const remaining = Math.max(0, target - score); return { label: `Score ${target} (${remaining} left)`, progress: target > 0 ? (100 * score) / target : 0, met: score >= target, }; } if (winCond.type === "colorClear" && Array.isArray(winCond.targets)) { const targets = winCond.targets.map((t) => ({ color: normalizeColor(t.color), count: t.count || 0, })); const totalTarget = targets.reduce((sum, t) => sum + t.count, 0); let totalAchieved = 0; const parts = targets.map((t) => { 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)`; }); return { label: parts.join(" • "), progress: totalTarget > 0 ? (100 * totalAchieved) / totalTarget : 0, met: targets.every( (t) => (clearedByColor[normalizeColor(t.color)] || 0) >= (t.count || 0), ), colors: targets.map((t) => t.color), }; } return null; }; const announceGoalMessage = () => { const goal = getGoalState(); const text = config.messages?.text || (goal && goal.label && goal.label !== "—" ? goal.label : null); if (!text) return; const colors = (Array.isArray(config.messages?.colors) && config.messages.colors) || goal?.colors || null; ui.showFloatingMessage( { text, colors }, { durationMs: config.messages.durationMs, position: config.messages.position, }, ); }; const clampBodiesIntoView = (prevWidth, prevHeight) => { const scaleX = width / (prevWidth || width); const scaleY = height / (prevHeight || height); const margin = config.ballRadius * 1.2; balls.forEach((ball) => { const nextX = Math.min( Math.max(ball.position.x * scaleX, margin), Math.max(margin, width - margin), ); const nextY = Math.min( ball.position.y * scaleY, Math.max(height - margin, margin), ); Body.setPosition(ball, { x: nextX, y: nextY }); Body.setVelocity(ball, { x: 0, y: 0 }); }); resetChainVisuals(); endDrag(); }; const handleResize = () => { const prevWidth = width; const prevHeight = height; const prevRadius = config.ballRadius; width = sceneEl.clientWidth; height = sceneEl.clientHeight; const pixelRatio = window.devicePixelRatio || 1; render.options.width = width; render.options.height = height; render.options.pixelRatio = pixelRatio; render.canvas.style.width = `${width}px`; render.canvas.style.height = `${height}px`; render.canvas.width = width * pixelRatio; render.canvas.height = height * pixelRatio; Render.setPixelRatio(render, pixelRatio); render.bounds.min.x = 0; render.bounds.min.y = 0; render.bounds.max.x = width; render.bounds.max.y = height; Render.lookAt(render, render.bounds); updateBallRadius(prevRadius); clampBodiesIntoView(prevWidth, prevHeight); rebuildSceneBodies(); }; Events.on(engine, "afterUpdate", () => { // Keep stray balls within the play area horizontally. balls.forEach((ball) => { if ( !ball.plugin.hasEntered && ball.position.y > config.ballRadius * 1.5 ) { ball.plugin.hasEntered = true; } if ( ball.position.x < -100 || ball.position.x > width + 100 || (currentScene?.config?.spawnFrom === "bottom" ? ball.position.y < -500 : ball.position.y > height + 500) ) { cleanupBall(ball); ball.plugin.hasEntered = true; const spawnFromBottom = currentScene?.config?.spawnFrom === "bottom"; Matter.Body.setPosition(ball, { 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 }); } }); }); Events.on(engine, "beforeUpdate", () => { // Rope-like constraint handling: allow shortening without push-back, tension when stretched. if ( !levelWon && 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( Vector.sub(c.bodyA.position, c.bodyB.position), ); const maxLen = c.plugin.maxLength ?? c.length; if (current <= maxLen) { c.length = current; c.stiffness = 0; } else { c.length = maxLen; c.stiffness = c.plugin.baseStiffness ?? c.stiffness; } }); // Rotate any scene rotators slowly. const dt = (engine.timing && engine.timing.delta) || 16; const timeScale = engine.timing?.timeScale ?? 1; if (isPaused || gameOver || timeScale === 0) return; rotators.forEach((b) => { const speed = b.plugin.rotSpeed || 0; if (speed !== 0) { Body.rotate(b, speed * ((dt * timeScale) / 1000)); } }); oscillators.forEach((b) => { const osc = b.plugin.oscillate; if (!osc) return; if (!osc.base) { osc.base = { x: b.position.x, y: b.position.y }; } const now = (engine.timing.timestamp || 0) / 1000; const amplitude = osc.amplitude ?? 0; const speed = osc.speed ?? 1; const phase = osc.phase ?? 0; const offset = Math.sin(now * speed + phase) * amplitude; const target = osc.axis === "x" ? { x: osc.base.x + offset, y: osc.base.y } : { x: osc.base.x, y: osc.base.y + offset }; Body.setPosition(b, target); Body.setVelocity(b, { x: 0, y: 0 }); }); 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(); } } }); Events.on(render, "afterRender", () => { if (!chain.active || !chain.pointer || chain.bodies.length === 0) return; const last = chain.bodies[chain.bodies.length - 1]; const ctx = render.context; const maxLinkDist = getMaxLinkDistance(); const delta = Vector.sub(chain.pointer, last.position); const dist = Vector.magnitude(delta); // Pull back the preview line slightly so it does not suggest a link will land on a too-distant ball. const previewMargin = config.ballRadius; const cappedDist = Math.max( 0, Math.min(dist, Math.max(0, maxLinkDist - previewMargin)), ); const safeTarget = dist > 0 ? Vector.add( last.position, Vector.mult(Vector.normalise(delta), cappedDist), ) : chain.pointer; ctx.save(); ctx.strokeStyle = chain.color || "#fff"; ctx.lineWidth = 3; ctx.setLineDash([6, 6]); ctx.beginPath(); ctx.moveTo(last.position.x, last.position.y); ctx.lineTo(safeTarget.x, safeTarget.y); ctx.stroke(); ctx.restore(); }); window.addEventListener("resize", handleResize); ui.setHandlers({ onPauseToggle: () => setPaused(!isPaused), onRestart: restartGame, onSceneChange: (id) => applyScene(id), onWinNext: () => applyScene(getNextSceneId()), }); ui.setSceneOptions( scenes, (currentScene && currentScene.id) || defaultSceneId, ); updateBallRadius(); applyScene((currentScene && currentScene.id) || defaultSceneId); })();