SIDE B: the crate worlds — three dive-in pockets, stamps, golden slipmat

Three records in the under-table crate are pressed with whole scenes. E beside
one dives you into its pocket above the booth walls; complete its sound-first
quest to press a STAMP; all three raise the golden slipmat + a permanent bonus
percussion stem. Dust bunnies move in under the table.

- Worlds (Lane W): ACID WAREHOUSE / DUB CHAMBER / DISCO LOFT — sealed themed
  pockets, portal rings, exit pads; deterministic, zero light leaks (incl. the
  flythrough path), meshes unchanged at 469, build ~760 ms.
- Machines (Lane M): portal dive/exit/safety (membership-based escape catch),
  tune-the-303 hold-sweep pedestals, carry-the-charge wobbling spring bridge,
  beat-Simon dance floor with glow tiles; stamps store + relay {t:'stamp'}
  (default-closed, persisted, join snapshot, fuzzed); golden slipmat; stamps
  survive the reset ritual.
- Ambience (Lane S): dust bunnies (wander/flee, zero steady-state allocs),
  vinyl-warp portal iris, HUD stamp tray, stamp confetti + golden shimmer.
- Audio (Lane E2): per-world grooves (acid 303 w/ fader-driven bands, dub
  feedback-echo + spring, disco pentatonic tiles) over a boothGain
  duck-and-handback that can never restore stale state; portal/stamp/golden
  SFX; bonus stem.

Post-integration review (2 seam finders, 10 findings, all fixed + verified):
stamp events carry origin (local/remote/replay) so consumers celebrate
proportionately instead of wall-clock guessing — kills phantom join
celebrations, first-person confetti for peers' wins, and audio/visual grace
mismatches; reconnect re-uploads locally-won stamps the server missed;
relay saveState survives disk errors; pocket escape via mined floor counts as
a real exit at any altitude; exit-pad speed gate + dwell stops mid-quest
yank-outs; dive cooldown stops exit/re-dive ping-pong; locked acid bands
need deliberate holds (tap-spam dead); disco foot band excludes jump apex.

Verified: whole-tree typecheck + build clean; per-lane live verification
(27/27 worldgen assertions, relay fuzz, 18-phase audio matrix, fps unchanged);
integrated smoke: dive/exit/safety/stamps/golden + all origin paths live.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-08-16 16:22:11 +10:00
parent 758e9dc4d3
commit 33232fdeba
40 changed files with 4485 additions and 22 deletions

126
docs/SIDEB_E2_handoff.md Normal file
View File

@ -0,0 +1,126 @@
# SIDE B — Lane E2 handoff (audio: world grooves + SIDE B sound)
Everything in `src/audio/**` only. `npm run typecheck` clean. AudioEngine
still self-wires to the bus, so the integrator changes **nothing** in
`src/main.ts` for this lane — construct `AudioEngine` exactly as before.
## Files touched
| file | change |
|---|---|
| `src/audio/worldGrooves.ts` | **new** — the three pocket grooves (acid 118 / dub 74 half-time / disco 122), each on its own lookahead Transport with spin-up/brake; quest hooks (acid bands + locks, dub echo/carry/feed, disco cues/steps, stamp glory) |
| `src/audio/worldSynth.ts` | **new** — SIDE B voices: the 303, shimmer/lock chime, dub sub/rimshot/skank/spring-boing/feed-burst, disco oct-bass/string stab/cue blip/string swell |
| `src/audio/AudioEngine.ts` | `boothGain` bus (every booth source funnels through it; pockets duck THIS), `worldBus` (+ analyser tap so pocket grooves drive the VU), world enter/exit + stamp + stamp:all handlers, SIDE B `machine:interact`/`fader:move` routing, new playSfx names |
| `src/audio/groove.ts` | bonus percussion stem (shaker + conga clave, own gain outside the 5 fader channels, schedule-gated) + `setEmitBeats` (beat/bar suppression during dives) |
| `src/audio/scheduler.ts` | additive `bpm` constructor param (booth default 118 unchanged) |
| `src/audio/synth.ts` | additive `shaker`, `conga` voices |
| `src/audio/sfx.ts` | additive `portalWhoosh`, `stampThunk`, `stampFanfare`, `goldenRiser`, `bunnySqueak` (+ SfxName entries `portalIn/portalOut/stampThunk/goldenRiser/bunny`) |
| `src/demo/audioDemo.ts` | SIDE B panel (world buttons, three acid faders + locks, dub pickup/feed, 4×4 disco tiles + step echo, stamps, bunny/airhorn/blow-fuse) + a marked `DEMO MOCK` automation handle (`window.DEMO`) |
## Duck-and-handback model (why a reset can't wedge it)
All booth music (stems via panners + dry bed **and the zero-repair
heartbeat**) funnels through one `boothGain`. `world:enter` ducks that gain to
0 in 300 ms and spins up the pocket groove on `worldBus`; `world:exit`
reverses. The booth graph stays **live underneath the duck** — repairs, the
reset ritual power-down, a win, remote stem changes all keep flowing — so the
handback never restores a snapshot; it reveals the booth's *current* state.
Verified: dead booth (heartbeat returns), won booth (full mix returns), and
`quest:reset` fired **while inside** a pocket (power-down runs muffled under
the groove; exit lands on the post-reset heartbeat).
## Events consumed (exact expectations)
- `world:enter { world }` / `world:exit { world }``world` in
`acid|dub|disco` (anything else is ignored). Enter = portal whoosh-down +
duck + groove spin-up. Exit = reverse whoosh + brake + handback. Re-entering
the same world while inside it is a no-op; entering a second world
hard-switches.
- `fader:move { faderId: 'acid_0'|'acid_1'|'acid_2', value: 0..1 }` — that
band's 303 cutoff, exponential across per-band offset ranges
(1102600 / 1603800 / 2405600 Hz), so the sweep is loudly audible
anywhere in 0..1 and Lane M's hidden targets can sit anywhere. **No booth
channel is touched** (the old digit→channel map is bypassed for `acid*`) and
no fader-zip spam. Values are remembered across dives and pre-init.
- `machine:interact` actions (verified against Lane M's
`src/machines/worlds/*` in-tree — their `interactAt()` both casts a runtime
`index` property in AND suffixes machineId; either alone satisfies me):
- `acid_lock` (their machineId `acid_ped<i>`, index prop) — lock chime +
that band's shimmer layer (16th-offbeat pings). Locks reset on every
`world:enter` acid (targets re-seed per entry). `acid_knob` and
`acid_tuned` are swallowed silently — the cutoff sweep / stamp glory IS
the sound.
- `dub_pickup` / `dub_drop` — echo wet 0.32 ↔ 0.8 over ~0.8 s ("carry the
wet"). Their chasm-fall `dub_drop` is handled.
- `dub_feed` — the big SPROING: bandpassed burst into the echo, feedback
0.55 → **0.8 for ~2 s** → back, wet settles down after.
- `disco_cue` (machineId `disco_tile_<0..15>`) — pentatonic blip, pitch
rises with index (F-minor pentatonic, 2+ octaves).
- `disco_step` (same scheme) — the player's step echoes that tile a fifth
down, softer. (Lane M already emits this in their WIP — confirmed.)
- `disco_miss` — a gentle descending "aw" (THE FLOOR FORGIVES — never a
buzzer); `disco_lit` swallowed (the stamp swell follows immediately).
- `portal_dive` / `portal_exit` — swallowed: the `world:enter`/`world:exit`
whooshes carry the moment (prevents a stray generic button clunk).
- `bunny_flee` — shy squeak, throttled to ≥1.1 s apart (Lane S already
caps at 1/s globally).
- `stamp:got { world, count, total }` — press-THUNK + fanfare in that world's
key + in-groove glory (acid: 4 bars of claps + octave doubling; dub: big
boing; disco: filtered string swell). **Deduped per world per session**
join-snapshot re-emits are silent. `count===total` also arms the golden
unlock (below), so `stamp:all` is not strictly required.
- `stamp:all {}` — golden riser (once per session), then the **bonus
percussion stem** (shaker + conga) enters the booth mix at the riser's bloom
and stays permanently (survives `quest:reset` — stamps live in the records,
not the booth). It rides whenever the booth mix is audible (gated on
stemCount > 0 so a dead booth never shakes alone) and sits outside the five
fader channels.
Also still consumed as before: `platter:state`, `signal:repair`, `game:win`,
`quest:reset`, `player:emote`, `workshop:*`, `block:*`, `player:*`.
## Events emitted
- `audio:beat { energy }` / `audio:bar { bar }` — **while inside a pocket
these come from the pocket groove's grid** (acid 118 / dub 74 / disco 122);
the booth groove's emission is suppressed for the duration so two tempos
never fight. Lane M's disco sequence should simply listen to `audio:beat`
as briefed — inside the loft it will be the loft's 122 BPM pulse, including
in a dead booth.
## What I parse (index conventions)
The typed bus payload for `machine:interact` has no `index` field, so I
resolve the index in this order: a runtime `index` property if cast in; else
**trailing digits of `machineId`**; else trailing digits of `action`; else a
sane fallback (first unlocked band / tile 0). Lane M's `worlds/common.ts`
`interactAt()` supplies BOTH the runtime prop and machineId digits — fully
compatible; nothing further needed.
## Lane M coordination — all asks already satisfied in their WIP (verified in-tree)
1. **`disco_step`** (player steps a tile → fifth-down echo): not in the
briefed vocab, but Lane M's `discoQuest.ts` already emits it. If that ever
changes, steps just go silent — nothing breaks.
2. **Boot handshake for stamps**: their `Stamps.apply()` is idempotent and
re-emits `stamp:got` per stamped world on join snapshots (and `stamp:all`
when the third lands, join included). Exactly what I assume: I dedupe
thunks per world per session, the golden riser fires once per session, and
any pre-gesture (pre-`audio.init`) stamp events arm the bonus stem
silently. A won crate on a fresh boot = bonus stem armed, no riser replay.
3. **`dub_drop` on a chasm fall**: their `dubQuest.ts` emits it. Wet falls
back as intended.
## Notes for the integrator
- Nothing in `src/main.ts` changes; `window.TURNCRAFT.audio` gains no new
required calls. New public surface: playSfx names
`portalIn/portalOut/stampThunk/goldenRiser/bunny` (demo/testing sugar).
- The win timeline / reset ritual / heartbeat behaviors are byte-compatible
when no SIDE B event ever fires.
- Verified live in `demo-audio.html` (SIDE B panel drives the real bus
events) and in the real game on the `turncraft-laneB` config (port 5184)
by emitting the same events from the console — see the demo's header
comment for the by-ear checklist. Zero console errors from audio code.
(During shared-tree testing a `ws://localhost:8433` error appears when the
relay isn't running — that's NetClient/Lane M territory, not audio.)

156
docs/SIDEB_M_handoff.md Normal file
View File

@ -0,0 +1,156 @@
# SIDE B — Lane M handoff (portals, world quests, stamps, relay)
**Brief:** [docs/briefs/SIDEB_MACHINES.md](briefs/SIDEB_MACHINES.md)
**Status:** ✅ All items delivered and verified live in the real game against a
real local relay AND against Lane W's landed pocket geometry (their build
arrived mid-session; every position below is aligned to their actual voxels,
not brief guesses). `npm run typecheck` clean; zero console errors in the
final build; `demo-machines.html` still runs standalone. **Not committed**
integrator commits (shared tree).
---
## Files touched (all inside Lane M ownership)
| file | what |
|---|---|
| `src/machines/worlds/common.ts` | **new** — pocket bounds/nearest-world helpers, structural `teleport`/`isFlying` access to Lane B's player (no cross-lane import), `interactAt()` (the `index`-carrying `machine:interact` emitter), mulberry32 |
| `src/machines/worlds/stamps.ts` | **new**`Stamps` store (Set<WorldKey>): idempotent `apply()` emits `stamp:got` / `stamp:all` + HUD toasts (suppressed by `quiet` for join replay); `Stamps.coerceKey` default-closed wire validation |
| `src/machines/worlds/portal.ts` | **new** — three dive plates at the crate discs (E → `world:enter` + teleport to `entry`), exit pads (stand 0.25 s → `world:exit` + teleport to `returnPos`), y>141 out-of-pocket safety net, proximity HUD prompts, accent glow panes |
| `src/machines/worlds/acidQuest.ts` | **new** — Tune the 303: 3 hold-E sweep pedestals, per-entry seeded hidden targets, release-inside-±0.07 locks, `fader:move acid_0..2` + `workshop:torque` gauge |
| `src/machines/worlds/dubQuest.ts` | **new** — Feed the Spring: charge carry (wire-carry pattern), 8-segment wobbling kinematic deck overlay (travelling sine, honest `velocityAt`), pit-fall forgiveness, mid-deck mouth feed |
| `src/machines/worlds/discoQuest.ts` | **new** — beat-Simon on the 4×4 tile grid: rounds 4→8, standing-on-tile detection, `audio:beat` sync + 122 BPM free-run fallback, **tile glow plates (lighting is Lane M's, per Lane S's scope)** |
| `src/machines/machine.ts` | + `IdInteractions` (optional structural per-collider press/hold surface for world machines) |
| `src/machines/index.ts` | constructs Portals + 3 quests + `Stamps` inside `createMachines`; `MachineSet.stamps`; `stamp:all` → deck A golden slipmat; re-exports |
| `src/machines/platter.ts` | + `setGoldenSlipmat(on)` / `isGoldenSlipmat()` (gold emissive material swap, idempotent) |
| `src/interact/interaction.ts` | generalized the workshop's press/hold routing: machines exposing `IdInteractions` get E with the hit collider id and may run hold-E; with none, behavior is byte-identical to before |
| `src/net/NetClient.ts` | sends `{t:'stamp', w}` on local `stamp:got` (guarded, re-validated); applies remote `stamp` + `hello.state.stamps` (quiet) via `Stamps.apply` |
| `server/relay.mjs` | **one additive message** `{t:'stamp', w}` — see protocol below |
**No other lane's files were touched.** Lane W's geometry constants are
mirrored (with file references), not imported — cross-lane imports stay
contract-only.
## Integrator wiring needed in main.ts: **none**
Everything rides existing wiring: the world machines are created inside
`createMachines()` (colliders/scene/update/attachPlayer already flow through
`MachineSet`), and the stamp sync lives in `NetClient`, which already receives
`machines`. Two facts worth knowing, no action required:
- `MachineSet` gained a `stamps: Stamps` field.
- Portals reach `PlayerController.teleport` / `isFlying` **structurally** via
`attachPlayer`'s object. A player without `teleport` (demo mock) means dives
emit events but don't move — harmless in the demo, real game unaffected.
## Event vocabulary emitted (for Lanes S / E2)
The `machine:interact` payloads carry the index BOTH ways E2's handoff asks
for: **trailing digits of `machineId`** (`acid_0..2`, `disco_tile_0..15`) AND
a runtime `index` property (typed-contract-safe via a widened variable —
`core/events.ts` untouched).
| event | when |
|---|---|
| `world:enter {world}` | E on a dive plate, before the teleport (S iris, E2 duck+groove) |
| `world:exit {world}` | exit pad (0.25 s stand) or the y>141 safety net |
| `fader:move {faderId:'acid_0'..'acid_2', value}` | pedestal sweeps; **initial (wrong) values re-emitted on `world:enter` + once more ~0.35 s later** (registration-order proofing) |
| `machine:interact {machineId:'acid_<i>', action:'acid_knob', index}` | ~6/s while a band sweeps |
| `machine:interact {machineId:'acid_<i>', action:'acid_lock', index}` | band locked (value snaps to target, so the last `fader:move` is exactly right) |
| `machine:interact {machineId:'acid', action:'acid_tuned'}` | all three locked (fires on toy replays too; stamp events do not) |
| `machine:interact {machineId:'dub', action:'dub_pickup' / 'dub_drop' / 'dub_feed'}` | charge taken / lost (pit fall OR leaving the pocket mid-carry) / fed to the tank |
| `machine:interact {machineId:'disco_tile_<i>', action:'disco_cue', index}` | sequence playback, one per beat |
| `machine:interact {machineId:'disco_tile_<i>', action:'disco_step', index}` | player steps a correct tile (E2 echoes a fifth down) |
| `machine:interact {machineId:'disco', action:'disco_miss' / 'disco_lit'}` | wrong step (gentle reset) / full 8 done |
| `machine:interact {machineId:'portals', action:'portal_dive' / 'portal_exit'}` | teleports (extra flavor hooks, ignorable) |
| `stamp:got {world, count, total}` | stamp landed: local win, remote win, **and once per stamped world from the join snapshot** (idempotent — never re-fires for a world already applied) |
| `stamp:all {}` | third stamp — exactly on the fresh transition, including when the join snapshot completes the set |
| `workshop:torque {value / null}` | acid hold gauge (reuses the HUD radial) |
| `workshop:msg {text}` | prompts/toasts (join-replay stamp toasts suppressed) |
Consumed: `audio:beat` (disco show pacing; free-runs at 122 BPM when silent —
the quest can't stall in a dead booth), `world:enter/exit` (self-wiring).
**Join handshake (S §"Join-replay coordination", E2 §"boot handshake"):**
`stamp:got`/`stamp:all` replays fire while `NetClient` handles `hello`, i.e.
on the ws connect during page boot — measured well inside FxSystem's 4 s
celebration-suppression grace. One edge: if the relay is unreachable at boot,
the replay arrives on the first successful (re)connect (15 s backoff) and
would land OUTSIDE the grace — rare, cosmetic (one extra confetti), noted.
## Relay protocol (additive, v3 + stamp)
- Client → server `{t:'stamp', w}`: `w` must be exactly `acid|dub|disco`
(default-closed; anything else dropped). Duplicate = dropped (no
rebroadcast). Accept → persist **immediately**, rebroadcast to other peers.
- `hello.state.stamps: string[]` — join snapshot; old clients ignore it, old
state files load as no stamps.
- **`reset` deliberately does NOT clear `stamps`** — stamps are pressed into
the records, not the booth (M5). Comment sits in the reset case.
## Geometry alignment (to Lane W's landed build)
- **Portals:** dive plate hugs each disc's lower rim (probed live: the rim
overhangs the stand at eye height — solid y4 to z 35, y5 to z 37), centre
`[discX+1, 4.0, 34.6]`, small enough not to wall the crate aisle.
- **Acid:** plinth colliders/knobs at world centres `[52.5, ·, 44.5/52.5/60.5]`;
our chrome knob rides ON Lane W's led pip voxel (top face 148), lock lamp above.
- **Dub:** deck overlay x 223..226 (centre 224.5), z 40..64, base **147.62**
(static deck top 147 — ours never dips below it: 147.07 min, so the ride
never pops onto voxels). Mouth = their rca_gold inlay `[224,146,52]` under
our floating deck → feeding is E on the deck within ±3.2 of z 52.5 (the pit's
reach geometry makes the bridge the only spot). Charge home `[232,146.2,78]`
in front of their horn (x 232 — their build + ours both keep the horn walk
off the exit-pad lane; an earlier centred placement walked players over the
pad, found live). Pit fall = feet < 143.6 while carrying (pit walks at 143,
canyon at 144 stays safe).
- **Disco:** tile centres at Lane W's **pitch-8** grid `x 384.5/392.5/400.5/408.5`,
`z 52.5/60.5/68.5/76.5` (not the brief's pitch-6 sketch), 5×5 halves. Glow
plates hover at y 144.05.
- **Exit pads:** trigger centred on the pad's world centre (`exitPad+0.5`),
±1.3 with a **0.25 s dwell** — clipping a pad corner mid-quest can't yank you
(the disco far row is 2 voxels from its pad); a 1 s post-teleport cooldown
stops re-triggers.
## Design decisions worth knowing
- **Acid locks evaluate on hold RELEASE** (release inside ±0.07 = lock, value
snaps to target). Sweeping through the sweet spot doesn't auto-lock — you
tune by ear and let go, radio style. Shift reverses the sweep; ends ping-pong.
- **Quests replay as toys** once stamped: same loop, flourish message, no
stamp events (Stamps.apply is the single idempotence gate, so E2's "4 bars
of glory" only fires on real stamps).
- Acid targets/values and disco sequences reseed per `world:enter`; disco
sequences avoid immediate repeats (re-entry detection can't see doubles).
- Dub deck `velocityAt` reports the true lateral surface velocity of the
weave; vertical rides on Lane B's ride-snap (verified 89%+ grounded on the
wobble at carry amplitude, worst-case rates ~25× inside the envelope).
- The safety net skips flying players (dev flight stays usable) and counts as
a `world:exit` of the nearest world so E2's duck always unwinds.
## Verification record (real game, port 5194; local relay only)
- Relay (node scripts, fresh state): stamp round-trip A→B; **no sender echo**;
duplicates dropped; **14-case fuzz** (bad types/keys/enums/proto/broken
JSON) all dropped with the socket alive; join snapshot carries stamps;
persistence + `winAt` rewind; **reset clears repairs, stamps survive**,
quest replayable. `server/booth-state.json` deleted before AND after.
- In-browser (headless-driven via `window.TURNCRAFT`, real raycast/E/hold
routing): all three portals dive with prompts; exit pad + dwell + cooldown;
safety net teleports non-flyers and leaves flyers alone; ACID three locks →
stamp (exact vocab above, gauge shown/hidden); DUB grounded wobble ride,
pickup, canyon-roam safe, pit fall → `dub_drop` + retry toast, feed →
stamp; DISCO wrong-step forgiveness, rounds 4→8 with cue/step/plate
lighting → stamp #3`stamp:all`**golden slipmat**; reload → join
replay (3× `stamp:got` quiet + golden restored ⇒ `stamp:all` fired);
**full reset→re-win cycle**: reset broadcast (`by` name), booth dead,
stamps 3/3 + golden intact, dead-booth dive+exit work, re-win with stamps
intact. Zero console errors in the final build; `demo-machines.html` clean.
## Known edges
- Late-reconnect join replay can land outside Lane S's 4 s grace (above).
- The `stamp:got` HUD toast for REMOTE stamps shows live (by design — a peer
pressing a stamp is multiplayer news); only join-snapshot replays are quiet.
- If a future phase ever wants stamps resettable, that's a relay change
(clear `stamps` in the reset case) + nothing client-side.

128
docs/SIDEB_S_handoff.md Normal file
View File

@ -0,0 +1,128 @@
# SIDE B — Lane S handoff (fx/ui ambience)
Dust bunnies, the vinyl-warp portal iris, and the HUD stamp tray + stamp
celebrations. All bus-driven, no imports from any other lane, nothing outside
`src/fx/**` + `src/ui/**` touched. **Not committed** (integrator commits).
## Files
| file | change |
|---|---|
| `src/fx/bunnies.ts` | NEW — `DustBunnies`: 4 fluff-ball critters on the under-table floor |
| `src/fx/portalIris.ts` | NEW — `PortalIris`: full-screen 2D-canvas groove-ring iris |
| `src/fx/FxSystem.ts` | additive — `attachAmbience()`, iris + bunnies update, stamp confetti, `stamp:all` emissive shimmer |
| `src/ui/Hud.ts` | additive — stamp tray (3 accent dots, top-right under the signal lamps) |
| `src/fx/HANDOFF.md`, `src/ui/HANDOFF.md` | SIDE B sections appended |
## Integrator wiring — ONE line in `src/main.ts`
```ts
// after the player is constructed (mirrors the fx.setBeacons pattern):
fx.attachAmbience(world, player);
```
That's it. Everything else self-wires to the bus in the existing
`FxSystem`/`Hud` constructors already called from `main.ts`. Without the line,
bunnies simply don't spawn (and stamp confetti falls back to the world's
`entry` anchor); iris + tray + shimmer work regardless.
## Events
**Consumed (new this phase):** `world:enter`, `world:exit` (iris),
`stamp:got` (tray dot + pulse, accent confetti at the player),
`stamp:all` (gold tray rings, booth-wide emissive shimmer via the existing
`setEmissiveBoost` path, gold confetti at the three crate portal discs).
**Emitted:** `machine:interact { machineId: 'bunny', action: 'bunny_flee' }`
on the wander→flee transition only, globally rate-limited to 1/s (a 10 s
continuous chase produces 1 emit, not 10 — the squeak marks the spook, per
brief "at most 1/s"). Lane E2 maps it to a squeak.
## Behaviour details
- **Bunnies** (`DustBunnies`): 4 critters, each a single mesh of 812 jittered
dust-grey quads (soft glow-texture alpha, normal blending — 4 draw calls
total). Homes: 2 in the record-crate area, 1 mixer PCB room, 1 deck-A PCB
room, leashed to 14 voxels so they stay findable. Wander ≈0.30.8 v/s with
idle pauses; spook at 5 voxels (player must be within 4 y — tabletop players
don't spook them); flee exactly 8 voxels with ease-out (measured ramp
7→5.2→3.6→2.4→1.3 v/s), then settle (tremble) and resume. Movement uses a
`world.isSolid` lookahead: ground must be reachable within ±1 voxel
(overhangs are never ground — that's what keeps them out of the crate's
edge-standing records), keep-out rings are steered around, fleeing tries
±45°/±90° deflections before giving up. If someone builds a block into a
bunny it pops on top.
- **Keep-out zones:** the three `WORLD_DEFS[k].portalStand` ±6 rings, the
parts-bin tray and the fuse-box spot. The last two mirror
`worldgen/anchors.ts` arithmetic (`M.minX+15/M.minZ+10`, `M.minX+24/M.maxZ-8`)
derived from `LAYOUT` — same convention as `fx/layout.ts` VU towers. If Lane
C ever moves the `UNDER` anchors, update `KEEP_OUT` in `bunnies.ts`.
- **Iris** (`PortalIris`): 400 ms ease-in collapse → 90 ms full black → 400 ms
release; groove rings drift INTO the aperture on `world:enter`, OUT on
`world:exit`; accent lip + rings tinted from `WORLD_DEFS[world].accent`
(warm-white fallback for unknown keys). Pure screen overlay on a
`position:fixed` canvas at **z-index 9 — under the HUD (z 10)**, so
start/pause overlays and subtitles are never covered; `pointer-events:none`;
the camera (incl. `TURNCRAFT_CINE` / the flythrough) is never touched.
Timeline runs on `performance.now()` inside `fx.update()` (no private rAF
loop); idle = `display:none` + a single boolean check per tick. Retrigger
mid-animation resumes from the current coverage (no pop). Canvas re-syncs
its size on every trigger (guards embedded panes that boot with a 0-sized
window).
- **Stamp tray** (`Hud`): three 15 px record dots (with a centre "spindle
hole") at `top:64px; right:14px`, right under the signal-lamp tracker, in
`WORLD_KEYS` order with `WORLD_DEFS` accents. Dim ring when not got; accent
fill + glow when got; scale-pulse on a live `stamp:got`; gold rings on all
three via `.tc-stamps.all` on `stamp:all`.
- **`stamp:all` shimmer:** 2.6 s additive boost wave (fast ramp, ~2 Hz glitter
oscillation, ease-out) through the SAME injected `setEmissiveBoost` path the
LED pulse uses — composes with (never replaces) the win/reset timelines.
## Join-replay coordination (Lane M — please confirm timing)
Lane M re-emits `stamp:got` (and `stamp:all` when applicable) from the join
snapshot to seed state. Both `FxSystem` and `Hud` treat stamp events arriving
**< 4 s after construction** as replays: state applies (dots light, gold ring
sticks) but celebrations are suppressed (no pulse, no confetti, no shimmer).
A genuine stamp can't happen 4 s into a session, so there's no false-suppress
risk; if the relay handshake can complete LATER than ~4 s after page load, the
window constant lives in `FxSystem.isJoinReplay()` and `Hud.bornAt` uses —
bump both. **Verified against Lane M's real relay during this session:** their
join replay lit all three dots + gold silently; a live re-emit afterwards
pulsed and threw confetti.
## Flag for the integrator — disco `disco_cue` lighting
`SIDEB_MACHINES.md` M2 says Lane M emits `machine:interact
{action:'disco_cue', index}` "so Lane S can light them", but
`SIDEB_WORLDS.md` says "Lane M lights them" and my brief (`SIDEB_SOCIAL.md`)
scopes Lane S to bunnies/iris/tray only — and the contract
`machine:interact` payload has no `index` field. **No disco-tile lighting
exists in Lane S.** If Lane M's handoff defines an encoding (e.g. index in
the action string), a small FX reaction can be added on request.
## Verification (live game, port 5205, real code paths)
- Typecheck: exit 0 (whole tree).
- Bunnies, 60 sim-seconds sweep: 0 solid-cell penetrations, 0 keep-out
violations (closest approach 6.14), max ground y=4 (rides copper traces,
never climbs structures), all 4 wandered 3.98.4 voxels. Flee: exactly
8.00 voxels, ease-out ramp above, settle → wander. Emits: 1 per spook;
10 s continuous chase → 1 emit. **Zero steady-state allocation: 0 KB heap
delta over 3600 warm ticks.**
- Iris phases probed pixel-level on the overlay canvas: 210 ms centre open /
edges black, 430 ms full black, 700 ms releasing, 900 ms hidden +
`display:none`; compositing over the game verified by screenshot; HUD stays
above it.
- Tray: dots at top:64/right:14 with correct accents; got/pulse/all classes
exercised by hand-emitted events AND by Lane M's real relay replay
(silent) + live emits (pulsed). Visually confirmed lit in-game.
- `stamp:all`: shimmer curve measured through the boost path
(0.56→1.02→glitter→base over 2.6 s); gold confetti seen over the portal
discs.
- **fps unchanged:** warm A/B with forced GPU sync — bunnies visible
1.124 ms/frame vs hidden 1.094 ms (~890 vs ~914 fps-equiv; delta = 4 tiny
draw calls); `fx.update` 7.6 µs/tick warm with everything attached; iris
actively closing ≈ 1.08 ms/frame. All ~9× inside the 100 fps budget.
- Zero console errors from fx/ui across the session (only the pre-existing
relay-offline WebSocket retry noise, which predates this phase).

175
docs/SIDEB_W_handoff.md Normal file
View File

@ -0,0 +1,175 @@
# SIDE B — Lane W handoff (worlds)
**Status: complete.** Whole-tree `npm run typecheck` exits 0. All demo
validation green (27/27 lines incl. the new pocket assertions). Verified live
on port 5212 (launch config `turncraft-laneC-verify`) in both the demo page
and the real game via the cine camera.
## Files touched
- `src/worldgen/worlds/` **(new)** — `index.ts` (`buildWorlds(w, seed)`),
`common.ts` (skin/lining/exit-pad toolkit + accent-led map),
`portalDressing.ts` (W1), `acid.ts`, `dub.ts`, `disco.ts`
- `src/worldgen/buildBooth.ts` — additive only: one import + one
`buildWorlds(w, WORLD_SEED)` call at the end of `buildBooth`
- `src/demo/worldgenDemo.ts` — pocket assertions, three "Pocket:" exterior
view buttons, header notes
- `docs/SIDEB_W_handoff.md` — this file
Nothing else. No `src/core/**` edits, no other lane's directory, no existing
anchor moved, no new block ids (31 still reserved). Portal-ring code lives in
`worlds/portalDressing.ts` and is invoked via `buildWorlds` so the buildBooth
diff stays two lines.
## Determinism & budgets
- **Deterministic:** two full builds byte-identical (asserted headless AND as
a standing demo validation line). All pocket randomness comes from
`mulberry32(WORLD_SEED ^ k)` streams private to this lane — the main
buildBooth rng is never consumed, so every pre-existing booth zone is
byte-identical to before this phase. acid/disco/portal dressing are fully
index-patterned (zero rng); only dub's embers draw randoms.
- **Build time:** buildBooth alone 1929 ms in the array-mock (pockets add
~5 ms; 66,834 pocket voxels). Real game boot log across five boots:
`booth built+meshed in 760 / 769 / 783 / 913 ms` — the 913 outlier was a
vite-reload storm in a background-throttled pane while three other lane
sessions shared the relay; steady state ~760780 ms. Budget < 900 ms.
- **Chunk meshes: 469** (was 469 pre-SIDE B — the pockets live in ceiling
chunks that already existed, so the count is unchanged; disco's glass adds
transparent geometry to already-counted chunks). Budget < 700.
- **Console:** zero errors attributable to worldgen. The only error all
session is a `WrongDocumentError` pointer-lock rejection fired by the
browser-pane sandbox when the start splash is clicked (pre-existing
main.ts `requestPointerLock` behaviour inside an embedded pane; does not
occur in a normal tab).
## W1 — portal dressing at the crate
Per world (accent leds: acid=`led_green`, dub=`led_amber`, disco=`led_blue`):
- **Floor ring:** flush 1-voxel ring inlaid in the crate floor (y=2, i.e.
`portalStand.y 1`) around each `portalStand`, radius 3 (16 voxels,
`d² ∈ [7,11]`). Stands: acid (31,3,36), dub (46,3,36), disco (61,3,36).
Matches Lane S's bunny keep-out (`portalStand ±6`).
- **Disc marquee:** 3 accent-led pips riding each portal disc's top rim, at
`(discX, 28, 31)`, `(discX, 27, 27)`, `(discX, 27, 35)` for discX 31/46/61.
Verified glowing in-game (screenshot: three rings green/amber/blue across the
dim crate). No record geometry moved.
## W2 — pocket geometry (everything Lane M needs colliders/anchors for)
Common to all three (contract `src/core/worlds.ts`):
- Shell: 1-voxel `matte_black` skin on all six faces of
`pocketMin..pocketMax` (y 142 base, y 158 roof). Fully sealed — entry/exit
is teleport-only. Interior air y 144..157 (≈14 high).
- Themed floor lining at y=143 (= `exitPad.y`); feet stand at 144
(= `entry.y`). Entry voxels verified solid-below + 2-high headroom.
- **Exit pad:** 3×3 `rca_gold` flush at y=143 centred on `exitPad`
(x±1, z 77..79), centre voxel is the world's accent led. 2-high air above
all 9 columns.
### ACID WAREHOUSE (x 20..84, z 20..84)
- **Knob pedestals (quest M2):** three 3×3×3 `matte_black` plinths, centres
x=52, z=44/52/60 — footprints x 51..53, y 144..146, z {43..45, 51..53,
59..61}; **top face y=147**; 1-voxel `led_green` pip at (52,147,z).
Exported: `ACID_PEDESTAL_X/Z/TOP_Y` from `src/worldgen/worlds`.
- Floor: steel/alu slab concrete with flush rubber seams. Walls: `ply_edge`
breeze-blocks with shadow slots. 4 pillars 2×2 at x{33,69}×z{35,67}, floor
to ceiling. `led_green` cable runs: wall drops at x=21, z=38/46/62
(y 144..149) + flush floor inlays snaking to each plinth base (all inlays
are walkable floor, no bumps). Strobe rail: `strobe_dot` at (52,157,z) for
z=28..76 step 3.
### DUB CHAMBER (x 192..256, z 20..84)
- Reading of "floor drops 4 at x 210..238": the x-flanks are **raised
shelves** (x 193..209 and 239..255, solid y 144..147, top face 148); the
centre band x 210..238 stays at floor 144 → a sunken canyon 4 below the
shelves, holding entry, bridge, and exit on the x=224 axis. (The shell
floor at 142 is inviolable, so the drop is made by raising the sides —
entry/exitPad y-values pin the canyon floor to 143/144.)
- **Tank pit:** floor lining carved to the shell over x 212..236, z 41..62 —
pit walk surface y=143, one step below the canyon (always escapable), four
below the bridge deck. Two chrome reverb-spring coils inside (helices
around (x=217,y=145) and (x=231,y=145), z 43..61, radius 2 — clear of the
bridge's x 223..225). **Gold catch pad** x 223..225, y=143, z 51..53.
- **Bridge (your moving deck overlays this):** static chrome deck
**x 223..225, y=146 (walk surface 147), z 40..63** (3×24 exactly), with a
`rca_gold` mouth inlay at **(224,146,52)** — the "feed the spring" E-drop
spot, directly over the catch pad. End legs at z=40/63 (y 144..145).
Stairs, 1-voxel steps (auto-step): near z=37 (y144), z=38 (y144..145),
z=39 (y144..146); far z=64 (y144..145), z=65 (y144).
Exported: `DUB_BRIDGE`, `DUB_PIT`.
- **Horn:** stacked `speaker_mesh` rings on the back wall, **centre
(232, 151)** — throat plate flush at z=83, rings flaring at z=82/81/80 to
r6; `led_amber` ember at (232,151,82). Exported: `DUB_HORN`.
**Deliberately mounted EAST of the exit axis:** the exit pad (x 223..225,
z 77..79) would otherwise sit between the room and the horn. With the horn
at x=232, a charge anchor near **(232, 147, 79)** is reachable and
carryable to the bridge without ever crossing the pad — safe even if you
make exit walk-on. If you'd rather the player never gets close, anchor at
(232,147,75).
- Dressing: vinyl_black everything, ~35 `led_amber` embers in walls/risers,
2 speaker-mesh stacks per shelf (3×3×3 at x 200..202 & 246..248 tops 150).
### DISCO LOFT (x 364..428, z 20..84)
- **Dance tiles (quest M2, your standing-on colliders):** 4×4 grid of 5×5
flush tiles at y=143, tile **centres x {384, 392, 400, 408} ×
z {52, 60, 68, 76}** (pitch 8; each tile spans centre±2; 3-voxel parquet
gaps between tiles). Border ring alternates `label_cream`/`rubber` per
tile (checker), field is the opposite, **centre voxel led_red/led_blue**
(Lane S lights on `disco_cue`). Exported: `DISCO_TILE_CX/CZ/Y`.
The exit pad (x 395..397, z 77..79) sits in the x-gap between tile
columns — no overlap.
- **Mirrorball:** chrome/glass checker sphere r≈3 centred **(396,152,64)**
(over the tile grid), chrome chain at (396,156..157,64). Bottom voxel
y=149 — 5 clear above heads.
- Dressing: plywood/ply_edge herringbone parquet, plywood walls + ply_edge
skirting, 16 vertical wall-wash strips (led_red/led_blue, y 146..151) on
all four walls.
## Verification performed (all live, port 5212)
Demo (`/demo-worldgen.html`) — validation 27/27 green, incl. per-world: skin
sealed matte_black (0 breaches — which also proves no emissive/transparent
block anywhere on the skin), entry floor+headroom, exit pad + pip + clear
above, portal ring 16/16 + marquee 3/3, pedestals/bridge/tiles present,
"nothing above y141 outside the pockets (0)", determinism. Build 28.7 ms in
the mock, 0 OOB writes, zero console errors.
Real game (`/`) screenshots via `window.TURNCRAFT_CINE`:
1. Flythrough keyframes from commit caac3da — K1 (224,132,26)→(224,60,128),
K2 (330,100,60)→(224,66,100), K3 (140,96,60)→(80,81,96): ceiling in frame
in K2/K3, zero pocket visibility, zero glow.
2. Under-pocket ceiling shots (below acid and dub, lights-off angles): pure
dark ceiling, no leaks.
3. Exterior night, high over the roof from the front: all three pockets read
as faint matte-black slabs, zero emission (two angles).
4. Interiors of all three pockets (front wall → room): every listed feature
visible and correct.
5. Crate: the three accent rings glowing round the portal stands.
## Notes / friction for the integrator
1. **`matte_black` is `breakable: true`** in the contract registry, so a
player could in principle mine a hole in a pocket skin (same as the mixer
body). Lane M's out-of-pocket safety teleport (M1) is the net; if we ever
want skins unmineable that's a contract decision, not mine.
2. Dub's "floor drops 4" is implemented as raised side shelves (see above) —
the contract's entry/exitPad y-values make a literal 4-deep dig
impossible without piercing the shell. Flagging per CONTRACTS §1 rather
than bending the contract.
3. The pocket skins rest directly on the booth ceiling slab (y 140..141), so
they are strictly invisible from inside the booth; the exterior view only
exists for cine/debug cameras.
4. `worldgen/index.ts` was NOT edited (not in this phase's sanctioned list);
`buildWorlds` is internal to `buildBooth`. Demo imports feature anchors
from `src/worldgen/worlds` directly.
5. Verified while three other lane sessions were live on the same tree/relay
(their WIP caused periodic vite reloads; boot numbers above include that
noise). One environmental console error only — see Budgets.

View File

@ -32,6 +32,8 @@ const AVATAR_COOLDOWN_MS = 2000; // 1 avatar change per 2 s per peer
const RESET_COOLDOWN_MS = 10 * 60_000; // anti-grief: 10 min since the win const RESET_COOLDOWN_MS = 10 * 60_000; // anti-grief: 10 min since the win
const AVATAR_RANGES = { hue: 12, gear: 5, face: 4 }; // enum sizes (0..n-1) const AVATAR_RANGES = { hue: 12, gear: 5, face: 4 }; // enum sizes (0..n-1)
const EMOTE_KINDS = 4; // 0..3 = wave | nod | point | airhorn const EMOTE_KINDS = 4; // 0..3 = wave | nod | point | airhorn
// SIDE B crate worlds — mirrors src/core/worlds.ts WORLD_KEYS.
const STAMP_WORLDS = new Set(['acid', 'dub', 'disco']);
// Grief ceiling: the diff map must stay bounded (18M voxels × ~30B/entry // Grief ceiling: the diff map must stay bounded (18M voxels × ~30B/entry
// would be ~550MB if a bot painted the whole booth). ~400k edits ≈ 25MB. // would be ~550MB if a bot painted the whole booth). ~400k edits ≈ 25MB.
const MAX_EDITS = 400_000; const MAX_EDITS = 400_000;
@ -49,6 +51,9 @@ const edits = new Map();
const repaired = new Set(); const repaired = new Set();
const platters = { A: { playing: false, rpm: 33 }, B: { playing: false, rpm: 33 } }; const platters = { A: { playing: false, rpm: 33 }, B: { playing: false, rpm: 33 } };
let workshop = null; // last-write-wins Headshell Workshop assembly state (or null) let workshop = null; // last-write-wins Headshell Workshop assembly state (or null)
/** SIDE B stamps (world keys). Global like repairs; NEVER cleared by reset
* stamps are pressed into the records, not the booth. */
const stamps = new Set();
let dirty = false; let dirty = false;
/** ms epoch of the last win — gates the reset cooldown. 0 = "long ago". */ /** ms epoch of the last win — gates the reset cooldown. 0 = "long ago". */
let winAt = 0; let winAt = 0;
@ -113,6 +118,7 @@ function loadState() {
Object.assign(platters.A, s.platters?.A ?? {}); Object.assign(platters.A, s.platters?.A ?? {});
Object.assign(platters.B, s.platters?.B ?? {}); Object.assign(platters.B, s.platters?.B ?? {});
workshop = validWorkshop(s.workshop); workshop = validWorkshop(s.workshop);
for (const w of s.stamps ?? []) if (STAMP_WORLDS.has(w)) stamps.add(w);
// Old state files predate winAt: treat as "won long ago" so the reset // Old state files predate winAt: treat as "won long ago" so the reset
// cooldown is already expired rather than blocking forever. // cooldown is already expired rather than blocking forever.
winAt = Number.isFinite(s.winAt) ? s.winAt : 0; winAt = Number.isFinite(s.winAt) ? s.winAt : 0;
@ -125,11 +131,20 @@ function loadState() {
function saveState() { function saveState() {
if (!dirty) return; if (!dirty) return;
dirty = false; dirty = false;
const tmp = STATE_FILE + '.tmp'; // A throwing write (ENOSPC, perms) must neither crash the relay from inside
writeFileSync(tmp, JSON.stringify({ // a ws message handler nor mark the unsaved state clean — restore `dirty` so
edits: [...edits.entries()], repaired: [...repaired], platters, workshop, winAt, // the periodic saver retries once the disk recovers.
})); try {
renameSync(tmp, STATE_FILE); const tmp = STATE_FILE + '.tmp';
writeFileSync(tmp, JSON.stringify({
edits: [...edits.entries()], repaired: [...repaired], platters, workshop, winAt,
stamps: [...stamps],
}));
renameSync(tmp, STATE_FILE);
} catch (e) {
dirty = true;
console.error('[relay] state save failed (will retry):', e.message);
}
} }
// ---- helpers ----------------------------------------------------------- // ---- helpers -----------------------------------------------------------
@ -178,7 +193,7 @@ wss.on('connection', (ws) => {
t: 'hello', id, t: 'hello', id,
peers: [...peers.entries()].filter(([pid]) => pid !== id) peers: [...peers.entries()].filter(([pid]) => pid !== id)
.map(([pid, p]) => ({ id: pid, name: p.name, avatar: p.avatar })), .map(([pid, p]) => ({ id: pid, name: p.name, avatar: p.avatar })),
state: { edits: [...edits.entries()], repaired: [...repaired], platters, workshop }, state: { edits: [...edits.entries()], repaired: [...repaired], platters, workshop, stamps: [...stamps] },
}); });
ws.on('pong', () => { peer.alive = true; }); ws.on('pong', () => { peer.alive = true; });
@ -226,6 +241,8 @@ wss.on('connection', (ws) => {
winAt = 0; winAt = 0;
dirty = true; dirty = true;
// NOTE: `edits` is deliberately untouched — player builds survive. // NOTE: `edits` is deliberately untouched — player builds survive.
// NOTE: `stamps` is deliberately untouched too — stamps are pressed
// into the records, not the booth (SIDE B M5: they survive the reset).
// Carry the workshop pose so live clients land on the same state a late // Carry the workshop pose so live clients land on the same state a late
// joiner would get from `hello.state.workshop`. // joiner would get from `hello.state.workshop`.
broadcast({ t: 'reset', by: peer.name, workshop }); // everyone incl. the breaker broadcast({ t: 'reset', by: peer.name, workshop }); // everyone incl. the breaker
@ -272,6 +289,19 @@ wss.on('connection', (ws) => {
broadcast({ t: 'workshop', s }, id); broadcast({ t: 'workshop', s }, id);
break; break;
} }
case 'stamp': {
// SIDE B (default-closed): w must be exactly a known world key; a
// repeat stamp is dropped (idempotent — no rebroadcast spam). Stamps
// are meta-progress, so they persist immediately, and the reset
// ritual deliberately never touches them.
if (typeof m.w !== 'string' || !STAMP_WORLDS.has(m.w)) return;
if (stamps.has(m.w)) return;
stamps.add(m.w);
dirty = true;
broadcast({ t: 'stamp', w: m.w }, id);
saveState();
break;
}
default: // unknown type: ignore default: // unknown type: ignore
} }
}); });

