(() => { 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 collectClearTargets = () => { const targets = []; const seen = new Set(); const blobIds = new Set(); const addBall = (ball) => { if (!ball || seen.has(ball.id)) return; seen.add(ball.id); const radius = typeof ball.circleRadius === "number" ? ball.circleRadius : Math.max( Math.abs(ball.bounds.max.x - ball.bounds.min.x), Math.abs(ball.bounds.max.y - ball.bounds.min.y), ) / 2 || config.ballRadius; targets.push({ x: ball.position.x, y: ball.position.y, color: ball.plugin?.color || ball.render?.fillStyle, radius, shape: ball.plugin?.shape || "circle", }); }; chain.bodies.forEach((body) => { if (body.plugin?.blobId) { blobIds.add(body.plugin.blobId); } addBall(body); }); blobIds.forEach((id) => { state.balls .filter((b) => b.plugin?.blobId === id) .forEach((b) => addBall(b)); }); return targets; }; const runClearAnimation = (targets) => { if (!targets.length || !ui?.spawnClearEffects) return; const linkCfg = config.link || {}; const clearCfg = linkCfg.clearAnimation || {}; const { render: renderOverride, ...animCfg } = clearCfg; const currentScene = getCurrentScene(); let handled = false; if (typeof renderOverride === "function") { try { const result = renderOverride({ targets, config: animCfg, scene: currentScene, ui, }); handled = result !== false; } catch (err) { console.error("clearAnimation render failed", err); } } if (!handled) { ui.spawnClearEffects(targets, { type: clearCfg.type, ...animCfg }); } }; 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; const clearTargets = collectClearTargets(); 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(); runClearAnimation(clearTargets); removeChainBodies(); if (isNegativeProgress) { applyNegativeProgressPenalty(chainLength); } } else { removeChainConstraints(); chain.bodies.forEach((b) => setHighlight(b, false)); } resetChainState(); }; return { addToChain, finishChain, removeLastFromChain, resetChainVisuals, setHighlight, }; }; window.PhysilinksChainController = { create }; })();