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>
96 lines
3.7 KiB
TypeScript
96 lines
3.7 KiB
TypeScript
// 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;
|
|
}
|