diff --git a/OPUS-BUILD-BRIEF.md b/OPUS-BUILD-BRIEF.md index eabb7e6..44431c8 100644 --- a/OPUS-BUILD-BRIEF.md +++ b/OPUS-BUILD-BRIEF.md @@ -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()`. diff --git a/src/dev.ts b/src/dev.ts index 5934689..670483f 100644 --- a/src/dev.ts +++ b/src/dev.ts @@ -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()'); } diff --git a/src/scenes/stage.ts b/src/scenes/stage.ts index e12d8ac..509b4c3 100644 --- a/src/scenes/stage.ts +++ b/src/scenes/stage.ts @@ -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); } } } diff --git a/src/sim/marionette.ts b/src/sim/marionette.ts new file mode 100644 index 0000000..68cac49 --- /dev/null +++ b/src/sim/marionette.ts @@ -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; + 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; + + /** 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 = { + 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; + } +}