// 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; } }