PROCITY/web/js/citizens/keepers.js
m3ultra 9eb1acc61b Lane D (Citizens): deterministic LOD crowd — rigs/impostors/keepers + test page
Populate the town at city scale, on budget, deterministic per-citizen identity:
- rigs.js — ported 90sDJsim rig stack: mixamorig canonicalisation so one shared
  walk clip drives all 19 peds, head-bone height-normalise, feet-plant, pooled
  near-tier actors with walk<->idle crossfade
- impostor.js — 4-yaw sprite-atlas baker + instanced billboards: whole mid crowd
  in 1 draw call, tone-matched to the ACES near rigs
- sim.js — deterministic roster, footpath lanes off the street graph, near/mid/far
  LOD with hysteresis + hard 24-cap, staggered mixer budget, time-of-day density
- placeholder.js — seeded low-poly box humanoids (frame-one population + ?noassets)
- keepers.js — one keeper per shop at the counter, idle + greet-turn
- 21 ped GLBs (byte-identical from 90sDJsim), citizens_test.html harness,
  LANE_D_NOTES.md, 5 beauty shots

Verified in-browser: 200 citizens at max mixer 0.2ms (budget 2ms), near capped 24,
determinism holds (200 identities match seed), no T-pose, ?noassets crash-free.
Consumes Lane A plan.streets with zero adapter. Six adversarial-review bugs fixed
(non-deterministic fleet order, shared-resource disposal on pool eviction,
impostor colour-management).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 13:43:05 +10:00

87 lines
3.6 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 }) → keeper handle. `ry` = the counter's outward facing.
spawn(target, { x = 0, z = 0, ry = 0, shopId = 'shop', type = 'shop' } = {}) {
const r = rng(this.citySeed, 'keeper', shopId);
const height = 1.58 + r() * 0.34;
let actor, kind;
if (this.fleet && this.fleet.ready) {
const pk = pickRig(this.fleet, r());
const rig = pk && this.fleet.all[pk.index];
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, 'keeper-body', 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 };
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 (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;
}
}
}
}