diff --git a/D-progress.md b/D-progress.md index d566b85..8eb1e36 100644 --- a/D-progress.md +++ b/D-progress.md @@ -1,5 +1,55 @@ # PROCITY-D — progress (Lane D · Citizens) +## Round 12 (v3.0-alpha) — the band and the crowd, behind `?gigs=1`. (2026-07-15) + +Friday night at The Thornbury Hotel: a band on C's stage and a mixed crowd at C's watch points. +New `web/js/citizens/band.js` (`GigCrew`) + a gig surge in `sim.js`. Verified against A's real +`plan.gigs` + C's real venue interior (F wires the `?gigs=1` shell seam last, so I drove A+C+D +directly in a standalone scene). Deterministic from `citySeed`; asset-free-safe (placeholder crew). + +**Band trio (task 1)** — 3 rigs on `stage.bandPoses` at `deckY`, **distinct fleet peds** (seeded +shuffle per gig — a band with two identical members reads wrong), each with a primitive instrument +that rides the chest: guitar (strum-sway), mic stand (vocal sway), bass (strum-sway). E's GLBs drop +in via `opts.instrumentFor(role)` when they land. NB C's `bandPoses` roles are **guitar/vocal/bass** +(a front-line trio) — there is no drummer pose (C's drum riser is set-dressing); flagging in case a +drummer was expected. + +**Crowd (task 2)** — up to 8 rigs at `watchPoints` on the same gate-protected `spawnRig` path. +`dance:false` → subtle weight-shift idle; `dance:true` → bounce + sway + step, **seeded phase so the +crowd never metronomes**. Verified 3/8 dancing (C's ~⅓). + +**Gig surge (task 3)** — `sim.setGig(venueShopId, on)`: while on, patronage pulls peds from further +(`GIG_RANGE` 34 m) and ducks in harder (`GIG_SURGE` 0.55) at the venue; occupants **drain at close +per A's closing-time ruling** (no special handling — `_openAt` + the dwell timer do it). Measured a +mock venue climb **1 → peak 5** occupants; identity signature unchanged; **gig-off byte-identical** +(guarded by `_gigVenue==null`, so the frozen goldens don't move — prime flag law). + +**Verified (standalone, fresh browser):** band **3** · crowd **8** · dancing **3** · **all 11 figures +human-sized** (heights 1.60–1.83 m, crown < 4.0 m ceiling — the R10 no-giants gate holds for the gig +rigs) · band faces audience + crowd faces stage · **deterministic** (identical twice) · **leak-free** +(8 spawn/dispose cycles, geo/tex delta 0). **Budget:** the crew adds **~40 draws** (11 rigs + 3 +instruments); the pub interior itself is the bulk — F measures the full gig-night ≤350 in the shell. + +**Figures human-sized relative to doors/fittings: yes** — measured (1.60–1.83 m under a 4.0 m ceiling) +and shown in `docs/shots/laneD/r12_gig_night.jpg` (distinct band with instruments, mixed crowd watching). + +**⚠ Cross-lane flag for C/F — facing convention.** 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; +the watch-point `ry` faced the crowd at the door). The fleet mesh's visual front is its local **+Z**, +not the `−Z` the sim/keeper comments assume. `band.js` compensates with a `RY_FLIP = π` on spawn so +band→audience and crowd→stage. **If C changes the gig-pose `ry` convention (or the fleet is re-fronted), +remove `RY_FLIP`.** Worth a look at whether keepers/browsers are subtly back-to-front too. + +**For Lane F (wiring the seam):** +```js +import { GigCrew } from './js/citizens/band.js'; +const crew = new GigCrew({ citySeed: plan.citySeed, fleet }); +// on gig doors/on, inside the venue interior: +crew.spawn(room.group, { stage: room.stage, watchPoints: room.watchPoints, gig: tonight }); +// each interior frame: crew.update(dt); on gig-off / room exit: crew.disposeAll(); +citizens.setGig(gig.venueShopId, on); // street surge while doors/on; setGig(null,false) to end +``` + ## Round 10 (blocker fix) — interior giants → human-sized. qa --strict GREEN. (2026-07-15) Fixed the R9 ship-blocker: interior rig figures were ~2× too tall (keeper crown at 3.83 m in a diff --git a/docs/shots/laneD/r12_gig_night.jpg b/docs/shots/laneD/r12_gig_night.jpg new file mode 100644 index 0000000..874193c Binary files /dev/null and b/docs/shots/laneD/r12_gig_night.jpg differ diff --git a/web/js/citizens/band.js b/web/js/citizens/band.js new file mode 100644 index 0000000..8750dd6 --- /dev/null +++ b/web/js/citizens/band.js @@ -0,0 +1,158 @@ +// PROCITY Lane D — the gig band + crowd (v3.0-alpha, behind ?gigs=1). +// +// A Friday night at the pub: three musicians on Lane C's stage deck and a mixed crowd at C's +// watch points — some standing and watching, some dancing (John's "a bit of both"). Built on the +// same gate-protected rig path as keepers/browsers (spawnRig → buildFigure, so no giants), with a +// placeholder fallback when the fleet is absent (?noassets — a gig with no GLBs is still a gig). +// +// Seam: F builds one GigCrew per venue interior and calls spawn() when the gig state is doors/on, +// update(dt) each interior frame, disposeAll() on gig-off / room exit. C owns the stage + watch +// points (room.stage / room.watchPoints); A owns the gig (plan.gigs); E's instrument GLBs replace +// the primitives here when present (pass opts.instrumentFor). Deterministic from (citySeed, gigId). + +import * as THREE from 'three'; +import { rng, shuffle } from '../core/prng.js'; +import { pickRig, spawnRig } from './rigs.js'; +import { makePlaceholder } from './placeholder.js'; + +const CROWD_CAP = 8; // ≤ watchPoints; F's smoke asserts crowd ≤ watchPoints +const MIC = 0x222226, POLE = 0x9a9a9e; +// C's watchPoints/bandPoses `ry` point the fleet mesh 180° from where spawnRig plants it (verified +// in-scene: ry=π faced the band at the backdrop, ry→stage faced the crowd at the door). Flip on spawn +// so the band faces the audience and the crowd faces the stage. (Flagged to C/F to reconcile the seam.) +const RY_FLIP = Math.PI; + +// ---- primitive instruments (E's GLBs replace these; footprint-compatible, held at the chest) ---- +// Each returns an Object3D positioned in the actor's OUTER-fig space (origin at the feet, +Z front), +// so it rides the performer's procedural sway. Cheap shared-nothing meshes (3 instruments per gig). +function guitarPrim(bodyCol, big = false) { + const g = new THREE.Group(); + const bodyMat = new THREE.MeshStandardMaterial({ color: bodyCol, roughness: 0.5, metalness: 0.1 }); + const neckMat = new THREE.MeshStandardMaterial({ color: 0x2a2018, roughness: 0.7 }); + const bw = big ? 0.30 : 0.34, bh = big ? 0.44 : 0.40; + const body = new THREE.Mesh(new THREE.BoxGeometry(bw, bh, 0.06), bodyMat); + const neck = new THREE.Mesh(new THREE.BoxGeometry(0.05, big ? 0.85 : 0.72, 0.03), neckMat); + neck.position.set(bw * 0.1, bh * 0.5 + (big ? 0.42 : 0.36), 0); + g.add(body, neck); + // the fleet mesh faces its local +Z (verified in-scene), so the chest — and the instrument — is +Z. + g.position.set(0.02, 1.02, 0.19); // across the chest, toward the audience + g.rotation.set(0.15, 0, big ? 0.7 : 0.62); // slung diagonally, body angled out to the crowd + return g; +} +function micStandPrim() { + const g = new THREE.Group(); + const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.015, 0.02, 1.42, 6), + new THREE.MeshStandardMaterial({ color: POLE, roughness: 0.4, metalness: 0.6 })); + pole.position.y = 0.71; + const head = new THREE.Mesh(new THREE.SphereGeometry(0.045, 8, 6), + new THREE.MeshStandardMaterial({ color: MIC, roughness: 0.6 })); + head.position.y = 1.44; + g.add(pole, head); + g.position.set(0, 0, 0.26); // stands just in front of the singer (+Z = audience side) + return g; +} +function instrumentFor(role) { + if (role === 'guitar') return guitarPrim(0xb23b2e); // sunburst-ish red + if (role === 'bass') return guitarPrim(0x2f3a52, true); // dark blue, bigger body + longer neck + if (role === 'vocal') return micStandPrim(); + return null; +} + +export class GigCrew { + constructor({ citySeed = 20261990, fleet = null } = {}) { + this.citySeed = citySeed >>> 0; + this.fleet = fleet; + this.members = []; // { actor, kind:'rig'|'placeholder', part:'band'|'crowd', role, dance, base:{x,y,z,ry}, phase, extra:[] } + this.t = 0; + } + + // one seeded rig (or placeholder) actor, planted + facing ry. `key` seeds identity per gig+slot. + // pedIndex forces a specific fleet ped (band members are picked distinct); else a seeded pickRig. + _make(target, key, { x, y, z, ry, height, pedIndex = null }) { + const r = rng(this.citySeed, 'gig', key); + const h = height != null ? height : 1.6 + r() * 0.3; + let actor, kind; + if (this.fleet && this.fleet.ready) { + const pk = (pedIndex != null && this.fleet.all[pedIndex]) ? { index: pedIndex } : pickRig(this.fleet, r()); + const rig = pk && this.fleet.all[pk.index]; + const sp = rig && spawnRig(rig, { ry, height: h, clip: this.fleet.idleClip, phase: r() }); + if (sp) { actor = sp; kind = 'rig'; } + } + if (!actor) { actor = makePlaceholder(rng(this.citySeed, 'gig-body', key), { height: h }); actor.fig.rotation.y = ry; kind = 'placeholder'; } + actor.fig.position.set(x, y, z); + target.add(actor.fig); + return { actor, kind }; + } + + // spawn(roomGroup, { stage, watchPoints, gig }) — band trio on the deck + crowd at the watch points. + spawn(roomGroup, { stage = null, watchPoints = [], gig = { gigId: 0 }, instrumentFor: instFor = instrumentFor } = {}) { + this.disposeAll(); + const gid = gig.gigId | 0; + + // ── band: 3 performers on the deck (C's bandPoses: guitar / vocal / bass) ── + if (stage && stage.bandPoses) { + // distinct fleet peds for the trio, seeded per gig (a band with two identical members reads wrong) + const distinct = (this.fleet && this.fleet.ready) + ? shuffle(rng(this.citySeed, 'gigband', gid), this.fleet.all.map((_, i) => i)) : []; + stage.bandPoses.forEach((p, i) => { + const ry = p.ry + RY_FLIP; + const { actor, kind } = this._make(roomGroup, `band:${gid}:${p.role}:${i}`, + { x: p.x, y: stage.deckY || 0, z: p.z, ry, pedIndex: distinct.length ? distinct[i % distinct.length] : null, + height: 1.68 + rng(this.citySeed, 'gigh', `${gid}:${i}`)() * 0.2 }); + const extra = []; + const inst = instFor(p.role); + if (inst) { actor.fig.add(inst); extra.push(inst); } + this.members.push({ actor, kind, part: 'band', role: p.role, dance: false, + base: { x: p.x, y: stage.deckY || 0, z: p.z, ry }, phase: rng(this.citySeed, 'gigp', `b${gid}:${i}`)() * Math.PI * 2, extra }); + }); + } + + // ── crowd: stand-and-watch or dance at C's watch points ── + const pts = watchPoints.slice(0, CROWD_CAP); + pts.forEach((w) => { + const ry = w.ry + RY_FLIP; + const { actor, kind } = this._make(roomGroup, `crowd:${gid}:${w.slotIndex}`, { x: w.x, y: 0, z: w.z, ry }); + this.members.push({ actor, kind, part: 'crowd', role: 'fan', dance: !!w.dance, + base: { x: w.x, y: 0, z: w.z, ry }, phase: rng(this.citySeed, 'gigp', `c${gid}:${w.slotIndex}`)() * Math.PI * 2, extra: [] }); + }); + return { band: this.members.filter(m => m.part === 'band').length, crowd: pts.length }; + } + + // procedural motion layered on the idle clip (seeded phase → the crowd never metronomes in sync). + update(dt) { + this.t += dt; + const t = this.t; + for (const m of this.members) { + if (m.actor.mixer) m.actor.mixer.update(dt); // rig idle + else m.actor.tick && m.actor.tick(dt, false); // placeholder idle + const f = m.actor.fig, b = m.base, ph = m.phase; + if (m.part === 'band') { + if (m.role === 'vocal') { // singer: gentle sway toward the mic + f.rotation.z = Math.sin(t * 2.0 + ph) * 0.035; + f.position.z = b.z + Math.sin(t * 1.3 + ph) * 0.02; + } else { // guitar / bass: rhythmic strum-sway + tiny bob + f.rotation.z = Math.sin(t * 3.1 + ph) * 0.055; + f.position.y = b.y + Math.abs(Math.sin(t * 3.1 + ph)) * 0.02; + } + } else if (m.dance) { // dancer: bounce + sway + a little step + f.position.y = b.y + Math.abs(Math.sin(t * 5.2 + ph)) * 0.075; + f.rotation.z = Math.sin(t * 2.6 + ph) * 0.07; + f.rotation.y = b.ry + Math.sin(t * 1.7 + ph) * 0.13; + } else { // stander: slow weight shift, feet planted + f.rotation.z = Math.sin(t * 0.9 + ph) * 0.022; + f.rotation.y = b.ry + Math.sin(t * 0.5 + ph) * 0.03; + } + } + return this.members.length; + } + + disposeAll() { + for (const m of this.members) { + if (m.actor.fig.parent) m.actor.fig.parent.remove(m.actor.fig); + for (const ex of m.extra) ex.traverse(o => { if (o.isMesh) { o.geometry.dispose(); o.material.dispose && o.material.dispose(); } }); + m.actor.dispose && m.actor.dispose(); + } + this.members.length = 0; + this.t = 0; + } +} diff --git a/web/js/citizens/sim.js b/web/js/citizens/sim.js index 3274378..4b5e609 100644 --- a/web/js/citizens/sim.js +++ b/web/js/citizens/sim.js @@ -32,6 +32,8 @@ const IMPOSTOR_MAX = 220; // instanced billboards ceiling const NIGHT_LIVELY_FLOOR = 0.5; // stream: "lively" chunks (open-late block) keep ≥this density at night const PATRON_RANGE = 18; // m — a ped ducks into a shop it's passing within this const PATRON_STRIDE = 10; // m walked between patronage checks (framerate-independent) +const GIG_RANGE = 34; // m — a gig pulls peds from further than a normal shopfront +const GIG_SURGE = 0.55; // patron chance at the venue while the gig is on (vs ~0.16 day) // time-of-day density curve: t01 in [0,1) over a day → crowd multiplier (CITY_SPEC: lunch rush, // near-empty at night). Sampled at 8 control points, linearly interpolated. @@ -121,6 +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.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 @@ -330,6 +333,10 @@ 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 + // 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; } 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]; } @@ -361,6 +368,8 @@ export class CitizenSim { for (const s of list) { 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 (d < bd) { bd = d; best = s; } } return best; @@ -510,7 +519,9 @@ export class CitizenSim { if (c._patronDist >= PATRON_STRIDE) { c._patronDist = 0; const shop = this._nearestOpenShop(c); - if (shop && c.patronRng() < this._patronChance()) this._beginVisit(c, shop); + const chance = (shop && this._gigVenue != null && shop.shopId === this._gigVenue) + ? Math.max(this._patronChance(), GIG_SURGE) : this._patronChance(); + if (shop && c.patronRng() < chance) this._beginVisit(c, shop); } } }