diff --git a/index.html b/index.html index 1813246..afac0c0 100644 --- a/index.html +++ b/index.html @@ -112,6 +112,8 @@ + + diff --git a/src/chain-controller.js b/src/chain-controller.js new file mode 100644 index 0000000..e73e90c --- /dev/null +++ b/src/chain-controller.js @@ -0,0 +1,209 @@ +(() => { + const { Constraint, Vector, World } = Matter; + + const create = ({ + config, + state, + chain, + world, + spawnSystem, + getCurrentScene, + normalizeColor, + saveHighScore, + saveLongestChain, + updateHud, + checkWinCondition, + ui, + }) => { + const setHighlight = (body, on) => { + body.render.lineWidth = on ? 4 : 2; + body.render.strokeStyle = on ? "#f8fafc" : "#0b1222"; + }; + + 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 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 getChainScoreState = () => { + const baseGain = 10 * Math.pow(chain.bodies.length, 2); + const currentScene = getCurrentScene(); + 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 currentScene = getCurrentScene(); + 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 updateLongestChain = (chainLength) => { + if (chainLength <= state.longestChainRecord) return; + const currentScene = getCurrentScene(); + 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 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; + const currentScene = getCurrentScene(); + 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(); + }; + + return { + addToChain, + finishChain, + removeLastFromChain, + resetChainVisuals, + setHighlight, + }; + }; + + window.PhysilinksChainController = { create }; +})(); diff --git a/src/loop.js b/src/loop.js new file mode 100644 index 0000000..c7782cd --- /dev/null +++ b/src/loop.js @@ -0,0 +1,177 @@ +(() => { + const { Body, Events, Vector } = Matter; + + const create = ({ + engine, + render, + config, + state, + chain, + spawnSystem, + getCurrentScene, + getMaxLinkDistance, + updateHud, + checkWinCondition, + }) => { + const runSceneBeforeUpdateHook = () => { + const currentScene = getCurrentScene(); + 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 = getCurrentScene()?.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(); + } + }; + + const keepBallsInBounds = () => { + const currentScene = getCurrentScene(); + 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"; + 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, + }); + Body.setVelocity(ball, { x: 0, y: 0 }); + } + }); + }; + + const beforeUpdateStep = () => { + runSceneBeforeUpdateHook(); + stepRopeConstraints(); + const dt = (engine.timing && engine.timing.delta) || 16; + const timeScale = engine.timing?.timeScale ?? 1; + if (state.paused || state.gameOver || timeScale === 0) return; + stepRotators(dt, timeScale); + stepOscillators(); + stepTimer(); + }; + + const drawChainPreview = () => { + 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); + 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(); + }; + + Events.on(engine, "afterUpdate", keepBallsInBounds); + Events.on(engine, "beforeUpdate", beforeUpdateStep); + Events.on(render, "afterRender", drawChainPreview); + }; + + window.PhysilinksLoop = { create }; +})(); diff --git a/src/main.js b/src/main.js index b3d7236..75f9faa 100644 --- a/src/main.js +++ b/src/main.js @@ -1,5 +1,5 @@ (() => { - const { World, Body, Constraint, Events, Vector } = Matter; + const { World, Body } = Matter; const load = (key, { create, fallback = {} } = {}) => { const mod = window[key] ?? fallback; @@ -59,12 +59,8 @@ const config = { ...normalizeSceneConfig(baseConfig) }; - const setConfigForScene = (sceneConfig = {}) => { - const normalized = normalizeSceneConfig(sceneConfig); - Object.assign(config, normalized); - config.link = { ...normalized.link }; - config.messages = normalized.messages; - }; + const setConfigForScene = (sceneConfig) => + Object.assign(config, normalizeSceneConfig(sceneConfig)); const scenesMod = load("PhysilinksScenes", { fallback: {} }); const { scenes = [], defaultSceneId, order: sceneOrder = [] } = scenesMod; @@ -151,6 +147,9 @@ saveLongestChain = () => {}, } = storage; + const optNum = (value, defaultValue) => + typeof value === "number" ? value : defaultValue; + const resetEngineForScene = ( sceneConfig, { prevRadius, timeScaleOverride, resetPlugins = true } = {}, @@ -165,18 +164,16 @@ engine.plugin.stormState = null; } spawnSystem.updateBallRadius(prevRadius); - engine.gravity.scale = - typeof sceneConfig?.gravityScale === "number" - ? sceneConfig.gravityScale - : defaultGravityScale; + engine.gravity.scale = optNum( + 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; + engine.timing.timeScale = optNum( + timeScaleOverride, + optNum(sceneConfig?.timeScale, defaultTimeScale), + ); }; const applyScene = (sceneId) => { @@ -257,6 +254,10 @@ ui, }); const normalizeColor = goals.normalizeColor; + const createChainController = load("PhysilinksChainController", { + create: "create", + }); + const createLoop = load("PhysilinksLoop", { create: "create" }); const restartGame = () => { spawnSystem.stopSpawner(); @@ -305,11 +306,6 @@ 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; @@ -327,17 +323,6 @@ } }; - 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; @@ -405,167 +390,6 @@ 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, @@ -580,6 +404,27 @@ goals.maybeAnnounceGoalProgress(goal); }; + const { + setHighlight, + resetChainVisuals, + removeLastFromChain, + addToChain, + finishChain, + } = createChainController({ + config, + state, + chain, + world, + spawnSystem, + getCurrentScene: () => currentScene, + normalizeColor, + saveHighScore, + saveLongestChain, + updateHud, + checkWinCondition, + ui, + }); + const createInput = load("PhysilinksInput", { create: "create" }); input = createInput({ render, @@ -636,161 +481,17 @@ 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(); + createLoop({ + engine, + render, + config, + state, + chain, + spawnSystem, + getCurrentScene: () => currentScene, + getMaxLinkDistance, + updateHud, + checkWinCondition, }); window.addEventListener("resize", handleResize);