Add EMF field mode, Armory shop/waitlist, and ambient haunting layer
Three self-contained features, verified complete and cross-wired end-to-end (audited: backend 54/54 tests, frontend 110/110 tests, tsc --noEmit clean, i18n coverage script clean): - EMF mode: DeviceMotion/DeviceOrientation-based field-meter sensing, a fifth séance channel alongside Wire/EVP/Radio/Ouija, with its own fragment prompt persona and full frontend gauge UI. - Armory (shop/waitlist): pre-order capture page for the future Ultimate Quantum Box hardware line, rate-limited public endpoint, explicitly no payment collection. - Haunting layer: ambient possession effects (dread-bed audio, title glitching, idle-paced whispers/manifests), respects prefers-reduced-motion, mounted once at the app root. Plus WebUSB robustness fixes in lib/sdr.ts (Terratec vendor ID, explicit selectConfiguration, isSecureContext gate). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013PphXq1s43DNRj1uWKGXof
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { GhostScene } from './GhostScene'
|
||||
import type { GhostMood, GhostVisualState } from './GhostScene'
|
||||
import { hauntBus } from '../lib/haunting'
|
||||
|
||||
export type GhostCanvasProps = {
|
||||
visual: GhostVisualState | null
|
||||
@@ -26,7 +27,10 @@ export function GhostCanvas({ visual, mood, getAmplitude, className }: GhostCanv
|
||||
const ampTimer = window.setInterval(() => {
|
||||
scene.setSpeakAmplitude(ampRef.current?.() ?? 0)
|
||||
}, 50)
|
||||
// Apparition flares from the haunting layer surge the ghost.
|
||||
const offFlare = hauntBus.on('flare', ({ strength }) => scene.flare(strength))
|
||||
return () => {
|
||||
offFlare()
|
||||
window.clearInterval(ampTimer)
|
||||
scene.dispose()
|
||||
sceneRef.current = null
|
||||
|
||||
@@ -234,6 +234,10 @@ export class GhostScene {
|
||||
private smoothedAmp = 0
|
||||
private energy = 0.15
|
||||
private disposed = false
|
||||
private flareAmp = 0
|
||||
private lastT = 0
|
||||
private baseScale = 1
|
||||
private baseOpacity = 1
|
||||
|
||||
constructor(canvas: HTMLCanvasElement) {
|
||||
this.canvas = canvas
|
||||
@@ -373,6 +377,14 @@ export class GhostScene {
|
||||
this.speakAmp = Math.max(0, Math.min(1, amp))
|
||||
}
|
||||
|
||||
/**
|
||||
* Brief apparition surge (strength 0..1): brightness, scale and agitation
|
||||
* spike, decaying back to normal over ~1.2s. Stacking flares take the max.
|
||||
*/
|
||||
flare(strength: number): void {
|
||||
this.flareAmp = Math.max(this.flareAmp, Math.max(0, Math.min(1, strength)))
|
||||
}
|
||||
|
||||
setVisual(visual: GhostVisualState | null): void {
|
||||
this.visual = visual
|
||||
if (!visual) {
|
||||
@@ -391,6 +403,8 @@ export class GhostScene {
|
||||
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.baseScale = params.scale
|
||||
this.baseOpacity = params.opacity
|
||||
this.ghost.scale.setScalar(params.scale)
|
||||
this.ghost.visible = true
|
||||
this.wisps.visible = true
|
||||
@@ -417,24 +431,33 @@ export class GhostScene {
|
||||
|
||||
private renderFrame(): void {
|
||||
const t = this.clock.getElapsedTime()
|
||||
const dt = Math.min(0.1, Math.max(0, t - this.lastT))
|
||||
this.lastT = t
|
||||
|
||||
// Energy target from mood; smooth toward it.
|
||||
// Apparition surge decays linearly over ~1.2s.
|
||||
this.flareAmp = Math.max(0, this.flareAmp - dt / 1.2)
|
||||
|
||||
// Energy target from mood; smooth toward it. A flare agitates on top.
|
||||
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 flaredEnergy = Math.min(1, this.energy + this.flareAmp * 0.55)
|
||||
|
||||
const ghostU = this.ghostMat.uniforms
|
||||
ghostU.uTime.value = t
|
||||
ghostU.uEnergy.value = this.energy
|
||||
ghostU.uEnergy.value = flaredEnergy
|
||||
ghostU.uSpeak.value = this.smoothedAmp
|
||||
ghostU.uOpacity.value = Math.min(1.8, this.baseOpacity * (1 + this.flareAmp * 0.9))
|
||||
this.wispMat.uniforms.uTime.value = t
|
||||
this.wispMat.uniforms.uEnergy.value = this.energy
|
||||
this.wispMat.uniforms.uEnergy.value = flaredEnergy
|
||||
this.wispMat.uniforms.uSpeak.value = this.smoothedAmp
|
||||
this.wispMat.uniforms.uOpacity.value = 0.55 * (1 + this.flareAmp * 0.9)
|
||||
this.mistMat.uniforms.uTime.value = t
|
||||
this.mistMat.uniforms.uOpacity.value = this.visual ? 0.16 : 0.1
|
||||
|
||||
// Idle drift / slow camera wander.
|
||||
// Idle drift / slow camera wander; the flare swells the apparition.
|
||||
this.ghost.scale.setScalar(this.baseScale * (1 + this.flareAmp * 0.14))
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user