THE ROUND IN ONE MEASUREMENT (12 samples x 146 active, ?clips=0 vs default — a new flag that turns off the library and nothing else): walking 99.3% -> 78.1% · bench-sit 0 -> 9.5% · lean 0 -> 7.1% · stopped in own idle 0.4% -> 5.1% · DISTINCT CLIPS ACROSS THE CROWD 4 -> 20. The town was 99.3% people walking because standing still had nowhere to happen. Wiring: new postures.js + clipbank.js. idles.glb (10/10) drives a per-citizen deterministic idle on every near-tier actor plus the seeded shopkeeper. locomotion gives 33.7% of walkers a shopping bag. sitlean (8/8, lazy) puts 4 sits on Lane B's ACTUAL benches and 4 leans on shopfront walls. browse (5/8, lazy) is a real BROWSE state at C's browse points, seeded per (shopId, slot). venue (5/6, lazy) widens the gig crowd, plus a publican pouring and a record keeper in headphones. social (0/8) is never fetched — two-person conversation needs a paired state machine, filed to R42. Cost: boot = 4 requests, 1.24 MB / 16 clips resident; the rest lazy on first need; heap delta +3.34 MB; mixer median 0.1 ms both arms. ?clips=0 / ?classic=1 / ?noassets=1 fetch ZERO clips — not even clipbank.js (dynamic import). No shell edit needed. DRAWS: +0 on every bookmark (street_noon 193, crossroads 108, night_crowd 128, market_square 94, night_neon 111, interior 110 — identical both arms). Ruling 4 respected exactly. DETERMINISM: 150 citizens, two fresh contexts, byte-equal posture signature. Controls: seed+1 differs; EVERY clip GLB delayed 2 s -> identical signature (posture is a pure function of (citySeed, id), never of residency). 6 new streams collide with none of the 12 pre-R41 keys. TWO FINDINGS THAT CHANGED THE DESIGN: the idle pool was INVISIBLE — wired only to the R17/R29 node loiter, so only 0.8% of citizens were ever stopped; and the lean never fired at all (0 in a 9 s run). Both moved to the patronage stride check. Bench stations are GATED not trusted: 14/14 derived stations coincide with real instanced geometry within 2 cm, and the control (same stations offset 2 m) matches 0/14. Filed to B: one benchStops(plan) export retires the mirror, and furniture.js puts the bench's front ALONG the street rather than facing the road, contradicting its own comment. Leak: +0 geometries, +1 texture over 6 enter/exit cycles. Goldens 157,647/157,647, 0x5f76e76. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
354 lines
22 KiB
JavaScript
354 lines
22 KiB
JavaScript
// PROCITY Lane D — the gig band + crowd (v3.0-beta, behind ?gigs=1).
|
||
//
|
||
// A Friday night at the venue: a 4-piece band on Lane C's stage (guitar / vocal / bass across the
|
||
// front, a seated drummer on the up-stage riser) and a mixed crowd at C's watch points — some
|
||
// standing, some dancing (John's "a bit of both"). Built on the gate-protected rig path
|
||
// (spawnRig → buildFigure, so no giants), with a placeholder fallback when the fleet is absent
|
||
// (?noassets — a gig with no GLBs is still a gig).
|
||
//
|
||
// Instruments: primitives show immediately (asset law), then E's manifest GLBs swap in async where
|
||
// present (placeholder-persists, like interiors/glb.js). guitar/bass held at the chest, mic stand +
|
||
// drum kit planted, an amp as backline dressing. Gated on fleet.ready so ?noassets never fetches.
|
||
//
|
||
// Seam: F builds one GigCrew per venue interior and calls spawn() when the gig state is doors/on,
|
||
// update(dt) each interior frame, disposeAll() on gig-off / room exit. C owns the stage + watch
|
||
// points (room.stage / room.watchPoints); A owns the gig (plan.gigs). Deterministic from (citySeed, gigId).
|
||
|
||
import * as THREE from 'three';
|
||
import { rng, shuffle } from '../core/prng.js';
|
||
import { pickRig, spawnRig, seatedLean } from './rigs.js';
|
||
import { makePlaceholder } from './placeholder.js';
|
||
import { loadGLB } from '../core/loaders.js';
|
||
import { gigPostureFor, GROUP_OF } from './postures.js';
|
||
|
||
const CROWD_CAP = 12; // hard ceiling; the real cap is watchPoints.length per venue (≤ always
|
||
// holds — F's smoke asserts it). RSL fields the most (8–12 watch points).
|
||
const MIC = 0x222226, POLE = 0x9a9a9e, KIT = 0x8f2b24, CHROME = 0xc8c8cc, AMP = 0x15151a, AMPCLOTH = 0x3a3020;
|
||
const SEAT_DROP = 0.52; // the drummer sits: sink the rig so the kit hides the straight legs.
|
||
// Reads seated from the front (the crowd POV / money shot); a true
|
||
// sit is a clip job — see the LANE_D_NOTES clip wishlist.
|
||
|
||
// ---- primitive instruments (E's GLBs swap in over these; a gig with no GLBs is still a gig) ----
|
||
// Each returns an Object3D in the actor's OUTER-fig local frame. After the R13 facing-normalise the
|
||
// rig's front (its chest) is fig-local -Z, so instruments sit on the -Z side and ride the sway.
|
||
// Primitives own their geometry/material (tagged ownGeo) so disposeAll frees them; GLB clones share
|
||
// the cached gltf's geo/mats and are only detached.
|
||
function tagOwn(g) { g.userData.ownGeo = true; return g; }
|
||
|
||
function guitarPrim(bodyCol, big = false) {
|
||
const g = new THREE.Group();
|
||
const bodyMat = new THREE.MeshStandardMaterial({ color: bodyCol, roughness: 0.5, metalness: 0.1 });
|
||
const neckMat = new THREE.MeshStandardMaterial({ color: 0x2a2018, roughness: 0.7 });
|
||
const bw = big ? 0.30 : 0.34, bh = big ? 0.44 : 0.40;
|
||
const body = new THREE.Mesh(new THREE.BoxGeometry(bw, bh, 0.06), bodyMat);
|
||
const neck = new THREE.Mesh(new THREE.BoxGeometry(0.05, big ? 0.85 : 0.72, 0.03), neckMat);
|
||
neck.position.set(bw * 0.1, bh * 0.5 + (big ? 0.42 : 0.36), 0);
|
||
g.add(body, neck);
|
||
g.position.set(0.02, 1.02, -0.19); // across the chest (fig-local -Z = the audience-facing front)
|
||
g.rotation.set(0.15, 0, big ? -0.7 : -0.62); // slung diagonally, body angled out to the crowd
|
||
return tagOwn(g);
|
||
}
|
||
function micStandPrim() {
|
||
const g = new THREE.Group();
|
||
const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.015, 0.02, 1.42, 6),
|
||
new THREE.MeshStandardMaterial({ color: POLE, roughness: 0.4, metalness: 0.6 }));
|
||
pole.position.y = 0.71;
|
||
const head = new THREE.Mesh(new THREE.SphereGeometry(0.045, 8, 6),
|
||
new THREE.MeshStandardMaterial({ color: MIC, roughness: 0.6 }));
|
||
head.position.y = 1.44;
|
||
g.add(pole, head);
|
||
g.position.set(0, 0, -0.26); // stands just in front of the singer (fig-local -Z)
|
||
return tagOwn(g);
|
||
}
|
||
// R16: the backline amp fallback (C dropped its primitive ampStacks; this stands in when the guitar_amp
|
||
// GLB is absent / ?noassets). A room prop (not fig-local): base at y=0, grille on the +Z (crowd) face.
|
||
function ampPrim() {
|
||
const g = new THREE.Group();
|
||
const cab = new THREE.MeshStandardMaterial({ color: AMP, roughness: 0.7, metalness: 0.05 });
|
||
const cloth = new THREE.MeshStandardMaterial({ color: AMPCLOTH, roughness: 0.9 });
|
||
const box = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.6, 0.29), cab); box.position.y = 0.3;
|
||
const grille = new THREE.Mesh(new THREE.BoxGeometry(0.42, 0.46, 0.02), cloth); grille.position.set(0, 0.3, 0.15);
|
||
g.add(box, grille);
|
||
return tagOwn(g);
|
||
}
|
||
// a cheap kit: kick drum + snare + a floor tom + two cymbals on stands. Reads as "drum kit" behind the
|
||
// seated drummer; the GLB replaces it when present. Sits on the riser (y = SEAT_DROP compensates the sink).
|
||
function drumKitPrim() {
|
||
const g = new THREE.Group();
|
||
const shell = new THREE.MeshStandardMaterial({ color: KIT, roughness: 0.4, metalness: 0.1 });
|
||
const chrome = new THREE.MeshStandardMaterial({ color: CHROME, roughness: 0.3, metalness: 0.7 });
|
||
const skin = new THREE.MeshStandardMaterial({ color: 0xe8e4d8, roughness: 0.6 });
|
||
const kick = new THREE.Mesh(new THREE.CylinderGeometry(0.30, 0.30, 0.42, 16), shell);
|
||
kick.rotation.x = Math.PI / 2; kick.position.set(0, 0.30, 0);
|
||
const kickHead = new THREE.Mesh(new THREE.CircleGeometry(0.29, 16), skin);
|
||
kickHead.position.set(0, 0.30, -0.215); kickHead.rotation.y = Math.PI;
|
||
const snare = new THREE.Mesh(new THREE.CylinderGeometry(0.16, 0.16, 0.16, 14), chrome);
|
||
snare.position.set(-0.32, 0.58, -0.10);
|
||
const tom = new THREE.Mesh(new THREE.CylinderGeometry(0.19, 0.19, 0.22, 14), shell);
|
||
tom.position.set(0.34, 0.52, -0.06);
|
||
const mkCymbal = (x, y, r) => {
|
||
const c = new THREE.Group();
|
||
const stand = new THREE.Mesh(new THREE.CylinderGeometry(0.012, 0.012, y, 6), chrome);
|
||
stand.position.y = y / 2;
|
||
const disc = new THREE.Mesh(new THREE.CylinderGeometry(r, r, 0.01, 16), chrome);
|
||
disc.position.y = y;
|
||
c.add(stand, disc); c.position.x = x; return c;
|
||
};
|
||
g.add(kick, kickHead, snare, tom, mkCymbal(-0.44, 0.86, 0.18), mkCymbal(0.50, 0.94, 0.20));
|
||
g.position.set(0, SEAT_DROP, -0.60); // on the riser, in front of the seated drummer (fig-local -Z)
|
||
return tagOwn(g);
|
||
}
|
||
function instrumentFor(role) {
|
||
if (role === 'guitar') return guitarPrim(0xb23b2e); // sunburst-ish red
|
||
if (role === 'bass') return guitarPrim(0x2f3a52, true); // dark blue, bigger body + longer neck
|
||
if (role === 'vocal') return micStandPrim();
|
||
if (role === 'drums') return drumKitPrim();
|
||
return null;
|
||
}
|
||
|
||
// role → E's manifest fitting id + its transform in the actor's fig-local frame (front/chest = -Z).
|
||
// GLBs are base-origin metres (glb_law): guitar/bass stand upright (long axis +Y = neck, thin face along
|
||
// Z), so a +Z roll slings them diagonally across the chest; the mic stand is upright as-is; the kit is
|
||
// scaled to fit C's tight riser gap (vocalist 0.73 m in front of the drummer) and planted in front of the
|
||
// sunk drummer. Tuned on-screen. Body-origin → position is where the strap-button / base sits.
|
||
const GLB_FIT = {
|
||
guitar: { id: 'electric_guitar', pos: [0.14, 0.60, -0.15], rot: [0.22, 0.0, 0.72], scale: 1 },
|
||
bass: { id: 'bass_guitar', pos: [0.14, 0.58, -0.15], rot: [0.22, 0.0, 0.66], scale: 1 },
|
||
vocal: { id: 'mic_stand', pos: [0.00, 0.00, -0.30], rot: [0, 0, 0], scale: 0.9 },
|
||
drums: { id: 'drum_kit', pos: [0.00, SEAT_DROP, -0.30], rot: [0, Math.PI, 0], scale: 0.66 },
|
||
};
|
||
|
||
// manifest (self-fetched, promise-cached, fail-soft). ONLY called behind a fleet.ready gate, so under
|
||
// ?noassets (fleet === null) this never runs → zero asset fetch, primitives everywhere. Decoupled from
|
||
// Lane C's interiors/glb.js on purpose (band.js is asset-law self-contained).
|
||
let _manifestP = null;
|
||
function loadManifest() {
|
||
return (_manifestP ||= fetch('assets/manifest.json').then(r => (r.ok ? r.json() : null)).catch(() => null));
|
||
}
|
||
|
||
export class GigCrew {
|
||
constructor({ citySeed = 20261990, fleet = null } = {}) {
|
||
this.citySeed = citySeed >>> 0;
|
||
this.fleet = fleet;
|
||
this.members = []; // { actor, kind, part, role, dance, seated, base:{x,y,z,ry}, phase, extra:[] }
|
||
this.props = []; // planted dressing added straight to the room group (amp) — freed on disposeAll
|
||
this._gen = 0; // bumped every spawn/dispose; async GLB swaps bail if the gen moved (room gone)
|
||
this.t = 0;
|
||
}
|
||
|
||
// one seeded rig (or placeholder) actor, planted + facing ry. `key` seeds identity per gig+slot.
|
||
// `seated` (R16): the drummer — use E's sit.glb pose (fleet.sitClip) + spawnRig's foot-replant + tag the
|
||
// fig for F's stature gate; falls back to the standing idle (caller sinks it via SEAT_DROP) if no sitClip.
|
||
// [R41 §41.3] `venue` is the WIDENING seam, and it is deliberately narrow: the v3 dance pick below
|
||
// (the `gigdance` stream over fleet.danceClips) is untouched, and gigPostureFor draws from its own
|
||
// freshly-keyed `gig-pose` stream to decide whether THIS slot takes a venue.glb clip instead —
|
||
// northern soul for a dancer, clap/cheer/headphones for a stander. Returns null for the rest, and
|
||
// for every boot where venue.glb isn't resident, so the R13/R14 crowd survives byte-identical
|
||
// wherever the swap doesn't fire. Not a redesign: no new member kinds, no new poses, no new draws.
|
||
_make(target, key, { x, y, z, ry, height, pedIndex = null, seated = false, dance = false, crowd = false }) {
|
||
const r = rng(this.citySeed, 'gig', key);
|
||
const h = height != null ? height : 1.6 + r() * 0.3;
|
||
const bank = this.fleet && this.fleet.bank;
|
||
const venueId = crowd ? gigPostureFor(this.citySeed, key, !!dance) : null;
|
||
if (bank && venueId) bank.ensureGroup(GROUP_OF[venueId]);
|
||
let actor, kind, usedIndex = null, ph = 0;
|
||
if (this.fleet && this.fleet.ready) {
|
||
const pk = (pedIndex != null && this.fleet.all[pedIndex]) ? { index: pedIndex } : pickRig(this.fleet, r());
|
||
const rig = pk && this.fleet.all[pk.index];
|
||
const useSit = seated && !!this.fleet.sitClip; // real seated pose iff E's sit clip is loaded
|
||
// dancers play a real dance clip (separate 'gigdance' stream keeps the pick off the existing draws)
|
||
const dcs = this.fleet.danceClips || [];
|
||
const danceClip = (dance && dcs.length) ? dcs[(rng(this.citySeed, 'gigdance', key)() * dcs.length) | 0] : null;
|
||
const venueClip = (!useSit && bank && venueId) ? bank.get(venueId) : null;
|
||
// `phase: (ph = r())` keeps R12's draw ORDER and its short-circuit exactly: the phase is drawn
|
||
// only when a rig actually resolved, so no crowd slot's identity moves under this round's edit.
|
||
const sp = rig && spawnRig(rig, { ry, height: h, clip: useSit ? this.fleet.sitClip : (venueClip || danceClip || this.fleet.idleClip), phase: (ph = r()), seated: useSit });
|
||
if (sp && venueClip) sp.setClip(venueClip, { phase: ph }); // re-plant off the posed skeleton
|
||
if (sp) { actor = sp; kind = 'rig'; usedIndex = pk.index; if (useSit) sp.fig.userData.procitySeated = true; }
|
||
}
|
||
if (!actor) { actor = makePlaceholder(rng(this.citySeed, 'gig-body', key), { height: h }); actor.fig.rotation.y = ry; kind = 'placeholder'; }
|
||
actor.fig.position.set(x, y, z);
|
||
target.add(actor.fig);
|
||
// `want` non-null only while the assigned venue clip is still in flight — update() installs it once.
|
||
const wantsIt = kind === 'rig' && !seated && venueId && this.fleet && this.fleet.clipsRequested
|
||
&& !(bank && bank.has(venueId));
|
||
return { actor, kind, pedIndex: usedIndex, clipPhase: ph, venueId, want: wantsIt ? venueId : null };
|
||
}
|
||
|
||
// spawn(roomGroup, { stage, watchPoints, gig, roster }) — the band on the deck + crowd at the watch points.
|
||
// roster (R14): [{pedIndex,height}] of the punters who entered this venue tonight (D's citizens.tonightRoster);
|
||
// the crowd's front slots become them (continuity), seeded strangers fill the rest.
|
||
spawn(roomGroup, { stage = null, watchPoints = [], gig = { gigId: 0 }, roster = [], instrumentFor: instFor = instrumentFor } = {}) {
|
||
this.disposeAll();
|
||
const gid = (gig && gig.gigId) | 0; // null-safe (a quiet-night call would pass no gig)
|
||
this._gen++;
|
||
const gen = this._gen;
|
||
|
||
// ── band: C's bandPoses (guitar / vocal / bass across the front, + a seated `drums` on the riser) ──
|
||
if (stage && stage.bandPoses) {
|
||
// distinct fleet peds per gig (a band with two identical members reads wrong)
|
||
const distinct = (this.fleet && this.fleet.ready)
|
||
? shuffle(rng(this.citySeed, 'gigband', gid), this.fleet.all.map((_, i) => i)) : [];
|
||
stage.bandPoses.forEach((p, i) => {
|
||
const seated = p.role === 'drums' || !!p.seated;
|
||
const poseY = p.y != null ? p.y : (stage.deckY || 0); // C tags per-pose y (front line deckY, drums riser)
|
||
// R16: a real sit clip seats the rig on the riser (spawnRig re-plants the feet) → no sink; only the
|
||
// no-clip fallback still sinks SEAT_DROP so the kit hides the standing legs.
|
||
const seatedRig = seated && !!(this.fleet && this.fleet.sitClip);
|
||
const y = seated ? poseY - (seatedRig ? 0 : SEAT_DROP) : poseY;
|
||
const { actor, kind, pedIndex } = this._make(roomGroup, `band:${gid}:${p.role}:${i}`,
|
||
{ x: p.x, y, z: p.z, ry: p.ry, seated, pedIndex: distinct.length ? distinct[i % distinct.length] : null,
|
||
height: 1.68 + rng(this.citySeed, 'gigh', `${gid}:${i}`)() * 0.2 });
|
||
const member = { actor, kind, pedIndex, part: 'band', role: p.role, dance: false, seated,
|
||
base: { x: p.x, y, z: p.z, ry: p.ry }, phase: rng(this.citySeed, 'gigp', `b${gid}:${i}`)() * Math.PI * 2, extra: [] };
|
||
const inst = instFor(p.role);
|
||
if (inst) { actor.fig.add(inst); member.extra.push(inst); }
|
||
this.members.push(member);
|
||
this._upgradeInstrument(member, gen); // async GLB swap over the primitive (fleet-gated)
|
||
});
|
||
this._addBackline(roomGroup, stage, gen); // amp dressing beside the riser (async, dressing)
|
||
}
|
||
|
||
// ── crowd: R14 identity continuity — the front slots become tonight's roster (the punters who came in
|
||
// via the queue / surge), seeded strangers fill the remainder. The roster override passes {pedIndex,height}
|
||
// for that slot only; _make's existing branches then skip that slot's own height+pickRig draws. Because
|
||
// every crowd slot is an INDEPENDENTLY keyed stream (`gig`/`crowd:<gid>:<slot>`), this can't shift any
|
||
// other slot, the sway-phase stream (`gigp`), or the band — and an EMPTY roster is byte-identical to R13
|
||
// (no override → the exact R13 draws). Roster heights are the sim's 1.55–1.95 m, inside the [1.4,2.0] gate.
|
||
const pts = watchPoints.slice(0, Math.min(CROWD_CAP, watchPoints.length));
|
||
let fromRoster = 0;
|
||
pts.forEach((w, i) => {
|
||
const who = roster[i] || null;
|
||
const { actor, kind, pedIndex, want, venueId, clipPhase } = this._make(roomGroup, `crowd:${gid}:${w.slotIndex}`,
|
||
who ? { x: w.x, y: 0, z: w.z, ry: w.ry, pedIndex: who.pedIndex, height: who.height, dance: !!w.dance, crowd: true }
|
||
: { x: w.x, y: 0, z: w.z, ry: w.ry, dance: !!w.dance, crowd: true });
|
||
if (who) fromRoster++;
|
||
this.members.push({ actor, kind, pedIndex, part: 'crowd', role: 'fan', dance: !!w.dance, seated: false, fromRoster: !!who,
|
||
want, venueId, clipPhase, // R41: the venue-clip widening (null ⇒ this slot is R13 verbatim)
|
||
base: { x: w.x, y: 0, z: w.z, ry: w.ry }, phase: rng(this.citySeed, 'gigp', `c${gid}:${w.slotIndex}`)() * Math.PI * 2, extra: [] });
|
||
});
|
||
return { band: this.members.filter(m => m.part === 'band').length, crowd: pts.length, fromRoster };
|
||
}
|
||
|
||
// Swap a band member's primitive instrument for E's manifest GLB, placeholder-persists style. Fail-soft
|
||
// at every step (no manifest / no entry / depot unreachable → the primitive stays), and generation-guarded
|
||
// so a GLB that resolves after the room is gone (or re-spawned) is dropped, never attached to a dead rig.
|
||
_upgradeInstrument(member, gen) {
|
||
if (!this.fleet || !this.fleet.ready) return; // ?noassets → primitive only, no fetch
|
||
const cfg = GLB_FIT[member.role];
|
||
if (!cfg) return;
|
||
loadManifest().then((mani) => {
|
||
if (!mani || !mani.fittings || this._gen !== gen) return;
|
||
const entry = mani.fittings[cfg.id];
|
||
if (!entry || !entry.file) return;
|
||
return loadGLB(`depot:${entry.file}`).then((gltf) => {
|
||
if (!gltf || this._gen !== gen || !member.actor.fig.parent) return; // room rebuilt / disposed mid-flight
|
||
const inst = gltf.scene.clone(true); // shares cached geo/mats — detach only, never dispose
|
||
inst.position.set(...cfg.pos);
|
||
inst.rotation.set(...cfg.rot);
|
||
inst.scale.setScalar(cfg.scale || 1);
|
||
inst.traverse((o) => { if (o.isMesh) { o.castShadow = false; o.frustumCulled = false; } });
|
||
for (const ex of member.extra) { if (ex.parent) ex.parent.remove(ex); disposePrim(ex); }
|
||
member.extra.length = 0;
|
||
member.actor.fig.add(inst);
|
||
member.extra.push(inst);
|
||
});
|
||
});
|
||
}
|
||
|
||
// The backline amp, planted at C's up-stage `stage.backline[0]` (R16 ledger #5 — C dropped its primitive
|
||
// ampStacks; D owns the whole backline now). Placeholder-persists: a primitive amp shows immediately (the
|
||
// ?noassets fallback too), then E's guitar_amp GLB swaps in when it resolves. Falls back to the R15
|
||
// hardcoded up-stage-right pose if C's backline isn't present. Room-local coords; grille to the crowd.
|
||
_addBackline(roomGroup, stage, gen) {
|
||
const slot = (stage.backline && stage.backline[0]) ||
|
||
{ x: stage.x + (stage.w || 3) * 0.36, z: stage.z - (stage.d || 3) * 0.22, ry: 0 };
|
||
const place = (obj) => {
|
||
obj.position.set(slot.x, stage.deckY || 0, slot.z);
|
||
if (slot.ry != null) obj.rotation.y = slot.ry;
|
||
roomGroup.add(obj); this.props.push(obj); return obj;
|
||
};
|
||
const prim = place(ampPrim()); // primitive amp immediately (asset-free backline)
|
||
if (!this.fleet || !this.fleet.ready) return; // ?noassets → primitive only, no fetch
|
||
loadManifest().then((mani) => {
|
||
if (!mani || !mani.fittings || this._gen !== gen) return;
|
||
const entry = mani.fittings.guitar_amp;
|
||
if (!entry || !entry.file) return;
|
||
return loadGLB(`depot:${entry.file}`).then((gltf) => {
|
||
if (!gltf || this._gen !== gen || !prim.parent) return; // room rebuilt / disposed mid-flight
|
||
const amp = gltf.scene.clone(true);
|
||
amp.traverse((o) => { if (o.isMesh) { o.castShadow = false; o.frustumCulled = false; } });
|
||
place(amp); // GLB swaps in over the primitive
|
||
const i = this.props.indexOf(prim); if (i >= 0) this.props.splice(i, 1);
|
||
disposePrim(prim); // free the primitive's own geo/mats
|
||
});
|
||
});
|
||
}
|
||
|
||
// procedural motion layered on the idle clip (seeded phase → the crowd never metronomes in sync).
|
||
update(dt) {
|
||
this.t += dt;
|
||
const t = this.t;
|
||
const bank = this.fleet && this.fleet.bank;
|
||
for (const m of this.members) {
|
||
// [R41 §41.3] self-heal: venue.glb landed after this crowd spawned → install the assigned clip
|
||
// once, in place. Which clip was assigned was decided at spawn from (citySeed, slot); this only
|
||
// decides when it starts, so a slow fetch cannot change the crowd, only delay it.
|
||
if (m.want && bank && m.actor.setClip) {
|
||
const c = bank.get(m.want);
|
||
if (c) { m.actor.setClip(c, { phase: m.clipPhase || 0 }); m.want = null; }
|
||
else bank.ensureGroup(GROUP_OF[m.want]); // spawned before the bank existed → kick it now
|
||
}
|
||
if (m.actor.mixer) m.actor.mixer.update(dt); // rig idle
|
||
else m.actor.tick && m.actor.tick(dt, false); // placeholder idle
|
||
if (m.seated) seatedLean(m.actor.seatBone); // R17: tilt the drummer forward into the kit (post-mix)
|
||
const f = m.actor.fig, b = m.base, ph = m.phase;
|
||
if (m.part === 'band') {
|
||
if (m.seated) { // drummer: seated bob + a little shoulder work, planted
|
||
f.position.y = b.y + Math.abs(Math.sin(t * 3.4 + ph)) * 0.03;
|
||
f.rotation.z = Math.sin(t * 3.4 + ph) * 0.04;
|
||
} else if (m.role === 'vocal') { // singer: gentle sway toward the mic
|
||
f.rotation.z = Math.sin(t * 2.0 + ph) * 0.035;
|
||
f.position.z = b.z + Math.sin(t * 1.3 + ph) * 0.02;
|
||
} else { // guitar / bass: rhythmic strum-sway + tiny bob
|
||
f.rotation.z = Math.sin(t * 3.1 + ph) * 0.055;
|
||
f.position.y = b.y + Math.abs(Math.sin(t * 3.1 + ph)) * 0.02;
|
||
}
|
||
} else if (m.dance) { // dancer
|
||
if (this.fleet && this.fleet.danceClips && this.fleet.danceClips.length) {
|
||
f.rotation.y = b.ry + Math.sin(t * 0.6 + ph) * 0.10; // real dance clip drives the body; just a slow facing drift
|
||
} else { // fallback (classic/noassets): procedural bounce + sway
|
||
f.position.y = b.y + Math.abs(Math.sin(t * 5.2 + ph)) * 0.075;
|
||
f.rotation.z = Math.sin(t * 2.6 + ph) * 0.07;
|
||
f.rotation.y = b.ry + Math.sin(t * 1.7 + ph) * 0.13;
|
||
}
|
||
} else { // stander: slow weight shift, feet planted
|
||
f.rotation.z = Math.sin(t * 0.9 + ph) * 0.022;
|
||
f.rotation.y = b.ry + Math.sin(t * 0.5 + ph) * 0.03;
|
||
}
|
||
}
|
||
return this.members.length;
|
||
}
|
||
|
||
disposeAll() {
|
||
for (const m of this.members) {
|
||
if (m.actor.fig.parent) m.actor.fig.parent.remove(m.actor.fig);
|
||
for (const ex of m.extra) disposePrim(ex); // GLB clones (shared geo/mats) are skipped inside
|
||
m.actor.dispose && m.actor.dispose();
|
||
}
|
||
for (const p of this.props) disposePrim(p); // GLB dressing → detach only; the ?noassets primitive amp → freed (ownGeo)
|
||
this.members.length = 0;
|
||
this.props.length = 0;
|
||
this._gen++; // invalidate any GLB load still in flight
|
||
this.t = 0;
|
||
}
|
||
}
|
||
|
||
// Free a primitive instrument's own geometry/materials. GLB clones share the cached gltf's geo/mats,
|
||
// so they are detached from the scene by disposeAll but NEVER disposed here (tagged: no ownGeo).
|
||
function disposePrim(obj) {
|
||
if (obj.parent) obj.parent.remove(obj);
|
||
if (!obj.userData || !obj.userData.ownGeo) return; // GLB clone → shared resources, leave them cached
|
||
obj.traverse((o) => { if (o.isMesh) { o.geometry.dispose(); o.material.dispose && o.material.dispose(); } });
|
||
}
|