// 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'; 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 } = {}) { const r = rng(this.citySeed, browse ? 'browser' : 'keeper', seedKey || shopId); const height = 1.58 + r() * 0.34; let actor, kind; if (this.fleet && this.fleet.ready) { const idx = (pedIndex != null && this.fleet.all[pedIndex]) ? pedIndex : (pickRig(this.fleet, r()) || {}).index; const rig = idx != null && this.fleet.all[idx]; const spawned = rig && spawnRig(rig, { ry, height, clip: this.fleet.idleClip, phase: r() }); if (spawned) { actor = spawned; kind = 'rig'; } } 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); const k = { actor, kind, baseRy: ry, curTurn: 0, target, shopId, type, browse }; 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; for (const k of this.keepers) { const a = k.actor; 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; } } } }