The last charter promise. A per-venue tonight roster (sim.js _venueRoster) of
who entered a gig venue tonight — surge occupants self-record in _enter, queue
admits relay via citizens.recordVenueEntry — is consumed by GigCrew.spawn: the
crowd's front slots BECOME the roster identities (same pedIndex, same height),
seeded strangers fill the rest.
- No golden move: both goldens are plan-gen hashes; each crowd slot is an
independently-keyed rng stream, so a roster override touches only that slot and
an EMPTY roster is byte-identical to R13 (verified). Roster heights 1.55-1.95m
stay inside the no-giants [1.4,2.0] gate. Pure bookkeeping: 0 rng, 0 GPU.
- Roster persists across interior exit/re-enter, bounded, auto-clears on the
setGig(id,false) transition (per-night). Deduped by stable key.
- queue.js: admitOne() returns {pedIndex,height,key} + fires an onAdmit callback
(auto-drain AND explicit) so F relays every walked-in punter to the roster;
_spawnAt stashes the identity. band.js: _make returns pedIndex; members carry
it for F's continuity smoke; crew.spawn is now null-gig-safe.
- Verified: surge 8/8 + queue 4/4 real identities land in the crowd; empty-roster
== R13; 7-night x 3-venue soak leak geo0/tex0, roster bounded, no identity
churn; interior 64 draws <=350 (0 new rigs); ?noassets placeholders, 0 fetch.
- F seam in LANE_D_NOTES: crew.spawn({roster}) + queue onAdmit relay + the
tonightRoster(venueShopId) accessor.
- Debt #2 (sit clip): investigated, deferred to v3.1 — the clip converts+binds
(tailnet Sitting_Idle.fbx -> 394KB sit.glb) but a real seated pose is ~1.2m
stature (fails F's no-giants floor) and needs a _rotWithHips loader + replant
(shared rig-stack) + an F smoke exemption; not a freeze-week change. SEAT_DROP
hack kept (reads seated from the money-shot front).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
166 lines
8.7 KiB
JavaScript
166 lines
8.7 KiB
JavaScript
// 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).
|
|
// onAdmit (R14): fired with {pedIndex,height,key} on EVERY admit (auto-drain or explicit), so F can relay
|
|
// the punter who walked in into the sim's tonight roster (citizens.recordVenueEntry) → they join the crowd.
|
|
spawn(parent, { door = null, queueZone = null, gigId = 0, count = null, onAdmit = null } = {}) {
|
|
this.disposeAll();
|
|
this._gen++;
|
|
this.gid = gigId | 0;
|
|
this.parent = parent;
|
|
this.onAdmit = onAdmit;
|
|
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, pedIndex = null;
|
|
if (this.fleet && this.fleet.ready) {
|
|
const pk = pickRig(this.fleet, r());
|
|
pedIndex = pk ? pk.index : null; // R14: remember the ped so admitOne can hand it to the crowd
|
|
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, pedIndex, height: h, key, // key = `${gid}:${slot}` — stable per punter (slot shuffles, key doesn't)
|
|
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.
|
|
// R14: returns the admitted punter's identity {pedIndex, height, key} (or null when empty) and fires
|
|
// onAdmit(identity) — F relays it to citizens.recordVenueEntry so the punter joins the interior crowd.
|
|
admitOne() {
|
|
if (!this.members.length) return null;
|
|
// 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();
|
|
const identity = { pedIndex: head.pedIndex, height: head.height, key: head.key };
|
|
// 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();
|
|
if (this.onAdmit) this.onAdmit(identity); // relay the walked-in punter → the crowd roster (auto or explicit)
|
|
return identity;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|