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:
Indiana
2026-07-20 21:11:49 +00:00
parent 1ec9ea5863
commit 6edbbbbc2a
48 changed files with 10494 additions and 168 deletions

View File

@@ -0,0 +1,49 @@
import { useEffect, useRef } from 'react'
import { GhostScene } from './GhostScene'
import type { GhostMood, GhostVisualState } from './GhostScene'
export type GhostCanvasProps = {
visual: GhostVisualState | null
mood: GhostMood
/** Called each frame to get the speaking amplitude (0..1). */
getAmplitude?: () => number
className?: string
}
/** React wrapper: owns the canvas element and the GhostScene lifecycle. */
export function GhostCanvas({ visual, mood, getAmplitude, className }: GhostCanvasProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const sceneRef = useRef<GhostScene | null>(null)
const ampRef = useRef(getAmplitude)
ampRef.current = getAmplitude
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const scene = new GhostScene(canvas)
sceneRef.current = scene
scene.start()
const ampTimer = window.setInterval(() => {
scene.setSpeakAmplitude(ampRef.current?.() ?? 0)
}, 50)
return () => {
window.clearInterval(ampTimer)
scene.dispose()
sceneRef.current = null
}
}, [])
useEffect(() => {
sceneRef.current?.setVisual(visual)
}, [visual])
useEffect(() => {
sceneRef.current?.setMood(mood)
}, [mood])
return (
<div className={className} style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
<canvas ref={canvasRef} style={{ width: '100%', height: '100%', display: 'block' }} />
</div>
)
}

View File

