M1: the marionette — dangle, walk, sticky grip

- sim/marionette.ts: control bar + 6 soft strings (head/back/2 hands/2 knees)
  over a 9-segment silhouette. Bar snaps to mouse; the felt lag is string
  stiffness<1 (worldConstraints), not an input buffer.
- Feet planted on floor + head+back strings keep the torso upright (no topple);
  earlier pure-pendulum rig spun past 360deg — the back string fixed it.
- Sticky grip: while gripped, any empty hand latches the nearest target on
  contact (puppet.update() each step). Reach = LMB drops the bar.
- Harness verbs: bar/sway/grip/pose/settle + dangle/walk/pickupTest + reset.
- Exit-bar measured: dangle settles 87 frames upright (lean 4.6); walk 8.84
  units upright (>6 req, lean 30<45); grip 10/10. Verified in-browser + shot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-17 20:18:56 +10:00
parent 802f738a3d
commit da840b531a
4 changed files with 424 additions and 46 deletions

View File

@ -1,16 +1,24 @@
# サンドニエット SANDONIETTE — Build Brief 1: The Puppet, The Food, The Samurai
## STATUS (update this as you go — a cold session reads here first)
- **M0 ✅** vite+ts+phaser(3.90)+Matter skeleton, typecheck green, harness (`window.__s`)
installed. Box drops from y=80, settles in **68 frames**, rests **y=608.05**
flush on the floor (gap 0.05px, `landed:true`). Verified in-browser + screenshot.
- Stack: Phaser Matter, `autoUpdate:false` — sim steps only via `stage.stepSim(dt)`,
so the harness drives frame-by-frame. Render contract: raw Matter body = truth,
flat silhouette `Prop.view` follows it (`Stage.addBody`). `Rng` = mulberry32.
- Assets: `scripts/gen-assets.sh` (2D flux only) firing on MODELBEAST →
`public/assets/img/`. Log: `~/.jobs/sandoniette-assets.log`. Game never blocks on art.
- **Next: M1** puppet (`sim/marionette.ts`) — the dangle.
- Run: `npm run dev` (:5173). Harness verbs live in `src/dev.ts`.
- **M0 ✅** vite+ts+phaser(3.90)+Matter skeleton, harness (`window.__s`), typecheck green.
Box drops → settles 68 frames, rests flush on floor.
- **M1 ✅** `sim/marionette.ts` — control bar + 6 strings (head, **back**, 2 hands,
2 knees) over a 9-segment silhouette. Bar snaps to mouse; lag comes from soft
worldConstraints (stiffness<1). Feet planted on floor + head/back strings = stable
upright stand, no topple. Grip is **sticky** (latches on contact via `puppet.update()`
each step). Exit-bar measured: **dangle** settles 87 frames upright (lean 4.6°);
**walk** 8.84 units upright (>6 req, lean 30° < 45 topple); **grip 10/10**. Verified
in-browser + walk screenshot.
- Stack: Phaser Matter, `autoUpdate:false` — sim steps only via `stage.stepSim(dt)`
(which also calls `puppet.update()`). Render contract: Matter body = truth, flat
silhouette `Prop.view` follows. `Rng` = mulberry32. Tuning knobs: `K` at top of
marionette.ts. Harness `reset(homeX)` teleports to a known pose for back-to-back tests.
- Assets: all 20 2D props rendered on MODELBEAST → `public/assets/img/` (samurai×5,
bg_washi, floor_boards, title, tickets, slash, 5 food faces, splat). Not wired in
yet — procedural silhouettes carry gameplay; art is backdrop/faces/juice (M4/M6).
- **Next: M2** cutting (`sim/food.ts` + the cleaver + `cutStats`) — the clean chop.
- Run: `npm run dev` (:5173). Harness: `__s.dangle() __s.walk() __s.pickupTest()`.

View File

