The town is inhabited: 23 towns finally differ, 181 back yards have a Hills Hoist with washing on it, a magpie owns one street for six weeks of the year, and six point sources put a life off-screen. MEASURED, walked, not bookmarked — two port-isolated no-store servers (:8472 = `git archive HEAD`, :8471 = treatment, assets symlinked so E's mid-round wave is identical on both sides), 102 8-metre spine steps at yaw 0, each frame fully streamed, through the shell's own composer: worst draws 224 -> 225 (+1) worst tris 89,502 -> 93,298 (+3,796 of ~75k spare) sum draws over 102 frames 16,676 -> 16,663 (-13) The citizen sim and the tram are hidden identically on both sides, and that IS the measurement: with them live the same step read control 346 vs treatment 232, because peds spawn on wall-clock time. `?yards=0` reproduces HEAD exactly (224 / 89,502 / 16,676). Isolated at a fixed pose: washing +1 draw / +512 tris, magpie +1 draw / +182 tris, every yard box prop +0 draws. §1.2 THE CHARACTER VECTOR — +0 draws, +0 tris, classic byte-identical BY CONSTRUCTION. `townCharacter(null)` returns the frozen v1 literals and a null tint keeps the SAME material cache key, so classic gets the same object, not a copy of it: default-boot ground hashes identical to HEAD mesh-for-mesh, `?classic=1` hashes unmoved from R37. There is no `if (classic)` in the ground path. The sky feeds the BASE skyName, never `setSky` (a one-way latch that would have killed R32's dawn dome on all 23 towns). The vacuous arm is per-town: `newtown_godverse` / `redhill_godverse` carry `state: ''`, so a per-corpus arm would have dropped exactly those two silently. 21 distinct palettes across 23 towns. `gravel` and `reddust` were not unwired but UNWIREABLE — their `use` values named no slot ground.js had; making the slot addressable fixed it at the root, and both are selected now. E's three new grounds landed mid-round and the upgrade was a name change in one table, which is what procedural-first is for. §1.4 BACKYARD AUSTRALIA — inside boxMats, +0 draws. Houses inset off their boundaries so there is a yard; 36 `yard` lots that had rendered as blank shopfront sheds since v1 are now fenced blocks with a hoist, a shed and often a tank. Collision byte-identical (the lot rect is where the fence stands). THE WASHING SHIPS, and I wrote the inverted weight rather than dropping it. The shipped top-weighted mode moves a hem 0.10 cm calm / 0.67 cm gusty; `pegged` gives 4.5 cm / 31 cm — ~52x, and at the hem instead of the pegs. The `canopy` string is byte-for-byte unchanged, so the gum-tree covenant argument is untouched. It is ONE TOWN-WIDE InstancedMesh (1 draw at any N, cap 256, proximity-first off the R37 chunk seam) rather than per-chunk, which would have been +1 x 31 live chunks = 4x the whole margin. Zero on every real town (no hoists ⇒ no mesh) and zero at night. §1.3 THE MAGPIE — +1 draw on screen, exactly 0 otherwise; 182 tris of 900. Territory scales with the road graph: 6 on the 6.78 km synthetic town, 157 on bendigo's 188.6 km / 2,593 edges, nearest 120 m from spawn. The latch sets, the day roll clears it, localStorage gains no key and the save never mentions it. `swoopPos()` is exported and pure. The hat stays cut. MY OWN SEASON MECHANISM WAS VACUOUS AND THE SWEEP CAUGHT IT: keyed on citySeed alone it produced a CONSTANT, because the town key is deliberately off the plan — 24 of 24 towns were 214 days from swooping season, and it would have gated green because `?magpie=1` forces it. Re-keyed on (citySeed, townKey): `newtown_real` is in season on arrival on the default seed, marrickville is 8 days out, and 12.9% of 1,000 seeds put the synthetic default in season against a design ratio of 11.5%. §1.1 THE AUDIO LAYER — six new mixer layers, zero draws, zero tris, seeded anchors, distance falloff, AND A StereoPannerNode PER LAYER, so direction is real (measured spread -0.71..+0.85) instead of a limitation written into the notes. Day-of-week derived locally. All six anchored on the default boot; 3 of 6 report `no-anchor` on real towns rather than being faked onto shopfronts. The falsifiability control — one manifest key renamed away, served from a third port — makes that source report `no-entry`, 0 plays, never `audible`, with the other five unchanged and zero console errors. TWO DEFECTS MY OWN GREEN NUMBERS COULD NOT SEE, both caught by a screenshot: the washing shipped at 256 instances / +1 draw / cap respected and was INVISIBLE behind its own back fence (per-instance peg height, and a fence taller than the hoist), and the magpie perched on THIN AIR over the road. R37's lesson relearned at full price. Fence 1.60 / hoist 2.15 measured against the object; territories now snap to furniture.js's own streetlight rule. LANE E'S PROPS, ADJUDICATED: hoist + aerial permanently primitive (E's spike killed them, and primitive is also the +0-draw mechanism). `paling_fence` at 1,121 tris x 905 runs = 1.01M triangles — no. The `magpie` GLB is HELD FOR THE TINT: it reads as a crow, and a magpie seen for 1.2 s in peripheral vision IS its white bar. My 182-tri bird already carries it, at a fifth of the triangles and no fetch. furniture.js's "novelty_record is 26.5k tris" was stale by six epochs — corrected to 8,000. Zero fetch delta: `?noassets=1` fetches the same 23 assets, same list, on HEAD and on R38. 0 console errors on 10 boots (default, classic, yards=0, washing=0&magpie=0&ambient=0, noassets, magpie=1, darwin, hobart, bendigo, the patched-manifest control) beyond the known PointerLock ones. Shots: docs/shots/laneB_r38/. Detail + every number: LANE_B_NOTES §38.
583 lines
32 KiB
JavaScript
583 lines
32 KiB
JavaScript
// 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.
|
|
|
|
import { gigKeyFor } from '../citygen/gigs.js'; // debt #1: the ONE genre→bed mapping (gig-<genreKey>)
|
|
import { rng, frange } from '../core/prng.js'; // [R38 §1.1] seeded anchors — the same dog, same fence
|
|
import { AMBIENCE_SOURCES } from './character.js'; // [R38 §1.1/§1.2] the six keys + the per-town mix
|
|
|
|
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),
|
|
};
|
|
// gig spill (round 12): the venue's live bed leaking THROUGH THE WALL — low-pass filtered (muffled
|
|
// bass/thump through the bricks) so it reads distinct from an open shop-door spill, and reaching
|
|
// further down the street. B owns the street side of the gig audio seam.
|
|
const gigLowpass = ctx.createBiquadFilter();
|
|
gigLowpass.type = 'lowpass'; gigLowpass.frequency.value = 470; gigLowpass.Q.value = 0.6;
|
|
gigLowpass.connect(buses.music);
|
|
L.gigSpill = layer(gigLowpass);
|
|
state.layers = L;
|
|
|
|
// ══ R38 §1.1 — THE AUDIO INHABITATION LAYER: SIX NEW MIXER LAYERS ═══════════════════════════════
|
|
//
|
|
// NOT a generalisation of the bed layers above. Those (`day` / `night` / `rain` / `spill` /
|
|
// `gigSpill` / `tram`) are hardcoded, one per source CLASS, each with its own bespoke rule for
|
|
// which key plays and how loud. This is a generic POINT-SOURCE system: a table of six entries,
|
|
// each with its own mixer layer, its own **seeded anchor set derived from the plan**, its own
|
|
// distance falloff, its own hours and its own day-of-week rule. A dog behind a paling fence, a
|
|
// mower two streets over, a radio through a window, a sprinkler, a roller door, a magpie carolling
|
|
// — the sound of a life happening off-screen, at the SAME anchor every time you walk that street.
|
|
//
|
|
// **COST: ZERO DRAWS, ZERO TRIANGLES.** Nothing in this block touches the scene graph, allocates
|
|
// geometry, or is reachable from the renderer. It is WebAudio nodes and a distance test. This is
|
|
// the one item in the epoch that cannot move the budget under any circumstance, and the reason is
|
|
// structural rather than careful.
|
|
//
|
|
// ── DIRECTION: THERE IS PANNING NOW, AND THERE WASN'T ───────────────────────────────────────────
|
|
// `playSfx` is mono with a gain multiplier — distance worked, direction did not, and a dog that is
|
|
// equally loud in both ears is a dog nowhere. Rather than write that down as a limitation, each of
|
|
// the six layers gets a **`StereoPannerNode`** driven by the dot product of the bearing-to-anchor
|
|
// with the camera's world right vector (matrixWorld's first column). Six extra nodes, no measurable
|
|
// cost, and a source that is genuinely *behind that fence over there*. Fail-soft: a browser without
|
|
// `createStereoPanner` (old Safari) connects the layer straight to the bus and is mono, exactly as
|
|
// before, with `state.ambient.panning:false` saying so.
|
|
//
|
|
// ── DAY OF WEEK IS DERIVED LOCALLY ─────────────────────────────────────────────────────────────
|
|
// `dowOf(day) = (day - 1) % 7`, 0 = Monday, computed HERE from `PROCITY.game.day`. It is not read
|
|
// from the gig week, not imported, and not persisted: no save key, no new contract, and no coupling
|
|
// to a layer that is null on half the boots. A mower on a Sunday morning and a roller door on a
|
|
// Tuesday are the whole point of knowing.
|
|
//
|
|
// ── FALSIFIABILITY (F's gate shape for the whole round) ────────────────────────────────────────
|
|
// Every entry publishes its own status at `PROCITY.audio.state.ambient.sources[key]`:
|
|
// `no-anchor` (this town has nowhere for it) · `no-entry` (**the manifest has no such key**) ·
|
|
// `idle` (anchored, out of range/hours) · `audible` (playing, with dist/gain/pan).
|
|
// Point one table entry at a key that does not exist and it reports `no-entry` and NEVER reaches
|
|
// `audible` — which is the leg F's control asserts must FAIL. A missing key is silence, never an
|
|
// error (house audio law 1), so the status field is the only thing that can tell the difference.
|
|
//
|
|
// ── SCOPE, HONESTLY ────────────────────────────────────────────────────────────────────────────
|
|
// Anchors come from the plan. All 21 real towns are 100% `use:'shop'` in one district (charter
|
|
// ruling 2), so on a real town three of the six (dog / mower / sprinkler) have **no anchors and
|
|
// report `no-anchor`** rather than being faked onto shopfronts. `radio-through-window` (flats above
|
|
// shops — `storeys ≥ 2`), `roller-door` (warehouse/dept/general lots) and `magpie-carol` (verge
|
|
// trees off the road graph) work everywhere. That is 3 of 6 on real towns and 6 of 6 on the
|
|
// default boot, and it becomes 6 of 6 everywhere the moment A's civic fetch lands districts.
|
|
const CLASSIC = (() => { try { return params.has('classic') && params.get('classic') !== '0'; } catch { return false; } })();
|
|
const AMBIENT_ON = !CLASSIC && params.get('ambient') !== '0';
|
|
|
|
// ── the anchor derivations: pure functions of the plan, seeded, never re-rolled ──
|
|
const lotsWhere = (plan, fn) => (plan?.lots || []).filter(fn);
|
|
const districtKind = (plan) => {
|
|
const byBlock = new Map((plan?.blocks || []).map((b) => [b.id, b.district]));
|
|
const byDistrict = new Map((plan?.districts || []).map((d) => [d.id, d.kind]));
|
|
return (lot) => byDistrict.get(byBlock.get(lot.block)) || null;
|
|
};
|
|
// Take a seeded subset so it is SOME houses, not every house — and the same ones every session.
|
|
function subset(list, citySeed, tag, frac) {
|
|
const out = [];
|
|
for (let i = 0; i < list.length; i++) {
|
|
if (rng(citySeed >>> 0, tag, i)() < frac) out.push(list[i]);
|
|
}
|
|
return out;
|
|
}
|
|
const homeLots = (plan) => lotsWhere(plan, (l) => l.use === 'house' || l.use === 'yard');
|
|
const anchorsOf = {
|
|
'dog-bark-fence': (plan, seed) => {
|
|
const kind = districtKind(plan);
|
|
const pool = homeLots(plan).concat(lotsWhere(plan, (l) => kind(l) === 'backstreets' && l.use === 'infill'));
|
|
return subset(pool, seed, 'amb:dog', 0.34).map((l) => ({ x: l.x, z: l.z }));
|
|
},
|
|
'mower-distant': (plan, seed) => subset(homeLots(plan), seed, 'amb:mower', 0.08).map((l) => ({ x: l.x, z: l.z })),
|
|
'radio-through-window': (plan, seed) => {
|
|
const pool = homeLots(plan).concat(
|
|
(plan?.shops || []).map((s) => ({ s, l: (plan.lots || []).find((x) => x.id === s.lot) }))
|
|
.filter((e) => e.l && (e.s.storeys || 1) >= 2).map((e) => e.l));
|
|
return subset(pool, seed, 'amb:radio', 0.2).map((l) => ({ x: l.x, z: l.z }));
|
|
},
|
|
sprinkler: (plan, seed) => subset(homeLots(plan), seed, 'amb:sprink', 0.16).map((l) => ({ x: l.x, z: l.z })),
|
|
'roller-door': (plan, seed) => {
|
|
const kind = districtKind(plan);
|
|
const byLot = new Map((plan?.shops || []).map((s) => [s.lot, s]));
|
|
const pool = lotsWhere(plan, (l) => {
|
|
const s = byLot.get(l.id);
|
|
return kind(l) === 'warehouse' || (s && (s.type === 'dept' || s.type === 'general' || s.type === 'pawn'));
|
|
});
|
|
return subset(pool, seed, 'amb:roller', 0.5).map((l) => ({ x: l.x, z: l.z }));
|
|
},
|
|
// The verge trees furniture.js plants: side/lane edges, every ~22 m, both verges. Derived from
|
|
// the plan rather than from the live furniture instances, so it works before a chunk is built.
|
|
// `i` is a RUNNING counter, deliberately not `out.length`: keying the seeded draw off the output
|
|
// length means the same number is drawn until something passes, so a first roll above the
|
|
// threshold produces zero anchors FOREVER — which is exactly what shipped in this file's first
|
|
// cut and exactly what `state.ambient.sources['magpie-carol'].status === 'no-anchor'` caught on
|
|
// the first boot. Left in the comment because the bug is invisible without the status field.
|
|
'magpie-carol': (plan, seed) => {
|
|
const out = [];
|
|
const nodeById = new Map((plan?.streets?.nodes || []).map((n) => [n.id, n]));
|
|
let i = 0;
|
|
for (const e of (plan?.streets?.edges || [])) {
|
|
if (e.kind !== 'side' && e.kind !== 'lane') continue;
|
|
const a = nodeById.get(e.a), b = nodeById.get(e.b);
|
|
if (!a || !b) continue;
|
|
const dx = b.x - a.x, dz = b.z - a.z, len = Math.hypot(dx, dz) || 1;
|
|
for (let s = 6; s < len - 4; s += 22) {
|
|
if (rng(seed >>> 0, 'amb:carol', i++)() > 0.22) continue;
|
|
out.push({ x: a.x + (dx / len) * s, z: a.z + (dz / len) * s });
|
|
}
|
|
if (out.length > 400) break;
|
|
}
|
|
return out;
|
|
},
|
|
};
|
|
|
|
// hours are [from, to) in the 6-segment clock's representative hours; days: 0=Mon … 6=Sun
|
|
const WEEKDAYS = [0, 1, 2, 3, 4], WEEKEND = [5, 6], ANY_DAY = [0, 1, 2, 3, 4, 5, 6];
|
|
const POINT_SOURCES = [
|
|
{ key: 'dog-bark-fence', bank: 'sfx', loop: false, radius: 34, gain: 0.55, hours: [6, 23],
|
|
days: ANY_DAY, gap: [7, 26], rainQuiet: 0.5 },
|
|
{ key: 'mower-distant', bank: 'ambience', loop: true, radius: 95, gain: 0.30, hours: [8, 18],
|
|
days: WEEKEND, gap: null, rainQuiet: 0 },
|
|
{ key: 'radio-through-window', bank: 'ambience', loop: true, radius: 17, gain: 0.34, hours: [7, 23],
|
|
days: ANY_DAY, gap: null, rainQuiet: 1 },
|
|
{ key: 'sprinkler', bank: 'ambience', loop: true, radius: 15, gain: 0.32, hours: [6, 10],
|
|
days: ANY_DAY, gap: null, rainQuiet: 0 },
|
|
{ key: 'roller-door', bank: 'sfx', loop: false, radius: 42, gain: 0.6, hours: [7, 18],
|
|
days: WEEKDAYS, gap: [40, 150], rainQuiet: 1 },
|
|
{ key: 'magpie-carol', bank: 'sfx', loop: false, radius: 62, gain: 0.5, hours: [6, 20],
|
|
days: ANY_DAY, gap: [12, 48], rainQuiet: 0.4 },
|
|
];
|
|
// The table and character.js's AMBIENCE_SOURCES must agree or one of them is decoration. Assert it
|
|
// here (dev-cheap, once) rather than discovering the drift in a gate three rounds later.
|
|
if (POINT_SOURCES.map((s) => s.key).join(',') !== AMBIENCE_SOURCES.join(',')) {
|
|
console.warn('[procity] §1.1 source table disagrees with character.js AMBIENCE_SOURCES');
|
|
}
|
|
|
|
const canPan = typeof ctx.createStereoPanner === 'function';
|
|
const points = [];
|
|
if (AMBIENT_ON) {
|
|
const plan = PROCITY.plan, citySeed = (plan?.citySeed ?? 0) >>> 0;
|
|
for (const def of POINT_SOURCES) {
|
|
let anchors = [];
|
|
try { anchors = anchorsOf[def.key](plan, citySeed) || []; } catch { anchors = []; }
|
|
const g = ctx.createGain(); g.gain.value = 0;
|
|
let pan = null;
|
|
if (canPan) { pan = ctx.createStereoPanner(); g.connect(pan); pan.connect(buses.ambience); }
|
|
else g.connect(buses.ambience);
|
|
points.push({ def, anchors, g, pan, src: null, key: null, starting: false,
|
|
r: rng(citySeed, 'amb:gap:' + def.key, 0), next: frange(rng(citySeed, 'amb:t0:' + def.key, 0), 1, 12),
|
|
status: anchors.length ? 'idle' : 'no-anchor', plays: 0, nearest: -1, dist: Infinity, gainNow: 0, panNow: 0 });
|
|
}
|
|
state.ambient = {
|
|
panning: canPan, sources: {},
|
|
get summary() { return points.map((p) => `${p.def.key}:${p.status}`).join(' '); },
|
|
};
|
|
for (const p of points) {
|
|
state.ambient.sources[p.def.key] = {
|
|
get anchors() { return p.anchors.length; }, get status() { return p.status; },
|
|
get dist() { return p.dist === Infinity ? null : +p.dist.toFixed(1); },
|
|
get gain() { return +p.gainNow.toFixed(3); }, get pan() { return +p.panNow.toFixed(2); },
|
|
get plays() { return p.plays; }, get nearest() { return p.nearest; },
|
|
};
|
|
}
|
|
} else {
|
|
state.ambient = { panning: false, off: true, sources: {} };
|
|
}
|
|
|
|
const dowOf = (day) => ((Math.max(1, day | 0) - 1) % 7); // derived LOCALLY (see the header)
|
|
let ambScan = 0;
|
|
function updatePoints(dt, hour, night, street) {
|
|
if (!points.length) return;
|
|
const cam = PROCITY.camera;
|
|
const day = (PROCITY.game && PROCITY.game.day) || 1;
|
|
const dow = dowOf(day);
|
|
const mix = (PROCITY.character && PROCITY.character.mix) || null;
|
|
const wet = (PROCITY.weather && PROCITY.weather.state === 'rain') ? (PROCITY.weather.intensity || 0) : 0;
|
|
// camera world right vector = matrixWorld's first column (for the pan bearing)
|
|
const me = cam && cam.matrixWorld ? cam.matrixWorld.elements : null;
|
|
const rx = me ? me[0] : 1, rz = me ? me[2] : 0;
|
|
ambScan -= dt;
|
|
const rescan = ambScan <= 0;
|
|
if (rescan) ambScan = 0.33;
|
|
for (const p of points) {
|
|
const d = p.def;
|
|
if (!p.anchors.length) { p.status = 'no-anchor'; continue; }
|
|
const entry = AUDIO && AUDIO[d.bank] ? AUDIO[d.bank][d.key] : null;
|
|
if (AUDIO && !entry) { p.status = 'no-entry'; ramp(p.g.gain, 0, 0.4); p.gainNow = 0; continue; }
|
|
if (rescan) { // nearest anchor — seeded set, so it is the same one on the same street
|
|
let bi = -1, bd = Infinity;
|
|
for (let i = 0; i < p.anchors.length; i++) {
|
|
const a = p.anchors[i];
|
|
const dd = (a.x - _cam.x) ** 2 + (a.z - _cam.z) ** 2;
|
|
if (dd < bd) { bd = dd; bi = i; }
|
|
}
|
|
p.nearest = bi; p.dist = Math.sqrt(bd);
|
|
}
|
|
const inHours = hour >= d.hours[0] && hour < d.hours[1];
|
|
const inDays = d.days.indexOf(dow) >= 0;
|
|
const near = p.dist < d.radius;
|
|
const rainMul = wet > 0 ? (d.rainQuiet + (1 - d.rainQuiet) * (1 - Math.min(1, wet))) : 1;
|
|
const townMul = mix ? (mix[d.key] ?? 1) : 1;
|
|
const audible = street && !night && near && inHours && inDays && townMul > 0;
|
|
const target = audible
|
|
? (entry?.gain ?? d.gain) * d.gain * townMul * rainMul * ((d.radius - p.dist) / d.radius)
|
|
: 0;
|
|
p.gainNow = target;
|
|
if (p.pan) {
|
|
const a = p.anchors[p.nearest];
|
|
const dx = a.x - _cam.x, dz = a.z - _cam.z, len = Math.hypot(dx, dz) || 1;
|
|
p.panNow = Math.max(-0.85, Math.min(0.85, ((dx * rx + dz * rz) / len) * 0.85));
|
|
p.pan.pan.setTargetAtTime(p.panNow, ctx.currentTime, 0.12);
|
|
}
|
|
if (d.loop) {
|
|
persistBed(p, 'amb:' + d.key, entry, target, 1.1);
|
|
p.status = audible && p.src ? 'audible' : (audible ? 'loading' : 'idle');
|
|
} else {
|
|
ramp(p.g.gain, audible ? 1 : 0, 0.25);
|
|
p.next -= dt;
|
|
if (audible && p.next <= 0) {
|
|
p.next = frange(p.r, d.gap[0], d.gap[1]);
|
|
firePoint(p, d, entry, target);
|
|
}
|
|
p.status = audible ? (p.plays ? 'audible' : 'loading') : 'idle';
|
|
}
|
|
}
|
|
}
|
|
async function firePoint(p, d, entry, gain) {
|
|
const buf = await load('amb:' + d.key, entry);
|
|
if (!buf) { p.status = 'no-entry'; return; }
|
|
const src = ctx.createBufferSource(); src.buffer = buf;
|
|
const vg = ctx.createGain(); vg.gain.value = Math.max(0, gain);
|
|
src.connect(vg); vg.connect(p.g); src.start();
|
|
src.onended = () => { try { src.disconnect(); vg.disconnect(); } catch {} };
|
|
p.plays++;
|
|
}
|
|
|
|
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, GIG_SPILL_R = 26;
|
|
|
|
// the district's venues (round 13), if the gig layer is present (?gigs); each lot carries the coords
|
|
// and each shop its genreKey ⇒ gigKeyFor(genreKey) is the bed. Empty flags-off ⇒ no gig spill.
|
|
const venues = (PROCITY.plan?.shops || [])
|
|
.filter((s) => s.venue)
|
|
.map((s) => ({ shop: s, lot: (PROCITY.plan?.lots || []).find((l) => l.id === s.lot), genreKey: s.genreKey }))
|
|
.filter((v) => v.lot);
|
|
if (!venues.length) { // R12 fallback: the pub is the sole venue if A didn't flag venues
|
|
const pub = (PROCITY.plan?.shops || []).find((s) => s.type === 'pub');
|
|
const lot = pub && (PROCITY.plan?.lots || []).find((l) => l.id === pub.lot);
|
|
if (lot) venues.push({ shop: pub, lot, genreKey: pub.genreKey || 'pubrock' });
|
|
}
|
|
// Per-venue gig state for the muffled spill (R14: the R12 alpha alias hop is retired):
|
|
// 1. F's per-venue map window.PROCITY.gigs.byVenue[id] (the canonical surface since R13)
|
|
// 2. clock fallback plan.gigs (night 0) vs the current segment (works before F wires / headless)
|
|
// Returns null only when there is no gig layer at all (flags-off ⇒ byte-identical, no spill).
|
|
function venueGigState(shopId) {
|
|
const g = PROCITY.gigs;
|
|
if (g && g.byVenue) { const e = g.byVenue[shopId]; if (e) return typeof e === 'string' ? e : (e.state || 'quiet'); }
|
|
const gigs = PROCITY.plan?.gigs; if (!gigs || !gigs.length) return null;
|
|
const ton = gigs.find((x) => x.night === 0 && x.venueShopId === shopId);
|
|
if (!ton) return 'quiet'; // dark tonight — reads true
|
|
const clk = PROCITY.lighting?.getClock?.(); if (!clk) return 'quiet';
|
|
const s = ton.startSeg ?? 5, e = ton.endSeg ?? 5;
|
|
if (clk.seg >= s && clk.seg <= e) return 'on';
|
|
if (clk.seg === (s - 1 + 6) % 6) return 'doors';
|
|
return 'quiet';
|
|
}
|
|
|
|
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);
|
|
|
|
// ── [R38 §1.1] the six point-source inhabitation layers (zero draws, zero tris) ──
|
|
// Runs before the beds so its own scan throttle shares the same frame budget; absent entirely
|
|
// under ?classic=1 / ?ambient=0 (points is empty ⇒ one length test and out).
|
|
if (points.length) {
|
|
const hr = (() => { try { return PROCITY.currentHour ? PROCITY.currentHour() : 12; }
|
|
catch { const [h, m] = String(clk?.hour || '12:00').split(':').map(Number); return h + (m || 0) / 60; } })();
|
|
updatePoints(dt, hr, night, street);
|
|
}
|
|
|
|
// ── 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);
|
|
}
|
|
|
|
// ── muffled gig spill through the nearest active venue's wall (round 13, the district) — a low-pass
|
|
// bed PER GENRE (gig-<genreKey>), reaching further than a door spill; the loudest (nearest, at
|
|
// doors/on) venue wins, so walking pub→band_room→RSL crossfades the genre. Byte-identical off. ──
|
|
if (street && venues.length && P.plan?.gigs?.length) {
|
|
let bestGain = 0, bestKey = null, bestState = null;
|
|
for (let i = 0; i < venues.length; i++) {
|
|
const vn = venues[i];
|
|
const gs = venueGigState(vn.shop.id);
|
|
if (gs !== 'on' && gs !== 'doors') continue;
|
|
const dist = Math.hypot(vn.lot.x - _cam.x, vn.lot.z - _cam.z);
|
|
if (dist >= GIG_SPILL_R) continue;
|
|
const key = gigKeyFor(vn.genreKey || 'pubrock');
|
|
const gg = (AUDIO?.music?.[key]?.gain ?? 0.6) * 0.55 * ((GIG_SPILL_R - dist) / GIG_SPILL_R);
|
|
if (gg > bestGain) { bestGain = gg; bestKey = key; bestState = gs; }
|
|
}
|
|
state.gig = bestState;
|
|
swapBed(L.gigSpill, bestGain > 0 ? bestKey : null, bestGain > 0 ? AUDIO?.music?.[bestKey] : null, bestGain, 0.9);
|
|
} else if (L.gigSpill.key) { ramp(L.gigSpill.g.gain, 0, 0.6); }
|
|
|
|
// ── 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,
|
|
};
|
|
}
|