diff --git a/src/config.js b/src/config.js index ae9159a..408e27a 100644 --- a/src/config.js +++ b/src/config.js @@ -32,6 +32,28 @@ endOpacity: 0, sizeScale: 1, }, + glowEffect: { + enabled: true, + opacity: 0.26, + baseWidth: 6, + pulseWidth: 3, + pulseSpeedMs: 900, + shadowBlur: 18, + color: null, + lengthScale: 0.4, + opacityBoostPerLink: 0.02, + maxOpacity: 0.7, + }, + sparkles: { + enabled: true, + rate: 0.12, + size: 4, + lifeMs: 260, + opacity: 0.9, + jitter: 6, + lengthRateBoost: 0.08, + sizeBoost: 0.2, + }, }, messages: { ...defaultMessageConfig }, }; diff --git a/src/loop.js b/src/loop.js index b513c87..676dfaa 100644 --- a/src/loop.js +++ b/src/loop.js @@ -141,25 +141,148 @@ 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 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 safeTarget = - dist > 0 - ? Vector.add( - last.position, - Vector.mult(Vector.normalise(delta), cappedDist), - ) - : chain.pointer; + 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; @@ -173,7 +296,7 @@ Events.on(engine, "afterUpdate", keepBallsInBounds); Events.on(engine, "beforeUpdate", beforeUpdateStep); - Events.on(render, "afterRender", drawChainPreview); + Events.on(render, "afterRender", drawLinkGlowAndSparkles); }; window.PhysilinksLoop = { create }; diff --git a/src/main.js b/src/main.js index ce43e49..6f21a4c 100644 --- a/src/main.js +++ b/src/main.js @@ -55,6 +55,14 @@ ...(defaults.clearAnimation || {}), ...(link.clearAnimation || {}), }, + glowEffect: { + ...(defaults.glowEffect || {}), + ...(link.glowEffect || {}), + }, + sparkles: { + ...(defaults.sparkles || {}), + ...(link.sparkles || {}), + }, }); const normalizeSceneConfig = (sceneConfig = {}, defaults = baseConfig) => { diff --git a/src/scenes/scene-template.js b/src/scenes/scene-template.js index 9418050..6808398 100644 --- a/src/scenes/scene-template.js +++ b/src/scenes/scene-template.js @@ -71,6 +71,28 @@ sizeScale: 1, // multiplies the base size (radius) used for the visual render: null, // optional function({ targets, config, scene, ui }) to fully override rendering }, + glowEffect: { + enabled: true, // set false to disable link glow while dragging + opacity: 0.26, // alpha for the glow stroke + baseWidth: 6, // base stroke width + pulseWidth: 3, // additional width added via pulse + pulseSpeedMs: 900, // speed of the width pulse + shadowBlur: 18, // blur radius for glow + color: null, // optional override; defaults to active chain color + lengthScale: 0.4, // extra stroke width per additional linked ball + opacityBoostPerLink: 0.02, // extra alpha per linked ball + maxOpacity: 0.7, // cap for glow alpha + }, + sparkles: { + enabled: true, // set false to disable sparkle particles on links + rate: 0.12, // spawn density multiplier per link per frame + size: 4, // base sparkle size + lifeMs: 260, // lifetime of each sparkle + opacity: 0.9, // starting opacity + jitter: 6, // positional jitter in pixels + lengthRateBoost: 0.08, // extra spawn rate per linked ball + sizeBoost: 0.2, // additional sparkle size per linked ball + }, }, // Messaging overlays (goal hints / milestones)