(() => { const { World, Body, Constraint, Events, Vector } = Matter; const fromWindow = (key, fallback = {}) => window[key] ?? fallback; const getFactory = (key, fallbackFactory) => { const mod = fromWindow(key); return typeof mod.create === "function" ? mod.create : fallbackFactory; }; const createConfig = getFactory("PhysilinksConfig", () => ({ config: {}, defaultMessageConfig: { durationMs: 4200, position: { xPercent: 50, yPercent: 10 }, text: null, colors: null, }, })); const { config: baseConfig, defaultMessageConfig } = createConfig(); const normalizeMessages = ( sceneMessages = {}, defaults = defaultMessageConfig, ) => ({ durationMs: Number.isFinite(sceneMessages.durationMs) ? sceneMessages.durationMs : defaults.durationMs, text: typeof sceneMessages.text === "string" && sceneMessages.text.trim() ? sceneMessages.text.trim() : defaults.text, position: { xPercent: typeof sceneMessages.position?.xPercent === "number" ? sceneMessages.position.xPercent : defaults.position.xPercent, yPercent: typeof sceneMessages.position?.yPercent === "number" ? sceneMessages.position.yPercent : defaults.position.yPercent, }, colors: Array.isArray(sceneMessages.colors) ? sceneMessages.colors : defaults.colors, }); const normalizeSceneConfig = (sceneConfig = {}) => { const { link = {}, messages = {}, ...rest } = sceneConfig; return { ...rest, link: { ...link }, messages: normalizeMessages(messages), }; }; const config = { ...normalizeSceneConfig(baseConfig) }; const setConfigForScene = (sceneConfig = {}) => { const normalized = normalizeSceneConfig(sceneConfig); Object.assign(config, normalized); config.link = { ...normalized.link }; config.messages = normalized.messages; }; const { scenes = [], defaultSceneId, order: sceneOrder = [], } = fromWindow("PhysilinksScenes", {}); const { getSceneById, getSceneIdFromUrl, setSceneIdInUrl, getNextSceneId } = fromWindow("PhysilinksSceneRegistry", {}); const createUI = getFactory("PhysilinksUI"); const ui = createUI(); const { sceneEl } = ui; const state = { width: sceneEl.clientWidth, height: sceneEl.clientHeight, boundaries: [], rotators: [], oscillators: [], balls: [], blobConstraints: new Map(), score: 0, highScore: 0, longestChainRecord: 0, clearedCount: 0, clearedByColor: {}, gameOver: false, paused: false, levelWon: false, timerEndMs: null, lastTimerDisplay: null, }; const BALL_BASELINE = 680; // reference height used for relative ball sizing const createEngine = getFactory("PhysilinksEngine"); const { engine, render, runner, startRunner, stopRunner, setRenderSize } = createEngine({ sceneEl, width: state.width, height: state.height, background: "transparent", wireframes: false, showAngleIndicator: false, }); const defaultGravityScale = engine.gravity.scale; const defaultTimeScale = engine.timing.timeScale || 1; engine.gravity.y = config.gravity; const world = engine.world; const initialSceneId = (getSceneIdFromUrl && getSceneIdFromUrl(scenes)) || (getSceneById && getSceneById(scenes, defaultSceneId) ? defaultSceneId : null) || scenes[0]?.id; let currentScene = (getSceneById && getSceneById(scenes, initialSceneId)) || scenes[0] || null; if (currentScene && currentScene.config) { setConfigForScene(currentScene.config); } const rebuildSceneBodies = () => { state.boundaries.forEach((b) => World.remove(world, b)); const nextBoundaries = currentScene.createBodies(state.width, state.height); state.boundaries.length = 0; state.boundaries.push(...nextBoundaries); state.rotators = state.boundaries.filter( (b) => b.plugin && b.plugin.rotSpeed, ); state.oscillators = state.boundaries.filter( (b) => b.plugin && b.plugin.oscillate, ); World.add(world, state.boundaries); }; let spawnSystem = null; let input = null; const storage = fromWindow("PhysilinksStorage", {}); const { loadHighScore = () => 0, loadLongestChain = () => 0, saveHighScore = () => {}, saveLongestChain = () => {}, } = storage; const resetEngineForScene = ( sceneConfig, { prevRadius, timeScaleOverride, resetPlugins = true } = {}, ) => { if (resetPlugins) { world.plugin ??= {}; world.plugin.stormSteps = Array.isArray(sceneConfig?.spawnIntervals) ? sceneConfig.spawnIntervals : null; world.plugin.squareOffset = 0; engine.plugin ??= {}; engine.plugin.stormState = null; } spawnSystem.updateBallRadius(prevRadius); engine.gravity.scale = typeof sceneConfig?.gravityScale === "number" ? sceneConfig.gravityScale : defaultGravityScale; engine.gravity.x = 0; engine.gravity.y = config.gravity; engine.timing.timeScale = typeof timeScaleOverride === "number" ? timeScaleOverride : typeof sceneConfig?.timeScale === "number" ? sceneConfig.timeScale : defaultTimeScale; }; const applyScene = (sceneId) => { const next = (getSceneById && getSceneById(scenes, sceneId)) || scenes[0]; if (!next) return; currentScene = next; ui.setSceneSelection(next.id); setSceneIdInUrl(next.id); const prevRadius = config.ballRadius; setConfigForScene(next.config); ui.setMessageDefaults(config.messages); resetEngineForScene(next.config, { prevRadius }); state.clearedCount = 0; state.levelWon = false; state.clearedByColor = {}; state.highScore = loadHighScore(next.id); state.longestChainRecord = loadLongestChain(next.id); rebuildSceneBodies(); buildLegend(); restartGame(); updateHud(); }; 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 triggerGameOver = () => { if (currentScene?.config?.noGameOver) return; if (state.gameOver) return; state.gameOver = true; state.paused = false; resetChainVisuals(); spawnSystem?.stopSpawner(); stopRunner(); engine.timing.timeScale = 0; ui.setPauseState(false); ui.showGameOver(state.score); }; const createSpawn = getFactory("PhysilinksSpawn"); spawnSystem = createSpawn({ config, world, balls: state.balls, blobConstraints: state.blobConstraints, getCurrentScene: () => currentScene, getDimensions: () => ({ width: state.width, height: state.height }), isGridScene, triggerGameOver, isGameOver: () => state.gameOver, ballBaseline: BALL_BASELINE, }); const createGoals = getFactory("PhysilinksGoals"); const goals = createGoals({ config, getCurrentScene: () => currentScene, getScore: () => state.score, getClearedCount: () => state.clearedCount, getClearedByColor: () => state.clearedByColor, getTimerEndMs: () => state.timerEndMs, ui, }); const normalizeColor = goals.normalizeColor; const restartGame = () => { spawnSystem.stopSpawner(); state.gameOver = false; state.paused = false; state.levelWon = false; spawnSystem.resetSpawnState(); state.score = 0; state.clearedCount = 0; state.clearedByColor = {}; goals.resetMilestones(); input?.endDrag(); const winCond = currentScene?.config?.winCondition; if (winCond?.type === "timer") { const duration = winCond.durationSec ?? 120; state.timerEndMs = Date.now() + duration * 1000; state.lastTimerDisplay = null; } else { state.timerEndMs = null; state.lastTimerDisplay = null; } resetChainVisuals(); state.balls.forEach((ball) => { spawnSystem.cleanupBall(ball); World.remove(world, ball); }); state.balls.length = 0; ui.hideGameOver(); ui.hideWin(); ui.setPauseState(false); resetEngineForScene(currentScene?.config || {}, { timeScaleOverride: 1, resetPlugins: false, }); startRunner(); updateHud(); if (isGridScene()) { spawnSystem.spawnGridBalls(); } else { const spawnedGrid = spawnSystem.spawnInitialColumns(); if (!spawnedGrid) { spawnSystem.spawnInitialBurst(); } spawnSystem.startSpawner(); } goals.showGoalIntro(); }; const setHighlight = (body, on) => { body.render.lineWidth = on ? 4 : 2; body.render.strokeStyle = on ? "#f8fafc" : "#0b1222"; }; const setPaused = (nextState) => { if (state.gameOver || state.levelWon) return; if (nextState === state.paused) return; state.paused = nextState; ui.setPauseState(state.paused); if (state.paused) { resetChainVisuals(); spawnSystem.stopSpawner(); stopRunner(); engine.timing.timeScale = 0; } else { startRunner(); spawnSystem.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) { state.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) { state.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 = []; state.boundaries.forEach((b) => { if (b.plugin && b.plugin.curve) { World.remove(world, b); } else { remaining.push(b); } }); state.boundaries.length = 0; state.boundaries.push(...remaining); state.rotators = state.rotators.filter((b) => state.boundaries.includes(b), ); state.oscillators = state.oscillators.filter((b) => state.boundaries.includes(b), ); } }; const checkWinCondition = () => { if (state.levelWon) return; const goal = goals.getGoalState(); ui.setGoal(goal || { label: "—", progress: 0 }); if (!goal || !goal.met) return; applyWinEffects(); state.levelWon = true; spawnSystem.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 updateLongestChain = (chainLength) => { if (chainLength <= state.longestChainRecord) return; state.longestChainRecord = chainLength; saveLongestChain(currentScene.id, state.longestChainRecord); console.log( "New longest chain record", chainLength, "scene", currentScene?.id, ); ui.showFloatingMessage(`New chain record: ${chainLength}`, { durationMs: 3600, position: config.messages.position, }); }; const getChainScoreState = () => { const baseGain = 10 * Math.pow(chain.bodies.length, 2); const negativeColors = ( currentScene?.config?.negativeScoreColors || [] ).map(normalizeColor); const negativeProgressColors = ( currentScene?.config?.negativeProgressColors || [] ).map(normalizeColor); const normalizedColor = chain.color ? normalizeColor(chain.color || "") : null; const isNegative = normalizedColor && negativeColors.includes(normalizedColor); const isNegativeProgress = normalizedColor && negativeProgressColors.includes(normalizedColor); const gain = isNegative ? -baseGain : baseGain; return { gain, isNegativeProgress }; }; const removeChainConstraints = () => { chain.constraints.forEach((c) => World.remove(world, c)); }; const removeChainBodies = () => { 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); state.clearedByColor[key] = (state.clearedByColor[key] || 0) + 1; } spawnSystem.cleanupBall(body); World.remove(world, body); } }); blobIds.forEach((id) => { state.balls .filter((b) => b.plugin?.blobId === id) .forEach((b) => { if (b.plugin?.color) { const key = normalizeColor(b.plugin.color); state.clearedByColor[key] = (state.clearedByColor[key] || 0) + 1; } }); spawnSystem.removeBlob(id); }); for (let i = state.balls.length - 1; i >= 0; i -= 1) { if ( chain.bodies.includes(state.balls[i]) || (state.balls[i].plugin?.blobId && blobIds.has(state.balls[i].plugin?.blobId)) ) { state.balls.splice(i, 1); } } }; const applyNegativeProgressPenalty = (chainLength) => { const winCond = currentScene?.config?.winCondition; if (winCond?.type !== "colorClear" || !Array.isArray(winCond.targets)) { return; } winCond.targets.forEach((target) => { const key = normalizeColor(target.color); const current = state.clearedByColor[key] || 0; state.clearedByColor[key] = Math.max(0, current - chainLength); }); }; const resetChainState = () => { chain.active = false; chain.color = null; chain.bodies = []; chain.constraints = []; chain.pointer = null; updateHud(); checkWinCondition(); }; const finishChain = (releasePoint) => { if (!chain.active || state.gameOver || state.paused) return; const chainLength = chain.bodies.length; if (chainLength >= config.minChain) { updateLongestChain(chainLength); const { gain, isNegativeProgress } = getChainScoreState(); state.score += gain; state.clearedCount += chainLength; if (state.score > state.highScore) { state.highScore = state.score; saveHighScore(currentScene.id, state.highScore); } ui.spawnScorePopup(releasePoint || chain.pointer, gain, chain.color); removeChainConstraints(); removeChainBodies(); if (isNegativeProgress) { applyNegativeProgressPenalty(chainLength); } } else { removeChainConstraints(); chain.bodies.forEach((b) => setHighlight(b, false)); } resetChainState(); }; const updateHud = () => { ui.updateHud({ spawnIntervalMs: config.spawnIntervalMs, minChain: config.minChain, chainLength: chain.bodies.length, score: state.score, highScore: state.highScore, activeColor: chain.color, }); const goal = goals.getGoalState(); ui.setGoal(goal || { label: "—", progress: 0 }); goals.maybeAnnounceGoalProgress(goal); }; const createInput = getFactory("PhysilinksInput"); input = createInput({ render, world, balls: state.balls, boundaries: state.boundaries, chain, config, getCurrentScene: () => currentScene, isPaused: () => state.paused, isLevelWon: () => state.levelWon, isGameOver: () => state.gameOver, getMaxLinkDistance, setHighlight, removeLastFromChain, addToChain, finishChain, updateHud, }); const buildLegend = () => { ui.buildLegend(config.palette); }; const clampBodiesIntoView = (prevWidth, prevHeight) => { const scaleX = state.width / (prevWidth || state.width); const scaleY = state.height / (prevHeight || state.height); const margin = config.ballRadius * 1.2; state.balls.forEach((ball) => { const nextX = Math.min( Math.max(ball.position.x * scaleX, margin), Math.max(margin, state.width - margin), ); const nextY = Math.min( ball.position.y * scaleY, Math.max(state.height - margin, margin), ); Body.setPosition(ball, { x: nextX, y: nextY }); Body.setVelocity(ball, { x: 0, y: 0 }); }); resetChainVisuals(); input?.endDrag(); }; const handleResize = () => { const prevWidth = state.width; const prevHeight = state.height; const prevRadius = config.ballRadius; state.width = sceneEl.clientWidth; state.height = sceneEl.clientHeight; setRenderSize(state.width, state.height); spawnSystem.updateBallRadius(prevRadius); clampBodiesIntoView(prevWidth, prevHeight); rebuildSceneBodies(); }; const runSceneBeforeUpdateHook = () => { if ( state.levelWon || typeof currentScene?.config?.onBeforeUpdate !== "function" ) { return; } currentScene.config.onBeforeUpdate({ engine, width: state.width, height: state.height, }); }; const stepRopeConstraints = () => { 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; } }); }; const stepRotators = (dt, timeScale) => { state.rotators.forEach((b) => { const speed = b.plugin.rotSpeed || 0; if (speed !== 0) { Body.rotate(b, speed * ((dt * timeScale) / 1000)); } }); }; const stepOscillators = () => { state.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 }); }); }; const stepTimer = () => { if (!state.timerEndMs) return; const winCond = currentScene?.config?.winCondition; const duration = winCond?.durationSec ?? 120; const now = Date.now(); const remainingMs = Math.max(0, state.timerEndMs - now); const remainingSec = Math.ceil(remainingMs / 1000); if (state.lastTimerDisplay !== remainingSec) { state.lastTimerDisplay = remainingSec; updateHud(); } if (remainingMs <= 0 && !state.levelWon) { checkWinCondition(); } }; Events.on(engine, "afterUpdate", () => { // Keep stray balls within the play area horizontally. state.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 > state.width + 100 || (currentScene?.config?.spawnFrom === "bottom" ? ball.position.y < -500 : ball.position.y > state.height + 500) ) { spawnSystem.cleanupBall(ball); ball.plugin.hasEntered = true; const spawnFromBottom = currentScene?.config?.spawnFrom === "bottom"; Matter.Body.setPosition(ball, { x: currentScene?.config?.spawnOrigin === "center" ? state.width / 2 : Math.random() * state.width, y: currentScene?.config?.spawnOrigin === "center" ? state.height / 2 : spawnFromBottom ? state.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. runSceneBeforeUpdateHook(); stepRopeConstraints(); const dt = (engine.timing && engine.timing.delta) || 16; const timeScale = engine.timing?.timeScale ?? 1; if (state.paused || state.gameOver || timeScale === 0) return; // Rotate any scene rotators slowly. stepRotators(dt, timeScale); stepOscillators(); stepTimer(); }); 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(!state.paused), onRestart: restartGame, onSceneChange: (id) => applyScene(id), onWinNext: () => applyScene( getNextSceneId ? getNextSceneId(scenes, sceneOrder, currentScene) || currentScene?.id : currentScene?.id, ), }); ui.setSceneOptions( scenes, (currentScene && currentScene.id) || defaultSceneId, ); spawnSystem.updateBallRadius(); applyScene((currentScene && currentScene.id) || defaultSceneId); })();