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>
87 lines
3.6 KiB
JavaScript
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;
|
|
}
|
|
}
|
|
}
|
|
}
|