PROCITY/web/js/citizens/queue.js
m3ultra fefbb4b806 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>
2026-07-16 00:00:05 +10:00

159 lines
7.9 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).
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;
}
}