Lane D R17 (v3.2): bench-sit loiter (ledger #2) — the carried R16 stretch
Street peds occasionally sit during a window-shop loiter (verandah/footpath edge). makeActor gains an opt-in setSitting with a SCALE-AWARE re-plant (the R16 spawnRig re-plant was scale-naive; the sim scales these rigs per-citizen), played only on sit so walkers/placeholders/?classic are byte-identical. Seeded via a dedicated benchsit rng stream (no shift to turn/loit/patron; identity signature unchanged). Classic inert at the source (roll gated on sitClip). In-shell (default, seed 20261990): head bone 1.466->1.070, bone span 1.667->1.299 (in seated gate [0.9,1.5]), feet re-plant to y=0 and hold through sit-idle, stand-up restores inner.y 0.929 byte-for-byte, procitySeated tagged, innerRotY:pi throughout. 9 distinct peds sat naturally per soak. Classic: fleetSitClip:false, everSat:0. Plan golden 0x3fa36874 unchanged. Draws 35/tris 6k. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
483a4b75c6
commit
64ded4ea01
@ -25,6 +25,61 @@ consumer moves. Seated-only, parallel path — same discipline as R16.
|
||||
|
||||
---
|
||||
|
||||
## ROUND 17 — v3.2 THE TAIL: bench-sit loiter (ledger #2, the carried R16 stretch)
|
||||
|
||||
*PROCITY-D, 2026-07-16. The loiter hook that's waited since v1: street peds occasionally sit during a
|
||||
window-shop stop. Files `rigs.js` (`makeActor` gains an opt-in `setSitting`), `sim.js` (the seeded
|
||||
choice + the near-tier toggle). Byte-identical for every non-sitting ped — the load-bearing proof.*
|
||||
|
||||
### The finding I had to solve first
|
||||
The R16 seated re-plant lives in `spawnRig` (drummer path) and is **scale-naive** — it drops `inner` by the
|
||||
posed lowest-bone height, which only lands at y=0 for **unscaled** rigs. But the street crowd runs on
|
||||
`makeActor` rigs that the sim scales **per-citizen** (`fig.scale = height/1.75`). So bench-sit is *not* the
|
||||
one-line "reuse the existing seam" the brief hoped for — the re-plant had to become **scale-aware**.
|
||||
|
||||
### What shipped
|
||||
- **`makeActor(rig, {…, sitClip})`** gains a third action `sitA`, **played only on `setSitting(true)`** — so
|
||||
walkers, placeholders, and `?classic` (no `sitClip` → `sitA` null → `setSitting` no-ops) are byte-identical:
|
||||
the sit action never advances the mixer and the plant never moves.
|
||||
- **`setSitting(s)`** snaps in/out of the sit (no crossfade — a background ped popping to a sit is fine) and
|
||||
does the **scale-aware re-plant**: after baking the pose it measures the posed lowest bone's *world* y and
|
||||
drops `inner` by `(worldLowestY − figY) / fig.scale.y`, landing the posed soles on the footpath at any
|
||||
citizen scale. Standing up restores the cached `bindPlantY` exactly. Tags `procitySeated`.
|
||||
- **`sim.js`**: a **dedicated** rng stream `sitRng = rng(citySeed,'benchsit',id)` (independently keyed → does
|
||||
**not** shift `turn`/`loit`/`patron`, so the walk/loiter pattern and the identity signature are unchanged).
|
||||
At the window-shop loiter trigger the roll fires ALWAYS (deterministic) but only flips `c.sit` when a sit
|
||||
clip is loaded (`?classic` inert at the source). The near-tier update toggles `setSitting(c.sit && loiter>0)`.
|
||||
`RigPool.release` forces `setSitting(false)` so a pooled rig is never handed out mid-sit.
|
||||
- No lean: bench-sitters sit **upright** (natural on a verandah step), so the drummer's `seatedLean` is not
|
||||
applied here — only `setSitting`'s pose + re-plant. No bench-position binding — they sit where they loitered
|
||||
(the footpath/verandah edge under the awnings).
|
||||
|
||||
### Byte-identical proof (in-shell, default boot, seed 20261990)
|
||||
Forced a near rig through stand → sit → stand, bone-measured (posed skeleton; `Box3` is bind-pose for skinned
|
||||
meshes and lies here):
|
||||
|
||||
| metric | standing | seated | +1 s sit-idle | stood back |
|
||||
|---|---|---|---|---|
|
||||
| head bone y | 1.466 | **1.070** | 1.071 | 1.466 |
|
||||
| bone span | 1.667 | **1.299** (∈ seated gate [0.9,1.5]) | 1.285 | 1.667 |
|
||||
| lowest bone y (feet) | 0.027 | **0.000** (planted) | 0.005 (holds) | 0.027 |
|
||||
| `inner.position.y` | 0.929 | 0.548 | 0.548 | **0.929** (restored) |
|
||||
| `procitySeated` | false | **true** | true | false |
|
||||
|
||||
Head drops 40 cm, feet re-plant to the footpath at citizen scale (0.978), the one-time plant **holds** through
|
||||
the sit-idle, and standing up restores the rig **byte-for-byte**. `innerRotY:π` throughout (facing convention
|
||||
intact). Draws **35 · tris 6k** at the seated block (seated peds are already-near rigs — ~0 draw delta).
|
||||
|
||||
- **Seeded path fires**: 6–9 distinct peds sat naturally per ~100 s soak (of ~10 window-shop loiters).
|
||||
- **Determinism unchanged**: identity signature `-4,2#0:5:1:1.711:1.370:0:-1` — no `sit` term; plan golden
|
||||
`0x3fa36874`, 15277/15277.
|
||||
- **Classic covenant airtight at the source**: `?classic=1` → `fleetSitClip:false`, 31 loiters over the soak
|
||||
but `everSat:0` (the gate never flips `c.sit`), zero fetch delta, `anySeatedNow:0`.
|
||||
|
||||
Both R17 ledger items (#1 pelvic-lean, #2 bench-sit) are delivered — the tail is drained.
|
||||
|
||||
---
|
||||
|
||||
## ROUND 16 — v3.1 THE FLIP: the drummer finally sits + backline unification
|
||||
|
||||
*PROCITY-D, 2026-07-16. Ledger #2 (sit-clip wiring) + #5 (amp at C's `backline[]`). Files `rigs.js`,
|
||||
|
||||
@ -223,12 +223,19 @@ export function seatedLean(spine, angle = 0.34) {
|
||||
|
||||
// ---- makeActor: near-tier pedestrian with walk↔idle crossfade (built at nominal height; the sim
|
||||
// scales the outer Group per-citizen so pooled actors are height-agnostic and reusable). ----
|
||||
export function makeActor(rig, { walkClip, idleClip, nominalHeight = 1.75 } = {}) {
|
||||
export function makeActor(rig, { walkClip, idleClip, sitClip = null, nominalHeight = 1.75 } = {}) {
|
||||
if (!rig || !rig.scene) return null;
|
||||
const { fig, inner, head, nominalHeight: nom } = buildFigure(rig, nominalHeight);
|
||||
const bindPlantY = inner.position.y; // R17 bench-sit: the standing feet-plant, to restore after a sit
|
||||
const mixer = new THREE.AnimationMixer(inner);
|
||||
const walkA = _action(mixer, inner, walkClip || rig.anims.find(c => /walk/i.test(c.name)));
|
||||
const idleA = _action(mixer, inner, idleClip || rig.anims.find(c => /idle/i.test(c.name)));
|
||||
// R17 bench-sit: a third action, PLAYED ONLY when setSitting(true) — so walkers (and ?classic, where
|
||||
// sitClip is null) are byte-identical: sitA never plays, the plant never moves, setSitting is a no-op.
|
||||
const sitA = sitClip ? _action(mixer, inner, sitClip) : null;
|
||||
let seatBone = null;
|
||||
if (sitA) inner.traverse(o => { if (!seatBone && o.isBone && /Spine1$/i.test(o.name)) seatBone = o; });
|
||||
let sitting = false;
|
||||
let moving = null; // tri-state so the first setMoving always applies
|
||||
// both actions play; exactly one holds weight 1 at rest, setMoving transfers between them
|
||||
if (walkA) { walkA.play(); walkA.setEffectiveWeight(1); }
|
||||
@ -246,6 +253,32 @@ export function makeActor(rig, { walkClip, idleClip, nominalHeight = 1.75 } = {}
|
||||
if (first || fade <= 0) { to.setEffectiveWeight(1); from.setEffectiveWeight(0); }
|
||||
else from.crossFadeTo(to, fade, false); // from holds weight 1 at rest → smooth transfer
|
||||
}
|
||||
// R17 bench-sit: snap the loitering ped into / out of the sit pose (no crossfade — a background ped
|
||||
// sitting instantly is fine, and it avoids the crossfade fighting the discrete foot-replant). The sim's
|
||||
// per-citizen scale is already on `fig`, so the re-plant is SCALE-AWARE: drop `inner` by the posed
|
||||
// lowest-bone height / scale so the seated feet land on the footpath. Restore the standing plant on stand.
|
||||
function setSitting(s) {
|
||||
if (!sitA || s === sitting) return;
|
||||
sitting = s;
|
||||
moving = null; // force the next setMoving to re-apply after standing up
|
||||
if (s) {
|
||||
if (walkA) walkA.setEffectiveWeight(0);
|
||||
if (idleA) idleA.setEffectiveWeight(0);
|
||||
sitA.reset().play().setEffectiveWeight(1);
|
||||
mixer.update(0); // bake the sit pose before measuring
|
||||
inner.updateWorldMatrix(true, true);
|
||||
let w = Infinity;
|
||||
inner.traverse(o => { if (o.isBone) { o.getWorldPosition(_wp); w = Math.min(w, _wp.y); } });
|
||||
const S = fig.scale.y || 1;
|
||||
if (isFinite(w)) inner.position.y -= (w - fig.position.y) / S; // posed soles → the footpath
|
||||
fig.userData.procitySeated = true;
|
||||
} else {
|
||||
sitA.stop();
|
||||
inner.position.y = bindPlantY; // restore the standing feet-plant
|
||||
if (idleA) idleA.setEffectiveWeight(1);
|
||||
fig.userData.procitySeated = false;
|
||||
}
|
||||
}
|
||||
// seed the clip phase so the crowd isn't in lockstep
|
||||
function setPhase(p) {
|
||||
if (walkA) walkA.time = p * (walkA.getClip().duration || 1);
|
||||
@ -253,6 +286,6 @@ export function makeActor(rig, { walkClip, idleClip, nominalHeight = 1.75 } = {}
|
||||
}
|
||||
function dispose() { mixer.stopAllAction(); mixer.uncacheRoot(inner); _disposeInner(inner); }
|
||||
|
||||
return { fig, inner, mixer, head, nominalHeight: nom, setMoving, setPhase, dispose,
|
||||
return { fig, inner, mixer, head, seatBone, nominalHeight: nom, setMoving, setSitting, setPhase, dispose,
|
||||
hasClips: !!(walkA && idleA) };
|
||||
}
|
||||
|
||||
@ -34,6 +34,7 @@ const PATRON_RANGE = 18; // m — a ped ducks into a shop it
|
||||
const PATRON_STRIDE = 10; // m walked between patronage checks (framerate-independent)
|
||||
const GIG_RANGE = 34; // m — a gig pulls peds from further than a normal shopfront
|
||||
const GIG_SURGE = 0.55; // patron chance at the venue while the gig is on (vs ~0.16 day)
|
||||
const BENCH_SIT_FRAC = 0.35; // R17: fraction of window-shop loiters that become a bench-sit (seeded)
|
||||
|
||||
// time-of-day density curve: t01 in [0,1) over a day → crowd multiplier (CITY_SPEC: lunch rush,
|
||||
// near-empty at night). Sampled at 8 control points, linearly interpolated.
|
||||
@ -91,6 +92,7 @@ class RigPool {
|
||||
}
|
||||
release(actor) {
|
||||
if (!actor) return;
|
||||
if (actor.setSitting) actor.setSitting(false); // R17: never pool a rig mid-sit — restore the standing plant
|
||||
let arr = this.free.get(actor.pedIndex);
|
||||
if (!arr) this.free.set(actor.pedIndex, arr = []);
|
||||
arr.push(actor);
|
||||
@ -255,6 +257,7 @@ export class CitizenSim {
|
||||
x: 0, z: 0, facing: 0, tier: 'far', actor: null, actorKind: null, _acc: 0,
|
||||
turn: rng(this.citySeed, 'turn', id), loit: rng(this.citySeed, 'loiter', id),
|
||||
patron: null, patronTarget: null, patronTimer: 0, patronRng: rng(this.citySeed, 'patron', id),
|
||||
sit: false, sitRng: rng(this.citySeed, 'benchsit', id), // R17: dedicated stream — no shift to turn/loit/patron
|
||||
};
|
||||
if (this.mode === 'rig' && this.fleet.ready) { const pk = pickRig(this.fleet, c.pedRoll); if (pk) { c.pedIndex = pk.index; c.subject = pk.index; } }
|
||||
this._placeOnLane(c);
|
||||
@ -303,6 +306,7 @@ export class CitizenSim {
|
||||
_acc: 0,
|
||||
turn: rng(this.citySeed, 'turn', id),
|
||||
loit: rng(this.citySeed, 'loiter', id),
|
||||
sit: false, sitRng: rng(this.citySeed, 'benchsit', id), // R17: dedicated stream — no shift to turn/loit/patron
|
||||
};
|
||||
// assign a real ped type if the fleet is already up (roster can grow after upgrade)
|
||||
if (this.mode === 'rig' && this.fleet.ready) {
|
||||
@ -485,7 +489,7 @@ export class CitizenSim {
|
||||
this._setImpostorLayer(atlas);
|
||||
|
||||
this.mode = 'rig';
|
||||
this.rigPool = new RigPool(this.fleet, { walkClip: this.fleet.walkClip, idleClip: this.fleet.idleClip });
|
||||
this.rigPool = new RigPool(this.fleet, { walkClip: this.fleet.walkClip, idleClip: this.fleet.idleClip, sitClip: this.fleet.sitClip }); // sitClip null under ?classic → bench-sit inert
|
||||
// assign real ped types to every citizen; drop placeholder near-actors so they re-acquire as rigs
|
||||
const assign = c => {
|
||||
const pk = pickRig(this.fleet, c.pedRoll);
|
||||
@ -545,7 +549,14 @@ export class CitizenSim {
|
||||
c.edge = next;
|
||||
c.forward = ne.a === node ? 1 : -1;
|
||||
c.s = Math.min(c.s - e.len, ne.len); // carry leftover distance
|
||||
if (c.loit() < 0.10 + c.loiterTend * 0.28) c.loiter = 1.4 + c.loit() * 3.2; // window-shop stop
|
||||
if (c.loit() < 0.10 + c.loiterTend * 0.28) {
|
||||
c.loiter = 1.4 + c.loit() * 3.2; // window-shop stop
|
||||
// R17 bench-sit: a seeded few of those become a bench-sit. Roll ALWAYS (dedicated stream →
|
||||
// deterministic, never shifts turn/loit/patron), but only flip c.sit when a sit clip is loaded
|
||||
// so ?classic (no sit.glb) is inert at the source, not just at the render.
|
||||
const wantsSit = c.sitRng ? (c.sitRng() < BENCH_SIT_FRAC) : false;
|
||||
c.sit = wantsSit && !!(this.fleet && this.fleet.sitClip);
|
||||
}
|
||||
}
|
||||
this._placeOnLane(c);
|
||||
// patronage: every ~PATRON_STRIDE walked, IF a nearby open shop is in range, a seeded chance to
|
||||
@ -683,7 +694,12 @@ export class CitizenSim {
|
||||
const a = c.actor;
|
||||
a.fig.position.set(c.x, 0, c.z);
|
||||
a.fig.rotation.y = c.facing;
|
||||
a.setMoving?.(c.loiter <= 0);
|
||||
// R17 bench-sit: a seeded few of the window-shop loiters sit (upright, on the verandah/footpath
|
||||
// edge — no bench-position binding). No-op for walkers, placeholders, and ?classic (sitClip null),
|
||||
// so every non-sitting ped is byte-identical.
|
||||
const wantSit = !!(c.sit && c.loiter > 0 && a.setSitting);
|
||||
if (a.setSitting) a.setSitting(wantSit);
|
||||
if (!wantSit) a.setMoving?.(c.loiter <= 0);
|
||||
} else if (want === 'mid') {
|
||||
mid.push(c);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user