// 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; } }