Lane B (Streetscape): round-11 audio — WebAudio engine (street side)
- web/js/world/audio.js: one AudioContext unlocked on first gesture; day/night ambience crossfade, rain (weather.intensity), footsteps, shop-door music spill (nearest open music-shop, distance-gain), tram rumble+bell, enterShop SFX. Consumes Lane E manifest.audio (3x retry). Self-ticks off window.PROCITY.
- House audio law verified live: silent pre-gesture (0 fetches), ?mute=1 silent surface (0 fetches), ?noassets=1 zero audio/manifest fetches, lazy-load (8.7MB<=25MB), no per-frame alloc.
- window.PROCITY.audio for Lane F: playInterior({musicKey,toneKey})/stopInterior (street beds duck while inside), playSfx, setMasterGain, mute, state diag. Confirms Lane C room.audio={musicKey,toneKey} contract.
- Small guarded [Lane B R11 audio] loader in index.html.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
89f278b603
commit
f7dd44f55a
@ -89,6 +89,25 @@ use-if-ready furniture path (fail-soft; `?noassets` → primitives, 0 network).
|
||||
(same as the ped rigs).** File: `furniture.js`.
|
||||
|
||||
|
||||
## Round 11 (AUDIO) — Lane B WebAudio engine — done
|
||||
Built **`web/js/world/audio.js`** (B-owned) + a guarded create-call in the shell. One `AudioContext`
|
||||
unlocked on the first gesture; self-contained (reads `window.PROCITY`, self-ticks); consumes Lane E's
|
||||
`manifest.audio` (23 assets, 8.7 MB, ≤25 MB) with a 3× fetch retry.
|
||||
- **Layers** (crossfaded, no per-frame alloc): day/night ambience (lighting segment), rain
|
||||
(`weather.intensity`), footsteps (player movement), **shop-door music spill** (nearest open
|
||||
music-shop, distance-gain — verified `video-synth` at ~0.08 by Video Barn's door), tram rumble+bell
|
||||
(`?tram`), SFX (doorbell/door-open on `procity:enterShop`).
|
||||
- **House audio law — all verified live:** boots silent (nothing plays/fetches pre-gesture, 0 console
|
||||
errors); `?mute=1` → silent surface (exists for smokes, 0 fetches); `?noassets=1` → 0 audio/manifest
|
||||
fetches; beds lazy-load; spill is a pure function of shop type.
|
||||
- **For Lane F:** `window.PROCITY.audio` exposes `playInterior({musicKey,toneKey})`/`stopInterior()`
|
||||
(street beds auto-duck while inside), `playSfx`, `setMasterGain`, `mute`, and a `state` diag surface.
|
||||
- **For Lane C:** confirmed `room.audio = { musicKey, toneKey }` on `buildInterior` is the right shape
|
||||
(musicKey → `manifest.music`, toneKey → `manifest.ambience`).
|
||||
- Gates green (manifest validator 0 errors incl. audio 23; selfcheck 1727/1727). `qa.sh --strict`
|
||||
hangs on a headless Playwright gate — ran the static gates directly. Files: `web/js/world/audio.js`
|
||||
(new) + a small `[Lane B R11 audio]` loader in `web/index.html` + these docs.
|
||||
|
||||
## Round 3 (Fable's ROUND3_INSTRUCTIONS → Lane B) — done
|
||||
- **Task 1 (draw budget, re-measured WITH citizens).** Radius-2 auto is already the spawn default;
|
||||
no config change needed. Drove the real loop (`chunks.update` + `citizens.update`, pop 140, up to
|
||||
|
||||
@ -324,6 +324,40 @@ smoothed value (0 under a throttled/background tab; populates in a foreground Pl
|
||||
for spawning/ambient/LOD. Inert by default (the shell doesn't set them; **Lane D's citizens drive off
|
||||
`plan.streets` instead of chunk lifecycle**, so this is a spare hook, not a dependency).
|
||||
|
||||
## WebAudio engine — `audio.js` (round 11, the audio round)
|
||||
|
||||
`web/js/world/audio.js` — one `AudioContext`, unlocked on the first user gesture. Self-contained: it
|
||||
reads live state off `window.PROCITY` and self-ticks (its own rAF), so the shell only calls
|
||||
`createAudioEngine(window.PROCITY, { noassets })` once (after `window.PROCITY` is assigned) and stores
|
||||
it at `window.PROCITY.audio`. Consumes Lane E's `manifest.audio` (self-fetched, **3× retry** so a
|
||||
dropped boot-burst fetch doesn't mute the session).
|
||||
|
||||
Layers, all crossfaded off systems that already exist (gains ramp; the update path allocates nothing):
|
||||
- **Ambience** — `street-day` / `street-night` beds crossfade on `lighting.getClock().night`; **rain**
|
||||
bed gain follows `PROCITY.weather.intensity` (silent unless it's actually raining, i.e. `?weather`).
|
||||
- **Footsteps** — distance-accumulated from `player.position` while pointer-locked (a step per stride).
|
||||
- **Shop-door spill** — nearest *open* music shop (record/milkbar/video/arcade/dept) within ~9 m leaks
|
||||
its interior music bed at low distance-gain (scan throttled to 5 Hz over live door meshes; honours
|
||||
`PROCITY.isOpen`). Verified: 6 m from "Video Barn" → `video-synth` at gain ~0.08.
|
||||
- **Tram** — rumble gain by camera↔tram distance (`scene.getObjectByName('tram')`); bell rings once as
|
||||
it settles at a nearby stop. Conditional on `?tram`.
|
||||
- **SFX** — `procity:enterShop` → doorbell + door-open; `playSfx(key)` for the rest (till/riffle/toast).
|
||||
|
||||
**House audio law — all verified in-browser:** silent-and-happy (missing/failed audio → silence, no
|
||||
errors); **nothing plays or fetches before the first gesture**; `?mute=1` → a silent surface (exists
|
||||
for smokes, never unlocks, 0 fetches); `?noassets=1` → live but **0 audio/manifest fetches**; beds
|
||||
lazy-load (≤25 MB pack, 8.7 MB shipped); spill/music selection is a pure function of shop type.
|
||||
|
||||
### Surface (`window.PROCITY.audio`, for Lane F's smokes + interior wiring)
|
||||
- `setMasterGain(0..1)`, `mute()`, `unmute()`, `get ready`, `get muted`.
|
||||
- `playSfx(key, {gain})`, `footstep(surface)`.
|
||||
- **`playInterior({ musicKey, toneKey })` / `stopInterior()`** — Lane F calls these from
|
||||
`interior_mode` on enter/exit, passing **Lane C's `room.audio` contract** (a `musicKey` into
|
||||
`manifest.music`, a `toneKey` into `manifest.ambience`). The street beds auto-duck to 0 while
|
||||
`mode !== 'street'`. Confirmed working: `playInterior` fades the room's music+tone in, `stopInterior`
|
||||
fades them out. **→ Lane C: `room.audio = { musicKey, toneKey }` on `buildInterior` is the right shape.**
|
||||
- `state` — `{ ready, muted, mode, manifest, loaded, layers, nearestSpill }` diagnostics for smokes.
|
||||
|
||||
## Controls
|
||||
WASD move · shift run · mouse look · click a door · `[` `]` step time-of-day · `T` pause clock ·
|
||||
`M` map · `P` screenshot · Esc release pointer.
|
||||
|
||||
@ -338,6 +338,13 @@ window.PROCITY = { plan, scene, camera, renderer, chunks, lighting, player, skin
|
||||
weather: weatherState, // [Lane F R8] Lane B contract: {state,intensity}, {clear,0} when off (Lane D reads this)
|
||||
wallet, // [Lane F R8] Lane C buy loop v0 (session wallet: cash/buy/count)
|
||||
THREE, get mode() { return MODE; } }; // [Lane F] bridge + drive hooks
|
||||
// [Lane B R11 audio] street WebAudio engine — self-unlocking on the first gesture; silent with
|
||||
// zero/blocked assets or ?mute=1; ?noassets=1 ⇒ no audio fetches. Exposes window.PROCITY.audio so
|
||||
// Lane F can play interior beds through it (playInterior/stopInterior via Lane C's room.audio).
|
||||
import('./js/world/audio.js')
|
||||
.then((m) => { window.PROCITY.audio = m.createAudioEngine(window.PROCITY, { noassets: NOASSETS }); })
|
||||
.catch((e) => console.warn('[procity] audio engine load failed:', e));
|
||||
|
||||
// [Lane B] QA debug harness (window.DBG) — loaded only with ?dbg=1 (LANE_F_NOTES §4). Logic lives
|
||||
// in js/world/dbg.js; the shell just hands it the systems it owns so shots.py/soak.py can drive it.
|
||||
if (params.get('dbg')) {
|
||||
|
||||
280
web/js/world/audio.js
Normal file
280
web/js/world/audio.js
Normal file
@ -0,0 +1,280 @@
|
||||
// PROCITY Lane B — audio.js (round 11, the audio round)
|
||||
// The street-side WebAudio engine. ONE AudioContext, unlocked on the first user gesture (the
|
||||
// pointer-lock click is the natural anchor). Looping beds crossfade off systems that already exist —
|
||||
// day/night segment, weather, tram proximity, player footsteps — plus shop-door music **spill** and
|
||||
// interaction SFX. Interior beds belong to Lane F (it calls playInterior/stopInterior with Lane C's
|
||||
// room.audio contract); this engine just ducks the street while inside.
|
||||
//
|
||||
// HOUSE AUDIO LAW (obeyed throughout):
|
||||
// 1. Silent-and-happy: missing/blocked/failed audio → silence, never an error or console warning.
|
||||
// 2. Nothing plays before the first gesture; ?mute=1 forces silence; ?noassets=1 → zero fetches.
|
||||
// 3. Deterministic flavour: what a shop spills is a pure function of its type (seeded upstream).
|
||||
// 4. ≤25 MB, lazy-loaded; the per-frame update() path allocates nothing (only ramps gains).
|
||||
//
|
||||
// Self-contained: reads live state off window.PROCITY and self-ticks via rAF, so the shell only has
|
||||
// to createAudioEngine(PROCITY) once. Exposes window.PROCITY.audio for Lane F's smokes.
|
||||
|
||||
const BASE = 'assets/';
|
||||
|
||||
// shop type → street-spill music key (manifest.music). Only these types leak a bed to the footpath;
|
||||
// others are interior room-tone only. Matches manifest.music[*].types.
|
||||
const SPILL_MUSIC = { record: 'record-shop', milkbar: 'milkbar', video: 'video-synth', arcade: 'arcade', dept: 'arcade', general: 'milkbar' };
|
||||
|
||||
export function createAudioEngine(PROCITY, { noassets = false, mute = false } = {}) {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const forcedMute = mute || (params.get('mute') != null && params.get('mute') !== '0');
|
||||
const NOASSETS = noassets || PROCITY.noassets || (params.get('noassets') != null && params.get('noassets') !== '0');
|
||||
const AC = window.AudioContext || window.webkitAudioContext;
|
||||
|
||||
const state = { ready: false, muted: !!forcedMute, unsupported: !AC, loaded: 0, mode: 'silent', nearestSpill: null };
|
||||
|
||||
// Silent surface — still satisfies the public API so F's smokes pass with zero sound.
|
||||
if (!AC || forcedMute) {
|
||||
state.mode = forcedMute ? 'muted' : 'unsupported';
|
||||
return {
|
||||
state, get ready() { return false; }, get muted() { return true; },
|
||||
setMasterGain() {}, mute() { state.muted = true; }, unmute() {},
|
||||
playSfx() {}, footstep() {}, playInterior() {}, stopInterior() {},
|
||||
update() {}, dispose() {},
|
||||
};
|
||||
}
|
||||
|
||||
let ctx, master, buses;
|
||||
try {
|
||||
ctx = new AC();
|
||||
master = ctx.createGain(); master.gain.value = 0.9; master.connect(ctx.destination);
|
||||
buses = { ambience: mk(0.5), music: mk(0.7), sfx: mk(0.95), tram: mk(0.7) };
|
||||
for (const b of Object.values(buses)) b.connect(master);
|
||||
} catch { state.mode = 'ctx-failed'; return { state, get ready() { return false; }, get muted() { return true; }, setMasterGain() {}, mute() {}, unmute() {}, playSfx() {}, footstep() {}, playInterior() {}, stopInterior() {}, update() {}, dispose() {} }; }
|
||||
state.mode = 'live';
|
||||
function mk(v) { const g = ctx.createGain(); g.gain.value = v; return g; }
|
||||
function ramp(param, to, sec) {
|
||||
const t = ctx.currentTime;
|
||||
param.cancelScheduledValues(t); param.setValueAtTime(param.value, t);
|
||||
param.linearRampToValueAtTime(to, t + Math.max(0.02, sec));
|
||||
}
|
||||
|
||||
// ── manifest.audio (self-fetched once; honours ?noassets) ──
|
||||
// Retries a few times: the single-threaded dev server can drop a fetch during the boot burst, and a
|
||||
// one-shot failure would otherwise mute the town for the whole session. state.manifest is a diag.
|
||||
let AUDIO = null;
|
||||
const manifestReady = (async () => {
|
||||
if (NOASSETS) { state.manifest = 'skipped-noassets'; return null; }
|
||||
if (PROCITY.manifest?.audio) { AUDIO = PROCITY.manifest.audio; state.manifest = 'ok'; return AUDIO; }
|
||||
for (let attempt = 0; attempt < 3 && !AUDIO; attempt++) {
|
||||
try {
|
||||
const r = await fetch(BASE + 'manifest.json', { cache: 'force-cache' });
|
||||
if (r.ok) { AUDIO = (await r.json()).audio || null; }
|
||||
} catch { /* transient — back off and retry */ }
|
||||
if (!AUDIO) await new Promise((res) => setTimeout(res, 150 * (attempt + 1)));
|
||||
}
|
||||
state.manifest = AUDIO ? 'ok' : 'null-audio'; // silent-and-happy either way
|
||||
return AUDIO;
|
||||
})();
|
||||
|
||||
// ── lazy buffer cache ──
|
||||
const buffers = new Map(); // key → AudioBuffer | 'loading' | 'failed'
|
||||
async function load(key, entry) {
|
||||
if (NOASSETS || !entry) return null;
|
||||
const cached = buffers.get(key);
|
||||
if (cached) return (cached === 'loading' || cached === 'failed') ? null : cached;
|
||||
buffers.set(key, 'loading');
|
||||
for (const url of [entry.file, entry.fallback]) {
|
||||
if (!url) continue;
|
||||
try {
|
||||
const res = await fetch(BASE + url);
|
||||
if (!res.ok) continue;
|
||||
const buf = await ctx.decodeAudioData(await res.arrayBuffer());
|
||||
buffers.set(key, buf); state.loaded++;
|
||||
return buf;
|
||||
} catch { /* try the fallback, else fall through to silence */ }
|
||||
}
|
||||
buffers.set(key, 'failed'); // house law: a missing file is silence, not an error
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── layers ──
|
||||
// persistent bed: one looping source started once, gain-ramped (day/night/rain never change key).
|
||||
// swap bed: source is replaced when its key changes (spill / interior music+tone).
|
||||
function layer(bus) { const g = ctx.createGain(); g.gain.value = 0; g.connect(bus); return { g, src: null, key: null, starting: false }; }
|
||||
const L = {
|
||||
day: layer(buses.ambience), night: layer(buses.ambience), rain: layer(buses.ambience),
|
||||
spill: layer(buses.music), tram: layer(buses.tram),
|
||||
iMusic: layer(buses.music), iTone: layer(buses.ambience),
|
||||
};
|
||||
state.layers = L;
|
||||
|
||||
async function persistBed(l, key, entry, target, fade = 1.4) {
|
||||
if (key && !l.src && !l.starting) {
|
||||
l.starting = true;
|
||||
const buf = await load(key, entry);
|
||||
if (buf) { const s = ctx.createBufferSource(); s.buffer = buf; s.loop = true; s.connect(l.g); s.start(); l.src = s; l.key = key; }
|
||||
l.starting = false;
|
||||
}
|
||||
ramp(l.g.gain, l.src ? target : 0, fade);
|
||||
}
|
||||
async function swapBed(l, key, entry, target, fade = 0.8) {
|
||||
if (l.key !== key) {
|
||||
if (l.src) { const old = l.src; l.src = null; try { old.stop(ctx.currentTime + 0.35); } catch {} }
|
||||
l.key = key;
|
||||
if (key && entry) {
|
||||
l.starting = true;
|
||||
const buf = await load('bed:' + key, entry);
|
||||
if (buf && l.key === key) { const s = ctx.createBufferSource(); s.buffer = buf; s.loop = true; s.connect(l.g); s.start(); l.src = s; }
|
||||
l.starting = false;
|
||||
}
|
||||
}
|
||||
ramp(l.g.gain, (l.key && l.src) ? target : 0, fade);
|
||||
}
|
||||
|
||||
// ── one-shot SFX ──
|
||||
async function playSfx(key, { gain = 1 } = {}) {
|
||||
if (state.muted || NOASSETS || !state.ready) return;
|
||||
await manifestReady;
|
||||
const entry = AUDIO?.sfx?.[key]; if (!entry) return;
|
||||
const buf = await load('sfx:' + key, entry); if (!buf) return;
|
||||
fire(buf, (entry.gain ?? 0.7) * gain, buses.sfx);
|
||||
}
|
||||
async function footstep(surface = 'pavement') {
|
||||
if (state.muted || NOASSETS || !state.ready) return;
|
||||
await manifestReady;
|
||||
const arr = AUDIO?.sfx?.footstep?.[surface] || AUDIO?.sfx?.footstep?.pavement;
|
||||
if (!arr || !arr.length) return;
|
||||
const i = Math.floor(Math.random() * arr.length); // transient variant — determinism law is about shop content, not step timing
|
||||
const buf = await load(`step:${surface}:${i}`, arr[i]); if (!buf) return;
|
||||
fire(buf, 0.5 + Math.random() * 0.15, buses.sfx);
|
||||
}
|
||||
function fire(buf, gain, bus) {
|
||||
const src = ctx.createBufferSource(); src.buffer = buf;
|
||||
const vg = ctx.createGain(); vg.gain.value = gain;
|
||||
src.connect(vg); vg.connect(bus); src.start();
|
||||
src.onended = () => { try { src.disconnect(); vg.disconnect(); } catch {} };
|
||||
}
|
||||
|
||||
// ── interior beds (Lane F calls these with Lane C's room.audio = {musicKey, toneKey}) ──
|
||||
async function playInterior(spec) {
|
||||
if (state.muted || NOASSETS || !spec) return;
|
||||
await manifestReady;
|
||||
const mEntry = spec.musicKey && AUDIO?.music?.[spec.musicKey];
|
||||
const tEntry = spec.toneKey && AUDIO?.ambience?.[spec.toneKey];
|
||||
swapBed(L.iMusic, spec.musicKey || null, mEntry || null, mEntry?.gain ?? 0.5, 1.0);
|
||||
swapBed(L.iTone, spec.toneKey || null, tEntry || null, tEntry?.gain ?? 0.35, 1.0);
|
||||
}
|
||||
function stopInterior() { ramp(L.iMusic.g.gain, 0, 0.7); ramp(L.iTone.g.gain, 0, 0.7); }
|
||||
|
||||
// ── unlock on first gesture (autoplay policy) ──
|
||||
function unlock() {
|
||||
if (state.ready || state.muted) return;
|
||||
ctx.resume().then(() => { state.ready = true; }).catch(() => {});
|
||||
window.removeEventListener('pointerdown', unlock, true);
|
||||
window.removeEventListener('keydown', unlock, true);
|
||||
}
|
||||
window.addEventListener('pointerdown', unlock, true);
|
||||
window.addEventListener('keydown', unlock, true);
|
||||
|
||||
// ── enterShop → doorbell + door SFX (dispatched by the shell on a door click) ──
|
||||
const onEnter = () => { playSfx('door-open'); playSfx('doorbell', { gain: 0.9 }); };
|
||||
window.addEventListener('procity:enterShop', onEnter);
|
||||
|
||||
// ── per-frame update (self-ticked; allocates nothing) ──
|
||||
const shopType = new Map();
|
||||
(PROCITY.plan?.shops || []).forEach((s) => shopType.set(s.id, s.type));
|
||||
const shopById = new Map();
|
||||
(PROCITY.plan?.shops || []).forEach((s) => shopById.set(s.id, s));
|
||||
const _cam = { x: 0, z: 0 };
|
||||
const v = { lx: null, lz: null, stepAcc: 0, spillT: 0, tramX: null, tramZ: null, tramRang: false };
|
||||
const STRIDE = 0.72, SPILL_R = 9, TRAM_R = 55, TRAM_BELL_R = 22;
|
||||
|
||||
function update(dt) {
|
||||
if (!state.ready || state.muted) return;
|
||||
const P = PROCITY;
|
||||
const mode = P.mode || 'street';
|
||||
const street = mode === 'street';
|
||||
const cam = P.camera?.position; if (!cam) return;
|
||||
_cam.x = cam.x; _cam.z = cam.z;
|
||||
const clk = P.lighting?.getClock?.();
|
||||
const night = !!(clk && clk.night);
|
||||
|
||||
// ── ambience day/night beds (street only; ducked to 0 in interior/map) ──
|
||||
const amb = AUDIO?.ambience;
|
||||
persistBed(L.day, 'street-day', amb?.['street-day'], street && !night ? (amb?.['street-day']?.gain ?? 0.5) : 0);
|
||||
persistBed(L.night, 'street-night', amb?.['street-night'], street && night ? (amb?.['street-night']?.gain ?? 0.5) : 0);
|
||||
|
||||
// ── rain layer (gain follows weather intensity; only when it's actually raining) ──
|
||||
const w = P.weather || { state: 'clear', intensity: 0 };
|
||||
const rainT = (street && w.state === 'rain') ? (amb?.rain?.gain ?? 0.45) * Math.max(0.15, w.intensity) : 0;
|
||||
persistBed(L.rain, 'rain', amb?.rain, rainT);
|
||||
|
||||
// ── footsteps (player movement on the street, while pointer-locked) ──
|
||||
if (street && P.player?.isLocked && v.lx != null) {
|
||||
const d = Math.hypot(_cam.x - v.lx, _cam.z - v.lz);
|
||||
if (d > 0.0005) {
|
||||
v.stepAcc += d;
|
||||
const stride = P.player.isRunning ? STRIDE * 1.15 : STRIDE;
|
||||
if (v.stepAcc >= stride) { v.stepAcc = 0; footstep('pavement'); }
|
||||
}
|
||||
} else { v.stepAcc = 0; }
|
||||
v.lx = _cam.x; v.lz = _cam.z;
|
||||
|
||||
// ── shop-door music spill (throttled scan of nearby live doors) ──
|
||||
v.spillT -= dt;
|
||||
if (street && v.spillT <= 0) {
|
||||
v.spillT = 0.2;
|
||||
let bestKey = null, bestGain = 0, bestId = null;
|
||||
const doorMeshes = P.chunks?.getDoorMeshes?.() || [];
|
||||
for (let m = 0; m < doorMeshes.length; m++) {
|
||||
const rects = doorMeshes[m].userData?.doorRects; if (!rects) continue;
|
||||
for (let i = 0; i < rects.length; i++) {
|
||||
const r = rects[i];
|
||||
const dist = Math.hypot(r.x - _cam.x, r.z - _cam.z);
|
||||
if (dist > SPILL_R) continue;
|
||||
const key = SPILL_MUSIC[shopType.get(r.shopId)]; if (!key) continue;
|
||||
const shop = shopById.get(r.shopId);
|
||||
if (P.isOpen && shop && !P.isOpen(shop)) continue; // only open shops spill (Lane F §3.5 hours)
|
||||
const g = (AUDIO?.music?.[key]?.gain ?? 0.5) * 0.5 * ((SPILL_R - dist) / SPILL_R);
|
||||
if (g > bestGain) { bestGain = g; bestKey = key; bestId = r.shopId; }
|
||||
}
|
||||
}
|
||||
state.nearestSpill = bestId;
|
||||
swapBed(L.spill, bestKey, bestKey ? AUDIO?.music?.[bestKey] : null, bestGain, 0.6);
|
||||
} else if (!street && L.spill.key) {
|
||||
ramp(L.spill.g.gain, 0, 0.4);
|
||||
}
|
||||
|
||||
// ── tram rumble (distance-gain) + bell (rings once as it settles at a stop nearby) ──
|
||||
const tramG = P.scene?.getObjectByName?.('tram');
|
||||
if (street && tramG) {
|
||||
const p = tramG.position, dist = Math.hypot(p.x - _cam.x, p.z - _cam.z);
|
||||
const rumbleT = dist < TRAM_R ? (AUDIO?.sfx?.['tram-rumble']?.gain ?? 0.5) * ((TRAM_R - dist) / TRAM_R) : 0;
|
||||
persistBed(L.tram, 'tram-rumble', AUDIO?.sfx?.['tram-rumble'], rumbleT, 0.5);
|
||||
// bell: near + (nearly) stationary (dwelling at a stop) → one ring per dwell
|
||||
const moved = v.tramX == null ? 1 : Math.hypot(p.x - v.tramX, p.z - v.tramZ);
|
||||
if (dist < TRAM_BELL_R && moved < 0.02) { if (!v.tramRang) { playSfx('tram-bell'); v.tramRang = true; } }
|
||||
else if (moved > 0.1) v.tramRang = false;
|
||||
v.tramX = p.x; v.tramZ = p.z;
|
||||
} else if (L.tram.key) { ramp(L.tram.g.gain, 0, 0.5); }
|
||||
}
|
||||
|
||||
// self-tick
|
||||
let raf = 0, lastT = 0;
|
||||
function tick(now) { raf = requestAnimationFrame(tick); const dt = lastT ? Math.min((now - lastT) / 1000, 0.1) : 0.016; lastT = now; try { update(dt); } catch {} }
|
||||
raf = requestAnimationFrame(tick);
|
||||
|
||||
// ── public surface ──
|
||||
function dispose() {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener('pointerdown', unlock, true);
|
||||
window.removeEventListener('keydown', unlock, true);
|
||||
window.removeEventListener('procity:enterShop', onEnter);
|
||||
try { ctx.close(); } catch {}
|
||||
}
|
||||
return {
|
||||
state,
|
||||
get ready() { return state.ready; },
|
||||
get muted() { return state.muted; },
|
||||
setMasterGain(v2) { ramp(master.gain, Math.max(0, Math.min(1, v2)), 0.1); },
|
||||
mute() { state.muted = true; ramp(master.gain, 0, 0.15); },
|
||||
unmute() { state.muted = false; ramp(master.gain, 0.9, 0.2); },
|
||||
playSfx, footstep, playInterior, stopInterior, update, dispose,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user