Lanes A (engine), B (player), C (worldgen), D (machines/quest), E (audio/fx/ui) as landed, each with HANDOFF.md + update docs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6.5 KiB
LANE E — Audio, FX & UI
Read first: docs/DESIGN.md (§5–§7), docs/CONTRACTS.md, everything in
src/core/.
You own: src/audio/**, src/fx/**, src/ui/**, src/demo/audioDemo.ts,
demo-audio.html.
You may not touch anything else.
Mission
Make the booth alive: a fully synthesized house groove in stems that unmute as the quest progresses, positional audio from the decks, beat events driving LED pulses/VU meters/particles, all diegetic SFX, and the HUD. "Sound is the win state" — you own the payoff of the whole game.
Provides
AudioEngine— WebAudio graph,init()on first user gesture,setStemCount(n)(0–5 stems audible),setChannelGain(ch, v)(live fader ducking),setPlaybackState(deck, playing, rpm)(pitch-bend spin-up/brake),playSfx(name, at?: Vec3), positional listener syncupdateListener(view: IPlayerView).FxSystem—update(dt), needs{ scene, setEmissiveBoost }injected (Lane A's hook), owns particles + LED pulse logic + VU animation hooks.Hud— DOM overlay: crosshair, hotbar (renders Lane D's hotbar model via events/injected getter), quest tracker (5 nodes), subtitles line ("SIGNAL RESTORED: CROSSFADER — 3/5"), win screen, start/pause overlay (pointer-lock gate + "click to drop the needle" splash).
Consumes
bus events (signal:repair, game:win, platter:state, fader:move,
audio:* are yours to emit, block:break/place, player:step/landed,
machine:interact), IPlayerView interface, blockDef().sound categories.
Tasks
E1. The groove (all synthesized, no samples)
- 118 BPM, 8-bar loop, five stems built from oscillators/noise through a
shared WebAudio clock (lookahead scheduler, ~25 ms tick — do not use
setIntervalnaively for note timing; schedule onAudioContext.currentTime):- drums — kick (sine drop + click), hats (filtered noise, offbeat), clap on 2/4
- bass — detuned saw through lowpass, one-bar riff, sidechain-ducked from the kick (a simple gain envelope is fine)
- chords — filtered saw stabs, minor 7th vamp, ping-pong-ish delay
- lead — square/sine hook, sparse (every 4th bar), long release
- sweeps — noise riser + downlifter each 8 bars, vinyl crackle bed
- Stems map to quest progress (
setStemCount): silence → drums → +bass → +chords → +lead → +sweeps/full. Keep musical when partial. - Vinyl-truth: master through a gentle highshelf + crackle when playing off
the blue record;
setPlaybackStatebends a master playbackRate-equivalent (detune all stems' pitch AND tempo via a global rate param — simplest: scale scheduler tempo and per-voice detune together) for spin-up/brake, and pitch-fader moves detune ±. - Positional: stems output into two
PannerNodes at the deck spindle world positions (constants LAYOUT), plus a dry low bed so it never fully disappears;updateListenerfollows the player eye/orientation.
E2. Analysis & beat events
- Drive
audio:beat { energy }from the scheduler itself (you know when the kick fires — no FFT needed; energy = current kick gain),audio:bar. Additionally anAnalyserNodeon master for VU levels (exposegetLevels(): { low, mid, high }for FX).
E3. SFX (synthesized, short)
- Footsteps by
blockDef.soundcategory (metal/wood/plastic/soft/glass — filtered noise bursts with different bodies), land thump scaled by impactSpeed, block break/place clicks per category, fader zip, button clunk, RCA "clunk-clunk-CLICK" (rising), fuse zap, tonearm cue lever creak, win: needle-drop crackle → the full mix slams in (brief lowpass sweep open).
E4. FX systems
- LED pulse: on
audio:beat, pulse Lane A'ssetEmissiveBoost(1.0 → ~1.8 decay ~150 ms) — but only after ≥1 repair (dead booth = dim: start boost at 0.35, step toward 1.0 per repair). - VU towers: Lane C built LED columns at known LAYOUT-derived positions;
animate them by swapping emissive boost per-level is Lane-A-internal, so
instead: overlay thin emissive sprite quads you own, positioned over the
tower faces, height-keyed to
getLevels(). Same trick for the patch-bay blink and signal-path trace on repairs (a moving bright sprite running the cartridge→tonearm→cable→mixer path — hardcode the polyline from LAYOUT). - Particles: drifting dust motes in light shafts (a few hundred point sprites, gentle brownian), block-break puff at break position, record-rim sparkle when a platter spins at 45.
- Win sequence: on
game:win— 2 s: kill boost, silence… then needle drop, full mix, boost pulse ×2 for 8 bars, every LED sprite strobing to levels, confetti of tiny vinyl-black quads over the decks. Then settle to living-booth steady state.
E5. HUD/UI
- DOM (not canvas): crosshair; hotbar strip (9 slots, block tint swatches +
counts, active ring); quest tracker top-right (5 icons, lit as repaired);
event subtitle line; start overlay (title "TURNCRAFT", "click to drop the
needle") gating AudioContext + pointer lock; pause on lock-loss; win
banner. Styling: dark, minimal, DJM-font-ish (system mono is fine),
amber/green LED accent colors from
blocks.tstints.
Demo (demo-audio.html)
No other lanes: a mock stage (dark plane, two glowing discs at deck
positions, a camera you orbit) + a control panel: stem count slider 0–5,
play/stop per deck with spin-up bend, pitch slider, channel-fader sliders
(live ducking), every SFX as a button, beat-pulse visual (the discs flash via
the same code path as setEmissiveBoost — inject a mock), quest simulator
buttons firing signal:repair 1→5 then game:win to preview the entire
audio-visual arc, HUD rendered live with a mock hotbar.
Acceptance
- Groove runs indefinitely with zero timing drift (scheduler, not setInterval); stems musically stack 0→5
- Spin-up/brake audibly bends tempo+pitch together; pitch fader detunes
- Walking between decks audibly pans/attenuates (test with camera move)
- Every SFX distinct and non-clipping; footsteps vary by surface category
- Beat pulse, VU sprites, dust motes, break puffs all run at 60 fps
- Full win sequence previews correctly from the quest simulator
- No audio starts before user gesture;
npm run typecheckclean;HANDOFF.md
Out of scope
Gameplay logic (D), world/meshes (A/C), player (B). You react to events and
render overlays/sprites you own — you never setBlock and never mutate
another lane's materials beyond the provided setEmissiveBoost hook.