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>
8.5 KiB
LANE E — Update (Audio / FX / UI)
Status: ✅ Complete, typechecks clean (whole 5-lane repo), verified live in the browser, adversarially reviewed (6 findings — all fixed).
Lane E owns src/audio/**, src/fx/**, src/ui/**, src/demo/audioDemo.ts,
demo-audio.html. Built strictly against the contract layer in src/core/ —
nothing there was edited, no setBlock anywhere, no imports of other lanes'
concrete code, no rAF loop outside the demo, no new runtime deps (three only),
all audio synthesized, all textures procedural.
What I built
Audio (src/audio/) — "sound is the win state"
synth.ts— low-level voices: kick (pitch-drop sine + click), hats, clap, detuned-saw bass, resonant chord stabs, square/sine lead, noise sweeps, plus procedural white-noise and vinyl-crackle buffers. No samples.scheduler.ts— a drift-free lookahead transport (25 ms tick, notes scheduled onAudioContext.currentTime, notsetIntervaltiming). A singleratemodels the Technics spin-up/brake: it eases torque-ily toward its target and scales tempo and per-voice detune together. Beat/bar events are queued and fired at their audible time so LED pulses stay in sync.groove.ts— the endless 118 BPM, 8-bar F-minor deep-house loop in five stems (drums → bass → chords → lead → sweeps).setStemCount(0..5)stacks them musically; muted stems are not scheduled (near-zero cost). Sidechain- ducked bass.sfx.ts— every diegetic one-shot: footsteps per surface category, impact-scaled land, break/place, fader zip, button clunk, RCA "clunk-clunk-CLICK", fuse zap, tonearm creak, needle drop.AudioEngine.ts— the WebAudio graph, positional sound from two PannerNodes at the deck spindles (+ an always-on low-passed dry bass bed), the analyser for VUgetLevels(), the full bus wiring, and the win audio timeline (silence → +1.8 s needle-drop → full mix slams in behind an opening lowpass — all scheduled on the ctx clock). Nothing sounds beforeinit()on a user gesture (autoplay policy).
FX (src/fx/) — the booth comes alive
particles.ts—DustField(ambient brownian motes) andBurst(a round-robin pool powering break puffs, win confetti, and 45-rpm rim sparkle). Preallocated typed arrays, mutated in place — zero per-frame allocation. One procedural glow texture, no assets.overlays.ts— emissive sprite overlays we own (never mutating another lane's materials): mixer VU towers keyed togetLevels(), a bright sprite that runs the signal-path polyline on each repair, and a patch-bay blink.layout.ts— every overlay anchor derived fromconstants.tsLAYOUT.FxSystem.ts— the global LED pulse via Lane A's injectedsetEmissiveBoost(dead-booth base 0.35 → steps toward 1.0 over the five repairs → pulses to ~1.8 on beats), plus the win visual timeline (1.8 s dark → drop flash + confetti → living-booth steady state).
UI (src/ui/Hud.ts) — DOM overlay
Crosshair, 9-slot hotbar (tint swatches + counts + active ring, via an injected
getHotbar()), 5-node quest tracker, event subtitle line, the start splash
that gates the AudioContext + pointer lock, a pause overlay, and the win
banner. pointer-events:none except the gate screens, so it never eats
gameplay input. Dynamic text via textContent (no injection risk).
Demo (demo-audio.html + src/demo/audioDemo.ts)
Runs with zero code from other lanes: a mock stage (dark tabletop + two
glowing "record" discs at the real deck spindle positions, orbit camera) and a
full control panel exercising every acceptance criterion, including a quest
simulator that previews the entire signal→stem→light arc and the win sequence.
A mock setEmissiveBoost flashes the discs on every beat via the real FX code
path; a mock hotbar feeds the HUD.
Acceptance criteria — all met
| # | Criterion | Status |
|---|---|---|
| 1 | Groove runs indefinitely with zero drift; stems stack 0→5 | ✅ lookahead scheduler; gated stems |
| 2 | Spin-up/brake bends tempo+pitch together; pitch fader detunes | ✅ single rate → tempo+detune; setPitch |
| 3 | Walking between decks pans/attenuates | ✅ two PannerNodes + listener follow; dry bed remains |
| 4 | Every SFX distinct, non-clipping; footsteps vary by surface | ✅ per-category footstep bodies |
| 5 | Beat pulse, VU, dust, break puffs at 60 fps | ✅ zero-alloc hot paths, verified live |
| 6 | Full win sequence previews from the quest simulator | ✅ verified end-to-end in browser |
| 7 | No audio before user gesture; typecheck clean; HANDOFF | ✅ start-splash gate; clean; 3 HANDOFFs |
Verification
npm run typecheck— clean across the entire repo (all five lanes are now present; the whole tree compiles under strict mode).- Browser smoke test (
/demo-audio.html): start gate → booth alive (discs, VU towers, dust, hotbar) → all five repairs light the tracker, run the LED signal-path trace, show subtitles, and step the booth brighter → WIN plays the dark→needle-drop→slam sequence with the "THE MIX IS LIVE" banner. Zero console errors across the whole session.
Adversarial review (multi-agent) — 6 findings, all fixed
Ran a 5-dimension review (contract / audio / fx / ui / demo) with an adversarial verify pass. 10 raw findings → 4 refuted as false positives → 6 confirmed, all now fixed:
- [med] Hud — pause "resume" reused
onStart, re-running first-start side effects. → Added a separateonResumecallback (re-lock only). - [low] FxSystem —
game:winwasn't idempotent (audio was), so a repeated event desynced visuals vs audio. → Guarded with an already-won check. - [low] groove —
audio:beat.energywas a bare constant vs the contract's "low-band energy". → Now scaled by the drums channel gain. - [low] Hud — the win-banner
setTimeoutleaked (not cleared indispose). → Tracked and cleared. - [low] demo — control panel
z-indexsat above the start splash, defeating the gesture gate. → Panel is hidden until start. - [low] demo — resume force-restarted Deck A and desynced its button. →
hasStartedguard + button label sync + pureonResume.
Fix #1 changed the Hud public API (added optional onResume) — noted in the
HANDOFFs. All fixes re-typechecked and re-verified live.
Notes for integration (friction to confirm — details in src/audio/HANDOFF.md)
fader:move→ channel mapping: I mapfaderIdcontainingpitch→ pitch bend, else the first digitN→ stem channelN-1. Confirm Lane D'sfaderIds (crossfader / named channels) and extend if needed.- Channel↔stem mapping: 5 stems vs 4 mixer channels + crossfader —
setChannelGain(ch, v)treatschas a stem index 0..4. player:landed/player:stepcarry no surface category / position; I use the last stepped surface and listener-centered playback.setEmissiveBoostsemantics: I drive it as an absolute intensity (0.35 → 1.0 base, ~1.8 peak). If Lane A's hook is a multiplier, tuneFxSystemconstants.- VU tower positions are derived from
LAYOUT.mixer. If Lane C placed physical LED towers elsewhere, alignsrc/fx/layout.tsVU_TOWERS. - Hotbar model — no core type;
HudexportsHotbarModel/HotbarSlot, consumed via injectedgetHotbar(). Adapt Lane D's hotbar to this shape. game:winforces deck playback in audio; ensure Lane D also spins the real platters at the finale so audio and visuals agree.
Wiring is exactly as docs/INTEGRATION.md step 8 describes:
AudioEngine + FxSystem({ scene, setEmissiveBoost }) + Hud, audio init
behind the start-overlay click, fx.update(dt) + audio.updateListener(player)
each frame.
Repo / git
The working tree is not yet a git repo locally, and the fresh remote
(ssh://git@100.71.119.27:222/monster/TURNCRAFT.git) is empty. I did not
git init/commit/push — that's a cross-lane integration decision, so I left it
for Fable/the integrator to sequence (init, land all five lanes, run
docs/INTEGRATION.md, then push).
Suggested next steps for Fable
- Integrate per
docs/INTEGRATION.md(all five lanes'HANDOFF.mdfirst, resolve the friction items above — especially #1, #4, #6). - Wire
src/main.tsstep 8 and run the smoke tests. - Once integrated, do a full-game soak (the demo already proves Lane E holds 60 fps and never drifts in isolation).