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>
159 lines
8.4 KiB
JavaScript
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;
|
|
}
|
|
}
|