diff --git a/frontend/src/components/PlanchetteBoard.tsx b/frontend/src/components/PlanchetteBoard.tsx index 5928cc0..67daf7e 100644 --- a/frontend/src/components/PlanchetteBoard.tsx +++ b/frontend/src/components/PlanchetteBoard.tsx @@ -162,18 +162,42 @@ export function PlanchetteBoard({ machine, hue = 150 }: PlanchetteBoardProps) { lastKnock = knockKey // --- target: spelled letter, else idle wander --- + // Wander radius is tied directly to the outer letter ring's own + // radius/squash (computeLayout's `arc('ABCDEFGHIJKLM', ...)` call) so + // the drift genuinely reaches the board's edges — not a fixed + // fraction of the canvas that happens to undershoot the ring. + const outerRingRadius = Math.min(w * 0.4, h * 0.52) let target: Point | null = null if (snap.current) target = letterTarget(snap.current) if (!target) { target = { - x: layout.center.x + wander(t, 1.3) * w * 0.16, - y: layout.center.y + wander(t * 0.83, 4.1) * h * 0.14, + x: layout.center.x + wander(t, 1.3) * outerRingRadius * 0.94, + y: layout.center.y + wander(t * 0.83, 4.1) * outerRingRadius * 0.72 * 0.94, } } const speed = snap.current ? 0.16 : 0.03 pos.x += (target.x - pos.x) * speed pos.y += (target.y - pos.y) * speed + // --- ambient letter-brushing: during idle wander (not actively + // spelling), a letter the planchette happens to drift near gets a + // soft "passively brushed" glow — distinct from the brighter + // hover/dwell glow used for real spelling, so it never reads as the + // spirit actually saying something. Purely cosmetic, no game state. + let brushed: string | null = null + if (!snap.current) { + let best = Infinity + for (const [ch, p] of layout.letters) { + const d = Math.hypot(p.x - pos.x, p.y - pos.y) + if (d < best) { + best = d + brushed = ch + } + } + const BRUSH_RADIUS = Math.max(26, h * 0.06) + if (best > BRUSH_RADIUS) brushed = null + } + // --- smoke trail --- const moving = Math.abs(target.x - pos.x) + Math.abs(target.y - pos.y) > 2 if ((moving || snap.current) && smoke.length < 220) { @@ -233,15 +257,15 @@ export function PlanchetteBoard({ machine, hue = 150 }: PlanchetteBoardProps) { ctx.fillText('G O O D B Y E', layout.goodbye.x, layout.goodbye.y) // Letters + numbers. - const hovered = snap.current + const hovered = snap.current ?? brushed const dwelling = snap.phase === 'dwelling' ? snap.current : null ctx.font = `600 ${Math.max(13, h * 0.04)}px Cinzel, Georgia, serif` for (const [ch, p] of layout.letters) { - drawGlyph(ctx, ch, p, hovered === ch, dwelling === ch, accent, hueDeg) + drawGlyph(ctx, ch, p, hovered === ch, dwelling === ch, accent, hueDeg, ch === brushed) } ctx.font = `600 ${Math.max(12, h * 0.034)}px "IBM Plex Mono", monospace` for (const [ch, p] of layout.numbers) { - drawGlyph(ctx, ch, p, hovered === ch, dwelling === ch, accent, hueDeg) + drawGlyph(ctx, ch, p, hovered === ch, dwelling === ch, accent, hueDeg, false) } // Smoke (additive). @@ -325,6 +349,7 @@ function drawGlyph( dwell: boolean, accent: string, hueDeg: number, + soft: boolean, ): void { if (hovered) { ctx.save() @@ -335,13 +360,18 @@ function drawGlyph( ctx.translate(-p.x, -p.y) } ctx.shadowColor = accent - ctx.shadowBlur = dwell ? 28 : 16 + // Ambient brush (idle drift passing near, `soft`) gets a faint glow — + // deliberately dimmer than a real spelled letter, so it never reads as + // the spirit actually saying something, just the planchette stirring. + ctx.shadowBlur = dwell ? 28 : soft ? 9 : 16 ctx.fillStyle = accent + ctx.globalAlpha = soft ? 0.6 : 1 ctx.fillText(ch, p.x, p.y) + ctx.globalAlpha = 1 ctx.restore() ctx.beginPath() - ctx.strokeStyle = `hsla(${hueDeg} 90% 75% / ${dwell ? 0.85 : 0.5})` - ctx.arc(p.x, p.y, dwell ? 19 : 15, 0, Math.PI * 2) + ctx.strokeStyle = `hsla(${hueDeg} 90% 75% / ${dwell ? 0.85 : soft ? 0.28 : 0.5})` + ctx.arc(p.x, p.y, dwell ? 19 : soft ? 13 : 15, 0, Math.PI * 2) ctx.stroke() } else { ctx.fillStyle = `hsla(${hueDeg} 40% 82% / 0.75)`