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
|
## 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`,
|
*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
|
// ---- 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). ----
|
// 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;
|
if (!rig || !rig.scene) return null;
|
||||||
const { fig, inner, head, nominalHeight: nom } = buildFigure(rig, nominalHeight);
|
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 mixer = new THREE.AnimationMixer(inner);
|
||||||
const walkA = _action(mixer, inner, walkClip || rig.anims.find(c => /walk/i.test(c.name)));
|
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)));
|
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
|
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
|
// both actions play; exactly one holds weight 1 at rest, setMoving transfers between them
|
||||||
if (walkA) { walkA.play(); walkA.setEffectiveWeight(1); }
|
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); }
|
if (first || fade <= 0) { to.setEffectiveWeight(1); from.setEffectiveWeight(0); }
|
||||||
else from.crossFadeTo(to, fade, false); // from holds weight 1 at rest → smooth transfer
|
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
|
// seed the clip phase so the crowd isn't in lockstep
|
||||||
function setPhase(p) {
|
function setPhase(p) {
|
||||||
if (walkA) walkA.time = p * (walkA.getClip().duration || 1);
|
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); }
|
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) };
|
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 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_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 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,
|
// 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.
|
// near-empty at night). Sampled at 8 control points, linearly interpolated.
|
||||||
@ -91,6 +92,7 @@ class RigPool {
|
|||||||
}
|
}
|
||||||
release(actor) {
|
release(actor) {
|
||||||
if (!actor) return;
|
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);
|
let arr = this.free.get(actor.pedIndex);
|
||||||
if (!arr) this.free.set(actor.pedIndex, arr = []);
|
if (!arr) this.free.set(actor.pedIndex, arr = []);
|
||||||
arr.push(actor);
|
arr.push(actor);
|
||||||
@ -255,6 +257,7 @@ export class CitizenSim {
|
|||||||
x: 0, z: 0, facing: 0, tier: 'far', actor: null, actorKind: null, _acc: 0,
|
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),
|
turn: rng(this.citySeed, 'turn', id), loit: rng(this.citySeed, 'loiter', id),
|
||||||
patron: null, patronTarget: null, patronTimer: 0, patronRng: rng(this.citySeed, 'patron', 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; } }
|
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);
|
this._placeOnLane(c);
|
||||||
@ -303,6 +306,7 @@ export class CitizenSim {
|
|||||||
_acc: 0,
|
_acc: 0,
|
||||||
turn: rng(this.citySeed, 'turn', id),
|
turn: rng(this.citySeed, 'turn', id),
|
||||||
loit: rng(this.citySeed, 'loiter', 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)
|
// assign a real ped type if the fleet is already up (roster can grow after upgrade)
|
||||||
if (this.mode === 'rig' && this.fleet.ready) {
|
if (this.mode === 'rig' && this.fleet.ready) {
|
||||||
@ -485,7 +489,7 @@ export class CitizenSim {
|
|||||||
this._setImpostorLayer(atlas);
|
this._setImpostorLayer(atlas);
|
||||||
|
|
||||||
this.mode = 'rig';
|
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
|
// assign real ped types to every citizen; drop placeholder near-actors so they re-acquire as rigs
|
||||||
const assign = c => {
|
const assign = c => {
|
||||||
const pk = pickRig(this.fleet, c.pedRoll);
|
const pk = pickRig(this.fleet, c.pedRoll);
|
||||||
@ -545,7 +549,14 @@ export class CitizenSim {
|
|||||||
c.edge = next;
|
c.edge = next;
|
||||||
c.forward = ne.a === node ? 1 : -1;
|
c.forward = ne.a === node ? 1 : -1;
|
||||||
c.s = Math.min(c.s - e.len, ne.len); // carry leftover distance
|
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);
|
this._placeOnLane(c);
|
||||||
// patronage: every ~PATRON_STRIDE walked, IF a nearby open shop is in range, a seeded chance to
|
// 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;
|
const a = c.actor;
|
||||||
a.fig.position.set(c.x, 0, c.z);
|
a.fig.position.set(c.x, 0, c.z);
|
||||||
a.fig.rotation.y = c.facing;
|
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') {
|
} else if (want === 'mid') {
|
||||||
mid.push(c);
|
mid.push(c);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user