PROCITY/web/js/citizens/band.js
m3ultra fefbb4b806 Lane D R13 (v3.0-beta): 4-piece + instruments, outdoor queue, multi-venue surge, RY fix
- RY audit (debt #4): the ped GLB fleet's visual front is +Z but the whole
  system assumed -Z (sim heading math, C's pose ry, placeholder, impostor).
  Fixed at the source (rigs.js buildFigure normalises every rig to -Z front)
  and dropped band.js RY_FLIP. Corrects keepers/browsers (faced the back wall)
  and streamed walkers (moon-walked): 223/223 gig + 188/188 flags-off now face
  travel. Visual only, no golden move. keepers.js/placeholder.js/impostor.js
  unchanged (fixed for free). Canonical convention -> LANE_D_NOTES for C.
- Instruments (debt #2): E's manifest GLBs swap over the primitives async
  (placeholder-persists, fleet-gated -> 0 fetch under ?noassets), guitar/bass
  held, mic + kit planted, guitar_amp dressing.
- Drummer (debt #3): 4-piece, seated at C's drums pose (sink + kit occlusion;
  reads from the front/crowd POV - a sit clip is the polish).
- Outdoor queue (new queue.js): seeded 2-6 line at the frontage, drains on
  admitOne() + auto-drain, consumes B's queueZone or a frontage-hugging door
  fallback. F wiring seam documented in LANE_D_NOTES.
- Multi-venue surge (sim.js): setGig -> Set of concurrent venues.
- Interior gig 56 draws <=350; ?noassets+?gigs placeholders, silent-and-fine.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 00:00:05 +10:00

281 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 } from './rigs.js';
import { makePlaceholder } from './placeholder.js';
import { loadGLB } from '../core/loaders.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 (812 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);
}
// 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.
_make(target, key, { x, y, z, ry, height, pedIndex = null }) {
const r = rng(this.citySeed, 'gig', key);
const h = height != null ? height : 1.6 + r() * 0.3;
let actor, kind;
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 sp = rig && spawnRig(rig, { ry, height: h, clip: this.fleet.idleClip, phase: r() });
if (sp) { actor = sp; kind = 'rig'; }
}
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);
return { actor, kind };
}
// spawn(roomGroup, { stage, watchPoints, gig }) — the band on the deck + crowd at the watch points.
spawn(roomGroup, { stage = null, watchPoints = [], gig = { gigId: 0 }, instrumentFor: instFor = instrumentFor } = {}) {
this.disposeAll();
const gid = gig.gigId | 0;
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)
const y = seated ? poseY - SEAT_DROP : poseY; // sink the drummer so the kit hides the legs
const { actor, kind } = this._make(roomGroup, `band:${gid}:${p.role}:${i}`,
{ x: p.x, y, z: p.z, ry: p.ry, pedIndex: distinct.length ? distinct[i % distinct.length] : null,
height: 1.68 + rng(this.citySeed, 'gigh', `${gid}:${i}`)() * 0.2 });
const member = { actor, kind, 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: stand-and-watch or dance at C's watch points ──
const pts = watchPoints.slice(0, Math.min(CROWD_CAP, watchPoints.length));
pts.forEach((w) => {
const { actor, kind } = this._make(roomGroup, `crowd:${gid}:${w.slotIndex}`, { x: w.x, y: 0, z: w.z, ry: w.ry });
this.members.push({ actor, kind, part: 'crowd', role: 'fan', dance: !!w.dance, seated: false,
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 };
}
// 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);
});
});
}
// A guitar amp as backline dressing, planted on the deck just off the riser (the classic pub backline).
// Dressing, not an instrument — planted in the room group (this.props), GLB-only (skipped under ?noassets).
_addBackline(roomGroup, stage, gen) {
if (!this.fleet || !this.fleet.ready) return;
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) return;
const amp = gltf.scene.clone(true);
amp.traverse((o) => { if (o.isMesh) { o.castShadow = false; o.frustumCulled = false; } });
// off to stage-right of the drum riser, on the deck, facing the crowd (world +Z). Room-local coords.
amp.position.set(stage.x + (stage.w || 3) * 0.32, (stage.deckY || 0), stage.z + 0.15);
roomGroup.add(amp);
this.props.push(amp);
});
});
}
// procedural motion layered on the idle clip (seeded phase → the crowd never metronomes in sync).
update(dt) {
this.t += dt;
const t = this.t;
for (const m of this.members) {
if (m.actor.mixer) m.actor.mixer.update(dt); // rig idle
else m.actor.tick && m.actor.tick(dt, false); // placeholder idle
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: bounce + sway + a little step
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) { if (p.parent) p.parent.remove(p); } // GLB dressing — cached geo/mats, detach only
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(); } });
}