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:
@@ -96,6 +96,15 @@ export function PlanchetteBoard({ machine, hue = 150 }: PlanchetteBoardProps) {
|
||||
let initialized = false
|
||||
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
|
||||
// (autoplay policy); a soft tick sounds as each letter is dwelled upon.
|
||||
let tickCtx: AudioContext | null = null
|
||||
@@ -162,17 +171,36 @@ 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)
|
||||
// Wander bounds are tied directly to the board's actual fixed
|
||||
// waypoints — YES/NO (the outermost, topmost points) and the number
|
||||
// row (the lowest) — rather than a derived "ring radius", so idle
|
||||
// drift genuinely covers the full interactive board, corners
|
||||
// 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 restingAtGoodbye = false
|
||||
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) {
|
||||
target = {
|
||||
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,
|
||||
x: wanderCx + wander(t, 1.3) * wanderXRange * 0.96,
|
||||
y: wanderCy + wander(t * 0.83, 4.1) * wanderYRange * 0.96,
|
||||
}
|
||||
}
|
||||
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('NO', layout.no.x, layout.no.y)
|
||||
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.fillText('G O O D B Y E', layout.goodbye.x, layout.goodbye.y)
|
||||
}
|
||||
|
||||
// Letters + numbers.
|
||||
const hovered = snap.current ?? brushed
|
||||
|
||||
Reference in New Issue
Block a user