feat: planchette wander reaches YES/NO, periodically rests on GOODBYE

Wander bounds were tied to the letter ring's radius, which sits inside
YES/NO's corner positions — widened to derive bounds directly from the
board's actual outermost fixed waypoints (YES/NO for the top/sides, the
number row for the bottom) so idle drift covers the whole interactive
board, corners included.

Added a periodic deliberate visit to GOODBYE: every 2-5 minutes
(randomized so multiple open tabs don't sync up), the planchette glides
down and rests there for 3-5 seconds with the same restrained glow used
for ambiently-brushed letters, then resumes normal wander. Purely a
visual beat — no session/game state changes, distinct from an actual
goodbye action.
This commit is contained in:
Indiana
2026-07-25 17:47:59 +00:00
parent a4103818d3
commit 53239a923d

View File

@@ -96,6 +96,15 @@ export function PlanchetteBoard({ machine, hue = 150 }: PlanchetteBoardProps) {
let initialized = false let initialized = false
const smoke: Smoke[] = [] const smoke: Smoke[] = []
// Periodic, deliberate visit to GOODBYE — distinct from the ambient
// wander/letter-brushing above: every 2-5 minutes (randomized so
// multiple open tabs don't all drift there in lockstep), the planchette
// glides down and rests on GOODBYE for a few seconds before resuming
// normal idle wander. `t`-relative (seconds since mount), not wall-clock.
const randomBetween = (min: number, max: number) => min + Math.random() * (max - min)
let nextGoodbyeAt = randomBetween(120, 300)
let goodbyeRestUntil = 0
// Spirit knock: an AudioContext created lazily on the first gesture // Spirit knock: an AudioContext created lazily on the first gesture
// (autoplay policy); a soft tick sounds as each letter is dwelled upon. // (autoplay policy); a soft tick sounds as each letter is dwelled upon.
let tickCtx: AudioContext | null = null let tickCtx: AudioContext | null = null
@@ -162,17 +171,36 @@ export function PlanchetteBoard({ machine, hue = 150 }: PlanchetteBoardProps) {
lastKnock = knockKey lastKnock = knockKey
// --- target: spelled letter, else idle wander --- // --- target: spelled letter, else idle wander ---
// Wander radius is tied directly to the outer letter ring's own // Wander bounds are tied directly to the board's actual fixed
// radius/squash (computeLayout's `arc('ABCDEFGHIJKLM', ...)` call) so // waypoints — YES/NO (the outermost, topmost points) and the number
// the drift genuinely reaches the board's edges — not a fixed // row (the lowest) — rather than a derived "ring radius", so idle
// fraction of the canvas that happens to undershoot the ring. // drift genuinely covers the full interactive board, corners
const outerRingRadius = Math.min(w * 0.4, h * 0.52) // included, not just the letter belt.
const wanderCx = (layout.yes.x + layout.no.x) / 2
const wanderXRange = (layout.no.x - layout.yes.x) / 2
const wanderCy = (layout.yes.y + layout.numbers.get('5')!.y) / 2
const wanderYRange = (layout.numbers.get('5')!.y - layout.yes.y) / 2
let target: Point | null = null let target: Point | null = null
let restingAtGoodbye = false
if (snap.current) target = letterTarget(snap.current) if (snap.current) target = letterTarget(snap.current)
if (!target) {
if (t >= nextGoodbyeAt && goodbyeRestUntil === 0) {
// Arrived at the scheduled moment — begin resting on GOODBYE.
goodbyeRestUntil = t + randomBetween(3, 5)
}
if (goodbyeRestUntil > 0 && t < goodbyeRestUntil) {
target = layout.goodbye
restingAtGoodbye = true
} else if (goodbyeRestUntil > 0) {
// Rest just ended — clear it and schedule the next visit.
goodbyeRestUntil = 0
nextGoodbyeAt = t + randomBetween(120, 300)
}
}
if (!target) { if (!target) {
target = { target = {
x: layout.center.x + wander(t, 1.3) * outerRingRadius * 0.94, x: wanderCx + wander(t, 1.3) * wanderXRange * 0.96,
y: layout.center.y + wander(t * 0.83, 4.1) * outerRingRadius * 0.72 * 0.94, y: wanderCy + wander(t * 0.83, 4.1) * wanderYRange * 0.96,
} }
} }
const speed = snap.current ? 0.16 : 0.03 const speed = snap.current ? 0.16 : 0.03
@@ -253,8 +281,20 @@ export function PlanchetteBoard({ machine, hue = 150 }: PlanchetteBoardProps) {
ctx.fillText('YES', layout.yes.x, layout.yes.y) ctx.fillText('YES', layout.yes.x, layout.yes.y)
ctx.fillText('NO', layout.no.x, layout.no.y) ctx.fillText('NO', layout.no.x, layout.no.y)
ctx.font = `600 ${Math.max(12, h * 0.03)}px "IBM Plex Mono", monospace` ctx.font = `600 ${Math.max(12, h * 0.03)}px "IBM Plex Mono", monospace`
if (restingAtGoodbye) {
// Same restrained glow language as an ambiently-brushed letter —
// the planchette pausing here is a beat, not an actual "goodbye"
// action; nothing about session state changes.
ctx.save()
ctx.shadowColor = accent
ctx.shadowBlur = 12
ctx.fillStyle = accent
ctx.fillText('G O O D B Y E', layout.goodbye.x, layout.goodbye.y)
ctx.restore()
} else {
ctx.fillStyle = `hsla(${hueDeg} 85% 78% / 0.65)` ctx.fillStyle = `hsla(${hueDeg} 85% 78% / 0.65)`
ctx.fillText('G O O D B Y E', layout.goodbye.x, layout.goodbye.y) ctx.fillText('G O O D B Y E', layout.goodbye.x, layout.goodbye.y)
}
// Letters + numbers. // Letters + numbers.
const hovered = snap.current ?? brushed const hovered = snap.current ?? brushed