@@ -0,0 +1,477 @@
// The 3D ghost scene: a smoky, wavy, translucent spirit rendered with
// custom shaders — fbm value-noise vertex displacement on a lathed figure,
// additive particle wisps, ground mist, fog, slow camera drift.
// Reacts to seance state via uniforms (energy / speaking amplitude / hue / form).
import * as THREE from 'three'
import type { GhostForm } from '../lib/types'
export type GhostMood = 'idle' | 'attuning' | 'gathering' | 'speaking'
export type GhostVisualState = {
hue: number // 0..360
form: GhostForm
}
const NOISE_GLSL = /* glsl */ `
float hash(vec3 p) {
p = fract(p * 0.3183099 + 0.1);
p *= 17.0;
return fract(p.x * p.y * p.z * (p.x + p.y + p.z));
}
float vnoise(vec3 x) {
vec3 i = floor(x);
vec3 f = fract(x);
f = f * f * (3.0 - 2.0 * f);
return mix(
mix(mix(hash(i + vec3(0,0,0)), hash(i + vec3(1,0,0)), f.x),
mix(hash(i + vec3(0,1,0)), hash(i + vec3(1,1,0)), f.x), f.y),
mix(mix(hash(i + vec3(0,0,1)), hash(i + vec3(1,0,1)), f.x),
mix(hash(i + vec3(0,1,1)), hash(i + vec3(1,1,1)), f.x), f.y),
f.z);
}
float fbm(vec3 p) {
float v = 0.0;
float a = 0.5;
for (int i = 0; i < 4; i++) {
v += a * vnoise(p);
p *= 2.03;
a *= 0.5;
}
return v;
}
`
const GHOST_VERT = /* glsl */ `
uniform float uTime;
uniform float uEnergy; // 0..1 swirl intensity
uniform float uSpeak; // 0..1 voice amplitude pulse
uniform float uFormStretch; // form-driven vertical stretch
varying vec2 vUv;
varying float vNoise;
varying vec3 vPos;
${NOISE_GLSL}
void main() {
vUv = uv;
vec3 p = position;
p.y *= uFormStretch;
float swirl = uTime * (0.35 + uEnergy * 1.6);
float n = fbm(vec3(p.x * 1.4 + swirl, p.y * 0.9 - uTime * (0.5 + uEnergy), p.z * 1.4));
float n2 = fbm(vec3(p.z * 2.2 - swirl * 0.7, p.y * 1.3 + uTime * 0.4, p.x * 2.2));
// Radial displacement, stronger toward the base (smoke trail) and with energy.
float base = smoothstep(1.0, -1.0, p.y);
float amp = (0.12 + uEnergy * 0.35 + uSpeak * 0.25) * (0.4 + base);
p.x += (n - 0.5) * 2.0 * amp;
p.z += (n2 - 0.5) * 2.0 * amp;
// Breathing pulse while speaking.
float pulse = 1.0 + uSpeak * 0.18 * sin(uTime * 9.0 + p.y * 3.0);
p.xz *= pulse;
vNoise = n;
vPos = p;
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
}
`
const GHOST_FRAG = /* glsl */ `
uniform vec3 uColor;
uniform float uTime;
uniform float uEnergy;
uniform float uSpeak;
uniform float uOpacity;
uniform float uDark; // shade form absorbs light
varying vec2 vUv;
varying float vNoise;
varying vec3 vPos;
void main() {
// Fade top and bottom, fade with noise, brighter core.
float vert = smoothstep(0.0, 0.25, vUv.y) * smoothstep(1.0, 0.55, vUv.y);
float body = smoothstep(0.25, 0.75, vNoise + uSpeak * 0.2);
float a = vert * body * (0.35 + uEnergy * 0.5 + uSpeak * 0.35) * uOpacity;
vec3 col = uColor * (0.55 + uEnergy * 0.8 + uSpeak * 0.9);
col = mix(col, col * 0.12, uDark);
// Flicker.
col *= 0.92 + 0.08 * sin(uTime * 13.7 + vPos.y * 5.0);
gl_FragColor = vec4(col, a);
}
`
const WISP_VERT = /* glsl */ `
uniform float uTime;
uniform float uEnergy;
uniform float uSpeak;
uniform float uMoteSpeed;
attribute float aSeed;
varying float vSeed;
void main() {
vSeed = aSeed;
vec3 p = position;
float t = uTime * (0.12 + 0.5 * uEnergy) * uMoteSpeed + aSeed * 17.0;
float r = length(p.xz) * (1.0 + 0.2 * sin(t * 0.9 + aSeed * 6.28));
float ang = atan(p.z, p.x) + t * (0.3 + aSeed * 0.2);
p.x = cos(ang) * r;
p.z = sin(ang) * r;
p.y += sin(t + aSeed * 12.0) * (0.35 + uSpeak * 0.4);
vec4 mv = modelViewMatrix * vec4(p, 1.0);
gl_PointSize = (2.0 + aSeed * 5.0 + uSpeak * 3.0) * (18.0 / -mv.z);
gl_Position = projectionMatrix * mv;
}
`
const WISP_FRAG = /* glsl */ `
uniform vec3 uColor;
uniform float uOpacity;
varying float vSeed;
void main() {
vec2 c = gl_PointCoord - 0.5;
float d = length(c);
float a = smoothstep(0.5, 0.0, d);
gl_FragColor = vec4(uColor * (0.7 + vSeed * 0.6), a * uOpacity);
}
`
const MIST_VERT = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`
const MIST_FRAG = /* glsl */ `
uniform float uTime;
uniform vec3 uColor;
uniform float uOpacity;
varying vec2 vUv;
${NOISE_GLSL}
void main() {
vec2 c = vUv - 0.5;
float r = length(c) * 2.0;
float n = fbm(vec3(vUv * 4.0, uTime * 0.08));
float a = smoothstep(1.0, 0.1, r) * n * uOpacity;
gl_FragColor = vec4(uColor * 0.6, a);
}
`
function hsl(hue: number, s: number, l: number): THREE.Color {
return new THREE.Color().setHSL(((hue % 360) + 360) % 360 / 360, s, l)
}
/** Build the lathed "hooded wisp" figure profile. */
function buildFigureGeometry(): THREE.LatheGeometry {
const pts: THREE.Vector2[] = []
// Profile: wide smoky base → waist → shoulders → head → dissolving tip.
const profile: Array<[number, number]> = [
[0.0, -1.6],
[0.9, -1.55],
[0.75, -1.1],
[0.5, -0.55],
[0.42, -0.1],
[0.5, 0.25],
[0.46, 0.55],
[0.3, 0.8],
[0.34, 0.95], // hood
[0.22, 1.15],
[0.08, 1.35],
[0.0, 1.55],
]
for (const [x, y] of profile) pts.push(new THREE.Vector2(x, y))
const geo = new THREE.LatheGeometry(pts, 48)
geo.computeVertexNormals()
return geo
}
type FormParams = {
scale: number
stretch: number
moteCount: number
moteSpeed: number
dark: number
opacity: number
}
function formParams(form: GhostForm): FormParams {
switch (form) {
case 'fairy':
return { scale: 0.55, stretch: 0.9, moteCount: 260, moteSpeed: 2.2, dark: 0, opacity: 1 }
case 'banshee':
return { scale: 1.05, stretch: 1.55, moteCount: 140, moteSpeed: 0.8, dark: 0.15, opacity: 0.95 }
case 'shade':
return { scale: 1.1, stretch: 1.15, moteCount: 90, moteSpeed: 0.5, dark: 0.85, opacity: 0.9 }
case 'wisp':
default:
return { scale: 0.85, stretch: 1.0, moteCount: 160, moteSpeed: 1.2, dark: 0, opacity: 1 }
}
}
export class GhostScene {
private renderer: THREE.WebGLRenderer
private scene = new THREE.Scene()
private camera: THREE.PerspectiveCamera
private canvas: HTMLCanvasElement
private ghostMat: THREE.ShaderMaterial
private ghost: THREE.Mesh
private veil: THREE.Mesh // banshee trailing veil
private wispMat: THREE.ShaderMaterial
private wisps: THREE.Points
private mistMat: THREE.ShaderMaterial
private mists: THREE.Mesh[] = []
private farWisps: THREE.Points
private farWispMat: THREE.PointsMaterial
private raf = 0
private running = false
private clock = new THREE.Clock()
private resizeObserver: ResizeObserver | null = null
private mood: GhostMood = 'idle'
private visual: GhostVisualState | null = null
private speakAmp = 0
private smoothedAmp = 0
private energy = 0.15
private disposed = false
constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas
this.renderer = new THREE.WebGLRenderer({
canvas,
antialias: true,
alpha: true,
powerPreference: 'high-performance',
})
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
this.camera = new THREE.PerspectiveCamera(50, 1, 0.1, 60)
this.camera.position.set(0, 0.6, 6.2)
this.scene.fog = new THREE.FogExp2(0x07070d, 0.09)
// --- Ghost figure ---
this.ghostMat = new THREE.ShaderMaterial({
vertexShader: GHOST_VERT,
fragmentShader: GHOST_FRAG,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
side: THREE.DoubleSide,
uniforms: {
uTime: { value: 0 },
uEnergy: { value: 0.15 },
uSpeak: { value: 0 },
uFormStretch: { value: 1 },
uColor: { value: hsl(150, 0.9, 0.65) },
uOpacity: { value: 1 },
uDark: { value: 0 },
},
})
this.ghost = new THREE.Mesh(buildFigureGeometry(), this.ghostMat)
this.ghost.position.set(0, 0.4, 0)
this.ghost.visible = false
this.scene.add(this.ghost)
// Trailing veil (banshee): a second, elongated, fainter copy behind.
this.veil = new THREE.Mesh(buildFigureGeometry(), this.ghostMat)
this.veil.position.set(0.35, 0.1, -0.6)
this.veil.scale.set(0.8, 1.3, 0.8)
this.veil.visible = false
this.scene.add(this.veil)
// --- Orbiting wisps / motes ---
this.wispMat = new THREE.ShaderMaterial({
vertexShader: WISP_VERT,
fragmentShader: WISP_FRAG,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
uniforms: {
uTime: { value: 0 },
uEnergy: { value: 0.15 },
uSpeak: { value: 0 },
uMoteSpeed: { value: 1.2 },
uColor: { value: hsl(150, 0.9, 0.7) },
uOpacity: { value: 0.55 },
},
})
this.wisps = new THREE.Points(this.buildWispGeometry(260), this.wispMat)
this.wisps.position.set(0, 0.4, 0)
this.wisps.visible = false
this.scene.add(this.wisps)
// --- Ground mist planes ---
this.mistMat = new THREE.ShaderMaterial({
vertexShader: MIST_VERT,
fragmentShader: MIST_FRAG,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
uniforms: {
uTime: { value: 0 },
uColor: { value: hsl(160, 0.5, 0.4) },
uOpacity: { value: 0.16 },
},
})
for (let i = 0; i < 3; i++) {
const m = new THREE.Mesh(new THREE.PlaneGeometry(14, 14), this.mistMat)
m.rotation.x = -Math.PI / 2
m.position.set(0, -1.5 + i * 0.22, 0)
m.rotation.z = (i * Math.PI) / 3
this.scene.add(m)
this.mists.push(m)
}
// --- Far-off ambient wisps (no entity summoned) ---
const farGeo = new THREE.BufferGeometry()
const farCount = 60
const farPos = new Float32Array(farCount * 3)
for (let i = 0; i < farCount; i++) {
farPos[i * 3] = (Math.random() - 0.5) * 30
farPos[i * 3 + 1] = Math.random() * 6 - 1
farPos[i * 3 + 2] = -6 - Math.random() * 18
}
farGeo.setAttribute('position', new THREE.BufferAttribute(farPos, 3))
this.farWispMat = new THREE.PointsMaterial({
color: 0x7cffb2,
size: 0.08,
transparent: true,
opacity: 0.35,
blending: THREE.AdditiveBlending,
depthWrite: false,
})
this.farWisps = new THREE.Points(farGeo, this.farWispMat)
this.scene.add(this.farWisps)
this.handleResize()
this.resizeObserver = new ResizeObserver(() => this.handleResize())
if (canvas.parentElement) this.resizeObserver.observe(canvas.parentElement)
}
private buildWispGeometry(count: number): THREE.BufferGeometry {
const geo = new THREE.BufferGeometry()
const pos = new Float32Array(count * 3)
const seed = new Float32Array(count)
for (let i = 0; i < count; i++) {
const r = 0.5 + Math.random() * 1.6
const a = Math.random() * Math.PI * 2
pos[i * 3] = Math.cos(a) * r
pos[i * 3 + 1] = Math.random() * 3 - 1.4
pos[i * 3 + 2] = Math.sin(a) * r
seed[i] = Math.random()
}
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3))
geo.setAttribute('aSeed', new THREE.BufferAttribute(seed, 1))
return geo
}
setMood(mood: GhostMood): void {
this.mood = mood
}
/** Call every frame with the audio player's instantaneous amplitude. */
setSpeakAmplitude(amp: number): void {
this.speakAmp = Math.max(0, Math.min(1, amp))
}
setVisual(visual: GhostVisualState | null): void {
this.visual = visual
if (!visual) {
this.ghost.visible = false
this.veil.visible = false
this.wisps.visible = false
return
}
const params = formParams(visual.form)
const color = hsl(visual.hue, 0.85, 0.62)
const ghostU = this.ghostMat.uniforms
ghostU.uColor.value = color
ghostU.uFormStretch.value = params.stretch
ghostU.uDark.value = params.dark
ghostU.uOpacity.value = params.opacity
this.wispMat.uniforms.uColor.value = hsl(visual.hue, 0.9, 0.7)
this.wispMat.uniforms.uMoteSpeed.value = params.moteSpeed
this.mistMat.uniforms.uColor.value = hsl(visual.hue, 0.5, 0.4)
this.ghost.scale.setScalar(params.scale)
this.ghost.visible = true
this.wisps.visible = true
this.veil.visible = visual.form === 'banshee'
// Rebuild motes if the form wants a different count.
const current = this.wisps.geometry.getAttribute('position')?.count ?? 0
if (current !== params.moteCount) {
this.wisps.geometry.dispose()
this.wisps.geometry = this.buildWispGeometry(params.moteCount)
}
}
start(): void {
if (this.running || this.disposed) return
this.running = true
this.clock.start()
const loop = () => {
if (!this.running) return
this.raf = requestAnimationFrame(loop)
this.renderFrame()
}
this.raf = requestAnimationFrame(loop)
}
private renderFrame(): void {
const t = this.clock.getElapsedTime()
// Energy target from mood; smooth toward it.
const target =
this.mood === 'gathering' ? 0.9 : this.mood === 'attuning' ? 0.4 : this.mood === 'speaking' ? 0.55 : 0.15
this.energy += (target - this.energy) * 0.04
this.smoothedAmp += (this.speakAmp - this.smoothedAmp) * 0.25
const ghostU = this.ghostMat.uniforms
ghostU.uTime.value = t
ghostU.uEnergy.value = this.energy
ghostU.uSpeak.value = this.smoothedAmp
this.wispMat.uniforms.uTime.value = t
this.wispMat.uniforms.uEnergy.value = this.energy
this.wispMat.uniforms.uSpeak.value = this.smoothedAmp
this.mistMat.uniforms.uTime.value = t
this.mistMat.uniforms.uOpacity.value = this.visual ? 0.16 : 0.1
// Idle drift / slow camera wander.
this.ghost.position.y = 0.4 + Math.sin(t * 0.6) * 0.12
this.ghost.rotation.y = Math.sin(t * 0.23) * 0.3
this.veil.position.y = this.ghost.position.y - 0.3
this.camera.position.x = Math.sin(t * 0.11) * 0.55
this.camera.position.y = 0.6 + Math.sin(t * 0.07) * 0.25
this.camera.lookAt(0, 0.4, 0)
// Far wisps drift slowly.
this.farWisps.rotation.y = t * 0.01
this.farWispMat.opacity = this.visual ? 0.12 : 0.3 + 0.1 * Math.sin(t * 0.5)
this.renderer.render(this.scene, this.camera)
}
private handleResize(): void {
const parent = this.canvas.parentElement
const w = parent?.clientWidth || window.innerWidth
const h = parent?.clientHeight || window.innerHeight
if (w === 0 || h === 0) return
this.renderer.setSize(w, h, false)
this.camera.aspect = w / h
this.camera.updateProjectionMatrix()
}
dispose(): void {
this.disposed = true
this.running = false
cancelAnimationFrame(this.raf)
this.resizeObserver?.disconnect()
this.scene.traverse((obj) => {
if (obj instanceof THREE.Mesh || obj instanceof THREE.Points) {
obj.geometry.dispose()
const mat = obj.material
if (Array.isArray(mat)) mat.forEach((m) => m.dispose())
else mat.dispose()
}
})
this.renderer.dispose()
}
}