View File

@ -5,8 +5,16 @@
// constructs it; the public methods exist for the demo and direct control. // constructs it; the public methods exist for the demo and direct control.
// //
// music path: stems -> vinylShelf -> musicGain -> masterLP -> {pannerA, // music path: stems -> vinylShelf -> musicGain -> masterLP -> {pannerA,
// pannerB, dryBed, analyser} -> outGain -> destination // pannerB, dryBed, analyser} -> boothGain -> outGain -> dest
// sfx path: oneshot -> [panner(at)] -> sfxGain -> outGain -> destination // sfx path: oneshot -> [panner(at)] -> sfxGain -> outGain -> destination
// SIDE B path: pocket groove -> worldBus -> outGain (+ analyser tap)
//
// SIDE B (SIDEB_AUDIO.md): everything the booth plays — stems, heartbeat, dry
// bed, deck panners — funnels through `boothGain`. Entering a pocket world
// ducks that ONE gain out (300 ms) and starts the world's own groove; exiting
// un-ducks it. The booth graph stays LIVE underneath the duck (repairs, the
// reset ritual, a win — they all keep flowing), so the handback never restores
// a snapshot: it simply reveals whatever the booth sounds like right now.
import { bus } from '../core/events'; import { bus } from '../core/events';
import { LAYOUT, PLATTER, Y_RECORD_TOP } from '../core/constants'; import { LAYOUT, PLATTER, Y_RECORD_TOP } from '../core/constants';
@ -15,6 +23,7 @@ import type { IPlayerView, Vec3 } from '../core/types';
import { Groove } from './groove'; import { Groove } from './groove';
import { Transport } from './scheduler'; import { Transport } from './scheduler';
import { createNoiseBuffer, createCrackleBuffer } from './synth'; import { createNoiseBuffer, createCrackleBuffer } from './synth';
import { WorldGrooves, isPocketWorld, type PocketWorld } from './worldGrooves';
import * as SFX from './sfx'; import * as SFX from './sfx';
import { playTestSignal, errorsFromKeys, type TestErrors } from './testSignal'; import { playTestSignal, errorsFromKeys, type TestErrors } from './testSignal';
@ -47,6 +56,8 @@ export class AudioEngine {
// graph nodes // graph nodes
private outGain!: GainNode; private outGain!: GainNode;
private boothGain!: GainNode; // everything the booth plays (ducked in pockets)
private worldBus!: GainNode; // SIDE B pocket grooves
private musicGain!: GainNode; private musicGain!: GainNode;
private masterLP!: BiquadFilterNode; private masterLP!: BiquadFilterNode;
private sfxGain!: GainNode; private sfxGain!: GainNode;
@ -74,6 +85,16 @@ export class AudioEngine {
private heartTimer: number | null = null; private heartTimer: number | null = null;
private heartDest: AudioNode | null = null; private heartDest: AudioNode | null = null;
// SIDE B — crate worlds. State kept even pre-init (join snapshots and demo
// clicks can arrive before the user gesture); init() applies it.
private worlds: WorldGrooves | null = null;
private currentWorld: PocketWorld | null = null;
private acidPending = [0.12, 0.85, 0.18]; // last fader:move acid_0..2 values
private dubCarrying = false;
private stampedWorlds = new Set<string>();
private stampAllOn = false;
private lastBunny = -10;
private readonly levels = { low: 0, mid: 0, high: 0 }; private readonly levels = { low: 0, mid: 0, high: 0 };
constructor() { constructor() {
@ -111,6 +132,12 @@ export class AudioEngine {
this.sfxGain.gain.value = 0.9; this.sfxGain.gain.value = 0.9;
this.sfxGain.connect(this.outGain); this.sfxGain.connect(this.outGain);
// every booth music source (stems via panners/dry bed, heartbeat) funnels
// through this one gain so pocket dives can duck the WHOLE booth live
this.boothGain = ctx.createGain();
this.boothGain.gain.value = 1;
this.boothGain.connect(this.outGain);
this.masterLP = ctx.createBiquadFilter(); this.masterLP = ctx.createBiquadFilter();
this.masterLP.type = 'lowpass'; this.masterLP.type = 'lowpass';
this.masterLP.frequency.value = 20000; // transparent until the win sweep this.masterLP.frequency.value = 20000; // transparent until the win sweep
@ -152,7 +179,7 @@ export class AudioEngine {
const dryLP = ctx.createBiquadFilter(); const dryLP = ctx.createBiquadFilter();
dryLP.type = 'lowpass'; dryLP.frequency.value = 380; dryLP.type = 'lowpass'; dryLP.frequency.value = 380;
const dryGain = ctx.createGain(); dryGain.gain.value = 0.5; const dryGain = ctx.createGain(); dryGain.gain.value = 0.5;
this.masterLP.connect(dryLP); dryLP.connect(dryGain); dryGain.connect(this.outGain); this.masterLP.connect(dryLP); dryLP.connect(dryGain); dryGain.connect(this.boothGain);
// positional taps at each deck spindle // positional taps at each deck spindle
for (const d of ['A', 'B'] as Deck[]) { for (const d of ['A', 'B'] as Deck[]) {
@ -164,7 +191,7 @@ export class AudioEngine {
this.setPos(p, lay.spindleX, Y_RECORD_TOP, lay.spindleZ); this.setPos(p, lay.spindleX, Y_RECORD_TOP, lay.spindleZ);
const gate = ctx.createGain(); const gate = ctx.createGain();
gate.gain.value = 0; // faded in when that deck is playing gate.gain.value = 0; // faded in when that deck is playing
this.masterLP.connect(gate); gate.connect(p); p.connect(this.outGain); this.masterLP.connect(gate); gate.connect(p); p.connect(this.boothGain);
this.panner[d] = p; this.panGate[d] = gate; this.panner[d] = p; this.panGate[d] = gate;
} }
@ -189,17 +216,33 @@ export class AudioEngine {
const lp = ctx.createBiquadFilter(); const lp = ctx.createBiquadFilter();
lp.type = 'lowpass'; lp.frequency.value = 240; lp.type = 'lowpass'; lp.frequency.value = 240;
const g = ctx.createGain(); g.gain.value = 0.55; const g = ctx.createGain(); g.gain.value = 0.55;
lp.connect(g); g.connect(hp); hp.connect(this.outGain); lp.connect(g); g.connect(hp); hp.connect(this.boothGain);
this.heartDest = lp; this.heartDest = lp;
this.heartNext = ctx.currentTime + 0.5; this.heartNext = ctx.currentTime + 0.5;
this.heartTimer = window.setInterval(() => this.scheduleHeartbeat(), 250); this.heartTimer = window.setInterval(() => this.scheduleHeartbeat(), 250);
} }
// ── SIDE B pocket-world layer ──
this.worldBus = ctx.createGain();
this.worldBus.gain.value = 0.95;
this.worldBus.connect(this.outGain);
this.worldBus.connect(this.analyser); // pocket grooves drive the VU too
this.worlds = new WorldGrooves(ctx, this.noise, this.worldBus);
// apply any state that arrived before init // apply any state that arrived before init
this.groove.setStemCount(this.stemCount); this.groove.setStemCount(this.stemCount);
for (let i = 0; i < this.channel.length; i++) this.groove.setChannelGain(i, this.channel[i]); for (let i = 0; i < this.channel.length; i++) this.groove.setChannelGain(i, this.channel[i]);
this.transport.setPitchTrim(this.pitchTrim); this.transport.setPitchTrim(this.pitchTrim);
this.applyPlayback(); this.applyPlayback();
for (let i = 0; i < 3; i++) this.worlds.setAcidBand(i, this.acidPending[i]);
if (this.dubCarrying) this.worlds.setCarrying(true);
if (this.stampAllOn) this.groove.setBonusStem(true); // won crate: silent arm
if (this.currentWorld) {
// init happened mid-dive (events arrived pre-gesture): land in the pocket
this.setBoothDuck(true, true);
this.groove.setEmitBeats(false);
this.worlds.start(this.currentWorld);
}
} }
// ── Public API ──────────────────────────────────────────────────────────── // ── Public API ────────────────────────────────────────────────────────────
@ -255,6 +298,12 @@ export class AudioEngine {
// Social & Reset Ritual // Social & Reset Ritual
case 'airhorn': SFX.airhorn(ctx, dest, t); break; case 'airhorn': SFX.airhorn(ctx, dest, t); break;
case 'powerDown': SFX.powerDown(ctx, dest, this.noise, t); break; case 'powerDown': SFX.powerDown(ctx, dest, this.noise, t); break;
// SIDE B — crate worlds
case 'portalIn': SFX.portalWhoosh(ctx, dest, this.noise, t, 'down'); break;
case 'portalOut': SFX.portalWhoosh(ctx, dest, this.noise, t, 'up'); break;
case 'stampThunk': SFX.stampThunk(ctx, dest, this.noise, t); break;
case 'goldenRiser': SFX.goldenRiser(ctx, dest, this.noise, t); break;
case 'bunny': SFX.bunnySqueak(ctx, dest, t); break;
} }
} }
@ -387,6 +436,128 @@ export class AudioEngine {
this.updateCrackle(); this.updateCrackle();
} }
// ── SIDE B — pocket dives, stamps, the golden unlock ─────────────────────
/** Duck/undock the entire booth path (stems + heartbeat + dry bed). */
private setBoothDuck(on: boolean, immediate = false): void {
if (!this.ctx) return;
const g = this.boothGain.gain, now = this.ctx.currentTime;
g.cancelScheduledValues(now);
if (immediate) {
g.setValueAtTime(on ? 0 : 1, now);
} else {
g.setValueAtTime(g.value, now);
g.linearRampToValueAtTime(on ? 0 : 1, now + (on ? 0.3 : 0.45));
}
}
private enterPocket(world: string): void {
if (!isPocketWorld(world)) return;
if (this.currentWorld === world) return;
this.currentWorld = world;
if (!this.ctx || !this.worlds) return;
SFX.portalWhoosh(this.ctx, this.sfxGain, this.noise, this.ctx.currentTime + 0.001, 'down');
this.setBoothDuck(true);
this.groove.setEmitBeats(false); // the pocket groove owns the beat now
this.worlds.start(world);
}
private exitPocket(): void {
if (this.currentWorld === null) return;
this.currentWorld = null;
if (!this.ctx || !this.worlds) return;
SFX.portalWhoosh(this.ctx, this.sfxGain, this.noise, this.ctx.currentTime + 0.001, 'up');
this.worlds.stop();
// Handback restores nothing from a snapshot: the booth path stayed live
// under the duck (resets, wins, repairs kept flowing), so un-ducking
// reveals whatever the booth ACTUALLY sounds like right now — heartbeat
// for a dead booth, the full mix for a won one, silence mid-reset.
this.setBoothDuck(false);
this.groove.setEmitBeats(true);
}
private onStampGot(p: { world: string; count: number; total: number; origin?: string }): void {
const first = !this.stampedWorlds.has(p.world);
this.stampedWorlds.add(p.world);
// The event's origin decides the sound, not a wall clock: 'replay' (join
// snapshot — possibly arriving LONG after boot on a slow reconnect) arms
// state silently; 'remote' shares the moment with the thunk only; 'local'
// gets the full fanfare + in-groove glory. First-sighting dedupe on top.
const replay = p.origin === 'replay';
if (first && !replay && this.ctx) {
const t = this.ctx.currentTime + 0.001;
SFX.stampThunk(this.ctx, this.sfxGain, this.noise, t);
if (p.origin !== 'remote' && isPocketWorld(p.world)) {
SFX.stampFanfare(this.ctx, this.sfxGain, this.noise, t + 0.12, p.world);
this.worlds?.stampGlory(p.world);
}
}
if (p.count >= p.total) this.unlockBonus(replay);
}
/** stamp:all (or derived count===total): golden riser once, then the bonus
* percussion stem permanently. Replay/pre-gesture arrivals arm silently. */
private unlockBonus(replay = false): void {
if (!this.stampAllOn) {
this.stampAllOn = true;
if (this.ctx && !replay) {
const t = this.ctx.currentTime + 0.55; // let the third stamp's thunk land
SFX.goldenRiser(this.ctx, this.sfxGain, this.noise, t);
this.groove.setBonusStem(true, t + 2.0); // percussion enters at the bloom
return;
}
// silent arm (replay, or context not up yet): the stem simply rides
if (this.ctx) this.groove.setBonusStem(true);
return;
}
// idempotent re-arrivals (boot snapshot + a following stamp:all)
if (this.ctx && !this.groove.getBonusStem()) this.groove.setBonusStem(true);
}
private setAcidBandValue(band: number, v: number): void {
if (band < 0 || band > 2 || !Number.isFinite(v)) return;
this.acidPending[band] = Math.max(0, Math.min(1, v));
this.worlds?.setAcidBand(band, this.acidPending[band]);
}
/** Tolerant index for SIDE B interactions: runtime `index` field if Lane M
* casts one in, else trailing digits of machineId, else of action. */
private eventIndex(p: { machineId: string; action: string }, fallback: number): number {
const runtime = (p as unknown as { index?: unknown }).index;
if (typeof runtime === 'number' && Number.isFinite(runtime)) return runtime;
const m = /(\d+)\s*$/.exec(p.machineId) ?? /(\d+)\s*$/.exec(p.action);
return m ? parseInt(m[1], 10) : fallback;
}
/** Route a SIDE B machine:interact; false = not ours, fall through. */
private sidebAction(a: string, p: { machineId: string; action: string }): boolean {
if (a === 'bunny_flee') {
if (this.ctx) {
const now = this.ctx.currentTime;
if (now - this.lastBunny > 1.1) { // shy: never a squeak chorus
this.lastBunny = now;
SFX.bunnySqueak(this.ctx, this.sfxGain, now + 0.001);
}
}
return true;
}
if (a.startsWith('acid_lock')) {
const fallback = this.worlds ? this.worlds.firstUnlockedBand() : 0;
this.worlds?.acidLock(this.eventIndex(p, fallback));
return true;
}
if (a.startsWith('acid')) return true; // acid_knob/acid_tuned: the sweep/stamp IS the sound
if (a.startsWith('dub_pickup')) { this.dubCarrying = true; this.worlds?.setCarrying(true); return true; }
if (a.startsWith('dub_drop')) { this.dubCarrying = false; this.worlds?.setCarrying(false); return true; }
if (a.startsWith('dub_feed')) { this.dubCarrying = false; this.worlds?.dubFeed(); return true; }
if (a.startsWith('disco_cue')) { this.worlds?.discoCue(this.eventIndex(p, 0)); return true; }
if (a.startsWith('disco_step')) { this.worlds?.discoStep(this.eventIndex(p, 0)); return true; }
if (a.startsWith('disco_miss')) { this.worlds?.discoMiss(); return true; }
if (a.startsWith('disco')) return true; // disco_lit: the stamp swell follows
if (a.startsWith('portal')) return true; // portal_dive/exit: world:enter/exit whooshes cover it
return false;
}
/** /**
* The Reset Ritual (SOCIAL_RESET): someone blew the fuse. The mix doesn't * The Reset Ritual (SOCIAL_RESET): someone blew the fuse. The mix doesn't
* mute the power dies. The transport brakes to a halt (the groove sags and * mute the power dies. The transport brakes to a halt (the groove sags and
@ -496,6 +667,12 @@ export class AudioEngine {
bus.on('quest:reset', () => { this.repairs = 0; this.runResetAudio(); }); bus.on('quest:reset', () => { this.repairs = 0; this.runResetAudio(); });
// ── SIDE B — crate worlds (SIDEB_AUDIO.md) ──
bus.on('world:enter', (p) => this.enterPocket(p.world));
bus.on('world:exit', () => this.exitPocket());
bus.on('stamp:got', (p) => this.onStampGot(p));
bus.on('stamp:all', () => this.unlockBonus());
// Mining juice: quiet dry ticks whose pitch rises with hold progress. // Mining juice: quiet dry ticks whose pitch rises with hold progress.
bus.on('block:mining', (p) => this.miningTick(p.progress)); bus.on('block:mining', (p) => this.miningTick(p.progress));
@ -512,6 +689,14 @@ export class AudioEngine {
bus.on('fader:move', (p) => { bus.on('fader:move', (p) => {
const id = p.faderId.toLowerCase(); const id = p.faderId.toLowerCase();
// SIDE B acid pedestals: acid_0..2 drive the 303's per-band cutoff —
// intercepted BEFORE the digit->channel map (else acid_0's '0' would
// duck a booth stem) and with no fader zip: the squelch IS the feedback.
if (id.startsWith('acid')) {
const m = /(\d+)\s*$/.exec(id);
this.setAcidBandValue(m ? parseInt(m[1], 10) : 0, p.value);
return;
}
if (id.includes('pitch')) { this.setPitch(p.value * 2 - 1); return; } if (id.includes('pitch')) { this.setPitch(p.value * 2 - 1); return; }
const m = id.match(/(\d)/); const m = id.match(/(\d)/);
if (m) this.setChannelGain(Math.max(0, Math.min(4, parseInt(m[1], 10) - 1)), p.value); if (m) this.setChannelGain(Math.max(0, Math.min(4, parseInt(m[1], 10) - 1)), p.value);
@ -535,6 +720,7 @@ export class AudioEngine {
bus.on('machine:interact', (p) => { bus.on('machine:interact', (p) => {
const a = p.action.toLowerCase(); const a = p.action.toLowerCase();
if (this.sidebAction(a, p)) return; // SIDE B first: 'disco_cue' contains 'cue'
if (this.workshopSfxFor(a)) return; // Headshell Workshop actions if (this.workshopSfxFor(a)) return; // Headshell Workshop actions
if (a.includes('fader') || a.includes('pitch')) this.playSfx('fader'); if (a.includes('fader') || a.includes('pitch')) this.playSfx('fader');
else if (a.includes('cue') || a.includes('arm') || a.includes('tonearm')) this.playSfx('cue'); else if (a.includes('cue') || a.includes('arm') || a.includes('tonearm')) this.playSfx('cue');

View File

@ -8,7 +8,7 @@
// spin-up/brake and pitch-fader moves. // spin-up/brake and pitch-fader moves.
import { bus } from '../core/events'; import { bus } from '../core/events';
import { mtof, kick, hat, clap, bass, stab, lead, sweep } from './synth'; import { mtof, kick, hat, clap, bass, stab, lead, sweep, shaker, conga } from './synth';
export const STEP_PER_BAR = 16; export const STEP_PER_BAR = 16;
export const BARS = 8; export const BARS = 8;
@ -51,9 +51,18 @@ export class Groove {
private chan: GainNode[] = []; private chan: GainNode[] = [];
private bassDuck: GainNode; private bassDuck: GainNode;
// SIDE B bonus percussion stem (stamp:all reward). Own enable gain outside
// the 5-channel fader map; scheduling is gated so it costs nothing until won.
private bonusEnable: GainNode;
private bonus = false;
private stemCount = 0; private stemCount = 0;
private barCounter = 0; private barCounter = 0;
private chanValue = [1, 1, 1, 1, 1]; // last setChannelGain per stem (for beat energy) private chanValue = [1, 1, 1, 1, 1]; // last setChannelGain per stem (for beat energy)
// While the player is inside a SIDE B pocket, that world's groove owns the
// audio:beat/audio:bar stream; the booth keeps playing (ducked) but goes
// quiet on the bus so two tempos never fight over the beat events.
private emitBeats = true;
constructor(ctx: BaseAudioContext, noise: AudioBuffer, out: AudioNode) { constructor(ctx: BaseAudioContext, noise: AudioBuffer, out: AudioNode) {
this.ctx = ctx; this.ctx = ctx;
@ -73,6 +82,10 @@ export class Groove {
this.bassDuck = ctx.createGain(); this.bassDuck = ctx.createGain();
this.bassDuck.gain.value = 1; this.bassDuck.gain.value = 1;
this.bassDuck.connect(this.enable[STEM.BASS]); this.bassDuck.connect(this.enable[STEM.BASS]);
this.bonusEnable = ctx.createGain();
this.bonusEnable.gain.value = 0;
this.bonusEnable.connect(out);
} }
/** Destination a stem's voices should connect into for this step. */ /** Destination a stem's voices should connect into for this step. */
@ -95,6 +108,26 @@ export class Groove {
getStemCount(): number { return this.stemCount; } getStemCount(): number { return this.stemCount; }
/**
* SIDE B: the permanent shaker/conga layer earned by stamp:all. `rampAt`
* (ctx time) lets the unlock moment enter after the golden riser blooms;
* omitted = immediate (the silent boot-handshake path for a won crate).
*/
setBonusStem(on: boolean, rampAt?: number): void {
this.bonus = on;
const g = this.bonusEnable.gain;
const t = Math.max(this.ctx.currentTime, rampAt ?? this.ctx.currentTime);
g.cancelScheduledValues(this.ctx.currentTime);
g.setValueAtTime(g.value, this.ctx.currentTime);
g.setValueAtTime(g.value, t);
g.linearRampToValueAtTime(on ? 1 : 0, t + (rampAt ? 1.2 : 0.05));
}
getBonusStem(): boolean { return this.bonus; }
/** Suppress/restore audio:beat + audio:bar emission (pocket dives). */
setEmitBeats(on: boolean): void { this.emitBeats = on; }
/** Live channel/fader ducking (0..1). `ch` is a stem index (0..4). */ /** Live channel/fader ducking (0..1). `ch` is a stem index (0..4). */
setChannelGain(ch: number, v: number): void { setChannelGain(ch: number, v: number): void {
if (ch < 0 || ch >= STEM_COUNT) return; if (ch < 0 || ch >= STEM_COUNT) return;
@ -171,10 +204,23 @@ export class Groove {
} }
} }
// ── BONUS (stamp:all) ── shaker 16ths + a two-bar conga clave. Rides any
// audible mix (gated on stemCount>0 so a dead booth never shakes alone).
if (this.bonus && ct > 0) {
const acc = s % 4 === 2 ? 0.13 : s % 2 === 0 ? 0.055 : 0.085;
shaker(this.ctx, this.bonusEnable, this.noise, time, { gain: acc, detune });
const odd = bar % 2 === 1;
if (s === 3 || s === 11) {
conga(this.ctx, this.bonusEnable, this.noise, time, { gain: 0.2, detune });
} else if (s === (odd ? 14 : 6)) {
conga(this.ctx, this.bonusEnable, this.noise, time, { gain: 0.17, detune, slap: true });
}
}
// ── Beat / bar events (only meaningful once drums are audible) ── // ── Beat / bar events (only meaningful once drums are audible) ──
// energy ≈ low-band loudness of this beat: kick emphasis (downbeat louder) // energy ≈ low-band loudness of this beat: kick emphasis (downbeat louder)
// scaled by the drums channel gain, so ducking the fader drops the pulse. // scaled by the drums channel gain, so ducking the fader drops the pulse.
if (ct > STEM.DRUMS) { if (this.emitBeats && ct > STEM.DRUMS) {
if (s % 4 === 0) { if (s % 4 === 0) {
const energy = (s === 0 ? 1.0 : 0.82) * this.chanValue[STEM.DRUMS]; const energy = (s === 0 ? 1.0 : 0.82) * this.chanValue[STEM.DRUMS];
emitAt(time, () => bus.emit('audio:beat', { energy })); emitAt(time, () => bus.emit('audio:beat', { energy }));

View File

@ -6,7 +6,6 @@
// (pitch) — tempo and pitch bend together, exactly like a real platter. // (pitch) — tempo and pitch bend together, exactly like a real platter.
export const BPM = 118; export const BPM = 118;
const SEC_PER_16TH = 60 / BPM / 4; // 0.1271 s at nominal speed
const LOOKAHEAD = 0.12; // schedule this far ahead (s) const LOOKAHEAD = 0.12; // schedule this far ahead (s)
const TICK_MS = 25; // scheduler wake interval const TICK_MS = 25; // scheduler wake interval
const RATE_MIN = 0.02; // below this the platter counts as stopped const RATE_MIN = 0.02; // below this the platter counts as stopped
@ -29,13 +28,16 @@ export class Transport {
private rateTarget = 0; private rateTarget = 0;
private spinTau: number; // exponential time constant for spin-up/brake private spinTau: number; // exponential time constant for spin-up/brake
private pitchTrim = 0; // extra detune (cents) from the pitch fader private pitchTrim = 0; // extra detune (cents) from the pitch fader
private secPer16th: number;
private pending: Pending[] = []; private pending: Pending[] = [];
constructor(ctx: BaseAudioContext, onStep: StepFn, spinUpSeconds = 1.2) { /** `bpm` defaults to the booth's 118; SIDE B pocket grooves pass their own. */
constructor(ctx: BaseAudioContext, onStep: StepFn, spinUpSeconds = 1.2, bpm = BPM) {
this.ctx = ctx; this.ctx = ctx;
this.onStep = onStep; this.onStep = onStep;
this.spinTau = spinUpSeconds / 3; // ~settles within spinUpSeconds this.spinTau = spinUpSeconds / 3; // ~settles within spinUpSeconds
this.secPer16th = 60 / bpm / 4;
} }
/** Begin the wake loop. Idempotent; stays cheap while the platter is stopped. */ /** Begin the wake loop. Idempotent; stays cheap while the platter is stopped. */
@ -80,7 +82,7 @@ export class Transport {
while (this.nextNoteTime < now + LOOKAHEAD) { while (this.nextNoteTime < now + LOOKAHEAD) {
const detune = 1200 * Math.log2(this.rate) + this.pitchTrim; const detune = 1200 * Math.log2(this.rate) + this.pitchTrim;
this.onStep(this.nextStep, this.nextNoteTime, detune, this.emitAt); this.onStep(this.nextStep, this.nextNoteTime, detune, this.emitAt);
this.nextNoteTime += SEC_PER_16TH / this.rate; this.nextNoteTime += this.secPer16th / this.rate;
this.nextStep++; this.nextStep++;
} }
} }

View File

@ -401,6 +401,265 @@ export function powerDown(ctx: BaseAudioContext, dest: AudioNode, noise: AudioBu
src.start(t); src.stop(t + 0.27); src.start(t); src.stop(t + 0.27);
} }
// ── SIDE B — crate worlds (SIDEB_AUDIO.md) ──────────────────────────────────
/**
* Portal transition. `dir: 'down'` = diving INTO a record (world:enter): a
* vinyl slow-down detuned saws diving with wow-flutter under a falling noise
* whoosh. `dir: 'up'` = the exact reverse spin-up (world:exit).
*/
export function portalWhoosh(
ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number, dir: 'down' | 'up',
): void {
const down = dir === 'down';
const dur = down ? 0.8 : 0.65;
// noise whoosh through a diving/rising bandpass
const src = ctx.createBufferSource();
src.buffer = noise; src.loop = true;
const bp = ctx.createBiquadFilter();
bp.type = 'bandpass'; bp.Q.value = 1.1;
bp.frequency.setValueAtTime(down ? 4200 : 170, t);
bp.frequency.exponentialRampToValueAtTime(down ? 170 : 4200, t + dur);
const ng = ctx.createGain();
ng.gain.setValueAtTime(0.0001, t);
ng.gain.exponentialRampToValueAtTime(0.3, t + dur * 0.35);
ng.gain.exponentialRampToValueAtTime(0.0001, t + dur);
src.connect(bp); bp.connect(ng); ng.connect(dest);
src.start(t); src.stop(t + dur + 0.03);
// the record itself slowing down / spinning up: two detuned saws with wow
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.setValueAtTime(down ? 2400 : 320, t);
lp.frequency.exponentialRampToValueAtTime(down ? 320 : 2400, t + dur);
const tg = ctx.createGain();
tg.gain.setValueAtTime(0.0001, t);
tg.gain.exponentialRampToValueAtTime(0.13, t + 0.05);
tg.gain.setValueAtTime(0.13, t + dur * 0.7);
tg.gain.exponentialRampToValueAtTime(0.0001, t + dur);
lp.connect(tg); tg.connect(dest);
// wow-flutter LFO wobbles both saw pitches
const wow = ctx.createOscillator();
wow.frequency.value = down ? 6.5 : 8.5;
const wowAmt = ctx.createGain(); wowAmt.gain.value = 35; // cents
wow.connect(wowAmt);
for (const cents of [-7, 6]) {
const osc = ctx.createOscillator();
osc.type = 'sawtooth';
osc.detune.value = cents;
osc.frequency.setValueAtTime(down ? 310 : 42, t);
osc.frequency.exponentialRampToValueAtTime(down ? 42 : 310, t + dur);
wowAmt.connect(osc.detune);
osc.connect(lp);
osc.start(t); osc.stop(t + dur + 0.03);
}
wow.start(t); wow.stop(t + dur + 0.03);
}
/** Press-stamp THUNK: heavy die drop, felt slap, tiny lift squeak. */
export function stampThunk(ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number): void {
// low die body
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(0.55, t + 0.006);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.3);
g.connect(dest);
const osc = ctx.createOscillator();
osc.type = 'sine';
osc.frequency.setValueAtTime(130, t);
osc.frequency.exponentialRampToValueAtTime(42, t + 0.16);
osc.connect(g); osc.start(t); osc.stop(t + 0.32);
// mid "chunk"
const cg = ctx.createGain();
cg.gain.setValueAtTime(0.22, t);
cg.gain.exponentialRampToValueAtTime(0.0001, t + 0.09);
cg.connect(dest);
const co = ctx.createOscillator();
co.type = 'triangle';
co.frequency.setValueAtTime(340, t);
co.frequency.exponentialRampToValueAtTime(110, t + 0.08);
co.connect(cg); co.start(t); co.stop(t + 0.11);
// felt slap
const src = ctx.createBufferSource();
src.buffer = noise; src.loop = true;
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass'; lp.frequency.value = 1300;
const ng = ctx.createGain();
ng.gain.setValueAtTime(0.3, t);
ng.gain.exponentialRampToValueAtTime(0.0001, t + 0.06);
src.connect(lp); lp.connect(ng); ng.connect(dest);
src.start(t); src.stop(t + 0.08);
// the stamp lifting off (tiny squeak, delayed)
const sq = ctx.createOscillator();
sq.type = 'sine';
sq.frequency.setValueAtTime(900, t + 0.24);
sq.frequency.exponentialRampToValueAtTime(1500, t + 0.3);
const sg = ctx.createGain();
sg.gain.setValueAtTime(0.0001, t + 0.24);
sg.gain.exponentialRampToValueAtTime(0.05, t + 0.26);
sg.gain.exponentialRampToValueAtTime(0.0001, t + 0.34);
sq.connect(sg); sg.connect(dest);
sq.start(t + 0.24); sq.stop(t + 0.36);
}
/**
* One-shot stamp fanfare in the stamped world's own voice/key:
* acid = squelchy Fm 303 run, dub = deep sub steps + boing, disco = pentatonic
* sparkle run. All land inside ~0.8 s so the thunk stays the star.
*/
export function stampFanfare(
ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number,
world: 'acid' | 'dub' | 'disco',
): void {
const mtof = (m: number) => 440 * Math.pow(2, (m - 69) / 12);
if (world === 'acid') {
// F3 Ab3 C4 F4 squelch run with an opening filter
[53, 56, 60, 65].forEach((m, i) => {
const tt = t + 0.12 + i * 0.095;
const osc = ctx.createOscillator();
osc.type = 'sawtooth'; osc.frequency.value = mtof(m);
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass'; lp.Q.value = 14;
lp.frequency.setValueAtTime(700 + i * 900, tt);
lp.frequency.exponentialRampToValueAtTime(300, tt + 0.16);
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, tt);
g.gain.exponentialRampToValueAtTime(0.24, tt + 0.008);
g.gain.exponentialRampToValueAtTime(0.0001, tt + 0.16);
osc.connect(lp); lp.connect(g); g.connect(dest);
osc.start(tt); osc.stop(tt + 0.2);
});
} else if (world === 'dub') {
// F1 Ab1 C2 sub steps, then a proud little spring twang
[29, 32, 36].forEach((m, i) => {
const tt = t + 0.14 + i * 0.19;
const osc = ctx.createOscillator();
osc.type = 'sine'; osc.frequency.value = mtof(m);
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, tt);
g.gain.exponentialRampToValueAtTime(0.5, tt + 0.02);
g.gain.exponentialRampToValueAtTime(0.0001, tt + 0.2);
osc.connect(g); g.connect(dest);
osc.start(tt); osc.stop(tt + 0.24);
});
// twang tail
const tw = ctx.createOscillator();
tw.type = 'sine';
tw.frequency.setValueAtTime(500, t + 0.72);
tw.frequency.exponentialRampToValueAtTime(110, t + 1.05);
const wob = ctx.createOscillator(); wob.frequency.value = 22;
const wg = ctx.createGain(); wg.gain.value = 160;
wob.connect(wg); wg.connect(tw.frequency);
const tg = ctx.createGain();
tg.gain.setValueAtTime(0.0001, t + 0.72);
tg.gain.exponentialRampToValueAtTime(0.16, t + 0.74);
tg.gain.exponentialRampToValueAtTime(0.0001, t + 1.1);
tw.connect(tg); tg.connect(dest);
tw.start(t + 0.72); tw.stop(t + 1.12);
wob.start(t + 0.72); wob.stop(t + 1.12);
} else {
// F minor pentatonic sparkle up two octaves
[65, 68, 70, 72, 75, 77].forEach((m, i) => {
const tt = t + 0.12 + i * 0.07;
const osc = ctx.createOscillator();
osc.type = 'triangle'; osc.frequency.value = mtof(m + 12);
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, tt);
g.gain.exponentialRampToValueAtTime(0.16, tt + 0.006);
g.gain.exponentialRampToValueAtTime(0.0001, tt + 0.18);
osc.connect(g); g.connect(dest);
osc.start(tt); osc.stop(tt + 0.2);
});
}
// shared glitter dust on top
const src = ctx.createBufferSource();
src.buffer = noise; src.loop = true;
const hp = ctx.createBiquadFilter();
hp.type = 'highpass'; hp.frequency.value = 6000;
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t + 0.1);
g.gain.exponentialRampToValueAtTime(0.06, t + 0.2);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.8);
src.connect(hp); hp.connect(g); g.connect(dest);
src.start(t + 0.1); src.stop(t + 0.85);
}
/**
* stamp:all golden riser: a long climbing sweep that blooms into an F-major
* add9 (the minor booth's picardy moment) with bell glints. ~3 s.
*/
export function goldenRiser(ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number): void {
const mtof = (m: number) => 440 * Math.pow(2, (m - 69) / 12);
// climbing noise riser
const src = ctx.createBufferSource();
src.buffer = noise; src.loop = true;
const bp = ctx.createBiquadFilter();
bp.type = 'bandpass'; bp.Q.value = 0.9;
bp.frequency.setValueAtTime(260, t);
bp.frequency.exponentialRampToValueAtTime(7500, t + 2.0);
const ng = ctx.createGain();
ng.gain.setValueAtTime(0.0001, t);
ng.gain.exponentialRampToValueAtTime(0.26, t + 1.85);
ng.gain.exponentialRampToValueAtTime(0.0001, t + 2.25);
src.connect(bp); bp.connect(ng); ng.connect(dest);
src.start(t); src.stop(t + 2.3);
// rising glissando tone under it
const gl = ctx.createOscillator();
gl.type = 'sawtooth';
gl.frequency.setValueAtTime(mtof(41), t);
gl.frequency.exponentialRampToValueAtTime(mtof(65), t + 2.0);
const glp = ctx.createBiquadFilter();
glp.type = 'lowpass'; glp.Q.value = 6;
glp.frequency.setValueAtTime(500, t);
glp.frequency.exponentialRampToValueAtTime(5200, t + 2.0);
const gg = ctx.createGain();
gg.gain.setValueAtTime(0.0001, t);
gg.gain.exponentialRampToValueAtTime(0.12, t + 1.6);
gg.gain.exponentialRampToValueAtTime(0.0001, t + 2.1);
gl.connect(glp); glp.connect(gg); gg.connect(dest);
gl.start(t); gl.stop(t + 2.15);
// bloom: F major add9 (F A C G) at the top
const bloom = t + 2.0;
for (const [m, amt] of [[53, 1], [57, 0.8], [60, 0.8], [67, 0.6], [65 + 12, 0.35]] as const) {
const osc = ctx.createOscillator();
osc.type = 'triangle';
osc.frequency.value = mtof(m);
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, bloom);
g.gain.exponentialRampToValueAtTime(0.14 * amt, bloom + 0.03);
g.gain.exponentialRampToValueAtTime(0.0001, bloom + 1.4);
osc.connect(g); g.connect(dest);
osc.start(bloom); osc.stop(bloom + 1.45);
}
// bell glints scattered over the bloom
[0, 0.14, 0.31, 0.52].forEach((off, i) => {
const osc = ctx.createOscillator();
osc.type = 'sine';
osc.frequency.value = mtof([89, 93, 96, 101][i]);
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, bloom + off);
g.gain.exponentialRampToValueAtTime(0.09, bloom + off + 0.008);
g.gain.exponentialRampToValueAtTime(0.0001, bloom + off + 0.5);
osc.connect(g); g.connect(dest);
osc.start(bloom + off); osc.stop(bloom + off + 0.55);
});
}
/** Dust bunny spooked: a shy, quiet double chirp. Rare by caller throttle. */
export function bunnySqueak(ctx: BaseAudioContext, dest: AudioNode, t: number): void {
for (const [off, f0, f1, dur] of [[0, 1500, 2400, 0.09], [0.12, 1900, 1450, 0.07]] as const) {
const osc = ctx.createOscillator();
osc.type = 'sine';
osc.frequency.setValueAtTime(f0, t + off);
osc.frequency.exponentialRampToValueAtTime(f1, t + off + dur);
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t + off);
g.gain.exponentialRampToValueAtTime(0.07, t + off + 0.012);
g.gain.exponentialRampToValueAtTime(0.0001, t + off + dur + 0.02);
osc.connect(g); g.connect(dest);
osc.start(t + off); osc.stop(t + off + dur + 0.04);
}
}
export type SfxName = export type SfxName =
| 'footstep' | 'land' | 'break' | 'place' | 'footstep' | 'land' | 'break' | 'place'
| 'fader' | 'button' | 'rca' | 'fuse' | 'cue' | 'needle' | 'fader' | 'button' | 'rca' | 'fuse' | 'cue' | 'needle'
@ -408,4 +667,6 @@ export type SfxName =
| 'crimp' | 'wireOn' | 'wireOff' | 'screwTick' | 'screwClunk' | 'crimp' | 'wireOn' | 'wireOff' | 'screwTick' | 'screwClunk'
| 'tiltCreak' | 'weightDetent' | 'bubbleLock' | 'skate' | 'tiltCreak' | 'weightDetent' | 'bubbleLock' | 'skate'
// Social & Reset Ritual: // Social & Reset Ritual:
| 'airhorn' | 'powerDown'; | 'airhorn' | 'powerDown'
// SIDE B — crate worlds:
| 'portalIn' | 'portalOut' | 'stampThunk' | 'goldenRiser' | 'bunny';

