Lane D round 12: gig band + crowd + patronage surge (?gigs=1)
v3.0-alpha — Friday night at the pub. New web/js/citizens/band.js (GigCrew): - Band trio on C's stage.bandPoses (deckY), distinct fleet peds (seeded per gig), each with a primitive instrument on the chest (guitar/mic/bass; E's GLBs drop in via opts.instrumentFor). - Crowd (<=8) at C's watchPoints on the gate-protected spawnRig path: dance:false = weight-shift idle, dance:true = bounce/sway/step with seeded phase (no metronome). - Deterministic from citySeed; placeholder crew under ?noassets. sim.js: setGig(venueShopId, on) gig-night surge — patronage pulls peds from further (GIG_RANGE) and ducks in harder (GIG_SURGE) at the venue; occupants drain at close per A's closing-time ruling. Guarded by _gigVenue==null so gig-off is byte-identical (prime flag law; goldens unmoved). Verified standalone (A withGigs + C buildInterior + D): band 3, crowd 8, dancing 3, all 11 figures human-sized (1.60-1.83m under a 4.0m ceiling; R10 no-giants holds), band faces audience + crowd faces stage, deterministic, leak-free (8 cycles, 0 delta). Crew ~40 draws. Surge: venue occupancy 1 -> peak 5, identity stable. Shot: docs/shots/laneD/r12_gig_night.jpg. Flag for C/F: band.js flips C's watchPoints/bandPoses ry by pi (RY_FLIP) — the fleet mesh's visual front is local +Z, not the -Z the comments assume. Remove RY_FLIP if C re-fronts the gig poses. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
7795780336
commit
888335d633
@ -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
|
||||
|
||||
BIN
docs/shots/laneD/r12_gig_night.jpg
Normal file
BIN
docs/shots/laneD/r12_gig_night.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 306 KiB |
158
web/js/citizens/band.js
Normal file
158
web/js/citizens/band.js
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user