diff --git a/docs/LANES/LANE_D_NOTES.md b/docs/LANES/LANE_D_NOTES.md index d512f9b..b1a61d7 100644 --- a/docs/LANES/LANE_D_NOTES.md +++ b/docs/LANES/LANE_D_NOTES.md @@ -6,6 +6,101 @@ _rotOnly/head-bone normalize/upgradeStreetPeople). Measurements on the M3 Ultra --- +## ROUND 13 — v3.0-beta the district: the RY fix, instruments/drummer, the queue, multi-venue surge + +*PROCITY-D, 2026-07-15. Behind `?gigs=1`; verified in-shell against A's landed 3-venue plan (pub #116 +`pubrock` / rsl #404 `covers` / band_room #490 `grunge`, 14 gigs / 14 posters) and C's landed 4-pose +stage (guitar/vocal/bass + a seated `drums` on the riser). Files: `rigs.js`, `band.js`, new `queue.js`, +`sim.js`. `keepers.js` / `placeholder.js` / `impostor.js` UNCHANGED — the RY fix at the source corrects +them for free.* + +### Debt #4 — the RY audit: fixed once at the source (⚠ this changes the flags-off street VISUAL) +**Finding (confirmed in-scene):** the imported ped GLBs' visual front is local **+Z**, but *everything +downstream assumes −Z* — `sim.js` heading math (`facing = atan2(-dx,-dz)`), C's pose `ry` +(counter.stand / bandPoses / watchPoints / browse points), the placeholder (`placeholder.js:61` builds +toes to −Z *on purpose*, "like the GLB rigs"), and the impostor bake/pick convention. So the GLB was the +lone outlier: **keepers faced the back wall, browsers faced away, and every streamed rig walker moon-walked +(faced anti-travel)** — all subtly, for ~12 rounds, because near+mid were consistently-wrong-together +(seamless) and peds are mostly seen from behind. R12's band carried a local `RY_FLIP=π` that patched only +the gig. + +**Fix:** one line in `rigs.js buildFigure` — `inner.rotation.y = Math.PI` — normalises every rig's front +to −Z, matching the rest of the system. Then `RY_FLIP` is deleted from `band.js` (band/crowd use C's `ry` +verbatim). Because the impostor atlas bakes this same `buildFigure`/`spawnRig` output and the layer's +yaw-pick already uses the −Z convention, near↔mid stays seamless (both flip together). It's **purely +visual** — a Y-rotation leaves every bone's world-Y untouched, so head-height / feet-plant, the no-giants +gate, and all seeded identity/determinism are byte-unchanged (**no golden move**). + +**Verified:** keeper now faces the shop floor (was the back wall); **223/223** gig-mode + **188/188** +flags-off streamed walkers face their edge-travel direction (avg dot **1.000**; was −1.0); band faces the +crowd + crowd faces the stage with `RY_FLIP` gone; `?noassets` placeholder band (which the old flip left +facing the backdrop) is now correct too. + +- **→ Lane C (pose contract, please land in LANE_C_PUB.md):** *"Rig visual front is canonical **local −Z** + (`rigs.js buildFigure` normalises the GLB's native +Z). All pose `ry` (counter.stand, bandPoses, + watchPoints, browse points) are the −Z-front heading the rig should face — no lane compensates; the R12 + `RY_FLIP` is gone."* Your existing `ry` values were already correct under this — nothing to change. +- **→ Lane F / Fable (flags-off flag):** the fix is in the shared rig stack, so the **default (flags-off) + street crowd now faces travel** instead of moon-walking. Determinism + the golden plan hash are + untouched (visual only), but if any regression snapshots *pixels* it will move — it's a bug fix, re-pin + if needed (improvement, not a leak). + +### Debt #2 — instrument GLBs wired (placeholder-persists, fail-soft, fleet-gated) +`band.js` keeps its primitive instruments as the immediate/​`?noassets` fallback, then swaps E's manifest +GLBs in async when they resolve (same pattern as `interiors/glb.js`). Role → fitting: `guitar`→ +`electric_guitar`, `bass`→`bass_guitar`, `vocal`→`mic_stand` (planted), `drums`→`drum_kit` (planted at the +riser), plus one `guitar_amp` as backline dressing. Manifest is **self-fetched** (promise-cached) so band.js +stays decoupled from C's glb.js, and the whole upgrade is **gated on `fleet.ready`** → under `?noassets` +(fleet null) it never fetches (proven: fresh-tab network shows **0** `procity_fit_*` / `manifest` +requests). Disposal-guarded by a generation counter; GLB clones share the cached geo/mats (detach-only, +never dispose — only the `ownGeo`-tagged primitives are freed). Orientation lives in one `GLB_FIT` table +(guitars need a +Z roll to sling; the kit is scaled 0.66 to fit C's tight 0.73 m vocal↔drums gap). + +### Debt #3 — the drummer (4-piece, Fable's ruling) +`bandPoses.forEach` already picks up C's 4th `drums` pose (`seated:true`, riser `y`). The drummer sinks +`SEAT_DROP` (0.52 m) so the kit hides the straight legs and it reads seated **from the front — the crowd +POV / money shot**; from a hard side angle the sink shows (a standing rig faked as seated). A true seated +pose is a clip job — see the wishlist. Build is **defensive**: if John had vetoed to a trio, C simply +omits the pose and the band is 3 with zero D change. + +### The outdoor queue (charter item — new `web/js/citizens/queue.js`) +`VenueQueue`: a seeded 2–6 line on the same gate-protected rig path, idle-animated, that drains as punters +are admitted and empties by close. Consumes B's `venue.queueZone` when present, else a straight line off +the door — the fallback trails along the **frontage tangent** so it hugs the building instead of spilling +onto the road. Verified: spawn (5 rigs, human-sized, hug the frontage, face the venue), `admitOne()` drains +the head + shuffles everyone up to target, and a seeded auto-drain safety. **F seam** (wire per venue off +the street-side gig state): +```js +const q = new VenueQueue({ citySeed: plan.citySeed, fleet }); +q.spawn(streetGroup, { door, queueZone, gigId }); // at 'doors' (+ 'on' overflow); door = {x,z,ry}, ry = facing the entrance +q.admitOne(); // on each admitted punter (cover-charge / entry hook) +q.update(dt); // each street frame — idle + lerp + slow auto-drain +q.disposeAll(); // at 'done' / venue out of view +``` +**→ Lane B:** publish `venue.queueZone = { x, z, ry, len }` — `(x,z)` = head by the door, `ry` = the +heading a punter faces (toward the entrance), `len` = line length; the line trails single-file opposite the +facing, so pick `ry` to run it along the verandah. + +### Multi-venue surge (`sim.js`) +`setGig(venueShopId, on)` widened from a single `_gigVenue` to a `_gigVenues` **Set** so several concurrent +gigs each pull their own block; `_nearestOpenShop` now prefers the nearest gig venue in `GIG_RANGE` across +all of them. F's R12 single-venue call is a subset — still works. Non-gig patronage path is byte-identical. + +### Measured budgets (seed 20261990, "Boolarra Heads", The Exchange Hotel #116) +| thing | number | budget | +|---|---|---| +| gig-night interior, rig fleet + GLB instruments | **56 draws** · 183k tris | ≤350 draws ✓ | +| — of which instruments (5 GLBs @ ~14k, E's ≤15k target) | ~70k tris | (no interior tri cap) | +| band / crowd | **4** on the deck (3 front + seated drummer) · **8** at 8 watch points | crowd ≤ watchPoints ✓ | +| outdoor queue | seeded **2–6** rigs (~1 draw each) at the frontage | stated | +| `?noassets`+`?gigs` | band+queue placeholders, **0** GLB/manifest fetch | silent-and-fine ✓ | +| flags-off street walkers face travel | 188/188 (dot 1.0) | determinism byte-unchanged ✓ | + +*RSL (#404) fields the crowd-cap stress case: `CROWD_CAP` is 12, real cap = `watchPoints.length` per venue +(≤ always holds). Re-measure the RSL interior once its archetype dressing lands.* + +--- + ## ROUND 9 — interior presence: occupancy truth + browser rigs (→ Lane F handoff below) **Follow a ped into a shop and find them browsing.** D owns occupancy truth; C owns the browse points; @@ -493,7 +588,10 @@ bind directly through the canonical `mixamorig` skeleton exactly like walk/idle, data task, not new rig work. **Verify each exists before requesting; never re-download.** Wanted for richer loiter/keeper behaviour, in priority order: -1. `sit` — bench-sit at benches/verandah steps (sim loiter already has the hook; needs the clip). +1. `sit` — bench-sit at benches/verandah steps (sim loiter has the hook; needs the clip). **R13: the gig + drummer also wants this (or a bespoke `drum`/`play-drums` loop)** — the 4th band member is currently a + standing rig sunk `SEAT_DROP` behind the kit, which reads seated only from the front. A real seated clip + retires the sink hack (band.js `SEAT_DROP` + the `m.seated` branch in `update`). 2. `lean` — loiter against a shopfront (window-shopping stops). 3. `look-around` / `idle-look` — window-shopping variety + a better keeper greet than the body-turn. 4. `talk` / `gesture` — pairs chatting on the footpath (spawn in seeded 2-groups). diff --git a/web/js/citizens/band.js b/web/js/citizens/band.js index 8750dd6..262ea05 100644 --- a/web/js/citizens/band.js +++ b/web/js/citizens/band.js @@ -1,30 +1,39 @@ -// PROCITY Lane D — the gig band + crowd (v3.0-alpha, behind ?gigs=1). +// PROCITY Lane D — the gig band + crowd (v3.0-beta, 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). +// 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); E's instrument GLBs replace -// the primitives here when present (pass opts.instrumentFor). Deterministic from (citySeed, gigId). +// 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 = 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; +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; } -// ---- 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 }); @@ -34,10 +43,9 @@ function guitarPrim(bodyCol, big = false) { 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; + 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(); @@ -48,26 +56,75 @@ function micStandPrim() { 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; + 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 === '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:'rig'|'placeholder', part:'band'|'crowd', role, dance, base:{x,y,z,ry}, phase, extra:[] } + 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. - // 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; @@ -84,40 +141,91 @@ export class GigCrew { return { actor, kind }; } - // spawn(roomGroup, { stage, watchPoints, gig }) — band trio on the deck + crowd at the watch points. + // spawn(roomGroup, { stage, watchPoints, gig }) — the band 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; + this._gen++; + const gen = this._gen; - // ── band: 3 performers on the deck (C's bandPoses: guitar / vocal / bass) ── + // ── band: C's bandPoses (guitar / vocal / bass across the front, + a seated `drums` on the riser) ── if (stage && stage.bandPoses) { - // distinct fleet peds for the trio, seeded per gig (a band with two identical members reads wrong) + // 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 ry = p.ry + RY_FLIP; + 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 } = 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, + { 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 extra = []; + const member = { actor, kind, 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); 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 }); + 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: stand-and-watch or dance at C's watch points ── - const pts = watchPoints.slice(0, CROWD_CAP); + const pts = watchPoints.slice(0, Math.min(CROWD_CAP, watchPoints.length)); 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: [] }); + const { actor, kind } = this._make(roomGroup, `crowd:${gid}:${w.slotIndex}`, { x: w.x, y: 0, z: w.z, ry: w.ry }); + this.members.push({ actor, kind, part: 'crowd', role: 'fan', dance: !!w.dance, seated: false, + 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 }; } + // 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; } }); + // off to stage-right of the drum riser, on the deck, facing the crowd (world +Z). Room-local coords. + amp.position.set(stage.x + (stage.w || 3) * 0.32, (stage.deckY || 0), stage.z + 0.15); + 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; @@ -127,7 +235,10 @@ export class GigCrew { 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 + 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 @@ -149,10 +260,21 @@ export class GigCrew { 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(); } }); + 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(); } }); +} diff --git a/web/js/citizens/queue.js b/web/js/citizens/queue.js new file mode 100644 index 0000000..75102ba --- /dev/null +++ b/web/js/citizens/queue.js @@ -0,0 +1,158 @@ +// PROCITY Lane D — the outdoor gig queue (v3.0-beta charter item, behind ?gigs=1). +// +// While the doors are open, a short line of punters waits at the venue frontage and drains into the +// room as people are admitted — gone by closing (`done`). Street-side companion to the interior +// GigCrew: same gate-protected rig path (spawnRig → buildFigure, so no giants; rigs face -Z after the +// R13 facing-normalise), same placeholder fallback under ?noassets (a queue of boxes is still a queue). +// +// Seam (Lane F wires it from the street-side gig state, per venue): +// const q = new VenueQueue({ citySeed: plan.citySeed, fleet }); +// q.spawn(streetGroup, { door, queueZone, gigId }); // at 'doors' (and 'on' overflow) +// q.admitOne(); // when a punter is admitted (F's cover-charge / entry hook) +// q.update(dt); // each street frame — idle shuffle + lerp + slow auto-drain +// q.disposeAll(); // at 'done' / venue out of view +// +// Geometry contract (consistent with the post-R13 -Z rig front): +// door = { x, z, ry } ry = the heading a punter at the door FACES to look at the entrance. +// queueZone = { x, z, ry, len } | null Lane B's marker: (x,z) = head by the door, ry = that same facing, +// len = line length (m). Absent → a straight line trailing the door. +// The line trails BEHIND the head (opposite the facing), punters spaced along it, everyone facing the door. + +import * as THREE from 'three'; +import { rng } from '../core/prng.js'; +import { pickRig, spawnRig } from './rigs.js'; +import { makePlaceholder } from './placeholder.js'; + +const SPACING = 0.85; // m between punters down the line +const QUEUE_MIN = 2, QUEUE_MAX = 6; +const DRAIN_MIN = 3.5, DRAIN_MAX = 7.0; // s between auto-admissions (seeded) — the safety drain if F + // doesn't couple admitOne() to real entries; empties over the night. +const STEP_EASE = 4.0; // how fast a punter slides up into a vacated slot + +export class VenueQueue { + constructor({ citySeed = 20261990, fleet = null } = {}) { + this.citySeed = citySeed >>> 0; + this.fleet = fleet; + this.members = []; // { actor, kind, slot, target:{x,z}, base:{ry}, phase } + this.parent = null; + this.gid = 0; + this._gen = 0; + this.t = 0; + this._drainAt = 0; + } + + // Build the seeded line. `count` overrides the seeded 2..6 (F may cap to the room's spare capacity). + spawn(parent, { door = null, queueZone = null, gigId = 0, count = null } = {}) { + this.disposeAll(); + this._gen++; + this.gid = gigId | 0; + this.parent = parent; + const anchor = this._anchor(door, queueZone); + if (!anchor) return { queue: 0 }; + this.anchor = anchor; + const r = rng(this.citySeed, 'queuecount', this.gid); + const n = count != null ? Math.max(0, count | 0) : QUEUE_MIN + Math.floor(r() * (QUEUE_MAX - QUEUE_MIN + 1)); + for (let i = 0; i < n; i++) this._spawnAt(i); + this._drainAt = this.t + this._nextDrainGap(); + return { queue: this.members.length }; + } + + // Resolve the head + facing + trail direction from B's zone, else a straight line off the door. + // Punters always face `ry` (toward the entrance; post-R13 rigs face world (-sin ry, -cos ry)). B's + // zone trails single-file the opposite way (B lays it down the verandah via x/z/ry/len). The door-only + // fallback instead trails along the frontage TANGENT, so the line hugs the building rather than spilling + // into the road — the "along the frontage" read, best-effort until B's queueZone lands. + _anchor(door, queueZone) { + const src = queueZone || door; + if (!src || src.x == null) return null; + const ry = src.ry || 0; + const trail = queueZone + ? { x: Math.sin(ry), z: Math.cos(ry) } // opposite the facing → classic single file + : { x: Math.cos(ry), z: -Math.sin(ry) }; // perpendicular to facing → along the frontage + return { x: src.x, z: src.z, ry, trail, len: queueZone && queueZone.len ? queueZone.len : null }; + } + + _spawnAt(slot) { + const a = this.anchor; + // even spacing; if B gave a finite zone length, don't overrun it (compress to fit) + const gap = (a.len && this.members ? Math.min(SPACING, a.len / Math.max(1, QUEUE_MAX - 1)) : SPACING); + const tx = a.x + a.trail.x * gap * slot; + const tz = a.z + a.trail.z * gap * slot; + const key = `${this.gid}:${slot}`; + const r = rng(this.citySeed, 'queueh', key); + const h = 1.58 + r() * 0.34; + let actor, kind; + if (this.fleet && this.fleet.ready) { + const pk = pickRig(this.fleet, r()); + const rig = pk && this.fleet.all[pk.index]; + const sp = rig && spawnRig(rig, { ry: a.ry, height: h, clip: this.fleet.idleClip, phase: r() }); + if (sp) { actor = sp; kind = 'rig'; } + } + if (!actor) { actor = makePlaceholder(rng(this.citySeed, 'queuebody', key), { height: h }); actor.fig.rotation.y = a.ry; kind = 'placeholder'; } + actor.fig.position.set(tx, 0, tz); + this.parent.add(actor.fig); + this.members.push({ actor, kind, slot, target: { x: tx, z: tz }, base: { ry: a.ry }, + phase: rng(this.citySeed, 'queuep', key)() * Math.PI * 2 }); + } + + _gap() { + const a = this.anchor; + return (a && a.len ? Math.min(SPACING, a.len / Math.max(1, QUEUE_MAX - 1)) : SPACING); + } + _nextDrainGap() { return DRAIN_MIN + rng(this.citySeed, 'queuedrain', `${this.gid}:${this.members.length}:${this.t | 0}`)() * (DRAIN_MAX - DRAIN_MIN); } + + // Admit the head of the line (nearest the door): it walks in, everyone else shuffles up one slot. + // Returns the admitted count so far (F can stop when the room is at watchPoint cap). + admitOne() { + if (!this.members.length) return 0; + // the head = the smallest slot (closest to the anchor) + let headIdx = 0; + for (let i = 1; i < this.members.length; i++) if (this.members[i].slot < this.members[headIdx].slot) headIdx = i; + const head = this.members.splice(headIdx, 1)[0]; + if (head.actor.fig.parent) head.actor.fig.parent.remove(head.actor.fig); + head.actor.dispose && head.actor.dispose(); + // shuffle everyone forward one slot → new target one step closer to the door + const a = this.anchor, gap = this._gap(); + for (const m of this.members) { + m.slot = Math.max(0, m.slot - 1); + m.target.x = a.x + a.trail.x * gap * m.slot; + m.target.z = a.z + a.trail.z * gap * m.slot; + } + this._drainAt = this.t + this._nextDrainGap(); + return 1; + } + + // idle shuffle + slide up into vacated slots. The seeded auto-drain is a safety so the line empties + // over the night even if F doesn't couple admitOne() to real entries; call disposeAll() at `done`. + update(dt, { autoDrain = true } = {}) { + this.t += dt; + const t = this.t; + if (autoDrain && this.members.length && t >= this._drainAt) this.admitOne(); + for (const m of this.members) { + if (m.actor.mixer) m.actor.mixer.update(dt); + else m.actor.tick && m.actor.tick(dt, false); + const f = m.actor.fig, ph = m.phase; + // ease toward the (possibly just-vacated) slot + f.position.x += (m.target.x - f.position.x) * Math.min(1, dt * STEP_EASE); + f.position.z += (m.target.z - f.position.z) * Math.min(1, dt * STEP_EASE); + const moving = Math.hypot(m.target.x - f.position.x, m.target.z - f.position.z) > 0.03; + if (m.actor.setMoving) m.actor.setMoving(moving); + // waiting-punter fidget: slow weight shift + the odd glance, feet planted + f.rotation.z = Math.sin(t * 0.8 + ph) * 0.02; + f.rotation.y = m.base.ry + Math.sin(t * 0.4 + ph) * 0.05; + } + return this.members.length; + } + + count() { return this.members.length; } + + disposeAll() { + for (const m of this.members) { + if (m.actor.fig.parent) m.actor.fig.parent.remove(m.actor.fig); + m.actor.dispose && m.actor.dispose(); + } + this.members.length = 0; + this._gen++; + this.t = 0; + } +} diff --git a/web/js/citizens/rigs.js b/web/js/citizens/rigs.js index f8d236f..56a1c45 100644 --- a/web/js/citizens/rigs.js +++ b/web/js/citizens/rigs.js @@ -100,6 +100,17 @@ const _bs = new THREE.Vector3(); export function buildFigure(rig, height = 1.75) { const inner = skeletonClone(rig.scene); inner.traverse(o => { if (o.isMesh) { o.frustumCulled = false; o.castShadow = false; } }); + // FACING NORMALISE (R13 debt #4 — the RY audit). The imported GLB fleet's visual front is local +Z, + // but EVERYTHING downstream assumes -Z: the sim's heading math (sim.js atan2(-dx,-dz)), C's pose ry + // (counter.stand / bandPoses / watchPoints / browse points), the placeholder (toes point -Z on + // purpose, placeholder.js:61), and the impostor bake/pick (impostor.js -Z convention). So the GLB was + // the lone outlier: keepers faced the back wall, browsers faced away, streamed walkers moon-walked + // (anti-travel), and band.js carried a local RY_FLIP=π to patch just the gig. Rotate the rig's own + // mesh 180° here so its front becomes -Z like the rest of the system — one place, everyone correct, + // near↔mid stays seamless (the impostor bakes this same figure), and the flip is purely visual (a + // Y-rotation leaves every bone's world-Y untouched → head-height/feet-plant + the no-giants gate and + // all seeded identity/determinism are byte-unchanged). See LANE_D_NOTES R13 + LANE_C_PUB.md pose contract. + inner.rotation.y = Math.PI; const fig = new THREE.Group(); fig.add(inner); fig.updateWorldMatrix(true, true); diff --git a/web/js/citizens/sim.js b/web/js/citizens/sim.js index 4b5e609..9005011 100644 --- a/web/js/citizens/sim.js +++ b/web/js/citizens/sim.js @@ -123,7 +123,7 @@ export class CitizenSim { // R8 shop patronage (default-on for the streamed roster; ?patronage=0 off). No-op until setShops. this.shopsByChunk = null; // chunkKey → [{ x, z, hours:[open,close], shopId }] door points this.patronageOn = true; - this._gigVenue = null; // R12: venueShopId while a gig is on (F sets via setGig) → surge + this._gigVenues = new Set(); // R13: venueShopIds with a gig on (F sets per-venue via setGig) → multi-venue surge this.weather = { state: 'clear', intensity: 0 }; // Lane B's PROCITY.weather contract (shell feeds it) this._occupancy = new Map(); // R9 shopId → [{ seed, enteredAt, pedIndex }] — the interior-presence truth this._dropKeys = []; // scratch: chunks to drop this frame @@ -333,10 +333,15 @@ export class CitizenSim { // map: chunkKey → [{ x, z, hours:[open,close] }]. Without it, patronage is inert. setShops(shopsByChunk) { this.shopsByChunk = shopsByChunk; } setPatronage(on) { this.patronageOn = !!on; } - // R12 gig-night surge (?gigs=1): while a gig is on, F points patronage at the venue so the street + // Gig-night surge (?gigs=1): while a gig is on, F points patronage at the venue so the street // converges on the pub — it pulls from further (GIG_RANGE) and ducks in harder (GIG_SURGE). Occupants // drain at close per A's closing-time ruling (no special handling — _openAt + the dwell timer do it). - setGig(venueShopId, on = true) { this._gigVenue = on && venueShopId != null ? venueShopId : null; } + // R13 the district: per-venue, so several concurrent gigs each pull their own block. F calls this once + // per venue off its per-venue state; the alpha single-venue call (one id) is a subset — still works. + setGig(venueShopId, on = true) { + if (venueShopId == null) return; + if (on) this._gigVenues.add(venueShopId); else this._gigVenues.delete(venueShopId); + } setWeather(w) { if (w && typeof w.state === 'string') this.weather = { state: w.state, intensity: +w.intensity || 0 }; } _openAt(hours) { if (!hours) return true; const h = (this.timeOfDay % 1) * 24; return h >= hours[0] && h < hours[1]; } @@ -365,14 +370,14 @@ export class CitizenSim { const list = this.shopsByChunk.get(this.chunkKeyAt(c.x, c.z)); if (!list || !list.length) return null; let best = null, bd = PATRON_RANGE; - for (const s of list) { + let gigBest = null, gbd = GIG_RANGE; // R13: the nearest gig venue in range — it wins over ordinary + for (const s of list) { // shops (follow the sound), and across concurrent venues. if (!this._openAt(s.hours)) continue; const d = Math.hypot(s.x - c.x, s.z - c.z); - // gig night: the venue reaches further and wins over a nearer shop (follow the sound to the pub) - if (this._gigVenue != null && s.shopId === this._gigVenue && d < GIG_RANGE) return s; + if (this._gigVenues.has(s.shopId) && d < gbd) { gbd = d; gigBest = s; } if (d < bd) { bd = d; best = s; } } - return best; + return gigBest || best; } _beginVisit(c, shop) { c._savedWalk = { edge: c.edge, forward: c.forward, s: c.s }; // resume the footpath walk on the way out @@ -519,7 +524,7 @@ export class CitizenSim { if (c._patronDist >= PATRON_STRIDE) { c._patronDist = 0; const shop = this._nearestOpenShop(c); - const chance = (shop && this._gigVenue != null && shop.shopId === this._gigVenue) + const chance = (shop && this._gigVenues.has(shop.shopId)) ? Math.max(this._patronChance(), GIG_SURGE) : this._patronChance(); if (shop && c.patronRng() < chance) this._beginVisit(c, shop); }