// PROCITY Lane D — shopkeepers. One keeper per open shop, standing at the counter slot, playing a // shared idle clip, turning to greet the player when they come near. Keepers are spawned when an // interior builds and disposed with it (they don't share the street's rig pool — a handful at a time, // each owns its actor). // // Coordination (Lane F wiring): Lane C exposes the counter position + facing in its interior `places`; // pass it to spawn(). Until Lane C lands, the test page mocks a counter. Deterministic per shop: // rng(citySeed,'keeper',shopId) picks the ped type + height so a shop's keeper is stable across visits. import * as THREE from 'three'; import { rng } from '../core/prng.js'; import { pickRig, spawnRig } from './rigs.js'; import { makePlaceholder } from './placeholder.js'; import { browsePostureFor, keeperPostureFor, GROUP_OF } from './postures.js'; const GREET_RANGE = 6.0; // m — start turning to the player inside this const GREET_CLAMP = 0.62; // rad — max turn off the counter-facing base (~35°) const GREET_EASE = 3.5; // turn responsiveness export class KeeperManager { constructor({ camera = null, citySeed = 20261990, fleet = null } = {}) { this.camera = camera; this.citySeed = citySeed >>> 0; this.fleet = fleet; this.keepers = []; this._p = new THREE.Vector3(); } // spawn(target, { x, z, ry, shopId, type, browse, pedIndex, seedKey }) → keeper handle. // ry = the counter's outward facing (keeper) / the browse point's shelf-facing yaw (browser). // browse = R9 interior-presence: a browser rig at a C browse point — faces the goods, no greet. // pedIndex = pick this exact fleet ped (so a browser IS the ped who ducked in off the street). // seedKey = per-instance seeding key (e.g. `${shopId}#${slot}`) so browsers at one shop differ. spawn(target, { x = 0, z = 0, ry = 0, shopId = 'shop', type = 'shop', browse = false, pedIndex = null, seedKey = null, slot = null } = {}) { const r = rng(this.citySeed, browse ? 'browser' : 'keeper', seedKey || shopId); const height = 1.58 + r() * 0.34; // [R41 §41.3] A REAL BROWSE STATE. Until now a "browser" was a rig standing at Lane C's browse // point playing the shopkeeper's idle. It now plays a browse clip — hold / carry / open the // cabinet / pick the record up — seeded per (shopId, slot), NOT per visit, so the same shop reads // the same way every time you walk back in. F already passes `seedKey = "#"`; the slot // index is taken from it when the caller doesn't pass one, so no shell edit is needed. // The keeper gets a seeded idle variant instead, or a trade flavour (the publican pours, the // record-shop keeper wears the cans) — see postures.KEEPER_TYPE_CLIP. const bank = this.fleet && this.fleet.bank; const slotIdx = slot != null ? slot : (seedKey && /#(\d+)$/.test(seedKey) ? +/#(\d+)$/.exec(seedKey)[1] : 0); const wantId = browse ? browsePostureFor(this.citySeed, shopId, slotIdx) : keeperPostureFor(this.citySeed, shopId, type); if (bank && wantId) bank.ensureGroup(GROUP_OF[wantId]); // first interior fetches browse.glb; a // pub/record counter fetches venue.glb let actor, kind, phase = 0; // [R42 §42.4] which BODY this figure is wearing, published on the handle. Purely informational — // it draws no randoms and changes no behaviour — but without it there is no way to audit a room's // cast at runtime, which is exactly what the recast round needed to prove (band.js already // publishes `pedIndex` on every crew member; keepers were the gap). let pedIdx = null; if (this.fleet && this.fleet.ready) { // NB the `r()` draw order below is R9's, unchanged and deliberately so: height, then pickRig // (only when the caller did NOT pin a pedIndex), then phase (only when a rig resolved). The // R41 posture is drawn from its OWN stream above, so no keeper's ped type or height moves. const idx = (pedIndex != null && this.fleet.all[pedIndex]) ? pedIndex : (pickRig(this.fleet, r()) || {}).index; const rig = idx != null && this.fleet.all[idx]; const want = bank ? bank.get(wantId) : null; // resident already? use it at spawn const spawned = rig && spawnRig(rig, { ry, height, clip: want || this.fleet.idleClip, phase: (phase = r()) }); if (spawned && want) spawned.setClip(want, { phase }); // re-plant off the posed skeleton if (spawned) { actor = spawned; kind = 'rig'; pedIdx = idx; } } if (!actor) { // asset-free fallback const ph = makePlaceholder(rng(this.citySeed, browse ? 'browser-body' : 'keeper-body', seedKey || shopId), { height }); ph.fig.rotation.y = ry; actor = ph; kind = 'placeholder'; } actor.fig.position.set(x, 0, z); target.add(actor.fig); // `want` is kept ONLY while the assigned clip is still unresolved — update() upgrades in place the // frame its group lands and then clears it. That is what makes lazy loading safe here: the clip a // browse point plays is decided by (citySeed, shopId, slot) at spawn, and the fetch only decides // when it starts, never which. A boot with no bank leaves `want` null and nothing ever runs. // Gated on `fleet.clipsRequested` (boot-stable), NOT on `bank` — a spawn that happens before the // dynamic import resolves would otherwise silently lose its posture forever. update() kicks the // group itself in that case, so the only thing timing decides is when the upgrade shows. const wantsIt = kind === 'rig' && wantId && this.fleet && this.fleet.clipsRequested && !(bank && bank.has(wantId)); const k = { actor, kind, baseRy: ry, curTurn: 0, target, shopId, type, browse, pedIndex: pedIdx, want: wantsIt ? wantId : null, phase }; this.keepers.push(k); return k; } remove(k) { if (!k) return; const i = this.keepers.indexOf(k); if (i >= 0) this.keepers.splice(i, 1); k.target.remove(k.actor.fig); k.actor.dispose?.(); } disposeAll() { [...this.keepers].forEach(k => this.remove(k)); } // update(dt, playerPos?) — tick idle animation + the greet head/body turn. playerPos defaults to // the camera position (the player IS the camera in interior mode). update(dt, playerPos = null) { const pp = playerPos || (this.camera && this.camera.position) || null; const bank = this.fleet && this.fleet.bank; for (const k of this.keepers) { const a = k.actor; // [R41 §41.3] self-heal: the assigned clip's group finished fetching → install it now, once. if (k.want && bank && a.setClip) { const c = bank.get(k.want); if (c) { a.setClip(c, { phase: k.phase }); k.want = null; } else bank.ensureGroup(GROUP_OF[k.want]); // spawned before the bank existed → kick it now } if (a.mixer) a.mixer.update(dt); // rig idle else a.tick?.(dt, false); // placeholder idle if (k.browse) continue; // browsers face the goods (ry) — no greet turn if (pp) { const dx = pp.x - a.fig.position.x, dz = pp.z - a.fig.position.z; const dist = Math.hypot(dx, dz); let want = 0; if (dist < GREET_RANGE) { // desired absolute facing toward the player, expressed relative to the counter base const toPlayer = Math.atan2(-dx, -dz); // rig-front convention (local -Z) let rel = toPlayer - k.baseRy; rel = Math.atan2(Math.sin(rel), Math.cos(rel)); // wrap to -π..π want = Math.max(-GREET_CLAMP, Math.min(GREET_CLAMP, rel)); } k.curTurn += (want - k.curTurn) * Math.min(1, dt * GREET_EASE); a.fig.rotation.y = k.baseRy + k.curTurn; } } } }