THE ROUND IN ONE MEASUREMENT (12 samples x 146 active, ?clips=0 vs default — a new flag that turns off the library and nothing else): walking 99.3% -> 78.1% · bench-sit 0 -> 9.5% · lean 0 -> 7.1% · stopped in own idle 0.4% -> 5.1% · DISTINCT CLIPS ACROSS THE CROWD 4 -> 20. The town was 99.3% people walking because standing still had nowhere to happen. Wiring: new postures.js + clipbank.js. idles.glb (10/10) drives a per-citizen deterministic idle on every near-tier actor plus the seeded shopkeeper. locomotion gives 33.7% of walkers a shopping bag. sitlean (8/8, lazy) puts 4 sits on Lane B's ACTUAL benches and 4 leans on shopfront walls. browse (5/8, lazy) is a real BROWSE state at C's browse points, seeded per (shopId, slot). venue (5/6, lazy) widens the gig crowd, plus a publican pouring and a record keeper in headphones. social (0/8) is never fetched — two-person conversation needs a paired state machine, filed to R42. Cost: boot = 4 requests, 1.24 MB / 16 clips resident; the rest lazy on first need; heap delta +3.34 MB; mixer median 0.1 ms both arms. ?clips=0 / ?classic=1 / ?noassets=1 fetch ZERO clips — not even clipbank.js (dynamic import). No shell edit needed. DRAWS: +0 on every bookmark (street_noon 193, crossroads 108, night_crowd 128, market_square 94, night_neon 111, interior 110 — identical both arms). Ruling 4 respected exactly. DETERMINISM: 150 citizens, two fresh contexts, byte-equal posture signature. Controls: seed+1 differs; EVERY clip GLB delayed 2 s -> identical signature (posture is a pure function of (citySeed, id), never of residency). 6 new streams collide with none of the 12 pre-R41 keys. TWO FINDINGS THAT CHANGED THE DESIGN: the idle pool was INVISIBLE — wired only to the R17/R29 node loiter, so only 0.8% of citizens were ever stopped; and the lean never fired at all (0 in a 9 s run). Both moved to the patronage stride check. Bench stations are GATED not trusted: 14/14 derived stations coincide with real instanced geometry within 2 cm, and the control (same stations offset 2 m) matches 0/14. Filed to B: one benchStops(plan) export retires the mirror, and furniture.js puts the bench's front ALONG the street rather than facing the road, contradicting its own comment. Leak: +0 geometries, +1 texture over 6 enter/exit cycles. Goldens 157,647/157,647, 0x5f76e76. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
127 lines
7.4 KiB
JavaScript
127 lines
7.4 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';
|
|
import { browsePostureFor, keeperPostureFor, GROUP_OF } from './postures.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, slot = null } = {}) {
|
|
const r = rng(this.citySeed, browse ? 'browser' : 'keeper', seedKey || shopId);
|
|
const height = 1.58 + r() * 0.34;
|
|
// [R41 §41.3] A REAL BROWSE STATE. Until now a "browser" was a rig standing at Lane C's browse
|
|
// point playing the shopkeeper's idle. It now plays a browse clip — hold / carry / open the
|
|
// cabinet / pick the record up — seeded per (shopId, slot), NOT per visit, so the same shop reads
|
|
// the same way every time you walk back in. F already passes `seedKey = "<shopId>#<i>"`; the slot
|
|
// index is taken from it when the caller doesn't pass one, so no shell edit is needed.
|
|
// The keeper gets a seeded idle variant instead, or a trade flavour (the publican pours, the
|
|
// record-shop keeper wears the cans) — see postures.KEEPER_TYPE_CLIP.
|
|
const bank = this.fleet && this.fleet.bank;
|
|
const slotIdx = slot != null ? slot : (seedKey && /#(\d+)$/.test(seedKey) ? +/#(\d+)$/.exec(seedKey)[1] : 0);
|
|
const wantId = browse ? browsePostureFor(this.citySeed, shopId, slotIdx)
|
|
: keeperPostureFor(this.citySeed, shopId, type);
|
|
if (bank && wantId) bank.ensureGroup(GROUP_OF[wantId]); // first interior fetches browse.glb; a
|
|
// pub/record counter fetches venue.glb
|
|
let actor, kind, phase = 0;
|
|
if (this.fleet && this.fleet.ready) {
|
|
// NB the `r()` draw order below is R9's, unchanged and deliberately so: height, then pickRig
|
|
// (only when the caller did NOT pin a pedIndex), then phase (only when a rig resolved). The
|
|
// R41 posture is drawn from its OWN stream above, so no keeper's ped type or height moves.
|
|
const idx = (pedIndex != null && this.fleet.all[pedIndex]) ? pedIndex : (pickRig(this.fleet, r()) || {}).index;
|
|
const rig = idx != null && this.fleet.all[idx];
|
|
const want = bank ? bank.get(wantId) : null; // resident already? use it at spawn
|
|
const spawned = rig && spawnRig(rig, { ry, height, clip: want || this.fleet.idleClip, phase: (phase = r()) });
|
|
if (spawned && want) spawned.setClip(want, { phase }); // re-plant off the posed skeleton
|
|
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);
|
|
// `want` is kept ONLY while the assigned clip is still unresolved — update() upgrades in place the
|
|
// frame its group lands and then clears it. That is what makes lazy loading safe here: the clip a
|
|
// browse point plays is decided by (citySeed, shopId, slot) at spawn, and the fetch only decides
|
|
// when it starts, never which. A boot with no bank leaves `want` null and nothing ever runs.
|
|
// Gated on `fleet.clipsRequested` (boot-stable), NOT on `bank` — a spawn that happens before the
|
|
// dynamic import resolves would otherwise silently lose its posture forever. update() kicks the
|
|
// group itself in that case, so the only thing timing decides is when the upgrade shows.
|
|
const wantsIt = kind === 'rig' && wantId && this.fleet && this.fleet.clipsRequested && !(bank && bank.has(wantId));
|
|
const k = { actor, kind, baseRy: ry, curTurn: 0, target, shopId, type, browse,
|
|
want: wantsIt ? wantId : null, phase };
|
|
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;
|
|
const bank = this.fleet && this.fleet.bank;
|
|
for (const k of this.keepers) {
|
|
const a = k.actor;
|
|
// [R41 §41.3] self-heal: the assigned clip's group finished fetching → install it now, once.
|
|
if (k.want && bank && a.setClip) {
|
|
const c = bank.get(k.want);
|
|
if (c) { a.setClip(c, { phase: k.phase }); k.want = null; }
|
|
else bank.ensureGroup(GROUP_OF[k.want]); // spawned before the bank existed → kick it now
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|