// 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 (8–12 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, 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 sp = rig && spawnRig(rig, { ry, height: h, clip: this.fleet.idleClip, phase: r() }); if (sp) { actor = sp; kind = 'rig'; usedIndex = pk.index; } // usedIndex: the fleet ped shown — F's continuity smoke reads it } 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) const y = seated ? poseY - SEAT_DROP : poseY; // sink the drummer so the kit hides the legs const { actor, kind, pedIndex } = 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, 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::`), 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.55–1.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 } : { x: w.x, y: 0, z: w.z, ry: w.ry }); 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); }); }); } // 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; } }); // R15 (C's §3 ruling): up-stage-right behind the front line — clears the bass rig and offsets from // C's primitive ampStacks (at ±w·0.28, z−d·0.18), so GLB + primitive read as a flanking backline. // The old z+0.15 was down-stage and interpenetrated the bassist. Room-local coords; grille to the crowd. amp.position.set(stage.x + (stage.w || 3) * 0.36, (stage.deckY || 0), stage.z - (stage.d || 3) * 0.22); 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(); } }); }