diff --git a/docs/SIDEB_E2_handoff.md b/docs/SIDEB_E2_handoff.md new file mode 100644 index 0000000..d716986 --- /dev/null +++ b/docs/SIDEB_E2_handoff.md @@ -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 + (110–2600 / 160–3800 / 240–5600 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`, 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.) diff --git a/docs/SIDEB_M_handoff.md b/docs/SIDEB_M_handoff.md new file mode 100644 index 0000000..b240388 --- /dev/null +++ b/docs/SIDEB_M_handoff.md @@ -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): 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_', action:'acid_knob', index}` | ~6/s while a band sweeps | +| `machine:interact {machineId:'acid_', 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_', action:'disco_cue', index}` | sequence playback, one per beat | +| `machine:interact {machineId:'disco_tile_', 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. diff --git a/docs/SIDEB_S_handoff.md b/docs/SIDEB_S_handoff.md new file mode 100644 index 0000000..e06d944 --- /dev/null +++ b/docs/SIDEB_S_handoff.md @@ -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 8–12 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.3–0.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.9–8.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). diff --git a/docs/SIDEB_W_handoff.md b/docs/SIDEB_W_handoff.md new file mode 100644 index 0000000..3b18bf6 --- /dev/null +++ b/docs/SIDEB_W_handoff.md @@ -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 19–29 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 ~760–780 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. diff --git a/server/relay.mjs b/server/relay.mjs index 7c3c2c3..6521704 100644 --- a/server/relay.mjs +++ b/server/relay.mjs @@ -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 AVATAR_RANGES = { hue: 12, gear: 5, face: 4 }; // enum sizes (0..n-1) 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 // would be ~550MB if a bot painted the whole booth). ~400k edits ≈ 25MB. const MAX_EDITS = 400_000; @@ -49,6 +51,9 @@ const edits = new Map(); const repaired = new Set(); const platters = { A: { playing: false, rpm: 33 }, B: { playing: false, rpm: 33 } }; 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; /** ms epoch of the last win — gates the reset cooldown. 0 = "long ago". */ let winAt = 0; @@ -113,6 +118,7 @@ function loadState() { Object.assign(platters.A, s.platters?.A ?? {}); Object.assign(platters.B, s.platters?.B ?? {}); 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 // cooldown is already expired rather than blocking forever. winAt = Number.isFinite(s.winAt) ? s.winAt : 0; @@ -125,11 +131,20 @@ function loadState() { function saveState() { if (!dirty) return; dirty = false; - const tmp = STATE_FILE + '.tmp'; - writeFileSync(tmp, JSON.stringify({ - edits: [...edits.entries()], repaired: [...repaired], platters, workshop, winAt, - })); - renameSync(tmp, STATE_FILE); + // A throwing write (ENOSPC, perms) must neither crash the relay from inside + // a ws message handler nor mark the unsaved state clean — restore `dirty` so + // the periodic saver retries once the disk recovers. + try { + 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 ----------------------------------------------------------- @@ -178,7 +193,7 @@ wss.on('connection', (ws) => { t: 'hello', id, peers: [...peers.entries()].filter(([pid]) => pid !== id) .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; }); @@ -226,6 +241,8 @@ wss.on('connection', (ws) => { winAt = 0; dirty = true; // 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 // joiner would get from `hello.state.workshop`. broadcast({ t: 'reset', by: peer.name, workshop }); // everyone incl. the breaker @@ -272,6 +289,19 @@ wss.on('connection', (ws) => { broadcast({ t: 'workshop', s }, id); 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 } }); diff --git a/src/audio/AudioEngine.ts b/src/audio/AudioEngine.ts index 4d74ee8..4ba6862 100644 --- a/src/audio/AudioEngine.ts +++ b/src/audio/AudioEngine.ts @@ -5,8 +5,16 @@ // constructs it; the public methods exist for the demo and direct control. // // 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 +// 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 { LAYOUT, PLATTER, Y_RECORD_TOP } from '../core/constants'; @@ -15,6 +23,7 @@ import type { IPlayerView, Vec3 } from '../core/types'; import { Groove } from './groove'; import { Transport } from './scheduler'; import { createNoiseBuffer, createCrackleBuffer } from './synth'; +import { WorldGrooves, isPocketWorld, type PocketWorld } from './worldGrooves'; import * as SFX from './sfx'; import { playTestSignal, errorsFromKeys, type TestErrors } from './testSignal'; @@ -47,6 +56,8 @@ export class AudioEngine { // graph nodes private outGain!: GainNode; + private boothGain!: GainNode; // everything the booth plays (ducked in pockets) + private worldBus!: GainNode; // SIDE B pocket grooves private musicGain!: GainNode; private masterLP!: BiquadFilterNode; private sfxGain!: GainNode; @@ -74,6 +85,16 @@ export class AudioEngine { private heartTimer: number | 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(); + private stampAllOn = false; + private lastBunny = -10; + private readonly levels = { low: 0, mid: 0, high: 0 }; constructor() { @@ -111,6 +132,12 @@ export class AudioEngine { this.sfxGain.gain.value = 0.9; 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.type = 'lowpass'; this.masterLP.frequency.value = 20000; // transparent until the win sweep @@ -152,7 +179,7 @@ export class AudioEngine { const dryLP = ctx.createBiquadFilter(); dryLP.type = 'lowpass'; dryLP.frequency.value = 380; 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 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); const gate = ctx.createGain(); 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; } @@ -189,17 +216,33 @@ export class AudioEngine { const lp = ctx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 240; 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.heartNext = ctx.currentTime + 0.5; 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 this.groove.setStemCount(this.stemCount); for (let i = 0; i < this.channel.length; i++) this.groove.setChannelGain(i, this.channel[i]); this.transport.setPitchTrim(this.pitchTrim); 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 ──────────────────────────────────────────────────────────── @@ -255,6 +298,12 @@ export class AudioEngine { // Social & Reset Ritual case 'airhorn': SFX.airhorn(ctx, dest, 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(); } + // ── 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 * 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(); }); + // ── 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. bus.on('block:mining', (p) => this.miningTick(p.progress)); @@ -512,6 +689,14 @@ export class AudioEngine { bus.on('fader:move', (p) => { 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; } const m = id.match(/(\d)/); 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) => { 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 (a.includes('fader') || a.includes('pitch')) this.playSfx('fader'); else if (a.includes('cue') || a.includes('arm') || a.includes('tonearm')) this.playSfx('cue'); diff --git a/src/audio/groove.ts b/src/audio/groove.ts index 9cbf8ba..580070c 100644 --- a/src/audio/groove.ts +++ b/src/audio/groove.ts @@ -8,7 +8,7 @@ // spin-up/brake and pitch-fader moves. 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 BARS = 8; @@ -51,9 +51,18 @@ export class Groove { private chan: 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 barCounter = 0; 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) { this.ctx = ctx; @@ -73,6 +82,10 @@ export class Groove { this.bassDuck = ctx.createGain(); this.bassDuck.gain.value = 1; 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. */ @@ -95,6 +108,26 @@ export class Groove { 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). */ setChannelGain(ch: number, v: number): void { 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) ── // energy ≈ low-band loudness of this beat: kick emphasis (downbeat louder) // 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) { const energy = (s === 0 ? 1.0 : 0.82) * this.chanValue[STEM.DRUMS]; emitAt(time, () => bus.emit('audio:beat', { energy })); diff --git a/src/audio/scheduler.ts b/src/audio/scheduler.ts index babb40b..c7f030f 100644 --- a/src/audio/scheduler.ts +++ b/src/audio/scheduler.ts @@ -6,7 +6,6 @@ // (pitch) — tempo and pitch bend together, exactly like a real platter. 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 TICK_MS = 25; // scheduler wake interval const RATE_MIN = 0.02; // below this the platter counts as stopped @@ -29,13 +28,16 @@ export class Transport { private rateTarget = 0; private spinTau: number; // exponential time constant for spin-up/brake private pitchTrim = 0; // extra detune (cents) from the pitch fader + private secPer16th: number; 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.onStep = onStep; this.spinTau = spinUpSeconds / 3; // ~settles within spinUpSeconds + this.secPer16th = 60 / bpm / 4; } /** Begin the wake loop. Idempotent; stays cheap while the platter is stopped. */ @@ -80,7 +82,7 @@ export class Transport { while (this.nextNoteTime < now + LOOKAHEAD) { const detune = 1200 * Math.log2(this.rate) + this.pitchTrim; this.onStep(this.nextStep, this.nextNoteTime, detune, this.emitAt); - this.nextNoteTime += SEC_PER_16TH / this.rate; + this.nextNoteTime += this.secPer16th / this.rate; this.nextStep++; } } diff --git a/src/audio/sfx.ts b/src/audio/sfx.ts index 8cb0529..7c358a9 100644 --- a/src/audio/sfx.ts +++ b/src/audio/sfx.ts @@ -401,6 +401,265 @@ export function powerDown(ctx: BaseAudioContext, dest: AudioNode, noise: AudioBu 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 = | 'footstep' | 'land' | 'break' | 'place' | 'fader' | 'button' | 'rca' | 'fuse' | 'cue' | 'needle' @@ -408,4 +667,6 @@ export type SfxName = | 'crimp' | 'wireOn' | 'wireOff' | 'screwTick' | 'screwClunk' | 'tiltCreak' | 'weightDetent' | 'bubbleLock' | 'skate' // Social & Reset Ritual: - | 'airhorn' | 'powerDown'; + | 'airhorn' | 'powerDown' + // SIDE B — crate worlds: + | 'portalIn' | 'portalOut' | 'stampThunk' | 'goldenRiser' | 'bunny'; diff --git a/src/audio/synth.ts b/src/audio/synth.ts index 8bfa59d..67d699d 100644 --- a/src/audio/synth.ts +++ b/src/audio/synth.ts @@ -239,6 +239,60 @@ export function lead( 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 * moving bandpass. Used at 8-bar boundaries; also the win-open sweep. diff --git a/src/audio/worldGrooves.ts b/src/audio/worldGrooves.ts new file mode 100644 index 0000000..f60f7ca --- /dev/null +++ b/src/audio/worldGrooves.ts @@ -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 = { 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 = { + 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 = { + 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 }); + } + } +} diff --git a/src/audio/worldSynth.ts b/src/audio/worldSynth.ts new file mode 100644 index 0000000..5b6df25 --- /dev/null +++ b/src/audio/worldSynth.ts @@ -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); + } + } +} diff --git a/src/core/events.ts b/src/core/events.ts index 021af03..aecdda1 100644 --- a/src/core/events.ts +++ b/src/core/events.ts @@ -41,8 +41,12 @@ export type GameEvents = { // predate this phase ('player:emote' / 'quest:reset' above). 'world:enter': { world: string }; // player dove into a pocket 'world:exit': { world: string }; // player returned to the booth - 'stamp:got': { world: string; count: number; total: number }; - 'stamp:all': Record; // golden slipmat unlock + // origin lets consumers celebrate proportionately: 'local' = this player just + // 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 // a block is being held-mined; FX puffs and audio ticks react. FX-only — diff --git a/src/demo/audioDemo.ts b/src/demo/audioDemo.ts index b4fbc36..190aef2 100644 --- a/src/demo/audioDemo.ts +++ b/src/demo/audioDemo.ts @@ -27,6 +27,31 @@ // radial gauge + ratchet ticks; "screws" → confetti; each workshop SFX. // (These fire the real workshop:test / workshop:torque / workshop:msg bus // 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 0–15 → 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 { 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 }; 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 | 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) ────────────────────── let last = performance.now(); function frame(now: number) { @@ -219,6 +277,7 @@ function buildPanel() { .tc-panel button.on { background:#2a5; color:#031; border-color:#3c7; } .tc-grid { display:grid; grid-template-columns:1fr 1fr; gap:4px; } .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; } `; document.head.appendChild(style); @@ -343,6 +402,85 @@ function buildPanel() { const wsfx = grid('three'); (['crimp', 'wireOn', 'wireOff', 'screwClunk', 'tiltCreak', 'weightDetent', 'bubbleLock', 'skate'] as const) .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; + 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(); + 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) { diff --git a/src/demo/worldgenDemo.ts b/src/demo/worldgenDemo.ts index c2bc9dd..1db46d3 100644 --- a/src/demo/worldgenDemo.ts +++ b/src/demo/worldgenDemo.ts @@ -16,6 +16,13 @@ // 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 // 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 { 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 { buildBooth, SPAWN } from '../worldgen/buildBooth'; 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 @@ -96,6 +107,106 @@ function validate(): { line: string; pass: boolean }[] { } 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; } 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 { 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] }, + // 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 }) { camera.position.set(z.from[0], z.from[1], z.from[2]); diff --git a/src/fx/FxSystem.ts b/src/fx/FxSystem.ts index a81731e..2c7fd9e 100644 --- a/src/fx/FxSystem.ts +++ b/src/fx/FxSystem.ts @@ -9,10 +9,22 @@ import * as THREE from 'three'; import { bus } from '../core/events'; import { PLATTER } from '../core/constants'; 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 { 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'; +/** SIDE B: accent lookup for a bus `world` string (warm white if unknown). */ +function worldDef(key: string): WorldDef | undefined { + return (WORLD_DEFS as Record)[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 { scene: THREE.Object3D; setEmissiveBoost: (v: number) => void; @@ -65,12 +77,23 @@ export class FxSystem { private beaconRepaired: (node: string) => boolean = () => true; 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) { this.scene = opts.scene; this.setBoost = opts.setEmissiveBoost; this.getLevels = opts.getLevels ?? (() => ({ low: 0, mid: 0, high: 0 })); const tex = makeGlowTexture(); + this.tex = tex; this.dust = new DustField(280, tex, DUST_BOX.min, DUST_BOX.max); this.puff = new Burst(160, 1.4, tex, 6, 3.5); this.confetti = new Burst(220, 2.2, tex, 9, 0.6); @@ -86,6 +109,17 @@ export class FxSystem { 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). */ setBeacons( anchors: { node: string; pos: [number, number, number] }[], @@ -118,6 +152,8 @@ export class FxSystem { this.vu.update(this.getLevels()); this.updateRimSparkle(dt); this.updateBeacons(dt); + this.bunnies?.update(dt); + this.iris.update(); // LED pulse decay this.pulse *= Math.exp(-dt / this.pulseTau); @@ -139,6 +175,14 @@ export class FxSystem { if (this.won) this.winClock += dt; 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.lastBoost = boost; } @@ -272,5 +316,56 @@ export class FxSystem { 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, + }); + } + }); } } diff --git a/src/fx/HANDOFF.md b/src/fx/HANDOFF.md index 538878d..95248a7 100644 --- a/src/fx/HANDOFF.md +++ b/src/fx/HANDOFF.md @@ -102,3 +102,21 @@ sideMat.needsUpdate = true; 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 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. diff --git a/src/fx/bunnies.ts b/src/fx/bunnies.ts new file mode 100644 index 0000000..fb6df17 --- /dev/null +++ b/src/fx/bunnies.ts @@ -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: 8–12 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(); + } +} diff --git a/src/fx/portalIris.ts b/src/fx/portalIris.ts new file mode 100644 index 0000000..82e9c59 --- /dev/null +++ b/src/fx/portalIris.ts @@ -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(); + } +} diff --git a/src/interact/interaction.ts b/src/interact/interaction.ts index 8773244..9d99beb 100644 --- a/src/interact/interaction.ts +++ b/src/interact/interaction.ts @@ -11,10 +11,12 @@ import { PLAYER } from '../core/constants'; import { bus } from '../core/events'; import type { IMachine, IPlayerView, IVoxelWorld, KinematicCollider, RayHit } from '../core/types'; import { Fader, QUEST_ITEMS, type MachineSet, type Workshop } from '../machines'; +import type { IdInteractions } from '../machines/machine'; import type { Hotbar } from './hotbar'; import { raycastColliders, raycastVoxel, type VoxelHit } from './raycast'; type WsTag = ReturnType; +type IdMachine = IMachine & IdInteractions; // 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 @@ -64,6 +66,9 @@ export class Interaction { private eDown = false; shiftDown = false; 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; @@ -144,6 +149,18 @@ export class Interaction { } 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?.(); return; } @@ -163,10 +180,11 @@ export class Interaction { } } - /** E released — end any workshop hold. */ + /** E released — end any workshop / world-machine hold. */ useUp(): void { this.eDown = false; 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. */ @@ -176,6 +194,7 @@ export class Interaction { const hit = this.raycast(); this.lastHit = hit; this.advanceWorkshopHold(dt, hit); + this.advanceIdHold(dt, hit); this.advanceMining(dt, hit); this.updateSelection(hit); } @@ -190,6 +209,17 @@ export class Interaction { 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; } // ---- internals ---- diff --git a/src/machines/index.ts b/src/machines/index.ts index e2ef134..0a1688c 100644 --- a/src/machines/index.ts +++ b/src/machines/index.ts @@ -18,6 +18,11 @@ import { Button } from './buttons'; import { RcaPlug } from './rca'; import { Quest, type QuestPositions } from './quest'; 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 { Platter } from './platter'; @@ -26,6 +31,11 @@ export { Fader } from './faders'; export { Button } from './buttons'; export { Workshop } from './workshop/Workshop'; 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 { /** Every machine (for iteration / collider lookup). */ @@ -37,6 +47,9 @@ export interface MachineSet { /** The Headshell Workshop (deck A) — Interaction routes assembly here, * NetClient syncs its state. */ 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). */ getColliders(): KinematicCollider[]; /** Advance all machines one fixed tick. */ @@ -198,6 +211,14 @@ export function createMachines(world: IVoxelWorld, questPos?: Partial a.platter.setGoldenSlipmat(true)); + // ---- MachineSet facade ---- const colliders: KinematicCollider[] = []; 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 colliders, update: (dt: number) => { for (const m of machines) m.update(dt); }, getObject3Ds: () => machines.map((m) => m.getObject3D()), diff --git a/src/machines/machine.ts b/src/machines/machine.ts index 316c663..1c06288 100644 --- a/src/machines/machine.ts +++ b/src/machines/machine.ts @@ -35,6 +35,26 @@ export abstract class MachineBase implements IMachine { 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. */ export interface AABB { minX: number; minY: number; minZ: number; diff --git a/src/machines/platter.ts b/src/machines/platter.ts index 46e1eb4..b4f15c5 100644 --- a/src/machines/platter.ts +++ b/src/machines/platter.ts @@ -58,6 +58,13 @@ export class Platter extends MachineBase { private readonly spin = new THREE.Group(); // everything that rotates about the spindle 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) { super(`platter${deck}`); this.deck = deck; @@ -125,8 +132,9 @@ export class Platter extends MachineBase { } // Slipmat. - const slipMat = blockMaterial(7); // slipmat_felt - this.spin.add(cylinderY(rr + 1.5, 0.5, slipMat, 0, Y_RECORD_TOP - 0.75, 0)); + this.slipmatFelt = blockMaterial(7); // slipmat_felt + 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), // matching the TIERS colliders — a stepped vinyl amphitheater. @@ -225,6 +233,23 @@ export class Platter extends MachineBase { isPlaying(): boolean { return this.playing; } 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 recomputeTarget(): void { diff --git a/src/machines/worlds/acidQuest.ts b/src/machines/worlds/acidQuest.ts new file mode 100644 index 0000000..3577b0e --- /dev/null +++ b/src/machines/worlds/acidQuest.ts @@ -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_'}` 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_ + } + 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_ + 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; + } + } +} diff --git a/src/machines/worlds/common.ts b/src/machines/worlds/common.ts new file mode 100644 index 0000000..9ee9d1b --- /dev/null +++ b/src/machines/worlds/common.ts @@ -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_`, `acid_ped`) 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; +} diff --git a/src/machines/worlds/discoQuest.ts b/src/machines/worlds/discoQuest.ts new file mode 100644 index 0000000..7c1b931 --- /dev/null +++ b/src/machines/worlds/discoQuest.ts @@ -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_', 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 cx−2..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. + } +} diff --git a/src/machines/worlds/dubQuest.ts b/src/machines/worlds/dubQuest.ts new file mode 100644 index 0000000..6433958 --- /dev/null +++ b/src/machines/worlds/dubQuest.ts @@ -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; + } +} diff --git a/src/machines/worlds/portal.ts b/src/machines/worlds/portal.ts new file mode 100644 index 0000000..b4c8de1 --- /dev/null +++ b/src/machines/worlds/portal.ts @@ -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 = { + 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 (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 e−1..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 }); + } +} diff --git a/src/machines/worlds/stamps.ts b/src/machines/worlds/stamps.ts new file mode 100644 index 0000000..cadf9da --- /dev/null +++ b/src/machines/worlds/stamps.ts @@ -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(); + + /** 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; + } +} diff --git a/src/main.ts b/src/main.ts index 132e75d..7777225 100644 --- a/src/main.ts +++ b/src/main.ts @@ -90,6 +90,7 @@ scene.add(interaction.getObject3D()); // starts only from the HUD's start-splash click (autoplay policy). const audio = new AudioEngine(); 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 // world itself points at what's broken (first-five-minutes pass). diff --git a/src/net/NetClient.ts b/src/net/NetClient.ts index 9ec262b..19d4a6e 100644 --- a/src/net/NetClient.ts +++ b/src/net/NetClient.ts @@ -12,7 +12,7 @@ import { AIR } from '../core/blocks'; import { bus } from '../core/events'; import type { SignalNode } from '../core/constants'; 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 { validLook, DEFAULT_LOOK, type AvatarLook } from './avatarLook'; @@ -147,6 +147,14 @@ export class NetClient { this.unsubs.push(this.o.machines.workshop.onChange((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 { @@ -213,6 +221,21 @@ export class NetClient { if (s && pl) { pl.setSpeed(s.rpm); pl.setPlaying(s.playing); } } 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(); + 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); break; @@ -283,6 +306,15 @@ export class NetClient { case 'workshop': this.applyRemote(() => { this.o.machines.workshop.setState(m.s); }); 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': console.warn('[net] booth is full — playing solo'); this.dispose(); diff --git a/src/ui/HANDOFF.md b/src/ui/HANDOFF.md index 8916ac5..fbc8f34 100644 --- a/src/ui/HANDOFF.md +++ b/src/ui/HANDOFF.md @@ -47,3 +47,13 @@ adapt Lane D's hotbar to this shape). - `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 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). diff --git a/src/ui/Hud.ts b/src/ui/Hud.ts index 5c6a735..48dd411 100644 --- a/src/ui/Hud.ts +++ b/src/ui/Hud.ts @@ -9,6 +9,7 @@ import { bus } from '../core/events'; import { SIGNAL_NODES, type SignalNode } from '../core/constants'; import { blockDef, type BlockId } from '../core/blocks'; +import { WORLD_DEFS, WORLD_KEYS } from '../core/worlds'; export interface HotbarSlot { id: BlockId; count: 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-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-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%); 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; } @@ -95,6 +107,9 @@ export class Hud { private slots: HTMLDivElement[] = []; private nodeEls: Record = {}; private progressEl!: HTMLDivElement; + private stampsEl!: HTMLDivElement; + private stampEls: Record = {}; + private bornAt = performance.now(); // gates join-replay stamp events (no pulse) private subEl!: HTMLDivElement; private torqueEl!: HTMLDivElement; private torqueVal!: HTMLDivElement; @@ -116,6 +131,7 @@ export class Hud { this.el.className = 'tc-hud'; this.buildCrosshair(); this.buildQuest(); + this.buildStamps(); this.buildSubtitle(); this.buildTorque(); this.buildHotbar(); @@ -224,6 +240,23 @@ export class Hud { 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 { this.subEl = document.createElement('div'); this.subEl.className = 'tc-sub'; @@ -340,5 +373,30 @@ export class Hud { else this.showTorque(p.value); })); 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'); + } + } + })); } } diff --git a/src/worldgen/buildBooth.ts b/src/worldgen/buildBooth.ts index 45f9a92..26298cb 100644 --- a/src/worldgen/buildBooth.ts +++ b/src/worldgen/buildBooth.ts @@ -20,6 +20,7 @@ import { 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 { QUEST_POS } from './questPositions'; +import { buildWorlds } from './worlds'; // ---- block palette (resolve names -> ids once) ----------------------------- @@ -570,6 +571,10 @@ export function buildBooth(w: IVoxelWorld): void { buildPatchBay(w, rng); buildUnderTable(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. diff --git a/src/worldgen/worlds/acid.ts b/src/worldgen/worlds/acid.ts new file mode 100644 index 0000000..2a88b65 --- /dev/null +++ b/src/worldgen/worlds/acid.ts @@ -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); +} diff --git a/src/worldgen/worlds/common.ts b/src/worldgen/worlds/common.ts new file mode 100644 index 0000000..0a2bbc7 --- /dev/null +++ b/src/worldgen/worlds/common.ts @@ -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 = { + 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); } + } +} diff --git a/src/worldgen/worlds/disco.ts b/src/worldgen/worlds/disco.ts new file mode 100644 index 0000000..0a79d24 --- /dev/null +++ b/src/worldgen/worlds/disco.ts @@ -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); +} diff --git a/src/worldgen/worlds/dub.ts b/src/worldgen/worlds/dub.ts new file mode 100644 index 0000000..f02090a --- /dev/null +++ b/src/worldgen/worlds/dub.ts @@ -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); +} diff --git a/src/worldgen/worlds/index.ts b/src/worldgen/worlds/index.ts new file mode 100644 index 0000000..31a0cf2 --- /dev/null +++ b/src/worldgen/worlds/index.ts @@ -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'; diff --git a/src/worldgen/worlds/portalDressing.ts b/src/worldgen/worlds/portalDressing.ts new file mode 100644 index 0000000..b810032 --- /dev/null +++ b/src/worldgen/worlds/portalDressing.ts @@ -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); + } +}