PROCITY/web/js/citizens/keepers.js
m3ultra 34145e24a7 Lane D round-9: interior presence — occupancy truth + browser rigs (C->D->F seam)
Follow a ped into a shop and find them browsing. D owns occupancy truth; C owns browse points;
F wires the handoff. Validated end-to-end in-shell (?stock=real). qa --strict GREEN; v1 untouched.

Occupancy (D1): patronage records who's inside which shop by shopId. New
citizens.occupancyOf(shopId) -> {count, occupants:[{seed,enteredAt,pedIndex}]} (pedIndex = the
exact ped who ducked in, for identity continuity). Cleaned on emerge/chunk-drop/stream-toggle.
Deterministic: 17 shops + occupants byte-equal across two runs (fixed a stale-occupancy leak on
stream toggle).

Browsers: keepers.js gained browse:true (faces the shelf via ry, no player-greet) + pedIndex +
seedKey. Each = a merged ped ~1 draw. 12 enter/exit cycles: count == min(occupancy,3) 12/12,
worst 61 draws <=350 (C's headroom holds), leak-free (0 GPU delta after warmup), disposed every
exit via keepers.disposeAll().

Consistency (D2): inside peds hidden on the street (0 rendered), re-emerge at the door.

F handoff verified by running F's exact interior_mode wiring (enter -> occupancyOf ->
browsePoints.slice(0,min(count,3)) -> keepers.spawn). index.html setShops door points need
shopId:s.id added. Closing-time handled by the dwell model (pending A's explicit ruling).

Evidence: docs/shots/laneD/r9_browsers_in_shop.jpg. Changed sim.js + keepers.js + test page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 10:21:10 +10:00

92 lines
4.3 KiB
JavaScript

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