(() => { 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, state, spawnSystem, config, }); }; 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 linkSparkles = []; const spawnSparkles = (now, previewTarget) => { const sparkCfg = config.link?.sparkles || {}; if (!sparkCfg.enabled || !chain.active || chain.bodies.length < 1) return; const deltaMs = engine.timing?.delta || 16; const extraLinks = Math.max(0, chain.bodies.length - 1); const rate = (sparkCfg.rate ?? 0.12) * (1 + (sparkCfg.lengthRateBoost ?? 0.08) * extraLinks); const segments = []; chain.constraints.forEach((c) => { if (c?.bodyA?.position && c?.bodyB?.position) { segments.push({ from: c.bodyA.position, to: c.bodyB.position }); } }); if (previewTarget && chain.bodies.length > 0) { const last = chain.bodies[chain.bodies.length - 1]; segments.push({ from: last.position, to: previewTarget }); } if (!segments.length) return; const count = Math.max( 1, Math.floor(segments.length * rate * (deltaMs / 16)), ); const jitter = sparkCfg.jitter ?? 6; const size = (sparkCfg.size ?? 4) + (sparkCfg.sizeBoost ?? 0.2) * extraLinks; const life = sparkCfg.lifeMs ?? 260; const opacity = sparkCfg.opacity ?? 0.9; for (let i = 0; i < count; i += 1) { const seg = segments[Math.floor(Math.random() * segments.length)]; if (!seg) continue; const t = Math.random(); const from = seg.from; const to = seg.to; const pos = Vector.add( from, Vector.mult(Vector.sub(to, from), t || 0.5), ); linkSparkles.push({ x: pos.x + (Math.random() - 0.5) * jitter, y: pos.y + (Math.random() - 0.5) * jitter, created: now, life, size, color: chain.color || "#fff", opacity, }); } }; const drawLinkGlowAndSparkles = () => { if (!chain.active || chain.bodies.length === 0) return; const ctx = render.context; const now = engine.timing?.timestamp || performance.now(); const linkCfg = config.link || {}; const glowCfg = linkCfg.glowEffect || {}; let safeTarget = null; if (chain.pointer) { const last = chain.bodies[chain.bodies.length - 1]; 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)), ); safeTarget = dist > 0 ? Vector.add( last.position, Vector.mult(Vector.normalise(delta), cappedDist), ) : chain.pointer; } if (glowCfg.enabled && (chain.bodies.length > 1 || safeTarget)) { const color = glowCfg.color || chain.color || "#fff"; const baseWidth = glowCfg.baseWidth ?? 6; const pulseWidth = glowCfg.pulseWidth ?? 3; const speed = glowCfg.pulseSpeedMs ?? 900; const extraLinks = Math.max(0, chain.bodies.length - 1); const lengthScale = glowCfg.lengthScale ?? 0.4; const pulse = speed > 0 ? Math.sin(((now % speed) / speed) * Math.PI * 2) * 0.5 + 0.5 : 0; ctx.save(); ctx.strokeStyle = color; ctx.lineWidth = baseWidth + pulseWidth * pulse + lengthScale * extraLinks; const maxOpacity = glowCfg.maxOpacity ?? 0.7; const alphaBase = glowCfg.opacity ?? 0.26; const alpha = alphaBase + (glowCfg.opacityBoostPerLink ?? 0.02) * extraLinks; ctx.globalAlpha = Math.min(maxOpacity, alpha); ctx.shadowColor = color; ctx.shadowBlur = glowCfg.shadowBlur ?? 18; ctx.beginPath(); chain.bodies.forEach((b, idx) => { if (idx === 0) { ctx.moveTo(b.position.x, b.position.y); } else { ctx.lineTo(b.position.x, b.position.y); } }); if (safeTarget) { const last = chain.bodies[chain.bodies.length - 1]; ctx.moveTo(last.position.x, last.position.y); ctx.lineTo(safeTarget.x, safeTarget.y); } ctx.stroke(); ctx.restore(); } spawnSparkles(now, safeTarget); if (linkSparkles.length > 0) { ctx.save(); for (let i = linkSparkles.length - 1; i >= 0; i -= 1) { const s = linkSparkles[i]; const age = now - s.created; if (age > s.life) { linkSparkles.splice(i, 1); continue; } const t = Math.max(0, Math.min(1, age / s.life)); const alpha = (s.opacity ?? 0.9) * (1 - t); ctx.fillStyle = s.color || "#fff"; ctx.globalAlpha = alpha; ctx.beginPath(); ctx.arc(s.x, s.y, s.size * (1 - t), 0, Math.PI * 2); ctx.fill(); } ctx.restore(); } if (!safeTarget) return; const last = chain.bodies[chain.bodies.length - 1]; 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", drawLinkGlowAndSparkles); }; window.PhysilinksLoop = { create }; })();