(() => { const { World, Body } = Matter; const load = (key, { create, fallback = {} } = {}) => { const mod = window[key] ?? fallback; if (!create) return mod; return typeof mod[create] === "function" ? mod[create].bind(mod) : fallback; }; const createConfig = load("PhysilinksConfig", { create: "create", fallback: () => ({ 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 normalizeLink = (link = {}, defaults = baseConfig.link || {}) => ({ ...defaults, ...link, clearAnimation: { ...(defaults.clearAnimation || {}), ...(link.clearAnimation || {}), }, glowEffect: { ...(defaults.glowEffect || {}), ...(link.glowEffect || {}), }, sparkles: { ...(defaults.sparkles || {}), ...(link.sparkles || {}), }, }); const normalizeGoalEffects = ( goalEffects = {}, defaults = baseConfig.goalEffects || {}, ) => ({ ...defaults, ...goalEffects, }); const normalizeBackdrop = ( backdrop = {}, defaults = baseConfig.backdrop || {}, ) => ({ ...defaults, ...backdrop, colors: Array.isArray(backdrop.colors) ? [...backdrop.colors] : Array.isArray(defaults.colors) ? [...defaults.colors] : null, }); const normalizeSounds = (sounds = {}, defaults = baseConfig.sounds || {}) => { const defaultChannels = defaults.channels || {}; const overrideChannels = sounds.channels || {}; const mergedChannels = { ...defaultChannels }; Object.keys(overrideChannels).forEach((key) => { mergedChannels[key] = { ...(defaultChannels[key] || {}), ...(overrideChannels[key] || {}), }; }); return { ...defaults, ...sounds, channels: mergedChannels, }; }; const normalizeSceneConfig = (sceneConfig = {}, defaults = baseConfig) => { const { link = {}, messages = {}, goalEffects = {}, backdrop = {}, sounds = {}, ...rest } = sceneConfig; const base = defaults || {}; return { ...base, ...rest, link: normalizeLink(link, base.link), messages: normalizeMessages( messages, base.messages || defaultMessageConfig, ), goalEffects: normalizeGoalEffects(goalEffects, base.goalEffects), backdrop: normalizeBackdrop(backdrop, base.backdrop), sounds: normalizeSounds(sounds, base.sounds), }; }; const config = { ...normalizeSceneConfig(baseConfig) }; const setConfigForScene = (sceneConfig) => Object.assign(config, normalizeSceneConfig(sceneConfig)); const scenesMod = load("PhysilinksScenes", { fallback: {} }); const { scenes = [], defaultSceneId, order: sceneOrder = [] } = scenesMod; const registry = load("PhysilinksSceneRegistry", { fallback: {} }); const { getSceneById, getSceneIdFromUrl, setSceneIdInUrl, getNextSceneId } = registry; const createUI = load("PhysilinksUI", { create: "create" }); const ui = createUI(); const { sceneEl } = ui; const createSound = load("PhysilinksSound", { create: "create", fallback: () => ({}), }); const sound = createSound({ sounds: config.sounds }); 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, chainTimerEndMs: null, chainTimerDurationSec: null, chainTimerLastDisplay: null, chainTimerLastWarnSec: null, chainTimerIntroTimeoutId: null, chainTimerIntroAtMs: null, chainTimerIntroRemainingMs: null, chainTimerIntroText: null, chainTimerFrozen: false, pauseStartMs: null, }; const BALL_BASELINE = 680; // reference height used for relative ball sizing const createEngine = load("PhysilinksEngine", { create: "create" }); 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 = load("PhysilinksStorage", { fallback: {} }); const { loadHighScore = () => 0, loadLongestChain = () => 0, saveHighScore = () => {}, saveLongestChain = () => {}, } = storage; const optNum = (value, defaultValue) => typeof value === "number" ? value : defaultValue; 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 = optNum( sceneConfig?.gravityScale, defaultGravityScale, ); engine.gravity.x = 0; engine.gravity.y = config.gravity; engine.timing.timeScale = optNum( timeScaleOverride, optNum(sceneConfig?.timeScale, defaultTimeScale), ); }; const applyScene = (sceneId) => { const next = (getSceneById && getSceneById(scenes, sceneId)) || scenes[0]; if (!next) return; ui.clearMessages(); currentScene = next; ui.setSceneSelection(next.id); setSceneIdInUrl(next.id); const prevRadius = config.ballRadius; setConfigForScene(next.config); sound?.setConfig?.(config.sounds); ui.setBackdrop(config.backdrop, config.palette); ui.setFpsVisibility(config.showFps); 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 = (options = {}) => { const force = options.force === true; if (currentScene?.config?.noGameOver && !force) return; if (state.gameOver) return; state.gameOver = true; state.paused = false; if (state.chainTimerIntroTimeoutId) { clearTimeout(state.chainTimerIntroTimeoutId); state.chainTimerIntroTimeoutId = null; state.chainTimerIntroAtMs = null; state.chainTimerIntroRemainingMs = null; state.chainTimerIntroText = null; } resetChainVisuals(); spawnSystem?.stopSpawner(); stopRunner(); engine.timing.timeScale = 0; ui.setPauseState(false); ui.showGameOver(state.score); }; const createSpawn = load("PhysilinksSpawn", { create: "create" }); 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 = load("PhysilinksGoals", { create: "create" }); const goals = createGoals({ config, getCurrentScene: () => currentScene, getScore: () => state.score, getClearedCount: () => state.clearedCount, getClearedByColor: () => state.clearedByColor, getTimerEndMs: () => state.timerEndMs, ui, }); const normalizeColor = goals.normalizeColor; const createChainController = load("PhysilinksChainController", { create: "create", }); const createLoop = load("PhysilinksLoop", { create: "create" }); const restartGame = () => { if (state.chainTimerIntroTimeoutId) { clearTimeout(state.chainTimerIntroTimeoutId); state.chainTimerIntroTimeoutId = null; state.chainTimerIntroAtMs = null; state.chainTimerIntroRemainingMs = null; state.chainTimerIntroText = null; } spawnSystem.stopSpawner(); state.gameOver = false; state.paused = false; state.pauseStartMs = null; state.levelWon = false; state.chainTimerFrozen = false; if (currentScene?.id === "stack-blocks-packed") { engine.plugin ??= {}; engine.plugin.stackBlocksPacked = null; } 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; } const chainLose = currentScene?.config?.chainLose; if ( chainLose && Number.isFinite(chainLose.idleSec) && chainLose.idleSec > 0 ) { state.chainTimerDurationSec = chainLose.idleSec; state.chainTimerEndMs = Date.now() + chainLose.idleSec * 1000; state.chainTimerLastDisplay = null; state.chainTimerLastWarnSec = null; state.chainTimerFrozen = false; if (ui.setChainTimerVisibility) { ui.setChainTimerVisibility(true); } if (ui.setChainTimer) { ui.setChainTimer({ label: `${chainLose.idleSec}s left`, progress: 100, urgent: false, }); } const introDelay = chainLose.introDelayMs ?? 1200; const introText = chainLose.introMessage || `Make a chain in ${chainLose.idleSec}s`; if (introDelay >= 0) { state.chainTimerIntroText = introText; state.chainTimerIntroAtMs = Date.now() + introDelay; state.chainTimerIntroTimeoutId = setTimeout(() => { state.chainTimerIntroAtMs = null; state.chainTimerIntroText = null; ui.showFloatingMessage( { text: introText }, { durationMs: chainLose.introDurationMs ?? 2600, position: chainLose.introPosition || { xPercent: 50, yPercent: 12, }, }, ); }, introDelay); } } else { state.chainTimerDurationSec = null; state.chainTimerEndMs = null; state.chainTimerLastDisplay = null; state.chainTimerLastWarnSec = null; state.chainTimerIntroText = null; state.chainTimerFrozen = false; if (ui.setChainTimerVisibility) { ui.setChainTimerVisibility(false); } } resetChainVisuals(); ui.clearMessages(); 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 setPaused = (nextState) => { if (state.gameOver || state.levelWon) return; if (nextState === state.paused) return; state.paused = nextState; ui.setPauseState(state.paused); if (state.paused) { state.pauseStartMs = Date.now(); if (state.chainTimerIntroTimeoutId && state.chainTimerIntroAtMs) { state.chainTimerIntroRemainingMs = Math.max( 0, state.chainTimerIntroAtMs - Date.now(), ); clearTimeout(state.chainTimerIntroTimeoutId); state.chainTimerIntroTimeoutId = null; state.chainTimerIntroAtMs = null; } resetChainVisuals(); spawnSystem.stopSpawner(); stopRunner(); engine.timing.timeScale = 0; } else { if (state.pauseStartMs) { const pausedFor = Date.now() - state.pauseStartMs; state.pauseStartMs = null; if (state.timerEndMs) state.timerEndMs += pausedFor; if (state.chainTimerEndMs) state.chainTimerEndMs += pausedFor; } if ( state.chainTimerIntroRemainingMs && state.chainTimerIntroRemainingMs > 0 ) { const introText = state.chainTimerIntroText; const chainLose = currentScene?.config?.chainLose; state.chainTimerIntroAtMs = Date.now() + state.chainTimerIntroRemainingMs; state.chainTimerIntroTimeoutId = setTimeout(() => { state.chainTimerIntroAtMs = null; state.chainTimerIntroRemainingMs = null; if (introText) { ui.showFloatingMessage( { text: introText }, { durationMs: chainLose?.introDurationMs ?? 2600, position: chainLose?.introPosition || { xPercent: 50, yPercent: 12, }, }, ); } state.chainTimerIntroText = null; }, state.chainTimerIntroRemainingMs); state.chainTimerIntroRemainingMs = null; } startRunner(); spawnSystem.startSpawner(); engine.timing.timeScale = 1; } }; 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 }, config.goalEffects); if (!goal || !goal.met) return; if (state.chainTimerIntroTimeoutId) { clearTimeout(state.chainTimerIntroTimeoutId); state.chainTimerIntroTimeoutId = null; state.chainTimerIntroAtMs = null; state.chainTimerIntroRemainingMs = null; state.chainTimerIntroText = null; } state.chainTimerFrozen = true; applyWinEffects(); state.levelWon = true; spawnSystem.stopSpawner(); engine.timing.timeScale = 1; startRunner(); ui.setPauseState(false); ui.showWin(goal.label.replace("left", "done")); }; 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 }, config.goalEffects); goals.maybeAnnounceGoalProgress(goal); }; const { setHighlight, resetChainVisuals, removeLastFromChain, addToChain, finishChain, } = createChainController({ config, state, chain, world, spawnSystem, getCurrentScene: () => currentScene, normalizeColor, saveHighScore, saveLongestChain, updateHud, checkWinCondition, ui, sound, }); const createInput = load("PhysilinksInput", { create: "create" }); 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, sound, }); 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(); }; createLoop({ engine, render, config, state, chain, spawnSystem, getCurrentScene: () => currentScene, getMaxLinkDistance, updateHud, checkWinCondition, triggerGameOver, ui, }); 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); })();