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>
125 lines
6.5 KiB
Markdown
125 lines
6.5 KiB
Markdown
# 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 sync `updateListener(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
|
||
`setInterval` naively for note timing; schedule on `AudioContext.currentTime`):
|
||
1. **drums** — kick (sine drop + click), hats (filtered noise, offbeat),
|
||
clap on 2/4
|
||
2. **bass** — detuned saw through lowpass, one-bar riff, sidechain-ducked
|
||
from the kick (a simple gain envelope is fine)
|
||
3. **chords** — filtered saw stabs, minor 7th vamp, ping-pong-ish delay
|
||
4. **lead** — square/sine hook, sparse (every 4th bar), long release
|
||
5. **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; `setPlaybackState` bends 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 `PannerNode`s at the deck spindle world
|
||
positions (constants LAYOUT), plus a dry low bed so it never fully
|
||
disappears; `updateListener` follows 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 an `AnalyserNode` on master for VU levels (expose
|
||
`getLevels(): { low, mid, high }` for FX).
|
||
|
||
### E3. SFX (synthesized, short)
|
||
- Footsteps by `blockDef.sound` category (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's `setEmissiveBoost` (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.ts` tints.
|
||
|
||
## 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 typecheck` clean; `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.
|