SIDE B: the crate worlds — three dive-in pockets, stamps, golden slipmat
Three records in the under-table crate are pressed with whole scenes. E beside
one dives you into its pocket above the booth walls; complete its sound-first
quest to press a STAMP; all three raise the golden slipmat + a permanent bonus
percussion stem. Dust bunnies move in under the table.
- Worlds (Lane W): ACID WAREHOUSE / DUB CHAMBER / DISCO LOFT — sealed themed
pockets, portal rings, exit pads; deterministic, zero light leaks (incl. the
flythrough path), meshes unchanged at 469, build ~760 ms.
- Machines (Lane M): portal dive/exit/safety (membership-based escape catch),
tune-the-303 hold-sweep pedestals, carry-the-charge wobbling spring bridge,
beat-Simon dance floor with glow tiles; stamps store + relay {t:'stamp'}
(default-closed, persisted, join snapshot, fuzzed); golden slipmat; stamps
survive the reset ritual.
- Ambience (Lane S): dust bunnies (wander/flee, zero steady-state allocs),
vinyl-warp portal iris, HUD stamp tray, stamp confetti + golden shimmer.
- Audio (Lane E2): per-world grooves (acid 303 w/ fader-driven bands, dub
feedback-echo + spring, disco pentatonic tiles) over a boothGain
duck-and-handback that can never restore stale state; portal/stamp/golden
SFX; bonus stem.
Post-integration review (2 seam finders, 10 findings, all fixed + verified):
stamp events carry origin (local/remote/replay) so consumers celebrate
proportionately instead of wall-clock guessing — kills phantom join
celebrations, first-person confetti for peers' wins, and audio/visual grace
mismatches; reconnect re-uploads locally-won stamps the server missed;
relay saveState survives disk errors; pocket escape via mined floor counts as
a real exit at any altitude; exit-pad speed gate + dwell stops mid-quest
yank-outs; dive cooldown stops exit/re-dive ping-pong; locked acid bands
need deliberate holds (tap-spam dead); disco foot band excludes jump apex.
Verified: whole-tree typecheck + build clean; per-lane live verification
(27/27 worldgen assertions, relay fuzz, 18-phase audio matrix, fps unchanged);
integrated smoke: dive/exit/safety/stamps/golden + all origin paths live.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
758e9dc4d3
commit
33232fdeba
126
docs/SIDEB_E2_handoff.md
Normal file
126
docs/SIDEB_E2_handoff.md
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
# SIDE B — Lane E2 handoff (audio: world grooves + SIDE B sound)
|
||||||
|
|
||||||
|
Everything in `src/audio/**` only. `npm run typecheck` clean. AudioEngine
|
||||||
|
still self-wires to the bus, so the integrator changes **nothing** in
|
||||||
|
`src/main.ts` for this lane — construct `AudioEngine` exactly as before.
|
||||||
|
|
||||||
|
## Files touched
|
||||||
|
|
||||||
|
| file | change |
|
||||||
|
|---|---|
|
||||||
|
| `src/audio/worldGrooves.ts` | **new** — the three pocket grooves (acid 118 / dub 74 half-time / disco 122), each on its own lookahead Transport with spin-up/brake; quest hooks (acid bands + locks, dub echo/carry/feed, disco cues/steps, stamp glory) |
|
||||||
|
| `src/audio/worldSynth.ts` | **new** — SIDE B voices: the 303, shimmer/lock chime, dub sub/rimshot/skank/spring-boing/feed-burst, disco oct-bass/string stab/cue blip/string swell |
|
||||||
|
| `src/audio/AudioEngine.ts` | `boothGain` bus (every booth source funnels through it; pockets duck THIS), `worldBus` (+ analyser tap so pocket grooves drive the VU), world enter/exit + stamp + stamp:all handlers, SIDE B `machine:interact`/`fader:move` routing, new playSfx names |
|
||||||
|
| `src/audio/groove.ts` | bonus percussion stem (shaker + conga clave, own gain outside the 5 fader channels, schedule-gated) + `setEmitBeats` (beat/bar suppression during dives) |
|
||||||
|
| `src/audio/scheduler.ts` | additive `bpm` constructor param (booth default 118 unchanged) |
|
||||||
|
| `src/audio/synth.ts` | additive `shaker`, `conga` voices |
|
||||||
|
| `src/audio/sfx.ts` | additive `portalWhoosh`, `stampThunk`, `stampFanfare`, `goldenRiser`, `bunnySqueak` (+ SfxName entries `portalIn/portalOut/stampThunk/goldenRiser/bunny`) |
|
||||||
|
| `src/demo/audioDemo.ts` | SIDE B panel (world buttons, three acid faders + locks, dub pickup/feed, 4×4 disco tiles + step echo, stamps, bunny/airhorn/blow-fuse) + a marked `DEMO MOCK` automation handle (`window.DEMO`) |
|
||||||
|
|
||||||
|
## Duck-and-handback model (why a reset can't wedge it)
|
||||||
|
|
||||||
|
All booth music (stems via panners + dry bed **and the zero-repair
|
||||||
|
heartbeat**) funnels through one `boothGain`. `world:enter` ducks that gain to
|
||||||
|
0 in 300 ms and spins up the pocket groove on `worldBus`; `world:exit`
|
||||||
|
reverses. The booth graph stays **live underneath the duck** — repairs, the
|
||||||
|
reset ritual power-down, a win, remote stem changes all keep flowing — so the
|
||||||
|
handback never restores a snapshot; it reveals the booth's *current* state.
|
||||||
|
Verified: dead booth (heartbeat returns), won booth (full mix returns), and
|
||||||
|
`quest:reset` fired **while inside** a pocket (power-down runs muffled under
|
||||||
|
the groove; exit lands on the post-reset heartbeat).
|
||||||
|
|
||||||
|
## Events consumed (exact expectations)
|
||||||
|
|
||||||
|
- `world:enter { world }` / `world:exit { world }` — `world` in
|
||||||
|
`acid|dub|disco` (anything else is ignored). Enter = portal whoosh-down +
|
||||||
|
duck + groove spin-up. Exit = reverse whoosh + brake + handback. Re-entering
|
||||||
|
the same world while inside it is a no-op; entering a second world
|
||||||
|
hard-switches.
|
||||||
|
- `fader:move { faderId: 'acid_0'|'acid_1'|'acid_2', value: 0..1 }` — that
|
||||||
|
band's 303 cutoff, exponential across per-band offset ranges
|
||||||
|
(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<i>`, index prop) — lock chime +
|
||||||
|
that band's shimmer layer (16th-offbeat pings). Locks reset on every
|
||||||
|
`world:enter` acid (targets re-seed per entry). `acid_knob` and
|
||||||
|
`acid_tuned` are swallowed silently — the cutoff sweep / stamp glory IS
|
||||||
|
the sound.
|
||||||
|
- `dub_pickup` / `dub_drop` — echo wet 0.32 ↔ 0.8 over ~0.8 s ("carry the
|
||||||
|
wet"). Their chasm-fall `dub_drop` is handled.
|
||||||
|
- `dub_feed` — the big SPROING: bandpassed burst into the echo, feedback
|
||||||
|
0.55 → **0.8 for ~2 s** → back, wet settles down after.
|
||||||
|
- `disco_cue` (machineId `disco_tile_<0..15>`) — pentatonic blip, pitch
|
||||||
|
rises with index (F-minor pentatonic, 2+ octaves).
|
||||||
|
- `disco_step` (same scheme) — the player's step echoes that tile a fifth
|
||||||
|
down, softer. (Lane M already emits this in their WIP — confirmed.)
|
||||||
|
- `disco_miss` — a gentle descending "aw" (THE FLOOR FORGIVES — never a
|
||||||
|
buzzer); `disco_lit` swallowed (the stamp swell follows immediately).
|
||||||
|
- `portal_dive` / `portal_exit` — swallowed: the `world:enter`/`world:exit`
|
||||||
|
whooshes carry the moment (prevents a stray generic button clunk).
|
||||||
|
- `bunny_flee` — shy squeak, throttled to ≥1.1 s apart (Lane S already
|
||||||
|
caps at 1/s globally).
|
||||||
|
- `stamp:got { world, count, total }` — press-THUNK + fanfare in that world's
|
||||||
|
key + in-groove glory (acid: 4 bars of claps + octave doubling; dub: big
|
||||||
|
boing; disco: filtered string swell). **Deduped per world per session** —
|
||||||
|
join-snapshot re-emits are silent. `count===total` also arms the golden
|
||||||
|
unlock (below), so `stamp:all` is not strictly required.
|
||||||
|
- `stamp:all {}` — golden riser (once per session), then the **bonus
|
||||||
|
percussion stem** (shaker + conga) enters the booth mix at the riser's bloom
|
||||||
|
and stays permanently (survives `quest:reset` — stamps live in the records,
|
||||||
|
not the booth). It rides whenever the booth mix is audible (gated on
|
||||||
|
stemCount > 0 so a dead booth never shakes alone) and sits outside the five
|
||||||
|
fader channels.
|
||||||
|
|
||||||
|
Also still consumed as before: `platter:state`, `signal:repair`, `game:win`,
|
||||||
|
`quest:reset`, `player:emote`, `workshop:*`, `block:*`, `player:*`.
|
||||||
|
|
||||||
|
## Events emitted
|
||||||
|
|
||||||
|
- `audio:beat { energy }` / `audio:bar { bar }` — **while inside a pocket
|
||||||
|
these come from the pocket groove's grid** (acid 118 / dub 74 / disco 122);
|
||||||
|
the booth groove's emission is suppressed for the duration so two tempos
|
||||||
|
never fight. Lane M's disco sequence should simply listen to `audio:beat`
|
||||||
|
as briefed — inside the loft it will be the loft's 122 BPM pulse, including
|
||||||
|
in a dead booth.
|
||||||
|
|
||||||
|
## What I parse (index conventions)
|
||||||
|
|
||||||
|
The typed bus payload for `machine:interact` has no `index` field, so I
|
||||||
|
resolve the index in this order: a runtime `index` property if cast in; else
|
||||||
|
**trailing digits of `machineId`**; else trailing digits of `action`; else a
|
||||||
|
sane fallback (first unlocked band / tile 0). Lane M's `worlds/common.ts`
|
||||||
|
`interactAt()` supplies BOTH the runtime prop and machineId digits — fully
|
||||||
|
compatible; nothing further needed.
|
||||||
|
|
||||||
|
## Lane M coordination — all asks already satisfied in their WIP (verified in-tree)
|
||||||
|
|
||||||
|
1. **`disco_step`** (player steps a tile → fifth-down echo): not in the
|
||||||
|
briefed vocab, but Lane M's `discoQuest.ts` already emits it. If that ever
|
||||||
|
changes, steps just go silent — nothing breaks.
|
||||||
|
2. **Boot handshake for stamps**: their `Stamps.apply()` is idempotent and
|
||||||
|
re-emits `stamp:got` per stamped world on join snapshots (and `stamp:all`
|
||||||
|
when the third lands, join included). Exactly what I assume: I dedupe
|
||||||
|
thunks per world per session, the golden riser fires once per session, and
|
||||||
|
any pre-gesture (pre-`audio.init`) stamp events arm the bonus stem
|
||||||
|
silently. A won crate on a fresh boot = bonus stem armed, no riser replay.
|
||||||
|
3. **`dub_drop` on a chasm fall**: their `dubQuest.ts` emits it. Wet falls
|
||||||
|
back as intended.
|
||||||
|
|
||||||
|
## Notes for the integrator
|
||||||
|
|
||||||
|
- Nothing in `src/main.ts` changes; `window.TURNCRAFT.audio` gains no new
|
||||||
|
required calls. New public surface: playSfx names
|
||||||
|
`portalIn/portalOut/stampThunk/goldenRiser/bunny` (demo/testing sugar).
|
||||||
|
- The win timeline / reset ritual / heartbeat behaviors are byte-compatible
|
||||||
|
when no SIDE B event ever fires.
|
||||||
|
- Verified live in `demo-audio.html` (SIDE B panel drives the real bus
|
||||||
|
events) and in the real game on the `turncraft-laneB` config (port 5184)
|
||||||
|
by emitting the same events from the console — see the demo's header
|
||||||
|
comment for the by-ear checklist. Zero console errors from audio code.
|
||||||
|
(During shared-tree testing a `ws://localhost:8433` error appears when the
|
||||||
|
relay isn't running — that's NetClient/Lane M territory, not audio.)
|
||||||
156
docs/SIDEB_M_handoff.md
Normal file
156
docs/SIDEB_M_handoff.md
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
# SIDE B — Lane M handoff (portals, world quests, stamps, relay)
|
||||||
|
|
||||||
|
**Brief:** [docs/briefs/SIDEB_MACHINES.md](briefs/SIDEB_MACHINES.md)
|
||||||
|
**Status:** ✅ All items delivered and verified live in the real game against a
|
||||||
|
real local relay AND against Lane W's landed pocket geometry (their build
|
||||||
|
arrived mid-session; every position below is aligned to their actual voxels,
|
||||||
|
not brief guesses). `npm run typecheck` clean; zero console errors in the
|
||||||
|
final build; `demo-machines.html` still runs standalone. **Not committed** —
|
||||||
|
integrator commits (shared tree).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files touched (all inside Lane M ownership)
|
||||||
|
|
||||||
|
| file | what |
|
||||||
|
|---|---|
|
||||||
|
| `src/machines/worlds/common.ts` | **new** — pocket bounds/nearest-world helpers, structural `teleport`/`isFlying` access to Lane B's player (no cross-lane import), `interactAt()` (the `index`-carrying `machine:interact` emitter), mulberry32 |
|
||||||
|
| `src/machines/worlds/stamps.ts` | **new** — `Stamps` store (Set<WorldKey>): idempotent `apply()` emits `stamp:got` / `stamp:all` + HUD toasts (suppressed by `quiet` for join replay); `Stamps.coerceKey` default-closed wire validation |
|
||||||
|
| `src/machines/worlds/portal.ts` | **new** — three dive plates at the crate discs (E → `world:enter` + teleport to `entry`), exit pads (stand 0.25 s → `world:exit` + teleport to `returnPos`), y>141 out-of-pocket safety net, proximity HUD prompts, accent glow panes |
|
||||||
|
| `src/machines/worlds/acidQuest.ts` | **new** — Tune the 303: 3 hold-E sweep pedestals, per-entry seeded hidden targets, release-inside-±0.07 locks, `fader:move acid_0..2` + `workshop:torque` gauge |
|
||||||
|
| `src/machines/worlds/dubQuest.ts` | **new** — Feed the Spring: charge carry (wire-carry pattern), 8-segment wobbling kinematic deck overlay (travelling sine, honest `velocityAt`), pit-fall forgiveness, mid-deck mouth feed |
|
||||||
|
| `src/machines/worlds/discoQuest.ts` | **new** — beat-Simon on the 4×4 tile grid: rounds 4→8, standing-on-tile detection, `audio:beat` sync + 122 BPM free-run fallback, **tile glow plates (lighting is Lane M's, per Lane S's scope)** |
|
||||||
|
| `src/machines/machine.ts` | + `IdInteractions` (optional structural per-collider press/hold surface for world machines) |
|
||||||
|
| `src/machines/index.ts` | constructs Portals + 3 quests + `Stamps` inside `createMachines`; `MachineSet.stamps`; `stamp:all` → deck A golden slipmat; re-exports |
|
||||||
|
| `src/machines/platter.ts` | + `setGoldenSlipmat(on)` / `isGoldenSlipmat()` (gold emissive material swap, idempotent) |
|
||||||
|
| `src/interact/interaction.ts` | generalized the workshop's press/hold routing: machines exposing `IdInteractions` get E with the hit collider id and may run hold-E; with none, behavior is byte-identical to before |
|
||||||
|
| `src/net/NetClient.ts` | sends `{t:'stamp', w}` on local `stamp:got` (guarded, re-validated); applies remote `stamp` + `hello.state.stamps` (quiet) via `Stamps.apply` |
|
||||||
|
| `server/relay.mjs` | **one additive message** `{t:'stamp', w}` — see protocol below |
|
||||||
|
|
||||||
|
**No other lane's files were touched.** Lane W's geometry constants are
|
||||||
|
mirrored (with file references), not imported — cross-lane imports stay
|
||||||
|
contract-only.
|
||||||
|
|
||||||
|
## Integrator wiring needed in main.ts: **none**
|
||||||
|
|
||||||
|
Everything rides existing wiring: the world machines are created inside
|
||||||
|
`createMachines()` (colliders/scene/update/attachPlayer already flow through
|
||||||
|
`MachineSet`), and the stamp sync lives in `NetClient`, which already receives
|
||||||
|
`machines`. Two facts worth knowing, no action required:
|
||||||
|
|
||||||
|
- `MachineSet` gained a `stamps: Stamps` field.
|
||||||
|
- Portals reach `PlayerController.teleport` / `isFlying` **structurally** via
|
||||||
|
`attachPlayer`'s object. A player without `teleport` (demo mock) means dives
|
||||||
|
emit events but don't move — harmless in the demo, real game unaffected.
|
||||||
|
|
||||||
|
## Event vocabulary emitted (for Lanes S / E2)
|
||||||
|
|
||||||
|
The `machine:interact` payloads carry the index BOTH ways E2's handoff asks
|
||||||
|
for: **trailing digits of `machineId`** (`acid_0..2`, `disco_tile_0..15`) AND
|
||||||
|
a runtime `index` property (typed-contract-safe via a widened variable —
|
||||||
|
`core/events.ts` untouched).
|
||||||
|
|
||||||
|
| event | when |
|
||||||
|
|---|---|
|
||||||
|
| `world:enter {world}` | E on a dive plate, before the teleport (S iris, E2 duck+groove) |
|
||||||
|
| `world:exit {world}` | exit pad (0.25 s stand) or the y>141 safety net |
|
||||||
|
| `fader:move {faderId:'acid_0'..'acid_2', value}` | pedestal sweeps; **initial (wrong) values re-emitted on `world:enter` + once more ~0.35 s later** (registration-order proofing) |
|
||||||
|
| `machine:interact {machineId:'acid_<i>', action:'acid_knob', index}` | ~6/s while a band sweeps |
|
||||||
|
| `machine:interact {machineId:'acid_<i>', action:'acid_lock', index}` | band locked (value snaps to target, so the last `fader:move` is exactly right) |
|
||||||
|
| `machine:interact {machineId:'acid', action:'acid_tuned'}` | all three locked (fires on toy replays too; stamp events do not) |
|
||||||
|
| `machine:interact {machineId:'dub', action:'dub_pickup' / 'dub_drop' / 'dub_feed'}` | charge taken / lost (pit fall OR leaving the pocket mid-carry) / fed to the tank |
|
||||||
|
| `machine:interact {machineId:'disco_tile_<i>', action:'disco_cue', index}` | sequence playback, one per beat |
|
||||||
|
| `machine:interact {machineId:'disco_tile_<i>', action:'disco_step', index}` | player steps a correct tile (E2 echoes a fifth down) |
|
||||||
|
| `machine:interact {machineId:'disco', action:'disco_miss' / 'disco_lit'}` | wrong step (gentle reset) / full 8 done |
|
||||||
|
| `machine:interact {machineId:'portals', action:'portal_dive' / 'portal_exit'}` | teleports (extra flavor hooks, ignorable) |
|
||||||
|
| `stamp:got {world, count, total}` | stamp landed: local win, remote win, **and once per stamped world from the join snapshot** (idempotent — never re-fires for a world already applied) |
|
||||||
|
| `stamp:all {}` | third stamp — exactly on the fresh transition, including when the join snapshot completes the set |
|
||||||
|
| `workshop:torque {value / null}` | acid hold gauge (reuses the HUD radial) |
|
||||||
|
| `workshop:msg {text}` | prompts/toasts (join-replay stamp toasts suppressed) |
|
||||||
|
|
||||||
|
Consumed: `audio:beat` (disco show pacing; free-runs at 122 BPM when silent —
|
||||||
|
the quest can't stall in a dead booth), `world:enter/exit` (self-wiring).
|
||||||
|
|
||||||
|
**Join handshake (S §"Join-replay coordination", E2 §"boot handshake"):**
|
||||||
|
`stamp:got`/`stamp:all` replays fire while `NetClient` handles `hello`, i.e.
|
||||||
|
on the ws connect during page boot — measured well inside FxSystem's 4 s
|
||||||
|
celebration-suppression grace. One edge: if the relay is unreachable at boot,
|
||||||
|
the replay arrives on the first successful (re)connect (15 s backoff) and
|
||||||
|
would land OUTSIDE the grace — rare, cosmetic (one extra confetti), noted.
|
||||||
|
|
||||||
|
## Relay protocol (additive, v3 + stamp)
|
||||||
|
|
||||||
|
- Client → server `{t:'stamp', w}`: `w` must be exactly `acid|dub|disco`
|
||||||
|
(default-closed; anything else dropped). Duplicate = dropped (no
|
||||||
|
rebroadcast). Accept → persist **immediately**, rebroadcast to other peers.
|
||||||
|
- `hello.state.stamps: string[]` — join snapshot; old clients ignore it, old
|
||||||
|
state files load as no stamps.
|
||||||
|
- **`reset` deliberately does NOT clear `stamps`** — stamps are pressed into
|
||||||
|
the records, not the booth (M5). Comment sits in the reset case.
|
||||||
|
|
||||||
|
## Geometry alignment (to Lane W's landed build)
|
||||||
|
|
||||||
|
- **Portals:** dive plate hugs each disc's lower rim (probed live: the rim
|
||||||
|
overhangs the stand at eye height — solid y4 to z 35, y5 to z 37), centre
|
||||||
|
`[discX+1, 4.0, 34.6]`, small enough not to wall the crate aisle.
|
||||||
|
- **Acid:** plinth colliders/knobs at world centres `[52.5, ·, 44.5/52.5/60.5]`;
|
||||||
|
our chrome knob rides ON Lane W's led pip voxel (top face 148), lock lamp above.
|
||||||
|
- **Dub:** deck overlay x 223..226 (centre 224.5), z 40..64, base **147.62**
|
||||||
|
(static deck top 147 — ours never dips below it: 147.07 min, so the ride
|
||||||
|
never pops onto voxels). Mouth = their rca_gold inlay `[224,146,52]` under
|
||||||
|
our floating deck → feeding is E on the deck within ±3.2 of z 52.5 (the pit's
|
||||||
|
reach geometry makes the bridge the only spot). Charge home `[232,146.2,78]`
|
||||||
|
in front of their horn (x 232 — their build + ours both keep the horn walk
|
||||||
|
off the exit-pad lane; an earlier centred placement walked players over the
|
||||||
|
pad, found live). Pit fall = feet < 143.6 while carrying (pit walks at 143,
|
||||||
|
canyon at 144 stays safe).
|
||||||
|
- **Disco:** tile centres at Lane W's **pitch-8** grid `x 384.5/392.5/400.5/408.5`,
|
||||||
|
`z 52.5/60.5/68.5/76.5` (not the brief's pitch-6 sketch), 5×5 halves. Glow
|
||||||
|
plates hover at y 144.05.
|
||||||
|
- **Exit pads:** trigger centred on the pad's world centre (`exitPad+0.5`),
|
||||||
|
±1.3 with a **0.25 s dwell** — clipping a pad corner mid-quest can't yank you
|
||||||
|
(the disco far row is 2 voxels from its pad); a 1 s post-teleport cooldown
|
||||||
|
stops re-triggers.
|
||||||
|
|
||||||
|
## Design decisions worth knowing
|
||||||
|
|
||||||
|
- **Acid locks evaluate on hold RELEASE** (release inside ±0.07 = lock, value
|
||||||
|
snaps to target). Sweeping through the sweet spot doesn't auto-lock — you
|
||||||
|
tune by ear and let go, radio style. Shift reverses the sweep; ends ping-pong.
|
||||||
|
- **Quests replay as toys** once stamped: same loop, flourish message, no
|
||||||
|
stamp events (Stamps.apply is the single idempotence gate, so E2's "4 bars
|
||||||
|
of glory" only fires on real stamps).
|
||||||
|
- Acid targets/values and disco sequences reseed per `world:enter`; disco
|
||||||
|
sequences avoid immediate repeats (re-entry detection can't see doubles).
|
||||||
|
- Dub deck `velocityAt` reports the true lateral surface velocity of the
|
||||||
|
weave; vertical rides on Lane B's ride-snap (verified 89%+ grounded on the
|
||||||
|
wobble at carry amplitude, worst-case rates ~25× inside the envelope).
|
||||||
|
- The safety net skips flying players (dev flight stays usable) and counts as
|
||||||
|
a `world:exit` of the nearest world so E2's duck always unwinds.
|
||||||
|
|
||||||
|
## Verification record (real game, port 5194; local relay only)
|
||||||
|
|
||||||
|
- Relay (node scripts, fresh state): stamp round-trip A→B; **no sender echo**;
|
||||||
|
duplicates dropped; **14-case fuzz** (bad types/keys/enums/proto/broken
|
||||||
|
JSON) all dropped with the socket alive; join snapshot carries stamps;
|
||||||
|
persistence + `winAt` rewind; **reset clears repairs, stamps survive**,
|
||||||
|
quest replayable. `server/booth-state.json` deleted before AND after.
|
||||||
|
- In-browser (headless-driven via `window.TURNCRAFT`, real raycast/E/hold
|
||||||
|
routing): all three portals dive with prompts; exit pad + dwell + cooldown;
|
||||||
|
safety net teleports non-flyers and leaves flyers alone; ACID three locks →
|
||||||
|
stamp (exact vocab above, gauge shown/hidden); DUB grounded wobble ride,
|
||||||
|
pickup, canyon-roam safe, pit fall → `dub_drop` + retry toast, feed →
|
||||||
|
stamp; DISCO wrong-step forgiveness, rounds 4→8 with cue/step/plate
|
||||||
|
lighting → stamp #3 → `stamp:all` → **golden slipmat**; reload → join
|
||||||
|
replay (3× `stamp:got` quiet + golden restored ⇒ `stamp:all` fired);
|
||||||
|
**full reset→re-win cycle**: reset broadcast (`by` name), booth dead,
|
||||||
|
stamps 3/3 + golden intact, dead-booth dive+exit work, re-win with stamps
|
||||||
|
intact. Zero console errors in the final build; `demo-machines.html` clean.
|
||||||
|
|
||||||
|
## Known edges
|
||||||
|
|
||||||
|
- Late-reconnect join replay can land outside Lane S's 4 s grace (above).
|
||||||
|
- The `stamp:got` HUD toast for REMOTE stamps shows live (by design — a peer
|
||||||
|
pressing a stamp is multiplayer news); only join-snapshot replays are quiet.
|
||||||
|
- If a future phase ever wants stamps resettable, that's a relay change
|
||||||
|
(clear `stamps` in the reset case) + nothing client-side.
|
||||||
128
docs/SIDEB_S_handoff.md
Normal file
128
docs/SIDEB_S_handoff.md
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
# SIDE B — Lane S handoff (fx/ui ambience)
|
||||||
|
|
||||||
|
Dust bunnies, the vinyl-warp portal iris, and the HUD stamp tray + stamp
|
||||||
|
celebrations. All bus-driven, no imports from any other lane, nothing outside
|
||||||
|
`src/fx/**` + `src/ui/**` touched. **Not committed** (integrator commits).
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| file | change |
|
||||||
|
|---|---|
|
||||||
|
| `src/fx/bunnies.ts` | NEW — `DustBunnies`: 4 fluff-ball critters on the under-table floor |
|
||||||
|
| `src/fx/portalIris.ts` | NEW — `PortalIris`: full-screen 2D-canvas groove-ring iris |
|
||||||
|
| `src/fx/FxSystem.ts` | additive — `attachAmbience()`, iris + bunnies update, stamp confetti, `stamp:all` emissive shimmer |
|
||||||
|
| `src/ui/Hud.ts` | additive — stamp tray (3 accent dots, top-right under the signal lamps) |
|
||||||
|
| `src/fx/HANDOFF.md`, `src/ui/HANDOFF.md` | SIDE B sections appended |
|
||||||
|
|
||||||
|
## Integrator wiring — ONE line in `src/main.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// after the player is constructed (mirrors the fx.setBeacons pattern):
|
||||||
|
fx.attachAmbience(world, player);
|
||||||
|
```
|
||||||
|
|
||||||
|
That's it. Everything else self-wires to the bus in the existing
|
||||||
|
`FxSystem`/`Hud` constructors already called from `main.ts`. Without the line,
|
||||||
|
bunnies simply don't spawn (and stamp confetti falls back to the world's
|
||||||
|
`entry` anchor); iris + tray + shimmer work regardless.
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
**Consumed (new this phase):** `world:enter`, `world:exit` (iris),
|
||||||
|
`stamp:got` (tray dot + pulse, accent confetti at the player),
|
||||||
|
`stamp:all` (gold tray rings, booth-wide emissive shimmer via the existing
|
||||||
|
`setEmissiveBoost` path, gold confetti at the three crate portal discs).
|
||||||
|
|
||||||
|
**Emitted:** `machine:interact { machineId: 'bunny', action: 'bunny_flee' }` —
|
||||||
|
on the wander→flee transition only, globally rate-limited to 1/s (a 10 s
|
||||||
|
continuous chase produces 1 emit, not 10 — the squeak marks the spook, per
|
||||||
|
brief "at most 1/s"). Lane E2 maps it to a squeak.
|
||||||
|
|
||||||
|
## Behaviour details
|
||||||
|
|
||||||
|
- **Bunnies** (`DustBunnies`): 4 critters, each a single mesh of 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).
|
||||||
175
docs/SIDEB_W_handoff.md
Normal file
175
docs/SIDEB_W_handoff.md
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
# SIDE B — Lane W handoff (worlds)
|
||||||
|
|
||||||
|
**Status: complete.** Whole-tree `npm run typecheck` exits 0. All demo
|
||||||
|
validation green (27/27 lines incl. the new pocket assertions). Verified live
|
||||||
|
on port 5212 (launch config `turncraft-laneC-verify`) in both the demo page
|
||||||
|
and the real game via the cine camera.
|
||||||
|
|
||||||
|
## Files touched
|
||||||
|
|
||||||
|
- `src/worldgen/worlds/` **(new)** — `index.ts` (`buildWorlds(w, seed)`),
|
||||||
|
`common.ts` (skin/lining/exit-pad toolkit + accent-led map),
|
||||||
|
`portalDressing.ts` (W1), `acid.ts`, `dub.ts`, `disco.ts`
|
||||||
|
- `src/worldgen/buildBooth.ts` — additive only: one import + one
|
||||||
|
`buildWorlds(w, WORLD_SEED)` call at the end of `buildBooth`
|
||||||
|
- `src/demo/worldgenDemo.ts` — pocket assertions, three "Pocket:" exterior
|
||||||
|
view buttons, header notes
|
||||||
|
- `docs/SIDEB_W_handoff.md` — this file
|
||||||
|
|
||||||
|
Nothing else. No `src/core/**` edits, no other lane's directory, no existing
|
||||||
|
anchor moved, no new block ids (31 still reserved). Portal-ring code lives in
|
||||||
|
`worlds/portalDressing.ts` and is invoked via `buildWorlds` so the buildBooth
|
||||||
|
diff stays two lines.
|
||||||
|
|
||||||
|
## Determinism & budgets
|
||||||
|
|
||||||
|
- **Deterministic:** two full builds byte-identical (asserted headless AND as
|
||||||
|
a standing demo validation line). All pocket randomness comes from
|
||||||
|
`mulberry32(WORLD_SEED ^ k)` streams private to this lane — the main
|
||||||
|
buildBooth rng is never consumed, so every pre-existing booth zone is
|
||||||
|
byte-identical to before this phase. acid/disco/portal dressing are fully
|
||||||
|
index-patterned (zero rng); only dub's embers draw randoms.
|
||||||
|
- **Build time:** buildBooth alone 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.
|
||||||
@ -32,6 +32,8 @@ const AVATAR_COOLDOWN_MS = 2000; // 1 avatar change per 2 s per peer
|
|||||||
const RESET_COOLDOWN_MS = 10 * 60_000; // anti-grief: 10 min since the win
|
const RESET_COOLDOWN_MS = 10 * 60_000; // anti-grief: 10 min since the win
|
||||||
const AVATAR_RANGES = { hue: 12, gear: 5, face: 4 }; // enum sizes (0..n-1)
|
const AVATAR_RANGES = { hue: 12, gear: 5, face: 4 }; // enum sizes (0..n-1)
|
||||||
const EMOTE_KINDS = 4; // 0..3 = wave | nod | point | airhorn
|
const EMOTE_KINDS = 4; // 0..3 = wave | nod | point | airhorn
|
||||||
|
// SIDE B crate worlds — mirrors src/core/worlds.ts WORLD_KEYS.
|
||||||
|
const STAMP_WORLDS = new Set(['acid', 'dub', 'disco']);
|
||||||
// Grief ceiling: the diff map must stay bounded (18M voxels × ~30B/entry
|
// Grief ceiling: the diff map must stay bounded (18M voxels × ~30B/entry
|
||||||
// would be ~550MB if a bot painted the whole booth). ~400k edits ≈ 25MB.
|
// would be ~550MB if a bot painted the whole booth). ~400k edits ≈ 25MB.
|
||||||
const MAX_EDITS = 400_000;
|
const MAX_EDITS = 400_000;
|
||||||
@ -49,6 +51,9 @@ const edits = new Map();
|
|||||||
const repaired = new Set();
|
const repaired = new Set();
|
||||||
const platters = { A: { playing: false, rpm: 33 }, B: { playing: false, rpm: 33 } };
|
const platters = { A: { playing: false, rpm: 33 }, B: { playing: false, rpm: 33 } };
|
||||||
let workshop = null; // last-write-wins Headshell Workshop assembly state (or null)
|
let workshop = null; // last-write-wins Headshell Workshop assembly state (or null)
|
||||||
|
/** SIDE B stamps (world keys). Global like repairs; NEVER cleared by reset —
|
||||||
|
* stamps are pressed into the records, not the booth. */
|
||||||
|
const stamps = new Set();
|
||||||
let dirty = false;
|
let dirty = false;
|
||||||
/** ms epoch of the last win — gates the reset cooldown. 0 = "long ago". */
|
/** ms epoch of the last win — gates the reset cooldown. 0 = "long ago". */
|
||||||
let winAt = 0;
|
let winAt = 0;
|
||||||
@ -113,6 +118,7 @@ function loadState() {
|
|||||||
Object.assign(platters.A, s.platters?.A ?? {});
|
Object.assign(platters.A, s.platters?.A ?? {});
|
||||||
Object.assign(platters.B, s.platters?.B ?? {});
|
Object.assign(platters.B, s.platters?.B ?? {});
|
||||||
workshop = validWorkshop(s.workshop);
|
workshop = validWorkshop(s.workshop);
|
||||||
|
for (const w of s.stamps ?? []) if (STAMP_WORLDS.has(w)) stamps.add(w);
|
||||||
// Old state files predate winAt: treat as "won long ago" so the reset
|
// Old state files predate winAt: treat as "won long ago" so the reset
|
||||||
// cooldown is already expired rather than blocking forever.
|
// cooldown is already expired rather than blocking forever.
|
||||||
winAt = Number.isFinite(s.winAt) ? s.winAt : 0;
|
winAt = Number.isFinite(s.winAt) ? s.winAt : 0;
|
||||||
@ -125,11 +131,20 @@ function loadState() {
|
|||||||
function saveState() {
|
function saveState() {
|
||||||
if (!dirty) return;
|
if (!dirty) return;
|
||||||
dirty = false;
|
dirty = false;
|
||||||
const tmp = STATE_FILE + '.tmp';
|
// A throwing write (ENOSPC, perms) must neither crash the relay from inside
|
||||||
writeFileSync(tmp, JSON.stringify({
|
// a ws message handler nor mark the unsaved state clean — restore `dirty` so
|
||||||
edits: [...edits.entries()], repaired: [...repaired], platters, workshop, winAt,
|
// the periodic saver retries once the disk recovers.
|
||||||
}));
|
try {
|
||||||
renameSync(tmp, STATE_FILE);
|
const tmp = STATE_FILE + '.tmp';
|
||||||
|
writeFileSync(tmp, JSON.stringify({
|
||||||
|
edits: [...edits.entries()], repaired: [...repaired], platters, workshop, winAt,
|
||||||
|
stamps: [...stamps],
|
||||||
|
}));
|
||||||
|
renameSync(tmp, STATE_FILE);
|
||||||
|
} catch (e) {
|
||||||
|
dirty = true;
|
||||||
|
console.error('[relay] state save failed (will retry):', e.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- helpers -----------------------------------------------------------
|
// ---- helpers -----------------------------------------------------------
|
||||||
@ -178,7 +193,7 @@ wss.on('connection', (ws) => {
|
|||||||
t: 'hello', id,
|
t: 'hello', id,
|
||||||
peers: [...peers.entries()].filter(([pid]) => pid !== id)
|
peers: [...peers.entries()].filter(([pid]) => pid !== id)
|
||||||
.map(([pid, p]) => ({ id: pid, name: p.name, avatar: p.avatar })),
|
.map(([pid, p]) => ({ id: pid, name: p.name, avatar: p.avatar })),
|
||||||
state: { edits: [...edits.entries()], repaired: [...repaired], platters, workshop },
|
state: { edits: [...edits.entries()], repaired: [...repaired], platters, workshop, stamps: [...stamps] },
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on('pong', () => { peer.alive = true; });
|
ws.on('pong', () => { peer.alive = true; });
|
||||||
@ -226,6 +241,8 @@ wss.on('connection', (ws) => {
|
|||||||
winAt = 0;
|
winAt = 0;
|
||||||
dirty = true;
|
dirty = true;
|
||||||
// NOTE: `edits` is deliberately untouched — player builds survive.
|
// NOTE: `edits` is deliberately untouched — player builds survive.
|
||||||
|
// NOTE: `stamps` is deliberately untouched too — stamps are pressed
|
||||||
|
// into the records, not the booth (SIDE B M5: they survive the reset).
|
||||||
// Carry the workshop pose so live clients land on the same state a late
|
// Carry the workshop pose so live clients land on the same state a late
|
||||||
// joiner would get from `hello.state.workshop`.
|
// joiner would get from `hello.state.workshop`.
|
||||||
broadcast({ t: 'reset', by: peer.name, workshop }); // everyone incl. the breaker
|
broadcast({ t: 'reset', by: peer.name, workshop }); // everyone incl. the breaker
|
||||||
@ -272,6 +289,19 @@ wss.on('connection', (ws) => {
|
|||||||
broadcast({ t: 'workshop', s }, id);
|
broadcast({ t: 'workshop', s }, id);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'stamp': {
|
||||||
|
// SIDE B (default-closed): w must be exactly a known world key; a
|
||||||
|
// repeat stamp is dropped (idempotent — no rebroadcast spam). Stamps
|
||||||
|
// are meta-progress, so they persist immediately, and the reset
|
||||||
|
// ritual deliberately never touches them.
|
||||||
|
if (typeof m.w !== 'string' || !STAMP_WORLDS.has(m.w)) return;
|
||||||
|
if (stamps.has(m.w)) return;
|
||||||
|
stamps.add(m.w);
|
||||||
|
dirty = true;
|
||||||
|
broadcast({ t: 'stamp', w: m.w }, id);
|
||||||
|
saveState();
|
||||||
|
break;
|
||||||
|
}
|
||||||
default: // unknown type: ignore
|
default: // unknown type: ignore
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -5,8 +5,16 @@
|
|||||||
// constructs it; the public methods exist for the demo and direct control.
|
// constructs it; the public methods exist for the demo and direct control.
|
||||||
//
|
//
|
||||||
// music path: stems -> vinylShelf -> musicGain -> masterLP -> {pannerA,
|
// music path: stems -> vinylShelf -> musicGain -> masterLP -> {pannerA,
|
||||||
// pannerB, dryBed, analyser} -> outGain -> destination
|
// pannerB, dryBed, analyser} -> boothGain -> outGain -> dest
|
||||||
// sfx path: oneshot -> [panner(at)] -> sfxGain -> outGain -> destination
|
// sfx path: oneshot -> [panner(at)] -> sfxGain -> outGain -> destination
|
||||||
|
// SIDE B path: pocket groove -> worldBus -> outGain (+ analyser tap)
|
||||||
|
//
|
||||||
|
// SIDE B (SIDEB_AUDIO.md): everything the booth plays — stems, heartbeat, dry
|
||||||
|
// bed, deck panners — funnels through `boothGain`. Entering a pocket world
|
||||||
|
// ducks that ONE gain out (300 ms) and starts the world's own groove; exiting
|
||||||
|
// un-ducks it. The booth graph stays LIVE underneath the duck (repairs, the
|
||||||
|
// reset ritual, a win — they all keep flowing), so the handback never restores
|
||||||
|
// a snapshot: it simply reveals whatever the booth sounds like right now.
|
||||||
|
|
||||||
import { bus } from '../core/events';
|
import { bus } from '../core/events';
|
||||||
import { LAYOUT, PLATTER, Y_RECORD_TOP } from '../core/constants';
|
import { LAYOUT, PLATTER, Y_RECORD_TOP } from '../core/constants';
|
||||||
@ -15,6 +23,7 @@ import type { IPlayerView, Vec3 } from '../core/types';
|
|||||||
import { Groove } from './groove';
|
import { Groove } from './groove';
|
||||||
import { Transport } from './scheduler';
|
import { Transport } from './scheduler';
|
||||||
import { createNoiseBuffer, createCrackleBuffer } from './synth';
|
import { createNoiseBuffer, createCrackleBuffer } from './synth';
|
||||||
|
import { WorldGrooves, isPocketWorld, type PocketWorld } from './worldGrooves';
|
||||||
import * as SFX from './sfx';
|
import * as SFX from './sfx';
|
||||||
import { playTestSignal, errorsFromKeys, type TestErrors } from './testSignal';
|
import { playTestSignal, errorsFromKeys, type TestErrors } from './testSignal';
|
||||||
|
|
||||||
@ -47,6 +56,8 @@ export class AudioEngine {
|
|||||||
|
|
||||||
// graph nodes
|
// graph nodes
|
||||||
private outGain!: GainNode;
|
private outGain!: GainNode;
|
||||||
|
private boothGain!: GainNode; // everything the booth plays (ducked in pockets)
|
||||||
|
private worldBus!: GainNode; // SIDE B pocket grooves
|
||||||
private musicGain!: GainNode;
|
private musicGain!: GainNode;
|
||||||
private masterLP!: BiquadFilterNode;
|
private masterLP!: BiquadFilterNode;
|
||||||
private sfxGain!: GainNode;
|
private sfxGain!: GainNode;
|
||||||
@ -74,6 +85,16 @@ export class AudioEngine {
|
|||||||
private heartTimer: number | null = null;
|
private heartTimer: number | null = null;
|
||||||
private heartDest: AudioNode | null = null;
|
private heartDest: AudioNode | null = null;
|
||||||
|
|
||||||
|
// SIDE B — crate worlds. State kept even pre-init (join snapshots and demo
|
||||||
|
// clicks can arrive before the user gesture); init() applies it.
|
||||||
|
private worlds: WorldGrooves | null = null;
|
||||||
|
private currentWorld: PocketWorld | null = null;
|
||||||
|
private acidPending = [0.12, 0.85, 0.18]; // last fader:move acid_0..2 values
|
||||||
|
private dubCarrying = false;
|
||||||
|
private stampedWorlds = new Set<string>();
|
||||||
|
private stampAllOn = false;
|
||||||
|
private lastBunny = -10;
|
||||||
|
|
||||||
private readonly levels = { low: 0, mid: 0, high: 0 };
|
private readonly levels = { low: 0, mid: 0, high: 0 };
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@ -111,6 +132,12 @@ export class AudioEngine {
|
|||||||
this.sfxGain.gain.value = 0.9;
|
this.sfxGain.gain.value = 0.9;
|
||||||
this.sfxGain.connect(this.outGain);
|
this.sfxGain.connect(this.outGain);
|
||||||
|
|
||||||
|
// every booth music source (stems via panners/dry bed, heartbeat) funnels
|
||||||
|
// through this one gain so pocket dives can duck the WHOLE booth live
|
||||||
|
this.boothGain = ctx.createGain();
|
||||||
|
this.boothGain.gain.value = 1;
|
||||||
|
this.boothGain.connect(this.outGain);
|
||||||
|
|
||||||
this.masterLP = ctx.createBiquadFilter();
|
this.masterLP = ctx.createBiquadFilter();
|
||||||
this.masterLP.type = 'lowpass';
|
this.masterLP.type = 'lowpass';
|
||||||
this.masterLP.frequency.value = 20000; // transparent until the win sweep
|
this.masterLP.frequency.value = 20000; // transparent until the win sweep
|
||||||
@ -152,7 +179,7 @@ export class AudioEngine {
|
|||||||
const dryLP = ctx.createBiquadFilter();
|
const dryLP = ctx.createBiquadFilter();
|
||||||
dryLP.type = 'lowpass'; dryLP.frequency.value = 380;
|
dryLP.type = 'lowpass'; dryLP.frequency.value = 380;
|
||||||
const dryGain = ctx.createGain(); dryGain.gain.value = 0.5;
|
const dryGain = ctx.createGain(); dryGain.gain.value = 0.5;
|
||||||
this.masterLP.connect(dryLP); dryLP.connect(dryGain); dryGain.connect(this.outGain);
|
this.masterLP.connect(dryLP); dryLP.connect(dryGain); dryGain.connect(this.boothGain);
|
||||||
|
|
||||||
// positional taps at each deck spindle
|
// positional taps at each deck spindle
|
||||||
for (const d of ['A', 'B'] as Deck[]) {
|
for (const d of ['A', 'B'] as Deck[]) {
|
||||||
@ -164,7 +191,7 @@ export class AudioEngine {
|
|||||||
this.setPos(p, lay.spindleX, Y_RECORD_TOP, lay.spindleZ);
|
this.setPos(p, lay.spindleX, Y_RECORD_TOP, lay.spindleZ);
|
||||||
const gate = ctx.createGain();
|
const gate = ctx.createGain();
|
||||||
gate.gain.value = 0; // faded in when that deck is playing
|
gate.gain.value = 0; // faded in when that deck is playing
|
||||||
this.masterLP.connect(gate); gate.connect(p); p.connect(this.outGain);
|
this.masterLP.connect(gate); gate.connect(p); p.connect(this.boothGain);
|
||||||
this.panner[d] = p; this.panGate[d] = gate;
|
this.panner[d] = p; this.panGate[d] = gate;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -189,17 +216,33 @@ export class AudioEngine {
|
|||||||
const lp = ctx.createBiquadFilter();
|
const lp = ctx.createBiquadFilter();
|
||||||
lp.type = 'lowpass'; lp.frequency.value = 240;
|
lp.type = 'lowpass'; lp.frequency.value = 240;
|
||||||
const g = ctx.createGain(); g.gain.value = 0.55;
|
const g = ctx.createGain(); g.gain.value = 0.55;
|
||||||
lp.connect(g); g.connect(hp); hp.connect(this.outGain);
|
lp.connect(g); g.connect(hp); hp.connect(this.boothGain);
|
||||||
this.heartDest = lp;
|
this.heartDest = lp;
|
||||||
this.heartNext = ctx.currentTime + 0.5;
|
this.heartNext = ctx.currentTime + 0.5;
|
||||||
this.heartTimer = window.setInterval(() => this.scheduleHeartbeat(), 250);
|
this.heartTimer = window.setInterval(() => this.scheduleHeartbeat(), 250);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── SIDE B pocket-world layer ──
|
||||||
|
this.worldBus = ctx.createGain();
|
||||||
|
this.worldBus.gain.value = 0.95;
|
||||||
|
this.worldBus.connect(this.outGain);
|
||||||
|
this.worldBus.connect(this.analyser); // pocket grooves drive the VU too
|
||||||
|
this.worlds = new WorldGrooves(ctx, this.noise, this.worldBus);
|
||||||
|
|
||||||
// apply any state that arrived before init
|
// apply any state that arrived before init
|
||||||
this.groove.setStemCount(this.stemCount);
|
this.groove.setStemCount(this.stemCount);
|
||||||
for (let i = 0; i < this.channel.length; i++) this.groove.setChannelGain(i, this.channel[i]);
|
for (let i = 0; i < this.channel.length; i++) this.groove.setChannelGain(i, this.channel[i]);
|
||||||
this.transport.setPitchTrim(this.pitchTrim);
|
this.transport.setPitchTrim(this.pitchTrim);
|
||||||
this.applyPlayback();
|
this.applyPlayback();
|
||||||
|
for (let i = 0; i < 3; i++) this.worlds.setAcidBand(i, this.acidPending[i]);
|
||||||
|
if (this.dubCarrying) this.worlds.setCarrying(true);
|
||||||
|
if (this.stampAllOn) this.groove.setBonusStem(true); // won crate: silent arm
|
||||||
|
if (this.currentWorld) {
|
||||||
|
// init happened mid-dive (events arrived pre-gesture): land in the pocket
|
||||||
|
this.setBoothDuck(true, true);
|
||||||
|
this.groove.setEmitBeats(false);
|
||||||
|
this.worlds.start(this.currentWorld);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Public API ────────────────────────────────────────────────────────────
|
// ── Public API ────────────────────────────────────────────────────────────
|
||||||
@ -255,6 +298,12 @@ export class AudioEngine {
|
|||||||
// Social & Reset Ritual
|
// Social & Reset Ritual
|
||||||
case 'airhorn': SFX.airhorn(ctx, dest, t); break;
|
case 'airhorn': SFX.airhorn(ctx, dest, t); break;
|
||||||
case 'powerDown': SFX.powerDown(ctx, dest, this.noise, t); break;
|
case 'powerDown': SFX.powerDown(ctx, dest, this.noise, t); break;
|
||||||
|
// SIDE B — crate worlds
|
||||||
|
case 'portalIn': SFX.portalWhoosh(ctx, dest, this.noise, t, 'down'); break;
|
||||||
|
case 'portalOut': SFX.portalWhoosh(ctx, dest, this.noise, t, 'up'); break;
|
||||||
|
case 'stampThunk': SFX.stampThunk(ctx, dest, this.noise, t); break;
|
||||||
|
case 'goldenRiser': SFX.goldenRiser(ctx, dest, this.noise, t); break;
|
||||||
|
case 'bunny': SFX.bunnySqueak(ctx, dest, t); break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -387,6 +436,128 @@ export class AudioEngine {
|
|||||||
this.updateCrackle();
|
this.updateCrackle();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── SIDE B — pocket dives, stamps, the golden unlock ─────────────────────
|
||||||
|
|
||||||
|
/** Duck/undock the entire booth path (stems + heartbeat + dry bed). */
|
||||||
|
private setBoothDuck(on: boolean, immediate = false): void {
|
||||||
|
if (!this.ctx) return;
|
||||||
|
const g = this.boothGain.gain, now = this.ctx.currentTime;
|
||||||
|
g.cancelScheduledValues(now);
|
||||||
|
if (immediate) {
|
||||||
|
g.setValueAtTime(on ? 0 : 1, now);
|
||||||
|
} else {
|
||||||
|
g.setValueAtTime(g.value, now);
|
||||||
|
g.linearRampToValueAtTime(on ? 0 : 1, now + (on ? 0.3 : 0.45));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enterPocket(world: string): void {
|
||||||
|
if (!isPocketWorld(world)) return;
|
||||||
|
if (this.currentWorld === world) return;
|
||||||
|
this.currentWorld = world;
|
||||||
|
if (!this.ctx || !this.worlds) return;
|
||||||
|
SFX.portalWhoosh(this.ctx, this.sfxGain, this.noise, this.ctx.currentTime + 0.001, 'down');
|
||||||
|
this.setBoothDuck(true);
|
||||||
|
this.groove.setEmitBeats(false); // the pocket groove owns the beat now
|
||||||
|
this.worlds.start(world);
|
||||||
|
}
|
||||||
|
|
||||||
|
private exitPocket(): void {
|
||||||
|
if (this.currentWorld === null) return;
|
||||||
|
this.currentWorld = null;
|
||||||
|
if (!this.ctx || !this.worlds) return;
|
||||||
|
SFX.portalWhoosh(this.ctx, this.sfxGain, this.noise, this.ctx.currentTime + 0.001, 'up');
|
||||||
|
this.worlds.stop();
|
||||||
|
// Handback restores nothing from a snapshot: the booth path stayed live
|
||||||
|
// under the duck (resets, wins, repairs kept flowing), so un-ducking
|
||||||
|
// reveals whatever the booth ACTUALLY sounds like right now — heartbeat
|
||||||
|
// for a dead booth, the full mix for a won one, silence mid-reset.
|
||||||
|
this.setBoothDuck(false);
|
||||||
|
this.groove.setEmitBeats(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onStampGot(p: { world: string; count: number; total: number; origin?: string }): void {
|
||||||
|
const first = !this.stampedWorlds.has(p.world);
|
||||||
|
this.stampedWorlds.add(p.world);
|
||||||
|
// The event's origin decides the sound, not a wall clock: 'replay' (join
|
||||||
|
// snapshot — possibly arriving LONG after boot on a slow reconnect) arms
|
||||||
|
// state silently; 'remote' shares the moment with the thunk only; 'local'
|
||||||
|
// gets the full fanfare + in-groove glory. First-sighting dedupe on top.
|
||||||
|
const replay = p.origin === 'replay';
|
||||||
|
if (first && !replay && this.ctx) {
|
||||||
|
const t = this.ctx.currentTime + 0.001;
|
||||||
|
SFX.stampThunk(this.ctx, this.sfxGain, this.noise, t);
|
||||||
|
if (p.origin !== 'remote' && isPocketWorld(p.world)) {
|
||||||
|
SFX.stampFanfare(this.ctx, this.sfxGain, this.noise, t + 0.12, p.world);
|
||||||
|
this.worlds?.stampGlory(p.world);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (p.count >= p.total) this.unlockBonus(replay);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** stamp:all (or derived count===total): golden riser once, then the bonus
|
||||||
|
* percussion stem — permanently. Replay/pre-gesture arrivals arm silently. */
|
||||||
|
private unlockBonus(replay = false): void {
|
||||||
|
if (!this.stampAllOn) {
|
||||||
|
this.stampAllOn = true;
|
||||||
|
if (this.ctx && !replay) {
|
||||||
|
const t = this.ctx.currentTime + 0.55; // let the third stamp's thunk land
|
||||||
|
SFX.goldenRiser(this.ctx, this.sfxGain, this.noise, t);
|
||||||
|
this.groove.setBonusStem(true, t + 2.0); // percussion enters at the bloom
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// silent arm (replay, or context not up yet): the stem simply rides
|
||||||
|
if (this.ctx) this.groove.setBonusStem(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// idempotent re-arrivals (boot snapshot + a following stamp:all)
|
||||||
|
if (this.ctx && !this.groove.getBonusStem()) this.groove.setBonusStem(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setAcidBandValue(band: number, v: number): void {
|
||||||
|
if (band < 0 || band > 2 || !Number.isFinite(v)) return;
|
||||||
|
this.acidPending[band] = Math.max(0, Math.min(1, v));
|
||||||
|
this.worlds?.setAcidBand(band, this.acidPending[band]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tolerant index for SIDE B interactions: runtime `index` field if Lane M
|
||||||
|
* casts one in, else trailing digits of machineId, else of action. */
|
||||||
|
private eventIndex(p: { machineId: string; action: string }, fallback: number): number {
|
||||||
|
const runtime = (p as unknown as { index?: unknown }).index;
|
||||||
|
if (typeof runtime === 'number' && Number.isFinite(runtime)) return runtime;
|
||||||
|
const m = /(\d+)\s*$/.exec(p.machineId) ?? /(\d+)\s*$/.exec(p.action);
|
||||||
|
return m ? parseInt(m[1], 10) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Route a SIDE B machine:interact; false = not ours, fall through. */
|
||||||
|
private sidebAction(a: string, p: { machineId: string; action: string }): boolean {
|
||||||
|
if (a === 'bunny_flee') {
|
||||||
|
if (this.ctx) {
|
||||||
|
const now = this.ctx.currentTime;
|
||||||
|
if (now - this.lastBunny > 1.1) { // shy: never a squeak chorus
|
||||||
|
this.lastBunny = now;
|
||||||
|
SFX.bunnySqueak(this.ctx, this.sfxGain, now + 0.001);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (a.startsWith('acid_lock')) {
|
||||||
|
const fallback = this.worlds ? this.worlds.firstUnlockedBand() : 0;
|
||||||
|
this.worlds?.acidLock(this.eventIndex(p, fallback));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (a.startsWith('acid')) return true; // acid_knob/acid_tuned: the sweep/stamp IS the sound
|
||||||
|
if (a.startsWith('dub_pickup')) { this.dubCarrying = true; this.worlds?.setCarrying(true); return true; }
|
||||||
|
if (a.startsWith('dub_drop')) { this.dubCarrying = false; this.worlds?.setCarrying(false); return true; }
|
||||||
|
if (a.startsWith('dub_feed')) { this.dubCarrying = false; this.worlds?.dubFeed(); return true; }
|
||||||
|
if (a.startsWith('disco_cue')) { this.worlds?.discoCue(this.eventIndex(p, 0)); return true; }
|
||||||
|
if (a.startsWith('disco_step')) { this.worlds?.discoStep(this.eventIndex(p, 0)); return true; }
|
||||||
|
if (a.startsWith('disco_miss')) { this.worlds?.discoMiss(); return true; }
|
||||||
|
if (a.startsWith('disco')) return true; // disco_lit: the stamp swell follows
|
||||||
|
if (a.startsWith('portal')) return true; // portal_dive/exit: world:enter/exit whooshes cover it
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Reset Ritual (SOCIAL_RESET): someone blew the fuse. The mix doesn't
|
* The Reset Ritual (SOCIAL_RESET): someone blew the fuse. The mix doesn't
|
||||||
* mute — the power dies. The transport brakes to a halt (the groove sags and
|
* mute — the power dies. The transport brakes to a halt (the groove sags and
|
||||||
@ -496,6 +667,12 @@ export class AudioEngine {
|
|||||||
|
|
||||||
bus.on('quest:reset', () => { this.repairs = 0; this.runResetAudio(); });
|
bus.on('quest:reset', () => { this.repairs = 0; this.runResetAudio(); });
|
||||||
|
|
||||||
|
// ── SIDE B — crate worlds (SIDEB_AUDIO.md) ──
|
||||||
|
bus.on('world:enter', (p) => this.enterPocket(p.world));
|
||||||
|
bus.on('world:exit', () => this.exitPocket());
|
||||||
|
bus.on('stamp:got', (p) => this.onStampGot(p));
|
||||||
|
bus.on('stamp:all', () => this.unlockBonus());
|
||||||
|
|
||||||
// Mining juice: quiet dry ticks whose pitch rises with hold progress.
|
// Mining juice: quiet dry ticks whose pitch rises with hold progress.
|
||||||
bus.on('block:mining', (p) => this.miningTick(p.progress));
|
bus.on('block:mining', (p) => this.miningTick(p.progress));
|
||||||
|
|
||||||
@ -512,6 +689,14 @@ export class AudioEngine {
|
|||||||
|
|
||||||
bus.on('fader:move', (p) => {
|
bus.on('fader:move', (p) => {
|
||||||
const id = p.faderId.toLowerCase();
|
const id = p.faderId.toLowerCase();
|
||||||
|
// SIDE B acid pedestals: acid_0..2 drive the 303's per-band cutoff —
|
||||||
|
// intercepted BEFORE the digit->channel map (else acid_0's '0' would
|
||||||
|
// duck a booth stem) and with no fader zip: the squelch IS the feedback.
|
||||||
|
if (id.startsWith('acid')) {
|
||||||
|
const m = /(\d+)\s*$/.exec(id);
|
||||||
|
this.setAcidBandValue(m ? parseInt(m[1], 10) : 0, p.value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (id.includes('pitch')) { this.setPitch(p.value * 2 - 1); return; }
|
if (id.includes('pitch')) { this.setPitch(p.value * 2 - 1); return; }
|
||||||
const m = id.match(/(\d)/);
|
const m = id.match(/(\d)/);
|
||||||
if (m) this.setChannelGain(Math.max(0, Math.min(4, parseInt(m[1], 10) - 1)), p.value);
|
if (m) this.setChannelGain(Math.max(0, Math.min(4, parseInt(m[1], 10) - 1)), p.value);
|
||||||
@ -535,6 +720,7 @@ export class AudioEngine {
|
|||||||
|
|
||||||
bus.on('machine:interact', (p) => {
|
bus.on('machine:interact', (p) => {
|
||||||
const a = p.action.toLowerCase();
|
const a = p.action.toLowerCase();
|
||||||
|
if (this.sidebAction(a, p)) return; // SIDE B first: 'disco_cue' contains 'cue'
|
||||||
if (this.workshopSfxFor(a)) return; // Headshell Workshop actions
|
if (this.workshopSfxFor(a)) return; // Headshell Workshop actions
|
||||||
if (a.includes('fader') || a.includes('pitch')) this.playSfx('fader');
|
if (a.includes('fader') || a.includes('pitch')) this.playSfx('fader');
|
||||||
else if (a.includes('cue') || a.includes('arm') || a.includes('tonearm')) this.playSfx('cue');
|
else if (a.includes('cue') || a.includes('arm') || a.includes('tonearm')) this.playSfx('cue');
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
// spin-up/brake and pitch-fader moves.
|
// spin-up/brake and pitch-fader moves.
|
||||||
|
|
||||||
import { bus } from '../core/events';
|
import { bus } from '../core/events';
|
||||||
import { mtof, kick, hat, clap, bass, stab, lead, sweep } from './synth';
|
import { mtof, kick, hat, clap, bass, stab, lead, sweep, shaker, conga } from './synth';
|
||||||
|
|
||||||
export const STEP_PER_BAR = 16;
|
export const STEP_PER_BAR = 16;
|
||||||
export const BARS = 8;
|
export const BARS = 8;
|
||||||
@ -51,9 +51,18 @@ export class Groove {
|
|||||||
private chan: GainNode[] = [];
|
private chan: GainNode[] = [];
|
||||||
private bassDuck: GainNode;
|
private bassDuck: GainNode;
|
||||||
|
|
||||||
|
// SIDE B bonus percussion stem (stamp:all reward). Own enable gain outside
|
||||||
|
// the 5-channel fader map; scheduling is gated so it costs nothing until won.
|
||||||
|
private bonusEnable: GainNode;
|
||||||
|
private bonus = false;
|
||||||
|
|
||||||
private stemCount = 0;
|
private stemCount = 0;
|
||||||
private barCounter = 0;
|
private barCounter = 0;
|
||||||
private chanValue = [1, 1, 1, 1, 1]; // last setChannelGain per stem (for beat energy)
|
private chanValue = [1, 1, 1, 1, 1]; // last setChannelGain per stem (for beat energy)
|
||||||
|
// While the player is inside a SIDE B pocket, that world's groove owns the
|
||||||
|
// audio:beat/audio:bar stream; the booth keeps playing (ducked) but goes
|
||||||
|
// quiet on the bus so two tempos never fight over the beat events.
|
||||||
|
private emitBeats = true;
|
||||||
|
|
||||||
constructor(ctx: BaseAudioContext, noise: AudioBuffer, out: AudioNode) {
|
constructor(ctx: BaseAudioContext, noise: AudioBuffer, out: AudioNode) {
|
||||||
this.ctx = ctx;
|
this.ctx = ctx;
|
||||||
@ -73,6 +82,10 @@ export class Groove {
|
|||||||
this.bassDuck = ctx.createGain();
|
this.bassDuck = ctx.createGain();
|
||||||
this.bassDuck.gain.value = 1;
|
this.bassDuck.gain.value = 1;
|
||||||
this.bassDuck.connect(this.enable[STEM.BASS]);
|
this.bassDuck.connect(this.enable[STEM.BASS]);
|
||||||
|
|
||||||
|
this.bonusEnable = ctx.createGain();
|
||||||
|
this.bonusEnable.gain.value = 0;
|
||||||
|
this.bonusEnable.connect(out);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Destination a stem's voices should connect into for this step. */
|
/** Destination a stem's voices should connect into for this step. */
|
||||||
@ -95,6 +108,26 @@ export class Groove {
|
|||||||
|
|
||||||
getStemCount(): number { return this.stemCount; }
|
getStemCount(): number { return this.stemCount; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SIDE B: the permanent shaker/conga layer earned by stamp:all. `rampAt`
|
||||||
|
* (ctx time) lets the unlock moment enter after the golden riser blooms;
|
||||||
|
* omitted = immediate (the silent boot-handshake path for a won crate).
|
||||||
|
*/
|
||||||
|
setBonusStem(on: boolean, rampAt?: number): void {
|
||||||
|
this.bonus = on;
|
||||||
|
const g = this.bonusEnable.gain;
|
||||||
|
const t = Math.max(this.ctx.currentTime, rampAt ?? this.ctx.currentTime);
|
||||||
|
g.cancelScheduledValues(this.ctx.currentTime);
|
||||||
|
g.setValueAtTime(g.value, this.ctx.currentTime);
|
||||||
|
g.setValueAtTime(g.value, t);
|
||||||
|
g.linearRampToValueAtTime(on ? 1 : 0, t + (rampAt ? 1.2 : 0.05));
|
||||||
|
}
|
||||||
|
|
||||||
|
getBonusStem(): boolean { return this.bonus; }
|
||||||
|
|
||||||
|
/** Suppress/restore audio:beat + audio:bar emission (pocket dives). */
|
||||||
|
setEmitBeats(on: boolean): void { this.emitBeats = on; }
|
||||||
|
|
||||||
/** Live channel/fader ducking (0..1). `ch` is a stem index (0..4). */
|
/** Live channel/fader ducking (0..1). `ch` is a stem index (0..4). */
|
||||||
setChannelGain(ch: number, v: number): void {
|
setChannelGain(ch: number, v: number): void {
|
||||||
if (ch < 0 || ch >= STEM_COUNT) return;
|
if (ch < 0 || ch >= STEM_COUNT) return;
|
||||||
@ -171,10 +204,23 @@ export class Groove {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── BONUS (stamp:all) ── shaker 16ths + a two-bar conga clave. Rides any
|
||||||
|
// audible mix (gated on stemCount>0 so a dead booth never shakes alone).
|
||||||
|
if (this.bonus && ct > 0) {
|
||||||
|
const acc = s % 4 === 2 ? 0.13 : s % 2 === 0 ? 0.055 : 0.085;
|
||||||
|
shaker(this.ctx, this.bonusEnable, this.noise, time, { gain: acc, detune });
|
||||||
|
const odd = bar % 2 === 1;
|
||||||
|
if (s === 3 || s === 11) {
|
||||||
|
conga(this.ctx, this.bonusEnable, this.noise, time, { gain: 0.2, detune });
|
||||||
|
} else if (s === (odd ? 14 : 6)) {
|
||||||
|
conga(this.ctx, this.bonusEnable, this.noise, time, { gain: 0.17, detune, slap: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Beat / bar events (only meaningful once drums are audible) ──
|
// ── Beat / bar events (only meaningful once drums are audible) ──
|
||||||
// energy ≈ low-band loudness of this beat: kick emphasis (downbeat louder)
|
// energy ≈ low-band loudness of this beat: kick emphasis (downbeat louder)
|
||||||
// scaled by the drums channel gain, so ducking the fader drops the pulse.
|
// scaled by the drums channel gain, so ducking the fader drops the pulse.
|
||||||
if (ct > STEM.DRUMS) {
|
if (this.emitBeats && ct > STEM.DRUMS) {
|
||||||
if (s % 4 === 0) {
|
if (s % 4 === 0) {
|
||||||
const energy = (s === 0 ? 1.0 : 0.82) * this.chanValue[STEM.DRUMS];
|
const energy = (s === 0 ? 1.0 : 0.82) * this.chanValue[STEM.DRUMS];
|
||||||
emitAt(time, () => bus.emit('audio:beat', { energy }));
|
emitAt(time, () => bus.emit('audio:beat', { energy }));
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
// (pitch) — tempo and pitch bend together, exactly like a real platter.
|
// (pitch) — tempo and pitch bend together, exactly like a real platter.
|
||||||
|
|
||||||
export const BPM = 118;
|
export const BPM = 118;
|
||||||
const SEC_PER_16TH = 60 / BPM / 4; // 0.1271 s at nominal speed
|
|
||||||
const LOOKAHEAD = 0.12; // schedule this far ahead (s)
|
const LOOKAHEAD = 0.12; // schedule this far ahead (s)
|
||||||
const TICK_MS = 25; // scheduler wake interval
|
const TICK_MS = 25; // scheduler wake interval
|
||||||
const RATE_MIN = 0.02; // below this the platter counts as stopped
|
const RATE_MIN = 0.02; // below this the platter counts as stopped
|
||||||
@ -29,13 +28,16 @@ export class Transport {
|
|||||||
private rateTarget = 0;
|
private rateTarget = 0;
|
||||||
private spinTau: number; // exponential time constant for spin-up/brake
|
private spinTau: number; // exponential time constant for spin-up/brake
|
||||||
private pitchTrim = 0; // extra detune (cents) from the pitch fader
|
private pitchTrim = 0; // extra detune (cents) from the pitch fader
|
||||||
|
private secPer16th: number;
|
||||||
|
|
||||||
private pending: Pending[] = [];
|
private pending: Pending[] = [];
|
||||||
|
|
||||||
constructor(ctx: BaseAudioContext, onStep: StepFn, spinUpSeconds = 1.2) {
|
/** `bpm` defaults to the booth's 118; SIDE B pocket grooves pass their own. */
|
||||||
|
constructor(ctx: BaseAudioContext, onStep: StepFn, spinUpSeconds = 1.2, bpm = BPM) {
|
||||||
this.ctx = ctx;
|
this.ctx = ctx;
|
||||||
this.onStep = onStep;
|
this.onStep = onStep;
|
||||||
this.spinTau = spinUpSeconds / 3; // ~settles within spinUpSeconds
|
this.spinTau = spinUpSeconds / 3; // ~settles within spinUpSeconds
|
||||||
|
this.secPer16th = 60 / bpm / 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Begin the wake loop. Idempotent; stays cheap while the platter is stopped. */
|
/** Begin the wake loop. Idempotent; stays cheap while the platter is stopped. */
|
||||||
@ -80,7 +82,7 @@ export class Transport {
|
|||||||
while (this.nextNoteTime < now + LOOKAHEAD) {
|
while (this.nextNoteTime < now + LOOKAHEAD) {
|
||||||
const detune = 1200 * Math.log2(this.rate) + this.pitchTrim;
|
const detune = 1200 * Math.log2(this.rate) + this.pitchTrim;
|
||||||
this.onStep(this.nextStep, this.nextNoteTime, detune, this.emitAt);
|
this.onStep(this.nextStep, this.nextNoteTime, detune, this.emitAt);
|
||||||
this.nextNoteTime += SEC_PER_16TH / this.rate;
|
this.nextNoteTime += this.secPer16th / this.rate;
|
||||||
this.nextStep++;
|
this.nextStep++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
263
src/audio/sfx.ts
263
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);
|
src.start(t); src.stop(t + 0.27);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── SIDE B — crate worlds (SIDEB_AUDIO.md) ──────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Portal transition. `dir: 'down'` = diving INTO a record (world:enter): a
|
||||||
|
* vinyl slow-down — detuned saws diving with wow-flutter under a falling noise
|
||||||
|
* whoosh. `dir: 'up'` = the exact reverse spin-up (world:exit).
|
||||||
|
*/
|
||||||
|
export function portalWhoosh(
|
||||||
|
ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number, dir: 'down' | 'up',
|
||||||
|
): void {
|
||||||
|
const down = dir === 'down';
|
||||||
|
const dur = down ? 0.8 : 0.65;
|
||||||
|
// noise whoosh through a diving/rising bandpass
|
||||||
|
const src = ctx.createBufferSource();
|
||||||
|
src.buffer = noise; src.loop = true;
|
||||||
|
const bp = ctx.createBiquadFilter();
|
||||||
|
bp.type = 'bandpass'; bp.Q.value = 1.1;
|
||||||
|
bp.frequency.setValueAtTime(down ? 4200 : 170, t);
|
||||||
|
bp.frequency.exponentialRampToValueAtTime(down ? 170 : 4200, t + dur);
|
||||||
|
const ng = ctx.createGain();
|
||||||
|
ng.gain.setValueAtTime(0.0001, t);
|
||||||
|
ng.gain.exponentialRampToValueAtTime(0.3, t + dur * 0.35);
|
||||||
|
ng.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
||||||
|
src.connect(bp); bp.connect(ng); ng.connect(dest);
|
||||||
|
src.start(t); src.stop(t + dur + 0.03);
|
||||||
|
// the record itself slowing down / spinning up: two detuned saws with wow
|
||||||
|
const lp = ctx.createBiquadFilter();
|
||||||
|
lp.type = 'lowpass';
|
||||||
|
lp.frequency.setValueAtTime(down ? 2400 : 320, t);
|
||||||
|
lp.frequency.exponentialRampToValueAtTime(down ? 320 : 2400, t + dur);
|
||||||
|
const tg = ctx.createGain();
|
||||||
|
tg.gain.setValueAtTime(0.0001, t);
|
||||||
|
tg.gain.exponentialRampToValueAtTime(0.13, t + 0.05);
|
||||||
|
tg.gain.setValueAtTime(0.13, t + dur * 0.7);
|
||||||
|
tg.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
||||||
|
lp.connect(tg); tg.connect(dest);
|
||||||
|
// wow-flutter LFO wobbles both saw pitches
|
||||||
|
const wow = ctx.createOscillator();
|
||||||
|
wow.frequency.value = down ? 6.5 : 8.5;
|
||||||
|
const wowAmt = ctx.createGain(); wowAmt.gain.value = 35; // cents
|
||||||
|
wow.connect(wowAmt);
|
||||||
|
for (const cents of [-7, 6]) {
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
osc.type = 'sawtooth';
|
||||||
|
osc.detune.value = cents;
|
||||||
|
osc.frequency.setValueAtTime(down ? 310 : 42, t);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(down ? 42 : 310, t + dur);
|
||||||
|
wowAmt.connect(osc.detune);
|
||||||
|
osc.connect(lp);
|
||||||
|
osc.start(t); osc.stop(t + dur + 0.03);
|
||||||
|
}
|
||||||
|
wow.start(t); wow.stop(t + dur + 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Press-stamp THUNK: heavy die drop, felt slap, tiny lift squeak. */
|
||||||
|
export function stampThunk(ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number): void {
|
||||||
|
// low die body
|
||||||
|
const g = ctx.createGain();
|
||||||
|
g.gain.setValueAtTime(0.0001, t);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.55, t + 0.006);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.3);
|
||||||
|
g.connect(dest);
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
osc.type = 'sine';
|
||||||
|
osc.frequency.setValueAtTime(130, t);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(42, t + 0.16);
|
||||||
|
osc.connect(g); osc.start(t); osc.stop(t + 0.32);
|
||||||
|
// mid "chunk"
|
||||||
|
const cg = ctx.createGain();
|
||||||
|
cg.gain.setValueAtTime(0.22, t);
|
||||||
|
cg.gain.exponentialRampToValueAtTime(0.0001, t + 0.09);
|
||||||
|
cg.connect(dest);
|
||||||
|
const co = ctx.createOscillator();
|
||||||
|
co.type = 'triangle';
|
||||||
|
co.frequency.setValueAtTime(340, t);
|
||||||
|
co.frequency.exponentialRampToValueAtTime(110, t + 0.08);
|
||||||
|
co.connect(cg); co.start(t); co.stop(t + 0.11);
|
||||||
|
// felt slap
|
||||||
|
const src = ctx.createBufferSource();
|
||||||
|
src.buffer = noise; src.loop = true;
|
||||||
|
const lp = ctx.createBiquadFilter();
|
||||||
|
lp.type = 'lowpass'; lp.frequency.value = 1300;
|
||||||
|
const ng = ctx.createGain();
|
||||||
|
ng.gain.setValueAtTime(0.3, t);
|
||||||
|
ng.gain.exponentialRampToValueAtTime(0.0001, t + 0.06);
|
||||||
|
src.connect(lp); lp.connect(ng); ng.connect(dest);
|
||||||
|
src.start(t); src.stop(t + 0.08);
|
||||||
|
// the stamp lifting off (tiny squeak, delayed)
|
||||||
|
const sq = ctx.createOscillator();
|
||||||
|
sq.type = 'sine';
|
||||||
|
sq.frequency.setValueAtTime(900, t + 0.24);
|
||||||
|
sq.frequency.exponentialRampToValueAtTime(1500, t + 0.3);
|
||||||
|
const sg = ctx.createGain();
|
||||||
|
sg.gain.setValueAtTime(0.0001, t + 0.24);
|
||||||
|
sg.gain.exponentialRampToValueAtTime(0.05, t + 0.26);
|
||||||
|
sg.gain.exponentialRampToValueAtTime(0.0001, t + 0.34);
|
||||||
|
sq.connect(sg); sg.connect(dest);
|
||||||
|
sq.start(t + 0.24); sq.stop(t + 0.36);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot stamp fanfare in the stamped world's own voice/key:
|
||||||
|
* acid = squelchy Fm 303 run, dub = deep sub steps + boing, disco = pentatonic
|
||||||
|
* sparkle run. All land inside ~0.8 s so the thunk stays the star.
|
||||||
|
*/
|
||||||
|
export function stampFanfare(
|
||||||
|
ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number,
|
||||||
|
world: 'acid' | 'dub' | 'disco',
|
||||||
|
): void {
|
||||||
|
const mtof = (m: number) => 440 * Math.pow(2, (m - 69) / 12);
|
||||||
|
if (world === 'acid') {
|
||||||
|
// F3 Ab3 C4 F4 squelch run with an opening filter
|
||||||
|
[53, 56, 60, 65].forEach((m, i) => {
|
||||||
|
const tt = t + 0.12 + i * 0.095;
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
osc.type = 'sawtooth'; osc.frequency.value = mtof(m);
|
||||||
|
const lp = ctx.createBiquadFilter();
|
||||||
|
lp.type = 'lowpass'; lp.Q.value = 14;
|
||||||
|
lp.frequency.setValueAtTime(700 + i * 900, tt);
|
||||||
|
lp.frequency.exponentialRampToValueAtTime(300, tt + 0.16);
|
||||||
|
const g = ctx.createGain();
|
||||||
|
g.gain.setValueAtTime(0.0001, tt);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.24, tt + 0.008);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, tt + 0.16);
|
||||||
|
osc.connect(lp); lp.connect(g); g.connect(dest);
|
||||||
|
osc.start(tt); osc.stop(tt + 0.2);
|
||||||
|
});
|
||||||
|
} else if (world === 'dub') {
|
||||||
|
// F1 Ab1 C2 sub steps, then a proud little spring twang
|
||||||
|
[29, 32, 36].forEach((m, i) => {
|
||||||
|
const tt = t + 0.14 + i * 0.19;
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
osc.type = 'sine'; osc.frequency.value = mtof(m);
|
||||||
|
const g = ctx.createGain();
|
||||||
|
g.gain.setValueAtTime(0.0001, tt);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.5, tt + 0.02);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, tt + 0.2);
|
||||||
|
osc.connect(g); g.connect(dest);
|
||||||
|
osc.start(tt); osc.stop(tt + 0.24);
|
||||||
|
});
|
||||||
|
// twang tail
|
||||||
|
const tw = ctx.createOscillator();
|
||||||
|
tw.type = 'sine';
|
||||||
|
tw.frequency.setValueAtTime(500, t + 0.72);
|
||||||
|
tw.frequency.exponentialRampToValueAtTime(110, t + 1.05);
|
||||||
|
const wob = ctx.createOscillator(); wob.frequency.value = 22;
|
||||||
|
const wg = ctx.createGain(); wg.gain.value = 160;
|
||||||
|
wob.connect(wg); wg.connect(tw.frequency);
|
||||||
|
const tg = ctx.createGain();
|
||||||
|
tg.gain.setValueAtTime(0.0001, t + 0.72);
|
||||||
|
tg.gain.exponentialRampToValueAtTime(0.16, t + 0.74);
|
||||||
|
tg.gain.exponentialRampToValueAtTime(0.0001, t + 1.1);
|
||||||
|
tw.connect(tg); tg.connect(dest);
|
||||||
|
tw.start(t + 0.72); tw.stop(t + 1.12);
|
||||||
|
wob.start(t + 0.72); wob.stop(t + 1.12);
|
||||||
|
} else {
|
||||||
|
// F minor pentatonic sparkle up two octaves
|
||||||
|
[65, 68, 70, 72, 75, 77].forEach((m, i) => {
|
||||||
|
const tt = t + 0.12 + i * 0.07;
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
osc.type = 'triangle'; osc.frequency.value = mtof(m + 12);
|
||||||
|
const g = ctx.createGain();
|
||||||
|
g.gain.setValueAtTime(0.0001, tt);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.16, tt + 0.006);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, tt + 0.18);
|
||||||
|
osc.connect(g); g.connect(dest);
|
||||||
|
osc.start(tt); osc.stop(tt + 0.2);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// shared glitter dust on top
|
||||||
|
const src = ctx.createBufferSource();
|
||||||
|
src.buffer = noise; src.loop = true;
|
||||||
|
const hp = ctx.createBiquadFilter();
|
||||||
|
hp.type = 'highpass'; hp.frequency.value = 6000;
|
||||||
|
const g = ctx.createGain();
|
||||||
|
g.gain.setValueAtTime(0.0001, t + 0.1);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.06, t + 0.2);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.8);
|
||||||
|
src.connect(hp); hp.connect(g); g.connect(dest);
|
||||||
|
src.start(t + 0.1); src.stop(t + 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* stamp:all golden riser: a long climbing sweep that blooms into an F-major
|
||||||
|
* add9 (the minor booth's picardy moment) with bell glints. ~3 s.
|
||||||
|
*/
|
||||||
|
export function goldenRiser(ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number): void {
|
||||||
|
const mtof = (m: number) => 440 * Math.pow(2, (m - 69) / 12);
|
||||||
|
// climbing noise riser
|
||||||
|
const src = ctx.createBufferSource();
|
||||||
|
src.buffer = noise; src.loop = true;
|
||||||
|
const bp = ctx.createBiquadFilter();
|
||||||
|
bp.type = 'bandpass'; bp.Q.value = 0.9;
|
||||||
|
bp.frequency.setValueAtTime(260, t);
|
||||||
|
bp.frequency.exponentialRampToValueAtTime(7500, t + 2.0);
|
||||||
|
const ng = ctx.createGain();
|
||||||
|
ng.gain.setValueAtTime(0.0001, t);
|
||||||
|
ng.gain.exponentialRampToValueAtTime(0.26, t + 1.85);
|
||||||
|
ng.gain.exponentialRampToValueAtTime(0.0001, t + 2.25);
|
||||||
|
src.connect(bp); bp.connect(ng); ng.connect(dest);
|
||||||
|
src.start(t); src.stop(t + 2.3);
|
||||||
|
// rising glissando tone under it
|
||||||
|
const gl = ctx.createOscillator();
|
||||||
|
gl.type = 'sawtooth';
|
||||||
|
gl.frequency.setValueAtTime(mtof(41), t);
|
||||||
|
gl.frequency.exponentialRampToValueAtTime(mtof(65), t + 2.0);
|
||||||
|
const glp = ctx.createBiquadFilter();
|
||||||
|
glp.type = 'lowpass'; glp.Q.value = 6;
|
||||||
|
glp.frequency.setValueAtTime(500, t);
|
||||||
|
glp.frequency.exponentialRampToValueAtTime(5200, t + 2.0);
|
||||||
|
const gg = ctx.createGain();
|
||||||
|
gg.gain.setValueAtTime(0.0001, t);
|
||||||
|
gg.gain.exponentialRampToValueAtTime(0.12, t + 1.6);
|
||||||
|
gg.gain.exponentialRampToValueAtTime(0.0001, t + 2.1);
|
||||||
|
gl.connect(glp); glp.connect(gg); gg.connect(dest);
|
||||||
|
gl.start(t); gl.stop(t + 2.15);
|
||||||
|
// bloom: F major add9 (F A C G) at the top
|
||||||
|
const bloom = t + 2.0;
|
||||||
|
for (const [m, amt] of [[53, 1], [57, 0.8], [60, 0.8], [67, 0.6], [65 + 12, 0.35]] as const) {
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
osc.type = 'triangle';
|
||||||
|
osc.frequency.value = mtof(m);
|
||||||
|
const g = ctx.createGain();
|
||||||
|
g.gain.setValueAtTime(0.0001, bloom);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.14 * amt, bloom + 0.03);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, bloom + 1.4);
|
||||||
|
osc.connect(g); g.connect(dest);
|
||||||
|
osc.start(bloom); osc.stop(bloom + 1.45);
|
||||||
|
}
|
||||||
|
// bell glints scattered over the bloom
|
||||||
|
[0, 0.14, 0.31, 0.52].forEach((off, i) => {
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
osc.type = 'sine';
|
||||||
|
osc.frequency.value = mtof([89, 93, 96, 101][i]);
|
||||||
|
const g = ctx.createGain();
|
||||||
|
g.gain.setValueAtTime(0.0001, bloom + off);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.09, bloom + off + 0.008);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, bloom + off + 0.5);
|
||||||
|
osc.connect(g); g.connect(dest);
|
||||||
|
osc.start(bloom + off); osc.stop(bloom + off + 0.55);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dust bunny spooked: a shy, quiet double chirp. Rare by caller throttle. */
|
||||||
|
export function bunnySqueak(ctx: BaseAudioContext, dest: AudioNode, t: number): void {
|
||||||
|
for (const [off, f0, f1, dur] of [[0, 1500, 2400, 0.09], [0.12, 1900, 1450, 0.07]] as const) {
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
osc.type = 'sine';
|
||||||
|
osc.frequency.setValueAtTime(f0, t + off);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(f1, t + off + dur);
|
||||||
|
const g = ctx.createGain();
|
||||||
|
g.gain.setValueAtTime(0.0001, t + off);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.07, t + off + 0.012);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t + off + dur + 0.02);
|
||||||
|
osc.connect(g); g.connect(dest);
|
||||||
|
osc.start(t + off); osc.stop(t + off + dur + 0.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type SfxName =
|
export type SfxName =
|
||||||
| 'footstep' | 'land' | 'break' | 'place'
|
| 'footstep' | 'land' | 'break' | 'place'
|
||||||
| 'fader' | 'button' | 'rca' | 'fuse' | 'cue' | 'needle'
|
| 'fader' | 'button' | 'rca' | 'fuse' | 'cue' | 'needle'
|
||||||
@ -408,4 +667,6 @@ export type SfxName =
|
|||||||
| 'crimp' | 'wireOn' | 'wireOff' | 'screwTick' | 'screwClunk'
|
| 'crimp' | 'wireOn' | 'wireOff' | 'screwTick' | 'screwClunk'
|
||||||
| 'tiltCreak' | 'weightDetent' | 'bubbleLock' | 'skate'
|
| 'tiltCreak' | 'weightDetent' | 'bubbleLock' | 'skate'
|
||||||
// Social & Reset Ritual:
|
// Social & Reset Ritual:
|
||||||
| 'airhorn' | 'powerDown';
|
| 'airhorn' | 'powerDown'
|
||||||
|
// SIDE B — crate worlds:
|
||||||
|
| 'portalIn' | 'portalOut' | 'stampThunk' | 'goldenRiser' | 'bunny';
|
||||||
|
|||||||
@ -239,6 +239,60 @@ export function lead(
|
|||||||
si.start(t); si.stop(t + dur + 0.05);
|
si.start(t); si.stop(t + dur + 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Shaker: a breathy high bandpass chiff. 16th-note bed of the bonus stem. */
|
||||||
|
export function shaker(
|
||||||
|
ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number,
|
||||||
|
o: { gain?: number; detune?: number } = {},
|
||||||
|
): void {
|
||||||
|
const gain = o.gain ?? 0.1;
|
||||||
|
const src = ctx.createBufferSource();
|
||||||
|
src.buffer = noise; src.loop = true;
|
||||||
|
src.detune.value = o.detune ?? 0;
|
||||||
|
const bp = ctx.createBiquadFilter();
|
||||||
|
bp.type = 'bandpass'; bp.frequency.value = 5200; bp.Q.value = 1.4;
|
||||||
|
const g = ctx.createGain();
|
||||||
|
g.gain.setValueAtTime(0.0001, t);
|
||||||
|
g.gain.exponentialRampToValueAtTime(gain, t + 0.012);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.07);
|
||||||
|
src.connect(bp); bp.connect(g); g.connect(dest);
|
||||||
|
src.start(t); src.stop(t + 0.09);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Conga: pitched hand-drum. `slap` = tighter/brighter with a skin tick. */
|
||||||
|
export function conga(
|
||||||
|
ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number,
|
||||||
|
o: { gain?: number; detune?: number; slap?: boolean } = {},
|
||||||
|
): void {
|
||||||
|
const gain = o.gain ?? 0.24;
|
||||||
|
const rate = Math.pow(2, (o.detune ?? 0) / 1200);
|
||||||
|
const f0 = (o.slap ? 300 : 205) * rate;
|
||||||
|
const dur = o.slap ? 0.09 : 0.16;
|
||||||
|
const g = ctx.createGain();
|
||||||
|
g.gain.setValueAtTime(0.0001, t);
|
||||||
|
g.gain.exponentialRampToValueAtTime(gain, t + 0.005);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
||||||
|
g.connect(dest);
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
osc.type = 'sine';
|
||||||
|
osc.frequency.setValueAtTime(f0, t);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(f0 * 0.72, t + dur);
|
||||||
|
osc.connect(g);
|
||||||
|
osc.start(t); osc.stop(t + dur + 0.03);
|
||||||
|
if (o.slap) {
|
||||||
|
// skin tick on the slap
|
||||||
|
const sg = ctx.createGain();
|
||||||
|
sg.gain.setValueAtTime(gain * 0.5, t);
|
||||||
|
sg.gain.exponentialRampToValueAtTime(0.0001, t + 0.03);
|
||||||
|
sg.connect(dest);
|
||||||
|
const src = ctx.createBufferSource();
|
||||||
|
src.buffer = noise; src.loop = true;
|
||||||
|
const hp = ctx.createBiquadFilter();
|
||||||
|
hp.type = 'highpass'; hp.frequency.value = 2600;
|
||||||
|
src.connect(hp); hp.connect(sg);
|
||||||
|
src.start(t); src.stop(t + 0.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* White-noise sweep (riser when up=true, downlifter when up=false) through a
|
* White-noise sweep (riser when up=true, downlifter when up=false) through a
|
||||||
* moving bandpass. Used at 8-bar boundaries; also the win-open sweep.
|
* moving bandpass. Used at 8-bar boundaries; also the win-open sweep.
|
||||||
|
|||||||
396
src/audio/worldGrooves.ts
Normal file
396
src/audio/worldGrooves.ts
Normal file
@ -0,0 +1,396 @@
|
|||||||
|
// TURNCRAFT — Lane E2 (SIDE B). The three pocket-world grooves. One groove is
|
||||||
|
// active at a time; each runs on its OWN lookahead Transport at its own BPM
|
||||||
|
// (acid 118, dub 74, disco 122) with the same spin-up/brake feel as the booth
|
||||||
|
// (you dove into a record — it spins up around you; leaving brakes it).
|
||||||
|
//
|
||||||
|
// AudioEngine owns the routing (worldBus -> outGain, booth mix ducked while a
|
||||||
|
// groove is active) and forwards the quest events:
|
||||||
|
// fader:move acid_0..2 -> setAcidBand (per-band 303 cutoff)
|
||||||
|
// machine:interact acid_lock -> acidLock (shimmer layer + chime)
|
||||||
|
// machine:interact dub_pickup/dub_drop -> setCarrying (echo wet rises)
|
||||||
|
// machine:interact dub_feed -> dubFeed (the big SPROING, feedback 0.8)
|
||||||
|
// machine:interact disco_cue/disco_step -> discoCue / discoStep
|
||||||
|
// stamp:got -> stampGlory (clap bars / swell / boing)
|
||||||
|
//
|
||||||
|
// While active, the groove emits audio:beat/audio:bar on ITS grid (the booth
|
||||||
|
// Groove's emission is suppressed by AudioEngine) so Lane M's disco sequence
|
||||||
|
// and Lane S's pulse FX stay locked to what the player actually hears.
|
||||||
|
|
||||||
|
import { bus } from '../core/events';
|
||||||
|
import { Transport } from './scheduler';
|
||||||
|
import { mtof, kick, hat, clap } from './synth';
|
||||||
|
import {
|
||||||
|
acid303, shimmerPing, lockChime,
|
||||||
|
dubSub, rimshot, skankChord, springBoing, feedBurst,
|
||||||
|
octBass, stringStab, cueBlip, stringSwell,
|
||||||
|
} from './worldSynth';
|
||||||
|
|
||||||
|
export type PocketWorld = 'acid' | 'dub' | 'disco';
|
||||||
|
export const POCKET_WORLDS: readonly PocketWorld[] = ['acid', 'dub', 'disco'];
|
||||||
|
|
||||||
|
export function isPocketWorld(w: string): w is PocketWorld {
|
||||||
|
return (POCKET_WORLDS as readonly string[]).includes(w);
|
||||||
|
}
|
||||||
|
|
||||||
|
const WORLD_BPM: Record<PocketWorld, number> = { acid: 118, dub: 74, disco: 122 };
|
||||||
|
const STEPS = 16; // per bar
|
||||||
|
const LOOP = 32; // every groove is a 2-bar loop
|
||||||
|
|
||||||
|
// ── ACID data ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// 2-bar 303 line in F minor (midi; a=accent, s=slide-from-previous).
|
||||||
|
type AcidStep = { n: number; a?: 1; s?: 1 } | null;
|
||||||
|
const ACID_LINE: AcidStep[] = [
|
||||||
|
// bar 0
|
||||||
|
{ n: 41, a: 1 }, { n: 41 }, null, { n: 53, s: 1 },
|
||||||
|
{ n: 41 }, null, { n: 44 }, { n: 41 },
|
||||||
|
null, { n: 41 }, { n: 48, a: 1, s: 1 }, { n: 41 },
|
||||||
|
null, { n: 39 }, { n: 41, a: 1 }, null,
|
||||||
|
// bar 1
|
||||||
|
{ n: 41, a: 1 }, null, { n: 41 }, { n: 46, s: 1 },
|
||||||
|
{ n: 41 }, null, { n: 44, a: 1 }, null,
|
||||||
|
{ n: 41 }, { n: 41 }, { n: 48, s: 1 }, null,
|
||||||
|
{ n: 53, a: 1 }, null, { n: 39 }, { n: 41 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Which pedestal band owns a step's filter cutoff (bar position 0..15).
|
||||||
|
function acidBandFor(s: number): number { return s <= 5 ? 0 : s <= 10 ? 1 : 2; }
|
||||||
|
|
||||||
|
// value 0..1 -> cutoff Hz, exponential across a wide per-band offset range so
|
||||||
|
// sweeping a pedestal is unmistakable. Lane M owns the hidden sweet targets;
|
||||||
|
// we only make the sweep audible and satisfying.
|
||||||
|
const ACID_CUT: [number, number][] = [[110, 2600], [160, 3800], [240, 5600]];
|
||||||
|
function acidCutoff(band: number, v: number): number {
|
||||||
|
const [lo, hi] = ACID_CUT[band];
|
||||||
|
return lo * Math.pow(hi / lo, Math.max(0, Math.min(1, v)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DUB data ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DUB_SUB: Record<number, { n: number; dur: number }> = {
|
||||||
|
0: { n: 29, dur: 0.7 }, 10: { n: 32, dur: 0.35 },
|
||||||
|
16: { n: 29, dur: 0.7 }, 22: { n: 27, dur: 0.35 }, 26: { n: 29, dur: 0.3 },
|
||||||
|
};
|
||||||
|
const DUB_SKANK = [53, 56, 60]; // Fm triad (midi) under the rim on the 2/4
|
||||||
|
|
||||||
|
// ── DISCO data ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DISCO_STABS: Record<number, number[]> = {
|
||||||
|
3: [65, 68, 72], 11: [65, 68, 72], // bar 0: Fm
|
||||||
|
19: [61, 65, 68], 27: [63, 67, 70], // bar 1: Db, Eb
|
||||||
|
};
|
||||||
|
const PENT = [0, 3, 5, 7, 10]; // F minor pentatonic degrees
|
||||||
|
|
||||||
|
/** Tile index 0..15 -> pentatonic midi note rising across two-plus octaves. */
|
||||||
|
function pentMidi(index: number): number {
|
||||||
|
const i = Math.max(0, Math.min(15, Math.round(index)));
|
||||||
|
return 65 + Math.floor(i / 5) * 12 + PENT[i % 5];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the manager ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface DubChain {
|
||||||
|
echoIn: GainNode; // send anything here to be echoed
|
||||||
|
delay: DelayNode;
|
||||||
|
fb: GainNode; // feedback amount (0.55 base, 0.8 on dub_feed)
|
||||||
|
wet: GainNode; // echo return level ("carry the wet")
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WorldGrooves {
|
||||||
|
private ctx: BaseAudioContext;
|
||||||
|
private noise: AudioBuffer;
|
||||||
|
private out: AudioNode;
|
||||||
|
|
||||||
|
private activeWorld: PocketWorld | null = null;
|
||||||
|
private transport: Transport | null = null;
|
||||||
|
private wg: GainNode | null = null; // active groove's output gain
|
||||||
|
private dub: DubChain | null = null;
|
||||||
|
private barCounter = 0;
|
||||||
|
|
||||||
|
// acid state (band values persist across dives; locks reset per entry)
|
||||||
|
private acidBand = [0.12, 0.85, 0.18]; // wrong-by-default per-band offsets
|
||||||
|
private acidLocked = [false, false, false];
|
||||||
|
private acidGloryBars = 0;
|
||||||
|
|
||||||
|
// dub state
|
||||||
|
private carrying = false;
|
||||||
|
|
||||||
|
constructor(ctx: BaseAudioContext, noise: AudioBuffer, out: AudioNode) {
|
||||||
|
this.ctx = ctx;
|
||||||
|
this.noise = noise;
|
||||||
|
this.out = out;
|
||||||
|
}
|
||||||
|
|
||||||
|
get active(): PocketWorld | null { return this.activeWorld; }
|
||||||
|
|
||||||
|
/** Dive in: build the world's chain and spin its transport up. */
|
||||||
|
start(world: PocketWorld): void {
|
||||||
|
if (this.activeWorld === world) return;
|
||||||
|
if (this.activeWorld) this.stop(true);
|
||||||
|
|
||||||
|
const ctx = this.ctx;
|
||||||
|
const wg = ctx.createGain();
|
||||||
|
wg.gain.value = 0;
|
||||||
|
wg.connect(this.out);
|
||||||
|
this.wg = wg;
|
||||||
|
const now = ctx.currentTime;
|
||||||
|
wg.gain.setValueAtTime(0, now);
|
||||||
|
wg.gain.linearRampToValueAtTime(1, now + 0.45);
|
||||||
|
|
||||||
|
if (world === 'acid') {
|
||||||
|
this.acidLocked = [false, false, false]; // targets re-seed per entry (Lane M)
|
||||||
|
this.acidGloryBars = 0;
|
||||||
|
}
|
||||||
|
if (world === 'dub') {
|
||||||
|
const echoIn = ctx.createGain(); echoIn.gain.value = 1;
|
||||||
|
const delay = ctx.createDelay(2.0);
|
||||||
|
delay.delayTime.value = (60 / WORLD_BPM.dub) * 0.75; // dotted 8th ≈ 0.608 s
|
||||||
|
const fb = ctx.createGain(); fb.gain.value = 0.55;
|
||||||
|
const highcut = ctx.createBiquadFilter();
|
||||||
|
highcut.type = 'lowpass'; highcut.frequency.value = 2000;
|
||||||
|
const wet = ctx.createGain();
|
||||||
|
wet.gain.value = this.carrying ? 0.8 : 0.32;
|
||||||
|
echoIn.connect(delay);
|
||||||
|
delay.connect(highcut); highcut.connect(fb); fb.connect(delay);
|
||||||
|
delay.connect(wet); wet.connect(wg);
|
||||||
|
this.dub = { echoIn, delay, fb, wet };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.barCounter = 0;
|
||||||
|
const tr = new Transport(
|
||||||
|
ctx,
|
||||||
|
(step, t, det, emitAt) => this.scheduleStep(world, step, t, det, emitAt),
|
||||||
|
0.9, // snappier than the booth platter
|
||||||
|
WORLD_BPM[world],
|
||||||
|
);
|
||||||
|
tr.start();
|
||||||
|
tr.setRateTarget(1);
|
||||||
|
this.transport = tr;
|
||||||
|
this.activeWorld = world;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Brake out. `immediate` (world switch) tears down without the tail. */
|
||||||
|
stop(immediate = false): void {
|
||||||
|
const wg = this.wg, tr = this.transport, dub = this.dub;
|
||||||
|
if (!wg || !tr) return;
|
||||||
|
this.activeWorld = null;
|
||||||
|
this.wg = null;
|
||||||
|
this.transport = null;
|
||||||
|
this.dub = null;
|
||||||
|
|
||||||
|
const now = this.ctx.currentTime;
|
||||||
|
tr.setRateTarget(0);
|
||||||
|
wg.gain.cancelScheduledValues(now);
|
||||||
|
wg.gain.setValueAtTime(wg.gain.value, now);
|
||||||
|
wg.gain.linearRampToValueAtTime(0, now + (immediate ? 0.12 : 0.85));
|
||||||
|
window.setTimeout(() => {
|
||||||
|
tr.stop();
|
||||||
|
wg.disconnect();
|
||||||
|
if (dub) { dub.echoIn.disconnect(); dub.delay.disconnect(); dub.fb.disconnect(); dub.wet.disconnect(); }
|
||||||
|
}, immediate ? 200 : 1100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── quest hooks (safe to call any time; no-ops when not applicable) ──────
|
||||||
|
|
||||||
|
/** Pedestal fader 0..1 -> that band's 303 cutoff (persists across dives). */
|
||||||
|
setAcidBand(band: number, v: number): void {
|
||||||
|
if (band < 0 || band > 2) return;
|
||||||
|
this.acidBand[band] = Math.max(0, Math.min(1, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A pedestal locked into its sweet spot: chime + that band's shimmer layer. */
|
||||||
|
acidLock(band: number): void {
|
||||||
|
const b = Math.max(0, Math.min(2, Math.round(band)));
|
||||||
|
if (this.acidLocked[b]) return;
|
||||||
|
this.acidLocked[b] = true;
|
||||||
|
if (this.wg && this.activeWorld === 'acid') {
|
||||||
|
lockChime(this.ctx, this.wg, this.ctx.currentTime + 0.01);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fallback band for an acid_lock event that carries no index. */
|
||||||
|
firstUnlockedBand(): number {
|
||||||
|
const i = this.acidLocked.indexOf(false);
|
||||||
|
return i < 0 ? 2 : i;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Carrying the echo charge: the wet rises with you. */
|
||||||
|
setCarrying(on: boolean): void {
|
||||||
|
this.carrying = on;
|
||||||
|
if (this.dub) {
|
||||||
|
const g = this.dub.wet.gain, now = this.ctx.currentTime;
|
||||||
|
g.cancelScheduledValues(now);
|
||||||
|
g.setValueAtTime(g.value, now);
|
||||||
|
g.linearRampToValueAtTime(on ? 0.8 : 0.32, now + 0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The big SPROING: burst into the echo, feedback momentarily 0.8. */
|
||||||
|
dubFeed(): void {
|
||||||
|
this.carrying = false;
|
||||||
|
if (!this.dub || !this.wg) return;
|
||||||
|
const now = this.ctx.currentTime;
|
||||||
|
const fb = this.dub.fb.gain;
|
||||||
|
fb.cancelScheduledValues(now);
|
||||||
|
fb.setValueAtTime(fb.value, now);
|
||||||
|
fb.linearRampToValueAtTime(0.8, now + 0.05);
|
||||||
|
fb.setValueAtTime(0.8, now + 1.9);
|
||||||
|
fb.linearRampToValueAtTime(0.55, now + 2.7);
|
||||||
|
feedBurst(this.ctx, this.dub.echoIn, this.noise, now + 0.02, { gain: 0.55 });
|
||||||
|
springBoing(this.ctx, this.wg, this.noise, now + 0.02, { big: true });
|
||||||
|
springBoing(this.ctx, this.dub.echoIn, this.noise, now + 0.06, { big: true, gain: 0.18 });
|
||||||
|
// the wet settles back down once the charge is fed
|
||||||
|
const w = this.dub.wet.gain;
|
||||||
|
w.cancelScheduledValues(now + 2.7);
|
||||||
|
w.setValueAtTime(0.8, now + 2.7);
|
||||||
|
w.linearRampToValueAtTime(0.32, now + 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Machine cue blip for tile `index` (pentatonic, pitch by index). */
|
||||||
|
discoCue(index: number): void {
|
||||||
|
if (!this.wg) return;
|
||||||
|
cueBlip(this.ctx, this.wg, this.ctx.currentTime + 0.001, mtof(pentMidi(index)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The player's step echoes the tile's blip a fifth down, softer. */
|
||||||
|
discoStep(index: number): void {
|
||||||
|
if (!this.wg) return;
|
||||||
|
cueBlip(this.ctx, this.wg, this.ctx.currentTime + 0.001, mtof(pentMidi(index) - 7), { soft: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wrong tile — THE FLOOR FORGIVES: a gentle descending "aw", never a buzzer. */
|
||||||
|
discoMiss(): void {
|
||||||
|
if (!this.wg) return;
|
||||||
|
const t = this.ctx.currentTime + 0.001;
|
||||||
|
cueBlip(this.ctx, this.wg, t, mtof(60), { soft: true, gain: 0.16 });
|
||||||
|
cueBlip(this.ctx, this.wg, t + 0.13, mtof(56), { soft: true, gain: 0.13 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** World-specific stamp glory layered into the running groove. */
|
||||||
|
stampGlory(world: PocketWorld): void {
|
||||||
|
if (this.activeWorld !== world || !this.wg) return;
|
||||||
|
const now = this.ctx.currentTime;
|
||||||
|
if (world === 'acid') {
|
||||||
|
this.acidGloryBars = 4; // clap layer for 4 bars (scheduled per step)
|
||||||
|
} else if (world === 'dub') {
|
||||||
|
springBoing(this.ctx, this.wg, this.noise, now + 0.05, { big: true, gain: 0.3 });
|
||||||
|
} else {
|
||||||
|
stringSwell(this.ctx, this.wg, now + 0.05, [53, 56, 60, 63, 67].map(mtof), { gain: 0.34 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── scheduling ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private scheduleStep(
|
||||||
|
world: PocketWorld, absStep: number, time: number, detune: number,
|
||||||
|
emitAt: (t: number, fn: () => void) => void,
|
||||||
|
): void {
|
||||||
|
const local = ((absStep % LOOP) + LOOP) % LOOP;
|
||||||
|
const bar = Math.floor(local / STEPS);
|
||||||
|
const s = local % STEPS;
|
||||||
|
const wg = this.wg;
|
||||||
|
if (!wg) return;
|
||||||
|
|
||||||
|
if (world === 'acid') this.acidStep(wg, bar, s, local, time, detune);
|
||||||
|
else if (world === 'dub') this.dubStep(wg, bar, s, local, time, detune);
|
||||||
|
else this.discoStepSched(wg, bar, s, local, time, detune);
|
||||||
|
|
||||||
|
// beat/bar events at the audible time, on this groove's grid
|
||||||
|
if (s % 4 === 0) {
|
||||||
|
const energy = world === 'dub' ? (s === 0 ? 1 : 0.55) : s === 0 ? 1 : 0.85;
|
||||||
|
emitAt(time, () => bus.emit('audio:beat', { energy }));
|
||||||
|
}
|
||||||
|
if (s === 0) {
|
||||||
|
const bn = this.barCounter++;
|
||||||
|
emitAt(time, () => bus.emit('audio:bar', { bar: bn }));
|
||||||
|
if (world === 'acid' && this.acidGloryBars > 0) this.acidGloryBars--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private acidStep(wg: GainNode, _bar: number, s: number, local: number, time: number, detune: number): void {
|
||||||
|
// drums: four-on-floor + off-hats
|
||||||
|
if (s % 4 === 0) kick(this.ctx, wg, time, { gain: 0.95, detune });
|
||||||
|
hat(this.ctx, wg, this.noise, time, { gain: s % 4 === 2 ? 0.2 : 0.05, detune });
|
||||||
|
|
||||||
|
// THE 303
|
||||||
|
const st = ACID_LINE[local];
|
||||||
|
if (st) {
|
||||||
|
const band = acidBandFor(s);
|
||||||
|
const cutoff = acidCutoff(band, this.acidBand[band]);
|
||||||
|
const prev = ACID_LINE[(local + LOOP - 1) % LOOP];
|
||||||
|
acid303(this.ctx, wg, time, mtof(st.n), {
|
||||||
|
cutoff, detune,
|
||||||
|
accent: st.a === 1,
|
||||||
|
dur: st.s ? 0.26 : 0.18,
|
||||||
|
slideFrom: st.s && prev ? mtof(prev.n) : undefined,
|
||||||
|
});
|
||||||
|
// glory doubles the line an octave up
|
||||||
|
if (this.acidGloryBars > 0) {
|
||||||
|
acid303(this.ctx, wg, time, mtof(st.n + 12), {
|
||||||
|
cutoff: Math.max(cutoff, 1800), detune, gain: 0.12, dur: 0.14,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// locked pedestals shimmer on the offbeats (one ping per locked band)
|
||||||
|
if (s % 4 === 2) {
|
||||||
|
for (let b = 0; b < 3; b++) {
|
||||||
|
if (this.acidLocked[b]) {
|
||||||
|
shimmerPing(this.ctx, wg, time, 1500 * (1 + b * 0.4), { detune, gain: 0.042 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stamped glory: claps on the backbeat
|
||||||
|
if (this.acidGloryBars > 0 && (s === 4 || s === 12)) {
|
||||||
|
clap(this.ctx, wg, this.noise, time, { gain: 0.4, detune });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private dubStep(wg: GainNode, bar: number, s: number, local: number, time: number, detune: number): void {
|
||||||
|
const dub = this.dub;
|
||||||
|
// half-time: kick on 1 and 3
|
||||||
|
if (s === 0) kick(this.ctx, wg, time, { gain: 0.95, detune });
|
||||||
|
else if (s === 8) kick(this.ctx, wg, time, { gain: 0.65, detune });
|
||||||
|
// whispered hats
|
||||||
|
if (s % 2 === 0) hat(this.ctx, wg, this.noise, time, { gain: s % 4 === 2 ? 0.06 : 0.03, detune });
|
||||||
|
// rim skank on the 2/4, sent through the feedback echo
|
||||||
|
if (s === 4 || s === 12) {
|
||||||
|
rimshot(this.ctx, wg, this.noise, time, { gain: 0.26, detune });
|
||||||
|
if (dub) {
|
||||||
|
rimshot(this.ctx, dub.echoIn, this.noise, time, { gain: 0.3, detune });
|
||||||
|
skankChord(this.ctx, dub.echoIn, time, DUB_SKANK.map(mtof), { gain: 0.12, detune });
|
||||||
|
}
|
||||||
|
skankChord(this.ctx, wg, time, DUB_SKANK.map(mtof), { gain: 0.1, detune });
|
||||||
|
}
|
||||||
|
// deep sub line
|
||||||
|
const sub = DUB_SUB[local];
|
||||||
|
if (sub) dubSub(this.ctx, wg, time, mtof(sub.n), { gain: 0.6, dur: sub.dur, detune });
|
||||||
|
// the chamber's ambient spring twang, once per loop
|
||||||
|
if (bar === 1 && s === 6) {
|
||||||
|
springBoing(this.ctx, dub ? dub.echoIn : wg, this.noise, time, { gain: 0.1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private discoStepSched(wg: GainNode, _bar: number, s: number, local: number, time: number, detune: number): void {
|
||||||
|
// four-on-floor + offbeat open hats + backbeat claps
|
||||||
|
if (s % 4 === 0) kick(this.ctx, wg, time, { gain: 0.95, detune });
|
||||||
|
if (s % 4 === 2) hat(this.ctx, wg, this.noise, time, { gain: 0.2, open: true, detune });
|
||||||
|
else if (s % 2 === 0) hat(this.ctx, wg, this.noise, time, { gain: 0.05, detune });
|
||||||
|
if (s === 4 || s === 12) clap(this.ctx, wg, this.noise, time, { gain: 0.32, detune });
|
||||||
|
|
||||||
|
// octave bass on the 8ths (root/octave), with a bar-1 turnaround
|
||||||
|
if (s % 2 === 0) {
|
||||||
|
let root = 41;
|
||||||
|
if (local >= 28) root = 39; // Eb walk into the loop
|
||||||
|
const midi = s % 4 === 0 ? root : root + 12;
|
||||||
|
octBass(this.ctx, wg, time, mtof(midi), { gain: 0.38, detune });
|
||||||
|
}
|
||||||
|
|
||||||
|
// string stab loop
|
||||||
|
const stabChord = DISCO_STABS[local];
|
||||||
|
if (stabChord) {
|
||||||
|
stringStab(this.ctx, wg, time, stabChord.map(mtof), { gain: 0.24, detune });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
357
src/audio/worldSynth.ts
Normal file
357
src/audio/worldSynth.ts
Normal file
@ -0,0 +1,357 @@
|
|||||||
|
// TURNCRAFT — Lane E2 (SIDE B). Voices for the three pocket-world grooves.
|
||||||
|
// Same rules as synth.ts: pure helpers, absolute-time scheduling into `dest`,
|
||||||
|
// zero shared mutable state, `detune` (cents) honoured on every pitched source
|
||||||
|
// so the pocket transports' spin-up/brake bends whole grooves together.
|
||||||
|
|
||||||
|
// ── ACID WAREHOUSE ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE 303: one step of the acid line. Sawtooth through a high-Q resonant
|
||||||
|
* lowpass. `cutoff` is the band's mapped value (Hz) — the filter envelope
|
||||||
|
* peaks above it and settles below it, so sweeping a pedestal fader is
|
||||||
|
* unmistakably audible. `accent` = louder + brighter + more resonant;
|
||||||
|
* `slideFrom` = legato glide from the previous note's frequency.
|
||||||
|
*/
|
||||||
|
export function acid303(
|
||||||
|
ctx: BaseAudioContext, dest: AudioNode, t: number, freq: number,
|
||||||
|
o: { cutoff: number; gain?: number; detune?: number; dur?: number; accent?: boolean; slideFrom?: number },
|
||||||
|
): void {
|
||||||
|
const accent = o.accent ?? false;
|
||||||
|
const gain = (o.gain ?? 0.3) * (accent ? 1.45 : 1);
|
||||||
|
const dur = o.dur ?? 0.18;
|
||||||
|
const cut = Math.max(70, Math.min(9000, o.cutoff * (accent ? 1.5 : 1)));
|
||||||
|
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
osc.type = 'sawtooth';
|
||||||
|
osc.detune.value = o.detune ?? 0;
|
||||||
|
if (o.slideFrom && o.slideFrom !== freq) {
|
||||||
|
osc.frequency.setValueAtTime(o.slideFrom, t);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(freq, t + Math.min(0.09, dur * 0.5));
|
||||||
|
} else {
|
||||||
|
osc.frequency.setValueAtTime(freq, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lp = ctx.createBiquadFilter();
|
||||||
|
lp.type = 'lowpass';
|
||||||
|
lp.Q.value = accent ? 17 : 12;
|
||||||
|
lp.frequency.setValueAtTime(Math.min(11000, cut * 2.1), t);
|
||||||
|
lp.frequency.exponentialRampToValueAtTime(Math.max(60, cut * 0.5), t + dur);
|
||||||
|
|
||||||
|
const g = ctx.createGain();
|
||||||
|
g.gain.setValueAtTime(0.0001, t);
|
||||||
|
g.gain.exponentialRampToValueAtTime(gain, t + 0.006);
|
||||||
|
g.gain.setValueAtTime(gain, t + dur * 0.55);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
||||||
|
|
||||||
|
osc.connect(lp); lp.connect(g); g.connect(dest);
|
||||||
|
osc.start(t); osc.stop(t + dur + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lock shimmer ping: tiny glassy dyad — one locked pedestal's sparkle layer. */
|
||||||
|
export function shimmerPing(
|
||||||
|
ctx: BaseAudioContext, dest: AudioNode, t: number, freq: number,
|
||||||
|
o: { gain?: number; detune?: number } = {},
|
||||||
|
): void {
|
||||||
|
const gain = o.gain ?? 0.045;
|
||||||
|
for (const [mult, amt] of [[1, 1], [1.5, 0.6]] as const) {
|
||||||
|
const g = ctx.createGain();
|
||||||
|
g.gain.setValueAtTime(0.0001, t);
|
||||||
|
g.gain.exponentialRampToValueAtTime(gain * amt, t + 0.008);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.22);
|
||||||
|
g.connect(dest);
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
osc.type = 'sine';
|
||||||
|
osc.frequency.value = freq * mult;
|
||||||
|
osc.detune.value = (o.detune ?? 0) + (mult === 1 ? 0 : 6);
|
||||||
|
osc.connect(g);
|
||||||
|
osc.start(t); osc.stop(t + 0.26);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A pedestal snaps into tune: rising fifth chirp, small and satisfied. */
|
||||||
|
export function lockChime(ctx: BaseAudioContext, dest: AudioNode, t: number): void {
|
||||||
|
for (const [f, off] of [[660, 0], [990, 0.07]] as const) {
|
||||||
|
const g = ctx.createGain();
|
||||||
|
g.gain.setValueAtTime(0.0001, t + off);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.14, t + off + 0.01);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t + off + 0.24);
|
||||||
|
g.connect(dest);
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
osc.type = 'triangle';
|
||||||
|
osc.frequency.value = f;
|
||||||
|
osc.connect(g);
|
||||||
|
osc.start(t + off); osc.stop(t + off + 0.28);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DUB CHAMBER ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Deep sub bass: soft-attack sine + a whisper of 2nd harmonic for shape. */
|
||||||
|
export function dubSub(
|
||||||
|
ctx: BaseAudioContext, dest: AudioNode, t: number, freq: number,
|
||||||
|
o: { gain?: number; detune?: number; dur?: number } = {},
|
||||||
|
): void {
|
||||||
|
const gain = o.gain ?? 0.62;
|
||||||
|
const dur = o.dur ?? 0.55;
|
||||||
|
const det = o.detune ?? 0;
|
||||||
|
const g = ctx.createGain();
|
||||||
|
g.gain.setValueAtTime(0.0001, t);
|
||||||
|
g.gain.exponentialRampToValueAtTime(gain, t + 0.03);
|
||||||
|
g.gain.setValueAtTime(gain, t + dur * 0.6);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
||||||
|
g.connect(dest);
|
||||||
|
const sub = ctx.createOscillator();
|
||||||
|
sub.type = 'sine'; sub.frequency.value = freq; sub.detune.value = det;
|
||||||
|
sub.connect(g);
|
||||||
|
sub.start(t); sub.stop(t + dur + 0.05);
|
||||||
|
const h2 = ctx.createOscillator();
|
||||||
|
h2.type = 'sine'; h2.frequency.value = freq * 2; h2.detune.value = det;
|
||||||
|
const hg = ctx.createGain(); hg.gain.value = 0.16;
|
||||||
|
h2.connect(hg); hg.connect(g);
|
||||||
|
h2.start(t); h2.stop(t + dur + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rimshot: woody click + tight ring — the dub skank hit (send it to the echo). */
|
||||||
|
export function rimshot(
|
||||||
|
ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number,
|
||||||
|
o: { gain?: number; detune?: number } = {},
|
||||||
|
): void {
|
||||||
|
const gain = o.gain ?? 0.3;
|
||||||
|
const rate = Math.pow(2, (o.detune ?? 0) / 1200);
|
||||||
|
// ring
|
||||||
|
const g = ctx.createGain();
|
||||||
|
g.gain.setValueAtTime(gain, t);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.07);
|
||||||
|
g.connect(dest);
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
osc.type = 'triangle';
|
||||||
|
osc.frequency.value = 440 * rate;
|
||||||
|
osc.connect(g);
|
||||||
|
osc.start(t); osc.stop(t + 0.09);
|
||||||
|
// stick click
|
||||||
|
const src = ctx.createBufferSource();
|
||||||
|
src.buffer = noise; src.loop = true;
|
||||||
|
src.detune.value = o.detune ?? 0;
|
||||||
|
const bp = ctx.createBiquadFilter();
|
||||||
|
bp.type = 'bandpass'; bp.frequency.value = 2000 * rate; bp.Q.value = 2.5;
|
||||||
|
const ng = ctx.createGain();
|
||||||
|
ng.gain.setValueAtTime(gain * 0.9, t);
|
||||||
|
ng.gain.exponentialRampToValueAtTime(0.0001, t + 0.03);
|
||||||
|
src.connect(bp); bp.connect(ng); ng.connect(dest);
|
||||||
|
src.start(t); src.stop(t + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dark staccato skank chord under the rim (body for the echo to chew on). */
|
||||||
|
export function skankChord(
|
||||||
|
ctx: BaseAudioContext, dest: AudioNode, t: number, freqs: number[],
|
||||||
|
o: { gain?: number; detune?: number } = {},
|
||||||
|
): void {
|
||||||
|
const gain = (o.gain ?? 0.14) / Math.max(1, freqs.length);
|
||||||
|
const lp = ctx.createBiquadFilter();
|
||||||
|
lp.type = 'lowpass'; lp.frequency.value = 1300; lp.Q.value = 1;
|
||||||
|
const g = ctx.createGain();
|
||||||
|
g.gain.setValueAtTime(0.0001, t);
|
||||||
|
g.gain.exponentialRampToValueAtTime(gain * freqs.length, t + 0.008);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.12);
|
||||||
|
lp.connect(g); g.connect(dest);
|
||||||
|
for (const f of freqs) {
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
osc.type = 'square';
|
||||||
|
osc.frequency.value = f;
|
||||||
|
osc.detune.value = o.detune ?? 0;
|
||||||
|
const og = ctx.createGain(); og.gain.value = 1 / freqs.length;
|
||||||
|
osc.connect(og); og.connect(lp);
|
||||||
|
osc.start(t); osc.stop(t + 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spring-reverb boing: an FM-wobbled sine diving through a bandpass with a
|
||||||
|
* metallic tick on top. `big` is the dub_feed SPROING; default is the ambient
|
||||||
|
* chamber twang.
|
||||||
|
*/
|
||||||
|
export function springBoing(
|
||||||
|
ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number,
|
||||||
|
o: { gain?: number; big?: boolean } = {},
|
||||||
|
): void {
|
||||||
|
const big = o.big ?? false;
|
||||||
|
const gain = o.gain ?? (big ? 0.4 : 0.14);
|
||||||
|
const dur = big ? 0.9 : 0.5;
|
||||||
|
const f0 = big ? 900 : 640;
|
||||||
|
|
||||||
|
const carrier = ctx.createOscillator();
|
||||||
|
carrier.type = 'sine';
|
||||||
|
carrier.frequency.setValueAtTime(f0, t);
|
||||||
|
carrier.frequency.exponentialRampToValueAtTime(big ? 60 : 90, t + dur);
|
||||||
|
// spring dispersion wobble: FM the carrier with a fast decaying LFO
|
||||||
|
const wob = ctx.createOscillator();
|
||||||
|
wob.type = 'sine';
|
||||||
|
wob.frequency.setValueAtTime(31, t);
|
||||||
|
wob.frequency.exponentialRampToValueAtTime(9, t + dur);
|
||||||
|
const wg = ctx.createGain();
|
||||||
|
wg.gain.setValueAtTime(f0 * 0.45, t);
|
||||||
|
wg.gain.exponentialRampToValueAtTime(6, t + dur);
|
||||||
|
wob.connect(wg); wg.connect(carrier.frequency);
|
||||||
|
|
||||||
|
const bp = ctx.createBiquadFilter();
|
||||||
|
bp.type = 'bandpass'; bp.Q.value = 3.5;
|
||||||
|
bp.frequency.setValueAtTime(f0 * 1.4, t);
|
||||||
|
bp.frequency.exponentialRampToValueAtTime(150, t + dur);
|
||||||
|
const g = ctx.createGain();
|
||||||
|
g.gain.setValueAtTime(0.0001, t);
|
||||||
|
g.gain.exponentialRampToValueAtTime(gain, t + 0.012);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
||||||
|
carrier.connect(bp); bp.connect(g); g.connect(dest);
|
||||||
|
carrier.start(t); carrier.stop(t + dur + 0.05);
|
||||||
|
wob.start(t); wob.stop(t + dur + 0.05);
|
||||||
|
|
||||||
|
// metallic tick as the spring is struck
|
||||||
|
const src = ctx.createBufferSource();
|
||||||
|
src.buffer = noise; src.loop = true;
|
||||||
|
const hp = ctx.createBiquadFilter();
|
||||||
|
hp.type = 'highpass'; hp.frequency.value = 3000;
|
||||||
|
const ng = ctx.createGain();
|
||||||
|
ng.gain.setValueAtTime(gain * 0.5, t);
|
||||||
|
ng.gain.exponentialRampToValueAtTime(0.0001, t + 0.05);
|
||||||
|
src.connect(hp); hp.connect(ng); ng.connect(dest);
|
||||||
|
src.start(t); src.stop(t + 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bandpassed noise burst — the dub_feed charge dropped into the echo input. */
|
||||||
|
export function feedBurst(
|
||||||
|
ctx: BaseAudioContext, dest: AudioNode, noise: AudioBuffer, t: number,
|
||||||
|
o: { gain?: number } = {},
|
||||||
|
): void {
|
||||||
|
const gain = o.gain ?? 0.5;
|
||||||
|
const src = ctx.createBufferSource();
|
||||||
|
src.buffer = noise; src.loop = true;
|
||||||
|
const bp = ctx.createBiquadFilter();
|
||||||
|
bp.type = 'bandpass'; bp.frequency.value = 950; bp.Q.value = 1.8;
|
||||||
|
const g = ctx.createGain();
|
||||||
|
g.gain.setValueAtTime(0.0001, t);
|
||||||
|
g.gain.exponentialRampToValueAtTime(gain, t + 0.01);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.3);
|
||||||
|
src.connect(bp); bp.connect(g); g.connect(dest);
|
||||||
|
src.start(t); src.stop(t + 0.33);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DISCO LOFT ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Octave disco bass: punchy saw+square pluck (root/octave alternation). */
|
||||||
|
export function octBass(
|
||||||
|
ctx: BaseAudioContext, dest: AudioNode, t: number, freq: number,
|
||||||
|
o: { gain?: number; detune?: number } = {},
|
||||||
|
): void {
|
||||||
|
const gain = o.gain ?? 0.4;
|
||||||
|
const det = o.detune ?? 0;
|
||||||
|
const lp = ctx.createBiquadFilter();
|
||||||
|
lp.type = 'lowpass';
|
||||||
|
lp.frequency.setValueAtTime(1400, t);
|
||||||
|
lp.frequency.exponentialRampToValueAtTime(260, t + 0.13);
|
||||||
|
lp.Q.value = 2;
|
||||||
|
const g = ctx.createGain();
|
||||||
|
g.gain.setValueAtTime(0.0001, t);
|
||||||
|
g.gain.exponentialRampToValueAtTime(gain, t + 0.008);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.15);
|
||||||
|
lp.connect(g); g.connect(dest);
|
||||||
|
for (const [type, amt] of [['sawtooth', 0.7], ['square', 0.4]] as const) {
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
osc.type = type;
|
||||||
|
osc.frequency.value = freq;
|
||||||
|
osc.detune.value = det;
|
||||||
|
const og = ctx.createGain(); og.gain.value = amt;
|
||||||
|
osc.connect(og); og.connect(lp);
|
||||||
|
osc.start(t); osc.stop(t + 0.18);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** String stab: chorused saw chord, softer attack than the booth stab. */
|
||||||
|
export function stringStab(
|
||||||
|
ctx: BaseAudioContext, dest: AudioNode, t: number, freqs: number[],
|
||||||
|
o: { gain?: number; detune?: number; dur?: number } = {},
|
||||||
|
): void {
|
||||||
|
const gain = (o.gain ?? 0.26) / Math.max(1, freqs.length);
|
||||||
|
const dur = o.dur ?? 0.32;
|
||||||
|
const det = o.detune ?? 0;
|
||||||
|
const lp = ctx.createBiquadFilter();
|
||||||
|
lp.type = 'lowpass'; lp.frequency.value = 3600; lp.Q.value = 0.8;
|
||||||
|
const g = ctx.createGain();
|
||||||
|
g.gain.setValueAtTime(0.0001, t);
|
||||||
|
g.gain.linearRampToValueAtTime(gain * freqs.length, t + 0.035);
|
||||||
|
g.gain.setValueAtTime(gain * freqs.length, t + dur * 0.5);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
||||||
|
lp.connect(g); g.connect(dest);
|
||||||
|
for (const f of freqs) {
|
||||||
|
for (const cents of [-8, 7]) {
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
osc.type = 'sawtooth';
|
||||||
|
osc.frequency.value = f;
|
||||||
|
osc.detune.value = det + cents;
|
||||||
|
const og = ctx.createGain(); og.gain.value = 0.5;
|
||||||
|
osc.connect(og); og.connect(lp);
|
||||||
|
osc.start(t); osc.stop(t + dur + 0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-tile cue blip: bright pentatonic pluck (sine + octave square glint). */
|
||||||
|
export function cueBlip(
|
||||||
|
ctx: BaseAudioContext, dest: AudioNode, t: number, freq: number,
|
||||||
|
o: { gain?: number; detune?: number; soft?: boolean } = {},
|
||||||
|
): void {
|
||||||
|
const gain = (o.gain ?? 0.22) * (o.soft ? 0.7 : 1);
|
||||||
|
const dur = 0.16;
|
||||||
|
const g = ctx.createGain();
|
||||||
|
g.gain.setValueAtTime(0.0001, t);
|
||||||
|
g.gain.exponentialRampToValueAtTime(gain, t + 0.008);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
||||||
|
g.connect(dest);
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
osc.type = o.soft ? 'triangle' : 'sine';
|
||||||
|
osc.frequency.value = freq;
|
||||||
|
osc.detune.value = o.detune ?? 0;
|
||||||
|
osc.connect(g);
|
||||||
|
osc.start(t); osc.stop(t + dur + 0.03);
|
||||||
|
if (!o.soft) {
|
||||||
|
const sq = ctx.createOscillator();
|
||||||
|
sq.type = 'square';
|
||||||
|
sq.frequency.value = freq * 2;
|
||||||
|
sq.detune.value = o.detune ?? 0;
|
||||||
|
const sg = ctx.createGain();
|
||||||
|
sg.gain.setValueAtTime(gain * 0.25, t);
|
||||||
|
sg.gain.exponentialRampToValueAtTime(0.0001, t + dur * 0.6);
|
||||||
|
sq.connect(sg); sg.connect(g);
|
||||||
|
sq.start(t); sq.stop(t + dur);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filtered-disco string swell — the DISCO stamp's 2.5 s glory chord. */
|
||||||
|
export function stringSwell(
|
||||||
|
ctx: BaseAudioContext, dest: AudioNode, t: number, freqs: number[],
|
||||||
|
o: { gain?: number; dur?: number } = {},
|
||||||
|
): void {
|
||||||
|
const dur = o.dur ?? 2.5;
|
||||||
|
const gain = (o.gain ?? 0.3) / Math.max(1, freqs.length);
|
||||||
|
const lp = ctx.createBiquadFilter();
|
||||||
|
lp.type = 'lowpass'; lp.Q.value = 2;
|
||||||
|
lp.frequency.setValueAtTime(420, t);
|
||||||
|
lp.frequency.exponentialRampToValueAtTime(6500, t + dur * 0.75);
|
||||||
|
lp.frequency.exponentialRampToValueAtTime(900, t + dur);
|
||||||
|
const g = ctx.createGain();
|
||||||
|
g.gain.setValueAtTime(0.0001, t);
|
||||||
|
g.gain.linearRampToValueAtTime(gain * freqs.length, t + dur * 0.45);
|
||||||
|
g.gain.setValueAtTime(gain * freqs.length, t + dur * 0.7);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
||||||
|
lp.connect(g); g.connect(dest);
|
||||||
|
for (const f of freqs) {
|
||||||
|
for (const cents of [-9, 8]) {
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
osc.type = 'sawtooth';
|
||||||
|
osc.frequency.value = f;
|
||||||
|
osc.detune.value = cents;
|
||||||
|
const og = ctx.createGain(); og.gain.value = 0.5;
|
||||||
|
osc.connect(og); og.connect(lp);
|
||||||
|
osc.start(t); osc.stop(t + dur + 0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -41,8 +41,12 @@ export type GameEvents = {
|
|||||||
// predate this phase ('player:emote' / 'quest:reset' above).
|
// predate this phase ('player:emote' / 'quest:reset' above).
|
||||||
'world:enter': { world: string }; // player dove into a pocket
|
'world:enter': { world: string }; // player dove into a pocket
|
||||||
'world:exit': { world: string }; // player returned to the booth
|
'world:exit': { world: string }; // player returned to the booth
|
||||||
'stamp:got': { world: string; count: number; total: number };
|
// origin lets consumers celebrate proportionately: 'local' = this player just
|
||||||
'stamp:all': Record<string, never>; // golden slipmat unlock
|
// won it (full glory), 'remote' = a peer won it live (share it, anchored at
|
||||||
|
// the crate, third person), 'replay' = join-snapshot state (apply silently).
|
||||||
|
// Absent = 'local' (back-compat).
|
||||||
|
'stamp:got': { world: string; count: number; total: number; origin?: string };
|
||||||
|
'stamp:all': { origin?: string }; // golden slipmat unlock
|
||||||
|
|
||||||
// Mining-in-progress juice (first-five-minutes pass). Emitted ~8 Hz while
|
// Mining-in-progress juice (first-five-minutes pass). Emitted ~8 Hz while
|
||||||
// a block is being held-mined; FX puffs and audio ticks react. FX-only —
|
// a block is being held-mined; FX puffs and audio ticks react. FX-only —
|
||||||
|
|||||||
@ -27,6 +27,31 @@
|
|||||||
// radial gauge + ratchet ticks; "screws" → confetti; each workshop SFX.
|
// radial gauge + ratchet ticks; "screws" → confetti; each workshop SFX.
|
||||||
// (These fire the real workshop:test / workshop:torque / workshop:msg bus
|
// (These fire the real workshop:test / workshop:torque / workshop:msg bus
|
||||||
// events that Lane D's machine emits in-game.)
|
// events that Lane D's machine emits in-game.)
|
||||||
|
// • SIDE B — crate worlds (fires the real world:*/stamp:*/fader:move/
|
||||||
|
// machine:interact events Lane M emits in-game):
|
||||||
|
// - Enter a world → portal whoosh-down, the WHOLE booth mix (incl. the
|
||||||
|
// dead-booth heartbeat) ducks out in ~300 ms, that world's groove spins
|
||||||
|
// up (acid 118 / dub 74 half-time / disco 122). Exit → reverse whoosh,
|
||||||
|
// groove brakes, booth mix hands back at its CURRENT state.
|
||||||
|
// - Duck/handback truth table to verify by ear: enter with deck A playing
|
||||||
|
// (mix ducks, returns), enter with all stopped (heartbeat ducks,
|
||||||
|
// returns), press "blow fuse" WHILE inside a pocket (booth power-down
|
||||||
|
// muffled under the groove; exiting lands on the reset heartbeat, never
|
||||||
|
// a stale snapshot), fix→WIN then dive+exit (full win mix hands back).
|
||||||
|
// - ACID: sweep the three pedestal faders — each drives its band of the
|
||||||
|
// 303 line's filter cutoff (audibly, exponentially). "lock N" adds that
|
||||||
|
// band's shimmer layer + chime. Stamp = 4 bars of clap glory.
|
||||||
|
// - DUB: rim skank echoes (3/8 feedback echo). "pickup" raises the echo
|
||||||
|
// wet (you carry it), "drop" lowers it, FEED = the big SPROING with
|
||||||
|
// feedback momentarily 0.8. Stamp = deep boing glory.
|
||||||
|
// - DISCO: tap tiles 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 * as THREE from 'three';
|
||||||
import { bus } from '../core/events';
|
import { bus } from '../core/events';
|
||||||
@ -159,6 +184,39 @@ const deckPlaying: Record<'A' | 'B', boolean> = { A: false, B: false };
|
|||||||
const rpm: Record<'A' | 'B', number> = { A: PLATTER.rpm33, B: PLATTER.rpm33 };
|
const rpm: Record<'A' | 'B', number> = { A: PLATTER.rpm33, B: PLATTER.rpm33 };
|
||||||
buildPanel();
|
buildPanel();
|
||||||
|
|
||||||
|
// DEMO MOCK — not for integration: automation/verification handle. Lets the
|
||||||
|
// demo be driven and *asserted* headlessly (duck gain, active pocket groove,
|
||||||
|
// true output RMS via an analyser tapped onto the master out).
|
||||||
|
{
|
||||||
|
let tap: AnalyserNode | null = null;
|
||||||
|
let tapBuf: Uint8Array<ArrayBuffer> | null = null;
|
||||||
|
const priv = audio as unknown as {
|
||||||
|
ctx?: AudioContext; outGain?: GainNode; boothGain?: GainNode;
|
||||||
|
worlds?: { active: string | null }; groove?: { getBonusStem(): boolean };
|
||||||
|
};
|
||||||
|
const ensureTap = () => {
|
||||||
|
if (tap || !priv.ctx || !priv.outGain) return;
|
||||||
|
tap = priv.ctx.createAnalyser();
|
||||||
|
tap.fftSize = 2048;
|
||||||
|
tapBuf = new Uint8Array(tap.fftSize);
|
||||||
|
priv.outGain.connect(tap);
|
||||||
|
};
|
||||||
|
(window as unknown as { DEMO: unknown }).DEMO = {
|
||||||
|
audio, bus,
|
||||||
|
boothDuck: () => priv.boothGain?.gain.value ?? -1,
|
||||||
|
worldActive: () => priv.worlds?.active ?? null,
|
||||||
|
bonusStem: () => priv.groove?.getBonusStem() ?? false,
|
||||||
|
outRms: () => {
|
||||||
|
ensureTap();
|
||||||
|
if (!tap || !tapBuf) return -1;
|
||||||
|
tap.getByteTimeDomainData(tapBuf);
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < tapBuf.length; i++) { const d = (tapBuf[i] - 128) / 128; sum += d * d; }
|
||||||
|
return Math.sqrt(sum / tapBuf.length);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ── loop (a demo may own a rAF loop; see CONTRACTS §5) ──────────────────────
|
// ── loop (a demo may own a rAF loop; see CONTRACTS §5) ──────────────────────
|
||||||
let last = performance.now();
|
let last = performance.now();
|
||||||
function frame(now: number) {
|
function frame(now: number) {
|
||||||
@ -219,6 +277,7 @@ function buildPanel() {
|
|||||||
.tc-panel button.on { background:#2a5; color:#031; border-color:#3c7; }
|
.tc-panel button.on { background:#2a5; color:#031; border-color:#3c7; }
|
||||||
.tc-grid { display:grid; grid-template-columns:1fr 1fr; gap:4px; }
|
.tc-grid { display:grid; grid-template-columns:1fr 1fr; gap:4px; }
|
||||||
.tc-grid.three { grid-template-columns:1fr 1fr 1fr; }
|
.tc-grid.three { grid-template-columns:1fr 1fr 1fr; }
|
||||||
|
.tc-grid.four { grid-template-columns:1fr 1fr 1fr 1fr; }
|
||||||
.tc-val { flex:0 0 30px; text-align:right; color:#8ab; }
|
.tc-val { flex:0 0 30px; text-align:right; color:#8ab; }
|
||||||
`;
|
`;
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
@ -343,6 +402,85 @@ function buildPanel() {
|
|||||||
const wsfx = grid('three');
|
const wsfx = grid('three');
|
||||||
(['crimp', 'wireOn', 'wireOff', 'screwClunk', 'tiltCreak', 'weightDetent', 'bubbleLock', 'skate'] as const)
|
(['crimp', 'wireOn', 'wireOff', 'screwClunk', 'tiltCreak', 'weightDetent', 'bubbleLock', 'skate'] as const)
|
||||||
.forEach((n) => btn(n, () => audio.playSfx(n), wsfx));
|
.forEach((n) => btn(n, () => audio.playSfx(n), wsfx));
|
||||||
|
|
||||||
|
// SIDE B — CRATE WORLDS: fires the exact bus events Lane M's portals/quests
|
||||||
|
// emit in-game (world:enter/exit, fader:move acid_N, machine:interact
|
||||||
|
// acid_lock/dub_*/disco_*, stamp:got, stamp:all, quest:reset) so this
|
||||||
|
// exercises the real AudioEngine duck/handback + groove paths.
|
||||||
|
h('SIDE B — crate worlds');
|
||||||
|
type PocketW = 'acid' | 'dub' | 'disco';
|
||||||
|
let inWorld: PocketW | null = null;
|
||||||
|
const worldBtns = {} as Record<PocketW, HTMLButtonElement>;
|
||||||
|
const setWorldBtn = (w: PocketW, on: boolean) => {
|
||||||
|
worldBtns[w].textContent = on ? `⏏ exit ${w}` : `▶ ${w}`;
|
||||||
|
worldBtns[w].classList.toggle('on', on);
|
||||||
|
};
|
||||||
|
const wGrid = grid('three');
|
||||||
|
(['acid', 'dub', 'disco'] as PocketW[]).forEach((w) => {
|
||||||
|
worldBtns[w] = btn(`▶ ${w}`, () => {
|
||||||
|
if (inWorld === w) {
|
||||||
|
inWorld = null;
|
||||||
|
bus.emit('world:exit', { world: w });
|
||||||
|
setWorldBtn(w, false);
|
||||||
|
} else {
|
||||||
|
if (inWorld) { bus.emit('world:exit', { world: inWorld }); setWorldBtn(inWorld, false); }
|
||||||
|
inWorld = w;
|
||||||
|
bus.emit('world:enter', { world: w });
|
||||||
|
setWorldBtn(w, true);
|
||||||
|
}
|
||||||
|
}, wGrid);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ACID: three pedestal faders (per-band 303 cutoff) + lock pips
|
||||||
|
(['acid_0', 'acid_1', 'acid_2'] as const).forEach((id, i) =>
|
||||||
|
slider(id, 0, 1, 0.01, [0.12, 0.85, 0.18][i], (v) => bus.emit('fader:move', { faderId: id, value: v })));
|
||||||
|
const lockRow = grid('three');
|
||||||
|
[0, 1, 2].forEach((i) => btn(`lock ${i}`, (b) => {
|
||||||
|
b.classList.add('on');
|
||||||
|
bus.emit('machine:interact', { machineId: `acid_${i}`, action: 'acid_lock' });
|
||||||
|
}, lockRow));
|
||||||
|
|
||||||
|
// DUB: carry the wet + the big SPROING
|
||||||
|
const dubRow = grid('three');
|
||||||
|
const pickupBtn = btn('pickup', (b) => {
|
||||||
|
const on = b.classList.toggle('on');
|
||||||
|
b.textContent = on ? 'drop' : 'pickup';
|
||||||
|
bus.emit('machine:interact', { machineId: 'dub_charge', action: on ? 'dub_pickup' : 'dub_drop' });
|
||||||
|
}, dubRow);
|
||||||
|
btn('FEED ⚡', () => {
|
||||||
|
pickupBtn.classList.remove('on'); pickupBtn.textContent = 'pickup';
|
||||||
|
bus.emit('machine:interact', { machineId: 'dub_horn', action: 'dub_feed' });
|
||||||
|
}, dubRow);
|
||||||
|
btn('boing', () => bus.emit('machine:interact', { machineId: 'dub_spring', action: 'dub_feed' }), dubRow);
|
||||||
|
|
||||||
|
// DISCO: the 4x4 tile grid (cue blips by index) + the player's step echo
|
||||||
|
let lastCue = 0;
|
||||||
|
const tileGrid = grid('four');
|
||||||
|
for (let i = 0; i < 16; i++) {
|
||||||
|
btn(String(i), () => {
|
||||||
|
lastCue = i;
|
||||||
|
bus.emit('machine:interact', { machineId: `disco_tile_${i}`, action: 'disco_cue' });
|
||||||
|
}, tileGrid);
|
||||||
|
}
|
||||||
|
btn('step echo (5th down)', () =>
|
||||||
|
bus.emit('machine:interact', { machineId: `disco_tile_${lastCue}`, action: 'disco_step' }), grid());
|
||||||
|
|
||||||
|
// STAMPS: thunk+fanfare per world; third one triggers stamp:all (riser +
|
||||||
|
// permanent bonus percussion stem in the booth mix). Re-click = idempotent.
|
||||||
|
const stamped = new Set<PocketW>();
|
||||||
|
const stampRow = grid('three');
|
||||||
|
(['acid', 'dub', 'disco'] as PocketW[]).forEach((w) => btn(`✦ ${w}`, (b) => {
|
||||||
|
stamped.add(w);
|
||||||
|
b.classList.add('on');
|
||||||
|
bus.emit('stamp:got', { world: w, count: stamped.size, total: 3 });
|
||||||
|
if (stamped.size === 3) window.setTimeout(() => bus.emit('stamp:all', {}), 350);
|
||||||
|
}, stampRow));
|
||||||
|
|
||||||
|
// misc: bunny squeak, emote airhorn, the real reset ritual (not a reload)
|
||||||
|
const miscRow = grid('three');
|
||||||
|
btn('bunny', () => bus.emit('machine:interact', { machineId: 'bunny_0', action: 'bunny_flee' }), miscRow);
|
||||||
|
btn('airhorn', () => bus.emit('player:emote', { kind: 3, self: true, at: [224, 82, 96] }), miscRow);
|
||||||
|
btn('blow fuse', () => bus.emit('quest:reset', { by: 'DEMO' }), miscRow);
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveCamTo(x: number, z: number) {
|
function moveCamTo(x: number, z: number) {
|
||||||
|
|||||||
@ -16,6 +16,13 @@
|
|||||||
// slots / button recesses, the spinning-strobe red dot, the empty gold
|
// slots / button recesses, the spinning-strobe red dot, the empty gold
|
||||||
// quest socket (red blink) on the patch bay, and the crate of blue/black
|
// quest socket (red blink) on the patch bay, and the crate of blue/black
|
||||||
// records under the front-left hatch.
|
// records under the front-left hatch.
|
||||||
|
// • SIDE B (Lane W): three sealed matte-black pocket slabs sit on the booth
|
||||||
|
// ceiling (ACID/DUB/DISCO buttons view them from outside — they must read
|
||||||
|
// as plain black boxes, zero glow). Interiors are verified by the POCKET
|
||||||
|
// validation lines (sealed skin, entry/exit, pedestals, bridge, tiles,
|
||||||
|
// portal rings) — walk them in the real game via Lane M's portals, or with
|
||||||
|
// the cine camera (window.TURNCRAFT_CINE) placed inside the bounds.
|
||||||
|
// "hide plywood shell" also reveals the crate portal rings + disc marquees.
|
||||||
|
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
||||||
@ -25,6 +32,10 @@ import { WORLD_X, WORLD_Y, WORLD_Z, Y_PLINTH_TOP, Y_TABLETOP, LAYOUT, PLATTER }
|
|||||||
import type { IVoxelWorld, Vec3 } from '../core/types';
|
import type { IVoxelWorld, Vec3 } from '../core/types';
|
||||||
import { buildBooth, SPAWN } from '../worldgen/buildBooth';
|
import { buildBooth, SPAWN } from '../worldgen/buildBooth';
|
||||||
import { QUEST_POS, QUEST_REACH_POINTS, hasAdjacentAir } from '../worldgen/questPositions';
|
import { QUEST_POS, QUEST_REACH_POINTS, hasAdjacentAir } from '../worldgen/questPositions';
|
||||||
|
import { WORLD_DEFS, WORLD_KEYS } from '../core/worlds';
|
||||||
|
import {
|
||||||
|
ACCENT_LED, ACID_PEDESTAL_X, ACID_PEDESTAL_Z, DUB_BRIDGE, DISCO_TILE_CX, DISCO_TILE_CZ,
|
||||||
|
} from '../worldgen/worlds';
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// DEMO MOCK — not for integration. A flat Uint8Array voxel store implementing
|
// DEMO MOCK — not for integration. A flat Uint8Array voxel store implementing
|
||||||
@ -96,6 +107,106 @@ function validate(): { line: string; pass: boolean }[] {
|
|||||||
}
|
}
|
||||||
add(`record crate populated (${crateVinyl})`, crateVinyl > 500);
|
add(`record crate populated (${crateVinyl})`, crateVinyl > 500);
|
||||||
|
|
||||||
|
// ── SIDE B (Lane W): pocket assertions ──────────────────────────────────
|
||||||
|
const BLACK = 5, GOLD = 29; // matte_black / rca_gold (registry-stable ids)
|
||||||
|
for (const k of WORLD_KEYS) {
|
||||||
|
const d = WORLD_DEFS[k];
|
||||||
|
const [x0, y0, z0] = d.pocketMin, [x1, y1, z1] = d.pocketMax;
|
||||||
|
|
||||||
|
// The 1-voxel skin is complete, all matte_black — which also means no
|
||||||
|
// emissive and no transparent block anywhere on it: zero light leaks.
|
||||||
|
let breach = 0;
|
||||||
|
for (let x = x0; x <= x1; x++) for (let y = y0; y <= y1; y++) for (let z = z0; z <= z1; z++) {
|
||||||
|
if (x !== x0 && x !== x1 && y !== y0 && y !== y1 && z !== z0 && z !== z1) continue;
|
||||||
|
if (world.getBlock(x, y, z) !== BLACK) breach++;
|
||||||
|
}
|
||||||
|
add(`pocket ${k}: skin sealed matte_black (${breach} breaches)`, breach === 0);
|
||||||
|
|
||||||
|
const [ex, ey, ez] = d.entry;
|
||||||
|
add(`pocket ${k}: entry solid floor + headroom`,
|
||||||
|
isSolidId(world.getBlock(ex, ey - 1, ez)) &&
|
||||||
|
world.getBlock(ex, ey, ez) === AIR && world.getBlock(ex, ey + 1, ez) === AIR);
|
||||||
|
|
||||||
|
const [px, py, pz] = d.exitPad;
|
||||||
|
let pad = true;
|
||||||
|
for (let dx = -1; dx <= 1; dx++) for (let dz = -1; dz <= 1; dz++) {
|
||||||
|
const want = dx === 0 && dz === 0 ? ACCENT_LED[k] : GOLD;
|
||||||
|
if (world.getBlock(px + dx, py, pz + dz) !== want) pad = false;
|
||||||
|
if (world.getBlock(px + dx, py + 1, pz + dz) !== AIR) pad = false;
|
||||||
|
}
|
||||||
|
add(`pocket ${k}: 3×3 gold exit pad + accent pip, clear above`, pad);
|
||||||
|
|
||||||
|
// portal dressing at the crate (W1): floor ring + disc marquee
|
||||||
|
const [sx, sy, sz] = d.portalStand;
|
||||||
|
let ring = 0;
|
||||||
|
for (let dx = -3; dx <= 3; dx++) for (let dz = -3; dz <= 3; dz++) {
|
||||||
|
const d2 = dx * dx + dz * dz;
|
||||||
|
if (d2 >= 7 && d2 <= 11 && world.getBlock(sx + dx, sy - 1, sz + dz) === ACCENT_LED[k]) ring++;
|
||||||
|
}
|
||||||
|
const [qx, qy, qz] = d.portalDisc;
|
||||||
|
const marq = [world.getBlock(qx, qy + 13, qz), world.getBlock(qx, qy + 12, qz - 4), world.getBlock(qx, qy + 12, qz + 4)]
|
||||||
|
.filter((id) => id === ACCENT_LED[k]).length;
|
||||||
|
add(`pocket ${k}: portal ring ${ring}/16 + marquee ${marq}/3`, ring === 16 && marq === 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// above the booth ceiling, ONLY the three pockets exist (no stray writes)
|
||||||
|
{
|
||||||
|
let stray = 0;
|
||||||
|
for (let y = 142; y < WORLD_Y; y++) for (let z = 0; z < WORLD_Z; z++) for (let x = 0; x < WORLD_X; x++) {
|
||||||
|
if (world.getBlock(x, y, z) === AIR) continue;
|
||||||
|
const inside = WORLD_KEYS.some((k) => {
|
||||||
|
const d = WORLD_DEFS[k];
|
||||||
|
return x >= d.pocketMin[0] && x <= d.pocketMax[0] && y >= d.pocketMin[1] &&
|
||||||
|
y <= d.pocketMax[1] && z >= d.pocketMin[2] && z <= d.pocketMax[2];
|
||||||
|
});
|
||||||
|
if (!inside) stray++;
|
||||||
|
}
|
||||||
|
add(`nothing above y141 outside the pockets (${stray})`, stray === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// acid pedestals (Lane M mounts the 303 knobs on these)
|
||||||
|
{
|
||||||
|
let ok = true;
|
||||||
|
for (const pz of ACID_PEDESTAL_Z) {
|
||||||
|
for (let dx = -1; dx <= 1; dx++) for (let dy = 0; dy <= 2; dy++) for (let dz = -1; dz <= 1; dz++)
|
||||||
|
if (world.getBlock(ACID_PEDESTAL_X + dx, 144 + dy, pz + dz) !== BLACK) ok = false;
|
||||||
|
if (blockDef(world.getBlock(ACID_PEDESTAL_X, 147, pz)).name !== 'led_green') ok = false;
|
||||||
|
}
|
||||||
|
add('pocket acid: 3 knob pedestals + green pips', ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
// dub bridge: 3×24 chrome deck with the gold tank-mouth inlay, walkable
|
||||||
|
{
|
||||||
|
let ok = true;
|
||||||
|
for (let x = DUB_BRIDGE.x0; x <= DUB_BRIDGE.x1; x++) for (let z = DUB_BRIDGE.z0; z <= DUB_BRIDGE.z1; z++) {
|
||||||
|
const want = x === DUB_BRIDGE.mouth[0] && z === DUB_BRIDGE.mouth[2] ? GOLD : 11 /* chrome */;
|
||||||
|
if (world.getBlock(x, DUB_BRIDGE.deckY, z) !== want) ok = false;
|
||||||
|
if (world.getBlock(x, DUB_BRIDGE.deckY + 1, z) !== AIR) ok = false;
|
||||||
|
}
|
||||||
|
add('pocket dub: 3×24 chrome bridge deck + gold mouth', ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
// disco: 16 flush 5×5 tiles with led centres
|
||||||
|
{
|
||||||
|
let ok = true, leds = 0;
|
||||||
|
for (const cx of DISCO_TILE_CX) for (const cz of DISCO_TILE_CZ) {
|
||||||
|
const nm = blockDef(world.getBlock(cx, 143, cz)).name;
|
||||||
|
if (nm === 'led_red' || nm === 'led_blue') leds++;
|
||||||
|
for (let dx = -2; dx <= 2; dx++) for (let dz = -2; dz <= 2; dz++)
|
||||||
|
if (world.getBlock(cx + dx, 143, cz + dz) === AIR) ok = false;
|
||||||
|
}
|
||||||
|
add(`pocket disco: dance-tile grid (${leds}/16 led centres)`, ok && leds === 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
// determinism: a second full build must be byte-identical
|
||||||
|
{
|
||||||
|
const w2 = new MockWorld();
|
||||||
|
buildBooth(w2);
|
||||||
|
let same = true;
|
||||||
|
for (let i = 0; i < world.data.length; i++) if (world.data[i] !== w2.data[i]) { same = false; break; }
|
||||||
|
add('deterministic (two builds byte-identical)', same);
|
||||||
|
}
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
const validation = validate();
|
const validation = validate();
|
||||||
@ -199,6 +310,16 @@ const zones: { name: string; target: Vec3; from: Vec3 }[] = [
|
|||||||
// aims at the crate's actual centre (under-ledge, in front of Deck A); toggle "hide shell" to see in
|
// aims at the crate's actual centre (under-ledge, in front of Deck A); toggle "hide shell" to see in
|
||||||
{ name: 'Record Crate', target: [LAYOUT.rimThickness + 30, 15, LAYOUT.rimThickness + 14], from: [LAYOUT.rimThickness + 30, 58, LAYOUT.frontLipZ - 50] },
|
{ name: 'Record Crate', target: [LAYOUT.rimThickness + 30, 15, LAYOUT.rimThickness + 14], from: [LAYOUT.rimThickness + 30, 58, LAYOUT.frontLipZ - 50] },
|
||||||
{ name: 'Whole booth', target: [WORLD_X / 2, 40, WORLD_Z / 2], from: [WORLD_X / 2 - 40, 260, -140] },
|
{ name: 'Whole booth', target: [WORLD_X / 2, 40, WORLD_Z / 2], from: [WORLD_X / 2 - 40, 260, -140] },
|
||||||
|
// SIDE B pockets, viewed from OUTSIDE: sealed matte-black slabs, zero glow.
|
||||||
|
...WORLD_KEYS.map((k) => {
|
||||||
|
const d = WORLD_DEFS[k];
|
||||||
|
const cx = (d.pocketMin[0] + d.pocketMax[0]) / 2;
|
||||||
|
return {
|
||||||
|
name: `Pocket: ${d.name}`,
|
||||||
|
target: [cx, 150, 52] as Vec3,
|
||||||
|
from: [cx, 195, -50] as Vec3,
|
||||||
|
};
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
function goto(z: { target: Vec3; from: Vec3 }) {
|
function goto(z: { target: Vec3; from: Vec3 }) {
|
||||||
camera.position.set(z.from[0], z.from[1], z.from[2]);
|
camera.position.set(z.from[0], z.from[1], z.from[2]);
|
||||||
|
|||||||
@ -9,10 +9,22 @@ import * as THREE from 'three';
|
|||||||
import { bus } from '../core/events';
|
import { bus } from '../core/events';
|
||||||
import { PLATTER } from '../core/constants';
|
import { PLATTER } from '../core/constants';
|
||||||
import { blockDef } from '../core/blocks';
|
import { blockDef } from '../core/blocks';
|
||||||
|
import { WORLD_DEFS, type WorldDef } from '../core/worlds';
|
||||||
|
import type { IVoxelWorld, IPlayerView } from '../core/types';
|
||||||
import { makeGlowTexture, Burst, DustField } from './particles';
|
import { makeGlowTexture, Burst, DustField } from './particles';
|
||||||
import { VuBank, SignalTrace, PatchBlink } from './overlays';
|
import { VuBank, SignalTrace, PatchBlink } from './overlays';
|
||||||
|
import { DustBunnies } from './bunnies';
|
||||||
|
import { PortalIris } from './portalIris';
|
||||||
import { DUST_BOX, DECK_RIM, PATCH_BAY_POS, SIGNAL_PATH } from './layout';
|
import { DUST_BOX, DECK_RIM, PATCH_BAY_POS, SIGNAL_PATH } from './layout';
|
||||||
|
|
||||||
|
/** SIDE B: accent lookup for a bus `world` string (warm white if unknown). */
|
||||||
|
function worldDef(key: string): WorldDef | undefined {
|
||||||
|
return (WORLD_DEFS as Record<string, WorldDef>)[key];
|
||||||
|
}
|
||||||
|
const DEFAULT_ACCENT: [number, number, number] = [255, 214, 120];
|
||||||
|
const GOLD = { r: 1.0, g: 0.84, b: 0.32 };
|
||||||
|
const SHIMMER_S = 2.6; // stamp:all golden shimmer length
|
||||||
|
|
||||||
export interface FxOpts {
|
export interface FxOpts {
|
||||||
scene: THREE.Object3D;
|
scene: THREE.Object3D;
|
||||||
setEmissiveBoost: (v: number) => void;
|
setEmissiveBoost: (v: number) => void;
|
||||||
@ -65,12 +77,23 @@ export class FxSystem {
|
|||||||
private beaconRepaired: (node: string) => boolean = () => true;
|
private beaconRepaired: (node: string) => boolean = () => true;
|
||||||
private beaconClock = 0;
|
private beaconClock = 0;
|
||||||
|
|
||||||
|
// SIDE B (Lane S): dust bunnies under the table (integrator wires via
|
||||||
|
// attachAmbience(); silent until then), the vinyl-warp portal iris (screen
|
||||||
|
// overlay, self-contained), and the stamp:all golden shimmer timeline.
|
||||||
|
private bunnies: DustBunnies | null = null;
|
||||||
|
private playerView: IPlayerView | null = null;
|
||||||
|
private iris = new PortalIris();
|
||||||
|
private tex: THREE.Texture;
|
||||||
|
private shimmer = 0; // seconds left of the golden shimmer
|
||||||
|
private bornAt = performance.now();
|
||||||
|
|
||||||
constructor(opts: FxOpts) {
|
constructor(opts: FxOpts) {
|
||||||
this.scene = opts.scene;
|
this.scene = opts.scene;
|
||||||
this.setBoost = opts.setEmissiveBoost;
|
this.setBoost = opts.setEmissiveBoost;
|
||||||
this.getLevels = opts.getLevels ?? (() => ({ low: 0, mid: 0, high: 0 }));
|
this.getLevels = opts.getLevels ?? (() => ({ low: 0, mid: 0, high: 0 }));
|
||||||
|
|
||||||
const tex = makeGlowTexture();
|
const tex = makeGlowTexture();
|
||||||
|
this.tex = tex;
|
||||||
this.dust = new DustField(280, tex, DUST_BOX.min, DUST_BOX.max);
|
this.dust = new DustField(280, tex, DUST_BOX.min, DUST_BOX.max);
|
||||||
this.puff = new Burst(160, 1.4, tex, 6, 3.5);
|
this.puff = new Burst(160, 1.4, tex, 6, 3.5);
|
||||||
this.confetti = new Burst(220, 2.2, tex, 9, 0.6);
|
this.confetti = new Burst(220, 2.2, tex, 9, 0.6);
|
||||||
@ -86,6 +109,17 @@ export class FxSystem {
|
|||||||
this.wireBus();
|
this.wireBus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** SIDE B: wire the under-table dust bunnies (they need world.isSolid for
|
||||||
|
* wall lookahead and the player position for the flee trigger) and give the
|
||||||
|
* stamp confetti a player anchor. Integrator calls this once after the
|
||||||
|
* player exists — like setBeacons, everything stays silent until wired. */
|
||||||
|
attachAmbience(world: IVoxelWorld, player: IPlayerView): void {
|
||||||
|
this.playerView = player;
|
||||||
|
if (this.bunnies) return; // idempotent
|
||||||
|
this.bunnies = new DustBunnies(world, player, this.tex);
|
||||||
|
this.scene.add(this.bunnies.group);
|
||||||
|
}
|
||||||
|
|
||||||
/** Anchor the broken-node spark beacons (integrator supplies positions + state). */
|
/** Anchor the broken-node spark beacons (integrator supplies positions + state). */
|
||||||
setBeacons(
|
setBeacons(
|
||||||
anchors: { node: string; pos: [number, number, number] }[],
|
anchors: { node: string; pos: [number, number, number] }[],
|
||||||
@ -118,6 +152,8 @@ export class FxSystem {
|
|||||||
this.vu.update(this.getLevels());
|
this.vu.update(this.getLevels());
|
||||||
this.updateRimSparkle(dt);
|
this.updateRimSparkle(dt);
|
||||||
this.updateBeacons(dt);
|
this.updateBeacons(dt);
|
||||||
|
this.bunnies?.update(dt);
|
||||||
|
this.iris.update();
|
||||||
|
|
||||||
// LED pulse decay
|
// LED pulse decay
|
||||||
this.pulse *= Math.exp(-dt / this.pulseTau);
|
this.pulse *= Math.exp(-dt / this.pulseTau);
|
||||||
@ -139,6 +175,14 @@ export class FxSystem {
|
|||||||
if (this.won) this.winClock += dt;
|
if (this.won) this.winClock += dt;
|
||||||
boost = this.boostBase + this.pulse;
|
boost = this.boostBase + this.pulse;
|
||||||
}
|
}
|
||||||
|
// stamp:all golden shimmer: a glittering wave over the whole booth that
|
||||||
|
// eases out (~2.6 s). Additive on top of whatever the booth is doing.
|
||||||
|
if (this.shimmer > 0) {
|
||||||
|
this.shimmer = Math.max(0, this.shimmer - dt);
|
||||||
|
const t = SHIMMER_S - this.shimmer;
|
||||||
|
const env = Math.min(1, t * 6) * (this.shimmer / SHIMMER_S);
|
||||||
|
boost += env * (0.45 + 0.2 * Math.sin(t * 12));
|
||||||
|
}
|
||||||
this.setBoost(Math.max(0, Math.min(2, boost)));
|
this.setBoost(Math.max(0, Math.min(2, boost)));
|
||||||
this.lastBoost = boost;
|
this.lastBoost = boost;
|
||||||
}
|
}
|
||||||
@ -272,5 +316,56 @@ export class FxSystem {
|
|||||||
r: tint[0] / 255, g: tint[1] / 255, b: tint[2] / 255,
|
r: tint[0] / 255, g: tint[1] / 255, b: tint[2] / 255,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── SIDE B (Lane S): portal iris + stamp celebrations ──────────────────
|
||||||
|
// Screen-overlay iris only — the camera (incl. the flythrough's cine
|
||||||
|
// override) is never touched.
|
||||||
|
bus.on('world:enter', (p) => {
|
||||||
|
this.iris.trigger(worldDef(p.world)?.accent ?? DEFAULT_ACCENT, 'enter');
|
||||||
|
});
|
||||||
|
bus.on('world:exit', (p) => {
|
||||||
|
this.iris.trigger(worldDef(p.world)?.accent ?? DEFAULT_ACCENT, 'exit');
|
||||||
|
});
|
||||||
|
|
||||||
|
// stamp:got — celebration scaled by origin (carried on the event, so no
|
||||||
|
// wall-clock guessing): 'local' = full accent confetti at the winner (they
|
||||||
|
// are standing in the pocket), 'remote' = a modest burst at that record's
|
||||||
|
// crate disc (the win happened across the booth — never at the local
|
||||||
|
// player's body), 'replay' = join-snapshot state, no celebration.
|
||||||
|
bus.on('stamp:got', (p) => {
|
||||||
|
if (p.origin === 'replay') return;
|
||||||
|
const def = worldDef(p.world);
|
||||||
|
const a = def?.accent ?? DEFAULT_ACCENT;
|
||||||
|
if (p.origin === 'remote') {
|
||||||
|
if (!def) return;
|
||||||
|
const d = def.portalDisc;
|
||||||
|
this.confetti.spawn(28, d[0], d[1] + 4, d[2], {
|
||||||
|
spread: 7, speed: 6, up: 5, life: 2.0, jitter: 4,
|
||||||
|
r: a[0] / 255, g: a[1] / 255, b: a[2] / 255,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const at = this.playerView?.position ?? def?.entry;
|
||||||
|
if (!at) return;
|
||||||
|
this.confetti.spawn(70, at[0], at[1] + 2.2, at[2], {
|
||||||
|
spread: 10, speed: 9, up: 6, life: 2.4, jitter: 2.5,
|
||||||
|
r: a[0] / 255, g: a[1] / 255, b: a[2] / 255,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// stamp:all — the golden finish: booth-wide emissive shimmer (via the same
|
||||||
|
// injected setEmissiveBoost path, see update()) + gold confetti at the
|
||||||
|
// crate's three portal discs. Replayed state seeds silently.
|
||||||
|
bus.on('stamp:all', (p) => {
|
||||||
|
if (p.origin === 'replay') return;
|
||||||
|
this.shimmer = SHIMMER_S;
|
||||||
|
for (const k of Object.keys(WORLD_DEFS) as (keyof typeof WORLD_DEFS)[]) {
|
||||||
|
const d = WORLD_DEFS[k].portalDisc;
|
||||||
|
this.confetti.spawn(50, d[0], d[1] + 4, d[2], {
|
||||||
|
spread: 9, speed: 8, up: 6, life: 2.8, jitter: 6,
|
||||||
|
r: GOLD.r, g: GOLD.g, b: GOLD.b,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -102,3 +102,21 @@ sideMat.needsUpdate = true;
|
|||||||
It's a standalone generator (no side effects); safe to call once and share the
|
It's a standalone generator (no side effects); safe to call once and share the
|
||||||
texture across both platters. Only the platter wiring is left — everything else
|
texture across both platters. Only the platter wiring is left — everything else
|
||||||
here is live and browser-verified.
|
here is live and browser-verified.
|
||||||
|
|
||||||
|
## SIDE B — Lane S (see docs/SIDEB_S_handoff.md for full detail)
|
||||||
|
|
||||||
|
- `bunnies.ts` — `DustBunnies`: 4 under-table fluff critters (wander / flee 8
|
||||||
|
voxels with ease-out / settle; `world.isSolid` lookahead; portal + quest-spot
|
||||||
|
keep-outs; zero steady-state allocation). Emits `machine:interact
|
||||||
|
{machineId:'bunny', action:'bunny_flee'}` ≤ 1/s.
|
||||||
|
- `portalIris.ts` — `PortalIris`: the vinyl-warp screen iris on
|
||||||
|
`world:enter`/`world:exit`, accent-tinted from `WORLD_DEFS`. Fixed-position
|
||||||
|
canvas at z-index 9 (under the HUD), never touches the camera; driven from
|
||||||
|
`fx.update()`, `display:none` when idle.
|
||||||
|
- `FxSystem` additions: **`fx.attachAmbience(world, player)` — the one line
|
||||||
|
the integrator wires in main.ts** (after the player exists; setBeacons
|
||||||
|
pattern — silent until called). Also: `stamp:got` accent confetti at the
|
||||||
|
player, `stamp:all` gold confetti at the crate discs + a 2.6 s golden
|
||||||
|
emissive shimmer through the existing `setEmissiveBoost` path. Stamp events
|
||||||
|
arriving < 4 s after construction are treated as Lane M's join replay:
|
||||||
|
state only, no celebration.
|
||||||
|
|||||||
323
src/fx/bunnies.ts
Normal file
323
src/fx/bunnies.ts
Normal file
@ -0,0 +1,323 @@
|
|||||||
|
// TURNCRAFT — Lane S (SIDE B). Dust bunnies: 4 fist-sized fluff-balls that
|
||||||
|
// drift around the under-table PCB floor (y≈3), flee when the player gets
|
||||||
|
// close, and settle again. Purely cosmetic — no colliders, no game effect.
|
||||||
|
//
|
||||||
|
// Consumes only core contracts (IVoxelWorld / IPlayerView / WORLD_DEFS) — the
|
||||||
|
// world + player views are injected by the integrator via
|
||||||
|
// `FxSystem.attachAmbience`. All per-tick math is scalar on preallocated
|
||||||
|
// per-bunny records: zero steady-state allocations. The only allocation a
|
||||||
|
// bunny ever causes after construction is the `machine:interact
|
||||||
|
// {action:'bunny_flee'}` payload, rate-limited to at most 1/s globally
|
||||||
|
// (Lane E2 maps it to a squeak).
|
||||||
|
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { bus } from '../core/events';
|
||||||
|
import { LAYOUT } from '../core/constants';
|
||||||
|
import { WORLD_DEFS, WORLD_KEYS } from '../core/worlds';
|
||||||
|
import type { IVoxelWorld, IPlayerView } from '../core/types';
|
||||||
|
|
||||||
|
// ── keep-out zones (bunnies never wander in; fleeing steers around) ─────────
|
||||||
|
// The three portal rings (WORLD_DEFS[k].portalStand ± 6) and the two quest
|
||||||
|
// spots in the fuse room. The parts-bin / fuse-box centres mirror the
|
||||||
|
// arithmetic in worldgen/anchors.ts (UNDER.partsBin / UNDER.fuseBox) the same
|
||||||
|
// way fx/layout.ts mirrors the VU towers — derived from LAYOUT, never imported
|
||||||
|
// (worldgen is another lane's directory).
|
||||||
|
interface Zone { x: number; z: number; r2: number; }
|
||||||
|
const M = LAYOUT.mixer;
|
||||||
|
const KEEP_OUT: Zone[] = [
|
||||||
|
...WORLD_KEYS.map((k) => {
|
||||||
|
const s = WORLD_DEFS[k].portalStand;
|
||||||
|
return { x: s[0] + 0.5, z: s[2] + 0.5, r2: 6 * 6 };
|
||||||
|
}),
|
||||||
|
{ x: M.minX + 15.5, z: M.minZ + 10.5, r2: 6 * 6 }, // parts bin tray (stylus + fuse pickups)
|
||||||
|
{ x: M.minX + 24.5, z: M.maxZ - 7.5, r2: 6 * 6 }, // fuse box wall spot (reset ritual ground)
|
||||||
|
];
|
||||||
|
|
||||||
|
// Home points on the under-table floor: two in the record-crate area (where
|
||||||
|
// SIDE B pulls players), one in the mixer PCB room, one under deck A. Each
|
||||||
|
// bunny is leashed to its home so it stays findable.
|
||||||
|
const RIM = LAYOUT.rimThickness;
|
||||||
|
const HOMES: [number, number][] = [
|
||||||
|
[RIM + 18, RIM + 9], // crate floor, in front of the discs
|
||||||
|
[WORLD_DEFS.disco.portalStand[0] + 8, RIM + 8], // crate floor, gap past the last discs
|
||||||
|
[(M.minX + M.maxX) / 2 + 6, (M.minZ + M.maxZ) / 2 + 8],// mixer PCB room
|
||||||
|
[LAYOUT.deckA.spindleX, LAYOUT.deckA.spindleZ], // deck A PCB room
|
||||||
|
];
|
||||||
|
|
||||||
|
const BUNNY_COUNT = 4;
|
||||||
|
const LEASH = 14; // max wander radius from home (voxels)
|
||||||
|
const SPOOK_DIST2 = 5 * 5; // player within 5 → flee
|
||||||
|
const FLEE_DIST = 8; // voxels of flight
|
||||||
|
const EMIT_COOLDOWN = 1.0; // machine:interact {bunny_flee} at most 1/s (global)
|
||||||
|
|
||||||
|
const enum Mode { Wander, Flee, Settle }
|
||||||
|
|
||||||
|
interface Bunny {
|
||||||
|
mesh: THREE.Mesh;
|
||||||
|
x: number; y: number; z: number;
|
||||||
|
ground: number; // y of the standing surface (top face) under the bunny
|
||||||
|
heading: number; // radians, horizontal
|
||||||
|
speed: number;
|
||||||
|
mode: Mode;
|
||||||
|
timer: number; // mode-specific countdown (wander re-aim / settle)
|
||||||
|
fleeLeft: number; // voxels of flight remaining
|
||||||
|
phase: number; // bob/breathe animation phase
|
||||||
|
spin: number; // idle tumble rate (rad/s)
|
||||||
|
turnSign: number; // alternate deflection direction on blocked moves
|
||||||
|
homeX: number; homeZ: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One fluff-ball: 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
160
src/fx/portalIris.ts
Normal file
160
src/fx/portalIris.ts
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
// TURNCRAFT — Lane S (SIDE B). The vinyl-warp portal iris: a full-screen 2D
|
||||||
|
// canvas overlay of concentric groove rings collapsing to black on
|
||||||
|
// `world:enter` (released on arrival) and the reverse drift on `world:exit`,
|
||||||
|
// accent-tinted per world (WORLD_DEFS accent).
|
||||||
|
//
|
||||||
|
// SCREEN OVERLAY ONLY — it never touches the camera, so it coexists with the
|
||||||
|
// first-visit flythrough's TURNCRAFT_CINE override and any future cine work.
|
||||||
|
// The canvas sits at z-index 9, UNDER the HUD (z 10), so the start/pause
|
||||||
|
// overlays and subtitles are never covered or blocked (pointer-events: none).
|
||||||
|
//
|
||||||
|
// Timeline runs on performance.now(), advanced from FxSystem.update() — no
|
||||||
|
// requestAnimationFrame loop of its own (CONTRACTS §5). While idle the canvas
|
||||||
|
// is display:none and update() is a single boolean check: zero steady-state
|
||||||
|
// work or allocation.
|
||||||
|
|
||||||
|
const CLOSE_S = 0.4; // brief: 400 ms collapse to black
|
||||||
|
const HOLD_S = 0.09; // beat of full black — the warp moment
|
||||||
|
const OPEN_S = 0.4; // release on arrival
|
||||||
|
const RINGS = 15;
|
||||||
|
|
||||||
|
export type IrisDir = 'enter' | 'exit';
|
||||||
|
|
||||||
|
export class PortalIris {
|
||||||
|
private canvas: HTMLCanvasElement;
|
||||||
|
private ctx: CanvasRenderingContext2D;
|
||||||
|
private active = false;
|
||||||
|
private t0 = 0; // performance.now() at trigger
|
||||||
|
private accent: [number, number, number] = [255, 214, 120];
|
||||||
|
private drift = 1; // grooves flow inward (enter) / outward (exit)
|
||||||
|
private lastDraw = 0;
|
||||||
|
private onResize = () => this.resize();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.canvas = document.createElement('canvas');
|
||||||
|
this.canvas.style.cssText =
|
||||||
|
'position:fixed;inset:0;width:100%;height:100%;pointer-events:none;' +
|
||||||
|
'z-index:9;display:none';
|
||||||
|
document.body.appendChild(this.canvas);
|
||||||
|
this.ctx = this.canvas.getContext('2d')!;
|
||||||
|
this.resize();
|
||||||
|
window.addEventListener('resize', this.onResize);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resize(): void {
|
||||||
|
const dpr = Math.min(window.devicePixelRatio || 1, 1.25); // fill-rate cap
|
||||||
|
// viewport size with fallbacks: some embedded/headless panes report 0 for
|
||||||
|
// innerWidth — fall back to the game canvas, then a sane default
|
||||||
|
const game = document.querySelector('#app canvas') as HTMLCanvasElement | null;
|
||||||
|
const vw = window.innerWidth || document.documentElement.clientWidth ||
|
||||||
|
game?.clientWidth || 800;
|
||||||
|
const vh = window.innerHeight || document.documentElement.clientHeight ||
|
||||||
|
game?.clientHeight || 450;
|
||||||
|
const w = Math.max(2, Math.round(vw * dpr));
|
||||||
|
const h = Math.max(2, Math.round(vh * dpr));
|
||||||
|
// setting .width/.height clears the canvas — only touch on real change
|
||||||
|
if (this.canvas.width !== w) this.canvas.width = w;
|
||||||
|
if (this.canvas.height !== h) this.canvas.height = h;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start (or restart) the iris. `accent` is the world's 0-255 RGB. */
|
||||||
|
trigger(accent: [number, number, number], dir: IrisDir): void {
|
||||||
|
this.resize(); // re-sync (a page can boot with a 0-sized window)
|
||||||
|
this.accent = accent;
|
||||||
|
this.drift = dir === 'enter' ? 1 : -1;
|
||||||
|
const now = performance.now();
|
||||||
|
if (this.active) {
|
||||||
|
// retrigger mid-animation: restart the close phase from the current
|
||||||
|
// coverage so the black level never pops
|
||||||
|
const c = this.coverAt(now);
|
||||||
|
this.t0 = now - Math.cbrt(c) * CLOSE_S * 1000; // inverse of easeInCubic
|
||||||
|
} else {
|
||||||
|
this.t0 = now;
|
||||||
|
}
|
||||||
|
this.active = true;
|
||||||
|
this.canvas.style.display = 'block';
|
||||||
|
this.lastDraw = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 0 = clear screen, 1 = full black, over close → hold → open. */
|
||||||
|
private coverAt(now: number): number {
|
||||||
|
const t = (now - this.t0) / 1000;
|
||||||
|
if (t < CLOSE_S) { const x = t / CLOSE_S; return x * x * x; } // ease-in
|
||||||
|
if (t < CLOSE_S + HOLD_S) return 1;
|
||||||
|
const x = (t - CLOSE_S - HOLD_S) / OPEN_S;
|
||||||
|
if (x >= 1) return 0;
|
||||||
|
const inv = 1 - x;
|
||||||
|
return inv * inv * inv; // ease-out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Advance + draw if active. Call every fixed tick; throttled internally so
|
||||||
|
* substep catch-up doesn't repaint the canvas more than once a frame. */
|
||||||
|
update(): void {
|
||||||
|
if (!this.active) return;
|
||||||
|
const now = performance.now();
|
||||||
|
if (now - this.lastDraw < 12) return;
|
||||||
|
this.lastDraw = now;
|
||||||
|
const t = (now - this.t0) / 1000;
|
||||||
|
if (t >= CLOSE_S + HOLD_S + OPEN_S) {
|
||||||
|
this.active = false;
|
||||||
|
this.canvas.style.display = 'none';
|
||||||
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.draw(this.coverAt(now), now);
|
||||||
|
}
|
||||||
|
|
||||||
|
private draw(cover: number, now: number): void {
|
||||||
|
const g = this.ctx;
|
||||||
|
const w = this.canvas.width, h = this.canvas.height;
|
||||||
|
const cx = w / 2, cy = h / 2;
|
||||||
|
const rMax = Math.hypot(cx, cy); // reaches the corners
|
||||||
|
const aperture = rMax * (1 - cover); // visible hole radius
|
||||||
|
const [ar, ag, ab] = this.accent;
|
||||||
|
|
||||||
|
g.clearRect(0, 0, w, h);
|
||||||
|
if (cover <= 0) return;
|
||||||
|
|
||||||
|
// vinyl-black slab with the aperture punched out (even-odd fill)
|
||||||
|
g.beginPath();
|
||||||
|
g.rect(0, 0, w, h);
|
||||||
|
g.arc(cx, cy, Math.max(aperture, 0), 0, Math.PI * 2);
|
||||||
|
g.fillStyle = 'rgb(5,5,8)';
|
||||||
|
g.fill('evenodd');
|
||||||
|
|
||||||
|
// concentric groove rings riding the slab, drifting toward (enter) or away
|
||||||
|
// from (exit) the aperture — the record being wound in / spat back out
|
||||||
|
const gap = Math.max(10, rMax * 0.045);
|
||||||
|
const slide = ((now * 0.00035 * this.drift) % 1 + 1) % 1; // 0..1 groove flow
|
||||||
|
g.lineWidth = Math.max(1, rMax * 0.0035);
|
||||||
|
for (let i = 0; i < RINGS; i++) {
|
||||||
|
const r = aperture + (i + slide) * gap;
|
||||||
|
if (r > rMax + gap) break;
|
||||||
|
const fade = 1 - i / RINGS;
|
||||||
|
const glow = i === 0 ? 0.85 : 0.32 * fade * fade;
|
||||||
|
g.strokeStyle = `rgba(${ar},${ag},${ab},${(glow * cover).toFixed(3)})`;
|
||||||
|
g.beginPath();
|
||||||
|
g.arc(cx, cy, r, 0, Math.PI * 2);
|
||||||
|
g.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// bright accent lip right on the aperture edge (the stylus-light)
|
||||||
|
if (aperture > 1) {
|
||||||
|
g.lineWidth = Math.max(2, rMax * 0.006);
|
||||||
|
g.strokeStyle = `rgba(${ar},${ag},${ab},${(0.9 * cover).toFixed(3)})`;
|
||||||
|
g.beginPath();
|
||||||
|
g.arc(cx, cy, aperture, 0, Math.PI * 2);
|
||||||
|
g.stroke();
|
||||||
|
g.lineWidth = Math.max(4, rMax * 0.014);
|
||||||
|
g.strokeStyle = `rgba(${ar},${ag},${ab},${(0.22 * cover).toFixed(3)})`;
|
||||||
|
g.beginPath();
|
||||||
|
g.arc(cx, cy, aperture + rMax * 0.008, 0, Math.PI * 2);
|
||||||
|
g.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
window.removeEventListener('resize', this.onResize);
|
||||||
|
this.canvas.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -11,10 +11,12 @@ import { PLAYER } from '../core/constants';
|
|||||||
import { bus } from '../core/events';
|
import { bus } from '../core/events';
|
||||||
import type { IMachine, IPlayerView, IVoxelWorld, KinematicCollider, RayHit } from '../core/types';
|
import type { IMachine, IPlayerView, IVoxelWorld, KinematicCollider, RayHit } from '../core/types';
|
||||||
import { Fader, QUEST_ITEMS, type MachineSet, type Workshop } from '../machines';
|
import { Fader, QUEST_ITEMS, type MachineSet, type Workshop } from '../machines';
|
||||||
|
import type { IdInteractions } from '../machines/machine';
|
||||||
import type { Hotbar } from './hotbar';
|
import type { Hotbar } from './hotbar';
|
||||||
import { raycastColliders, raycastVoxel, type VoxelHit } from './raycast';
|
import { raycastColliders, raycastVoxel, type VoxelHit } from './raycast';
|
||||||
|
|
||||||
type WsTag = ReturnType<Workshop['tagFor']>;
|
type WsTag = ReturnType<Workshop['tagFor']>;
|
||||||
|
type IdMachine = IMachine & IdInteractions;
|
||||||
|
|
||||||
// Seconds to mine a block, by material feel: soft stuff (dust, felt) digs
|
// Seconds to mine a block, by material feel: soft stuff (dust, felt) digs
|
||||||
// fast — the crossfader jam is ~240 dust blocks, and 0.4 s each made that
|
// fast — the crossfader jam is ~240 dust blocks, and 0.4 s each made that
|
||||||
@ -64,6 +66,9 @@ export class Interaction {
|
|||||||
private eDown = false;
|
private eDown = false;
|
||||||
shiftDown = false;
|
shiftDown = false;
|
||||||
private hold: { tag: WsTag; colliderId: string } | null = null;
|
private hold: { tag: WsTag; colliderId: string } | null = null;
|
||||||
|
// SIDE B world machines: same press/hold idea, routed by collider id via the
|
||||||
|
// structural IdInteractions surface (machines/machine.ts).
|
||||||
|
private idHold: { m: IdMachine; colliderId: string } | null = null;
|
||||||
|
|
||||||
private readonly selection: THREE.LineSegments;
|
private readonly selection: THREE.LineSegments;
|
||||||
|
|
||||||
@ -144,6 +149,18 @@ export class Interaction {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// SIDE B world machines: collider-id-aware press/hold (portals, acid
|
||||||
|
// pedestals, dub charge/mouth). Hold wins if the machine starts one.
|
||||||
|
const idm = m as IdMachine;
|
||||||
|
if (this.lastColliderId && (idm.pressId || idm.holdStartId)) {
|
||||||
|
const cid = this.lastColliderId;
|
||||||
|
if (idm.holdStartId && idm.holdTickId && idm.holdStartId(cid, shift)) {
|
||||||
|
this.idHold = { m: idm, colliderId: cid };
|
||||||
|
} else {
|
||||||
|
idm.pressId?.(cid);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
m.onInteract?.();
|
m.onInteract?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -163,10 +180,11 @@ export class Interaction {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** E released — end any workshop hold. */
|
/** E released — end any workshop / world-machine hold. */
|
||||||
useUp(): void {
|
useUp(): void {
|
||||||
this.eDown = false;
|
this.eDown = false;
|
||||||
if (this.hold) { this.machines.workshop.holdEnd(); this.hold = null; }
|
if (this.hold) { this.machines.workshop.holdEnd(); this.hold = null; }
|
||||||
|
if (this.idHold) { this.idHold.m.holdEndId?.(); this.idHold = null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Q — drop a carried wire back to the loom. */
|
/** Q — drop a carried wire back to the loom. */
|
||||||
@ -176,6 +194,7 @@ export class Interaction {
|
|||||||
const hit = this.raycast();
|
const hit = this.raycast();
|
||||||
this.lastHit = hit;
|
this.lastHit = hit;
|
||||||
this.advanceWorkshopHold(dt, hit);
|
this.advanceWorkshopHold(dt, hit);
|
||||||
|
this.advanceIdHold(dt, hit);
|
||||||
this.advanceMining(dt, hit);
|
this.advanceMining(dt, hit);
|
||||||
this.updateSelection(hit);
|
this.updateSelection(hit);
|
||||||
}
|
}
|
||||||
@ -190,6 +209,17 @@ export class Interaction {
|
|||||||
if (r !== 'run') this.hold = null;
|
if (r !== 'run') this.hold = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Advance a held world-machine interaction (mirrors the workshop hold:
|
||||||
|
* E stays down and aim stays on the same collider, else it cancels). */
|
||||||
|
private advanceIdHold(dt: number, hit: RayHit): void {
|
||||||
|
if (!this.idHold) return;
|
||||||
|
const g = this.idHold;
|
||||||
|
const onTarget = this.eDown && hit && hit.kind === 'machine'
|
||||||
|
&& (hit.machine as IdMachine) === g.m && this.lastColliderId === g.colliderId;
|
||||||
|
if (!onTarget) { g.m.holdEndId?.(); this.idHold = null; return; }
|
||||||
|
if (g.m.holdTickId!(g.colliderId, dt, this.shiftDown) !== 'run') this.idHold = null;
|
||||||
|
}
|
||||||
|
|
||||||
currentHit(): RayHit { return this.lastHit; }
|
currentHit(): RayHit { return this.lastHit; }
|
||||||
|
|
||||||
// ---- internals ----
|
// ---- internals ----
|
||||||
|
|||||||
@ -18,6 +18,11 @@ import { Button } from './buttons';
|
|||||||
import { RcaPlug } from './rca';
|
import { RcaPlug } from './rca';
|
||||||
import { Quest, type QuestPositions } from './quest';
|
import { Quest, type QuestPositions } from './quest';
|
||||||
import { Workshop } from './workshop/Workshop';
|
import { Workshop } from './workshop/Workshop';
|
||||||
|
import { Stamps } from './worlds/stamps';
|
||||||
|
import { Portals } from './worlds/portal';
|
||||||
|
import { AcidQuest } from './worlds/acidQuest';
|
||||||
|
import { DubQuest } from './worlds/dubQuest';
|
||||||
|
import { DiscoQuest } from './worlds/discoQuest';
|
||||||
|
|
||||||
export { Quest, QUEST_ITEMS, type QuestPositions } from './quest';
|
export { Quest, QUEST_ITEMS, type QuestPositions } from './quest';
|
||||||
export { Platter } from './platter';
|
export { Platter } from './platter';
|
||||||
@ -26,6 +31,11 @@ export { Fader } from './faders';
|
|||||||
export { Button } from './buttons';
|
export { Button } from './buttons';
|
||||||
export { Workshop } from './workshop/Workshop';
|
export { Workshop } from './workshop/Workshop';
|
||||||
export type { WorkshopState } from './workshop/types';
|
export type { WorkshopState } from './workshop/types';
|
||||||
|
export { Stamps } from './worlds/stamps';
|
||||||
|
export { Portals } from './worlds/portal';
|
||||||
|
export { AcidQuest } from './worlds/acidQuest';
|
||||||
|
export { DubQuest } from './worlds/dubQuest';
|
||||||
|
export { DiscoQuest } from './worlds/discoQuest';
|
||||||
|
|
||||||
export interface MachineSet {
|
export interface MachineSet {
|
||||||
/** Every machine (for iteration / collider lookup). */
|
/** Every machine (for iteration / collider lookup). */
|
||||||
@ -37,6 +47,9 @@ export interface MachineSet {
|
|||||||
/** The Headshell Workshop (deck A) — Interaction routes assembly here,
|
/** The Headshell Workshop (deck A) — Interaction routes assembly here,
|
||||||
* NetClient syncs its state. */
|
* NetClient syncs its state. */
|
||||||
workshop: Workshop;
|
workshop: Workshop;
|
||||||
|
/** SIDE B crate-world stamps (the only world state that syncs) —
|
||||||
|
* NetClient mirrors it over the relay. */
|
||||||
|
stamps: Stamps;
|
||||||
/** Flattened kinematic colliders (stable array; shapes mutate in place). */
|
/** Flattened kinematic colliders (stable array; shapes mutate in place). */
|
||||||
getColliders(): KinematicCollider[];
|
getColliders(): KinematicCollider[];
|
||||||
/** Advance all machines one fixed tick. */
|
/** Advance all machines one fixed tick. */
|
||||||
@ -198,6 +211,14 @@ export function createMachines(world: IVoxelWorld, questPos?: Partial<QuestPosit
|
|||||||
// ---- RCA plug ----
|
// ---- RCA plug ----
|
||||||
machines.push(new RcaPlug(quest, pos.rcaPlug, pos.rcaSocket));
|
machines.push(new RcaPlug(quest, pos.rcaPlug, pos.rcaSocket));
|
||||||
|
|
||||||
|
// ---- SIDE B: crate-world portals, mini-quests, stamps (core/worlds.ts) ----
|
||||||
|
// Positions are contract constants — no LAYOUT dependency, no main.ts wiring.
|
||||||
|
const stamps = new Stamps();
|
||||||
|
machines.push(new Portals(), new AcidQuest(stamps), new DubQuest(stamps), new DiscoQuest(stamps));
|
||||||
|
// All three stamps → deck A's golden slipmat (fires for a local third stamp,
|
||||||
|
// a remote one, or the join snapshot replay — Stamps emits in every case).
|
||||||
|
bus.on('stamp:all', () => a.platter.setGoldenSlipmat(true));
|
||||||
|
|
||||||
// ---- MachineSet facade ----
|
// ---- MachineSet facade ----
|
||||||
const colliders: KinematicCollider[] = [];
|
const colliders: KinematicCollider[] = [];
|
||||||
for (const m of machines) for (const c of m.getColliders()) colliders.push(c);
|
for (const m of machines) for (const c of m.getColliders()) colliders.push(c);
|
||||||
@ -207,6 +228,7 @@ export function createMachines(world: IVoxelWorld, questPos?: Partial<QuestPosit
|
|||||||
quest,
|
quest,
|
||||||
tonearmA: a.tonearm,
|
tonearmA: a.tonearm,
|
||||||
workshop,
|
workshop,
|
||||||
|
stamps,
|
||||||
getColliders: () => colliders,
|
getColliders: () => colliders,
|
||||||
update: (dt: number) => { for (const m of machines) m.update(dt); },
|
update: (dt: number) => { for (const m of machines) m.update(dt); },
|
||||||
getObject3Ds: () => machines.map((m) => m.getObject3D()),
|
getObject3Ds: () => machines.map((m) => m.getObject3D()),
|
||||||
|
|||||||
@ -35,6 +35,26 @@ export abstract class MachineBase implements IMachine {
|
|||||||
onInteract?(): void;
|
onInteract?(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional per-collider interaction surface (SIDE B world machines). The
|
||||||
|
* interaction layer detects these structurally: a machine implementing them
|
||||||
|
* gets E routed WITH the hit collider id, and may run hold-E interactions like
|
||||||
|
* the workshop's crimp/torque (the workshop keeps its own bespoke routing).
|
||||||
|
* All methods are optional; a machine with none behaves exactly as before
|
||||||
|
* (plain `onInteract`).
|
||||||
|
*/
|
||||||
|
export interface IdInteractions {
|
||||||
|
/** One-shot E press on a specific collider. */
|
||||||
|
pressId?(colliderId: string): void;
|
||||||
|
/** Begin a hold-E on a collider; return true if a hold actually started
|
||||||
|
* (false falls through to pressId/onInteract). */
|
||||||
|
holdStartId?(colliderId: string, shift: boolean): boolean;
|
||||||
|
/** Advance a running hold; 'run' keeps it, anything else ends it. */
|
||||||
|
holdTickId?(colliderId: string, dt: number, shift: boolean): 'run' | 'done' | 'cancel';
|
||||||
|
/** Hold released or aim left the target. */
|
||||||
|
holdEndId?(): void;
|
||||||
|
}
|
||||||
|
|
||||||
/** Player AABB (world-space) from an IPlayerView. Reused scratch, no alloc. */
|
/** Player AABB (world-space) from an IPlayerView. Reused scratch, no alloc. */
|
||||||
export interface AABB {
|
export interface AABB {
|
||||||
minX: number; minY: number; minZ: number;
|
minX: number; minY: number; minZ: number;
|
||||||
|
|||||||
@ -58,6 +58,13 @@ export class Platter extends MachineBase {
|
|||||||
private readonly spin = new THREE.Group(); // everything that rotates about the spindle
|
private readonly spin = new THREE.Group(); // everything that rotates about the spindle
|
||||||
private readonly _vel: Vec3 = [0, 0, 0]; // velocityAt scratch (reused, no alloc)
|
private readonly _vel: Vec3 = [0, 0, 0]; // velocityAt scratch (reused, no alloc)
|
||||||
|
|
||||||
|
// SIDE B M3: the slipmat swaps to a golden variant when all three crate-world
|
||||||
|
// stamps land (stamp:all). Materials made once; created in buildVisuals.
|
||||||
|
private slipmatMesh!: THREE.Mesh;
|
||||||
|
private slipmatFelt!: THREE.Material;
|
||||||
|
private slipmatGold: THREE.Material | null = null;
|
||||||
|
private golden = false;
|
||||||
|
|
||||||
constructor(deck: 'A' | 'B', spindleX: number, spindleZ: number) {
|
constructor(deck: 'A' | 'B', spindleX: number, spindleZ: number) {
|
||||||
super(`platter${deck}`);
|
super(`platter${deck}`);
|
||||||
this.deck = deck;
|
this.deck = deck;
|
||||||
@ -125,8 +132,9 @@ export class Platter extends MachineBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Slipmat.
|
// Slipmat.
|
||||||
const slipMat = blockMaterial(7); // slipmat_felt
|
this.slipmatFelt = blockMaterial(7); // slipmat_felt
|
||||||
this.spin.add(cylinderY(rr + 1.5, 0.5, slipMat, 0, Y_RECORD_TOP - 0.75, 0));
|
this.slipmatMesh = cylinderY(rr + 1.5, 0.5, this.slipmatFelt, 0, Y_RECORD_TOP - 0.75, 0);
|
||||||
|
this.spin.add(this.slipmatMesh);
|
||||||
|
|
||||||
// Record body: terraced groove tiers (blue translucent on A / black on B),
|
// Record body: terraced groove tiers (blue translucent on A / black on B),
|
||||||
// matching the TIERS colliders — a stepped vinyl amphitheater.
|
// matching the TIERS colliders — a stepped vinyl amphitheater.
|
||||||
@ -225,6 +233,23 @@ export class Platter extends MachineBase {
|
|||||||
isPlaying(): boolean { return this.playing; }
|
isPlaying(): boolean { return this.playing; }
|
||||||
currentRpm(): number { return this.playing ? this.baseRpm() * this.pitch : 0; }
|
currentRpm(): number { return this.playing ? this.baseRpm() * this.pitch : 0; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SIDE B golden finish (stamp:all): swap the slipmat to a glowing gold
|
||||||
|
* variant. Idempotent; survives the reset ritual (stamps are pressed into
|
||||||
|
* the records, not the booth — nothing ever calls this with `false` in
|
||||||
|
* game, but the toggle exists for demos).
|
||||||
|
*/
|
||||||
|
setGoldenSlipmat(on: boolean): void {
|
||||||
|
if (on === this.golden) return;
|
||||||
|
this.golden = on;
|
||||||
|
if (on && !this.slipmatGold) {
|
||||||
|
this.slipmatGold = blockMaterial(29, { emissive: 0.55, metalness: 0.85, roughness: 0.3 }); // rca_gold
|
||||||
|
}
|
||||||
|
this.slipmatMesh.material = on ? this.slipmatGold! : this.slipmatFelt;
|
||||||
|
}
|
||||||
|
|
||||||
|
isGoldenSlipmat(): boolean { return this.golden; }
|
||||||
|
|
||||||
private baseRpm(): number { return this.speed === 33 ? PLATTER.rpm33 : PLATTER.rpm45; }
|
private baseRpm(): number { return this.speed === 33 ? PLATTER.rpm33 : PLATTER.rpm45; }
|
||||||
|
|
||||||
private recomputeTarget(): void {
|
private recomputeTarget(): void {
|
||||||
|
|||||||
221
src/machines/worlds/acidQuest.ts
Normal file
221
src/machines/worlds/acidQuest.ts
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
// LANE M — ACID WAREHOUSE: Tune the 303 (SIDEB_MACHINES M2).
|
||||||
|
// Three knob pedestals (Lane W builds the 3×3×3 plinths at x=52, z=44/52/60;
|
||||||
|
// the colliders here are ours and coincide). Each has a hidden target 0..1,
|
||||||
|
// reseeded per entry. Hold-E sweeps a pedestal's band (ping-pong 0↔1, the
|
||||||
|
// workshop hold/torque pattern; `workshop:torque` drives the HUD gauge) and
|
||||||
|
// every meaningful change goes out as `fader:move {faderId:'acid_<i>'}` so
|
||||||
|
// Lane E2 maps value→cutoff — you TUNE BY EAR. Release inside ±0.07 of the
|
||||||
|
// target = the band locks (value snaps to target, pip glows, `acid_lock`).
|
||||||
|
// All three locked → the line snaps into tune → stamp. Already-stamped: the
|
||||||
|
// quest replays as a toy, no double-stamp.
|
||||||
|
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { bus } from '../../core/events';
|
||||||
|
import type { Vec3 } from '../../core/types';
|
||||||
|
import { MachineBase, type IdInteractions } from '../machine';
|
||||||
|
import { blockMaterial } from '../util';
|
||||||
|
import { POCKET_WALK_Y, entrySeed, interactAt, mulberry32 } from './common';
|
||||||
|
import type { Stamps } from './stamps';
|
||||||
|
|
||||||
|
// Aligned to Lane W's actual plinths (src/worldgen/worlds/acid.ts): 3×3×3
|
||||||
|
// matte_black at voxels x 51..53, z (pz-1)..(pz+1), y 144..146 — world centre
|
||||||
|
// [52.5, ·, pz+0.5], top face 147 — with their led_green pip VOXEL sitting at
|
||||||
|
// [52,147,pz] (occupying 147..148). Our knob rides above that pip; the lock
|
||||||
|
// lamp above the knob.
|
||||||
|
const PED_X = 52.5;
|
||||||
|
const PED_Z: readonly number[] = [44.5, 52.5, 60.5];
|
||||||
|
const PED_CY = POCKET_WALK_Y + 2; // collider centre — encloses plinth + pip
|
||||||
|
const PED_HALF = 2.1;
|
||||||
|
const KNOB_Y = POCKET_WALK_Y + 4.45; // 148.45 — on top of the pip voxel
|
||||||
|
const PIP_Y = POCKET_WALK_Y + 5.55; // lock lamp above the knob
|
||||||
|
|
||||||
|
const LOCK_WINDOW = 0.07;
|
||||||
|
/** A locked band re-opens only after this much deliberate hold (anti tap-spam). */
|
||||||
|
const UNLOCK_HOLD_S = 0.25;
|
||||||
|
const SWEEP_RATE = 0.32; // full scale per second (≈3.1 s per traverse)
|
||||||
|
const FADER_EPS = 0.01; // min value delta before a fader:move goes out
|
||||||
|
const KNOB_TICK_S = 0.16; // acid_knob machine:interact cadence while sweeping
|
||||||
|
|
||||||
|
const PIP_ON = new THREE.MeshStandardMaterial({ color: 0x2bff5c, emissive: 0x36ff62, emissiveIntensity: 1.6 });
|
||||||
|
const PIP_OFF = new THREE.MeshStandardMaterial({ color: 0x123315, emissive: 0x0c2a10, emissiveIntensity: 0.25 });
|
||||||
|
|
||||||
|
interface Pedestal {
|
||||||
|
value: number;
|
||||||
|
target: number;
|
||||||
|
locked: boolean;
|
||||||
|
dir: 1 | -1;
|
||||||
|
lastSent: number; // last fader:move value that went out
|
||||||
|
knob: THREE.Mesh;
|
||||||
|
pip: THREE.Mesh;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AcidQuest extends MachineBase implements IdInteractions {
|
||||||
|
private readonly stamps: Stamps;
|
||||||
|
private readonly peds: Pedestal[] = [];
|
||||||
|
private holding = -1; // pedestal index being swept, -1 = none
|
||||||
|
private heldFor = 0; // seconds the current hold has run (unlock intent)
|
||||||
|
private knobTick = 0;
|
||||||
|
private reemitIn = 0; // one delayed re-emit of all values post-enter
|
||||||
|
|
||||||
|
constructor(stamps: Stamps) {
|
||||||
|
super('acid');
|
||||||
|
this.stamps = stamps;
|
||||||
|
|
||||||
|
const chrome = blockMaterial(11);
|
||||||
|
for (let i = 0; i < PED_Z.length; i++) {
|
||||||
|
const z = PED_Z[i];
|
||||||
|
const shape = {
|
||||||
|
kind: 'aabb' as const,
|
||||||
|
min: [PED_X - PED_HALF, PED_CY - PED_HALF, z - PED_HALF] as Vec3,
|
||||||
|
max: [PED_X + PED_HALF, PED_CY + PED_HALF, z + PED_HALF] as Vec3,
|
||||||
|
};
|
||||||
|
this.colliders.push({ id: `acid_ped${i}`, shape, velocityAt: () => [0, 0, 0] });
|
||||||
|
|
||||||
|
// Our dressing on Lane W's plinth: a chrome knob that turns with the
|
||||||
|
// value (riding on their glowing pip voxel), and the lock lamp above it.
|
||||||
|
const knob = new THREE.Mesh(new THREE.CylinderGeometry(0.9, 1.1, 0.9, 12), chrome);
|
||||||
|
knob.position.set(PED_X, KNOB_Y, z);
|
||||||
|
const marker = new THREE.Mesh(new THREE.BoxGeometry(0.22, 0.25, 1.0), blockMaterial(20, { emissive: 0.8 }));
|
||||||
|
marker.position.set(0, 0.35, 0.55);
|
||||||
|
knob.add(marker);
|
||||||
|
const pip = new THREE.Mesh(new THREE.SphereGeometry(0.4, 10, 10), PIP_OFF);
|
||||||
|
pip.position.set(PED_X, PIP_Y, z);
|
||||||
|
this.group.add(knob, pip);
|
||||||
|
|
||||||
|
this.peds.push({ value: 0.5, target: 0.5, locked: false, dir: 1, lastSent: -1, knob, pip });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.reseed();
|
||||||
|
bus.on('world:enter', ({ world }) => { if (world === 'acid') this.onEnter(); });
|
||||||
|
bus.on('world:exit', ({ world }) => { if (world === 'acid') this.endHold(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fresh targets + audibly-wrong starting values (≥0.25 off target). */
|
||||||
|
private reseed(): void {
|
||||||
|
const rng = mulberry32(entrySeed());
|
||||||
|
for (const p of this.peds) {
|
||||||
|
p.target = 0.15 + rng() * 0.7;
|
||||||
|
let v = rng();
|
||||||
|
for (let tries = 0; tries < 16 && Math.abs(v - p.target) < 0.25; tries++) v = rng();
|
||||||
|
p.value = Math.round(v * 100) / 100;
|
||||||
|
p.locked = false;
|
||||||
|
p.dir = 1;
|
||||||
|
p.lastSent = -1;
|
||||||
|
p.pip.material = PIP_OFF;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onEnter(): void {
|
||||||
|
this.reseed();
|
||||||
|
this.emitAll();
|
||||||
|
// Bus handler registration order between lanes isn't guaranteed — one
|
||||||
|
// delayed re-emit makes sure Lane E2's freshly-started loop has the values.
|
||||||
|
this.reemitIn = 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitAll(): void {
|
||||||
|
for (let i = 0; i < this.peds.length; i++) this.emitValue(i, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitValue(i: number, force = false): void {
|
||||||
|
const p = this.peds[i];
|
||||||
|
const v = Math.round(p.value * 1000) / 1000;
|
||||||
|
if (!force && Math.abs(v - p.lastSent) < FADER_EPS) return;
|
||||||
|
p.lastSent = v;
|
||||||
|
bus.emit('fader:move', { faderId: `acid_${i}`, value: v });
|
||||||
|
}
|
||||||
|
|
||||||
|
private lockedCount(): number { return this.peds.reduce((n, p) => n + (p.locked ? 1 : 0), 0); }
|
||||||
|
|
||||||
|
// ── hold-E surface (routed by Interaction via IdInteractions) ──
|
||||||
|
holdStartId(colliderId: string): boolean {
|
||||||
|
const i = this.pedIndex(colliderId);
|
||||||
|
if (i < 0) return false;
|
||||||
|
const p = this.peds[i];
|
||||||
|
// A locked band does NOT unlock on the press — only a deliberate hold
|
||||||
|
// (UNLOCK_HOLD_S) re-opens it. A bare tap would otherwise unlock and
|
||||||
|
// instantly re-lock inside the window, spamming acid_lock (and, at 3/3,
|
||||||
|
// acid_tuned + toasts) at keyboard rate.
|
||||||
|
this.holding = i;
|
||||||
|
this.heldFor = 0;
|
||||||
|
this.knobTick = 0;
|
||||||
|
bus.emit('workshop:torque', { value: p.value });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
holdTickId(colliderId: string, dt: number, shift: boolean): 'run' | 'done' | 'cancel' {
|
||||||
|
const i = this.pedIndex(colliderId);
|
||||||
|
if (i < 0 || i !== this.holding) return 'cancel';
|
||||||
|
const p = this.peds[i];
|
||||||
|
this.heldFor += dt;
|
||||||
|
if (p.locked) {
|
||||||
|
// pinned at the sweet spot until the hold shows intent, then re-open
|
||||||
|
if (this.heldFor < UNLOCK_HOLD_S) return 'run';
|
||||||
|
p.locked = false;
|
||||||
|
p.pip.material = PIP_OFF;
|
||||||
|
}
|
||||||
|
// Ping-pong sweep; shift reverses, so fine backtracking is possible.
|
||||||
|
const rate = SWEEP_RATE * (shift ? -1 : 1) * p.dir;
|
||||||
|
p.value += rate * dt;
|
||||||
|
if (p.value >= 1) { p.value = 1; p.dir = -p.dir as 1 | -1; }
|
||||||
|
else if (p.value <= 0) { p.value = 0; p.dir = -p.dir as 1 | -1; }
|
||||||
|
this.emitValue(i);
|
||||||
|
bus.emit('workshop:torque', { value: p.value });
|
||||||
|
this.knobTick -= dt;
|
||||||
|
if (this.knobTick <= 0) {
|
||||||
|
this.knobTick = KNOB_TICK_S;
|
||||||
|
interactAt(`acid_${i}`, 'acid_knob', i); // machineId scheme per E2: acid_<band>
|
||||||
|
}
|
||||||
|
return 'run'; // sweeps until release; release evaluates the lock
|
||||||
|
}
|
||||||
|
|
||||||
|
holdEndId(): void {
|
||||||
|
const i = this.holding;
|
||||||
|
this.holding = -1;
|
||||||
|
bus.emit('workshop:torque', { value: null });
|
||||||
|
if (i < 0) return;
|
||||||
|
const p = this.peds[i];
|
||||||
|
if (p.locked) return; // tap on a locked band: released before intent — no-op
|
||||||
|
if (Math.abs(p.value - p.target) <= LOCK_WINDOW) {
|
||||||
|
p.locked = true;
|
||||||
|
p.value = p.target; // snap: the band now sounds exactly right
|
||||||
|
p.pip.material = PIP_ON;
|
||||||
|
this.emitValue(i, true);
|
||||||
|
interactAt(`acid_${i}`, 'acid_lock', i); // machineId scheme per E2: acid_<band>
|
||||||
|
const n = this.lockedCount();
|
||||||
|
if (n < this.peds.length) {
|
||||||
|
bus.emit('workshop:msg', { text: `BAND LOCKED (${n}/3)` });
|
||||||
|
} else {
|
||||||
|
this.complete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private endHold(): void {
|
||||||
|
if (this.holding >= 0) this.holdEndId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private complete(): void {
|
||||||
|
interactAt('acid', 'acid_tuned');
|
||||||
|
if (!this.stamps.apply('acid')) {
|
||||||
|
bus.emit('workshop:msg', { text: 'THE 303 SNAPS INTO TUNE — STILL GOT IT' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private pedIndex(colliderId: string): number {
|
||||||
|
if (!colliderId.startsWith('acid_ped')) return -1;
|
||||||
|
const i = +colliderId.slice(8);
|
||||||
|
return i >= 0 && i < this.peds.length ? i : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(dt: number): void {
|
||||||
|
if (this.reemitIn > 0) {
|
||||||
|
this.reemitIn -= dt;
|
||||||
|
if (this.reemitIn <= 0) this.emitAll();
|
||||||
|
}
|
||||||
|
// Knob angle follows the value (single rotation span, reads as a dial).
|
||||||
|
for (const p of this.peds) {
|
||||||
|
p.knob.rotation.y = (p.value - 0.5) * Math.PI * 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
95
src/machines/worlds/common.ts
Normal file
95
src/machines/worlds/common.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
// LANE M — SIDE B shared helpers for the crate-world machines.
|
||||||
|
// Positions come from the contract (src/core/worlds.ts); this file only adds
|
||||||
|
// the small conversions every world machine needs. No game state lives here.
|
||||||
|
|
||||||
|
import { WORLD_DEFS, WORLD_KEYS, type WorldKey } from '../../core/worlds';
|
||||||
|
import { bus, type GameEvents } from '../../core/events';
|
||||||
|
import type { IPlayerView, Vec3 } from '../../core/types';
|
||||||
|
|
||||||
|
/** Walkable floor level inside every pocket: shell 142 + dressing 143 → feet 144. */
|
||||||
|
export const POCKET_WALK_Y = 144;
|
||||||
|
|
||||||
|
/** Out-of-pocket safety altitude (brief M1): above this and outside any pocket
|
||||||
|
* bounds, the player is teleported home. The booth wall top is 140. */
|
||||||
|
export const POCKET_SAFETY_Y = 141;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit `machine:interact`, optionally carrying the SIDEB briefs' `index` field
|
||||||
|
* (disco cues, acid locks). The contract payload is `{machineId, action}`;
|
||||||
|
* `index` rides along as a structural extra so Lanes S/E2 — whose briefs quote
|
||||||
|
* `{action:'disco_cue', index}` — can read it, while the contract file stays
|
||||||
|
* untouched. The tile/pedestal id is ALSO encoded in machineId
|
||||||
|
* (`disco_tile_<i>`, `acid_ped<i>`) so consumers can use either.
|
||||||
|
*/
|
||||||
|
export function interactAt(machineId: string, action: string, index?: number): void {
|
||||||
|
const payload: GameEvents['machine:interact'] & { index?: number } = { machineId, action };
|
||||||
|
if (index !== undefined) payload.index = index;
|
||||||
|
bus.emit('machine:interact', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Is a world-space point inside a pocket's INCLUSIVE voxel bounds (+margin)? */
|
||||||
|
export function insidePocket(k: WorldKey, x: number, y: number, z: number, margin = 0.5): boolean {
|
||||||
|
const d = WORLD_DEFS[k];
|
||||||
|
return (
|
||||||
|
x >= d.pocketMin[0] - margin && x <= d.pocketMax[0] + 1 + margin &&
|
||||||
|
y >= d.pocketMin[1] - margin && y <= d.pocketMax[1] + 1 + margin &&
|
||||||
|
z >= d.pocketMin[2] - margin && z <= d.pocketMax[2] + 1 + margin
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The pocket the point is inside, or null. */
|
||||||
|
export function pocketAt(x: number, y: number, z: number): WorldKey | null {
|
||||||
|
for (const k of WORLD_KEYS) if (insidePocket(k, x, y, z)) return k;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nearest world by xz distance to the pocket centre (safety-net homing). */
|
||||||
|
export function nearestWorld(x: number, z: number): WorldKey {
|
||||||
|
let best: WorldKey = WORLD_KEYS[0];
|
||||||
|
let bestD = Infinity;
|
||||||
|
for (const k of WORLD_KEYS) {
|
||||||
|
const d = WORLD_DEFS[k];
|
||||||
|
const cx = (d.pocketMin[0] + d.pocketMax[0]) / 2;
|
||||||
|
const cz = (d.pocketMin[2] + d.pocketMax[2]) / 2;
|
||||||
|
const dd = (x - cx) * (x - cx) + (z - cz) * (z - cz);
|
||||||
|
if (dd < bestD) { bestD = dd; best = k; }
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lane B's PlayerController exposes `teleport` and `isFlying` beyond the
|
||||||
|
* IPlayerView contract. We reach them structurally (optional), never by
|
||||||
|
* importing Lane B — a mock player without them simply doesn't move.
|
||||||
|
*/
|
||||||
|
export interface PlayerControl {
|
||||||
|
teleport?(v: Vec3): void;
|
||||||
|
isFlying?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function teleportPlayer(p: IPlayerView | null, v: Vec3): void {
|
||||||
|
(p as (IPlayerView & PlayerControl) | null)?.teleport?.(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playerFlying(p: IPlayerView | null): boolean {
|
||||||
|
return (p as (IPlayerView & PlayerControl) | null)?.isFlying === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tiny deterministic PRNG (mulberry32) for per-entry quest seeds. */
|
||||||
|
export function mulberry32(seed: number): () => number {
|
||||||
|
let a = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
a = (a + 0x6d2b79f5) >>> 0;
|
||||||
|
let t = a;
|
||||||
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||||
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fresh per-entry seed (wall clock + a counter so re-entries differ). */
|
||||||
|
let seedCounter = 0;
|
||||||
|
export function entrySeed(): number {
|
||||||
|
seedCounter = (seedCounter + 0x9e3779b9) >>> 0;
|
||||||
|
return (Date.now() ^ seedCounter) >>> 0;
|
||||||
|
}
|
||||||
231
src/machines/worlds/discoQuest.ts
Normal file
231
src/machines/worlds/discoQuest.ts
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
// LANE M — DISCO LOFT: Light the Floor (SIDEB_MACHINES M2).
|
||||||
|
// Beat-Simon on Lane W's 4×4 dance-tile grid (5×5 tiles at PITCH 8 — aligned
|
||||||
|
// to their actual build in src/worldgen/worlds/disco.ts: centres at voxels
|
||||||
|
// x 384/392/400/408, z 52/60/68/76, flush at y 143, walk level 144; world
|
||||||
|
// centres sit at +0.5). On each beat a cue fires —
|
||||||
|
// `machine:interact {machineId:'disco_tile_<i>', action:'disco_cue', index}` —
|
||||||
|
// so Lane S lights the tile and Lane E2 blips it (pentatonic by index). The
|
||||||
|
// player answers by STEPPING the tiles in order (standing-on-tile detection;
|
||||||
|
// any pace, order is what matters). Wrong tile = gentle reset ("THE FLOOR
|
||||||
|
// FORGIVES — WATCH AGAIN", same round replays). Rounds grow 4 → 8; the full
|
||||||
|
// 8 stamps the record. Beats come from `audio:beat`, with a 122 BPM free-run
|
||||||
|
// fallback so a silent booth can't stall the floor.
|
||||||
|
//
|
||||||
|
// Tile LIGHTING is ours too (Lane S is scoped out of quest-specific
|
||||||
|
// rendering): 16 thin glow plates over the tiles, lit from the same cue/step
|
||||||
|
// path that emits the events. Shared materials swapped by reference — the
|
||||||
|
// workshop lamp-LED pattern, zero per-frame allocation.
|
||||||
|
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { bus } from '../../core/events';
|
||||||
|
import { MachineBase } from '../machine';
|
||||||
|
import { entrySeed, interactAt, mulberry32 } from './common';
|
||||||
|
import type { Stamps } from './stamps';
|
||||||
|
|
||||||
|
const GRID_X: readonly number[] = [384.5, 392.5, 400.5, 408.5];
|
||||||
|
const GRID_Z: readonly number[] = [52.5, 60.5, 68.5, 76.5];
|
||||||
|
const TILE_HALF = 2.5; // 5×5 tiles (voxels 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.
|
||||||
|
}
|
||||||
|
}
|
||||||
238
src/machines/worlds/dubQuest.ts
Normal file
238
src/machines/worlds/dubQuest.ts
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
// LANE M — DUB CHAMBER: Feed the Spring (SIDEB_MACHINES M2).
|
||||||
|
// An echo charge (glowing carryable, the wire-carry pattern with isCarrying-
|
||||||
|
// style state) sits at the horn on the far wall. Carry it back across the
|
||||||
|
// spring-tank bridge: an 8-segment kinematic deck overlaid on Lane W's static
|
||||||
|
// chrome deck, riding a gentle travelling sine — mostly a shimmer when empty,
|
||||||
|
// a real wobble + lateral breathe while you carry (the spring FEELS you
|
||||||
|
// coming; ride-snap keeps you glued, rates far inside Lane B's envelope).
|
||||||
|
// Reach the tank mouth — the rca_gold inlay mid-deck — and E to drop →
|
||||||
|
// `dub_feed` → stamp. Fall into the tank pit with the charge and it returns
|
||||||
|
// to the horn: "THE SPRING WANTS IT — TRY AGAIN".
|
||||||
|
//
|
||||||
|
// Geometry aligned to Lane W's ACTUAL build (src/worldgen/worlds/dub.ts,
|
||||||
|
// constants mirrored — cross-lane imports are contract-only): static deck
|
||||||
|
// chrome x 223..225 (voxels) z 40..63, deck top face 147; tank pit x 212..236
|
||||||
|
// z 41..62, pit walk surface 143 (canyon floor around it walks at 144); mouth
|
||||||
|
// inlay voxel [224,146,52]; horn cone at x 232 on the back wall — East of the
|
||||||
|
// exit-pad lane, so the horn walk never crosses the pad.
|
||||||
|
//
|
||||||
|
// Lane E2 vocabulary: machine:interact dub_pickup / dub_drop / dub_feed
|
||||||
|
// (the echo wet literally follows the carry).
|
||||||
|
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { bus } from '../../core/events';
|
||||||
|
import type { Vec3 } from '../../core/types';
|
||||||
|
import { MachineBase, type IdInteractions } from '../machine';
|
||||||
|
import { blockMaterial } from '../util';
|
||||||
|
import { insidePocket, interactAt } from './common';
|
||||||
|
import type { Stamps } from './stamps';
|
||||||
|
|
||||||
|
// Bridge overlay (world-space; Lane W's deck voxels 223..225 span 223..226).
|
||||||
|
const BX = 224.5; // bridge centre line
|
||||||
|
const BZ0 = 40, BZ1 = 64; // deck span (voxels 40..63)
|
||||||
|
const SEGS = 8;
|
||||||
|
const SEG_LEN = (BZ1 - BZ0) / SEGS;
|
||||||
|
/** Static deck top face is 147; ours floats proud and never dips below it
|
||||||
|
* (147.62 − 0.55 wobble = 147.07), so the ride never pops onto the voxels. */
|
||||||
|
const DECK_BASE = 147.62;
|
||||||
|
const DECK_HALF_W = 1.5;
|
||||||
|
const DECK_THICK = 0.8;
|
||||||
|
const WOBBLE_Y = 0.55; // carrying amplitude (idle = ×0.22)
|
||||||
|
const WOBBLE_X = 0.26;
|
||||||
|
const OMEGA = 1.7; // rad/s — max dy/dt ≈ 0.94 v/s, dx/dt ≈ 0.44 v/s
|
||||||
|
const PHASE = 0.9; // rad per segment (travelling wave)
|
||||||
|
const IDLE_SCALE = 0.22;
|
||||||
|
|
||||||
|
/** Tank-mouth inlay centre (voxel [224,146,52] → world centre). Feeding = E
|
||||||
|
* on the deck within MOUTH_HALF_Z of it while carrying; the inlay sits under
|
||||||
|
* our floating deck, so the deck segments ARE the feed target. */
|
||||||
|
const MOUTH_Z = 52.5;
|
||||||
|
const MOUTH_HALF_Z = 3.2;
|
||||||
|
/** Floats before the horn mouth (horn cone cx 232, mouth ring at z 80). */
|
||||||
|
const CHARGE_HOME: Vec3 = [232, 146.2, 78];
|
||||||
|
/** Feet below this while carrying = fell into the tank pit (pit walk surface
|
||||||
|
* is 143; the surrounding canyon floor at 144 stays safe to roam). */
|
||||||
|
const FALL_Y = 143.6;
|
||||||
|
|
||||||
|
export class DubQuest extends MachineBase implements IdInteractions {
|
||||||
|
private readonly stamps: Stamps;
|
||||||
|
private carrying = false;
|
||||||
|
private wobble = IDLE_SCALE; // eased amplitude scale
|
||||||
|
private t = 0;
|
||||||
|
|
||||||
|
private readonly segShapes: { kind: 'aabb'; min: Vec3; max: Vec3 }[] = [];
|
||||||
|
private readonly segVel: Vec3[] = [];
|
||||||
|
private readonly segMeshes: THREE.Mesh[] = [];
|
||||||
|
private readonly chargeShape: { kind: 'aabb'; min: Vec3; max: Vec3 };
|
||||||
|
private readonly chargeMesh: THREE.Mesh;
|
||||||
|
private readonly chargeHalo: THREE.Mesh;
|
||||||
|
private mouthRing!: THREE.Mesh;
|
||||||
|
|
||||||
|
constructor(stamps: Stamps) {
|
||||||
|
super('dub');
|
||||||
|
this.stamps = stamps;
|
||||||
|
|
||||||
|
// ── the moving deck: 8 riding segments + meshes ──
|
||||||
|
const chrome = blockMaterial(11, { roughness: 0.3 });
|
||||||
|
for (let i = 0; i < SEGS; i++) {
|
||||||
|
const shape = {
|
||||||
|
kind: 'aabb' as const,
|
||||||
|
min: [BX - DECK_HALF_W, DECK_BASE - DECK_THICK, BZ0 + i * SEG_LEN] as Vec3,
|
||||||
|
max: [BX + DECK_HALF_W, DECK_BASE, BZ0 + (i + 1) * SEG_LEN] as Vec3,
|
||||||
|
};
|
||||||
|
this.segShapes.push(shape);
|
||||||
|
const vel: Vec3 = [0, 0, 0];
|
||||||
|
this.segVel.push(vel);
|
||||||
|
this.colliders.push({ id: `dub_deck${i}`, shape, velocityAt: () => vel });
|
||||||
|
const mesh = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(DECK_HALF_W * 2, DECK_THICK, SEG_LEN - 0.12), chrome,
|
||||||
|
);
|
||||||
|
this.segMeshes.push(mesh);
|
||||||
|
this.group.add(mesh);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the echo charge at the horn ──
|
||||||
|
this.chargeShape = {
|
||||||
|
kind: 'aabb',
|
||||||
|
min: [CHARGE_HOME[0] - 1.1, CHARGE_HOME[1] - 1.1, CHARGE_HOME[2] - 1.1],
|
||||||
|
max: [CHARGE_HOME[0] + 1.1, CHARGE_HOME[1] + 1.1, CHARGE_HOME[2] + 1.1],
|
||||||
|
};
|
||||||
|
this.colliders.push({ id: 'dub_charge', shape: this.chargeShape, velocityAt: () => [0, 0, 0] });
|
||||||
|
this.chargeMesh = new THREE.Mesh(
|
||||||
|
new THREE.SphereGeometry(0.85, 14, 14),
|
||||||
|
blockMaterial(20, { emissive: 1.4 }), // led_amber glow
|
||||||
|
);
|
||||||
|
this.chargeHalo = new THREE.Mesh(
|
||||||
|
new THREE.SphereGeometry(1.25, 12, 12),
|
||||||
|
new THREE.MeshStandardMaterial({
|
||||||
|
color: 0xffb340, emissive: 0xffa030, emissiveIntensity: 0.5,
|
||||||
|
transparent: true, opacity: 0.18, depthWrite: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.chargeMesh.position.set(CHARGE_HOME[0], CHARGE_HOME[1], CHARGE_HOME[2]);
|
||||||
|
this.chargeHalo.position.copy(this.chargeMesh.position);
|
||||||
|
this.group.add(this.chargeMesh, this.chargeHalo);
|
||||||
|
|
||||||
|
// ── the tank-mouth marker: an amber ring hovering over Lane W's gold
|
||||||
|
// inlay (which sits in the static deck under our floating overlay). No
|
||||||
|
// collider — the deck segments are the feed target; a physical box here
|
||||||
|
// would wall off the walking lane mid-bridge.
|
||||||
|
this.mouthRing = new THREE.Mesh(
|
||||||
|
new THREE.TorusGeometry(1.15, 0.16, 8, 20),
|
||||||
|
blockMaterial(20, { emissive: 0.9 }),
|
||||||
|
);
|
||||||
|
this.mouthRing.rotation.x = Math.PI / 2;
|
||||||
|
this.mouthRing.position.set(BX, DECK_BASE + 0.35, MOUTH_Z);
|
||||||
|
this.group.add(this.mouthRing);
|
||||||
|
|
||||||
|
bus.on('world:enter', ({ world }) => { if (world === 'dub') this.resetCharge(false); });
|
||||||
|
bus.on('world:exit', ({ world }) => {
|
||||||
|
// Leaving mid-carry (exit pad or safety net): the charge slips home.
|
||||||
|
if (world === 'dub' && this.carrying) this.resetCharge(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
isCarrying(): boolean { return this.carrying; }
|
||||||
|
|
||||||
|
private resetCharge(emitDrop: boolean): void {
|
||||||
|
if (this.carrying && emitDrop) interactAt('dub', 'dub_drop');
|
||||||
|
this.carrying = false;
|
||||||
|
this.chargeShape.min[0] = CHARGE_HOME[0] - 1.1; this.chargeShape.max[0] = CHARGE_HOME[0] + 1.1;
|
||||||
|
this.chargeShape.min[1] = CHARGE_HOME[1] - 1.1; this.chargeShape.max[1] = CHARGE_HOME[1] + 1.1;
|
||||||
|
this.chargeShape.min[2] = CHARGE_HOME[2] - 1.1; this.chargeShape.max[2] = CHARGE_HOME[2] + 1.1;
|
||||||
|
this.chargeMesh.position.set(CHARGE_HOME[0], CHARGE_HOME[1], CHARGE_HOME[2]);
|
||||||
|
this.chargeMesh.visible = true;
|
||||||
|
this.chargeHalo.visible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private collapseCharge(): void {
|
||||||
|
this.chargeShape.min[0] = this.chargeShape.min[1] = this.chargeShape.min[2] = -1e6;
|
||||||
|
this.chargeShape.max[0] = this.chargeShape.max[1] = this.chargeShape.max[2] = -1e6 + 0.01;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── E routing ──
|
||||||
|
pressId(colliderId: string): void {
|
||||||
|
if (colliderId === 'dub_charge') {
|
||||||
|
if (this.carrying) return;
|
||||||
|
this.carrying = true;
|
||||||
|
this.collapseCharge();
|
||||||
|
interactAt('dub', 'dub_pickup');
|
||||||
|
bus.emit('workshop:msg', { text: 'THE ECHO CHARGE HUMS — FEED IT TO THE TANK MOUTH, MID-BRIDGE' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The mouth inlay lives under the floating deck: E on a deck segment near
|
||||||
|
// mid-span is the feed (the pit's reach geometry makes the bridge the only
|
||||||
|
// spot you can do this from).
|
||||||
|
if (colliderId.startsWith('dub_deck')
|
||||||
|
&& Math.abs((this.player ? this.player.position[2] : 1e9) - MOUTH_Z) < MOUTH_HALF_Z) {
|
||||||
|
if (!this.carrying) {
|
||||||
|
bus.emit('workshop:msg', { text: 'THE TANK GAPES BELOW — THE CHARGE WAITS AT THE HORN' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.feed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private feed(): void {
|
||||||
|
this.carrying = false;
|
||||||
|
interactAt('dub', 'dub_feed');
|
||||||
|
if (!this.stamps.apply('dub')) {
|
||||||
|
bus.emit('workshop:msg', { text: 'THE SPRING DRINKS DEEP — SPROING' });
|
||||||
|
}
|
||||||
|
// Replayable toy: the charge re-forms at the horn after the sproing.
|
||||||
|
this.resetCharge(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(dt: number): void {
|
||||||
|
this.t += dt;
|
||||||
|
// Amplitude eases toward carry/idle scale (no pop when picked up).
|
||||||
|
const target = this.carrying ? 1 : IDLE_SCALE;
|
||||||
|
this.wobble += (target - this.wobble) * Math.min(1, dt * 2.5);
|
||||||
|
|
||||||
|
// Travelling wave: y bob + a lateral breathe. Colliders and meshes move
|
||||||
|
// together and velocityAt reports the TRUE surface velocity, so the ride
|
||||||
|
// is honest (Lane B carries x via velocityAt, ride-snap handles y).
|
||||||
|
for (let i = 0; i < SEGS; i++) {
|
||||||
|
const ph = this.t * OMEGA - i * PHASE;
|
||||||
|
const yOff = WOBBLE_Y * this.wobble * Math.sin(ph);
|
||||||
|
const xOff = WOBBLE_X * this.wobble * Math.sin(ph * 0.7 + 1.3);
|
||||||
|
const dxdt = WOBBLE_X * this.wobble * 0.7 * OMEGA * Math.cos(ph * 0.7 + 1.3);
|
||||||
|
const s = this.segShapes[i];
|
||||||
|
s.min[0] = BX + xOff - DECK_HALF_W; s.max[0] = BX + xOff + DECK_HALF_W;
|
||||||
|
s.min[1] = DECK_BASE + yOff - DECK_THICK; s.max[1] = DECK_BASE + yOff;
|
||||||
|
const v = this.segVel[i];
|
||||||
|
v[0] = dxdt; v[1] = 0; v[2] = 0;
|
||||||
|
const m = this.segMeshes[i];
|
||||||
|
m.position.set(BX + xOff, DECK_BASE + yOff - DECK_THICK / 2, BZ0 + (i + 0.5) * SEG_LEN);
|
||||||
|
m.rotation.x = 0.10 * this.wobble * Math.cos(ph);
|
||||||
|
}
|
||||||
|
// The mouth ring rides the mid-span segment and burns brighter while the
|
||||||
|
// charge is on its way (the tank calls for it).
|
||||||
|
const midSeg = this.segShapes[4];
|
||||||
|
this.mouthRing.position.y = midSeg.max[1] + 0.35;
|
||||||
|
(this.mouthRing.material as THREE.MeshStandardMaterial).emissiveIntensity =
|
||||||
|
this.carrying ? 1.3 + 0.6 * Math.sin(this.t * 5) : 0.7;
|
||||||
|
|
||||||
|
const pl = this.player;
|
||||||
|
if (this.carrying && pl) {
|
||||||
|
// The charge floats in front of the eye, wire-carry style.
|
||||||
|
const e = pl.eye, l = pl.lookDir;
|
||||||
|
this.chargeMesh.position.set(e[0] + l[0] * 2.2, e[1] + l[1] * 2.2 - 0.4, e[2] + l[2] * 2.2);
|
||||||
|
this.chargeHalo.position.copy(this.chargeMesh.position);
|
||||||
|
// Fell into the chasm with it? The spring takes it back to the horn.
|
||||||
|
const [x, y, z] = pl.position;
|
||||||
|
if (y < FALL_Y && insidePocket('dub', x, y, z)) {
|
||||||
|
this.resetCharge(true);
|
||||||
|
bus.emit('workshop:msg', { text: 'THE SPRING WANTS IT — TRY AGAIN' });
|
||||||
|
}
|
||||||
|
} else if (!this.carrying) {
|
||||||
|
// Home bob + halo breathe.
|
||||||
|
const bob = Math.sin(this.t * 1.6) * 0.15;
|
||||||
|
this.chargeMesh.position.y = CHARGE_HOME[1] + bob;
|
||||||
|
this.chargeHalo.position.y = CHARGE_HOME[1] + bob;
|
||||||
|
const halo = this.chargeHalo.material as THREE.MeshStandardMaterial;
|
||||||
|
halo.opacity = 0.14 + 0.07 * Math.sin(this.t * 2.3);
|
||||||
|
}
|
||||||
|
this.chargeHalo.visible = this.chargeMesh.visible = !this.carrying || pl !== null;
|
||||||
|
}
|
||||||
|
}
|
||||||
208
src/machines/worlds/portal.ts
Normal file
208
src/machines/worlds/portal.ts
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
// LANE M — the crate portals (SIDEB_MACHINES M1).
|
||||||
|
// Three records in the under-table crate are pressed with whole scenes. Beside
|
||||||
|
// each (WORLD_DEFS[k].portalStand) floats a soft accent-glow dive plate: aim +
|
||||||
|
// E → `world:enter` + teleport to the pocket's entry. Standing on a pocket's
|
||||||
|
// exit pad → `world:exit` + teleport back to the crate. Belt-and-braces: a
|
||||||
|
// player above the booth walls (y > 141) who is NOT inside any pocket bounds
|
||||||
|
// is teleported home to the nearest world's returnPos (Lane W seals the
|
||||||
|
// shells; this catches everything else).
|
||||||
|
//
|
||||||
|
// Portals never read quest state — they keep working while the booth is dead
|
||||||
|
// (reset ritual coexistence, brief M5).
|
||||||
|
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { WORLD_DEFS, WORLD_KEYS, type WorldKey } from '../../core/worlds';
|
||||||
|
import { bus } from '../../core/events';
|
||||||
|
import type { KinematicCollider, Vec3 } from '../../core/types';
|
||||||
|
import { MachineBase } from '../machine';
|
||||||
|
import {
|
||||||
|
POCKET_SAFETY_Y, insidePocket, interactAt, nearestWorld, pocketAt, playerFlying, teleportPlayer,
|
||||||
|
} from './common';
|
||||||
|
|
||||||
|
/** How close (xz, voxels) to a portal stand the HUD prompt appears. */
|
||||||
|
const PROMPT_RADIUS = 5.5;
|
||||||
|
/** Re-show the prompt this often while the player stays in the zone (s). */
|
||||||
|
const PROMPT_REPEAT_S = 4;
|
||||||
|
/** Post-teleport grace so the exit pad / safety can't retrigger mid-flight. */
|
||||||
|
const TELEPORT_COOLDOWN_S = 1.0;
|
||||||
|
/** One-line quest hook shown on arrival, per world. */
|
||||||
|
const ARRIVAL_HINT: Record<WorldKey, string> = {
|
||||||
|
acid: 'TUNE THE 303 — HOLD E ON THE PEDESTALS, LISTEN FOR SWEET',
|
||||||
|
dub: 'FEED THE SPRING — CARRY THE CHARGE FROM THE HORN TO THE TANK',
|
||||||
|
disco: 'LIGHT THE FLOOR — WATCH THE TILES, REPEAT THE STEPS',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PortalEntry {
|
||||||
|
key: WorldKey;
|
||||||
|
plate: THREE.Mesh;
|
||||||
|
mat: THREE.MeshStandardMaterial;
|
||||||
|
promptAt: number; // seconds until the prompt may re-show (<=0 = armed)
|
||||||
|
inZone: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Portals extends MachineBase {
|
||||||
|
private readonly portals: PortalEntry[] = [];
|
||||||
|
private cooldown = 0;
|
||||||
|
private safetyTimer = 0;
|
||||||
|
private t = 0;
|
||||||
|
/** Seconds the player has stood on an exit pad (accident guard). */
|
||||||
|
private padDwell = 0;
|
||||||
|
/** World the player dove into (null = in the booth). Lets the safety catch
|
||||||
|
* EVERY way out of a pocket — mined floor, glitched wall — not just y>141:
|
||||||
|
* membership is checked, not altitude. */
|
||||||
|
private inWorld: WorldKey | null = null;
|
||||||
|
/** Previous tick's xz while on a pad (speed gate: striding across the pad
|
||||||
|
* never accumulates dwell; standing does). */
|
||||||
|
private padPrevX = 0;
|
||||||
|
private padPrevZ = 0;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super('portals');
|
||||||
|
for (const key of WORLD_KEYS) {
|
||||||
|
const d = WORLD_DEFS[key];
|
||||||
|
// The crate discs are vertical records in the y-z plane, 2 voxels thick
|
||||||
|
// starting at portalDisc.x (buildBooth discX). Probed live: the rim
|
||||||
|
// overhangs the stand (solid at y=4 up to z=35, y=5 up to z=37), so the
|
||||||
|
// dive plate sits LOW against the disc's lower rim, hugging the disc's
|
||||||
|
// own x footprint (bodies can't walk there) and poking just past the
|
||||||
|
// overhang — hittable from the stand and from the record aisle, without
|
||||||
|
// walling off the crate floor.
|
||||||
|
const px = d.portalDisc[0] + 1; // centre of the 2-thick disc
|
||||||
|
const pz = d.portalStand[2] - 0.7; // just proud of the rim overhang
|
||||||
|
const py = d.portalStand[1] + 1.0; // knee/chest height off the floor
|
||||||
|
const shape = {
|
||||||
|
kind: 'aabb' as const,
|
||||||
|
min: [px - 1.1, py - 1.2, pz - 1.1] as Vec3,
|
||||||
|
max: [px + 1.1, py + 1.2, pz + 1.1] as Vec3,
|
||||||
|
};
|
||||||
|
const collider: KinematicCollider = {
|
||||||
|
id: `portal_${key}`,
|
||||||
|
shape,
|
||||||
|
velocityAt: () => [0, 0, 0],
|
||||||
|
};
|
||||||
|
this.colliders.push(collider);
|
||||||
|
|
||||||
|
// Soft translucent accent pane — the "this record is a door" tell.
|
||||||
|
const [r, g, b] = d.accent;
|
||||||
|
const color = new THREE.Color().setRGB(r / 255, g / 255, b / 255, THREE.SRGBColorSpace);
|
||||||
|
const mat = new THREE.MeshStandardMaterial({
|
||||||
|
color, emissive: color, emissiveIntensity: 0.7,
|
||||||
|
transparent: true, opacity: 0.22, depthWrite: false,
|
||||||
|
});
|
||||||
|
const plate = new THREE.Mesh(new THREE.BoxGeometry(2.0, 2.2, 0.45), mat);
|
||||||
|
plate.position.set(px, py, pz + 0.85); // the pane floats at the collider's front face
|
||||||
|
this.group.add(plate);
|
||||||
|
|
||||||
|
this.portals.push({ key, plate, mat, promptAt: 0, inZone: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** E on a dive plate (routed by collider id). Respects the teleport
|
||||||
|
* cooldown — an exit lands you within press range of the plate, and an E
|
||||||
|
* held through the exit must not ping-pong you straight back in. */
|
||||||
|
pressId(colliderId: string): void {
|
||||||
|
if (this.cooldown > 0) return;
|
||||||
|
if (!colliderId.startsWith('portal_')) return;
|
||||||
|
const key = colliderId.slice(7) as WorldKey;
|
||||||
|
const d = WORLD_DEFS[key];
|
||||||
|
if (!d) return;
|
||||||
|
this.cooldown = TELEPORT_COOLDOWN_S;
|
||||||
|
this.inWorld = key;
|
||||||
|
bus.emit('world:enter', { world: key });
|
||||||
|
interactAt('portals', 'portal_dive');
|
||||||
|
teleportPlayer(this.player, d.entry);
|
||||||
|
bus.emit('workshop:msg', { text: `▶ ${d.name} — ${ARRIVAL_HINT[key]}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
update(dt: number): void {
|
||||||
|
this.t += dt;
|
||||||
|
if (this.cooldown > 0) this.cooldown -= dt;
|
||||||
|
|
||||||
|
// Breathing accent glow on the plates (shared cheap pulse, no allocs).
|
||||||
|
const pulse = 0.55 + 0.35 * Math.sin(this.t * 2.1);
|
||||||
|
for (const p of this.portals) p.mat.emissiveIntensity = pulse;
|
||||||
|
|
||||||
|
const pl = this.player;
|
||||||
|
if (!pl) return;
|
||||||
|
const [x, y, z] = pl.position;
|
||||||
|
|
||||||
|
// ── Dive prompts: near a portal stand → "DIVE INTO <NAME> (E)" ──
|
||||||
|
for (const p of this.portals) {
|
||||||
|
const d = WORLD_DEFS[p.key];
|
||||||
|
const dx = x - d.portalStand[0], dz = z - d.portalStand[2];
|
||||||
|
const near = dx * dx + dz * dz <= PROMPT_RADIUS * PROMPT_RADIUS
|
||||||
|
&& Math.abs(y - d.portalStand[1]) < 4;
|
||||||
|
if (near) {
|
||||||
|
p.promptAt -= dt;
|
||||||
|
if (!p.inZone || p.promptAt <= 0) {
|
||||||
|
p.inZone = true;
|
||||||
|
p.promptAt = PROMPT_REPEAT_S;
|
||||||
|
bus.emit('workshop:msg', { text: `DIVE INTO ${d.name} (E)` });
|
||||||
|
}
|
||||||
|
} else if (p.inZone) {
|
||||||
|
p.inZone = false;
|
||||||
|
p.promptAt = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.cooldown > 0) return;
|
||||||
|
|
||||||
|
// ── Exit pads: STANDING on one returns you to the crate. Two guards keep
|
||||||
|
// quest traffic from being yanked out (the disco grid's far row overlaps
|
||||||
|
// the pad's street): a 0.5 s dwell, and a speed gate — dwell accumulates
|
||||||
|
// only while near-stationary, so striding across the pad mid-round never
|
||||||
|
// counts, while deliberately standing on it still feels quick.
|
||||||
|
let onPad: WorldKey | null = null;
|
||||||
|
for (const k of WORLD_KEYS) {
|
||||||
|
const e = WORLD_DEFS[k].exitPad;
|
||||||
|
// +0.5: the 3×3 gold pad is voxels 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/machines/worlds/stamps.ts
Normal file
58
src/machines/worlds/stamps.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
// LANE M — the stamp store (SIDEB_MACHINES M3).
|
||||||
|
// A stamp is pressed into your copy of a record when you complete its world's
|
||||||
|
// quest. Stamps are the ONLY thing the crate worlds sync (global, like
|
||||||
|
// repairs): quests call apply() on a local win; NetClient forwards the
|
||||||
|
// resulting `stamp:got` to the relay and calls apply() for remote stamps and
|
||||||
|
// the join snapshot. apply() is idempotent — a replayed broadcast, a reconnect
|
||||||
|
// hello, or a re-won quest can never double-count or re-fire events.
|
||||||
|
//
|
||||||
|
// Stamps deliberately do NOT listen to `quest:reset`: they're pressed into the
|
||||||
|
// records, not the booth, so the reset ritual leaves them alone (brief M5).
|
||||||
|
|
||||||
|
import { STAMP_TOTAL, WORLD_DEFS, WORLD_KEYS, type WorldKey } from '../../core/worlds';
|
||||||
|
import { bus } from '../../core/events';
|
||||||
|
|
||||||
|
export class Stamps {
|
||||||
|
private readonly got = new Set<WorldKey>();
|
||||||
|
|
||||||
|
/** Validate an arbitrary wire value into a WorldKey (default-closed). */
|
||||||
|
static coerceKey(v: unknown): WorldKey | null {
|
||||||
|
return typeof v === 'string' && (WORLD_KEYS as readonly string[]).includes(v)
|
||||||
|
? (v as WorldKey) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
has(w: WorldKey): boolean { return this.got.has(w); }
|
||||||
|
count(): number { return this.got.size; }
|
||||||
|
all(): boolean { return this.got.size >= STAMP_TOTAL; }
|
||||||
|
list(): WorldKey[] { return WORLD_KEYS.filter((k) => this.got.has(k)); }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a stamp and emit `stamp:got` (+ `stamp:all` when the third lands),
|
||||||
|
* tagged with WHO caused it so consumers celebrate proportionately:
|
||||||
|
* 'local' = this player just won the quest (full glory), 'remote' = a peer
|
||||||
|
* won it live (share it, third person), 'replay' = join-snapshot state
|
||||||
|
* (apply silently — no toasts, no confetti, no audio). Returns whether
|
||||||
|
* state changed; false = already stamped, nothing emitted.
|
||||||
|
*/
|
||||||
|
apply(w: WorldKey, origin: 'local' | 'remote' | 'replay' = 'local'): boolean {
|
||||||
|
if (this.got.has(w)) return false;
|
||||||
|
this.got.add(w);
|
||||||
|
bus.emit('stamp:got', { world: w, count: this.got.size, total: STAMP_TOTAL, origin });
|
||||||
|
if (origin === 'local') {
|
||||||
|
bus.emit('workshop:msg', {
|
||||||
|
text: `STAMP PRESSED — ${WORLD_DEFS[w].name} (${this.got.size}/${STAMP_TOTAL})`,
|
||||||
|
});
|
||||||
|
} else if (origin === 'remote') {
|
||||||
|
bus.emit('workshop:msg', {
|
||||||
|
text: `A STAMP LANDED ACROSS THE BOOTH — ${WORLD_DEFS[w].name} (${this.got.size}/${STAMP_TOTAL})`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (this.got.size === STAMP_TOTAL) {
|
||||||
|
bus.emit('stamp:all', { origin });
|
||||||
|
if (origin !== 'replay') {
|
||||||
|
bus.emit('workshop:msg', { text: 'ALL THREE STAMPS — THE GOLDEN SLIPMAT RISES' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -90,6 +90,7 @@ scene.add(interaction.getObject3D());
|
|||||||
// starts only from the HUD's start-splash click (autoplay policy).
|
// starts only from the HUD's start-splash click (autoplay policy).
|
||||||
const audio = new AudioEngine();
|
const audio = new AudioEngine();
|
||||||
const fx = new FxSystem({ scene, setEmissiveBoost, getLevels: () => audio.getLevels() });
|
const fx = new FxSystem({ scene, setEmissiveBoost, getLevels: () => audio.getLevels() });
|
||||||
|
fx.attachAmbience(world, player); // SIDE B: dust bunnies + portal iris anchor + stamp FX
|
||||||
|
|
||||||
// Broken-node spark beacons: each unrepaired quest fixture crackles — the
|
// Broken-node spark beacons: each unrepaired quest fixture crackles — the
|
||||||
// world itself points at what's broken (first-five-minutes pass).
|
// world itself points at what's broken (first-five-minutes pass).
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import { AIR } from '../core/blocks';
|
|||||||
import { bus } from '../core/events';
|
import { bus } from '../core/events';
|
||||||
import type { SignalNode } from '../core/constants';
|
import type { SignalNode } from '../core/constants';
|
||||||
import type { IPlayerView, IVoxelWorld, Vec3 } from '../core/types';
|
import type { IPlayerView, IVoxelWorld, Vec3 } from '../core/types';
|
||||||
import type { MachineSet, Platter } from '../machines';
|
import { Stamps, type MachineSet, type Platter } from '../machines';
|
||||||
import { Avatars } from './Avatars';
|
import { Avatars } from './Avatars';
|
||||||
import { validLook, DEFAULT_LOOK, type AvatarLook } from './avatarLook';
|
import { validLook, DEFAULT_LOOK, type AvatarLook } from './avatarLook';
|
||||||
|
|
||||||
@ -147,6 +147,14 @@ export class NetClient {
|
|||||||
this.unsubs.push(this.o.machines.workshop.onChange((s) => {
|
this.unsubs.push(this.o.machines.workshop.onChange((s) => {
|
||||||
if (!this.applyingRemote) this.send({ t: 'workshop', s });
|
if (!this.applyingRemote) this.send({ t: 'workshop', s });
|
||||||
}));
|
}));
|
||||||
|
// SIDE B stamps (global, like repairs). Stamps.apply emits stamp:got for
|
||||||
|
// local wins AND remote/join applies; the guard keeps only local wins
|
||||||
|
// going out. w is re-validated so a spoofed bus event can't ride the wire.
|
||||||
|
this.unsubs.push(bus.on('stamp:got', ({ world }) => {
|
||||||
|
if (this.applyingRemote) return;
|
||||||
|
const w = Stamps.coerceKey(world);
|
||||||
|
if (w) this.send({ t: 'stamp', w });
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
private connect(): void {
|
private connect(): void {
|
||||||
@ -213,6 +221,21 @@ export class NetClient {
|
|||||||
if (s && pl) { pl.setSpeed(s.rpm); pl.setPlaying(s.playing); }
|
if (s && pl) { pl.setSpeed(s.rpm); pl.setPlaying(s.playing); }
|
||||||
}
|
}
|
||||||
if (m.state?.workshop) this.o.machines.workshop.setState(m.state.workshop);
|
if (m.state?.workshop) this.o.machines.workshop.setState(m.state.workshop);
|
||||||
|
// Stamps from the join snapshot: idempotent, origin 'replay' (events
|
||||||
|
// still fire so the tray/golden state seeds, but consumers celebrate
|
||||||
|
// nothing — no wall-clock grace window needed).
|
||||||
|
const snapshot = new Set<string>();
|
||||||
|
for (const wv of m.state?.stamps ?? []) {
|
||||||
|
const w = Stamps.coerceKey(wv);
|
||||||
|
if (w) { snapshot.add(w); this.o.machines.stamps.apply(w, 'replay'); }
|
||||||
|
}
|
||||||
|
// Re-upload stamps the server is missing (won while the socket was
|
||||||
|
// down: the original send() dropped on a closed ws, and idempotent
|
||||||
|
// apply() means the stamp:got that triggers a send can never re-fire
|
||||||
|
// — without this, an offline-won stamp would be lost forever).
|
||||||
|
for (const w of this.o.machines.stamps.list()) {
|
||||||
|
if (!snapshot.has(w)) this.send({ t: 'stamp', w });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
this.o.onStatus?.(this.peerCount, true);
|
this.o.onStatus?.(this.peerCount, true);
|
||||||
break;
|
break;
|
||||||
@ -283,6 +306,15 @@ export class NetClient {
|
|||||||
case 'workshop':
|
case 'workshop':
|
||||||
this.applyRemote(() => { this.o.machines.workshop.setState(m.s); });
|
this.applyRemote(() => { this.o.machines.workshop.setState(m.s); });
|
||||||
break;
|
break;
|
||||||
|
case 'stamp': {
|
||||||
|
// A peer pressed a stamp live. Validated default-closed; apply is
|
||||||
|
// idempotent so replays/races can't double-count. Origin 'remote' —
|
||||||
|
// consumers share the moment third-person (crate-anchored confetti,
|
||||||
|
// "ACROSS THE BOOTH" toast) instead of celebrating at the local player.
|
||||||
|
const w = Stamps.coerceKey(m.w);
|
||||||
|
if (w) this.applyRemote(() => { this.o.machines.stamps.apply(w, 'remote'); });
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'full':
|
case 'full':
|
||||||
console.warn('[net] booth is full — playing solo');
|
console.warn('[net] booth is full — playing solo');
|
||||||
this.dispose();
|
this.dispose();
|
||||||
|
|||||||
@ -47,3 +47,13 @@ adapt Lane D's hotbar to this shape).
|
|||||||
- `workshop:msg {text}` → the existing subtitle line. Lane D sends the authored
|
- `workshop:msg {text}` → the existing subtitle line. Lane D sends the authored
|
||||||
fault labels (`ERROR_LABEL` in `machines/workshop/types.ts`), so the HUD does
|
fault labels (`ERROR_LABEL` in `machines/workshop/types.ts`), so the HUD does
|
||||||
not duplicate that text.
|
not duplicate that text.
|
||||||
|
|
||||||
|
## SIDE B — stamp tray (Lane S; see docs/SIDEB_S_handoff.md)
|
||||||
|
|
||||||
|
`Hud` self-wires two more reactions (Lane M emits): a tray of three
|
||||||
|
record-stamp dots top-right under the signal lamps (`WORLD_KEYS` order,
|
||||||
|
`WORLD_DEFS` accent colours). `stamp:got` fills + glows the world's dot and
|
||||||
|
scale-pulses it; `stamp:all` turns all rings gold. Stamp events within 4 s of
|
||||||
|
construction are treated as join-snapshot replays: dots light silently (no
|
||||||
|
pulse) so a joiner's HUD seeds without a celebration. No new public API; no
|
||||||
|
subtitle (Lane M sends its own `workshop:msg` line on stamping).
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
import { bus } from '../core/events';
|
import { bus } from '../core/events';
|
||||||
import { SIGNAL_NODES, type SignalNode } from '../core/constants';
|
import { SIGNAL_NODES, type SignalNode } from '../core/constants';
|
||||||
import { blockDef, type BlockId } from '../core/blocks';
|
import { blockDef, type BlockId } from '../core/blocks';
|
||||||
|
import { WORLD_DEFS, WORLD_KEYS } from '../core/worlds';
|
||||||
|
|
||||||
export interface HotbarSlot { id: BlockId; count: number; }
|
export interface HotbarSlot { id: BlockId; count: number; }
|
||||||
export interface HotbarModel { slots: HotbarSlot[]; active: number; }
|
export interface HotbarModel { slots: HotbarSlot[]; active: number; }
|
||||||
@ -48,6 +49,17 @@ const CSS = `
|
|||||||
.tc-node.lit .tc-dot { box-shadow:0 0 10px 2px currentColor; }
|
.tc-node.lit .tc-dot { box-shadow:0 0 10px 2px currentColor; }
|
||||||
.tc-nlabel { font-size:9px; letter-spacing:.06em; color:#9aa; }
|
.tc-nlabel { font-size:9px; letter-spacing:.06em; color:#9aa; }
|
||||||
.tc-progress { position:absolute; top:44px; right:14px; font-size:11px; color:#7a8; letter-spacing:.1em; }
|
.tc-progress { position:absolute; top:44px; right:14px; font-size:11px; color:#7a8; letter-spacing:.1em; }
|
||||||
|
.tc-stamps { position:absolute; top:64px; right:14px; display:flex; gap:9px; }
|
||||||
|
.tc-stamp { width:15px; height:15px; border-radius:50%; border:2px solid #3a3a3e;
|
||||||
|
position:relative; opacity:.5; background:transparent;
|
||||||
|
transition:opacity .3s, background .3s, border-color .3s, box-shadow .3s; }
|
||||||
|
.tc-stamp::after { content:""; position:absolute; left:50%; top:50%; width:3px; height:3px;
|
||||||
|
margin:-1.5px 0 0 -1.5px; border-radius:50%; background:#101012; }
|
||||||
|
.tc-stamp.got { opacity:1; background:currentColor; border-color:currentColor;
|
||||||
|
box-shadow:0 0 10px 2px currentColor; }
|
||||||
|
.tc-stamp.pulse { animation:tcstamp .55s cubic-bezier(.2,1.6,.4,1); }
|
||||||
|
@keyframes tcstamp { 0% { transform:scale(1); } 40% { transform:scale(1.75); } 100% { transform:scale(1); } }
|
||||||
|
.tc-stamps.all .tc-stamp { border-color:#ffd24a; box-shadow:0 0 12px 3px rgba(255,210,74,.85); }
|
||||||
.tc-sub { position:absolute; left:50%; bottom:120px; transform:translateX(-50%);
|
.tc-sub { position:absolute; left:50%; bottom:120px; transform:translateX(-50%);
|
||||||
font-size:15px; letter-spacing:.14em; color:#ffb028; text-shadow:0 0 12px rgba(255,176,40,.5);
|
font-size:15px; letter-spacing:.14em; color:#ffb028; text-shadow:0 0 12px rgba(255,176,40,.5);
|
||||||
opacity:0; transition:opacity .4s; white-space:nowrap; }
|
opacity:0; transition:opacity .4s; white-space:nowrap; }
|
||||||
@ -95,6 +107,9 @@ export class Hud {
|
|||||||
private slots: HTMLDivElement[] = [];
|
private slots: HTMLDivElement[] = [];
|
||||||
private nodeEls: Record<string, HTMLDivElement> = {};
|
private nodeEls: Record<string, HTMLDivElement> = {};
|
||||||
private progressEl!: HTMLDivElement;
|
private progressEl!: HTMLDivElement;
|
||||||
|
private stampsEl!: HTMLDivElement;
|
||||||
|
private stampEls: Record<string, HTMLDivElement> = {};
|
||||||
|
private bornAt = performance.now(); // gates join-replay stamp events (no pulse)
|
||||||
private subEl!: HTMLDivElement;
|
private subEl!: HTMLDivElement;
|
||||||
private torqueEl!: HTMLDivElement;
|
private torqueEl!: HTMLDivElement;
|
||||||
private torqueVal!: HTMLDivElement;
|
private torqueVal!: HTMLDivElement;
|
||||||
@ -116,6 +131,7 @@ export class Hud {
|
|||||||
this.el.className = 'tc-hud';
|
this.el.className = 'tc-hud';
|
||||||
this.buildCrosshair();
|
this.buildCrosshair();
|
||||||
this.buildQuest();
|
this.buildQuest();
|
||||||
|
this.buildStamps();
|
||||||
this.buildSubtitle();
|
this.buildSubtitle();
|
||||||
this.buildTorque();
|
this.buildTorque();
|
||||||
this.buildHotbar();
|
this.buildHotbar();
|
||||||
@ -224,6 +240,23 @@ export class Hud {
|
|||||||
this.el.appendChild(this.progressEl);
|
this.el.appendChild(this.progressEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SIDE B (Lane S): the stamp tray — three record-stamp dots top-right under
|
||||||
|
// the signal lamps, one per crate world (accent-coloured when got, dim ring
|
||||||
|
// when not; gold rings once all three are pressed).
|
||||||
|
private buildStamps(): void {
|
||||||
|
this.stampsEl = document.createElement('div');
|
||||||
|
this.stampsEl.className = 'tc-stamps';
|
||||||
|
for (const k of WORLD_KEYS) {
|
||||||
|
const a = WORLD_DEFS[k].accent;
|
||||||
|
const dot = document.createElement('div');
|
||||||
|
dot.className = 'tc-stamp';
|
||||||
|
dot.style.color = `rgb(${a[0]},${a[1]},${a[2]})`; // currentColor drives .got
|
||||||
|
this.stampsEl.appendChild(dot);
|
||||||
|
this.stampEls[k] = dot;
|
||||||
|
}
|
||||||
|
this.el.appendChild(this.stampsEl);
|
||||||
|
}
|
||||||
|
|
||||||
private buildSubtitle(): void {
|
private buildSubtitle(): void {
|
||||||
this.subEl = document.createElement('div');
|
this.subEl = document.createElement('div');
|
||||||
this.subEl.className = 'tc-sub';
|
this.subEl.className = 'tc-sub';
|
||||||
@ -340,5 +373,30 @@ export class Hud {
|
|||||||
else this.showTorque(p.value);
|
else this.showTorque(p.value);
|
||||||
}));
|
}));
|
||||||
this.disposers.push(bus.on('workshop:msg', (p) => this.showSubtitle(p.text)));
|
this.disposers.push(bus.on('workshop:msg', (p) => this.showSubtitle(p.text)));
|
||||||
|
|
||||||
|
// SIDE B (Lane S): stamp tray. The event's origin says whether this is a
|
||||||
|
// live win ('local'/'remote' → scale-pulse the dot) or join-snapshot
|
||||||
|
// replay ('replay' → light silently) — no wall-clock guessing.
|
||||||
|
this.disposers.push(bus.on('stamp:got', (p) => {
|
||||||
|
const dot = this.stampEls[p.world];
|
||||||
|
if (!dot) return;
|
||||||
|
dot.classList.add('got');
|
||||||
|
if (p.origin !== 'replay') {
|
||||||
|
dot.classList.remove('pulse');
|
||||||
|
void dot.offsetWidth; // restart the animation on repeat stamps
|
||||||
|
dot.classList.add('pulse');
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
this.disposers.push(bus.on('stamp:all', (p) => {
|
||||||
|
this.stampsEl.classList.add('all');
|
||||||
|
if (p.origin !== 'replay') {
|
||||||
|
for (const k of WORLD_KEYS) {
|
||||||
|
const dot = this.stampEls[k];
|
||||||
|
dot.classList.remove('pulse');
|
||||||
|
void dot.offsetWidth;
|
||||||
|
dot.classList.add('pulse');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import {
|
|||||||
import { box, hollowBox, cylinderY, sphere, spline, mulberry32, randInt, setVox } from './tools';
|
import { box, hollowBox, cylinderY, sphere, spline, mulberry32, randInt, setVox } from './tools';
|
||||||
import { DECK_A, DECK_B, MIXER, UNDER, PATCH, RCA_PLUG, type DeckAnchors, type DeckFootprint } from './anchors';
|
import { DECK_A, DECK_B, MIXER, UNDER, PATCH, RCA_PLUG, type DeckAnchors, type DeckFootprint } from './anchors';
|
||||||
import { QUEST_POS } from './questPositions';
|
import { QUEST_POS } from './questPositions';
|
||||||
|
import { buildWorlds } from './worlds';
|
||||||
|
|
||||||
// ---- block palette (resolve names -> ids once) -----------------------------
|
// ---- block palette (resolve names -> ids once) -----------------------------
|
||||||
|
|
||||||
@ -570,6 +571,10 @@ export function buildBooth(w: IVoxelWorld): void {
|
|||||||
buildPatchBay(w, rng);
|
buildPatchBay(w, rng);
|
||||||
buildUnderTable(w, rng);
|
buildUnderTable(w, rng);
|
||||||
buildDressing(w, rng);
|
buildDressing(w, rng);
|
||||||
|
// SIDE B: portal rings at the crate + the three sealed crate-world pockets
|
||||||
|
// above the ceiling. Derives its own seeded streams — `rng` is untouched, so
|
||||||
|
// every zone above stays byte-identical (see worlds/index.ts).
|
||||||
|
buildWorlds(w, WORLD_SEED);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-export the quest contract + anchors so integrators have one import site.
|
// Re-export the quest contract + anchors so integrators have one import site.
|
||||||
|
|||||||
74
src/worldgen/worlds/acid.ts
Normal file
74
src/worldgen/worlds/acid.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
// TURNCRAFT — SIDE B Lane W: ACID WAREHOUSE (pocket x 20..84, z 20..84).
|
||||||
|
// Raw concrete room: steel/alu slab floor with rubber expansion seams,
|
||||||
|
// breeze-block ply_edge walls (shadow slots show the black skin), four square
|
||||||
|
// pillars, a strobe rail on the ceiling, and three led_green cable runs that
|
||||||
|
// snake down the west wall and across the floor to the three knob pedestals
|
||||||
|
// (3×3×3 matte_black plinths at x 52, z 44/52/60 — Lane M mounts the 303
|
||||||
|
// knobs here; the 1-voxel green pip sits top-centre of each).
|
||||||
|
// Fully index-patterned — no rng — so it is trivially byte-stable.
|
||||||
|
|
||||||
|
import type { IVoxelWorld } from '../../core/types';
|
||||||
|
import { WORLD_DEFS } from '../../core/worlds';
|
||||||
|
import { box, setVox } from '../tools';
|
||||||
|
import { buildExitPad, buildSkin, frame, inlayPath, P, paintFloor, paintWalls } from './common';
|
||||||
|
|
||||||
|
/** Pedestal line (brief + Lane M's SIDEB_MACHINES M2): x=52, z=44/52/60. */
|
||||||
|
export const ACID_PEDESTAL_X = WORLD_DEFS.acid.entry[0]; // 52
|
||||||
|
export const ACID_PEDESTAL_Z = [44, 52, 60] as const;
|
||||||
|
/** Plinth: 3×3 footprint, y 144..146 (top face 147); pip led_green at (52,147,z). */
|
||||||
|
export const ACID_PEDESTAL_TOP_Y = 147;
|
||||||
|
|
||||||
|
export function buildAcid(w: IVoxelWorld): void {
|
||||||
|
const d = WORLD_DEFS.acid;
|
||||||
|
const fr = frame(d);
|
||||||
|
buildSkin(w, fr);
|
||||||
|
|
||||||
|
// Concrete floor: 8×8 slabs, mostly steel_grey with scattered brushed_alu
|
||||||
|
// plates, separated by flush rubber expansion seams.
|
||||||
|
paintFloor(w, fr, (x, z) => {
|
||||||
|
const lx = x - fr.ix0, lz = z - fr.iz0;
|
||||||
|
if (lx % 8 === 0 || lz % 8 === 0) return P.rubber;
|
||||||
|
const sx = (lx / 8) | 0, sz = (lz / 8) | 0;
|
||||||
|
return (sx * 7 + sz * 3) % 5 === 1 ? P.alu : P.steel;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Breeze-block wall rhythm: ply_edge courses with 2-tall shadow slots every
|
||||||
|
// third column (-1 leaves air so the black skin reads as the hole).
|
||||||
|
paintWalls(w, fr, (_face, u, y) => {
|
||||||
|
const row = y - (fr.floorY + 1); // 0..13
|
||||||
|
if (u % 3 === 1 && row % 4 >= 2 && row < 12) return -1;
|
||||||
|
return P.plyEdge;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Four concrete pillars, floor to ceiling, with a brushed collar band.
|
||||||
|
for (const px of [33, 69]) {
|
||||||
|
for (const pz of [35, 67]) {
|
||||||
|
box(w, px, fr.floorY + 1, pz, px + 1, fr.topY, pz + 1, P.steel);
|
||||||
|
box(w, px, 150, pz, px + 1, 150, pz + 1, P.alu);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Three knob pedestals + pips (Lane M's hold-E colliders target these).
|
||||||
|
const bx = ACID_PEDESTAL_X;
|
||||||
|
for (const pz of ACID_PEDESTAL_Z) {
|
||||||
|
box(w, bx - 1, fr.floorY + 1, pz - 1, bx + 1, fr.floorY + 3, pz + 1, P.black);
|
||||||
|
setVox(w, bx, ACID_PEDESTAL_TOP_Y, pz, P.ledG);
|
||||||
|
}
|
||||||
|
|
||||||
|
// led_green cable runs: down the west wall (x=21 lining), then flush floor
|
||||||
|
// inlays snaking to each pedestal's base. One run per pedestal.
|
||||||
|
const runs: Array<{ wallZ: number; path: Array<[number, number]> }> = [
|
||||||
|
{ wallZ: 38, path: [[22, 38], [30, 38], [30, 46], [42, 46], [42, 44], [50, 44]] },
|
||||||
|
{ wallZ: 46, path: [[22, 46], [26, 46], [26, 54], [38, 54], [38, 52], [50, 52]] },
|
||||||
|
{ wallZ: 62, path: [[22, 62], [34, 62], [34, 58], [44, 58], [44, 60], [50, 60]] },
|
||||||
|
];
|
||||||
|
for (const r of runs) {
|
||||||
|
for (let y = fr.floorY + 1; y <= fr.floorY + 6; y++) setVox(w, fr.ix0, y, r.wallZ, P.ledG);
|
||||||
|
inlayPath(w, r.path, fr.floorY, P.ledG);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strobe rail: dashed strobe_dot line on the ceiling over the pedestal axis.
|
||||||
|
for (let z = 28; z <= 76; z += 3) setVox(w, bx, fr.topY, z, P.strobe);
|
||||||
|
|
||||||
|
buildExitPad(w, d);
|
||||||
|
}
|
||||||
112
src/worldgen/worlds/common.ts
Normal file
112
src/worldgen/worlds/common.ts
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
// TURNCRAFT — SIDE B Lane W shared pocket toolkit (docs/briefs/SIDEB_WORLDS.md).
|
||||||
|
// The three crate worlds are sealed rooms floating in the dark above the booth
|
||||||
|
// ceiling, at the WORLD_DEFS bounds. Everything here enforces the two laws the
|
||||||
|
// brief cares about most:
|
||||||
|
// 1. the OUTER skin of every pocket is 1 voxel of matte_black on all six
|
||||||
|
// faces — no hole, no emissive, no transparent block ever on that skin;
|
||||||
|
// 2. every themed/emissive voxel lives strictly INSIDE the skin.
|
||||||
|
// Elevation model (matches the contract): pocketMin.y=142 is the shell floor
|
||||||
|
// layer; the themed floor lining sits at 143 (= exitPad.y), so feet stand at
|
||||||
|
// 144 (= entry.y); fixtures hang at 157; pocketMax.y=158 is the shell roof.
|
||||||
|
|
||||||
|
import { AIR, BLOCK_BY_NAME } from '../../core/blocks';
|
||||||
|
import type { IVoxelWorld } from '../../core/types';
|
||||||
|
import type { WorldDef, WorldKey } from '../../core/worlds';
|
||||||
|
import { hollowBox, setVox } from '../tools';
|
||||||
|
|
||||||
|
function bid(name: string): number {
|
||||||
|
const d = BLOCK_BY_NAME.get(name);
|
||||||
|
if (!d) throw new Error(`worldgen/worlds: unknown block '${name}'`);
|
||||||
|
return d.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pocket palette (existing registry ids only — no new blocks, 31 stays reserved). */
|
||||||
|
export const P = {
|
||||||
|
air: AIR,
|
||||||
|
ply: bid('plywood'), plyEdge: bid('ply_edge'),
|
||||||
|
alu: bid('brushed_alu'), steel: bid('steel_grey'), black: bid('matte_black'),
|
||||||
|
rubber: bid('rubber'), vinyl: bid('vinyl_black'), label: bid('label_cream'),
|
||||||
|
chrome: bid('chrome'), mesh: bid('speaker_mesh'), glass: bid('glass'),
|
||||||
|
ledR: bid('led_red'), ledG: bid('led_green'), ledA: bid('led_amber'), ledB: bid('led_blue'),
|
||||||
|
strobe: bid('strobe_dot'), gold: bid('rca_gold'),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Accent → nearest existing led block (brief W1: acid=green, dub=amber, disco=blue). */
|
||||||
|
export const ACCENT_LED: Record<WorldKey, number> = {
|
||||||
|
acid: P.ledG, dub: P.ledA, disco: P.ledB,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Derived working coordinates of one pocket. */
|
||||||
|
export interface Frame {
|
||||||
|
x0: number; y0: number; z0: number; x1: number; y1: number; z1: number; // shell, inclusive
|
||||||
|
ix0: number; ix1: number; iz0: number; iz1: number; // wall-lining planes (1 inside the skin)
|
||||||
|
floorY: number; // themed floor lining layer (y0+1 = 143 = exitPad.y)
|
||||||
|
topY: number; // fixture layer under the roof skin (y1-1 = 157)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function frame(d: WorldDef): Frame {
|
||||||
|
const [x0, y0, z0] = d.pocketMin;
|
||||||
|
const [x1, y1, z1] = d.pocketMax;
|
||||||
|
return {
|
||||||
|
x0, y0, z0, x1, y1, z1,
|
||||||
|
ix0: x0 + 1, ix1: x1 - 1, iz0: z0 + 1, iz1: z1 - 1,
|
||||||
|
floorY: y0 + 1, topY: y1 - 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The sealed 1-voxel matte_black skin. Interiors above y=141 are virgin air. */
|
||||||
|
export function buildSkin(w: IVoxelWorld, fr: Frame): void {
|
||||||
|
hollowBox(w, fr.x0, fr.y0, fr.z0, fr.x1, fr.y1, fr.z1, P.black, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Paint the floor lining (y=143) from a per-voxel picker. */
|
||||||
|
export function paintFloor(w: IVoxelWorld, fr: Frame, pick: (x: number, z: number) => number): void {
|
||||||
|
for (let x = fr.ix0; x <= fr.ix1; x++)
|
||||||
|
for (let z = fr.iz0; z <= fr.iz1; z++)
|
||||||
|
setVox(w, x, fr.floorY, z, pick(x, z));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paint the four interior wall-lining planes (1 voxel inside the skin),
|
||||||
|
* y 144..157. `pick` returns a block id or -1 to leave the voxel as-is (the
|
||||||
|
* black skin shows through — used for breeze-block shadow slots).
|
||||||
|
* face: 0 = west (x=ix0), 1 = east (x=ix1), 2 = front (z=iz0), 3 = back (z=iz1).
|
||||||
|
* u is the along-wall coordinate (z on x-walls, x on z-walls).
|
||||||
|
*/
|
||||||
|
export function paintWalls(
|
||||||
|
w: IVoxelWorld, fr: Frame,
|
||||||
|
pick: (face: 0 | 1 | 2 | 3, u: number, y: number) => number,
|
||||||
|
): void {
|
||||||
|
const yLo = fr.floorY + 1, yHi = fr.topY;
|
||||||
|
for (let y = yLo; y <= yHi; y++) {
|
||||||
|
for (let z = fr.iz0; z <= fr.iz1; z++) {
|
||||||
|
const a = pick(0, z, y); if (a >= 0) setVox(w, fr.ix0, y, z, a);
|
||||||
|
const b = pick(1, z, y); if (b >= 0) setVox(w, fr.ix1, y, z, b);
|
||||||
|
}
|
||||||
|
for (let x = fr.ix0; x <= fr.ix1; x++) {
|
||||||
|
const c = pick(2, x, y); if (c >= 0) setVox(w, x, y, fr.iz0, c);
|
||||||
|
const d = pick(3, x, y); if (d >= 0) setVox(w, x, y, fr.iz1, d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 3×3 rca_gold exit pad flush in the floor lining + one accent led pip (centre). */
|
||||||
|
export function buildExitPad(w: IVoxelWorld, d: WorldDef): void {
|
||||||
|
const [ex, ey, ez] = d.exitPad;
|
||||||
|
for (let dx = -1; dx <= 1; dx++)
|
||||||
|
for (let dz = -1; dz <= 1; dz++)
|
||||||
|
setVox(w, ex + dx, ey, ez + dz, P.gold);
|
||||||
|
setVox(w, ex, ey, ez, ACCENT_LED[d.key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flush 1-voxel inlay path along Manhattan segments through `pts` at height y. */
|
||||||
|
export function inlayPath(w: IVoxelWorld, pts: Array<[number, number]>, y: number, id: number): void {
|
||||||
|
if (pts.length === 0) return;
|
||||||
|
setVox(w, pts[0][0], y, pts[0][1], id);
|
||||||
|
for (let i = 1; i < pts.length; i++) {
|
||||||
|
let [ax, az] = pts[i - 1];
|
||||||
|
const [bx, bz] = pts[i];
|
||||||
|
while (ax !== bx) { ax += Math.sign(bx - ax); setVox(w, ax, y, az, id); }
|
||||||
|
while (az !== bz) { az += Math.sign(bz - az); setVox(w, ax, y, az, id); }
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/worldgen/worlds/disco.ts
Normal file
74
src/worldgen/worlds/disco.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
// TURNCRAFT — SIDE B Lane W: DISCO LOFT (pocket x 364..428, z 20..84).
|
||||||
|
// Warm loft: plywood/ply_edge herringbone parquet, plywood walls with a
|
||||||
|
// ply_edge skirting course and vertical led_red/led_blue wall-wash strips, a
|
||||||
|
// chrome+glass mirrorball hanging over the dance floor, and the 4×4 grid of
|
||||||
|
// 5×5 flush dance tiles (Lane M's step-sequencer quest: their colliders detect
|
||||||
|
// standing-on-tile; Lane S flashes them on 'disco_cue').
|
||||||
|
// Tile grid per brief "centred on [396, 143, 52..76]": tile CENTRES at
|
||||||
|
// x 396±4/±12 and z 52/60/68/76 (pitch 8 — a 3-voxel parquet gap between
|
||||||
|
// tiles), all flush at y 143. Fully index-patterned — no rng.
|
||||||
|
|
||||||
|
import type { IVoxelWorld } from '../../core/types';
|
||||||
|
import { WORLD_DEFS } from '../../core/worlds';
|
||||||
|
import { setVox } from '../tools';
|
||||||
|
import { buildExitPad, buildSkin, frame, P, paintFloor, paintWalls } from './common';
|
||||||
|
|
||||||
|
/** Dance-tile centres (16 tiles, each 5×5 flush at y 143). */
|
||||||
|
export const DISCO_TILE_CX = [-12, -4, 4, 12].map((o) => WORLD_DEFS.disco.entry[0] + o); // 384,392,400,408
|
||||||
|
export const DISCO_TILE_CZ = [52, 60, 68, 76] as const;
|
||||||
|
export const DISCO_TILE_Y = 143;
|
||||||
|
/** Mirrorball centre (chrome/glass checker sphere r3, chain to the roof). */
|
||||||
|
export const DISCO_BALL = { cx: WORLD_DEFS.disco.entry[0], cy: 152, cz: 64 } as const;
|
||||||
|
|
||||||
|
export function buildDisco(w: IVoxelWorld): void {
|
||||||
|
const d = WORLD_DEFS.disco;
|
||||||
|
const fr = frame(d);
|
||||||
|
buildSkin(w, fr);
|
||||||
|
|
||||||
|
// Herringbone parquet: two-wide diagonal weave of plywood / ply_edge.
|
||||||
|
paintFloor(w, fr, (x, z) => (((x + z) & 2) ^ ((x - z + 256) & 2)) ? P.ply : P.plyEdge);
|
||||||
|
|
||||||
|
// Plywood walls with a ply_edge skirting course at floor+1.
|
||||||
|
paintWalls(w, fr, (_face, _u, y) => (y === fr.floorY + 1 ? P.plyEdge : P.ply));
|
||||||
|
|
||||||
|
// Vertical wall-wash strips (y 146..151), alternating red/blue around the room.
|
||||||
|
const strip = (x: number, z: number, idx: number) => {
|
||||||
|
const id = (idx & 1) ? P.ledB : P.ledR;
|
||||||
|
for (let y = 146; y <= 151; y++) setVox(w, x, y, z, id);
|
||||||
|
};
|
||||||
|
([28, 42, 56, 70] as const).forEach((z, i) => { strip(fr.ix0, z, i); strip(fr.ix1, z, i + 1); });
|
||||||
|
([376, 386, 406, 416] as const).forEach((x, i) => { strip(x, fr.iz0, i + 1); strip(x, fr.iz1, i); });
|
||||||
|
|
||||||
|
// Dance tiles: border ring alternates label_cream/rubber per tile (checker),
|
||||||
|
// field is the opposite, centre voxel is a led (red/blue checker) Lane S lights.
|
||||||
|
for (let i = 0; i < DISCO_TILE_CX.length; i++) {
|
||||||
|
for (let j = 0; j < DISCO_TILE_CZ.length; j++) {
|
||||||
|
const cx = DISCO_TILE_CX[i], cz = DISCO_TILE_CZ[j];
|
||||||
|
const chk = (i + j) & 1;
|
||||||
|
const border = chk ? P.rubber : P.label;
|
||||||
|
const field = chk ? P.label : P.rubber;
|
||||||
|
for (let dx = -2; dx <= 2; dx++) {
|
||||||
|
for (let dz = -2; dz <= 2; dz++) {
|
||||||
|
const edge = Math.abs(dx) === 2 || Math.abs(dz) === 2;
|
||||||
|
setVox(w, cx + dx, DISCO_TILE_Y, cz + dz, edge ? border : field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setVox(w, cx, DISCO_TILE_Y, cz, chk ? P.ledR : P.ledB);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrorball: r-3 chrome/glass checker sphere on a 2-voxel chrome chain.
|
||||||
|
const { cx, cy, cz } = DISCO_BALL;
|
||||||
|
for (let dx = -3; dx <= 3; dx++) {
|
||||||
|
for (let dy = -3; dy <= 3; dy++) {
|
||||||
|
for (let dz = -3; dz <= 3; dz++) {
|
||||||
|
if (dx * dx + dy * dy + dz * dz > 10) continue;
|
||||||
|
setVox(w, cx + dx, cy + dy, cz + dz, ((dx + dy + dz) & 1) ? P.chrome : P.glass);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setVox(w, cx, 156, cz, P.chrome);
|
||||||
|
setVox(w, cx, 157, cz, P.chrome);
|
||||||
|
|
||||||
|
buildExitPad(w, d);
|
||||||
|
}
|
||||||
98
src/worldgen/worlds/dub.ts
Normal file
98
src/worldgen/worlds/dub.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
// TURNCRAFT — SIDE B Lane W: DUB CHAMBER (pocket x 192..256, z 20..84).
|
||||||
|
// Deep space inside a spring-reverb tank. vinyl_black everywhere with sparse
|
||||||
|
// led_amber embers; two raised side shelves make the central x 210..238 band a
|
||||||
|
// sunken canyon ("floor drops 4": shelf top face 148 → canyon floor 144); in
|
||||||
|
// the canyon's middle a tank pit is cut to the shell (surface 143) holding two
|
||||||
|
// chrome reverb-spring coils. The static spring-tank bridge — chrome, 3 wide,
|
||||||
|
// 24 long along z at x 223..225, deck y 146 (walk surface 147) — crosses the
|
||||||
|
// pit rim-to-rim with 1-voxel stairs at both ends; Lane M overlays its wobbling
|
||||||
|
// kinematic deck on top. The tank mouth is the rca_gold inlay mid-deck. The
|
||||||
|
// horn-speaker cone sits on the far wall EAST of the exit axis (x 232) so
|
||||||
|
// reaching it never forces a walk across the exit pad.
|
||||||
|
|
||||||
|
import type { IVoxelWorld, Vec3 } from '../../core/types';
|
||||||
|
import { WORLD_DEFS } from '../../core/worlds';
|
||||||
|
import { box, mulberry32, randInt, setVox } from '../tools';
|
||||||
|
import { buildExitPad, buildSkin, frame, P, paintFloor, paintWalls } from './common';
|
||||||
|
|
||||||
|
/** Static bridge deck (Lane M overlays the moving collider on this). */
|
||||||
|
export const DUB_BRIDGE = {
|
||||||
|
x0: 223, x1: 225, deckY: 146, z0: 40, z1: 63,
|
||||||
|
/** rca_gold inlay in the deck — the "feed the spring" drop spot. */
|
||||||
|
mouth: [224, 146, 52] as Vec3,
|
||||||
|
} as const;
|
||||||
|
/** Tank pit (floor carved to the shell, walk surface 143). */
|
||||||
|
export const DUB_PIT = { x0: 212, x1: 236, z0: 41, z1: 62 } as const;
|
||||||
|
/** Horn cone centre on the back wall (throat at z 83, mouth flaring to z 80). */
|
||||||
|
export const DUB_HORN = { cx: 232, cy: 151, throatZ: 83, mouthZ: 80 } as const;
|
||||||
|
|
||||||
|
export function buildDub(w: IVoxelWorld, seed: number): void {
|
||||||
|
const rng = mulberry32((seed ^ 0x0d0b) >>> 0);
|
||||||
|
const d = WORLD_DEFS.dub;
|
||||||
|
const fr = frame(d);
|
||||||
|
buildSkin(w, fr);
|
||||||
|
|
||||||
|
// Floor + walls: vinyl_black (the grooves pattern reads as pressed wax).
|
||||||
|
paintFloor(w, fr, () => P.vinyl);
|
||||||
|
// Sparse amber embers glowing in the wall lining (~30 across the room).
|
||||||
|
paintWalls(w, fr, () => (rng() < 0.008 ? P.ledA : P.vinyl));
|
||||||
|
|
||||||
|
// Raised side shelves: canyon walls for the x 210..238 sunken band.
|
||||||
|
box(w, fr.ix0, fr.floorY + 1, fr.iz0, 209, fr.floorY + 4, fr.iz1, P.vinyl);
|
||||||
|
box(w, 239, fr.floorY + 1, fr.iz0, fr.ix1, fr.floorY + 4, fr.iz1, P.vinyl);
|
||||||
|
// A few embers set into the canyon-facing riser faces.
|
||||||
|
for (const rx of [209, 239]) {
|
||||||
|
for (let z = fr.iz0 + 2; z <= fr.iz1 - 2; z++) {
|
||||||
|
if (rng() < 0.09) setVox(w, rx, fr.floorY + 1 + randInt(rng, 0, 3), z, P.ledA);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Speaker stacks up on the shelves (soundsystem silhouettes against the dark).
|
||||||
|
for (const sx of [201, 247]) {
|
||||||
|
box(w, sx - 1, fr.floorY + 5, 34, sx + 1, fr.floorY + 7, 36, P.mesh);
|
||||||
|
box(w, sx - 1, fr.floorY + 5, 66, sx + 1, fr.floorY + 7, 68, P.mesh);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tank pit: carve the floor lining to the black shell (walk surface 143 —
|
||||||
|
// one step below the canyon, four below the bridge deck; always escapable).
|
||||||
|
box(w, DUB_PIT.x0, fr.floorY, DUB_PIT.z0, DUB_PIT.x1, fr.floorY, DUB_PIT.z1, P.air);
|
||||||
|
// Two chrome reverb-spring coils lying along the pit.
|
||||||
|
for (const [ax, phase] of [[217, 0], [231, 1.7]] as const) {
|
||||||
|
for (let s = 0; s <= 36; s++) {
|
||||||
|
const a = s * 0.55 + phase;
|
||||||
|
const z = DUB_PIT.z0 + 2 + ((s * 0.5) | 0);
|
||||||
|
setVox(w, ax + Math.round(2 * Math.cos(a)), 145 + Math.round(2 * Math.sin(a)), z, P.chrome);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Gold catch pad on the pit floor directly under the mouth.
|
||||||
|
box(w, 223, fr.floorY, 51, 225, fr.floorY, 53, P.gold);
|
||||||
|
|
||||||
|
// The bridge: stairs up (1-voxel steps), legs seating the deck on both pit
|
||||||
|
// rims, the 3×24 chrome deck, and the rca_gold tank-mouth inlay mid-span.
|
||||||
|
box(w, 223, 144, 37, 225, 144, 37, P.chrome);
|
||||||
|
box(w, 223, 144, 38, 225, 145, 38, P.chrome);
|
||||||
|
box(w, 223, 144, 39, 225, 146, 39, P.chrome);
|
||||||
|
box(w, DUB_BRIDGE.x0, 144, DUB_BRIDGE.z0, DUB_BRIDGE.x1, 145, DUB_BRIDGE.z0, P.chrome);
|
||||||
|
box(w, DUB_BRIDGE.x0, 144, DUB_BRIDGE.z1, DUB_BRIDGE.x1, 145, DUB_BRIDGE.z1, P.chrome);
|
||||||
|
box(w, DUB_BRIDGE.x0, DUB_BRIDGE.deckY, DUB_BRIDGE.z0, DUB_BRIDGE.x1, DUB_BRIDGE.deckY, DUB_BRIDGE.z1, P.chrome);
|
||||||
|
setVox(w, DUB_BRIDGE.mouth[0], DUB_BRIDGE.mouth[1], DUB_BRIDGE.mouth[2], P.gold);
|
||||||
|
box(w, 223, 144, 64, 225, 145, 64, P.chrome);
|
||||||
|
box(w, 223, 144, 65, 225, 144, 65, P.chrome);
|
||||||
|
|
||||||
|
// Horn-speaker cone: stacked speaker_mesh rings telescoping out of the back
|
||||||
|
// wall, throat plate flush in the lining, amber ember glowing in the throat.
|
||||||
|
const ring = (z: number, rIn: number, rOut: number) => {
|
||||||
|
for (let dx = -7; dx <= 7; dx++) {
|
||||||
|
for (let dy = -7; dy <= 7; dy++) {
|
||||||
|
const dist = Math.hypot(dx, dy);
|
||||||
|
if (dist >= rIn && dist <= rOut) setVox(w, DUB_HORN.cx + dx, DUB_HORN.cy + dy, z, P.mesh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ring(DUB_HORN.throatZ, 0, 3); // solid throat plate in the wall lining
|
||||||
|
ring(82, 2.5, 3.9);
|
||||||
|
ring(81, 3.4, 4.9);
|
||||||
|
ring(DUB_HORN.mouthZ, 4.4, 6.0); // mouth bottom y 145 — floats over the floor
|
||||||
|
setVox(w, DUB_HORN.cx, DUB_HORN.cy, 82, P.ledA);
|
||||||
|
|
||||||
|
buildExitPad(w, d);
|
||||||
|
}
|
||||||
36
src/worldgen/worlds/index.ts
Normal file
36
src/worldgen/worlds/index.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
// TURNCRAFT — SIDE B Lane W: the crate worlds (docs/briefs/SIDEB_WORLDS.md).
|
||||||
|
// buildWorlds(w, seed) is called ONCE from buildBooth, after every booth zone,
|
||||||
|
// and builds: the portal dressing at the crate (W1) and the three fully-shelled
|
||||||
|
// pocket rooms at the WORLD_DEFS bounds (W2) — ACID WAREHOUSE, DUB CHAMBER,
|
||||||
|
// DISCO LOFT — plus a 3×3 rca_gold exit pad in each.
|
||||||
|
//
|
||||||
|
// Seed discipline (same pattern as buildPcbDetail): every builder that wants
|
||||||
|
// randomness derives its OWN mulberry32 stream from the seed passed in, so the
|
||||||
|
// main buildBooth PRNG stream is never consumed and every pre-existing booth
|
||||||
|
// zone stays byte-identical. Math.random appears nowhere.
|
||||||
|
//
|
||||||
|
// Sealed-room law: pockets sit on the booth ceiling slab (y 140..141) in the
|
||||||
|
// dark above the walls. Their 1-voxel matte_black skin is never pierced and no
|
||||||
|
// emissive/transparent block ever touches it — from the booth (or the cine
|
||||||
|
// flythrough) they read as, at most, faint black slabs. Entry/exit is by
|
||||||
|
// teleport only (Lane M portals).
|
||||||
|
|
||||||
|
import type { IVoxelWorld } from '../../core/types';
|
||||||
|
import { buildPortalDressing } from './portalDressing';
|
||||||
|
import { buildAcid } from './acid';
|
||||||
|
import { buildDub } from './dub';
|
||||||
|
import { buildDisco } from './disco';
|
||||||
|
|
||||||
|
export function buildWorlds(w: IVoxelWorld, seed: number): void {
|
||||||
|
buildPortalDressing(w);
|
||||||
|
buildAcid(w);
|
||||||
|
buildDub(w, seed);
|
||||||
|
buildDisco(w);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feature anchors other lanes / the demo assert against (positions only —
|
||||||
|
// no lane imports our builders).
|
||||||
|
export { ACID_PEDESTAL_X, ACID_PEDESTAL_Z, ACID_PEDESTAL_TOP_Y } from './acid';
|
||||||
|
export { DUB_BRIDGE, DUB_PIT, DUB_HORN } from './dub';
|
||||||
|
export { DISCO_TILE_CX, DISCO_TILE_CZ, DISCO_TILE_Y, DISCO_BALL } from './disco';
|
||||||
|
export { ACCENT_LED } from './common';
|
||||||
39
src/worldgen/worlds/portalDressing.ts
Normal file
39
src/worldgen/worlds/portalDressing.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
// TURNCRAFT — SIDE B Lane W (W1): portal dressing at the record crate.
|
||||||
|
// The three portal records already stand in the crate (buildUnderTable places
|
||||||
|
// slabs at x = 26+5s, cy 15, cz 31, r 12 — WORLD_DEFS.portalDisc points at the
|
||||||
|
// s = 1 / 4 / 7 slabs). We add the "these three are special" tell, kept subtle
|
||||||
|
// so the crate stays dim:
|
||||||
|
// • a flush 1-voxel accent-led ring inlaid in the crate floor around each
|
||||||
|
// portalStand (radius 3 — matches Lane S's bunny keep-out of stand ±6);
|
||||||
|
// • a 3-voxel accent-led marquee riding each portal disc's top rim.
|
||||||
|
// No geometry moves; every write replaces crate-floor plywood or sits proud on
|
||||||
|
// existing disc voxels. Deterministic with zero rng.
|
||||||
|
|
||||||
|
import type { IVoxelWorld } from '../../core/types';
|
||||||
|
import { WORLD_DEFS, WORLD_KEYS } from '../../core/worlds';
|
||||||
|
import { setVox } from '../tools';
|
||||||
|
import { ACCENT_LED } from './common';
|
||||||
|
|
||||||
|
export function buildPortalDressing(w: IVoxelWorld): void {
|
||||||
|
for (const k of WORLD_KEYS) {
|
||||||
|
const d = WORLD_DEFS[k];
|
||||||
|
const led = ACCENT_LED[k];
|
||||||
|
|
||||||
|
// Ring inlay: portalStand is the feet voxel; the crate floor block is one
|
||||||
|
// below. d2 in [7,11] traces a clean 1-wide radius-3 circle (16 voxels).
|
||||||
|
const [sx, sy, sz] = d.portalStand;
|
||||||
|
for (let dx = -3; dx <= 3; dx++) {
|
||||||
|
for (let dz = -3; dz <= 3; dz++) {
|
||||||
|
const d2 = dx * dx + dz * dz;
|
||||||
|
if (d2 >= 7 && d2 <= 11) setVox(w, sx + dx, sy - 1, sz + dz, led);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marquee: the disc's crown voxel is at (px, cy+12, cz); its top surface at
|
||||||
|
// dz ±4 is dy 11. Three pips arc over the rim, each resting on disc vinyl.
|
||||||
|
const [px, py, pz] = d.portalDisc;
|
||||||
|
setVox(w, px, py + 13, pz, led);
|
||||||
|
setVox(w, px, py + 12, pz - 4, led);
|
||||||
|
setVox(w, px, py + 12, pz + 4, led);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user