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>
92 lines
4.3 KiB
JavaScript
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;
|
|
}
|
|
}
|
|
}
|
|
}
|