Lane D R13 (v3.0-beta): 4-piece + instruments, outdoor queue, multi-venue surge, RY fix
- RY audit (debt #4): the ped GLB fleet's visual front is +Z but the whole system assumed -Z (sim heading math, C's pose ry, placeholder, impostor). Fixed at the source (rigs.js buildFigure normalises every rig to -Z front) and dropped band.js RY_FLIP. Corrects keepers/browsers (faced the back wall) and streamed walkers (moon-walked): 223/223 gig + 188/188 flags-off now face travel. Visual only, no golden move. keepers.js/placeholder.js/impostor.js unchanged (fixed for free). Canonical convention -> LANE_D_NOTES for C. - Instruments (debt #2): E's manifest GLBs swap over the primitives async (placeholder-persists, fleet-gated -> 0 fetch under ?noassets), guitar/bass held, mic + kit planted, guitar_amp dressing. - Drummer (debt #3): 4-piece, seated at C's drums pose (sink + kit occlusion; reads from the front/crowd POV - a sit clip is the polish). - Outdoor queue (new queue.js): seeded 2-6 line at the frontage, drains on admitOne() + auto-drain, consumes B's queueZone or a frontage-hugging door fallback. F wiring seam documented in LANE_D_NOTES. - Multi-venue surge (sim.js): setGig -> Set of concurrent venues. - Interior gig 56 draws <=350; ?noassets+?gigs placeholders, silent-and-fine. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
5dac2cac5a
commit
fefbb4b806
@ -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)
|
## 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;
|
**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.**
|
data task, not new rig work. **Verify each exists before requesting; never re-download.**
|
||||||
|
|
||||||
Wanted for richer loiter/keeper behaviour, in priority order:
|
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).
|
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.
|
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).
|
4. `talk` / `gesture` — pairs chatting on the footpath (spawn in seeded 2-groups).
|
||||||
|
|||||||
@ -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
|
// A Friday night at the venue: a 4-piece band on Lane C's stage (guitar / vocal / bass across the
|
||||||
// watch points — some standing and watching, some dancing (John's "a bit of both"). Built on the
|
// front, a seated drummer on the up-stage riser) and a mixed crowd at C's watch points — some
|
||||||
// same gate-protected rig path as keepers/browsers (spawnRig → buildFigure, so no giants), with a
|
// standing, some dancing (John's "a bit of both"). Built on the gate-protected rig path
|
||||||
// placeholder fallback when the fleet is absent (?noassets — a gig with no GLBs is still a gig).
|
// (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,
|
// 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
|
// 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
|
// points (room.stage / room.watchPoints); A owns the gig (plan.gigs). Deterministic from (citySeed, gigId).
|
||||||
// the primitives here when present (pass opts.instrumentFor). Deterministic from (citySeed, gigId).
|
|
||||||
|
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { rng, shuffle } from '../core/prng.js';
|
import { rng, shuffle } from '../core/prng.js';
|
||||||
import { pickRig, spawnRig } from './rigs.js';
|
import { pickRig, spawnRig } from './rigs.js';
|
||||||
import { makePlaceholder } from './placeholder.js';
|
import { makePlaceholder } from './placeholder.js';
|
||||||
|
import { loadGLB } from '../core/loaders.js';
|
||||||
|
|
||||||
const CROWD_CAP = 8; // ≤ watchPoints; F's smoke asserts crowd ≤ watchPoints
|
const CROWD_CAP = 12; // hard ceiling; the real cap is watchPoints.length per venue (≤ always
|
||||||
const MIC = 0x222226, POLE = 0x9a9a9e;
|
// holds — F's smoke asserts it). RSL fields the most (8–12 watch points).
|
||||||
// C's watchPoints/bandPoses `ry` point the fleet mesh 180° from where spawnRig plants it (verified
|
const MIC = 0x222226, POLE = 0x9a9a9e, KIT = 0x8f2b24, CHROME = 0xc8c8cc, AMP = 0x15151a, AMPCLOTH = 0x3a3020;
|
||||||
// in-scene: ry=π faced the band at the backdrop, ry→stage faced the crowd at the door). Flip on spawn
|
const SEAT_DROP = 0.52; // the drummer sits: sink the rig so the kit hides the straight legs.
|
||||||
// so the band faces the audience and the crowd faces the stage. (Flagged to C/F to reconcile the seam.)
|
// Reads seated from the front (the crowd POV / money shot); a true
|
||||||
const RY_FLIP = Math.PI;
|
// 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) {
|
function guitarPrim(bodyCol, big = false) {
|
||||||
const g = new THREE.Group();
|
const g = new THREE.Group();
|
||||||
const bodyMat = new THREE.MeshStandardMaterial({ color: bodyCol, roughness: 0.5, metalness: 0.1 });
|
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);
|
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);
|
neck.position.set(bw * 0.1, bh * 0.5 + (big ? 0.42 : 0.36), 0);
|
||||||
g.add(body, neck);
|
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 (fig-local -Z = the audience-facing front)
|
||||||
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
|
||||||
g.rotation.set(0.15, 0, big ? 0.7 : 0.62); // slung diagonally, body angled out to the crowd
|
return tagOwn(g);
|
||||||
return g;
|
|
||||||
}
|
}
|
||||||
function micStandPrim() {
|
function micStandPrim() {
|
||||||
const g = new THREE.Group();
|
const g = new THREE.Group();
|
||||||
@ -48,26 +56,75 @@ function micStandPrim() {
|
|||||||
new THREE.MeshStandardMaterial({ color: MIC, roughness: 0.6 }));
|
new THREE.MeshStandardMaterial({ color: MIC, roughness: 0.6 }));
|
||||||
head.position.y = 1.44;
|
head.position.y = 1.44;
|
||||||
g.add(pole, head);
|
g.add(pole, head);
|
||||||
g.position.set(0, 0, 0.26); // stands just in front of the singer (+Z = audience side)
|
g.position.set(0, 0, -0.26); // stands just in front of the singer (fig-local -Z)
|
||||||
return g;
|
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) {
|
function instrumentFor(role) {
|
||||||
if (role === 'guitar') return guitarPrim(0xb23b2e); // sunburst-ish red
|
if (role === 'guitar') return guitarPrim(0xb23b2e); // sunburst-ish red
|
||||||
if (role === 'bass') return guitarPrim(0x2f3a52, true); // dark blue, bigger body + longer neck
|
if (role === 'bass') return guitarPrim(0x2f3a52, true); // dark blue, bigger body + longer neck
|
||||||
if (role === 'vocal') return micStandPrim();
|
if (role === 'vocal') return micStandPrim();
|
||||||
|
if (role === 'drums') return drumKitPrim();
|
||||||
return null;
|
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 {
|
export class GigCrew {
|
||||||
constructor({ citySeed = 20261990, fleet = null } = {}) {
|
constructor({ citySeed = 20261990, fleet = null } = {}) {
|
||||||
this.citySeed = citySeed >>> 0;
|
this.citySeed = citySeed >>> 0;
|
||||||
this.fleet = fleet;
|
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;
|
this.t = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// one seeded rig (or placeholder) actor, planted + facing ry. `key` seeds identity per gig+slot.
|
// 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 }) {
|
_make(target, key, { x, y, z, ry, height, pedIndex = null }) {
|
||||||
const r = rng(this.citySeed, 'gig', key);
|
const r = rng(this.citySeed, 'gig', key);
|
||||||
const h = height != null ? height : 1.6 + r() * 0.3;
|
const h = height != null ? height : 1.6 + r() * 0.3;
|
||||||
@ -84,40 +141,91 @@ export class GigCrew {
|
|||||||
return { actor, kind };
|
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 } = {}) {
|
spawn(roomGroup, { stage = null, watchPoints = [], gig = { gigId: 0 }, instrumentFor: instFor = instrumentFor } = {}) {
|
||||||
this.disposeAll();
|
this.disposeAll();
|
||||||
const gid = gig.gigId | 0;
|
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) {
|
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)
|
const distinct = (this.fleet && this.fleet.ready)
|
||||||
? shuffle(rng(this.citySeed, 'gigband', gid), this.fleet.all.map((_, i) => i)) : [];
|
? shuffle(rng(this.citySeed, 'gigband', gid), this.fleet.all.map((_, i) => i)) : [];
|
||||||
stage.bandPoses.forEach((p, 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}`,
|
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 });
|
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);
|
const inst = instFor(p.role);
|
||||||
if (inst) { actor.fig.add(inst); extra.push(inst); }
|
if (inst) { actor.fig.add(inst); member.extra.push(inst); }
|
||||||
this.members.push({ actor, kind, part: 'band', role: p.role, dance: false,
|
this.members.push(member);
|
||||||
base: { x: p.x, y: stage.deckY || 0, z: p.z, ry }, phase: rng(this.citySeed, 'gigp', `b${gid}:${i}`)() * Math.PI * 2, extra });
|
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 ──
|
// ── 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) => {
|
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: w.ry });
|
||||||
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, seated: false,
|
||||||
this.members.push({ actor, kind, part: 'crowd', role: 'fan', dance: !!w.dance,
|
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: [] });
|
||||||
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 };
|
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).
|
// procedural motion layered on the idle clip (seeded phase → the crowd never metronomes in sync).
|
||||||
update(dt) {
|
update(dt) {
|
||||||
this.t += dt;
|
this.t += dt;
|
||||||
@ -127,7 +235,10 @@ export class GigCrew {
|
|||||||
else m.actor.tick && m.actor.tick(dt, false); // placeholder idle
|
else m.actor.tick && m.actor.tick(dt, false); // placeholder idle
|
||||||
const f = m.actor.fig, b = m.base, ph = m.phase;
|
const f = m.actor.fig, b = m.base, ph = m.phase;
|
||||||
if (m.part === 'band') {
|
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.rotation.z = Math.sin(t * 2.0 + ph) * 0.035;
|
||||||
f.position.z = b.z + Math.sin(t * 1.3 + ph) * 0.02;
|
f.position.z = b.z + Math.sin(t * 1.3 + ph) * 0.02;
|
||||||
} else { // guitar / bass: rhythmic strum-sway + tiny bob
|
} else { // guitar / bass: rhythmic strum-sway + tiny bob
|
||||||
@ -149,10 +260,21 @@ export class GigCrew {
|
|||||||
disposeAll() {
|
disposeAll() {
|
||||||
for (const m of this.members) {
|
for (const m of this.members) {
|
||||||
if (m.actor.fig.parent) m.actor.fig.parent.remove(m.actor.fig);
|
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();
|
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.members.length = 0;
|
||||||
|
this.props.length = 0;
|
||||||
|
this._gen++; // invalidate any GLB load still in flight
|
||||||
this.t = 0;
|
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(); } });
|
||||||
|
}
|
||||||
|
|||||||
158
web/js/citizens/queue.js
Normal file
158
web/js/citizens/queue.js
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -100,6 +100,17 @@ const _bs = new THREE.Vector3();
|
|||||||
export function buildFigure(rig, height = 1.75) {
|
export function buildFigure(rig, height = 1.75) {
|
||||||
const inner = skeletonClone(rig.scene);
|
const inner = skeletonClone(rig.scene);
|
||||||
inner.traverse(o => { if (o.isMesh) { o.frustumCulled = false; o.castShadow = false; } });
|
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();
|
const fig = new THREE.Group();
|
||||||
fig.add(inner);
|
fig.add(inner);
|
||||||
fig.updateWorldMatrix(true, true);
|
fig.updateWorldMatrix(true, true);
|
||||||
|
|||||||
@ -123,7 +123,7 @@ export class CitizenSim {
|
|||||||
// R8 shop patronage (default-on for the streamed roster; ?patronage=0 off). No-op until setShops.
|
// 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.shopsByChunk = null; // chunkKey → [{ x, z, hours:[open,close], shopId }] door points
|
||||||
this.patronageOn = true;
|
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.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._occupancy = new Map(); // R9 shopId → [{ seed, enteredAt, pedIndex }] — the interior-presence truth
|
||||||
this._dropKeys = []; // scratch: chunks to drop this frame
|
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.
|
// map: chunkKey → [{ x, z, hours:[open,close] }]. Without it, patronage is inert.
|
||||||
setShops(shopsByChunk) { this.shopsByChunk = shopsByChunk; }
|
setShops(shopsByChunk) { this.shopsByChunk = shopsByChunk; }
|
||||||
setPatronage(on) { this.patronageOn = !!on; }
|
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
|
// 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).
|
// 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 }; }
|
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]; }
|
_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));
|
const list = this.shopsByChunk.get(this.chunkKeyAt(c.x, c.z));
|
||||||
if (!list || !list.length) return null;
|
if (!list || !list.length) return null;
|
||||||
let best = null, bd = PATRON_RANGE;
|
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;
|
if (!this._openAt(s.hours)) continue;
|
||||||
const d = Math.hypot(s.x - c.x, s.z - c.z);
|
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._gigVenues.has(s.shopId) && d < gbd) { gbd = d; gigBest = s; }
|
||||||
if (this._gigVenue != null && s.shopId === this._gigVenue && d < GIG_RANGE) return s;
|
|
||||||
if (d < bd) { bd = d; best = s; }
|
if (d < bd) { bd = d; best = s; }
|
||||||
}
|
}
|
||||||
return best;
|
return gigBest || best;
|
||||||
}
|
}
|
||||||
_beginVisit(c, shop) {
|
_beginVisit(c, shop) {
|
||||||
c._savedWalk = { edge: c.edge, forward: c.forward, s: c.s }; // resume the footpath walk on the way out
|
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) {
|
if (c._patronDist >= PATRON_STRIDE) {
|
||||||
c._patronDist = 0;
|
c._patronDist = 0;
|
||||||
const shop = this._nearestOpenShop(c);
|
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();
|
? Math.max(this._patronChance(), GIG_SURGE) : this._patronChance();
|
||||||
if (shop && c.patronRng() < chance) this._beginVisit(c, shop);
|
if (shop && c.patronRng() < chance) this._beginVisit(c, shop);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user