- 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>
159 lines
7.9 KiB
JavaScript
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;
|
|
}
|
|
}
|