feat: complete Quantumancy web app — full frontend + docs
Frontend (React 18 + TS + Vite): - Landing: glitching hero, live /api/stats veil ticker, mode cards, featured spirits - Séance: three.js shader ghost (hue/form per entity, mood + audio-reactive), Ouija planchette board spelling utterances, transcript with TTS replay, entity dossier, direct contact streaming, passive/active listening - Modes: Wire Ghost telemetry panel, EVP mic anomaly detection, WebUSB RTL-SDR sweep + waterfall (hardware pass pending), Ouija/Direct Contact - Codex: public registry + entity dossiers, rarity tiers, i18n EN/ES complete - State: VeilSocket (reconnect/backoff), seance reducer, auth context - 72 vitest tests green; served by FastAPI at :7777 Docs: README + as-built plans 3-7
This commit is contained in:
158
frontend/src/lib/planchette.ts
Normal file
158
frontend/src/lib/planchette.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
// Planchette word-queue / spelling state machine.
|
||||
//
|
||||
// The Ouija board spells out spirit utterances letter by letter. This module
|
||||
// is the pure logic: queue words, step through letters at a fixed cadence,
|
||||
// and report which character the planchette is currently hovering over.
|
||||
// Rendering (canvas, DOM) lives elsewhere; this is unit-tested in isolation.
|
||||
|
||||
export type PlanchettePhase = 'idle' | 'moving' | 'dwelling' | 'returning'
|
||||
|
||||
export type PlanchetteSnapshot = {
|
||||
phase: PlanchettePhase
|
||||
/** Character currently hovered, or null when drifting idle. */
|
||||
current: string | null
|
||||
/** The full normalized word being spelled, or null. */
|
||||
word: string | null
|
||||
/** Index of `current` within `word`. */
|
||||
index: number
|
||||
/** Words still waiting to be spelled. */
|
||||
queued: readonly string[]
|
||||
/** Words already spelled this session (most recent last). */
|
||||
spelled: readonly string[]
|
||||
}
|
||||
|
||||
export type PlanchetteOptions = {
|
||||
/** Milliseconds spent on each letter. Default 300. */
|
||||
msPerLetter?: number
|
||||
/** Milliseconds paused between words. Default 700. */
|
||||
msBetweenWords?: number
|
||||
/** Cap on the internal queue so a flood of utterances cannot wedge it. */
|
||||
maxQueue?: number
|
||||
}
|
||||
|
||||
/** Keep A–Z, 0–9 and spaces; uppercase; drop everything else. */
|
||||
export function normalizeWord(text: string): string {
|
||||
return text
|
||||
.toUpperCase()
|
||||
.replace(/[^A-Z0-9 ]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Split an utterance into queueable word tokens: punctuation stripped,
|
||||
* whitespace-split, empties removed.
|
||||
*/
|
||||
export function tokenize(text: string): string[] {
|
||||
const norm = normalizeWord(text)
|
||||
if (!norm) return []
|
||||
return norm.split(' ').filter((w) => w.length > 0)
|
||||
}
|
||||
|
||||
export class PlanchetteMachine {
|
||||
private queue: string[] = []
|
||||
private done: string[] = []
|
||||
private word: string | null = null
|
||||
private index = 0
|
||||
private phase: PlanchettePhase = 'idle'
|
||||
private clock = 0
|
||||
private readonly msPerLetter: number
|
||||
private readonly msBetweenWords: number
|
||||
private readonly maxQueue: number
|
||||
private version = 0
|
||||
|
||||
constructor(opts: PlanchetteOptions = {}) {
|
||||
this.msPerLetter = opts.msPerLetter ?? 300
|
||||
this.msBetweenWords = opts.msBetweenWords ?? 700
|
||||
this.maxQueue = opts.maxQueue ?? 64
|
||||
}
|
||||
|
||||
/** Monotonic counter bumped on every mutation — handy for render loops. */
|
||||
getVersion(): number {
|
||||
return this.version
|
||||
}
|
||||
|
||||
/** Enqueue an utterance (may contain several words + punctuation). */
|
||||
enqueue(text: string): void {
|
||||
const words = tokenize(text)
|
||||
for (const w of words) {
|
||||
if (this.queue.length < this.maxQueue) this.queue.push(w)
|
||||
}
|
||||
if (words.length > 0) this.version++
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.queue = []
|
||||
this.word = null
|
||||
this.index = 0
|
||||
this.phase = 'idle'
|
||||
this.clock = 0
|
||||
this.version++
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the machine by `dtMs`. Call from a rAF loop or a timer.
|
||||
* Returns a snapshot after advancing.
|
||||
*/
|
||||
tick(dtMs: number): PlanchetteSnapshot {
|
||||
this.clock += dtMs
|
||||
|
||||
if (this.phase === 'idle') {
|
||||
if (this.queue.length > 0) {
|
||||
this.word = this.queue.shift() ?? null
|
||||
this.index = 0
|
||||
this.phase = this.word && this.word.length > 0 ? 'moving' : 'idle'
|
||||
this.clock = 0
|
||||
this.version++
|
||||
}
|
||||
return this.snapshot()
|
||||
}
|
||||
|
||||
if (this.phase === 'returning') {
|
||||
if (this.clock >= this.msBetweenWords) {
|
||||
this.clock = 0
|
||||
this.phase = 'idle'
|
||||
this.word = null
|
||||
this.index = 0
|
||||
this.version++
|
||||
}
|
||||
return this.snapshot()
|
||||
}
|
||||
|
||||
// moving / dwelling: advance letters on the per-letter cadence.
|
||||
if (this.word) {
|
||||
while (this.clock >= this.msPerLetter && this.phase !== 'returning') {
|
||||
this.clock -= this.msPerLetter
|
||||
this.index++
|
||||
this.version++
|
||||
if (this.index >= this.word.length) {
|
||||
this.done.push(this.word)
|
||||
if (this.done.length > 128) this.done.shift()
|
||||
this.phase = 'returning'
|
||||
this.clock = 0
|
||||
} else {
|
||||
this.phase = this.clock >= this.msPerLetter ? 'moving' : 'dwelling'
|
||||
}
|
||||
}
|
||||
if (this.phase === 'dwelling' || this.phase === 'moving') {
|
||||
this.phase = 'dwelling'
|
||||
}
|
||||
}
|
||||
return this.snapshot()
|
||||
}
|
||||
|
||||
snapshot(): PlanchetteSnapshot {
|
||||
const active =
|
||||
(this.phase === 'moving' || this.phase === 'dwelling') && this.word
|
||||
? this.word
|
||||
: null
|
||||
return {
|
||||
phase: this.phase,
|
||||
current: active ? active[this.index] ?? null : null,
|
||||
word: active,
|
||||
index: active ? this.index : 0,
|
||||
queued: [...this.queue],
|
||||
spelled: [...this.done],
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user