PROCITY/web/js/citizens/band.js
m3ultra 888335d633 Lane D round 12: gig band + crowd + patronage surge (?gigs=1)
v3.0-alpha — Friday night at the pub. New web/js/citizens/band.js (GigCrew):
- Band trio on C's stage.bandPoses (deckY), distinct fleet peds (seeded per gig), each with a
  primitive instrument on the chest (guitar/mic/bass; E's GLBs drop in via opts.instrumentFor).
- Crowd (<=8) at C's watchPoints on the gate-protected spawnRig path: dance:false = weight-shift
  idle, dance:true = bounce/sway/step with seeded phase (no metronome).
- Deterministic from citySeed; placeholder crew under ?noassets.

sim.js: setGig(venueShopId, on) gig-night surge — patronage pulls peds from further (GIG_RANGE)
and ducks in harder (GIG_SURGE) at the venue; occupants drain at close per A's closing-time ruling.
Guarded by _gigVenue==null so gig-off is byte-identical (prime flag law; goldens unmoved).

Verified standalone (A withGigs + C buildInterior + D): band 3, crowd 8, dancing 3, all 11 figures
human-sized (1.60-1.83m under a 4.0m ceiling; R10 no-giants holds), band faces audience + crowd
faces stage, deterministic, leak-free (8 cycles, 0 delta). Crew ~40 draws. Surge: venue occupancy
1 -> peak 5, identity stable. Shot: docs/shots/laneD/r12_gig_night.jpg.

Flag for C/F: band.js flips C's watchPoints/bandPoses ry by pi (RY_FLIP) — the fleet mesh's visual
front is local +Z, not the -Z the comments assume. Remove RY_FLIP if C re-fronts the gig poses.

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

159 lines
8.4 KiB
JavaScript

// PROCITY Lane D — the gig band + crowd (v3.0-alpha, behind ?gigs=1).
//
// A Friday night at the pub: three musicians on Lane C's stage deck and a mixed crowd at C's
// watch points — some standing and watching, some dancing (John's "a bit of both"). Built on the
// same gate-protected rig path as keepers/browsers (spawnRig → buildFigure, so no giants), with a
// placeholder fallback when the fleet is absent (?noassets — a gig with no GLBs is still a gig).
//
// 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); E's instrument GLBs replace
// the primitives here when present (pass opts.instrumentFor). 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';
const CROWD_CAP = 8; // ≤ watchPoints; F's smoke asserts crowd ≤ watchPoints
const MIC = 0x222226, POLE = 0x9a9a9e;
// C's watchPoints/bandPoses `ry` point the fleet mesh 180° from where spawnRig plants it (verified
// in-scene: ry=π faced the band at the backdrop, ry→stage faced the crowd at the door). Flip on spawn
// so the band faces the audience and the crowd faces the stage. (Flagged to C/F to reconcile the seam.)
const RY_FLIP = Math.PI;
// ---- primitive instruments (E's GLBs replace these; footprint-compatible, held at the chest) ----
// Each returns an Object3D positioned in the actor's OUTER-fig space (origin at the feet, +Z front),
// so it rides the performer's procedural sway. Cheap shared-nothing meshes (3 instruments per gig).
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);
// the fleet mesh faces its local +Z (verified in-scene), so the chest — and the instrument — is +Z.
g.position.set(0.02, 1.02, 0.19); // across the chest, toward the audience
g.rotation.set(0.15, 0, big ? 0.7 : 0.62); // slung diagonally, body angled out to the crowd
return 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 (+Z = audience side)
return 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();
return null;
}
export class GigCrew {
constructor({ citySeed = 20261990, fleet = null } = {}) {
this.citySeed = citySeed >>> 0;
this.fleet = fleet;
this.members = []; // { actor, kind:'rig'|'placeholder', part:'band'|'crowd', role, dance, base:{x,y,z,ry}, phase, extra:[] }
this.t = 0;
}
// one seeded rig (or placeholder) actor, planted + facing ry. `key` seeds identity per gig+slot.
// pedIndex forces a specific fleet ped (band members are picked distinct); else a seeded pickRig.
_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 }) — band trio 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;
// ── band: 3 performers on the deck (C's bandPoses: guitar / vocal / bass) ──
if (stage && stage.bandPoses) {
// distinct fleet peds for the trio, seeded 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 ry = p.ry + RY_FLIP;
const { actor, kind } = this._make(roomGroup, `band:${gid}:${p.role}:${i}`,
{ x: p.x, y: stage.deckY || 0, z: p.z, ry, pedIndex: distinct.length ? distinct[i % distinct.length] : null,
height: 1.68 + rng(this.citySeed, 'gigh', `${gid}:${i}`)() * 0.2 });
const extra = [];
const inst = instFor(p.role);
if (inst) { actor.fig.add(inst); extra.push(inst); }
this.members.push({ actor, kind, part: 'band', role: p.role, dance: false,
base: { x: p.x, y: stage.deckY || 0, z: p.z, ry }, phase: rng(this.citySeed, 'gigp', `b${gid}:${i}`)() * Math.PI * 2, extra });
});
}
// ── crowd: stand-and-watch or dance at C's watch points ──
const pts = watchPoints.slice(0, CROWD_CAP);
pts.forEach((w) => {
const ry = w.ry + RY_FLIP;
const { actor, kind } = this._make(roomGroup, `crowd:${gid}:${w.slotIndex}`, { x: w.x, y: 0, z: w.z, ry });
this.members.push({ actor, kind, part: 'crowd', role: 'fan', dance: !!w.dance,
base: { x: w.x, y: 0, z: w.z, 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 };
}
// 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.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) ex.traverse(o => { if (o.isMesh) { o.geometry.dispose(); o.material.dispose && o.material.dispose(); } });
m.actor.dispose && m.actor.dispose();
}
this.members.length = 0;
this.t = 0;
}
}