PROCITY/web/js/citizens/band.js
m3ultra 046668e5af feat(gig): club dancers play real dance clips, not procedural sway
loadPedFleet gains dance:!CLASSIC (mirrors the sit/look gate) → fleet.danceClips
= 4 mesh-free mixamorig club dances (party/medium/drink/sway). _make picks one
per dancer off a fresh gigdance seeded stream (zero impact on existing draws /
the same-seed-same-crowd covenant), spawnRig plays it, and update() drops the
fake bounce for real-clip dancers (procedural sway stays as the classic/noassets
fallback). ?classic fetches nothing, byte-identical.

Verified: default boot fetches all 4 dance GLBs (gated load runs), both modules
parse + export clean, page boots. Same clips + _canon/_rotOnly/spawnRig proven
animating in 90sDJsim (its crowd/rigs.js is this file ported).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 02:11:55 +10:00

327 lines
20 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, seatedLean } 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);
}
// 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.
_make(target, key, { x, y, z, ry, height, pedIndex = null, seated = false, dance = false }) {
const r = rng(this.citySeed, 'gig', key);
const h = height != null ? height : 1.6 + r() * 0.3;
let actor, kind, usedIndex = null;
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 sp = rig && spawnRig(rig, { ry, height: h, clip: useSit ? this.fleet.sitClip : (danceClip || this.fleet.idleClip), phase: r(), seated: useSit });
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);
return { actor, kind, pedIndex: usedIndex };
}
// 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.551.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 } = 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 }
: { x: w.x, y: 0, z: w.z, ry: w.ry, dance: !!w.dance });
if (who) fromRoster++;
this.members.push({ actor, kind, pedIndex, part: 'crowd', role: 'fan', dance: !!w.dance, seated: false, fromRoster: !!who,
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;
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
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(); } });
}