View File

@ -239,6 +239,60 @@ export function lead(
si.start(t); si.stop(t + dur + 0.05); si.start(t); si.stop(t + dur + 0.05);
} }
/** Shaker: a breathy high bandpass chiff. 16th-note bed of the bonus stem. */
export function shaker(
ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number,
o: { gain?: number; detune?: number } = {},
): void {
const gain = o.gain ?? 0.1;
const src = ctx.createBufferSource();
src.buffer = noise; src.loop = true;
src.detune.value = o.detune ?? 0;
const bp = ctx.createBiquadFilter();
bp.type = 'bandpass'; bp.frequency.value = 5200; bp.Q.value = 1.4;
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(gain, t + 0.012);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.07);
src.connect(bp); bp.connect(g); g.connect(dest);
src.start(t); src.stop(t + 0.09);
}
/** Conga: pitched hand-drum. `slap` = tighter/brighter with a skin tick. */
export function conga(
ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number,
o: { gain?: number; detune?: number; slap?: boolean } = {},
): void {
const gain = o.gain ?? 0.24;
const rate = Math.pow(2, (o.detune ?? 0) / 1200);
const f0 = (o.slap ? 300 : 205) * rate;
const dur = o.slap ? 0.09 : 0.16;
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(gain, t + 0.005);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
g.connect(dest);
const osc = ctx.createOscillator();
osc.type = 'sine';
osc.frequency.setValueAtTime(f0, t);
osc.frequency.exponentialRampToValueAtTime(f0 * 0.72, t + dur);
osc.connect(g);
osc.start(t); osc.stop(t + dur + 0.03);
if (o.slap) {
// skin tick on the slap
const sg = ctx.createGain();
sg.gain.setValueAtTime(gain * 0.5, t);
sg.gain.exponentialRampToValueAtTime(0.0001, t + 0.03);
sg.connect(dest);
const src = ctx.createBufferSource();
src.buffer = noise; src.loop = true;
const hp = ctx.createBiquadFilter();
hp.type = 'highpass'; hp.frequency.value = 2600;
src.connect(hp); hp.connect(sg);
src.start(t); src.stop(t + 0.04);
}
}
/** /**
* White-noise sweep (riser when up=true, downlifter when up=false) through a * White-noise sweep (riser when up=true, downlifter when up=false) through a
* moving bandpass. Used at 8-bar boundaries; also the win-open sweep. * moving bandpass. Used at 8-bar boundaries; also the win-open sweep.

396
src/audio/worldGrooves.ts Normal file
View File

@ -0,0 +1,396 @@
// TURNCRAFT — Lane E2 (SIDE B). The three pocket-world grooves. One groove is
// active at a time; each runs on its OWN lookahead Transport at its own BPM
// (acid 118, dub 74, disco 122) with the same spin-up/brake feel as the booth
// (you dove into a record — it spins up around you; leaving brakes it).
//
// AudioEngine owns the routing (worldBus -> outGain, booth mix ducked while a
// groove is active) and forwards the quest events:
// fader:move acid_0..2 -> setAcidBand (per-band 303 cutoff)
// machine:interact acid_lock -> acidLock (shimmer layer + chime)
// machine:interact dub_pickup/dub_drop -> setCarrying (echo wet rises)
// machine:interact dub_feed -> dubFeed (the big SPROING, feedback 0.8)
// machine:interact disco_cue/disco_step -> discoCue / discoStep
// stamp:got -> stampGlory (clap bars / swell / boing)
//
// While active, the groove emits audio:beat/audio:bar on ITS grid (the booth
// Groove's emission is suppressed by AudioEngine) so Lane M's disco sequence
// and Lane S's pulse FX stay locked to what the player actually hears.
import { bus } from '../core/events';
import { Transport } from './scheduler';
import { mtof, kick, hat, clap } from './synth';
import {
acid303, shimmerPing, lockChime,
dubSub, rimshot, skankChord, springBoing, feedBurst,
octBass, stringStab, cueBlip, stringSwell,
} from './worldSynth';
export type PocketWorld = 'acid' | 'dub' | 'disco';
export const POCKET_WORLDS: readonly PocketWorld[] = ['acid', 'dub', 'disco'];
export function isPocketWorld(w: string): w is PocketWorld {
return (POCKET_WORLDS as readonly string[]).includes(w);
}
const WORLD_BPM: Record<PocketWorld, number> = { acid: 118, dub: 74, disco: 122 };
const STEPS = 16; // per bar
const LOOP = 32; // every groove is a 2-bar loop
// ── ACID data ───────────────────────────────────────────────────────────────
// 2-bar 303 line in F minor (midi; a=accent, s=slide-from-previous).
type AcidStep = { n: number; a?: 1; s?: 1 } | null;
const ACID_LINE: AcidStep[] = [
// bar 0
{ n: 41, a: 1 }, { n: 41 }, null, { n: 53, s: 1 },
{ n: 41 }, null, { n: 44 }, { n: 41 },
null, { n: 41 }, { n: 48, a: 1, s: 1 }, { n: 41 },
null, { n: 39 }, { n: 41, a: 1 }, null,
// bar 1
{ n: 41, a: 1 }, null, { n: 41 }, { n: 46, s: 1 },
{ n: 41 }, null, { n: 44, a: 1 }, null,
{ n: 41 }, { n: 41 }, { n: 48, s: 1 }, null,
{ n: 53, a: 1 }, null, { n: 39 }, { n: 41 },
];
// Which pedestal band owns a step's filter cutoff (bar position 0..15).
function acidBandFor(s: number): number { return s <= 5 ? 0 : s <= 10 ? 1 : 2; }
// value 0..1 -> cutoff Hz, exponential across a wide per-band offset range so
// sweeping a pedestal is unmistakable. Lane M owns the hidden sweet targets;
// we only make the sweep audible and satisfying.
const ACID_CUT: [number, number][] = [[110, 2600], [160, 3800], [240, 5600]];
function acidCutoff(band: number, v: number): number {
const [lo, hi] = ACID_CUT[band];
return lo * Math.pow(hi / lo, Math.max(0, Math.min(1, v)));
}
// ── DUB data ────────────────────────────────────────────────────────────────
const DUB_SUB: Record<number, { n: number; dur: number }> = {
0: { n: 29, dur: 0.7 }, 10: { n: 32, dur: 0.35 },
16: { n: 29, dur: 0.7 }, 22: { n: 27, dur: 0.35 }, 26: { n: 29, dur: 0.3 },
};
const DUB_SKANK = [53, 56, 60]; // Fm triad (midi) under the rim on the 2/4
// ── DISCO data ──────────────────────────────────────────────────────────────
const DISCO_STABS: Record<number, number[]> = {
3: [65, 68, 72], 11: [65, 68, 72], // bar 0: Fm
19: [61, 65, 68], 27: [63, 67, 70], // bar 1: Db, Eb
};
const PENT = [0, 3, 5, 7, 10]; // F minor pentatonic degrees
/** Tile index 0..15 -> pentatonic midi note rising across two-plus octaves. */
function pentMidi(index: number): number {
const i = Math.max(0, Math.min(15, Math.round(index)));
return 65 + Math.floor(i / 5) * 12 + PENT[i % 5];
}
// ── the manager ─────────────────────────────────────────────────────────────
interface DubChain {
echoIn: GainNode; // send anything here to be echoed
delay: DelayNode;
fb: GainNode; // feedback amount (0.55 base, 0.8 on dub_feed)
wet: GainNode; // echo return level ("carry the wet")
}
export class WorldGrooves {
private ctx: BaseAudioContext;
private noise: AudioBuffer;
private out: AudioNode;
private activeWorld: PocketWorld | null = null;
private transport: Transport | null = null;
private wg: GainNode | null = null; // active groove's output gain
private dub: DubChain | null = null;
private barCounter = 0;
// acid state (band values persist across dives; locks reset per entry)
private acidBand = [0.12, 0.85, 0.18]; // wrong-by-default per-band offsets
private acidLocked = [false, false, false];
private acidGloryBars = 0;
// dub state
private carrying = false;
constructor(ctx: BaseAudioContext, noise: AudioBuffer, out: AudioNode) {
this.ctx = ctx;
this.noise = noise;
this.out = out;
}
get active(): PocketWorld | null { return this.activeWorld; }
/** Dive in: build the world's chain and spin its transport up. */
start(world: PocketWorld): void {
if (this.activeWorld === world) return;
if (this.activeWorld) this.stop(true);
const ctx = this.ctx;
const wg = ctx.createGain();
wg.gain.value = 0;
wg.connect(this.out);
this.wg = wg;
const now = ctx.currentTime;
wg.gain.setValueAtTime(0, now);
wg.gain.linearRampToValueAtTime(1, now + 0.45);
if (world === 'acid') {
this.acidLocked = [false, false, false]; // targets re-seed per entry (Lane M)
this.acidGloryBars = 0;
}
if (world === 'dub') {
const echoIn = ctx.createGain(); echoIn.gain.value = 1;
const delay = ctx.createDelay(2.0);
delay.delayTime.value = (60 / WORLD_BPM.dub) * 0.75; // dotted 8th ≈ 0.608 s
const fb = ctx.createGain(); fb.gain.value = 0.55;
const highcut = ctx.createBiquadFilter();
highcut.type = 'lowpass'; highcut.frequency.value = 2000;
const wet = ctx.createGain();
wet.gain.value = this.carrying ? 0.8 : 0.32;
echoIn.connect(delay);
delay.connect(highcut); highcut.connect(fb); fb.connect(delay);
delay.connect(wet); wet.connect(wg);
this.dub = { echoIn, delay, fb, wet };
}
this.barCounter = 0;
const tr = new Transport(
ctx,
(step, t, det, emitAt) => this.scheduleStep(world, step, t, det, emitAt),
0.9, // snappier than the booth platter
WORLD_BPM[world],
);
tr.start();
tr.setRateTarget(1);
this.transport = tr;
this.activeWorld = world;
}
/** Brake out. `immediate` (world switch) tears down without the tail. */
stop(immediate = false): void {
const wg = this.wg, tr = this.transport, dub = this.dub;
if (!wg || !tr) return;
this.activeWorld = null;
this.wg = null;
this.transport = null;
this.dub = null;
const now = this.ctx.currentTime;
tr.setRateTarget(0);
wg.gain.cancelScheduledValues(now);
wg.gain.setValueAtTime(wg.gain.value, now);
wg.gain.linearRampToValueAtTime(0, now + (immediate ? 0.12 : 0.85));
window.setTimeout(() => {
tr.stop();
wg.disconnect();
if (dub) { dub.echoIn.disconnect(); dub.delay.disconnect(); dub.fb.disconnect(); dub.wet.disconnect(); }
}, immediate ? 200 : 1100);
}
// ── quest hooks (safe to call any time; no-ops when not applicable) ──────
/** Pedestal fader 0..1 -> that band's 303 cutoff (persists across dives). */
setAcidBand(band: number, v: number): void {
if (band < 0 || band > 2) return;
this.acidBand[band] = Math.max(0, Math.min(1, v));
}
/** A pedestal locked into its sweet spot: chime + that band's shimmer layer. */
acidLock(band: number): void {
const b = Math.max(0, Math.min(2, Math.round(band)));
if (this.acidLocked[b]) return;
this.acidLocked[b] = true;
if (this.wg && this.activeWorld === 'acid') {
lockChime(this.ctx, this.wg, this.ctx.currentTime + 0.01);
}
}
/** Fallback band for an acid_lock event that carries no index. */
firstUnlockedBand(): number {
const i = this.acidLocked.indexOf(false);
return i < 0 ? 2 : i;
}
/** Carrying the echo charge: the wet rises with you. */
setCarrying(on: boolean): void {
this.carrying = on;
if (this.dub) {
const g = this.dub.wet.gain, now = this.ctx.currentTime;
g.cancelScheduledValues(now);
g.setValueAtTime(g.value, now);
g.linearRampToValueAtTime(on ? 0.8 : 0.32, now + 0.8);
}
}
/** The big SPROING: burst into the echo, feedback momentarily 0.8. */
dubFeed(): void {
this.carrying = false;
if (!this.dub || !this.wg) return;
const now = this.ctx.currentTime;
const fb = this.dub.fb.gain;
fb.cancelScheduledValues(now);
fb.setValueAtTime(fb.value, now);
fb.linearRampToValueAtTime(0.8, now + 0.05);
fb.setValueAtTime(0.8, now + 1.9);
fb.linearRampToValueAtTime(0.55, now + 2.7);
feedBurst(this.ctx, this.dub.echoIn, this.noise, now + 0.02, { gain: 0.55 });
springBoing(this.ctx, this.wg, this.noise, now + 0.02, { big: true });
springBoing(this.ctx, this.dub.echoIn, this.noise, now + 0.06, { big: true, gain: 0.18 });
// the wet settles back down once the charge is fed
const w = this.dub.wet.gain;
w.cancelScheduledValues(now + 2.7);
w.setValueAtTime(0.8, now + 2.7);
w.linearRampToValueAtTime(0.32, now + 4);
}
/** Machine cue blip for tile `index` (pentatonic, pitch by index). */
discoCue(index: number): void {
if (!this.wg) return;
cueBlip(this.ctx, this.wg, this.ctx.currentTime + 0.001, mtof(pentMidi(index)));
}
/** The player's step echoes the tile's blip a fifth down, softer. */
discoStep(index: number): void {
if (!this.wg) return;
cueBlip(this.ctx, this.wg, this.ctx.currentTime + 0.001, mtof(pentMidi(index) - 7), { soft: true });
}
/** Wrong tile — THE FLOOR FORGIVES: a gentle descending "aw", never a buzzer. */
discoMiss(): void {
if (!this.wg) return;
const t = this.ctx.currentTime + 0.001;
cueBlip(this.ctx, this.wg, t, mtof(60), { soft: true, gain: 0.16 });
cueBlip(this.ctx, this.wg, t + 0.13, mtof(56), { soft: true, gain: 0.13 });
}
/** World-specific stamp glory layered into the running groove. */
stampGlory(world: PocketWorld): void {
if (this.activeWorld !== world || !this.wg) return;
const now = this.ctx.currentTime;
if (world === 'acid') {
this.acidGloryBars = 4; // clap layer for 4 bars (scheduled per step)
} else if (world === 'dub') {
springBoing(this.ctx, this.wg, this.noise, now + 0.05, { big: true, gain: 0.3 });
} else {
stringSwell(this.ctx, this.wg, now + 0.05, [53, 56, 60, 63, 67].map(mtof), { gain: 0.34 });
}
}
// ── scheduling ───────────────────────────────────────────────────────────
private scheduleStep(
world: PocketWorld, absStep: number, time: number, detune: number,
emitAt: (t: number, fn: () => void) => void,
): void {
const local = ((absStep % LOOP) + LOOP) % LOOP;
const bar = Math.floor(local / STEPS);
const s = local % STEPS;
const wg = this.wg;
if (!wg) return;
if (world === 'acid') this.acidStep(wg, bar, s, local, time, detune);
else if (world === 'dub') this.dubStep(wg, bar, s, local, time, detune);
else this.discoStepSched(wg, bar, s, local, time, detune);
// beat/bar events at the audible time, on this groove's grid
if (s % 4 === 0) {
const energy = world === 'dub' ? (s === 0 ? 1 : 0.55) : s === 0 ? 1 : 0.85;
emitAt(time, () => bus.emit('audio:beat', { energy }));
}
if (s === 0) {
const bn = this.barCounter++;
emitAt(time, () => bus.emit('audio:bar', { bar: bn }));
if (world === 'acid' && this.acidGloryBars > 0) this.acidGloryBars--;
}
}
private acidStep(wg: GainNode, _bar: number, s: number, local: number, time: number, detune: number): void {
// drums: four-on-floor + off-hats
if (s % 4 === 0) kick(this.ctx, wg, time, { gain: 0.95, detune });
hat(this.ctx, wg, this.noise, time, { gain: s % 4 === 2 ? 0.2 : 0.05, detune });
// THE 303
const st = ACID_LINE[local];
if (st) {
const band = acidBandFor(s);
const cutoff = acidCutoff(band, this.acidBand[band]);
const prev = ACID_LINE[(local + LOOP - 1) % LOOP];
acid303(this.ctx, wg, time, mtof(st.n), {
cutoff, detune,
accent: st.a === 1,
dur: st.s ? 0.26 : 0.18,
slideFrom: st.s && prev ? mtof(prev.n) : undefined,
});
// glory doubles the line an octave up
if (this.acidGloryBars > 0) {
acid303(this.ctx, wg, time, mtof(st.n + 12), {
cutoff: Math.max(cutoff, 1800), detune, gain: 0.12, dur: 0.14,
});
}
}
// locked pedestals shimmer on the offbeats (one ping per locked band)
if (s % 4 === 2) {
for (let b = 0; b < 3; b++) {
if (this.acidLocked[b]) {
shimmerPing(this.ctx, wg, time, 1500 * (1 + b * 0.4), { detune, gain: 0.042 });
}
}
}
// stamped glory: claps on the backbeat
if (this.acidGloryBars > 0 && (s === 4 || s === 12)) {
clap(this.ctx, wg, this.noise, time, { gain: 0.4, detune });
}
}
private dubStep(wg: GainNode, bar: number, s: number, local: number, time: number, detune: number): void {
const dub = this.dub;
// half-time: kick on 1 and 3
if (s === 0) kick(this.ctx, wg, time, { gain: 0.95, detune });
else if (s === 8) kick(this.ctx, wg, time, { gain: 0.65, detune });
// whispered hats
if (s % 2 === 0) hat(this.ctx, wg, this.noise, time, { gain: s % 4 === 2 ? 0.06 : 0.03, detune });
// rim skank on the 2/4, sent through the feedback echo
if (s === 4 || s === 12) {
rimshot(this.ctx, wg, this.noise, time, { gain: 0.26, detune });
if (dub) {
rimshot(this.ctx, dub.echoIn, this.noise, time, { gain: 0.3, detune });
skankChord(this.ctx, dub.echoIn, time, DUB_SKANK.map(mtof), { gain: 0.12, detune });
}
skankChord(this.ctx, wg, time, DUB_SKANK.map(mtof), { gain: 0.1, detune });
}
// deep sub line
const sub = DUB_SUB[local];
if (sub) dubSub(this.ctx, wg, time, mtof(sub.n), { gain: 0.6, dur: sub.dur, detune });
// the chamber's ambient spring twang, once per loop
if (bar === 1 && s === 6) {
springBoing(this.ctx, dub ? dub.echoIn : wg, this.noise, time, { gain: 0.1 });
}
}
private discoStepSched(wg: GainNode, _bar: number, s: number, local: number, time: number, detune: number): void {
// four-on-floor + offbeat open hats + backbeat claps
if (s % 4 === 0) kick(this.ctx, wg, time, { gain: 0.95, detune });
if (s % 4 === 2) hat(this.ctx, wg, this.noise, time, { gain: 0.2, open: true, detune });
else if (s % 2 === 0) hat(this.ctx, wg, this.noise, time, { gain: 0.05, detune });
if (s === 4 || s === 12) clap(this.ctx, wg, this.noise, time, { gain: 0.32, detune });
// octave bass on the 8ths (root/octave), with a bar-1 turnaround
if (s % 2 === 0) {
let root = 41;
if (local >= 28) root = 39; // Eb walk into the loop
const midi = s % 4 === 0 ? root : root + 12;
octBass(this.ctx, wg, time, mtof(midi), { gain: 0.38, detune });
}
// string stab loop
const stabChord = DISCO_STABS[local];
if (stabChord) {
stringStab(this.ctx, wg, time, stabChord.map(mtof), { gain: 0.24, detune });
}
}
}

