sandoniette/src/dev.ts
type-two da840b531a 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>
2026-07-17 20:18:56 +10:00

157 lines
5.4 KiB
TypeScript

import type { Stage } from './scenes/stage';
import { FLOOR_Y } from './scenes/stage';
/**
* Dev harness — toastsim's single most valuable organ, transplanted.
*
* 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;
* 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 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);
};
/** 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;
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();
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(),
/** Set the control bar to an absolute position and step one frame. */
bar(x: number, y = p().barHomeY) {
stage.auto = false;
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 {
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 — __s.dangle() __s.walk() __s.pickupTest()');
}