@ -6,56 +6,151 @@ import { FLOOR_Y } from './scenes/stage';
*
* Drives the REAL sim deterministically at a fixed dt so "does it actually work"
* is answered by measured numbers, not by squinting. Every helper returns a
* stats object. Installed on window.__s in dev builds.
*
* Milestones add verbs here; nothing ships without a harness number behind it.
* stats object. Installed on window.__s in dev builds. Milestones add verbs;
* nothing ships without a harness number behind it.
*/
const STAGE_UNIT = 40; // px per "stage-unit" — the walk bar is measured in these
const HOME_X = 400;
export function installHarness(stage: Stage): void {
const rest = (body: MatterJS.BodyType) =>
Math.hypot(body.velocity.x, body.velocity.y) < 0.05 && Math.abs(body.angularVelocity) < 0.01;
const p = () => stage.puppet;
const M = () => stage.matter.body;
const step = (frames: number, dt = 1000 / 60): void => {
stage.auto = false;
for (let i = 0; i < frames; i++) stage.stepSim(dt);
};
/** Step until the box stops moving (or maxFrames). Returns frames elapsed. */
const settle = (body: MatterJS.BodyType, maxFrames = 600): number => {
/** Hold the bar at (x,y) for `frames`, stepping each frame (no tilt). */
const hold = (x: number, y: number, frames: number, grip = false): void => {
stage.auto = false;
let f = 0;
for (let i = 0; i < frames; i++) {
p().setBar(x, y, 0);
p().setGrip(grip);
stage.stepSim();
}
};
/** Frames until the swing steadies. Soft strings keep a marionette gently
* alive forever, so "settled" means the energy has plateaued (stopped
* falling), not literal zero. Returns frames-to-steady. */
const settle = (maxFrames = 600): number => {
stage.auto = false;
let f = 0, low = Infinity, plateau = 0;
for (; f < maxFrames; f++) {
stage.stepSim();
if (rest(body)) break;
const v = p().restSpeed();
if (v < low - 0.02) { low = v; plateau = 0; } else if (++plateau > 45) break;
if (v < 0.15) break;
}
return f;
};
const placeCrate = (x: number): void => {
M().setPosition(stage.crate, { x, y: FLOOR_Y - 16 }, false);
M().setAngle(stage.crate, 0, false);
M().setVelocity(stage.crate, { x: 0, y: 0 });
M().setAngularVelocity(stage.crate, 0);
};
const s = {
stage,
step,
settle,
pose: () => p().pose(),
/** M0 exit-bar: drop the box from height, let it settle, report where it landed. */
drop(x = 640, y = 80) {
/** Set the control bar to an absolute position and step one frame. */
bar(x: number, y = p().barHomeY) {
stage.auto = false;
const body = stage.matter.body;
body.setPosition(stage.box, { x, y }, false);
body.setVelocity(stage.box, { x: 0, y: 0 });
body.setAngle(stage.box, 0, false);
body.setAngularVelocity(stage.box, 0);
const frames = settle(stage.box);
const restY = +stage.box.position.y.toFixed(2);
p().setBar(x, y, 0);
stage.stepSim();
return p().pose();
},
grip(on: boolean) {
stage.auto = false;
p().setGrip(on);
stage.stepSim();
return { gripping: p().gripping, hands: p().handWorld() };
},
/** Oscillate the bar around home: freq Hz, amp px, for secs seconds. */
sway(freq = 1.2, amp = 90, secs = 3) {
stage.auto = false;
const frames = Math.round(secs * 60);
for (let i = 0; i < frames; i++) {
const x = HOME_X + amp * Math.sin((2 * Math.PI * freq * i) / 60);
p().applyInput(x, p().barHomeY, false, false);
stage.stepSim();
}
return p().pose();
},
/** THE DANGLE (M1 feel gate): yank the bar aside, let go, measure the swing
* home. Long settle + a clean decay = a puppet that has real pendulum. */
dangle() {
hold(HOME_X, p().barHomeY, 30); // start at rest
hold(HOME_X + 220, p().barHomeY, 12); // yank right
p().setBar(HOME_X, p().barHomeY, 0); // release to home
const frames = settle();
return { settleFrames: frames, ...p().pose() };
},
/** THE WALK (M1 exit-bar): a rhythmic sway whose centre drifts `dir`, so the
* legs swing and the puppet is dragged along. Reports units travelled and
* whether it stayed upright the whole way. */
walk(dir = 1, secs = 8, freq = 1.2, amp = 45, advance = 460) {
hold(HOME_X, p().barHomeY, 20);
const x0 = p().torsoX;
const frames = Math.round(secs * 60);
let upright = true;
for (let i = 0; i < frames; i++) {
const center = HOME_X + dir * advance * (i / frames);
const x = center + amp * Math.sin((2 * Math.PI * freq * i) / 60);
p().applyInput(x, p().barHomeY, false, false);
stage.stepSim();
if (!p().pose().upright) upright = false;
}
const dx = p().torsoX - x0;
return {
frames,
restY,
floorTopY: FLOOR_Y,
gap: +(FLOOR_Y - 32 - restY).toFixed(2), // 0 ≈ sitting flush (half the 64px box)
landed: rest(stage.box) && restY < FLOOR_Y + 8,
units: +(dx / STAGE_UNIT).toFixed(2),
dx: +dx.toFixed(1),
upright,
faceplant: !upright,
lean: p().pose().lean,
};
},
/** Reach down over the crate, grip, lift — did the crate leave the floor? */
pickup(crateX = 470) {
placeCrate(crateX);
// hover the hands over the crate, then reach + grip
hold(crateX, p().barHomeY, 25);
hold(crateX, p().barHomeY + 130, 30, true); // reach + clamp
const grabbedY = stage.crate.position.y;
hold(crateX, p().barHomeY - 40, 40, true); // lift
const lift = grabbedY - stage.crate.position.y;
const held = stage.crate.position.y < FLOOR_Y - 40;
p().setGrip(false);
return { lift: +lift.toFixed(1), crateY: +stage.crate.position.y.toFixed(1), held };
},
/** Grip must lift the crate 10/10. Vary the crate x each trial. */
pickupTest(n = 10) {
let ok = 0;
const lifts: number[] = [];
for (let i = 0; i < n; i++) {
stage.auto = false;
hold(HOME_X, p().barHomeY, 25); // re-dangle to home between trials
const r = this.pickup(440 + (i % 5) * 15);
if (r.held) ok++;
lifts.push(r.lift);
}
return { ok, n, rate: `${ok}/${n}`, lifts };
},
};
(window as { __s?: typeof s }).__s = s;
// eslint-disable-next-line no-console
console.log('[dev] __s ready — try __s.drop()');
console.log('[dev] __s ready — __s.dangle() __s.walk() __s.pickupTest()');
}

View File

@ -1,5 +1,6 @@
import Phaser from 'phaser';
import { installHarness } from '../dev';
import { Marionette } from '../sim/marionette';
export const STAGE_W = 1280;
export const STAGE_H = 720;
@ -15,13 +16,15 @@ export interface Prop {
/**
* The kitchen stage. Matter's auto-step is OFF so the sim advances only when we
* call stepSim deterministic, drivable frame-by-frame from the dev harness.
* In normal play scene.update drives one fixed step per frame; the harness sets
* auto=false and steps itself.
* In normal play scene.update maps the pointer to the bar and steps once; the
* harness sets auto=false and drives the puppet itself.
*/
export class Stage extends Phaser.Scene {
auto = true;
props: Prop[] = [];
box!: MatterJS.BodyType;
puppet!: Marionette;
crate!: MatterJS.BodyType;
private space!: Phaser.Input.Keyboard.Key;
constructor() {
super('stage');
@ -30,34 +33,51 @@ export class Stage extends Phaser.Scene {
create(): void {
this.matter.world.autoUpdate = false;
// floor + side walls so nothing wanders off before there are stage edges to care about
// floor + side walls
this.addBody(this.matter.add.rectangle(STAGE_W / 2, FLOOR_Y + 20, STAGE_W, 40, { isStatic: true }), STAGE_W, 40, 0x2a211a);
this.matter.add.rectangle(-20, STAGE_H / 2, 40, STAGE_H * 2, { isStatic: true });
this.matter.add.rectangle(STAGE_W + 20, STAGE_H / 2, 40, STAGE_H * 2, { isStatic: true });
// M0: a single box that drops onto the floor and comes to rest.
this.box = this.matter.add.rectangle(STAGE_W / 2, 80, 64, 64, { restitution: 0.15, friction: 0.6 });
this.addBody(this.box, 64, 64, 0x111111);
this.puppet = new Marionette(this, 400);
// a crate on the floor for the M1 grip test — low mass so a hand can lift it.
this.crate = this.matter.add.rectangle(470, FLOOR_Y - 16, 48, 48, { restitution: 0.1, friction: 0.8, density: 0.0006 });
this.addBody(this.crate, 48, 48, 0x3a2d20);
this.puppet.gripTargets.push(this.crate);
this.space = this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE);
installHarness(this);
}
/** Register a body with a flat silhouette that tracks it. */
/** Register a body with a flat rectangular silhouette that tracks it. */
addBody(body: MatterJS.BodyType, w: number, h: number, color: number): MatterJS.BodyType {
const view = this.add.rectangle(body.position.x, body.position.y, w, h, color);
this.props.push({ body, view });
return body;
}
/** Register a body with a circular silhouette (the head). */
addCircle(body: MatterJS.BodyType, r: number, color: number): MatterJS.BodyType {
const view = this.add.circle(body.position.x, body.position.y, r, color);
this.props.push({ body, view });
return body;
}
stepSim(dt = 1000 / 60): void {
this.matter.world.step(dt);
this.puppet?.update();
}
update(): void {
if (this.auto) this.stepSim();
for (const p of this.props) {
p.view.setPosition(p.body.position.x, p.body.position.y);
p.view.setRotation(p.body.angle);
if (this.auto) {
const p = this.input.activePointer;
const grip = p.rightButtonDown() || this.space.isDown;
this.puppet.applyInput(p.worldX, p.worldY || this.puppet.barHomeY, p.leftButtonDown(), grip);
this.stepSim();
}
for (const pr of this.props) {
pr.view.setPosition(pr.body.position.x, pr.body.position.y);
pr.view.setRotation(pr.body.angle);
}
}
}

255
src/sim/marionette.ts Normal file
View File

@ -0,0 +1,255 @@
import type { Stage } from '../scenes/stage';
/**
* The marionette. A control bar (the player's proxy) hangs 5 strings head,
* two hands, two knees over a 9-segment silhouette puppet. The bar snaps to
* the mouse instantly; the LAG the whole game is built on comes from soft
* string constraints (stiffness < 1), not from any input buffer. The head
* string bears the posture; hands and knees are floppy on purpose.
*
* Bodies live in the Stage's Matter world; the sim owns the control + readouts.
*/
// --- tuning knobs (the calibration surface — tune by harness, not by guess) ---
const K = {
// string stiffness / damping. head + back hold posture; limbs dangle.
headK: 0.14, headD: 0.09,
backK: 0.11, backD: 0.08,
handK: 0.045, handD: 0.06,
kneeK: 0.07, kneeD: 0.06,
jointK: 0.8, // near-rigid pin joints between segments
gripK: 0.75, // clamp stiffness when a hand grabs
gripReach: 56, // px a hand can grab across
barHomeY: 175,
reachDrop: 130, // extra bar lowering while LMB held
group: -7, // shared negative collision group: puppet never collides with itself
railMin: 30, railMax: 1250, // bar x clamp
barCeil: 40, barFloor: 380, // bar y clamp (before reach)
tiltGain: 0.012, tiltMax: 0.5, // horizontal bar speed -> lean radians
};
type Seg =
| 'head' | 'chest' | 'hips'
| 'uArmL' | 'lArmL' | 'uArmR' | 'lArmR'
| 'legL' | 'legR';
type Vec = { x: number; y: number };
export interface Pose {
head: number; chest: number; hips: number;
armL: number; armR: number; legL: number; legR: number;
lean: number; // chest tilt from vertical, degrees
upright: boolean;
}
export class Marionette {
readonly seg = {} as Record<Seg, MatterJS.BodyType>;
private strings = {} as Record<'head' | 'back' | 'handL' | 'handR' | 'kneeL' | 'kneeR', MatterJS.ConstraintType>;
private gripCon: (MatterJS.ConstraintType | null)[] = [null, null]; // [L, R]
private gripped = false;
barX: number;
readonly barHomeY = K.barHomeY;
private prevBarX: number;
private home0 = {} as Record<Seg, { x: number; y: number }>;
/** Bodies the hands can grab (crate, later food). Stage fills this. */
gripTargets: MatterJS.BodyType[] = [];
constructor(private stage: Stage, homeX = 400) {
this.barX = homeX;
this.prevBarX = homeX;
const m = stage.matter;
const g = { collisionFilter: { group: K.group } };
const rect = (x: number, y: number, w: number, h: number) =>
m.add.rectangle(x, y, w, h, { ...g, frictionAir: 0.05, friction: 0.7 });
const view = (b: MatterJS.BodyType, w: number, h: number) => stage.addBody(b, w, h, 0x111111);
// 9 segments laid out standing, feet planted on the floor (~642) so the rig
// has traction and self-rights against the strings instead of pure-pendulum.
const hx = homeX;
this.seg.head = m.add.circle(hx, 388, 20, { ...g, frictionAir: 0.05 });
stage.addCircle(this.seg.head, 20, 0x111111);
this.seg.chest = rect(hx, 437, 44, 46); view(this.seg.chest, 44, 46);
this.seg.hips = rect(hx, 482, 44, 40); view(this.seg.hips, 44, 40);
this.seg.uArmL = rect(hx - 30, 439, 13, 46); view(this.seg.uArmL, 13, 46);
this.seg.lArmL = rect(hx - 30, 486, 11, 46); view(this.seg.lArmL, 11, 46);
this.seg.uArmR = rect(hx + 30, 439, 13, 46); view(this.seg.uArmR, 13, 46);
this.seg.lArmR = rect(hx + 30, 486, 11, 46); view(this.seg.lArmR, 11, 46);
this.seg.legL = rect(hx - 12, 587, 16, 110); view(this.seg.legL, 16, 110);
this.seg.legR = rect(hx + 12, 587, 16, 110); view(this.seg.legR, 16, 110);
// pin joints (length 0 = hinge around shared point)
const pin = (a: MatterJS.BodyType, pa: Vec, b: MatterJS.BodyType, pb: Vec) =>
m.add.constraint(a, b, 0, K.jointK, { pointA: pa, pointB: pb, damping: 0.1 });
pin(this.seg.chest, { x: 0, y: -23 }, this.seg.head, { x: 0, y: 20 }); // neck
pin(this.seg.hips, { x: 0, y: -20 }, this.seg.chest, { x: 0, y: 23 }); // spine
pin(this.seg.chest, { x: -22, y: -16 }, this.seg.uArmL, { x: 0, y: -23 });
pin(this.seg.uArmL, { x: 0, y: 23 }, this.seg.lArmL, { x: 0, y: -23 });
pin(this.seg.chest, { x: 22, y: -16 }, this.seg.uArmR, { x: 0, y: -23 });
pin(this.seg.uArmR, { x: 0, y: 23 }, this.seg.lArmR, { x: 0, y: -23 });
pin(this.seg.hips, { x: -14, y: 20 }, this.seg.legL, { x: 0, y: -55 });
pin(this.seg.hips, { x: 14, y: 20 }, this.seg.legR, { x: 0, y: -55 });
// strings — soft, world-anchored to the control bar. This is the lag.
const string = (b: MatterJS.BodyType, pb: Vec, len: number, k: number, d: number) =>
m.add.worldConstraint(b, len, k, {
pointA: { x: this.barX, y: K.barHomeY },
pointB: pb, damping: d, render: { visible: false },
});
// rest lengths match the standing pose so the strings hold posture without
// hauling the feet off the floor. head + back together fix torso rotation.
this.strings.head = string(this.seg.head, { x: 0, y: -20 }, 195, K.headK, K.headD);
this.strings.back = string(this.seg.chest, { x: 0, y: -23 }, 240, K.backK, K.backD);
this.strings.handL = string(this.seg.lArmL, { x: 0, y: 23 }, 335, K.handK, K.handD);
this.strings.handR = string(this.seg.lArmR, { x: 0, y: 23 }, 335, K.handK, K.handD);
this.strings.kneeL = string(this.seg.legL, { x: 0, y: -15 }, 395, K.kneeK, K.kneeD);
this.strings.kneeR = string(this.seg.legR, { x: 0, y: -15 }, 395, K.kneeK, K.kneeD);
for (const key of Object.keys(this.seg) as Seg[]) {
this.home0[key] = { x: this.seg[key].position.x, y: this.seg[key].position.y };
}
this.setBar(this.barX, K.barHomeY, 0);
}
/** Teleport every segment back to its start pose and drop anything held.
* Harness-only lets tests run back to back from a known state. */
reset(homeX = 400): void {
this.setGrip(false);
const b = this.stage.matter.body;
const dx = homeX - 400;
for (const key of Object.keys(this.seg) as Seg[]) {
b.setPosition(this.seg[key], { x: this.home0[key].x + dx, y: this.home0[key].y }, false);
b.setAngle(this.seg[key], 0, false);
b.setVelocity(this.seg[key], { x: 0, y: 0 });
b.setAngularVelocity(this.seg[key], 0);
}
this.prevBarX = homeX;
this.setBar(homeX, K.barHomeY, 0);
}
/** Anchor offsets from the bar centre, before tilt rotation. */
private static ANCHOR: Record<keyof Marionette['strings'], Vec> = {
head: { x: 0, y: -8 }, back: { x: 0, y: 8 },
handL: { x: -46, y: 12 }, handR: { x: 46, y: 12 },
kneeL: { x: -22, y: 26 }, kneeR: { x: 22, y: 26 },
};
/** Move the control bar. tilt (radians) rotates the whole string array a
* fast horizontal move tilts the bar and the puppet leans into it. */
setBar(x: number, y: number, tilt: number): void {
this.barX = x;
const c = Math.cos(tilt), s = Math.sin(tilt);
for (const key of Object.keys(this.strings) as (keyof Marionette['strings'])[]) {
const o = Marionette.ANCHOR[key];
const p = this.strings[key].pointA as Vec;
p.x = x + o.x * c - o.y * s;
p.y = y + o.x * s + o.y * c;
}
}
/** Raw pointer/harness input bar move + grip. Tilt comes from how fast the
* bar is travelling sideways; LMB (reach) drops the bar to grab low. */
applyInput(mouseX: number, mouseY: number, reach: boolean, grip: boolean): void {
const clamp = (v: number, a: number, b: number) => (v < a ? a : v > b ? b : v);
const x = clamp(mouseX, K.railMin, K.railMax);
const y = clamp(mouseY, K.barCeil, K.barFloor) + (reach ? K.reachDrop : 0);
const tilt = clamp((x - this.prevBarX) * K.tiltGain, -K.tiltMax, K.tiltMax);
this.prevBarX = x;
this.setBar(x, y, tilt);
this.setGrip(grip);
}
/** hand tip world positions. */
handWorld(): { L: Vec; R: Vec } {
return { L: this.handTipWorld(this.seg.lArmL), R: this.handTipWorld(this.seg.lArmR) };
}
get gripping(): boolean {
return this.gripped;
}
get holding(): boolean {
return this.gripCon.some((c) => c !== null);
}
/** Both hands want to clamp. Turning grip OFF drops whatever's held. The
* actual grab happens in update(): while gripped, any empty hand latches
* onto the nearest target the instant it comes within reach so you can
* lower a closed hand onto food and it catches on contact. */
setGrip(on: boolean): void {
if (on === this.gripped) return;
this.gripped = on;
if (!on) {
for (const c of this.gripCon) if (c) this.stage.matter.world.remove(c);
this.gripCon = [null, null];
}
}
/** Per-frame: latch empty gripping hands onto anything in reach. */
update(): void {
if (!this.gripped) return;
const arms = [this.seg.lArmL, this.seg.lArmR];
for (let i = 0; i < 2; i++) {
if (this.gripCon[i]) continue;
const tipW = this.handTipWorld(arms[i]);
const t = this.nearestTarget(tipW);
if (!t) continue;
this.gripCon[i] = this.stage.matter.add.constraint(arms[i], t, 0, K.gripK, {
pointA: { x: 0, y: 23 }, pointB: this.toLocal(t, tipW), damping: 0.1,
});
}
}
private handTipWorld(arm: MatterJS.BodyType): Vec {
const a = arm.angle;
return { x: arm.position.x - Math.sin(a) * 23, y: arm.position.y + Math.cos(a) * 23 };
}
private nearestTarget(p: Vec): MatterJS.BodyType | null {
let best: MatterJS.BodyType | null = null, bd = K.gripReach;
for (const t of this.gripTargets) {
const d = Math.hypot(t.position.x - p.x, t.position.y - p.y);
if (d < bd) { bd = d; best = t; }
}
return best;
}
private toLocal(body: MatterJS.BodyType, world: Vec): Vec {
const dx = world.x - body.position.x, dy = world.y - body.position.y;
const c = Math.cos(-body.angle), s = Math.sin(-body.angle);
return { x: dx * c - dy * s, y: dx * s + dy * c };
}
private deg(rad: number): number {
return +((rad * 180) / Math.PI).toFixed(1);
}
pose(): Pose {
const lean = this.deg(this.seg.chest.angle);
const upright = this.seg.head.position.y < this.seg.hips.position.y && Math.abs(lean) < 45;
return {
head: this.deg(this.seg.head.angle),
chest: this.deg(this.seg.chest.angle),
hips: this.deg(this.seg.hips.angle),
armL: this.deg(this.seg.lArmL.angle),
armR: this.deg(this.seg.lArmR.angle),
legL: this.deg(this.seg.legL.angle),
legR: this.deg(this.seg.legR.angle),
lean,
upright,
};
}
/** hips x — the thing that moves when the puppet walks. */
get torsoX(): number {
return this.seg.hips.position.x;
}
/** peak speed of any segment — 0 ≈ fully settled. */
restSpeed(): number {
let v = 0;
for (const key of Object.keys(this.seg) as Seg[]) {
v = Math.max(v, Math.hypot(this.seg[key].velocity.x, this.seg[key].velocity.y));
}
return v;
}
}