357
src/audio/worldSynth.ts Normal file
View File

@ -0,0 +1,357 @@
// TURNCRAFT — Lane E2 (SIDE B). Voices for the three pocket-world grooves.
// Same rules as synth.ts: pure helpers, absolute-time scheduling into `dest`,
// zero shared mutable state, `detune` (cents) honoured on every pitched source
// so the pocket transports' spin-up/brake bends whole grooves together.
// ── ACID WAREHOUSE ──────────────────────────────────────────────────────────
/**
* THE 303: one step of the acid line. Sawtooth through a high-Q resonant
* lowpass. `cutoff` is the band's mapped value (Hz) the filter envelope
* peaks above it and settles below it, so sweeping a pedestal fader is
* unmistakably audible. `accent` = louder + brighter + more resonant;
* `slideFrom` = legato glide from the previous note's frequency.
*/
export function acid303(
ctx: BaseAudioContext, dest: AudioNode, t: number, freq: number,
o: { cutoff: number; gain?: number; detune?: number; dur?: number; accent?: boolean; slideFrom?: number },
): void {
const accent = o.accent ?? false;
const gain = (o.gain ?? 0.3) * (accent ? 1.45 : 1);
const dur = o.dur ?? 0.18;
const cut = Math.max(70, Math.min(9000, o.cutoff * (accent ? 1.5 : 1)));
const osc = ctx.createOscillator();
osc.type = 'sawtooth';
osc.detune.value = o.detune ?? 0;
if (o.slideFrom && o.slideFrom !== freq) {
osc.frequency.setValueAtTime(o.slideFrom, t);
osc.frequency.exponentialRampToValueAtTime(freq, t + Math.min(0.09, dur * 0.5));
} else {
osc.frequency.setValueAtTime(freq, t);
}
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.Q.value = accent ? 17 : 12;
lp.frequency.setValueAtTime(Math.min(11000, cut * 2.1), t);
lp.frequency.exponentialRampToValueAtTime(Math.max(60, cut * 0.5), t + dur);
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(gain, t + 0.006);
g.gain.setValueAtTime(gain, t + dur * 0.55);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
osc.connect(lp); lp.connect(g); g.connect(dest);
osc.start(t); osc.stop(t + dur + 0.05);
}
/** Lock shimmer ping: tiny glassy dyad — one locked pedestal's sparkle layer. */
export function shimmerPing(
ctx: BaseAudioContext, dest: AudioNode, t: number, freq: number,
o: { gain?: number; detune?: number } = {},
): void {
const gain = o.gain ?? 0.045;
for (const [mult, amt] of [[1, 1], [1.5, 0.6]] as const) {
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(gain * amt, t + 0.008);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.22);
g.connect(dest);
const osc = ctx.createOscillator();
osc.type = 'sine';
osc.frequency.value = freq * mult;
osc.detune.value = (o.detune ?? 0) + (mult === 1 ? 0 : 6);
osc.connect(g);
osc.start(t); osc.stop(t + 0.26);
}
}
/** A pedestal snaps into tune: rising fifth chirp, small and satisfied. */
export function lockChime(ctx: BaseAudioContext, dest: AudioNode, t: number): void {
for (const [f, off] of [[660, 0], [990, 0.07]] as const) {
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t + off);
g.gain.exponentialRampToValueAtTime(0.14, t + off + 0.01);
g.gain.exponentialRampToValueAtTime(0.0001, t + off + 0.24);
g.connect(dest);
const osc = ctx.createOscillator();
osc.type = 'triangle';
osc.frequency.value = f;
osc.connect(g);
osc.start(t + off); osc.stop(t + off + 0.28);
}
}
// ── DUB CHAMBER ─────────────────────────────────────────────────────────────
/** Deep sub bass: soft-attack sine + a whisper of 2nd harmonic for shape. */
export function dubSub(
ctx: BaseAudioContext, dest: AudioNode, t: number, freq: number,
o: { gain?: number; detune?: number; dur?: number } = {},
): void {
const gain = o.gain ?? 0.62;
const dur = o.dur ?? 0.55;
const det = o.detune ?? 0;
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(gain, t + 0.03);
g.gain.setValueAtTime(gain, t + dur * 0.6);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
g.connect(dest);
const sub = ctx.createOscillator();
sub.type = 'sine'; sub.frequency.value = freq; sub.detune.value = det;
sub.connect(g);
sub.start(t); sub.stop(t + dur + 0.05);
const h2 = ctx.createOscillator();
h2.type = 'sine'; h2.frequency.value = freq * 2; h2.detune.value = det;
const hg = ctx.createGain(); hg.gain.value = 0.16;
h2.connect(hg); hg.connect(g);
h2.start(t); h2.stop(t + dur + 0.05);
}
/** Rimshot: woody click + tight ring — the dub skank hit (send it to the echo). */
export function rimshot(
ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number,
o: { gain?: number; detune?: number } = {},
): void {
const gain = o.gain ?? 0.3;
const rate = Math.pow(2, (o.detune ?? 0) / 1200);
// ring
const g = ctx.createGain();
g.gain.setValueAtTime(gain, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.07);
g.connect(dest);
const osc = ctx.createOscillator();
osc.type = 'triangle';
osc.frequency.value = 440 * rate;
osc.connect(g);
osc.start(t); osc.stop(t + 0.09);
// stick click
const src = ctx.createBufferSource();
src.buffer = noise; src.loop = true;
src.detune.value = o.detune ?? 0;
const bp = ctx.createBiquadFilter();
bp.type = 'bandpass'; bp.frequency.value = 2000 * rate; bp.Q.value = 2.5;
const ng = ctx.createGain();
ng.gain.setValueAtTime(gain * 0.9, t);
ng.gain.exponentialRampToValueAtTime(0.0001, t + 0.03);
src.connect(bp); bp.connect(ng); ng.connect(dest);
src.start(t); src.stop(t + 0.05);
}
/** Dark staccato skank chord under the rim (body for the echo to chew on). */
export function skankChord(
ctx: BaseAudioContext, dest: AudioNode, t: number, freqs: number[],
o: { gain?: number; detune?: number } = {},
): void {
const gain = (o.gain ?? 0.14) / Math.max(1, freqs.length);
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass'; lp.frequency.value = 1300; lp.Q.value = 1;
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(gain * freqs.length, t + 0.008);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.12);
lp.connect(g); g.connect(dest);
for (const f of freqs) {
const osc = ctx.createOscillator();
osc.type = 'square';
osc.frequency.value = f;
osc.detune.value = o.detune ?? 0;
const og = ctx.createGain(); og.gain.value = 1 / freqs.length;
osc.connect(og); og.connect(lp);
osc.start(t); osc.stop(t + 0.15);
}
}
/**
* Spring-reverb boing: an FM-wobbled sine diving through a bandpass with a
* metallic tick on top. `big` is the dub_feed SPROING; default is the ambient
* chamber twang.
*/
export function springBoing(
ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number,
o: { gain?: number; big?: boolean } = {},
): void {
const big = o.big ?? false;
const gain = o.gain ?? (big ? 0.4 : 0.14);
const dur = big ? 0.9 : 0.5;
const f0 = big ? 900 : 640;
const carrier = ctx.createOscillator();
carrier.type = 'sine';
carrier.frequency.setValueAtTime(f0, t);
carrier.frequency.exponentialRampToValueAtTime(big ? 60 : 90, t + dur);
// spring dispersion wobble: FM the carrier with a fast decaying LFO
const wob = ctx.createOscillator();
wob.type = 'sine';
wob.frequency.setValueAtTime(31, t);
wob.frequency.exponentialRampToValueAtTime(9, t + dur);
const wg = ctx.createGain();
wg.gain.setValueAtTime(f0 * 0.45, t);
wg.gain.exponentialRampToValueAtTime(6, t + dur);
wob.connect(wg); wg.connect(carrier.frequency);
const bp = ctx.createBiquadFilter();
bp.type = 'bandpass'; bp.Q.value = 3.5;
bp.frequency.setValueAtTime(f0 * 1.4, t);
bp.frequency.exponentialRampToValueAtTime(150, t + dur);
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(gain, t + 0.012);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
carrier.connect(bp); bp.connect(g); g.connect(dest);
carrier.start(t); carrier.stop(t + dur + 0.05);
wob.start(t); wob.stop(t + dur + 0.05);
// metallic tick as the spring is struck
const src = ctx.createBufferSource();
src.buffer = noise; src.loop = true;
const hp = ctx.createBiquadFilter();
hp.type = 'highpass'; hp.frequency.value = 3000;
const ng = ctx.createGain();
ng.gain.setValueAtTime(gain * 0.5, t);
ng.gain.exponentialRampToValueAtTime(0.0001, t + 0.05);
src.connect(hp); hp.connect(ng); ng.connect(dest);
src.start(t); src.stop(t + 0.07);
}
/** Bandpassed noise burst — the dub_feed charge dropped into the echo input. */
export function feedBurst(
ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number,
o: { gain?: number } = {},
): void {
const gain = o.gain ?? 0.5;
const src = ctx.createBufferSource();
src.buffer = noise; src.loop = true;
const bp = ctx.createBiquadFilter();
bp.type = 'bandpass'; bp.frequency.value = 950; bp.Q.value = 1.8;
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(gain, t + 0.01);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.3);
src.connect(bp); bp.connect(g); g.connect(dest);
src.start(t); src.stop(t + 0.33);
}
// ── DISCO LOFT ──────────────────────────────────────────────────────────────
/** Octave disco bass: punchy saw+square pluck (root/octave alternation). */
export function octBass(
ctx: BaseAudioContext, dest: AudioNode, t: number, freq: number,
o: { gain?: number; detune?: number } = {},
): void {
const gain = o.gain ?? 0.4;
const det = o.detune ?? 0;
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.setValueAtTime(1400, t);
lp.frequency.exponentialRampToValueAtTime(260, t + 0.13);
lp.Q.value = 2;
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(gain, t + 0.008);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.15);
lp.connect(g); g.connect(dest);
for (const [type, amt] of [['sawtooth', 0.7], ['square', 0.4]] as const) {
const osc = ctx.createOscillator();
osc.type = type;
osc.frequency.value = freq;
osc.detune.value = det;
const og = ctx.createGain(); og.gain.value = amt;
osc.connect(og); og.connect(lp);
osc.start(t); osc.stop(t + 0.18);
}
}
/** String stab: chorused saw chord, softer attack than the booth stab. */
export function stringStab(
ctx: BaseAudioContext, dest: AudioNode, t: number, freqs: number[],
o: { gain?: number; detune?: number; dur?: number } = {},
): void {
const gain = (o.gain ?? 0.26) / Math.max(1, freqs.length);
const dur = o.dur ?? 0.32;
const det = o.detune ?? 0;
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass'; lp.frequency.value = 3600; lp.Q.value = 0.8;
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.linearRampToValueAtTime(gain * freqs.length, t + 0.035);
g.gain.setValueAtTime(gain * freqs.length, t + dur * 0.5);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
lp.connect(g); g.connect(dest);
for (const f of freqs) {
for (const cents of [-8, 7]) {
const osc = ctx.createOscillator();
osc.type = 'sawtooth';
osc.frequency.value = f;
osc.detune.value = det + cents;
const og = ctx.createGain(); og.gain.value = 0.5;
osc.connect(og); og.connect(lp);
osc.start(t); osc.stop(t + dur + 0.05);
}
}
}
/** Per-tile cue blip: bright pentatonic pluck (sine + octave square glint). */
export function cueBlip(
ctx: BaseAudioContext, dest: AudioNode, t: number, freq: number,
o: { gain?: number; detune?: number; soft?: boolean } = {},
): void {
const gain = (o.gain ?? 0.22) * (o.soft ? 0.7 : 1);
const dur = 0.16;
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(gain, t + 0.008);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
g.connect(dest);
const osc = ctx.createOscillator();
osc.type = o.soft ? 'triangle' : 'sine';
osc.frequency.value = freq;
osc.detune.value = o.detune ?? 0;
osc.connect(g);
osc.start(t); osc.stop(t + dur + 0.03);
if (!o.soft) {
const sq = ctx.createOscillator();
sq.type = 'square';
sq.frequency.value = freq * 2;
sq.detune.value = o.detune ?? 0;
const sg = ctx.createGain();
sg.gain.setValueAtTime(gain * 0.25, t);
sg.gain.exponentialRampToValueAtTime(0.0001, t + dur * 0.6);
sq.connect(sg); sg.connect(g);
sq.start(t); sq.stop(t + dur);
}
}
/** Filtered-disco string swell — the DISCO stamp's 2.5 s glory chord. */
export function stringSwell(
ctx: BaseAudioContext, dest: AudioNode, t: number, freqs: number[],
o: { gain?: number; dur?: number } = {},
): void {
const dur = o.dur ?? 2.5;
const gain = (o.gain ?? 0.3) / Math.max(1, freqs.length);
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass'; lp.Q.value = 2;
lp.frequency.setValueAtTime(420, t);
lp.frequency.exponentialRampToValueAtTime(6500, t + dur * 0.75);
lp.frequency.exponentialRampToValueAtTime(900, t + dur);
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.linearRampToValueAtTime(gain * freqs.length, t + dur * 0.45);
g.gain.setValueAtTime(gain * freqs.length, t + dur * 0.7);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
lp.connect(g); g.connect(dest);
for (const f of freqs) {
for (const cents of [-9, 8]) {
const osc = ctx.createOscillator();
osc.type = 'sawtooth';
osc.frequency.value = f;
osc.detune.value = cents;
const og = ctx.createGain(); og.gain.value = 0.5;
osc.connect(og); og.connect(lp);
osc.start(t); osc.stop(t + dur + 0.05);
}
}
}

View File

@ -41,8 +41,12 @@ export type GameEvents = {
// predate this phase ('player:emote' / 'quest:reset' above). // predate this phase ('player:emote' / 'quest:reset' above).
'world:enter': { world: string }; // player dove into a pocket 'world:enter': { world: string }; // player dove into a pocket
'world:exit': { world: string }; // player returned to the booth 'world:exit': { world: string }; // player returned to the booth
'stamp:got': { world: string; count: number; total: number }; // origin lets consumers celebrate proportionately: 'local' = this player just
'stamp:all': Record<string, never>; // golden slipmat unlock // won it (full glory), 'remote' = a peer won it live (share it, anchored at
// the crate, third person), 'replay' = join-snapshot state (apply silently).
// Absent = 'local' (back-compat).
'stamp:got': { world: string; count: number; total: number; origin?: string };
'stamp:all': { origin?: string }; // golden slipmat unlock
// Mining-in-progress juice (first-five-minutes pass). Emitted ~8 Hz while // Mining-in-progress juice (first-five-minutes pass). Emitted ~8 Hz while
// a block is being held-mined; FX puffs and audio ticks react. FX-only — // a block is being held-mined; FX puffs and audio ticks react. FX-only —

View File

@ -27,6 +27,31 @@
// radial gauge + ratchet ticks; "screws" → confetti; each workshop SFX. // radial gauge + ratchet ticks; "screws" → confetti; each workshop SFX.
// (These fire the real workshop:test / workshop:torque / workshop:msg bus // (These fire the real workshop:test / workshop:torque / workshop:msg bus
// events that Lane D's machine emits in-game.) // events that Lane D's machine emits in-game.)
// • SIDE B — crate worlds (fires the real world:*/stamp:*/fader:move/
// machine:interact events Lane M emits in-game):
// - Enter a world → portal whoosh-down, the WHOLE booth mix (incl. the
// dead-booth heartbeat) ducks out in ~300 ms, that world's groove spins
// up (acid 118 / dub 74 half-time / disco 122). Exit → reverse whoosh,
// groove brakes, booth mix hands back at its CURRENT state.
// - Duck/handback truth table to verify by ear: enter with deck A playing
// (mix ducks, returns), enter with all stopped (heartbeat ducks,
// returns), press "blow fuse" WHILE inside a pocket (booth power-down
// muffled under the groove; exiting lands on the reset heartbeat, never
// a stale snapshot), fix→WIN then dive+exit (full win mix hands back).
// - ACID: sweep the three pedestal faders — each drives its band of the
// 303 line's filter cutoff (audibly, exponentially). "lock N" adds that
// band's shimmer layer + chime. Stamp = 4 bars of clap glory.
// - DUB: rim skank echoes (3/8 feedback echo). "pickup" raises the echo
// wet (you carry it), "drop" lowers it, FEED = the big SPROING with
// feedback momentarily 0.8. Stamp = deep boing glory.
// - DISCO: tap tiles 015 → pentatonic cue blips rising by index;
// "step echo" repeats the last tile a fifth down, softer. Stamp =
// filtered-disco string swell.
// - Stamps: each ✦ = press-THUNK + that world's fanfare; the third fires
// stamp:all → golden riser, then a permanent shaker/conga bonus stem in
// the BOOTH mix (audible once you leave / when a deck plays; survives
// "blow fuse"). Re-clicking a ✦ re-emits idempotently: silence.
// - bunny = shy squeak (throttled), airhorn = the emote horn.
import * as THREE from 'three'; import * as THREE from 'three';
import { bus } from '../core/events'; import { bus } from '../core/events';
@ -159,6 +184,39 @@ const deckPlaying: Record<'A' | 'B', boolean> = { A: false, B: false };
const rpm: Record<'A' | 'B', number> = { A: PLATTER.rpm33, B: PLATTER.rpm33 }; const rpm: Record<'A' | 'B', number> = { A: PLATTER.rpm33, B: PLATTER.rpm33 };
buildPanel(); buildPanel();
// DEMO MOCK — not for integration: automation/verification handle. Lets the
// demo be driven and *asserted* headlessly (duck gain, active pocket groove,
// true output RMS via an analyser tapped onto the master out).
{
let tap: AnalyserNode | null = null;
let tapBuf: Uint8Array<ArrayBuffer> | null = null;
const priv = audio as unknown as {
ctx?: AudioContext; outGain?: GainNode; boothGain?: GainNode;
worlds?: { active: string | null }; groove?: { getBonusStem(): boolean };
};
const ensureTap = () => {
if (tap || !priv.ctx || !priv.outGain) return;
tap = priv.ctx.createAnalyser();
tap.fftSize = 2048;
tapBuf = new Uint8Array(tap.fftSize);
priv.outGain.connect(tap);
};
(window as unknown as { DEMO: unknown }).DEMO = {
audio, bus,
boothDuck: () => priv.boothGain?.gain.value ?? -1,
worldActive: () => priv.worlds?.active ?? null,
bonusStem: () => priv.groove?.getBonusStem() ?? false,
outRms: () => {
ensureTap();
if (!tap || !tapBuf) return -1;
tap.getByteTimeDomainData(tapBuf);
let sum = 0;
for (let i = 0; i < tapBuf.length; i++) { const d = (tapBuf[i] - 128) / 128; sum += d * d; }
return Math.sqrt(sum / tapBuf.length);
},
};
}
// ── loop (a demo may own a rAF loop; see CONTRACTS §5) ────────────────────── // ── loop (a demo may own a rAF loop; see CONTRACTS §5) ──────────────────────
let last = performance.now(); let last = performance.now();
function frame(now: number) { function frame(now: number) {
@ -219,6 +277,7 @@ function buildPanel() {
.tc-panel button.on { background:#2a5; color:#031; border-color:#3c7; } .tc-panel button.on { background:#2a5; color:#031; border-color:#3c7; }
.tc-grid { display:grid; grid-template-columns:1fr 1fr; gap:4px; } .tc-grid { display:grid; grid-template-columns:1fr 1fr; gap:4px; }
.tc-grid.three { grid-template-columns:1fr 1fr 1fr; } .tc-grid.three { grid-template-columns:1fr 1fr 1fr; }
.tc-grid.four { grid-template-columns:1fr 1fr 1fr 1fr; }
.tc-val { flex:0 0 30px; text-align:right; color:#8ab; } .tc-val { flex:0 0 30px; text-align:right; color:#8ab; }
`; `;
document.head.appendChild(style); document.head.appendChild(style);
@ -343,6 +402,85 @@ function buildPanel() {
const wsfx = grid('three'); const wsfx = grid('three');
(['crimp', 'wireOn', 'wireOff', 'screwClunk', 'tiltCreak', 'weightDetent', 'bubbleLock', 'skate'] as const) (['crimp', 'wireOn', 'wireOff', 'screwClunk', 'tiltCreak', 'weightDetent', 'bubbleLock', 'skate'] as const)
.forEach((n) => btn(n, () => audio.playSfx(n), wsfx)); .forEach((n) => btn(n, () => audio.playSfx(n), wsfx));
// SIDE B — CRATE WORLDS: fires the exact bus events Lane M's portals/quests
// emit in-game (world:enter/exit, fader:move acid_N, machine:interact
// acid_lock/dub_*/disco_*, stamp:got, stamp:all, quest:reset) so this
// exercises the real AudioEngine duck/handback + groove paths.
h('SIDE B — crate worlds');
type PocketW = 'acid' | 'dub' | 'disco';
let inWorld: PocketW | null = null;
const worldBtns = {} as Record<PocketW, HTMLButtonElement>;
const setWorldBtn = (w: PocketW, on: boolean) => {
worldBtns[w].textContent = on ? `⏏ exit ${w}` : `${w}`;
worldBtns[w].classList.toggle('on', on);
};
const wGrid = grid('three');
(['acid', 'dub', 'disco'] as PocketW[]).forEach((w) => {
worldBtns[w] = btn(`${w}`, () => {
if (inWorld === w) {
inWorld = null;
bus.emit('world:exit', { world: w });
setWorldBtn(w, false);
} else {
if (inWorld) { bus.emit('world:exit', { world: inWorld }); setWorldBtn(inWorld, false); }
inWorld = w;
bus.emit('world:enter', { world: w });
setWorldBtn(w, true);
}
}, wGrid);
});
// ACID: three pedestal faders (per-band 303 cutoff) + lock pips
(['acid_0', 'acid_1', 'acid_2'] as const).forEach((id, i) =>
slider(id, 0, 1, 0.01, [0.12, 0.85, 0.18][i], (v) => bus.emit('fader:move', { faderId: id, value: v })));
const lockRow = grid('three');
[0, 1, 2].forEach((i) => btn(`lock ${i}`, (b) => {
b.classList.add('on');
bus.emit('machine:interact', { machineId: `acid_${i}`, action: 'acid_lock' });
}, lockRow));
// DUB: carry the wet + the big SPROING
const dubRow = grid('three');
const pickupBtn = btn('pickup', (b) => {
const on = b.classList.toggle('on');
b.textContent = on ? 'drop' : 'pickup';
bus.emit('machine:interact', { machineId: 'dub_charge', action: on ? 'dub_pickup' : 'dub_drop' });
}, dubRow);
btn('FEED ⚡', () => {
pickupBtn.classList.remove('on'); pickupBtn.textContent = 'pickup';
bus.emit('machine:interact', { machineId: 'dub_horn', action: 'dub_feed' });
}, dubRow);
btn('boing', () => bus.emit('machine:interact', { machineId: 'dub_spring', action: 'dub_feed' }), dubRow);
// DISCO: the 4x4 tile grid (cue blips by index) + the player's step echo
let lastCue = 0;
const tileGrid = grid('four');
for (let i = 0; i < 16; i++) {
btn(String(i), () => {
lastCue = i;
bus.emit('machine:interact', { machineId: `disco_tile_${i}`, action: 'disco_cue' });
}, tileGrid);
}
btn('step echo (5th down)', () =>
bus.emit('machine:interact', { machineId: `disco_tile_${lastCue}`, action: 'disco_step' }), grid());
// STAMPS: thunk+fanfare per world; third one triggers stamp:all (riser +
// permanent bonus percussion stem in the booth mix). Re-click = idempotent.
const stamped = new Set<PocketW>();
const stampRow = grid('three');
(['acid', 'dub', 'disco'] as PocketW[]).forEach((w) => btn(`${w}`, (b) => {
stamped.add(w);
b.classList.add('on');
bus.emit('stamp:got', { world: w, count: stamped.size, total: 3 });
if (stamped.size === 3) window.setTimeout(() => bus.emit('stamp:all', {}), 350);
}, stampRow));
// misc: bunny squeak, emote airhorn, the real reset ritual (not a reload)
const miscRow = grid('three');
btn('bunny', () => bus.emit('machine:interact', { machineId: 'bunny_0', action: 'bunny_flee' }), miscRow);
btn('airhorn', () => bus.emit('player:emote', { kind: 3, self: true, at: [224, 82, 96] }), miscRow);
btn('blow fuse', () => bus.emit('quest:reset', { by: 'DEMO' }), miscRow);
} }
function moveCamTo(x: number, z: number) { function moveCamTo(x: number, z: number) {

View File

@ -16,6 +16,13 @@
// slots / button recesses, the spinning-strobe red dot, the empty gold // slots / button recesses, the spinning-strobe red dot, the empty gold
// quest socket (red blink) on the patch bay, and the crate of blue/black // quest socket (red blink) on the patch bay, and the crate of blue/black
// records under the front-left hatch. // records under the front-left hatch.
// • SIDE B (Lane W): three sealed matte-black pocket slabs sit on the booth
// ceiling (ACID/DUB/DISCO buttons view them from outside — they must read
// as plain black boxes, zero glow). Interiors are verified by the POCKET
// validation lines (sealed skin, entry/exit, pedestals, bridge, tiles,
// portal rings) — walk them in the real game via Lane M's portals, or with
// the cine camera (window.TURNCRAFT_CINE) placed inside the bounds.
// "hide plywood shell" also reveals the crate portal rings + disc marquees.
import * as THREE from 'three'; import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
@ -25,6 +32,10 @@ import { WORLD_X, WORLD_Y, WORLD_Z, Y_PLINTH_TOP, Y_TABLETOP, LAYOUT, PLATTER }
import type { IVoxelWorld, Vec3 } from '../core/types'; import type { IVoxelWorld, Vec3 } from '../core/types';
import { buildBooth, SPAWN } from '../worldgen/buildBooth'; import { buildBooth, SPAWN } from '../worldgen/buildBooth';
import { QUEST_POS, QUEST_REACH_POINTS, hasAdjacentAir } from '../worldgen/questPositions'; import { QUEST_POS, QUEST_REACH_POINTS, hasAdjacentAir } from '../worldgen/questPositions';
import { WORLD_DEFS, WORLD_KEYS } from '../core/worlds';
import {
ACCENT_LED, ACID_PEDESTAL_X, ACID_PEDESTAL_Z, DUB_BRIDGE, DISCO_TILE_CX, DISCO_TILE_CZ,
} from '../worldgen/worlds';
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// DEMO MOCK — not for integration. A flat Uint8Array voxel store implementing // DEMO MOCK — not for integration. A flat Uint8Array voxel store implementing
@ -96,6 +107,106 @@ function validate(): { line: string; pass: boolean }[] {
} }
add(`record crate populated (${crateVinyl})`, crateVinyl > 500); add(`record crate populated (${crateVinyl})`, crateVinyl > 500);
// ── SIDE B (Lane W): pocket assertions ──────────────────────────────────
const BLACK = 5, GOLD = 29; // matte_black / rca_gold (registry-stable ids)
for (const k of WORLD_KEYS) {
const d = WORLD_DEFS[k];
const [x0, y0, z0] = d.pocketMin, [x1, y1, z1] = d.pocketMax;
// The 1-voxel skin is complete, all matte_black — which also means no
// emissive and no transparent block anywhere on it: zero light leaks.
let breach = 0;
for (let x = x0; x <= x1; x++) for (let y = y0; y <= y1; y++) for (let z = z0; z <= z1; z++) {
if (x !== x0 && x !== x1 && y !== y0 && y !== y1 && z !== z0 && z !== z1) continue;
if (world.getBlock(x, y, z) !== BLACK) breach++;
}
add(`pocket ${k}: skin sealed matte_black (${breach} breaches)`, breach === 0);
const [ex, ey, ez] = d.entry;
add(`pocket ${k}: entry solid floor + headroom`,
isSolidId(world.getBlock(ex, ey - 1, ez)) &&
world.getBlock(ex, ey, ez) === AIR && world.getBlock(ex, ey + 1, ez) === AIR);
const [px, py, pz] = d.exitPad;
let pad = true;
for (let dx = -1; dx <= 1; dx++) for (let dz = -1; dz <= 1; dz++) {
const want = dx === 0 && dz === 0 ? ACCENT_LED[k] : GOLD;
if (world.getBlock(px + dx, py, pz + dz) !== want) pad = false;
if (world.getBlock(px + dx, py + 1, pz + dz) !== AIR) pad = false;
}
add(`pocket ${k}: 3×3 gold exit pad + accent pip, clear above`, pad);
// portal dressing at the crate (W1): floor ring + disc marquee
const [sx, sy, sz] = d.portalStand;
let ring = 0;
for (let dx = -3; dx <= 3; dx++) for (let dz = -3; dz <= 3; dz++) {
const d2 = dx * dx + dz * dz;
if (d2 >= 7 && d2 <= 11 && world.getBlock(sx + dx, sy - 1, sz + dz) === ACCENT_LED[k]) ring++;
}
const [qx, qy, qz] = d.portalDisc;
const marq = [world.getBlock(qx, qy + 13, qz), world.getBlock(qx, qy + 12, qz - 4), world.getBlock(qx, qy + 12, qz + 4)]
.filter((id) => id === ACCENT_LED[k]).length;
add(`pocket ${k}: portal ring ${ring}/16 + marquee ${marq}/3`, ring === 16 && marq === 3);
}
// above the booth ceiling, ONLY the three pockets exist (no stray writes)
{
let stray = 0;
for (let y = 142; y < WORLD_Y; y++) for (let z = 0; z < WORLD_Z; z++) for (let x = 0; x < WORLD_X; x++) {
if (world.getBlock(x, y, z) === AIR) continue;
const inside = WORLD_KEYS.some((k) => {
const d = WORLD_DEFS[k];
return x >= d.pocketMin[0] && x <= d.pocketMax[0] && y >= d.pocketMin[1] &&
y <= d.pocketMax[1] && z >= d.pocketMin[2] && z <= d.pocketMax[2];
});
if (!inside) stray++;
}
add(`nothing above y141 outside the pockets (${stray})`, stray === 0);
}
// acid pedestals (Lane M mounts the 303 knobs on these)
{
let ok = true;
for (const pz of ACID_PEDESTAL_Z) {
for (let dx = -1; dx <= 1; dx++) for (let dy = 0; dy <= 2; dy++) for (let dz = -1; dz <= 1; dz++)
if (world.getBlock(ACID_PEDESTAL_X + dx, 144 + dy, pz + dz) !== BLACK) ok = false;
if (blockDef(world.getBlock(ACID_PEDESTAL_X, 147, pz)).name !== 'led_green') ok = false;
}
add('pocket acid: 3 knob pedestals + green pips', ok);
}
// dub bridge: 3×24 chrome deck with the gold tank-mouth inlay, walkable
{
let ok = true;
for (let x = DUB_BRIDGE.x0; x <= DUB_BRIDGE.x1; x++) for (let z = DUB_BRIDGE.z0; z <= DUB_BRIDGE.z1; z++) {
const want = x === DUB_BRIDGE.mouth[0] && z === DUB_BRIDGE.mouth[2] ? GOLD : 11 /* chrome */;
if (world.getBlock(x, DUB_BRIDGE.deckY, z) !== want) ok = false;
if (world.getBlock(x, DUB_BRIDGE.deckY + 1, z) !== AIR) ok = false;
}
add('pocket dub: 3×24 chrome bridge deck + gold mouth', ok);
}
// disco: 16 flush 5×5 tiles with led centres
{
let ok = true, leds = 0;
for (const cx of DISCO_TILE_CX) for (const cz of DISCO_TILE_CZ) {
const nm = blockDef(world.getBlock(cx, 143, cz)).name;
if (nm === 'led_red' || nm === 'led_blue') leds++;
for (let dx = -2; dx <= 2; dx++) for (let dz = -2; dz <= 2; dz++)
if (world.getBlock(cx + dx, 143, cz + dz) === AIR) ok = false;
}
add(`pocket disco: dance-tile grid (${leds}/16 led centres)`, ok && leds === 16);
}
// determinism: a second full build must be byte-identical
{
const w2 = new MockWorld();
buildBooth(w2);
let same = true;
for (let i = 0; i < world.data.length; i++) if (world.data[i] !== w2.data[i]) { same = false; break; }
add('deterministic (two builds byte-identical)', same);
}
return out; return out;
} }
const validation = validate(); const validation = validate();
@ -199,6 +310,16 @@ const zones: { name: string; target: Vec3; from: Vec3 }[] = [
// aims at the crate's actual centre (under-ledge, in front of Deck A); toggle "hide shell" to see in // aims at the crate's actual centre (under-ledge, in front of Deck A); toggle "hide shell" to see in
{ name: 'Record Crate', target: [LAYOUT.rimThickness + 30, 15, LAYOUT.rimThickness + 14], from: [LAYOUT.rimThickness + 30, 58, LAYOUT.frontLipZ - 50] }, { name: 'Record Crate', target: [LAYOUT.rimThickness + 30, 15, LAYOUT.rimThickness + 14], from: [LAYOUT.rimThickness + 30, 58, LAYOUT.frontLipZ - 50] },
{ name: 'Whole booth', target: [WORLD_X / 2, 40, WORLD_Z / 2], from: [WORLD_X / 2 - 40, 260, -140] }, { name: 'Whole booth', target: [WORLD_X / 2, 40, WORLD_Z / 2], from: [WORLD_X / 2 - 40, 260, -140] },
// SIDE B pockets, viewed from OUTSIDE: sealed matte-black slabs, zero glow.
...WORLD_KEYS.map((k) => {
const d = WORLD_DEFS[k];
const cx = (d.pocketMin[0] + d.pocketMax[0]) / 2;
return {
name: `Pocket: ${d.name}`,
target: [cx, 150, 52] as Vec3,
from: [cx, 195, -50] as Vec3,
};
}),
]; ];
function goto(z: { target: Vec3; from: Vec3 }) { function goto(z: { target: Vec3; from: Vec3 }) {
camera.position.set(z.from[0], z.from[1], z.from[2]); camera.position.set(z.from[0], z.from[1], z.from[2]);

View File

@ -9,10 +9,22 @@ import * as THREE from 'three';
import { bus } from '../core/events'; import { bus } from '../core/events';
import { PLATTER } from '../core/constants'; import { PLATTER } from '../core/constants';
import { blockDef } from '../core/blocks'; import { blockDef } from '../core/blocks';
import { WORLD_DEFS, type WorldDef } from '../core/worlds';
import type { IVoxelWorld, IPlayerView } from '../core/types';
import { makeGlowTexture, Burst, DustField } from './particles'; import { makeGlowTexture, Burst, DustField } from './particles';
import { VuBank, SignalTrace, PatchBlink } from './overlays'; import { VuBank, SignalTrace, PatchBlink } from './overlays';
import { DustBunnies } from './bunnies';
import { PortalIris } from './portalIris';
import { DUST_BOX, DECK_RIM, PATCH_BAY_POS, SIGNAL_PATH } from './layout'; import { DUST_BOX, DECK_RIM, PATCH_BAY_POS, SIGNAL_PATH } from './layout';
/** SIDE B: accent lookup for a bus `world` string (warm white if unknown). */
function worldDef(key: string): WorldDef | undefined {
return (WORLD_DEFS as Record<string, WorldDef>)[key];
}
const DEFAULT_ACCENT: [number, number, number] = [255, 214, 120];
const GOLD = { r: 1.0, g: 0.84, b: 0.32 };
const SHIMMER_S = 2.6; // stamp:all golden shimmer length
export interface FxOpts { export interface FxOpts {
scene: THREE.Object3D; scene: THREE.Object3D;
setEmissiveBoost: (v: number) => void; setEmissiveBoost: (v: number) => void;
@ -65,12 +77,23 @@ export class FxSystem {
private beaconRepaired: (node: string) => boolean = () => true; private beaconRepaired: (node: string) => boolean = () => true;
private beaconClock = 0; private beaconClock = 0;
// SIDE B (Lane S): dust bunnies under the table (integrator wires via
// attachAmbience(); silent until then), the vinyl-warp portal iris (screen
// overlay, self-contained), and the stamp:all golden shimmer timeline.
private bunnies: DustBunnies | null = null;
private playerView: IPlayerView | null = null;
private iris = new PortalIris();
private tex: THREE.Texture;
private shimmer = 0; // seconds left of the golden shimmer
private bornAt = performance.now();
constructor(opts: FxOpts) { constructor(opts: FxOpts) {
this.scene = opts.scene; this.scene = opts.scene;
this.setBoost = opts.setEmissiveBoost; this.setBoost = opts.setEmissiveBoost;
this.getLevels = opts.getLevels ?? (() => ({ low: 0, mid: 0, high: 0 })); this.getLevels = opts.getLevels ?? (() => ({ low: 0, mid: 0, high: 0 }));
const tex = makeGlowTexture(); const tex = makeGlowTexture();
this.tex = tex;
this.dust = new DustField(280, tex, DUST_BOX.min, DUST_BOX.max); this.dust = new DustField(280, tex, DUST_BOX.min, DUST_BOX.max);
this.puff = new Burst(160, 1.4, tex, 6, 3.5); this.puff = new Burst(160, 1.4, tex, 6, 3.5);
this.confetti = new Burst(220, 2.2, tex, 9, 0.6); this.confetti = new Burst(220, 2.2, tex, 9, 0.6);
@ -86,6 +109,17 @@ export class FxSystem {
this.wireBus(); this.wireBus();
} }
/** SIDE B: wire the under-table dust bunnies (they need world.isSolid for
* wall lookahead and the player position for the flee trigger) and give the
* stamp confetti a player anchor. Integrator calls this once after the
* player exists like setBeacons, everything stays silent until wired. */
attachAmbience(world: IVoxelWorld, player: IPlayerView): void {
this.playerView = player;
if (this.bunnies) return; // idempotent
this.bunnies = new DustBunnies(world, player, this.tex);
this.scene.add(this.bunnies.group);
}
/** Anchor the broken-node spark beacons (integrator supplies positions + state). */ /** Anchor the broken-node spark beacons (integrator supplies positions + state). */
setBeacons( setBeacons(
anchors: { node: string; pos: [number, number, number] }[], anchors: { node: string; pos: [number, number, number] }[],
@ -118,6 +152,8 @@ export class FxSystem {
this.vu.update(this.getLevels()); this.vu.update(this.getLevels());
this.updateRimSparkle(dt); this.updateRimSparkle(dt);
this.updateBeacons(dt); this.updateBeacons(dt);
this.bunnies?.update(dt);
this.iris.update();
// LED pulse decay // LED pulse decay
this.pulse *= Math.exp(-dt / this.pulseTau); this.pulse *= Math.exp(-dt / this.pulseTau);
@ -139,6 +175,14 @@ export class FxSystem {
if (this.won) this.winClock += dt; if (this.won) this.winClock += dt;
boost = this.boostBase + this.pulse; boost = this.boostBase + this.pulse;
} }
// stamp:all golden shimmer: a glittering wave over the whole booth that
// eases out (~2.6 s). Additive on top of whatever the booth is doing.
if (this.shimmer > 0) {
this.shimmer = Math.max(0, this.shimmer - dt);
const t = SHIMMER_S - this.shimmer;
const env = Math.min(1, t * 6) * (this.shimmer / SHIMMER_S);
boost += env * (0.45 + 0.2 * Math.sin(t * 12));
}
this.setBoost(Math.max(0, Math.min(2, boost))); this.setBoost(Math.max(0, Math.min(2, boost)));
this.lastBoost = boost; this.lastBoost = boost;
} }
@ -272,5 +316,56 @@ export class FxSystem {
r: tint[0] / 255, g: tint[1] / 255, b: tint[2] / 255, r: tint[0] / 255, g: tint[1] / 255, b: tint[2] / 255,
}); });
}); });
// ── SIDE B (Lane S): portal iris + stamp celebrations ──────────────────
// Screen-overlay iris only — the camera (incl. the flythrough's cine
// override) is never touched.
bus.on('world:enter', (p) => {
this.iris.trigger(worldDef(p.world)?.accent ?? DEFAULT_ACCENT, 'enter');
});
bus.on('world:exit', (p) => {
this.iris.trigger(worldDef(p.world)?.accent ?? DEFAULT_ACCENT, 'exit');
});
// stamp:got — celebration scaled by origin (carried on the event, so no
// wall-clock guessing): 'local' = full accent confetti at the winner (they
// are standing in the pocket), 'remote' = a modest burst at that record's
// crate disc (the win happened across the booth — never at the local
// player's body), 'replay' = join-snapshot state, no celebration.
bus.on('stamp:got', (p) => {
if (p.origin === 'replay') return;
const def = worldDef(p.world);
const a = def?.accent ?? DEFAULT_ACCENT;
if (p.origin === 'remote') {
if (!def) return;
const d = def.portalDisc;
this.confetti.spawn(28, d[0], d[1] + 4, d[2], {
spread: 7, speed: 6, up: 5, life: 2.0, jitter: 4,
r: a[0] / 255, g: a[1] / 255, b: a[2] / 255,
});
return;
}
const at = this.playerView?.position ?? def?.entry;
if (!at) return;
this.confetti.spawn(70, at[0], at[1] + 2.2, at[2], {
spread: 10, speed: 9, up: 6, life: 2.4, jitter: 2.5,
r: a[0] / 255, g: a[1] / 255, b: a[2] / 255,
});
});
// stamp:all — the golden finish: booth-wide emissive shimmer (via the same
// injected setEmissiveBoost path, see update()) + gold confetti at the
// crate's three portal discs. Replayed state seeds silently.
bus.on('stamp:all', (p) => {
if (p.origin === 'replay') return;
this.shimmer = SHIMMER_S;
for (const k of Object.keys(WORLD_DEFS) as (keyof typeof WORLD_DEFS)[]) {
const d = WORLD_DEFS[k].portalDisc;
this.confetti.spawn(50, d[0], d[1] + 4, d[2], {
spread: 9, speed: 8, up: 6, life: 2.8, jitter: 6,
r: GOLD.r, g: GOLD.g, b: GOLD.b,
});
}
});
} }
} }

