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
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user