View File

@ -102,3 +102,21 @@ sideMat.needsUpdate = true;
It's a standalone generator (no side effects); safe to call once and share the It's a standalone generator (no side effects); safe to call once and share the
texture across both platters. Only the platter wiring is left — everything else texture across both platters. Only the platter wiring is left — everything else
here is live and browser-verified. here is live and browser-verified.
## SIDE B — Lane S (see docs/SIDEB_S_handoff.md for full detail)
- `bunnies.ts``DustBunnies`: 4 under-table fluff critters (wander / flee 8
voxels with ease-out / settle; `world.isSolid` lookahead; portal + quest-spot
keep-outs; zero steady-state allocation). Emits `machine:interact
{machineId:'bunny', action:'bunny_flee'}` ≤ 1/s.
- `portalIris.ts``PortalIris`: the vinyl-warp screen iris on
`world:enter`/`world:exit`, accent-tinted from `WORLD_DEFS`. Fixed-position
canvas at z-index 9 (under the HUD), never touches the camera; driven from
`fx.update()`, `display:none` when idle.
- `FxSystem` additions: **`fx.attachAmbience(world, player)` — the one line
the integrator wires in main.ts** (after the player exists; setBeacons
pattern — silent until called). Also: `stamp:got` accent confetti at the
player, `stamp:all` gold confetti at the crate discs + a 2.6 s golden
emissive shimmer through the existing `setEmissiveBoost` path. Stamp events
arriving < 4 s after construction are treated as Lane M's join replay:
state only, no celebration.

323
src/fx/bunnies.ts Normal file
View File

@ -0,0 +1,323 @@
// TURNCRAFT — Lane S (SIDE B). Dust bunnies: 4 fist-sized fluff-balls that
// drift around the under-table PCB floor (y≈3), flee when the player gets
// close, and settle again. Purely cosmetic — no colliders, no game effect.
//
// Consumes only core contracts (IVoxelWorld / IPlayerView / WORLD_DEFS) — the
// world + player views are injected by the integrator via
// `FxSystem.attachAmbience`. All per-tick math is scalar on preallocated
// per-bunny records: zero steady-state allocations. The only allocation a
// bunny ever causes after construction is the `machine:interact
// {action:'bunny_flee'}` payload, rate-limited to at most 1/s globally
// (Lane E2 maps it to a squeak).
import * as THREE from 'three';
import { bus } from '../core/events';
import { LAYOUT } from '../core/constants';
import { WORLD_DEFS, WORLD_KEYS } from '../core/worlds';
import type { IVoxelWorld, IPlayerView } from '../core/types';
// ── keep-out zones (bunnies never wander in; fleeing steers around) ─────────
// The three portal rings (WORLD_DEFS[k].portalStand ± 6) and the two quest
// spots in the fuse room. The parts-bin / fuse-box centres mirror the
// arithmetic in worldgen/anchors.ts (UNDER.partsBin / UNDER.fuseBox) the same
// way fx/layout.ts mirrors the VU towers — derived from LAYOUT, never imported
// (worldgen is another lane's directory).
interface Zone { x: number; z: number; r2: number; }
const M = LAYOUT.mixer;
const KEEP_OUT: Zone[] = [
...WORLD_KEYS.map((k) => {
const s = WORLD_DEFS[k].portalStand;
return { x: s[0] + 0.5, z: s[2] + 0.5, r2: 6 * 6 };
}),
{ x: M.minX + 15.5, z: M.minZ + 10.5, r2: 6 * 6 }, // parts bin tray (stylus + fuse pickups)
{ x: M.minX + 24.5, z: M.maxZ - 7.5, r2: 6 * 6 }, // fuse box wall spot (reset ritual ground)
];
// Home points on the under-table floor: two in the record-crate area (where
// SIDE B pulls players), one in the mixer PCB room, one under deck A. Each
// bunny is leashed to its home so it stays findable.
const RIM = LAYOUT.rimThickness;
const HOMES: [number, number][] = [
[RIM + 18, RIM + 9], // crate floor, in front of the discs
[WORLD_DEFS.disco.portalStand[0] + 8, RIM + 8], // crate floor, gap past the last discs
[(M.minX + M.maxX) / 2 + 6, (M.minZ + M.maxZ) / 2 + 8],// mixer PCB room
[LAYOUT.deckA.spindleX, LAYOUT.deckA.spindleZ], // deck A PCB room
];
const BUNNY_COUNT = 4;
const LEASH = 14; // max wander radius from home (voxels)
const SPOOK_DIST2 = 5 * 5; // player within 5 → flee
const FLEE_DIST = 8; // voxels of flight
const EMIT_COOLDOWN = 1.0; // machine:interact {bunny_flee} at most 1/s (global)
const enum Mode { Wander, Flee, Settle }
interface Bunny {
mesh: THREE.Mesh;
x: number; y: number; z: number;
ground: number; // y of the standing surface (top face) under the bunny
heading: number; // radians, horizontal
speed: number;
mode: Mode;
timer: number; // mode-specific countdown (wander re-aim / settle)
fleeLeft: number; // voxels of flight remaining
phase: number; // bob/breathe animation phase
spin: number; // idle tumble rate (rad/s)
turnSign: number; // alternate deflection direction on blocked moves
homeX: number; homeZ: number;
}
/** One fluff-ball: 812 soft grey quads jittered into a ball, one draw call. */
function makeFluffGeometry(rng: () => number): THREE.BufferGeometry {
const quads = 8 + Math.floor(rng() * 5); // 8..12
const pos = new Float32Array(quads * 4 * 3);
const col = new Float32Array(quads * 4 * 3);
const uv = new Float32Array(quads * 4 * 2);
const idx = new Uint16Array(quads * 6);
const a = new THREE.Vector3(), b = new THREE.Vector3(), n = new THREE.Vector3();
for (let q = 0; q < quads; q++) {
// random centre inside the ball + random plane orientation
const cx = (rng() * 2 - 1) * 0.34;
const cy = (rng() * 2 - 1) * 0.26;
const cz = (rng() * 2 - 1) * 0.34;
n.set(rng() * 2 - 1, rng() * 2 - 1, rng() * 2 - 1).normalize();
a.set(rng() * 2 - 1, rng() * 2 - 1, rng() * 2 - 1).cross(n).normalize();
b.crossVectors(n, a);
const s = 0.26 + rng() * 0.16; // quad half-size → puffs 0.5..0.85 across
// dust tone (matches the `dust` block tint 120,116,110) with per-quad jitter
const shade = 0.30 + rng() * 0.17;
const r = shade * 1.02, g = shade * 0.99, bcol = shade * 0.94;
for (let corner = 0; corner < 4; corner++) {
const su = corner === 1 || corner === 2 ? s : -s;
const sv = corner >= 2 ? s : -s;
const i3 = (q * 4 + corner) * 3;
pos[i3] = cx + a.x * su + b.x * sv;
pos[i3 + 1] = cy + a.y * su + b.y * sv;
pos[i3 + 2] = cz + a.z * su + b.z * sv;
col[i3] = r; col[i3 + 1] = g; col[i3 + 2] = bcol;
const i2 = (q * 4 + corner) * 2;
uv[i2] = corner === 1 || corner === 2 ? 1 : 0;
uv[i2 + 1] = corner >= 2 ? 1 : 0;
}
const v0 = q * 4, ii = q * 6;
idx[ii] = v0; idx[ii + 1] = v0 + 1; idx[ii + 2] = v0 + 2;
idx[ii + 3] = v0; idx[ii + 4] = v0 + 2; idx[ii + 5] = v0 + 3;
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
geo.setAttribute('color', new THREE.BufferAttribute(col, 3));
geo.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
geo.setIndex(new THREE.BufferAttribute(idx, 1));
return geo;
}
export class DustBunnies {
readonly group: THREE.Group;
private world: IVoxelWorld;
private player: IPlayerView;
private bunnies: Bunny[] = [];
private material: THREE.MeshBasicMaterial;
private emitCooldown = 0;
constructor(world: IVoxelWorld, player: IPlayerView, tex: THREE.Texture) {
this.world = world;
this.player = player;
this.group = new THREE.Group();
// soft round alpha from the shared glow canvas, normal blending so the
// fluff reads as matte dust, not light
this.material = new THREE.MeshBasicMaterial({
map: tex, transparent: true, depthWrite: false,
vertexColors: true, side: THREE.DoubleSide,
});
// deterministic-ish per-session fluff (visual only; Math.random is fine
// for FX per CONTRACTS — worldgen determinism doesn't apply here)
for (let i = 0; i < BUNNY_COUNT; i++) {
const [hx, hz] = HOMES[i % HOMES.length];
const mesh = new THREE.Mesh(makeFluffGeometry(Math.random), this.material);
mesh.frustumCulled = true; // tiny sphere, fine to cull
this.group.add(mesh);
const bunny: Bunny = {
mesh,
x: hx + 0.5, y: 0, z: hz + 0.5,
ground: 3,
heading: Math.random() * Math.PI * 2,
speed: 0.5,
mode: Mode.Wander,
timer: 1 + Math.random() * 3,
fleeLeft: 0,
phase: Math.random() * Math.PI * 2,
spin: 0.5 + Math.random() * 0.7,
turnSign: i & 1 ? 1 : -1,
homeX: hx + 0.5, homeZ: hz + 0.5,
};
bunny.ground = this.groundTopAt(bunny.x, bunny.z);
bunny.y = bunny.ground + 0.42;
this.bunnies.push(bunny);
}
}
/** Spawn-only: standing surface at a known-flat home point (top face of the
* highest floor block in the under-table band). */
private groundTopAt(x: number, z: number): number {
const xi = Math.floor(x), zi = Math.floor(z);
for (let y = 5; y >= 0; y--) {
if (this.world.isSolid(xi, y, zi)) return y + 1;
}
return 2; // world floor slab top (should not happen inside the booth)
}
/** Standing surface at (x,z) reachable from ground level `from` with at most
* a 1-voxel step: a valid surface g has solid under it and air at body
* level. Overhangs (e.g. the crate's edge-standing records) are NEVER
* ground that's what keeps bunnies from climbing into geometry. Returns
* -1 when nothing within ±1 works (a wall or cliff). */
private reachableGround(x: number, z: number, from: number): number {
const xi = Math.floor(x), zi = Math.floor(z);
// same level first, then step down, then step up
if (this.world.isSolid(xi, from - 1, zi) && !this.world.isSolid(xi, from, zi)) return from;
if (this.world.isSolid(xi, from - 2, zi) && !this.world.isSolid(xi, from - 1, zi)) return from - 1;
if (this.world.isSolid(xi, from, zi) && !this.world.isSolid(xi, from + 1, zi)) return from + 1;
return -1;
}
/** Can a bunny occupy (x,z) coming from ground level `from`? Checks walls
* (isSolid lookahead via reachableGround), keep-out zones and the
* under-table bounds. Pure scalar math, no allocation. */
private passable(x: number, z: number, from: number): boolean {
// hard bounds: stay inside the rim, under the table
if (x < RIM + 2 || x > this.world.sizeX - RIM - 2) return false;
if (z < RIM + 2 || z > LAYOUT.backWallZ - 2) return false;
if (this.reachableGround(x, z, from) < 0) return false;
for (let i = 0; i < KEEP_OUT.length; i++) {
const zo = KEEP_OUT[i];
const dx = x - zo.x, dz = z - zo.z;
if (dx * dx + dz * dz < zo.r2) return false;
}
return true;
}
update(dt: number): void {
if (this.emitCooldown > 0) this.emitCooldown -= dt;
const pp = this.player.position;
for (let i = 0; i < this.bunnies.length; i++) {
this.step(this.bunnies[i], pp[0], pp[1], pp[2], dt);
}
}
private step(b: Bunny, px: number, py: number, pz: number, dt: number): void {
// escape hatch: if someone builds a block into a bunny, pop it on top
const bxi = Math.floor(b.x), bzi = Math.floor(b.z);
if (this.world.isSolid(bxi, b.ground, bzi)) {
for (let g = b.ground + 1; g <= b.ground + 3; g++) {
if (!this.world.isSolid(bxi, g, bzi)) { b.ground = g; break; }
}
}
const dx = b.x - px, dz = b.z - pz;
const d2 = dx * dx + dz * dz;
const nearY = Math.abs(py - b.y) < 4; // only spooked by a player down here
// ── spook / re-spook ──
if (nearY && d2 < SPOOK_DIST2) {
if (b.mode !== Mode.Flee) {
b.mode = Mode.Flee;
b.fleeLeft = FLEE_DIST;
b.heading = Math.atan2(dz, dx); // straight away from the player
if (this.emitCooldown <= 0) {
this.emitCooldown = EMIT_COOLDOWN;
bus.emit('machine:interact', { machineId: 'bunny', action: 'bunny_flee' });
}
} else if (b.fleeLeft < FLEE_DIST * 0.5) {
b.fleeLeft = FLEE_DIST; // still being chased — keep running
}
}
// ── move ──
if (b.mode === Mode.Flee) {
// ease out: fast off the mark, slowing as the 8 voxels run out
const k = b.fleeLeft / FLEE_DIST;
b.speed = 0.6 + 6.4 * Math.pow(k, 0.7);
const stepLen = b.speed * dt;
if (!this.tryMove(b, stepLen, true)) {
// cornered — give up early and settle
b.fleeLeft = 0;
}
b.fleeLeft -= stepLen;
if (b.fleeLeft <= 0) {
b.mode = Mode.Settle;
b.timer = 0.8 + Math.random() * 0.8;
}
} else if (b.mode === Mode.Settle) {
b.timer -= dt;
b.speed = 0;
if (b.timer <= 0) {
b.mode = Mode.Wander;
b.timer = 1 + Math.random() * 3;
}
} else {
// wander: slow drift, heading random-walks; re-aim on a timer
b.timer -= dt;
if (b.timer <= 0) {
b.timer = 2 + Math.random() * 4;
b.heading = Math.random() * Math.PI * 2;
b.speed = Math.random() < 0.25 ? 0 : 0.3 + Math.random() * 0.5; // sometimes just sit
}
b.heading += (Math.random() - 0.5) * 1.4 * dt;
// leash: beyond it, ease the heading back toward home
const hx = b.homeX - b.x, hz = b.homeZ - b.z;
if (hx * hx + hz * hz > LEASH * LEASH) {
const want = Math.atan2(hz, hx);
let delta = want - b.heading;
while (delta > Math.PI) delta -= Math.PI * 2;
while (delta < -Math.PI) delta += Math.PI * 2;
b.heading += delta * Math.min(1, 2.5 * dt);
}
if (b.speed > 0) this.tryMove(b, b.speed * dt, false);
}
// ── settle onto the floor + dress the mesh (no allocation) ──
b.phase += dt * (b.mode === Mode.Flee ? 9 : 2.2);
const bob = b.mode === Mode.Settle
? Math.sin(b.phase * 6) * 0.015 // trembling
: Math.sin(b.phase) * (b.mode === Mode.Flee ? 0.14 : 0.06);
b.y = b.ground + 0.42 + Math.abs(bob);
const m = b.mesh;
m.position.set(b.x, b.y, b.z);
m.rotation.y += dt * (b.mode === Mode.Flee ? 7 : b.spin);
m.rotation.z = Math.sin(b.phase * 0.7) * 0.12;
const breathe = 1 + Math.sin(b.phase * 1.3) * 0.05;
m.scale.setScalar(breathe);
}
/** Advance along the heading with an isSolid lookahead; on a blocked cell,
* deflect (flee tries harder before giving up). Returns false if stuck. */
private tryMove(b: Bunny, stepLen: number, fleeing: boolean): boolean {
const look = Math.max(stepLen, 0.35); // look at least a third of a voxel ahead
const tries = fleeing ? 5 : 2;
for (let t = 0; t < tries; t++) {
// deflections: 0, ±45°, ±90° (sign alternates per bunny for variety)
const deflect = t === 0 ? 0 : b.turnSign * Math.ceil(t / 2) * (Math.PI / 4) * (t & 1 ? 1 : -1);
const h = b.heading + deflect;
const cx = Math.cos(h), cz = Math.sin(h);
if (this.passable(b.x + cx * look, b.z + cz * look, b.ground)) {
if (deflect !== 0) b.heading = h;
b.x += cx * stepLen;
b.z += cz * stepLen;
// ride 1-voxel bumps (copper traces); keep last ground under overhangs
const g = this.reachableGround(b.x, b.z, b.ground);
if (g >= 0) b.ground = g;
return true;
}
}
// blocked: turn away for next tick
b.turnSign = -b.turnSign;
b.heading += b.turnSign * (Math.PI / 2 + Math.random() * (Math.PI / 2));
return false;
}
dispose(): void {
for (const b of this.bunnies) b.mesh.geometry.dispose();
this.material.dispose();
this.group.removeFromParent();
}
}

160
src/fx/portalIris.ts Normal file
View File

@ -0,0 +1,160 @@
// TURNCRAFT — Lane S (SIDE B). The vinyl-warp portal iris: a full-screen 2D
// canvas overlay of concentric groove rings collapsing to black on
// `world:enter` (released on arrival) and the reverse drift on `world:exit`,
// accent-tinted per world (WORLD_DEFS accent).
//
// SCREEN OVERLAY ONLY — it never touches the camera, so it coexists with the
// first-visit flythrough's TURNCRAFT_CINE override and any future cine work.
// The canvas sits at z-index 9, UNDER the HUD (z 10), so the start/pause
// overlays and subtitles are never covered or blocked (pointer-events: none).
//
// Timeline runs on performance.now(), advanced from FxSystem.update() — no
// requestAnimationFrame loop of its own (CONTRACTS §5). While idle the canvas
// is display:none and update() is a single boolean check: zero steady-state
// work or allocation.
const CLOSE_S = 0.4; // brief: 400 ms collapse to black
const HOLD_S = 0.09; // beat of full black — the warp moment
const OPEN_S = 0.4; // release on arrival
const RINGS = 15;
export type IrisDir = 'enter' | 'exit';
export class PortalIris {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
private active = false;
private t0 = 0; // performance.now() at trigger
private accent: [number, number, number] = [255, 214, 120];
private drift = 1; // grooves flow inward (enter) / outward (exit)
private lastDraw = 0;
private onResize = () => this.resize();
constructor() {
this.canvas = document.createElement('canvas');
this.canvas.style.cssText =
'position:fixed;inset:0;width:100%;height:100%;pointer-events:none;' +
'z-index:9;display:none';
document.body.appendChild(this.canvas);
this.ctx = this.canvas.getContext('2d')!;
this.resize();
window.addEventListener('resize', this.onResize);
}
private resize(): void {
const dpr = Math.min(window.devicePixelRatio || 1, 1.25); // fill-rate cap
// viewport size with fallbacks: some embedded/headless panes report 0 for
// innerWidth — fall back to the game canvas, then a sane default
const game = document.querySelector('#app canvas') as HTMLCanvasElement | null;
const vw = window.innerWidth || document.documentElement.clientWidth ||
game?.clientWidth || 800;
const vh = window.innerHeight || document.documentElement.clientHeight ||
game?.clientHeight || 450;
const w = Math.max(2, Math.round(vw * dpr));
const h = Math.max(2, Math.round(vh * dpr));
// setting .width/.height clears the canvas — only touch on real change
if (this.canvas.width !== w) this.canvas.width = w;
if (this.canvas.height !== h) this.canvas.height = h;
}
/** Start (or restart) the iris. `accent` is the world's 0-255 RGB. */
trigger(accent: [number, number, number], dir: IrisDir): void {
this.resize(); // re-sync (a page can boot with a 0-sized window)
this.accent = accent;
this.drift = dir === 'enter' ? 1 : -1;
const now = performance.now();
if (this.active) {
// retrigger mid-animation: restart the close phase from the current
// coverage so the black level never pops
const c = this.coverAt(now);
this.t0 = now - Math.cbrt(c) * CLOSE_S * 1000; // inverse of easeInCubic
} else {
this.t0 = now;
}
this.active = true;
this.canvas.style.display = 'block';
this.lastDraw = 0;
}
/** 0 = clear screen, 1 = full black, over close → hold → open. */
private coverAt(now: number): number {
const t = (now - this.t0) / 1000;
if (t < CLOSE_S) { const x = t / CLOSE_S; return x * x * x; } // ease-in
if (t < CLOSE_S + HOLD_S) return 1;
const x = (t - CLOSE_S - HOLD_S) / OPEN_S;
if (x >= 1) return 0;
const inv = 1 - x;
return inv * inv * inv; // ease-out
}
/** Advance + draw if active. Call every fixed tick; throttled internally so
* substep catch-up doesn't repaint the canvas more than once a frame. */
update(): void {
if (!this.active) return;
const now = performance.now();
if (now - this.lastDraw < 12) return;
this.lastDraw = now;
const t = (now - this.t0) / 1000;
if (t >= CLOSE_S + HOLD_S + OPEN_S) {
this.active = false;
this.canvas.style.display = 'none';
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
return;
}
this.draw(this.coverAt(now), now);
}
private draw(cover: number, now: number): void {
const g = this.ctx;
const w = this.canvas.width, h = this.canvas.height;
const cx = w / 2, cy = h / 2;
const rMax = Math.hypot(cx, cy); // reaches the corners
const aperture = rMax * (1 - cover); // visible hole radius
const [ar, ag, ab] = this.accent;
g.clearRect(0, 0, w, h);
if (cover <= 0) return;
// vinyl-black slab with the aperture punched out (even-odd fill)
g.beginPath();
g.rect(0, 0, w, h);
g.arc(cx, cy, Math.max(aperture, 0), 0, Math.PI * 2);
g.fillStyle = 'rgb(5,5,8)';
g.fill('evenodd');
// concentric groove rings riding the slab, drifting toward (enter) or away
// from (exit) the aperture — the record being wound in / spat back out
const gap = Math.max(10, rMax * 0.045);
const slide = ((now * 0.00035 * this.drift) % 1 + 1) % 1; // 0..1 groove flow
g.lineWidth = Math.max(1, rMax * 0.0035);
for (let i = 0; i < RINGS; i++) {
const r = aperture + (i + slide) * gap;
if (r > rMax + gap) break;
const fade = 1 - i / RINGS;
const glow = i === 0 ? 0.85 : 0.32 * fade * fade;
g.strokeStyle = `rgba(${ar},${ag},${ab},${(glow * cover).toFixed(3)})`;
g.beginPath();
g.arc(cx, cy, r, 0, Math.PI * 2);
g.stroke();
}
// bright accent lip right on the aperture edge (the stylus-light)
if (aperture > 1) {
g.lineWidth = Math.max(2, rMax * 0.006);
g.strokeStyle = `rgba(${ar},${ag},${ab},${(0.9 * cover).toFixed(3)})`;
g.beginPath();
g.arc(cx, cy, aperture, 0, Math.PI * 2);
g.stroke();
g.lineWidth = Math.max(4, rMax * 0.014);
g.strokeStyle = `rgba(${ar},${ag},${ab},${(0.22 * cover).toFixed(3)})`;
g.beginPath();
g.arc(cx, cy, aperture + rMax * 0.008, 0, Math.PI * 2);
g.stroke();
}
}
dispose(): void {
window.removeEventListener('resize', this.onResize);
this.canvas.remove();
}
}

View File

@ -11,10 +11,12 @@ import { PLAYER } from '../core/constants';
import { bus } from '../core/events'; import { bus } from '../core/events';
import type { IMachine, IPlayerView, IVoxelWorld, KinematicCollider, RayHit } from '../core/types'; import type { IMachine, IPlayerView, IVoxelWorld, KinematicCollider, RayHit } from '../core/types';
import { Fader, QUEST_ITEMS, type MachineSet, type Workshop } from '../machines'; import { Fader, QUEST_ITEMS, type MachineSet, type Workshop } from '../machines';
import type { IdInteractions } from '../machines/machine';
import type { Hotbar } from './hotbar'; import type { Hotbar } from './hotbar';
import { raycastColliders, raycastVoxel, type VoxelHit } from './raycast'; import { raycastColliders, raycastVoxel, type VoxelHit } from './raycast';
type WsTag = ReturnType<Workshop['tagFor']>; type WsTag = ReturnType<Workshop['tagFor']>;
type IdMachine = IMachine & IdInteractions;
// Seconds to mine a block, by material feel: soft stuff (dust, felt) digs // Seconds to mine a block, by material feel: soft stuff (dust, felt) digs
// fast — the crossfader jam is ~240 dust blocks, and 0.4 s each made that // fast — the crossfader jam is ~240 dust blocks, and 0.4 s each made that
@ -64,6 +66,9 @@ export class Interaction {
private eDown = false; private eDown = false;
shiftDown = false; shiftDown = false;
private hold: { tag: WsTag; colliderId: string } | null = null; private hold: { tag: WsTag; colliderId: string } | null = null;
// SIDE B world machines: same press/hold idea, routed by collider id via the
// structural IdInteractions surface (machines/machine.ts).
private idHold: { m: IdMachine; colliderId: string } | null = null;
private readonly selection: THREE.LineSegments; private readonly selection: THREE.LineSegments;
@ -144,6 +149,18 @@ export class Interaction {
} }
return; return;
} }
// SIDE B world machines: collider-id-aware press/hold (portals, acid
// pedestals, dub charge/mouth). Hold wins if the machine starts one.
const idm = m as IdMachine;
if (this.lastColliderId && (idm.pressId || idm.holdStartId)) {
const cid = this.lastColliderId;
if (idm.holdStartId && idm.holdTickId && idm.holdStartId(cid, shift)) {
this.idHold = { m: idm, colliderId: cid };
} else {
idm.pressId?.(cid);
}
return;
}
m.onInteract?.(); m.onInteract?.();
return; return;
} }
@ -163,10 +180,11 @@ export class Interaction {
} }
} }
/** E released — end any workshop hold. */ /** E released — end any workshop / world-machine hold. */
useUp(): void { useUp(): void {
this.eDown = false; this.eDown = false;
if (this.hold) { this.machines.workshop.holdEnd(); this.hold = null; } if (this.hold) { this.machines.workshop.holdEnd(); this.hold = null; }
if (this.idHold) { this.idHold.m.holdEndId?.(); this.idHold = null; }
} }
/** Q — drop a carried wire back to the loom. */ /** Q — drop a carried wire back to the loom. */
@ -176,6 +194,7 @@ export class Interaction {
const hit = this.raycast(); const hit = this.raycast();
this.lastHit = hit; this.lastHit = hit;
this.advanceWorkshopHold(dt, hit); this.advanceWorkshopHold(dt, hit);
this.advanceIdHold(dt, hit);
this.advanceMining(dt, hit); this.advanceMining(dt, hit);
this.updateSelection(hit); this.updateSelection(hit);
} }
@ -190,6 +209,17 @@ export class Interaction {
if (r !== 'run') this.hold = null; if (r !== 'run') this.hold = null;
} }
/** Advance a held world-machine interaction (mirrors the workshop hold:
* E stays down and aim stays on the same collider, else it cancels). */
private advanceIdHold(dt: number, hit: RayHit): void {
if (!this.idHold) return;
const g = this.idHold;
const onTarget = this.eDown && hit && hit.kind === 'machine'
&& (hit.machine as IdMachine) === g.m && this.lastColliderId === g.colliderId;
if (!onTarget) { g.m.holdEndId?.(); this.idHold = null; return; }
if (g.m.holdTickId!(g.colliderId, dt, this.shiftDown) !== 'run') this.idHold = null;
}
currentHit(): RayHit { return this.lastHit; } currentHit(): RayHit { return this.lastHit; }
// ---- internals ---- // ---- internals ----

View File

@ -18,6 +18,11 @@ import { Button } from './buttons';
import { RcaPlug } from './rca'; import { RcaPlug } from './rca';
import { Quest, type QuestPositions } from './quest'; import { Quest, type QuestPositions } from './quest';
import { Workshop } from './workshop/Workshop'; import { Workshop } from './workshop/Workshop';
import { Stamps } from './worlds/stamps';
import { Portals } from './worlds/portal';
import { AcidQuest } from './worlds/acidQuest';
import { DubQuest } from './worlds/dubQuest';
import { DiscoQuest } from './worlds/discoQuest';
export { Quest, QUEST_ITEMS, type QuestPositions } from './quest'; export { Quest, QUEST_ITEMS, type QuestPositions } from './quest';
export { Platter } from './platter'; export { Platter } from './platter';
@ -26,6 +31,11 @@ export { Fader } from './faders';
export { Button } from './buttons'; export { Button } from './buttons';
export { Workshop } from './workshop/Workshop'; export { Workshop } from './workshop/Workshop';
export type { WorkshopState } from './workshop/types'; export type { WorkshopState } from './workshop/types';
export { Stamps } from './worlds/stamps';
export { Portals } from './worlds/portal';
export { AcidQuest } from './worlds/acidQuest';
export { DubQuest } from './worlds/dubQuest';
export { DiscoQuest } from './worlds/discoQuest';
export interface MachineSet { export interface MachineSet {
/** Every machine (for iteration / collider lookup). */ /** Every machine (for iteration / collider lookup). */
@ -37,6 +47,9 @@ export interface MachineSet {
/** The Headshell Workshop (deck A) Interaction routes assembly here, /** The Headshell Workshop (deck A) Interaction routes assembly here,
* NetClient syncs its state. */ * NetClient syncs its state. */
workshop: Workshop; workshop: Workshop;
/** SIDE B crate-world stamps (the only world state that syncs)
* NetClient mirrors it over the relay. */
stamps: Stamps;
/** Flattened kinematic colliders (stable array; shapes mutate in place). */ /** Flattened kinematic colliders (stable array; shapes mutate in place). */
getColliders(): KinematicCollider[]; getColliders(): KinematicCollider[];
/** Advance all machines one fixed tick. */ /** Advance all machines one fixed tick. */
@ -198,6 +211,14 @@ export function createMachines(world: IVoxelWorld, questPos?: Partial<QuestPosit
// ---- RCA plug ---- // ---- RCA plug ----
machines.push(new RcaPlug(quest, pos.rcaPlug, pos.rcaSocket)); machines.push(new RcaPlug(quest, pos.rcaPlug, pos.rcaSocket));
// ---- SIDE B: crate-world portals, mini-quests, stamps (core/worlds.ts) ----
// Positions are contract constants — no LAYOUT dependency, no main.ts wiring.
const stamps = new Stamps();
machines.push(new Portals(), new AcidQuest(stamps), new DubQuest(stamps), new DiscoQuest(stamps));
// All three stamps → deck A's golden slipmat (fires for a local third stamp,
// a remote one, or the join snapshot replay — Stamps emits in every case).
bus.on('stamp:all', () => a.platter.setGoldenSlipmat(true));
// ---- MachineSet facade ---- // ---- MachineSet facade ----
const colliders: KinematicCollider[] = []; const colliders: KinematicCollider[] = [];
for (const m of machines) for (const c of m.getColliders()) colliders.push(c); for (const m of machines) for (const c of m.getColliders()) colliders.push(c);
@ -207,6 +228,7 @@ export function createMachines(world: IVoxelWorld, questPos?: Partial<QuestPosit
quest, quest,
tonearmA: a.tonearm, tonearmA: a.tonearm,
workshop, workshop,
stamps,
getColliders: () => colliders, getColliders: () => colliders,
update: (dt: number) => { for (const m of machines) m.update(dt); }, update: (dt: number) => { for (const m of machines) m.update(dt); },
getObject3Ds: () => machines.map((m) => m.getObject3D()), getObject3Ds: () => machines.map((m) => m.getObject3D()),

View File

@ -35,6 +35,26 @@ export abstract class MachineBase implements IMachine {
onInteract?(): void; onInteract?(): void;
} }
/**
* Optional per-collider interaction surface (SIDE B world machines). The
* interaction layer detects these structurally: a machine implementing them
* gets E routed WITH the hit collider id, and may run hold-E interactions like
* the workshop's crimp/torque (the workshop keeps its own bespoke routing).
* All methods are optional; a machine with none behaves exactly as before
* (plain `onInteract`).
*/
export interface IdInteractions {
/** One-shot E press on a specific collider. */
pressId?(colliderId: string): void;
/** Begin a hold-E on a collider; return true if a hold actually started
* (false falls through to pressId/onInteract). */
holdStartId?(colliderId: string, shift: boolean): boolean;
/** Advance a running hold; 'run' keeps it, anything else ends it. */
holdTickId?(colliderId: string, dt: number, shift: boolean): 'run' | 'done' | 'cancel';
/** Hold released or aim left the target. */
holdEndId?(): void;
}
/** Player AABB (world-space) from an IPlayerView. Reused scratch, no alloc. */ /** Player AABB (world-space) from an IPlayerView. Reused scratch, no alloc. */
export interface AABB { export interface AABB {
minX: number; minY: number; minZ: number; minX: number; minY: number; minZ: number;

View File

@ -58,6 +58,13 @@ export class Platter extends MachineBase {
private readonly spin = new THREE.Group(); // everything that rotates about the spindle private readonly spin = new THREE.Group(); // everything that rotates about the spindle
private readonly _vel: Vec3 = [0, 0, 0]; // velocityAt scratch (reused, no alloc) private readonly _vel: Vec3 = [0, 0, 0]; // velocityAt scratch (reused, no alloc)
// SIDE B M3: the slipmat swaps to a golden variant when all three crate-world
// stamps land (stamp:all). Materials made once; created in buildVisuals.
private slipmatMesh!: THREE.Mesh;
private slipmatFelt!: THREE.Material;
private slipmatGold: THREE.Material | null = null;
private golden = false;
constructor(deck: 'A' | 'B', spindleX: number, spindleZ: number) { constructor(deck: 'A' | 'B', spindleX: number, spindleZ: number) {
super(`platter${deck}`); super(`platter${deck}`);
this.deck = deck; this.deck = deck;
@ -125,8 +132,9 @@ export class Platter extends MachineBase {
} }
// Slipmat. // Slipmat.
const slipMat = blockMaterial(7); // slipmat_felt this.slipmatFelt = blockMaterial(7); // slipmat_felt
this.spin.add(cylinderY(rr + 1.5, 0.5, slipMat, 0, Y_RECORD_TOP - 0.75, 0)); this.slipmatMesh = cylinderY(rr + 1.5, 0.5, this.slipmatFelt, 0, Y_RECORD_TOP - 0.75, 0);
this.spin.add(this.slipmatMesh);
// Record body: terraced groove tiers (blue translucent on A / black on B), // Record body: terraced groove tiers (blue translucent on A / black on B),
// matching the TIERS colliders — a stepped vinyl amphitheater. // matching the TIERS colliders — a stepped vinyl amphitheater.
@ -225,6 +233,23 @@ export class Platter extends MachineBase {
isPlaying(): boolean { return this.playing; } isPlaying(): boolean { return this.playing; }
currentRpm(): number { return this.playing ? this.baseRpm() * this.pitch : 0; } currentRpm(): number { return this.playing ? this.baseRpm() * this.pitch : 0; }
/**
* SIDE B golden finish (stamp:all): swap the slipmat to a glowing gold
* variant. Idempotent; survives the reset ritual (stamps are pressed into
* the records, not the booth nothing ever calls this with `false` in
* game, but the toggle exists for demos).
*/
setGoldenSlipmat(on: boolean): void {
if (on === this.golden) return;
this.golden = on;
if (on && !this.slipmatGold) {
this.slipmatGold = blockMaterial(29, { emissive: 0.55, metalness: 0.85, roughness: 0.3 }); // rca_gold
}
this.slipmatMesh.material = on ? this.slipmatGold! : this.slipmatFelt;
}
isGoldenSlipmat(): boolean { return this.golden; }
private baseRpm(): number { return this.speed === 33 ? PLATTER.rpm33 : PLATTER.rpm45; } private baseRpm(): number { return this.speed === 33 ? PLATTER.rpm33 : PLATTER.rpm45; }
private recomputeTarget(): void { private recomputeTarget(): void {

View File

@ -0,0 +1,221 @@
// LANE M — ACID WAREHOUSE: Tune the 303 (SIDEB_MACHINES M2).
// Three knob pedestals (Lane W builds the 3×3×3 plinths at x=52, z=44/52/60;
// the colliders here are ours and coincide). Each has a hidden target 0..1,
// reseeded per entry. Hold-E sweeps a pedestal's band (ping-pong 0↔1, the
// workshop hold/torque pattern; `workshop:torque` drives the HUD gauge) and
// every meaningful change goes out as `fader:move {faderId:'acid_<i>'}` so
// Lane E2 maps value→cutoff — you TUNE BY EAR. Release inside ±0.07 of the
// target = the band locks (value snaps to target, pip glows, `acid_lock`).
// All three locked → the line snaps into tune → stamp. Already-stamped: the
// quest replays as a toy, no double-stamp.
import * as THREE from 'three';
import { bus } from '../../core/events';
import type { Vec3 } from '../../core/types';
import { MachineBase, type IdInteractions } from '../machine';
import { blockMaterial } from '../util';
import { POCKET_WALK_Y, entrySeed, interactAt, mulberry32 } from './common';
import type { Stamps } from './stamps';
// Aligned to Lane W's actual plinths (src/worldgen/worlds/acid.ts): 3×3×3
// matte_black at voxels x 51..53, z (pz-1)..(pz+1), y 144..146 — world centre
// [52.5, ·, pz+0.5], top face 147 — with their led_green pip VOXEL sitting at
// [52,147,pz] (occupying 147..148). Our knob rides above that pip; the lock
// lamp above the knob.
const PED_X = 52.5;
const PED_Z: readonly number[] = [44.5, 52.5, 60.5];
const PED_CY = POCKET_WALK_Y + 2; // collider centre — encloses plinth + pip
const PED_HALF = 2.1;
const KNOB_Y = POCKET_WALK_Y + 4.45; // 148.45 — on top of the pip voxel
const PIP_Y = POCKET_WALK_Y + 5.55; // lock lamp above the knob
const LOCK_WINDOW = 0.07;
/** A locked band re-opens only after this much deliberate hold (anti tap-spam). */
const UNLOCK_HOLD_S = 0.25;
const SWEEP_RATE = 0.32; // full scale per second (≈3.1 s per traverse)
const FADER_EPS = 0.01; // min value delta before a fader:move goes out
const KNOB_TICK_S = 0.16; // acid_knob machine:interact cadence while sweeping
const PIP_ON = new THREE.MeshStandardMaterial({ color: 0x2bff5c, emissive: 0x36ff62, emissiveIntensity: 1.6 });
const PIP_OFF = new THREE.MeshStandardMaterial({ color: 0x123315, emissive: 0x0c2a10, emissiveIntensity: 0.25 });
interface Pedestal {
value: number;
target: number;
locked: boolean;
dir: 1 | -1;
lastSent: number; // last fader:move value that went out
knob: THREE.Mesh;
pip: THREE.Mesh;
}
export class AcidQuest extends MachineBase implements IdInteractions {
private readonly stamps: Stamps;
private readonly peds: Pedestal[] = [];
private holding = -1; // pedestal index being swept, -1 = none
private heldFor = 0; // seconds the current hold has run (unlock intent)
private knobTick = 0;
private reemitIn = 0; // one delayed re-emit of all values post-enter
constructor(stamps: Stamps) {
super('acid');
this.stamps = stamps;
const chrome = blockMaterial(11);
for (let i = 0; i < PED_Z.length; i++) {
const z = PED_Z[i];
const shape = {
kind: 'aabb' as const,
min: [PED_X - PED_HALF, PED_CY - PED_HALF, z - PED_HALF] as Vec3,
max: [PED_X + PED_HALF, PED_CY + PED_HALF, z + PED_HALF] as Vec3,
};
this.colliders.push({ id: `acid_ped${i}`, shape, velocityAt: () => [0, 0, 0] });
// Our dressing on Lane W's plinth: a chrome knob that turns with the
// value (riding on their glowing pip voxel), and the lock lamp above it.
const knob = new THREE.Mesh(new THREE.CylinderGeometry(0.9, 1.1, 0.9, 12), chrome);
knob.position.set(PED_X, KNOB_Y, z);
const marker = new THREE.Mesh(new THREE.BoxGeometry(0.22, 0.25, 1.0), blockMaterial(20, { emissive: 0.8 }));
marker.position.set(0, 0.35, 0.55);
knob.add(marker);
const pip = new THREE.Mesh(new THREE.SphereGeometry(0.4, 10, 10), PIP_OFF);
pip.position.set(PED_X, PIP_Y, z);
this.group.add(knob, pip);
this.peds.push({ value: 0.5, target: 0.5, locked: false, dir: 1, lastSent: -1, knob, pip });
}
this.reseed();
bus.on('world:enter', ({ world }) => { if (world === 'acid') this.onEnter(); });
bus.on('world:exit', ({ world }) => { if (world === 'acid') this.endHold(); });
}
/** Fresh targets + audibly-wrong starting values (≥0.25 off target). */
private reseed(): void {
const rng = mulberry32(entrySeed());
for (const p of this.peds) {
p.target = 0.15 + rng() * 0.7;
let v = rng();
for (let tries = 0; tries < 16 && Math.abs(v - p.target) < 0.25; tries++) v = rng();
p.value = Math.round(v * 100) / 100;
p.locked = false;
p.dir = 1;
p.lastSent = -1;
p.pip.material = PIP_OFF;
}
}
private onEnter(): void {
this.reseed();
this.emitAll();
// Bus handler registration order between lanes isn't guaranteed — one
// delayed re-emit makes sure Lane E2's freshly-started loop has the values.
this.reemitIn = 0.35;
}
private emitAll(): void {
for (let i = 0; i < this.peds.length; i++) this.emitValue(i, true);
}
private emitValue(i: number, force = false): void {
const p = this.peds[i];
const v = Math.round(p.value * 1000) / 1000;
if (!force && Math.abs(v - p.lastSent) < FADER_EPS) return;
p.lastSent = v;
bus.emit('fader:move', { faderId: `acid_${i}`, value: v });
}
private lockedCount(): number { return this.peds.reduce((n, p) => n + (p.locked ? 1 : 0), 0); }
// ── hold-E surface (routed by Interaction via IdInteractions) ──
holdStartId(colliderId: string): boolean {
const i = this.pedIndex(colliderId);
if (i < 0) return false;
const p = this.peds[i];
// A locked band does NOT unlock on the press — only a deliberate hold
// (UNLOCK_HOLD_S) re-opens it. A bare tap would otherwise unlock and
// instantly re-lock inside the window, spamming acid_lock (and, at 3/3,
// acid_tuned + toasts) at keyboard rate.
this.holding = i;
this.heldFor = 0;
this.knobTick = 0;
bus.emit('workshop:torque', { value: p.value });
return true;
}
holdTickId(colliderId: string, dt: number, shift: boolean): 'run' | 'done' | 'cancel' {
const i = this.pedIndex(colliderId);
if (i < 0 || i !== this.holding) return 'cancel';
const p = this.peds[i];
this.heldFor += dt;
if (p.locked) {
// pinned at the sweet spot until the hold shows intent, then re-open
if (this.heldFor < UNLOCK_HOLD_S) return 'run';
p.locked = false;
p.pip.material = PIP_OFF;
}
// Ping-pong sweep; shift reverses, so fine backtracking is possible.
const rate = SWEEP_RATE * (shift ? -1 : 1) * p.dir;
p.value += rate * dt;
if (p.value >= 1) { p.value = 1; p.dir = -p.dir as 1 | -1; }
else if (p.value <= 0) { p.value = 0; p.dir = -p.dir as 1 | -1; }
this.emitValue(i);
bus.emit('workshop:torque', { value: p.value });
this.knobTick -= dt;
if (this.knobTick <= 0) {
this.knobTick = KNOB_TICK_S;
interactAt(`acid_${i}`, 'acid_knob', i); // machineId scheme per E2: acid_<band>
}
return 'run'; // sweeps until release; release evaluates the lock
}
holdEndId(): void {
const i = this.holding;
this.holding = -1;
bus.emit('workshop:torque', { value: null });
if (i < 0) return;
const p = this.peds[i];
if (p.locked) return; // tap on a locked band: released before intent — no-op
if (Math.abs(p.value - p.target) <= LOCK_WINDOW) {
p.locked = true;
p.value = p.target; // snap: the band now sounds exactly right
p.pip.material = PIP_ON;
this.emitValue(i, true);
interactAt(`acid_${i}`, 'acid_lock', i); // machineId scheme per E2: acid_<band>
const n = this.lockedCount();
if (n < this.peds.length) {
bus.emit('workshop:msg', { text: `BAND LOCKED (${n}/3)` });
} else {
this.complete();
}
}
}
private endHold(): void {
if (this.holding >= 0) this.holdEndId();
}
private complete(): void {
interactAt('acid', 'acid_tuned');
if (!this.stamps.apply('acid')) {
bus.emit('workshop:msg', { text: 'THE 303 SNAPS INTO TUNE — STILL GOT IT' });
}
}
private pedIndex(colliderId: string): number {
if (!colliderId.startsWith('acid_ped')) return -1;
const i = +colliderId.slice(8);
return i >= 0 && i < this.peds.length ? i : -1;
}
update(dt: number): void {
if (this.reemitIn > 0) {
this.reemitIn -= dt;
if (this.reemitIn <= 0) this.emitAll();
}
// Knob angle follows the value (single rotation span, reads as a dial).
for (const p of this.peds) {
p.knob.rotation.y = (p.value - 0.5) * Math.PI * 1.5;
}
}
}

View File

@ -0,0 +1,95 @@
// LANE M — SIDE B shared helpers for the crate-world machines.
// Positions come from the contract (src/core/worlds.ts); this file only adds
// the small conversions every world machine needs. No game state lives here.
import { WORLD_DEFS, WORLD_KEYS, type WorldKey } from '../../core/worlds';
import { bus, type GameEvents } from '../../core/events';
import type { IPlayerView, Vec3 } from '../../core/types';
/** Walkable floor level inside every pocket: shell 142 + dressing 143 → feet 144. */
export const POCKET_WALK_Y = 144;
/** Out-of-pocket safety altitude (brief M1): above this and outside any pocket
* bounds, the player is teleported home. The booth wall top is 140. */
export const POCKET_SAFETY_Y = 141;
/**
* Emit `machine:interact`, optionally carrying the SIDEB briefs' `index` field
* (disco cues, acid locks). The contract payload is `{machineId, action}`;
* `index` rides along as a structural extra so Lanes S/E2 whose briefs quote
* `{action:'disco_cue', index}` can read it, while the contract file stays
* untouched. The tile/pedestal id is ALSO encoded in machineId
* (`disco_tile_<i>`, `acid_ped<i>`) so consumers can use either.
*/
export function interactAt(machineId: string, action: string, index?: number): void {
const payload: GameEvents['machine:interact'] & { index?: number } = { machineId, action };
if (index !== undefined) payload.index = index;
bus.emit('machine:interact', payload);
}
/** Is a world-space point inside a pocket's INCLUSIVE voxel bounds (+margin)? */
export function insidePocket(k: WorldKey, x: number, y: number, z: number, margin = 0.5): boolean {
const d = WORLD_DEFS[k];
return (
x >= d.pocketMin[0] - margin && x <= d.pocketMax[0] + 1 + margin &&
y >= d.pocketMin[1] - margin && y <= d.pocketMax[1] + 1 + margin &&
z >= d.pocketMin[2] - margin && z <= d.pocketMax[2] + 1 + margin
);
}
/** The pocket the point is inside, or null. */
export function pocketAt(x: number, y: number, z: number): WorldKey | null {
for (const k of WORLD_KEYS) if (insidePocket(k, x, y, z)) return k;
return null;
}
/** Nearest world by xz distance to the pocket centre (safety-net homing). */
export function nearestWorld(x: number, z: number): WorldKey {
let best: WorldKey = WORLD_KEYS[0];
let bestD = Infinity;
for (const k of WORLD_KEYS) {
const d = WORLD_DEFS[k];
const cx = (d.pocketMin[0] + d.pocketMax[0]) / 2;
const cz = (d.pocketMin[2] + d.pocketMax[2]) / 2;
const dd = (x - cx) * (x - cx) + (z - cz) * (z - cz);
if (dd < bestD) { bestD = dd; best = k; }
}
return best;
}
/**
* Lane B's PlayerController exposes `teleport` and `isFlying` beyond the
* IPlayerView contract. We reach them structurally (optional), never by
* importing Lane B a mock player without them simply doesn't move.
*/
export interface PlayerControl {
teleport?(v: Vec3): void;
isFlying?: boolean;
}
export function teleportPlayer(p: IPlayerView | null, v: Vec3): void {
(p as (IPlayerView & PlayerControl) | null)?.teleport?.(v);
}
export function playerFlying(p: IPlayerView | null): boolean {
return (p as (IPlayerView & PlayerControl) | null)?.isFlying === true;
}
/** Tiny deterministic PRNG (mulberry32) for per-entry quest seeds. */
export function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** A fresh per-entry seed (wall clock + a counter so re-entries differ). */
let seedCounter = 0;
export function entrySeed(): number {
seedCounter = (seedCounter + 0x9e3779b9) >>> 0;
return (Date.now() ^ seedCounter) >>> 0;
}

View File

@ -0,0 +1,231 @@
// LANE M — DISCO LOFT: Light the Floor (SIDEB_MACHINES M2).
// Beat-Simon on Lane W's 4×4 dance-tile grid (5×5 tiles at PITCH 8 — aligned
// to their actual build in src/worldgen/worlds/disco.ts: centres at voxels
// x 384/392/400/408, z 52/60/68/76, flush at y 143, walk level 144; world
// centres sit at +0.5). On each beat a cue fires —
// `machine:interact {machineId:'disco_tile_<i>', action:'disco_cue', index}` —
// so Lane S lights the tile and Lane E2 blips it (pentatonic by index). The
// player answers by STEPPING the tiles in order (standing-on-tile detection;
// any pace, order is what matters). Wrong tile = gentle reset ("THE FLOOR
// FORGIVES — WATCH AGAIN", same round replays). Rounds grow 4 → 8; the full
// 8 stamps the record. Beats come from `audio:beat`, with a 122 BPM free-run
// fallback so a silent booth can't stall the floor.
//
// Tile LIGHTING is ours too (Lane S is scoped out of quest-specific
// rendering): 16 thin glow plates over the tiles, lit from the same cue/step
// path that emits the events. Shared materials swapped by reference — the
// workshop lamp-LED pattern, zero per-frame allocation.
import * as THREE from 'three';
import { bus } from '../../core/events';
import { MachineBase } from '../machine';
import { entrySeed, interactAt, mulberry32 } from './common';
import type { Stamps } from './stamps';
const GRID_X: readonly number[] = [384.5, 392.5, 400.5, 408.5];
const GRID_Z: readonly number[] = [52.5, 60.5, 68.5, 76.5];
const TILE_HALF = 2.5; // 5×5 tiles (voxels cx2..cx+2)
const FOOT_Y_MIN = 143.4; // feet band that counts as "on the floor"
// Tight top: a jump from the 144 floor peaks at ~145.3 (v=9, g=32), so a band
// reaching 145.6 counted tiles the player HOPPED OVER as steps — hopping the
// intermediate tile of a two-apart sequence must be a legal route. 144.6 keeps
// standing/walking (feet ≈ 144.0) and excludes everything airborne past the
// first ~0.1 s of a jump.
const FOOT_Y_MAX = 144.6;
const ROUND_MIN = 4;
const ROUND_MAX = 8;
const BEAT_FALLBACK_S = 60 / 122; // free-run when audio:beat is silent
const BEAT_MIN_GAP_S = 0.30; // ignore double-triggers (beat + fallback)
const INPUT_IDLE_REPLAY_S = 10; // stuck mid-answer → courtesy replay
// Glow-plate flash lengths (s) + shared materials (swap by reference — the
// workshop lamp pattern; allocating per flash would churn the GC).
const FLASH_CUE_S = 0.42; // under one beat, reads as a pulse
const FLASH_STEP_S = 0.30;
const FLASH_MISS_S = 0.85;
const FLASH_WIN_S = 1.5;
const TILE_OFF = new THREE.MeshStandardMaterial({ color: 0x1c1216, emissive: 0x140a10, emissiveIntensity: 0.25, transparent: true, opacity: 0.85 });
const TILE_CUE = new THREE.MeshStandardMaterial({ color: 0xff78dc, emissive: 0xff78dc, emissiveIntensity: 1.5 }); // disco accent
const TILE_STEP = new THREE.MeshStandardMaterial({ color: 0xffd97a, emissive: 0xffc84a, emissiveIntensity: 1.3 }); // your echo, warm gold
const TILE_MISS = new THREE.MeshStandardMaterial({ color: 0xff5a4a, emissive: 0xff3a28, emissiveIntensity: 1.1 });
type Phase = 'idle' | 'intro' | 'showing' | 'input' | 'pause' | 'done';
export class DiscoQuest extends MachineBase {
private readonly stamps: Stamps;
private phase: Phase = 'idle';
private seq: number[] = [];
private roundLen = ROUND_MIN;
private showPos = 0; // next sequence element to cue
private progress = 0; // correct steps so far this round
private timer = 0; // phase-local countdown
private sinceCue = 0; // fallback beat clock
private idleFor = 0; // input-phase inactivity
private cell: number | null = null; // tile underfoot (edge detection)
private rng: () => number = mulberry32(1);
private readonly plates: THREE.Mesh[] = [];
private readonly flash = new Float32Array(16); // seconds left per plate
constructor(stamps: Stamps) {
super('disco');
this.stamps = stamps;
// No colliders: the tiles are Lane W's voxels; standing-on-tile detection
// is positional (a thin static grid needs no kinematic boxes to ride).
// Glow plates float a hair over the tile tops (walk level 144).
const geo = new THREE.BoxGeometry(4.6, 0.12, 4.6);
for (let r = 0; r < 4; r++) {
for (let c = 0; c < 4; c++) {
const m = new THREE.Mesh(geo, TILE_OFF);
m.position.set(GRID_X[c], 144.05, GRID_Z[r]);
this.plates.push(m);
this.group.add(m);
}
}
bus.on('world:enter', ({ world }) => { if (world === 'disco') this.begin(); });
bus.on('world:exit', ({ world }) => { if (world === 'disco') this.phase = 'idle'; });
bus.on('audio:beat', () => {
// Phase-sync the show to the groove: a real beat fires the pending cue.
if (this.phase === 'showing' && this.sinceCue >= BEAT_MIN_GAP_S) this.sinceCue = BEAT_FALLBACK_S;
});
}
private begin(): void {
this.rng = mulberry32(entrySeed());
this.roundLen = ROUND_MIN;
this.seq = [];
this.extendTo(ROUND_MIN);
this.phase = 'intro';
this.timer = 1.6; // let the arrival settle before the floor speaks
}
/** Grow the sequence to n tiles; no immediate repeats (re-entry detection
* can't see the same tile twice in a row). */
private extendTo(n: number): void {
while (this.seq.length < n) {
let t = Math.floor(this.rng() * 16);
const prev = this.seq[this.seq.length - 1];
if (t === prev) t = (t + 1 + Math.floor(this.rng() * 14)) % 16;
this.seq.push(t);
}
}
private startShow(msg: string | null): void {
this.phase = 'showing';
this.showPos = 0;
this.sinceCue = BEAT_FALLBACK_S * 0.6; // first cue lands quickly
if (msg) bus.emit('workshop:msg', { text: msg });
}
private startInput(): void {
this.phase = 'input';
this.progress = 0;
this.idleFor = 0;
// The tile underfoot right now doesn't count until re-entered.
this.cell = this.cellUnder();
bus.emit('workshop:msg', { text: 'YOUR TURN — STEP THE TILES' });
}
/** Flash a glow plate (shared materials, swapped by reference). */
private lightTile(i: number, mat: THREE.Material, secs: number): void {
this.plates[i].material = mat;
this.flash[i] = secs;
}
private cellUnder(): number | null {
const pl = this.player;
if (!pl) return null;
const [x, y, z] = pl.position;
if (y < FOOT_Y_MIN || y > FOOT_Y_MAX) return null;
for (let c = 0; c < 4; c++) {
if (Math.abs(x - GRID_X[c]) > TILE_HALF) continue;
for (let r = 0; r < 4; r++) {
if (Math.abs(z - GRID_Z[r]) <= TILE_HALF) return r * 4 + c;
}
return null;
}
return null;
}
update(dt: number): void {
// Flash decay runs in every phase, so a mid-flash exit can't strand a lit
// plate.
for (let i = 0; i < 16; i++) {
if (this.flash[i] > 0) {
this.flash[i] -= dt;
if (this.flash[i] <= 0) this.plates[i].material = TILE_OFF;
}
}
switch (this.phase) {
case 'idle':
case 'done':
return;
case 'intro':
case 'pause':
this.timer -= dt;
if (this.timer <= 0) {
this.startShow(this.phase === 'intro' ? 'WATCH THE FLOOR' : null);
}
return;
case 'showing': {
this.sinceCue += dt;
if (this.sinceCue >= BEAT_FALLBACK_S) {
this.sinceCue = 0;
if (this.showPos < this.roundLen) {
const tile = this.seq[this.showPos++];
interactAt(`disco_tile_${tile}`, 'disco_cue', tile);
this.lightTile(tile, TILE_CUE, FLASH_CUE_S);
} else {
this.startInput(); // one beat of air after the last cue
}
}
return;
}
case 'input': {
const c = this.cellUnder();
if (c !== this.cell) {
this.cell = c;
if (c !== null) this.step(c);
}
this.idleFor += dt;
if (this.idleFor >= INPUT_IDLE_REPLAY_S) {
this.startShow('WATCH AGAIN'); // courtesy: lost the thread, re-show
}
return;
}
}
}
private step(tile: number): void {
this.idleFor = 0;
if (tile === this.seq[this.progress]) {
interactAt(`disco_tile_${tile}`, 'disco_step', tile);
this.lightTile(tile, TILE_STEP, FLASH_STEP_S);
this.progress++;
if (this.progress < this.roundLen) return;
if (this.roundLen >= ROUND_MAX) { this.complete(); return; }
// Round up: same sequence + one more, brief breath, then show again.
this.roundLen++;
this.extendTo(this.roundLen);
this.phase = 'pause';
this.timer = 1.3;
bus.emit('workshop:msg', { text: `THE FLOOR LIKES IT — ${this.roundLen} STEPS NOW` });
} else {
interactAt('disco', 'disco_miss');
this.lightTile(tile, TILE_MISS, FLASH_MISS_S);
this.phase = 'pause';
this.timer = 1.2;
bus.emit('workshop:msg', { text: 'THE FLOOR FORGIVES — WATCH AGAIN' });
}
}
private complete(): void {
this.phase = 'done';
interactAt('disco', 'disco_lit');
for (let i = 0; i < 16; i++) this.lightTile(i, TILE_CUE, FLASH_WIN_S); // the whole floor takes a bow
if (!this.stamps.apply('disco')) {
bus.emit('workshop:msg', { text: 'THE FLOOR REMEMBERS YOU — FULLY LIT' });
}
// Replayable toy: dive out and back in for a fresh game.
}
}

View File

@ -0,0 +1,238 @@
// LANE M — DUB CHAMBER: Feed the Spring (SIDEB_MACHINES M2).
// An echo charge (glowing carryable, the wire-carry pattern with isCarrying-
// style state) sits at the horn on the far wall. Carry it back across the
// spring-tank bridge: an 8-segment kinematic deck overlaid on Lane W's static
// chrome deck, riding a gentle travelling sine — mostly a shimmer when empty,
// a real wobble + lateral breathe while you carry (the spring FEELS you
// coming; ride-snap keeps you glued, rates far inside Lane B's envelope).
// Reach the tank mouth — the rca_gold inlay mid-deck — and E to drop →
// `dub_feed` → stamp. Fall into the tank pit with the charge and it returns
// to the horn: "THE SPRING WANTS IT — TRY AGAIN".
//
// Geometry aligned to Lane W's ACTUAL build (src/worldgen/worlds/dub.ts,
// constants mirrored — cross-lane imports are contract-only): static deck
// chrome x 223..225 (voxels) z 40..63, deck top face 147; tank pit x 212..236
// z 41..62, pit walk surface 143 (canyon floor around it walks at 144); mouth
// inlay voxel [224,146,52]; horn cone at x 232 on the back wall — East of the
// exit-pad lane, so the horn walk never crosses the pad.
//
// Lane E2 vocabulary: machine:interact dub_pickup / dub_drop / dub_feed
// (the echo wet literally follows the carry).
import * as THREE from 'three';
import { bus } from '../../core/events';
import type { Vec3 } from '../../core/types';
import { MachineBase, type IdInteractions } from '../machine';
import { blockMaterial } from '../util';
import { insidePocket, interactAt } from './common';
import type { Stamps } from './stamps';
// Bridge overlay (world-space; Lane W's deck voxels 223..225 span 223..226).
const BX = 224.5; // bridge centre line
const BZ0 = 40, BZ1 = 64; // deck span (voxels 40..63)
const SEGS = 8;
const SEG_LEN = (BZ1 - BZ0) / SEGS;
/** Static deck top face is 147; ours floats proud and never dips below it
* (147.62 0.55 wobble = 147.07), so the ride never pops onto the voxels. */
const DECK_BASE = 147.62;
const DECK_HALF_W = 1.5;
const DECK_THICK = 0.8;
const WOBBLE_Y = 0.55; // carrying amplitude (idle = ×0.22)
const WOBBLE_X = 0.26;
const OMEGA = 1.7; // rad/s — max dy/dt ≈ 0.94 v/s, dx/dt ≈ 0.44 v/s
const PHASE = 0.9; // rad per segment (travelling wave)
const IDLE_SCALE = 0.22;
/** Tank-mouth inlay centre (voxel [224,146,52] world centre). Feeding = E
* on the deck within MOUTH_HALF_Z of it while carrying; the inlay sits under
* our floating deck, so the deck segments ARE the feed target. */
const MOUTH_Z = 52.5;
const MOUTH_HALF_Z = 3.2;
/** Floats before the horn mouth (horn cone cx 232, mouth ring at z 80). */
const CHARGE_HOME: Vec3 = [232, 146.2, 78];
/** Feet below this while carrying = fell into the tank pit (pit walk surface
* is 143; the surrounding canyon floor at 144 stays safe to roam). */
const FALL_Y = 143.6;
export class DubQuest extends MachineBase implements IdInteractions {
private readonly stamps: Stamps;
private carrying = false;
private wobble = IDLE_SCALE; // eased amplitude scale
private t = 0;
private readonly segShapes: { kind: 'aabb'; min: Vec3; max: Vec3 }[] = [];
private readonly segVel: Vec3[] = [];
private readonly segMeshes: THREE.Mesh[] = [];
private readonly chargeShape: { kind: 'aabb'; min: Vec3; max: Vec3 };
private readonly chargeMesh: THREE.Mesh;
private readonly chargeHalo: THREE.Mesh;
private mouthRing!: THREE.Mesh;
constructor(stamps: Stamps) {
super('dub');
this.stamps = stamps;
// ── the moving deck: 8 riding segments + meshes ──
const chrome = blockMaterial(11, { roughness: 0.3 });
for (let i = 0; i < SEGS; i++) {
const shape = {
kind: 'aabb' as const,
min: [BX - DECK_HALF_W, DECK_BASE - DECK_THICK, BZ0 + i * SEG_LEN] as Vec3,
max: [BX + DECK_HALF_W, DECK_BASE, BZ0 + (i + 1) * SEG_LEN] as Vec3,
};
this.segShapes.push(shape);
const vel: Vec3 = [0, 0, 0];
this.segVel.push(vel);
this.colliders.push({ id: `dub_deck${i}`, shape, velocityAt: () => vel });
const mesh = new THREE.Mesh(
new THREE.BoxGeometry(DECK_HALF_W * 2, DECK_THICK, SEG_LEN - 0.12), chrome,
);
this.segMeshes.push(mesh);
this.group.add(mesh);
}
// ── the echo charge at the horn ──
this.chargeShape = {
kind: 'aabb',
min: [CHARGE_HOME[0] - 1.1, CHARGE_HOME[1] - 1.1, CHARGE_HOME[2] - 1.1],
max: [CHARGE_HOME[0] + 1.1, CHARGE_HOME[1] + 1.1, CHARGE_HOME[2] + 1.1],
};
this.colliders.push({ id: 'dub_charge', shape: this.chargeShape, velocityAt: () => [0, 0, 0] });
this.chargeMesh = new THREE.Mesh(
new THREE.SphereGeometry(0.85, 14, 14),
blockMaterial(20, { emissive: 1.4 }), // led_amber glow
);
this.chargeHalo = new THREE.Mesh(
new THREE.SphereGeometry(1.25, 12, 12),
new THREE.MeshStandardMaterial({
color: 0xffb340, emissive: 0xffa030, emissiveIntensity: 0.5,
transparent: true, opacity: 0.18, depthWrite: false,
}),
);
this.chargeMesh.position.set(CHARGE_HOME[0], CHARGE_HOME[1], CHARGE_HOME[2]);
this.chargeHalo.position.copy(this.chargeMesh.position);
this.group.add(this.chargeMesh, this.chargeHalo);
// ── the tank-mouth marker: an amber ring hovering over Lane W's gold
// inlay (which sits in the static deck under our floating overlay). No
// collider — the deck segments are the feed target; a physical box here
// would wall off the walking lane mid-bridge.
this.mouthRing = new THREE.Mesh(
new THREE.TorusGeometry(1.15, 0.16, 8, 20),
blockMaterial(20, { emissive: 0.9 }),
);
this.mouthRing.rotation.x = Math.PI / 2;
this.mouthRing.position.set(BX, DECK_BASE + 0.35, MOUTH_Z);
this.group.add(this.mouthRing);
bus.on('world:enter', ({ world }) => { if (world === 'dub') this.resetCharge(false); });
bus.on('world:exit', ({ world }) => {
// Leaving mid-carry (exit pad or safety net): the charge slips home.
if (world === 'dub' && this.carrying) this.resetCharge(true);
});
}
isCarrying(): boolean { return this.carrying; }
private resetCharge(emitDrop: boolean): void {
if (this.carrying && emitDrop) interactAt('dub', 'dub_drop');
this.carrying = false;
this.chargeShape.min[0] = CHARGE_HOME[0] - 1.1; this.chargeShape.max[0] = CHARGE_HOME[0] + 1.1;
this.chargeShape.min[1] = CHARGE_HOME[1] - 1.1; this.chargeShape.max[1] = CHARGE_HOME[1] + 1.1;
this.chargeShape.min[2] = CHARGE_HOME[2] - 1.1; this.chargeShape.max[2] = CHARGE_HOME[2] + 1.1;
this.chargeMesh.position.set(CHARGE_HOME[0], CHARGE_HOME[1], CHARGE_HOME[2]);
this.chargeMesh.visible = true;
this.chargeHalo.visible = true;
}
private collapseCharge(): void {
this.chargeShape.min[0] = this.chargeShape.min[1] = this.chargeShape.min[2] = -1e6;
this.chargeShape.max[0] = this.chargeShape.max[1] = this.chargeShape.max[2] = -1e6 + 0.01;
}
// ── E routing ──
pressId(colliderId: string): void {
if (colliderId === 'dub_charge') {
if (this.carrying) return;
this.carrying = true;
this.collapseCharge();
interactAt('dub', 'dub_pickup');
bus.emit('workshop:msg', { text: 'THE ECHO CHARGE HUMS — FEED IT TO THE TANK MOUTH, MID-BRIDGE' });
return;
}
// The mouth inlay lives under the floating deck: E on a deck segment near
// mid-span is the feed (the pit's reach geometry makes the bridge the only
// spot you can do this from).
if (colliderId.startsWith('dub_deck')
&& Math.abs((this.player ? this.player.position[2] : 1e9) - MOUTH_Z) < MOUTH_HALF_Z) {
if (!this.carrying) {
bus.emit('workshop:msg', { text: 'THE TANK GAPES BELOW — THE CHARGE WAITS AT THE HORN' });
return;
}
this.feed();
}
}
private feed(): void {
this.carrying = false;
interactAt('dub', 'dub_feed');
if (!this.stamps.apply('dub')) {
bus.emit('workshop:msg', { text: 'THE SPRING DRINKS DEEP — SPROING' });
}
// Replayable toy: the charge re-forms at the horn after the sproing.
this.resetCharge(false);
}
update(dt: number): void {
this.t += dt;
// Amplitude eases toward carry/idle scale (no pop when picked up).
const target = this.carrying ? 1 : IDLE_SCALE;
this.wobble += (target - this.wobble) * Math.min(1, dt * 2.5);
// Travelling wave: y bob + a lateral breathe. Colliders and meshes move
// together and velocityAt reports the TRUE surface velocity, so the ride
// is honest (Lane B carries x via velocityAt, ride-snap handles y).
for (let i = 0; i < SEGS; i++) {
const ph = this.t * OMEGA - i * PHASE;
const yOff = WOBBLE_Y * this.wobble * Math.sin(ph);
const xOff = WOBBLE_X * this.wobble * Math.sin(ph * 0.7 + 1.3);
const dxdt = WOBBLE_X * this.wobble * 0.7 * OMEGA * Math.cos(ph * 0.7 + 1.3);
const s = this.segShapes[i];
s.min[0] = BX + xOff - DECK_HALF_W; s.max[0] = BX + xOff + DECK_HALF_W;
s.min[1] = DECK_BASE + yOff - DECK_THICK; s.max[1] = DECK_BASE + yOff;
const v = this.segVel[i];
v[0] = dxdt; v[1] = 0; v[2] = 0;
const m = this.segMeshes[i];
m.position.set(BX + xOff, DECK_BASE + yOff - DECK_THICK / 2, BZ0 + (i + 0.5) * SEG_LEN);
m.rotation.x = 0.10 * this.wobble * Math.cos(ph);
}
// The mouth ring rides the mid-span segment and burns brighter while the
// charge is on its way (the tank calls for it).
const midSeg = this.segShapes[4];
this.mouthRing.position.y = midSeg.max[1] + 0.35;
(this.mouthRing.material as THREE.MeshStandardMaterial).emissiveIntensity =
this.carrying ? 1.3 + 0.6 * Math.sin(this.t * 5) : 0.7;
const pl = this.player;
if (this.carrying && pl) {
// The charge floats in front of the eye, wire-carry style.
const e = pl.eye, l = pl.lookDir;
this.chargeMesh.position.set(e[0] + l[0] * 2.2, e[1] + l[1] * 2.2 - 0.4, e[2] + l[2] * 2.2);
this.chargeHalo.position.copy(this.chargeMesh.position);
// Fell into the chasm with it? The spring takes it back to the horn.
const [x, y, z] = pl.position;
if (y < FALL_Y && insidePocket('dub', x, y, z)) {
this.resetCharge(true);
bus.emit('workshop:msg', { text: 'THE SPRING WANTS IT — TRY AGAIN' });
}
} else if (!this.carrying) {
// Home bob + halo breathe.
const bob = Math.sin(this.t * 1.6) * 0.15;
this.chargeMesh.position.y = CHARGE_HOME[1] + bob;
this.chargeHalo.position.y = CHARGE_HOME[1] + bob;
const halo = this.chargeHalo.material as THREE.MeshStandardMaterial;
halo.opacity = 0.14 + 0.07 * Math.sin(this.t * 2.3);
}
this.chargeHalo.visible = this.chargeMesh.visible = !this.carrying || pl !== null;
}
}

View File

@ -0,0 +1,208 @@
// LANE M — the crate portals (SIDEB_MACHINES M1).
// Three records in the under-table crate are pressed with whole scenes. Beside
// each (WORLD_DEFS[k].portalStand) floats a soft accent-glow dive plate: aim +
// E → `world:enter` + teleport to the pocket's entry. Standing on a pocket's
// exit pad → `world:exit` + teleport back to the crate. Belt-and-braces: a
// player above the booth walls (y > 141) who is NOT inside any pocket bounds
// is teleported home to the nearest world's returnPos (Lane W seals the
// shells; this catches everything else).
//
// Portals never read quest state — they keep working while the booth is dead
// (reset ritual coexistence, brief M5).
import * as THREE from 'three';
import { WORLD_DEFS, WORLD_KEYS, type WorldKey } from '../../core/worlds';
import { bus } from '../../core/events';
import type { KinematicCollider, Vec3 } from '../../core/types';
import { MachineBase } from '../machine';
import {
POCKET_SAFETY_Y, insidePocket, interactAt, nearestWorld, pocketAt, playerFlying, teleportPlayer,
} from './common';
/** How close (xz, voxels) to a portal stand the HUD prompt appears. */
const PROMPT_RADIUS = 5.5;
/** Re-show the prompt this often while the player stays in the zone (s). */
const PROMPT_REPEAT_S = 4;
/** Post-teleport grace so the exit pad / safety can't retrigger mid-flight. */
const TELEPORT_COOLDOWN_S = 1.0;
/** One-line quest hook shown on arrival, per world. */
const ARRIVAL_HINT: Record<WorldKey, string> = {
acid: 'TUNE THE 303 — HOLD E ON THE PEDESTALS, LISTEN FOR SWEET',
dub: 'FEED THE SPRING — CARRY THE CHARGE FROM THE HORN TO THE TANK',
disco: 'LIGHT THE FLOOR — WATCH THE TILES, REPEAT THE STEPS',
};
interface PortalEntry {
key: WorldKey;
plate: THREE.Mesh;
mat: THREE.MeshStandardMaterial;
promptAt: number; // seconds until the prompt may re-show (<=0 = armed)
inZone: boolean;
}
export class Portals extends MachineBase {
private readonly portals: PortalEntry[] = [];
private cooldown = 0;
private safetyTimer = 0;
private t = 0;
/** Seconds the player has stood on an exit pad (accident guard). */
private padDwell = 0;
/** World the player dove into (null = in the booth). Lets the safety catch
* EVERY way out of a pocket mined floor, glitched wall not just y>141:
* membership is checked, not altitude. */
private inWorld: WorldKey | null = null;
/** Previous tick's xz while on a pad (speed gate: striding across the pad
* never accumulates dwell; standing does). */
private padPrevX = 0;
private padPrevZ = 0;
constructor() {
super('portals');
for (const key of WORLD_KEYS) {
const d = WORLD_DEFS[key];
// The crate discs are vertical records in the y-z plane, 2 voxels thick
// starting at portalDisc.x (buildBooth discX). Probed live: the rim
// overhangs the stand (solid at y=4 up to z=35, y=5 up to z=37), so the
// dive plate sits LOW against the disc's lower rim, hugging the disc's
// own x footprint (bodies can't walk there) and poking just past the
// overhang — hittable from the stand and from the record aisle, without
// walling off the crate floor.
const px = d.portalDisc[0] + 1; // centre of the 2-thick disc
const pz = d.portalStand[2] - 0.7; // just proud of the rim overhang
const py = d.portalStand[1] + 1.0; // knee/chest height off the floor
const shape = {
kind: 'aabb' as const,
min: [px - 1.1, py - 1.2, pz - 1.1] as Vec3,
max: [px + 1.1, py + 1.2, pz + 1.1] as Vec3,
};
const collider: KinematicCollider = {
id: `portal_${key}`,
shape,
velocityAt: () => [0, 0, 0],
};
this.colliders.push(collider);
// Soft translucent accent pane — the "this record is a door" tell.
const [r, g, b] = d.accent;
const color = new THREE.Color().setRGB(r / 255, g / 255, b / 255, THREE.SRGBColorSpace);
const mat = new THREE.MeshStandardMaterial({
color, emissive: color, emissiveIntensity: 0.7,
transparent: true, opacity: 0.22, depthWrite: false,
});
const plate = new THREE.Mesh(new THREE.BoxGeometry(2.0, 2.2, 0.45), mat);
plate.position.set(px, py, pz + 0.85); // the pane floats at the collider's front face
this.group.add(plate);
this.portals.push({ key, plate, mat, promptAt: 0, inZone: false });
}
}
/** E on a dive plate (routed by collider id). Respects the teleport
* cooldown an exit lands you within press range of the plate, and an E
* held through the exit must not ping-pong you straight back in. */
pressId(colliderId: string): void {
if (this.cooldown > 0) return;
if (!colliderId.startsWith('portal_')) return;
const key = colliderId.slice(7) as WorldKey;
const d = WORLD_DEFS[key];
if (!d) return;
this.cooldown = TELEPORT_COOLDOWN_S;
this.inWorld = key;
bus.emit('world:enter', { world: key });
interactAt('portals', 'portal_dive');
teleportPlayer(this.player, d.entry);
bus.emit('workshop:msg', { text: `${d.name}${ARRIVAL_HINT[key]}` });
}
update(dt: number): void {
this.t += dt;
if (this.cooldown > 0) this.cooldown -= dt;
// Breathing accent glow on the plates (shared cheap pulse, no allocs).
const pulse = 0.55 + 0.35 * Math.sin(this.t * 2.1);
for (const p of this.portals) p.mat.emissiveIntensity = pulse;
const pl = this.player;
if (!pl) return;
const [x, y, z] = pl.position;
// ── Dive prompts: near a portal stand → "DIVE INTO <NAME> (E)" ──
for (const p of this.portals) {
const d = WORLD_DEFS[p.key];
const dx = x - d.portalStand[0], dz = z - d.portalStand[2];
const near = dx * dx + dz * dz <= PROMPT_RADIUS * PROMPT_RADIUS
&& Math.abs(y - d.portalStand[1]) < 4;
if (near) {
p.promptAt -= dt;
if (!p.inZone || p.promptAt <= 0) {
p.inZone = true;
p.promptAt = PROMPT_REPEAT_S;
bus.emit('workshop:msg', { text: `DIVE INTO ${d.name} (E)` });
}
} else if (p.inZone) {
p.inZone = false;
p.promptAt = 0;
}
}
if (this.cooldown > 0) return;
// ── Exit pads: STANDING on one returns you to the crate. Two guards keep
// quest traffic from being yanked out (the disco grid's far row overlaps
// the pad's street): a 0.5 s dwell, and a speed gate — dwell accumulates
// only while near-stationary, so striding across the pad mid-round never
// counts, while deliberately standing on it still feels quick.
let onPad: WorldKey | null = null;
for (const k of WORLD_KEYS) {
const e = WORLD_DEFS[k].exitPad;
// +0.5: the 3×3 gold pad is voxels e1..e+1, so its world centre is at
// e+0.5 (Lane W buildExitPad).
if (
Math.abs(x - (e[0] + 0.5)) <= 1.3 && Math.abs(z - (e[2] + 0.5)) <= 1.3 &&
y >= e[1] - 0.6 && y <= e[1] + 3.2
) { onPad = k; break; }
}
if (onPad) {
const speed = Math.hypot(x - this.padPrevX, z - this.padPrevZ) / Math.max(dt, 1e-6);
this.padPrevX = x; this.padPrevZ = z;
if (speed < 2.5) this.padDwell += dt; else this.padDwell = 0;
if (this.padDwell >= 0.5) {
this.padDwell = 0;
this.leave(onPad, WORLD_DEFS[onPad].returnPos, 'BACK AT THE CRATE');
return;
}
} else {
this.padDwell = 0;
this.padPrevX = x; this.padPrevZ = z;
}
// ── Out-of-pocket safety (throttled; skipped in fly mode so dev flight
// isn't yanked around). Two nets: (1) MEMBERSHIP — a player who dove
// into a pocket and is no longer inside its bounds left through a hole
// (mined floor, anything) at ANY altitude: count it as a proper exit so
// quests/audio unwind instead of running zombie back in the booth.
// (2) ALTITUDE — anyone above the booth walls outside every pocket
// (never dove, e.g. built up there) gets spat home. ──
this.safetyTimer -= dt;
if (this.safetyTimer > 0) return;
this.safetyTimer = 0.25;
if (playerFlying(pl)) return;
if (this.inWorld !== null && !insidePocket(this.inWorld, x, y, z, 2)) {
this.leave(this.inWorld, WORLD_DEFS[this.inWorld].returnPos, 'THE RECORD SPAT YOU OUT');
return;
}
if (this.inWorld === null && y > POCKET_SAFETY_Y && pocketAt(x, y, z) === null) {
const k = nearestWorld(x, z);
this.leave(k, WORLD_DEFS[k].returnPos, 'THE DARK ABOVE THE BOOTH SPAT YOU OUT');
}
}
private leave(k: WorldKey, to: Vec3, msg: string): void {
this.cooldown = TELEPORT_COOLDOWN_S;
this.inWorld = null;
bus.emit('world:exit', { world: k });
interactAt('portals', 'portal_exit');
teleportPlayer(this.player, to);
bus.emit('workshop:msg', { text: msg });
}
}

View File

@ -0,0 +1,58 @@
// LANE M — the stamp store (SIDEB_MACHINES M3).
// A stamp is pressed into your copy of a record when you complete its world's
// quest. Stamps are the ONLY thing the crate worlds sync (global, like
// repairs): quests call apply() on a local win; NetClient forwards the
// resulting `stamp:got` to the relay and calls apply() for remote stamps and
// the join snapshot. apply() is idempotent — a replayed broadcast, a reconnect
// hello, or a re-won quest can never double-count or re-fire events.
//
// Stamps deliberately do NOT listen to `quest:reset`: they're pressed into the
// records, not the booth, so the reset ritual leaves them alone (brief M5).
import { STAMP_TOTAL, WORLD_DEFS, WORLD_KEYS, type WorldKey } from '../../core/worlds';
import { bus } from '../../core/events';
export class Stamps {
private readonly got = new Set<WorldKey>();
/** Validate an arbitrary wire value into a WorldKey (default-closed). */
static coerceKey(v: unknown): WorldKey | null {
return typeof v === 'string' && (WORLD_KEYS as readonly string[]).includes(v)
? (v as WorldKey) : null;
}
has(w: WorldKey): boolean { return this.got.has(w); }
count(): number { return this.got.size; }
all(): boolean { return this.got.size >= STAMP_TOTAL; }
list(): WorldKey[] { return WORLD_KEYS.filter((k) => this.got.has(k)); }
/**
* Add a stamp and emit `stamp:got` (+ `stamp:all` when the third lands),
* tagged with WHO caused it so consumers celebrate proportionately:
* 'local' = this player just won the quest (full glory), 'remote' = a peer
* won it live (share it, third person), 'replay' = join-snapshot state
* (apply silently no toasts, no confetti, no audio). Returns whether
* state changed; false = already stamped, nothing emitted.
*/
apply(w: WorldKey, origin: 'local' | 'remote' | 'replay' = 'local'): boolean {
if (this.got.has(w)) return false;
this.got.add(w);
bus.emit('stamp:got', { world: w, count: this.got.size, total: STAMP_TOTAL, origin });
if (origin === 'local') {
bus.emit('workshop:msg', {
text: `STAMP PRESSED — ${WORLD_DEFS[w].name} (${this.got.size}/${STAMP_TOTAL})`,
});
} else if (origin === 'remote') {
bus.emit('workshop:msg', {
text: `A STAMP LANDED ACROSS THE BOOTH — ${WORLD_DEFS[w].name} (${this.got.size}/${STAMP_TOTAL})`,
});
}
if (this.got.size === STAMP_TOTAL) {
bus.emit('stamp:all', { origin });
if (origin !== 'replay') {
bus.emit('workshop:msg', { text: 'ALL THREE STAMPS — THE GOLDEN SLIPMAT RISES' });
}
}
return true;
}
}

View File

@ -90,6 +90,7 @@ scene.add(interaction.getObject3D());
// starts only from the HUD's start-splash click (autoplay policy). // starts only from the HUD's start-splash click (autoplay policy).
const audio = new AudioEngine(); const audio = new AudioEngine();
const fx = new FxSystem({ scene, setEmissiveBoost, getLevels: () => audio.getLevels() }); const fx = new FxSystem({ scene, setEmissiveBoost, getLevels: () => audio.getLevels() });
fx.attachAmbience(world, player); // SIDE B: dust bunnies + portal iris anchor + stamp FX
// Broken-node spark beacons: each unrepaired quest fixture crackles — the // Broken-node spark beacons: each unrepaired quest fixture crackles — the
// world itself points at what's broken (first-five-minutes pass). // world itself points at what's broken (first-five-minutes pass).

View File

@ -12,7 +12,7 @@ import { AIR } from '../core/blocks';
import { bus } from '../core/events'; import { bus } from '../core/events';
import type { SignalNode } from '../core/constants'; import type { SignalNode } from '../core/constants';
import type { IPlayerView, IVoxelWorld, Vec3 } from '../core/types'; import type { IPlayerView, IVoxelWorld, Vec3 } from '../core/types';
import type { MachineSet, Platter } from '../machines'; import { Stamps, type MachineSet, type Platter } from '../machines';
import { Avatars } from './Avatars'; import { Avatars } from './Avatars';
import { validLook, DEFAULT_LOOK, type AvatarLook } from './avatarLook'; import { validLook, DEFAULT_LOOK, type AvatarLook } from './avatarLook';
@ -147,6 +147,14 @@ export class NetClient {
this.unsubs.push(this.o.machines.workshop.onChange((s) => { this.unsubs.push(this.o.machines.workshop.onChange((s) => {
if (!this.applyingRemote) this.send({ t: 'workshop', s }); if (!this.applyingRemote) this.send({ t: 'workshop', s });
})); }));
// SIDE B stamps (global, like repairs). Stamps.apply emits stamp:got for
// local wins AND remote/join applies; the guard keeps only local wins
// going out. w is re-validated so a spoofed bus event can't ride the wire.
this.unsubs.push(bus.on('stamp:got', ({ world }) => {
if (this.applyingRemote) return;
const w = Stamps.coerceKey(world);
if (w) this.send({ t: 'stamp', w });
}));
} }
private connect(): void { private connect(): void {
@ -213,6 +221,21 @@ export class NetClient {
if (s && pl) { pl.setSpeed(s.rpm); pl.setPlaying(s.playing); } if (s && pl) { pl.setSpeed(s.rpm); pl.setPlaying(s.playing); }
} }
if (m.state?.workshop) this.o.machines.workshop.setState(m.state.workshop); if (m.state?.workshop) this.o.machines.workshop.setState(m.state.workshop);
// Stamps from the join snapshot: idempotent, origin 'replay' (events
// still fire so the tray/golden state seeds, but consumers celebrate
// nothing — no wall-clock grace window needed).
const snapshot = new Set<string>();
for (const wv of m.state?.stamps ?? []) {
const w = Stamps.coerceKey(wv);
if (w) { snapshot.add(w); this.o.machines.stamps.apply(w, 'replay'); }
}
// Re-upload stamps the server is missing (won while the socket was
// down: the original send() dropped on a closed ws, and idempotent
// apply() means the stamp:got that triggers a send can never re-fire
// — without this, an offline-won stamp would be lost forever).
for (const w of this.o.machines.stamps.list()) {
if (!snapshot.has(w)) this.send({ t: 'stamp', w });
}
}); });
this.o.onStatus?.(this.peerCount, true); this.o.onStatus?.(this.peerCount, true);
break; break;
@ -283,6 +306,15 @@ export class NetClient {
case 'workshop': case 'workshop':
this.applyRemote(() => { this.o.machines.workshop.setState(m.s); }); this.applyRemote(() => { this.o.machines.workshop.setState(m.s); });
break; break;
case 'stamp': {
// A peer pressed a stamp live. Validated default-closed; apply is
// idempotent so replays/races can't double-count. Origin 'remote' —
// consumers share the moment third-person (crate-anchored confetti,
// "ACROSS THE BOOTH" toast) instead of celebrating at the local player.
const w = Stamps.coerceKey(m.w);
if (w) this.applyRemote(() => { this.o.machines.stamps.apply(w, 'remote'); });
break;
}
case 'full': case 'full':
console.warn('[net] booth is full — playing solo'); console.warn('[net] booth is full — playing solo');
this.dispose(); this.dispose();

View File

@ -47,3 +47,13 @@ adapt Lane D's hotbar to this shape).
- `workshop:msg {text}` → the existing subtitle line. Lane D sends the authored - `workshop:msg {text}` → the existing subtitle line. Lane D sends the authored
fault labels (`ERROR_LABEL` in `machines/workshop/types.ts`), so the HUD does fault labels (`ERROR_LABEL` in `machines/workshop/types.ts`), so the HUD does
not duplicate that text. not duplicate that text.
## SIDE B — stamp tray (Lane S; see docs/SIDEB_S_handoff.md)
`Hud` self-wires two more reactions (Lane M emits): a tray of three
record-stamp dots top-right under the signal lamps (`WORLD_KEYS` order,
`WORLD_DEFS` accent colours). `stamp:got` fills + glows the world's dot and
scale-pulses it; `stamp:all` turns all rings gold. Stamp events within 4 s of
construction are treated as join-snapshot replays: dots light silently (no
pulse) so a joiner's HUD seeds without a celebration. No new public API; no
subtitle (Lane M sends its own `workshop:msg` line on stamping).

View File

@ -9,6 +9,7 @@
import { bus } from '../core/events'; import { bus } from '../core/events';
import { SIGNAL_NODES, type SignalNode } from '../core/constants'; import { SIGNAL_NODES, type SignalNode } from '../core/constants';
import { blockDef, type BlockId } from '../core/blocks'; import { blockDef, type BlockId } from '../core/blocks';
import { WORLD_DEFS, WORLD_KEYS } from '../core/worlds';
export interface HotbarSlot { id: BlockId; count: number; } export interface HotbarSlot { id: BlockId; count: number; }
export interface HotbarModel { slots: HotbarSlot[]; active: number; } export interface HotbarModel { slots: HotbarSlot[]; active: number; }
@ -48,6 +49,17 @@ const CSS = `
.tc-node.lit .tc-dot { box-shadow:0 0 10px 2px currentColor; } .tc-node.lit .tc-dot { box-shadow:0 0 10px 2px currentColor; }
.tc-nlabel { font-size:9px; letter-spacing:.06em; color:#9aa; } .tc-nlabel { font-size:9px; letter-spacing:.06em; color:#9aa; }
.tc-progress { position:absolute; top:44px; right:14px; font-size:11px; color:#7a8; letter-spacing:.1em; } .tc-progress { position:absolute; top:44px; right:14px; font-size:11px; color:#7a8; letter-spacing:.1em; }
.tc-stamps { position:absolute; top:64px; right:14px; display:flex; gap:9px; }
.tc-stamp { width:15px; height:15px; border-radius:50%; border:2px solid #3a3a3e;
position:relative; opacity:.5; background:transparent;
transition:opacity .3s, background .3s, border-color .3s, box-shadow .3s; }
.tc-stamp::after { content:""; position:absolute; left:50%; top:50%; width:3px; height:3px;
margin:-1.5px 0 0 -1.5px; border-radius:50%; background:#101012; }
.tc-stamp.got { opacity:1; background:currentColor; border-color:currentColor;
box-shadow:0 0 10px 2px currentColor; }
.tc-stamp.pulse { animation:tcstamp .55s cubic-bezier(.2,1.6,.4,1); }
@keyframes tcstamp { 0% { transform:scale(1); } 40% { transform:scale(1.75); } 100% { transform:scale(1); } }
.tc-stamps.all .tc-stamp { border-color:#ffd24a; box-shadow:0 0 12px 3px rgba(255,210,74,.85); }
.tc-sub { position:absolute; left:50%; bottom:120px; transform:translateX(-50%); .tc-sub { position:absolute; left:50%; bottom:120px; transform:translateX(-50%);
font-size:15px; letter-spacing:.14em; color:#ffb028; text-shadow:0 0 12px rgba(255,176,40,.5); font-size:15px; letter-spacing:.14em; color:#ffb028; text-shadow:0 0 12px rgba(255,176,40,.5);
opacity:0; transition:opacity .4s; white-space:nowrap; } opacity:0; transition:opacity .4s; white-space:nowrap; }
@ -95,6 +107,9 @@ export class Hud {
private slots: HTMLDivElement[] = []; private slots: HTMLDivElement[] = [];
private nodeEls: Record<string, HTMLDivElement> = {}; private nodeEls: Record<string, HTMLDivElement> = {};
private progressEl!: HTMLDivElement; private progressEl!: HTMLDivElement;
private stampsEl!: HTMLDivElement;
private stampEls: Record<string, HTMLDivElement> = {};
private bornAt = performance.now(); // gates join-replay stamp events (no pulse)
private subEl!: HTMLDivElement; private subEl!: HTMLDivElement;
private torqueEl!: HTMLDivElement; private torqueEl!: HTMLDivElement;
private torqueVal!: HTMLDivElement; private torqueVal!: HTMLDivElement;
@ -116,6 +131,7 @@ export class Hud {
this.el.className = 'tc-hud'; this.el.className = 'tc-hud';
this.buildCrosshair(); this.buildCrosshair();
this.buildQuest(); this.buildQuest();
this.buildStamps();
this.buildSubtitle(); this.buildSubtitle();
this.buildTorque(); this.buildTorque();
this.buildHotbar(); this.buildHotbar();
@ -224,6 +240,23 @@ export class Hud {
this.el.appendChild(this.progressEl); this.el.appendChild(this.progressEl);
} }
// SIDE B (Lane S): the stamp tray — three record-stamp dots top-right under
// the signal lamps, one per crate world (accent-coloured when got, dim ring
// when not; gold rings once all three are pressed).
private buildStamps(): void {
this.stampsEl = document.createElement('div');
this.stampsEl.className = 'tc-stamps';
for (const k of WORLD_KEYS) {
const a = WORLD_DEFS[k].accent;
const dot = document.createElement('div');
dot.className = 'tc-stamp';
dot.style.color = `rgb(${a[0]},${a[1]},${a[2]})`; // currentColor drives .got
this.stampsEl.appendChild(dot);
this.stampEls[k] = dot;
}
this.el.appendChild(this.stampsEl);
}
private buildSubtitle(): void { private buildSubtitle(): void {
this.subEl = document.createElement('div'); this.subEl = document.createElement('div');
this.subEl.className = 'tc-sub'; this.subEl.className = 'tc-sub';
@ -340,5 +373,30 @@ export class Hud {
else this.showTorque(p.value); else this.showTorque(p.value);
})); }));
this.disposers.push(bus.on('workshop:msg', (p) => this.showSubtitle(p.text))); this.disposers.push(bus.on('workshop:msg', (p) => this.showSubtitle(p.text)));
// SIDE B (Lane S): stamp tray. The event's origin says whether this is a
// live win ('local'/'remote' → scale-pulse the dot) or join-snapshot
// replay ('replay' → light silently) — no wall-clock guessing.
this.disposers.push(bus.on('stamp:got', (p) => {
const dot = this.stampEls[p.world];
if (!dot) return;
dot.classList.add('got');
if (p.origin !== 'replay') {
dot.classList.remove('pulse');
void dot.offsetWidth; // restart the animation on repeat stamps
dot.classList.add('pulse');
}
}));
this.disposers.push(bus.on('stamp:all', (p) => {
this.stampsEl.classList.add('all');
if (p.origin !== 'replay') {
for (const k of WORLD_KEYS) {
const dot = this.stampEls[k];
dot.classList.remove('pulse');
void dot.offsetWidth;
dot.classList.add('pulse');
}
}
}));
} }
} }

View File

@ -20,6 +20,7 @@ import {
import { box, hollowBox, cylinderY, sphere, spline, mulberry32, randInt, setVox } from './tools'; import { box, hollowBox, cylinderY, sphere, spline, mulberry32, randInt, setVox } from './tools';
import { DECK_A, DECK_B, MIXER, UNDER, PATCH, RCA_PLUG, type DeckAnchors, type DeckFootprint } from './anchors'; import { DECK_A, DECK_B, MIXER, UNDER, PATCH, RCA_PLUG, type DeckAnchors, type DeckFootprint } from './anchors';
import { QUEST_POS } from './questPositions'; import { QUEST_POS } from './questPositions';
import { buildWorlds } from './worlds';
// ---- block palette (resolve names -> ids once) ----------------------------- // ---- block palette (resolve names -> ids once) -----------------------------
@ -570,6 +571,10 @@ export function buildBooth(w: IVoxelWorld): void {
buildPatchBay(w, rng); buildPatchBay(w, rng);
buildUnderTable(w, rng); buildUnderTable(w, rng);
buildDressing(w, rng); buildDressing(w, rng);
// SIDE B: portal rings at the crate + the three sealed crate-world pockets
// above the ceiling. Derives its own seeded streams — `rng` is untouched, so
// every zone above stays byte-identical (see worlds/index.ts).
buildWorlds(w, WORLD_SEED);
} }
// Re-export the quest contract + anchors so integrators have one import site. // Re-export the quest contract + anchors so integrators have one import site.

View File

@ -0,0 +1,74 @@
// TURNCRAFT — SIDE B Lane W: ACID WAREHOUSE (pocket x 20..84, z 20..84).
// Raw concrete room: steel/alu slab floor with rubber expansion seams,
// breeze-block ply_edge walls (shadow slots show the black skin), four square
// pillars, a strobe rail on the ceiling, and three led_green cable runs that
// snake down the west wall and across the floor to the three knob pedestals
// (3×3×3 matte_black plinths at x 52, z 44/52/60 — Lane M mounts the 303
// knobs here; the 1-voxel green pip sits top-centre of each).
// Fully index-patterned — no rng — so it is trivially byte-stable.
import type { IVoxelWorld } from '../../core/types';
import { WORLD_DEFS } from '../../core/worlds';
import { box, setVox } from '../tools';
import { buildExitPad, buildSkin, frame, inlayPath, P, paintFloor, paintWalls } from './common';
/** Pedestal line (brief + Lane M's SIDEB_MACHINES M2): x=52, z=44/52/60. */
export const ACID_PEDESTAL_X = WORLD_DEFS.acid.entry[0]; // 52
export const ACID_PEDESTAL_Z = [44, 52, 60] as const;
/** Plinth: 3×3 footprint, y 144..146 (top face 147); pip led_green at (52,147,z). */
export const ACID_PEDESTAL_TOP_Y = 147;
export function buildAcid(w: IVoxelWorld): void {
const d = WORLD_DEFS.acid;
const fr = frame(d);
buildSkin(w, fr);
// Concrete floor: 8×8 slabs, mostly steel_grey with scattered brushed_alu
// plates, separated by flush rubber expansion seams.
paintFloor(w, fr, (x, z) => {
const lx = x - fr.ix0, lz = z - fr.iz0;
if (lx % 8 === 0 || lz % 8 === 0) return P.rubber;
const sx = (lx / 8) | 0, sz = (lz / 8) | 0;
return (sx * 7 + sz * 3) % 5 === 1 ? P.alu : P.steel;
});
// Breeze-block wall rhythm: ply_edge courses with 2-tall shadow slots every
// third column (-1 leaves air so the black skin reads as the hole).
paintWalls(w, fr, (_face, u, y) => {
const row = y - (fr.floorY + 1); // 0..13
if (u % 3 === 1 && row % 4 >= 2 && row < 12) return -1;
return P.plyEdge;
});
// Four concrete pillars, floor to ceiling, with a brushed collar band.
for (const px of [33, 69]) {
for (const pz of [35, 67]) {
box(w, px, fr.floorY + 1, pz, px + 1, fr.topY, pz + 1, P.steel);
box(w, px, 150, pz, px + 1, 150, pz + 1, P.alu);
}
}
// Three knob pedestals + pips (Lane M's hold-E colliders target these).
const bx = ACID_PEDESTAL_X;
for (const pz of ACID_PEDESTAL_Z) {
box(w, bx - 1, fr.floorY + 1, pz - 1, bx + 1, fr.floorY + 3, pz + 1, P.black);
setVox(w, bx, ACID_PEDESTAL_TOP_Y, pz, P.ledG);
}
// led_green cable runs: down the west wall (x=21 lining), then flush floor
// inlays snaking to each pedestal's base. One run per pedestal.
const runs: Array<{ wallZ: number; path: Array<[number, number]> }> = [
{ wallZ: 38, path: [[22, 38], [30, 38], [30, 46], [42, 46], [42, 44], [50, 44]] },
{ wallZ: 46, path: [[22, 46], [26, 46], [26, 54], [38, 54], [38, 52], [50, 52]] },
{ wallZ: 62, path: [[22, 62], [34, 62], [34, 58], [44, 58], [44, 60], [50, 60]] },
];
for (const r of runs) {
for (let y = fr.floorY + 1; y <= fr.floorY + 6; y++) setVox(w, fr.ix0, y, r.wallZ, P.ledG);
inlayPath(w, r.path, fr.floorY, P.ledG);
}
// Strobe rail: dashed strobe_dot line on the ceiling over the pedestal axis.
for (let z = 28; z <= 76; z += 3) setVox(w, bx, fr.topY, z, P.strobe);
buildExitPad(w, d);
}

View File

@ -0,0 +1,112 @@
// TURNCRAFT — SIDE B Lane W shared pocket toolkit (docs/briefs/SIDEB_WORLDS.md).
// The three crate worlds are sealed rooms floating in the dark above the booth
// ceiling, at the WORLD_DEFS bounds. Everything here enforces the two laws the
// brief cares about most:
// 1. the OUTER skin of every pocket is 1 voxel of matte_black on all six
// faces — no hole, no emissive, no transparent block ever on that skin;
// 2. every themed/emissive voxel lives strictly INSIDE the skin.
// Elevation model (matches the contract): pocketMin.y=142 is the shell floor
// layer; the themed floor lining sits at 143 (= exitPad.y), so feet stand at
// 144 (= entry.y); fixtures hang at 157; pocketMax.y=158 is the shell roof.
import { AIR, BLOCK_BY_NAME } from '../../core/blocks';
import type { IVoxelWorld } from '../../core/types';
import type { WorldDef, WorldKey } from '../../core/worlds';
import { hollowBox, setVox } from '../tools';
function bid(name: string): number {
const d = BLOCK_BY_NAME.get(name);
if (!d) throw new Error(`worldgen/worlds: unknown block '${name}'`);
return d.id;
}
/** Pocket palette (existing registry ids only — no new blocks, 31 stays reserved). */
export const P = {
air: AIR,
ply: bid('plywood'), plyEdge: bid('ply_edge'),
alu: bid('brushed_alu'), steel: bid('steel_grey'), black: bid('matte_black'),
rubber: bid('rubber'), vinyl: bid('vinyl_black'), label: bid('label_cream'),
chrome: bid('chrome'), mesh: bid('speaker_mesh'), glass: bid('glass'),
ledR: bid('led_red'), ledG: bid('led_green'), ledA: bid('led_amber'), ledB: bid('led_blue'),
strobe: bid('strobe_dot'), gold: bid('rca_gold'),
};
/** Accent → nearest existing led block (brief W1: acid=green, dub=amber, disco=blue). */
export const ACCENT_LED: Record<WorldKey, number> = {
acid: P.ledG, dub: P.ledA, disco: P.ledB,
};
/** Derived working coordinates of one pocket. */
export interface Frame {
x0: number; y0: number; z0: number; x1: number; y1: number; z1: number; // shell, inclusive
ix0: number; ix1: number; iz0: number; iz1: number; // wall-lining planes (1 inside the skin)
floorY: number; // themed floor lining layer (y0+1 = 143 = exitPad.y)
topY: number; // fixture layer under the roof skin (y1-1 = 157)
}
export function frame(d: WorldDef): Frame {
const [x0, y0, z0] = d.pocketMin;
const [x1, y1, z1] = d.pocketMax;
return {
x0, y0, z0, x1, y1, z1,
ix0: x0 + 1, ix1: x1 - 1, iz0: z0 + 1, iz1: z1 - 1,
floorY: y0 + 1, topY: y1 - 1,
};
}
/** The sealed 1-voxel matte_black skin. Interiors above y=141 are virgin air. */
export function buildSkin(w: IVoxelWorld, fr: Frame): void {
hollowBox(w, fr.x0, fr.y0, fr.z0, fr.x1, fr.y1, fr.z1, P.black, 1);
}
/** Paint the floor lining (y=143) from a per-voxel picker. */
export function paintFloor(w: IVoxelWorld, fr: Frame, pick: (x: number, z: number) => number): void {
for (let x = fr.ix0; x <= fr.ix1; x++)
for (let z = fr.iz0; z <= fr.iz1; z++)
setVox(w, x, fr.floorY, z, pick(x, z));
}
/**
* Paint the four interior wall-lining planes (1 voxel inside the skin),
* y 144..157. `pick` returns a block id or -1 to leave the voxel as-is (the
* black skin shows through used for breeze-block shadow slots).
* face: 0 = west (x=ix0), 1 = east (x=ix1), 2 = front (z=iz0), 3 = back (z=iz1).
* u is the along-wall coordinate (z on x-walls, x on z-walls).
*/
export function paintWalls(
w: IVoxelWorld, fr: Frame,
pick: (face: 0 | 1 | 2 | 3, u: number, y: number) => number,
): void {
const yLo = fr.floorY + 1, yHi = fr.topY;
for (let y = yLo; y <= yHi; y++) {
for (let z = fr.iz0; z <= fr.iz1; z++) {
const a = pick(0, z, y); if (a >= 0) setVox(w, fr.ix0, y, z, a);
const b = pick(1, z, y); if (b >= 0) setVox(w, fr.ix1, y, z, b);
}
for (let x = fr.ix0; x <= fr.ix1; x++) {
const c = pick(2, x, y); if (c >= 0) setVox(w, x, y, fr.iz0, c);
const d = pick(3, x, y); if (d >= 0) setVox(w, x, y, fr.iz1, d);
}
}
}
/** 3×3 rca_gold exit pad flush in the floor lining + one accent led pip (centre). */
export function buildExitPad(w: IVoxelWorld, d: WorldDef): void {
const [ex, ey, ez] = d.exitPad;
for (let dx = -1; dx <= 1; dx++)
for (let dz = -1; dz <= 1; dz++)
setVox(w, ex + dx, ey, ez + dz, P.gold);
setVox(w, ex, ey, ez, ACCENT_LED[d.key]);
}
/** Flush 1-voxel inlay path along Manhattan segments through `pts` at height y. */
export function inlayPath(w: IVoxelWorld, pts: Array<[number, number]>, y: number, id: number): void {
if (pts.length === 0) return;
setVox(w, pts[0][0], y, pts[0][1], id);
for (let i = 1; i < pts.length; i++) {
let [ax, az] = pts[i - 1];
const [bx, bz] = pts[i];
while (ax !== bx) { ax += Math.sign(bx - ax); setVox(w, ax, y, az, id); }
while (az !== bz) { az += Math.sign(bz - az); setVox(w, ax, y, az, id); }
}
}

View File

@ -0,0 +1,74 @@
// TURNCRAFT — SIDE B Lane W: DISCO LOFT (pocket x 364..428, z 20..84).
// Warm loft: plywood/ply_edge herringbone parquet, plywood walls with a
// ply_edge skirting course and vertical led_red/led_blue wall-wash strips, a
// chrome+glass mirrorball hanging over the dance floor, and the 4×4 grid of
// 5×5 flush dance tiles (Lane M's step-sequencer quest: their colliders detect
// standing-on-tile; Lane S flashes them on 'disco_cue').
// Tile grid per brief "centred on [396, 143, 52..76]": tile CENTRES at
// x 396±4/±12 and z 52/60/68/76 (pitch 8 — a 3-voxel parquet gap between
// tiles), all flush at y 143. Fully index-patterned — no rng.
import type { IVoxelWorld } from '../../core/types';
import { WORLD_DEFS } from '../../core/worlds';
import { setVox } from '../tools';
import { buildExitPad, buildSkin, frame, P, paintFloor, paintWalls } from './common';
/** Dance-tile centres (16 tiles, each 5×5 flush at y 143). */
export const DISCO_TILE_CX = [-12, -4, 4, 12].map((o) => WORLD_DEFS.disco.entry[0] + o); // 384,392,400,408
export const DISCO_TILE_CZ = [52, 60, 68, 76] as const;
export const DISCO_TILE_Y = 143;
/** Mirrorball centre (chrome/glass checker sphere r3, chain to the roof). */
export const DISCO_BALL = { cx: WORLD_DEFS.disco.entry[0], cy: 152, cz: 64 } as const;
export function buildDisco(w: IVoxelWorld): void {
const d = WORLD_DEFS.disco;
const fr = frame(d);
buildSkin(w, fr);
// Herringbone parquet: two-wide diagonal weave of plywood / ply_edge.
paintFloor(w, fr, (x, z) => (((x + z) & 2) ^ ((x - z + 256) & 2)) ? P.ply : P.plyEdge);
// Plywood walls with a ply_edge skirting course at floor+1.
paintWalls(w, fr, (_face, _u, y) => (y === fr.floorY + 1 ? P.plyEdge : P.ply));
// Vertical wall-wash strips (y 146..151), alternating red/blue around the room.
const strip = (x: number, z: number, idx: number) => {
const id = (idx & 1) ? P.ledB : P.ledR;
for (let y = 146; y <= 151; y++) setVox(w, x, y, z, id);
};
([28, 42, 56, 70] as const).forEach((z, i) => { strip(fr.ix0, z, i); strip(fr.ix1, z, i + 1); });
([376, 386, 406, 416] as const).forEach((x, i) => { strip(x, fr.iz0, i + 1); strip(x, fr.iz1, i); });
// Dance tiles: border ring alternates label_cream/rubber per tile (checker),
// field is the opposite, centre voxel is a led (red/blue checker) Lane S lights.
for (let i = 0; i < DISCO_TILE_CX.length; i++) {
for (let j = 0; j < DISCO_TILE_CZ.length; j++) {
const cx = DISCO_TILE_CX[i], cz = DISCO_TILE_CZ[j];
const chk = (i + j) & 1;
const border = chk ? P.rubber : P.label;
const field = chk ? P.label : P.rubber;
for (let dx = -2; dx <= 2; dx++) {
for (let dz = -2; dz <= 2; dz++) {
const edge = Math.abs(dx) === 2 || Math.abs(dz) === 2;
setVox(w, cx + dx, DISCO_TILE_Y, cz + dz, edge ? border : field);
}
}
setVox(w, cx, DISCO_TILE_Y, cz, chk ? P.ledR : P.ledB);
}
}
// Mirrorball: r-3 chrome/glass checker sphere on a 2-voxel chrome chain.
const { cx, cy, cz } = DISCO_BALL;
for (let dx = -3; dx <= 3; dx++) {
for (let dy = -3; dy <= 3; dy++) {
for (let dz = -3; dz <= 3; dz++) {
if (dx * dx + dy * dy + dz * dz > 10) continue;
setVox(w, cx + dx, cy + dy, cz + dz, ((dx + dy + dz) & 1) ? P.chrome : P.glass);
}
}
}
setVox(w, cx, 156, cz, P.chrome);
setVox(w, cx, 157, cz, P.chrome);
buildExitPad(w, d);
}

View File

@ -0,0 +1,98 @@
// TURNCRAFT — SIDE B Lane W: DUB CHAMBER (pocket x 192..256, z 20..84).
// Deep space inside a spring-reverb tank. vinyl_black everywhere with sparse
// led_amber embers; two raised side shelves make the central x 210..238 band a
// sunken canyon ("floor drops 4": shelf top face 148 → canyon floor 144); in
// the canyon's middle a tank pit is cut to the shell (surface 143) holding two
// chrome reverb-spring coils. The static spring-tank bridge — chrome, 3 wide,
// 24 long along z at x 223..225, deck y 146 (walk surface 147) — crosses the
// pit rim-to-rim with 1-voxel stairs at both ends; Lane M overlays its wobbling
// kinematic deck on top. The tank mouth is the rca_gold inlay mid-deck. The
// horn-speaker cone sits on the far wall EAST of the exit axis (x 232) so
// reaching it never forces a walk across the exit pad.
import type { IVoxelWorld, Vec3 } from '../../core/types';
import { WORLD_DEFS } from '../../core/worlds';
import { box, mulberry32, randInt, setVox } from '../tools';
import { buildExitPad, buildSkin, frame, P, paintFloor, paintWalls } from './common';
/** Static bridge deck (Lane M overlays the moving collider on this). */
export const DUB_BRIDGE = {
x0: 223, x1: 225, deckY: 146, z0: 40, z1: 63,
/** rca_gold inlay in the deck — the "feed the spring" drop spot. */
mouth: [224, 146, 52] as Vec3,
} as const;
/** Tank pit (floor carved to the shell, walk surface 143). */
export const DUB_PIT = { x0: 212, x1: 236, z0: 41, z1: 62 } as const;
/** Horn cone centre on the back wall (throat at z 83, mouth flaring to z 80). */
export const DUB_HORN = { cx: 232, cy: 151, throatZ: 83, mouthZ: 80 } as const;
export function buildDub(w: IVoxelWorld, seed: number): void {
const rng = mulberry32((seed ^ 0x0d0b) >>> 0);
const d = WORLD_DEFS.dub;
const fr = frame(d);
buildSkin(w, fr);
// Floor + walls: vinyl_black (the grooves pattern reads as pressed wax).
paintFloor(w, fr, () => P.vinyl);
// Sparse amber embers glowing in the wall lining (~30 across the room).
paintWalls(w, fr, () => (rng() < 0.008 ? P.ledA : P.vinyl));
// Raised side shelves: canyon walls for the x 210..238 sunken band.
box(w, fr.ix0, fr.floorY + 1, fr.iz0, 209, fr.floorY + 4, fr.iz1, P.vinyl);
box(w, 239, fr.floorY + 1, fr.iz0, fr.ix1, fr.floorY + 4, fr.iz1, P.vinyl);
// A few embers set into the canyon-facing riser faces.
for (const rx of [209, 239]) {
for (let z = fr.iz0 + 2; z <= fr.iz1 - 2; z++) {
if (rng() < 0.09) setVox(w, rx, fr.floorY + 1 + randInt(rng, 0, 3), z, P.ledA);
}
}
// Speaker stacks up on the shelves (soundsystem silhouettes against the dark).
for (const sx of [201, 247]) {
box(w, sx - 1, fr.floorY + 5, 34, sx + 1, fr.floorY + 7, 36, P.mesh);
box(w, sx - 1, fr.floorY + 5, 66, sx + 1, fr.floorY + 7, 68, P.mesh);
}
// Tank pit: carve the floor lining to the black shell (walk surface 143 —
// one step below the canyon, four below the bridge deck; always escapable).
box(w, DUB_PIT.x0, fr.floorY, DUB_PIT.z0, DUB_PIT.x1, fr.floorY, DUB_PIT.z1, P.air);
// Two chrome reverb-spring coils lying along the pit.
for (const [ax, phase] of [[217, 0], [231, 1.7]] as const) {
for (let s = 0; s <= 36; s++) {
const a = s * 0.55 + phase;
const z = DUB_PIT.z0 + 2 + ((s * 0.5) | 0);
setVox(w, ax + Math.round(2 * Math.cos(a)), 145 + Math.round(2 * Math.sin(a)), z, P.chrome);
}
}
// Gold catch pad on the pit floor directly under the mouth.
box(w, 223, fr.floorY, 51, 225, fr.floorY, 53, P.gold);
// The bridge: stairs up (1-voxel steps), legs seating the deck on both pit
// rims, the 3×24 chrome deck, and the rca_gold tank-mouth inlay mid-span.
box(w, 223, 144, 37, 225, 144, 37, P.chrome);
box(w, 223, 144, 38, 225, 145, 38, P.chrome);
box(w, 223, 144, 39, 225, 146, 39, P.chrome);
box(w, DUB_BRIDGE.x0, 144, DUB_BRIDGE.z0, DUB_BRIDGE.x1, 145, DUB_BRIDGE.z0, P.chrome);
box(w, DUB_BRIDGE.x0, 144, DUB_BRIDGE.z1, DUB_BRIDGE.x1, 145, DUB_BRIDGE.z1, P.chrome);
box(w, DUB_BRIDGE.x0, DUB_BRIDGE.deckY, DUB_BRIDGE.z0, DUB_BRIDGE.x1, DUB_BRIDGE.deckY, DUB_BRIDGE.z1, P.chrome);
setVox(w, DUB_BRIDGE.mouth[0], DUB_BRIDGE.mouth[1], DUB_BRIDGE.mouth[2], P.gold);
box(w, 223, 144, 64, 225, 145, 64, P.chrome);
box(w, 223, 144, 65, 225, 144, 65, P.chrome);
// Horn-speaker cone: stacked speaker_mesh rings telescoping out of the back
// wall, throat plate flush in the lining, amber ember glowing in the throat.
const ring = (z: number, rIn: number, rOut: number) => {
for (let dx = -7; dx <= 7; dx++) {
for (let dy = -7; dy <= 7; dy++) {
const dist = Math.hypot(dx, dy);
if (dist >= rIn && dist <= rOut) setVox(w, DUB_HORN.cx + dx, DUB_HORN.cy + dy, z, P.mesh);
}
}
};
ring(DUB_HORN.throatZ, 0, 3); // solid throat plate in the wall lining
ring(82, 2.5, 3.9);
ring(81, 3.4, 4.9);
ring(DUB_HORN.mouthZ, 4.4, 6.0); // mouth bottom y 145 — floats over the floor
setVox(w, DUB_HORN.cx, DUB_HORN.cy, 82, P.ledA);
buildExitPad(w, d);
}

View File

@ -0,0 +1,36 @@
// TURNCRAFT — SIDE B Lane W: the crate worlds (docs/briefs/SIDEB_WORLDS.md).
// buildWorlds(w, seed) is called ONCE from buildBooth, after every booth zone,
// and builds: the portal dressing at the crate (W1) and the three fully-shelled
// pocket rooms at the WORLD_DEFS bounds (W2) — ACID WAREHOUSE, DUB CHAMBER,
// DISCO LOFT — plus a 3×3 rca_gold exit pad in each.
//
// Seed discipline (same pattern as buildPcbDetail): every builder that wants
// randomness derives its OWN mulberry32 stream from the seed passed in, so the
// main buildBooth PRNG stream is never consumed and every pre-existing booth
// zone stays byte-identical. Math.random appears nowhere.
//
// Sealed-room law: pockets sit on the booth ceiling slab (y 140..141) in the
// dark above the walls. Their 1-voxel matte_black skin is never pierced and no
// emissive/transparent block ever touches it — from the booth (or the cine
// flythrough) they read as, at most, faint black slabs. Entry/exit is by
// teleport only (Lane M portals).
import type { IVoxelWorld } from '../../core/types';
import { buildPortalDressing } from './portalDressing';
import { buildAcid } from './acid';
import { buildDub } from './dub';
import { buildDisco } from './disco';
export function buildWorlds(w: IVoxelWorld, seed: number): void {
buildPortalDressing(w);
buildAcid(w);
buildDub(w, seed);
buildDisco(w);
}
// Feature anchors other lanes / the demo assert against (positions only —
// no lane imports our builders).
export { ACID_PEDESTAL_X, ACID_PEDESTAL_Z, ACID_PEDESTAL_TOP_Y } from './acid';
export { DUB_BRIDGE, DUB_PIT, DUB_HORN } from './dub';
export { DISCO_TILE_CX, DISCO_TILE_CZ, DISCO_TILE_Y, DISCO_BALL } from './disco';
export { ACCENT_LED } from './common';

View File

@ -0,0 +1,39 @@
// TURNCRAFT — SIDE B Lane W (W1): portal dressing at the record crate.
// The three portal records already stand in the crate (buildUnderTable places
// slabs at x = 26+5s, cy 15, cz 31, r 12 — WORLD_DEFS.portalDisc points at the
// s = 1 / 4 / 7 slabs). We add the "these three are special" tell, kept subtle
// so the crate stays dim:
// • a flush 1-voxel accent-led ring inlaid in the crate floor around each
// portalStand (radius 3 — matches Lane S's bunny keep-out of stand ±6);
// • a 3-voxel accent-led marquee riding each portal disc's top rim.
// No geometry moves; every write replaces crate-floor plywood or sits proud on
// existing disc voxels. Deterministic with zero rng.
import type { IVoxelWorld } from '../../core/types';
import { WORLD_DEFS, WORLD_KEYS } from '../../core/worlds';
import { setVox } from '../tools';
import { ACCENT_LED } from './common';
export function buildPortalDressing(w: IVoxelWorld): void {
for (const k of WORLD_KEYS) {
const d = WORLD_DEFS[k];
const led = ACCENT_LED[k];
// Ring inlay: portalStand is the feet voxel; the crate floor block is one
// below. d2 in [7,11] traces a clean 1-wide radius-3 circle (16 voxels).
const [sx, sy, sz] = d.portalStand;
for (let dx = -3; dx <= 3; dx++) {
for (let dz = -3; dz <= 3; dz++) {
const d2 = dx * dx + dz * dz;
if (d2 >= 7 && d2 <= 11) setVox(w, sx + dx, sy - 1, sz + dz, led);
}
}
// Marquee: the disc's crown voxel is at (px, cy+12, cz); its top surface at
// dz ±4 is dy 11. Three pips arc over the rim, each resting on disc vinyl.
const [px, py, pz] = d.portalDisc;
setVox(w, px, py + 13, pz, led);
setVox(w, px, py + 12, pz - 4, led);
setVox(w, px, py + 12, pz + 4, led);
}
}