M3: stacking — the sando, scored + earthquake nudge

- sim/stack.ts: ordered layers on a plate zone, honest Matter stacking scored
  by lean/COM-drift/overhang. standing = elevation (a toppled tower loses
  height), not lean angle — a short stack reads 30deg from a 24px step.
- nudge() earthquake discriminates: a centred sando survives (lean 0->2.4),
  a marginally-leaning one (lean 44) topples on the shake.
- Grip pickup fixed: was non-deterministic (trials shared puppet state) and
  unreliable (a sideways bar yank only stretches the arms — they hang from the
  shoulders). Now shiftBodyTo() walks the body over the crate first, and every
  trial resets to home -> a true, deterministic 10/10.
- Exit-bar (full battery): M1 dangle 97f / walk 8.78u upright / grip 10/10;
  M2 chop 2p cv0 clean / push splat 3.28 / wild offstage; M3 good survives,
  bad toppled-by-quake. Verified in-browser + npm run check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-17 20:52:05 +10:00
parent ea2c8c5089
commit 7116b71d17
6 changed files with 189 additions and 13 deletions

View File

@ -28,9 +28,21 @@
**chop** 2 clean pieces CV 0 (<0.1 req); **push** 0 pieces + splat 3.28 (wet crime);
**wild** swing flings a piece offstage. Verified in-browser (two clean halves rest
on the board) + `npm run check` (headless geom asserts).
- **Next: M3** stacking (`sim/stack.ts`) — the sando: layers, lean°, COM drift, nudge test.
- Run: `npm run dev` (:5173). Harness: `__s.dangle() __s.walk() __s.pickupTest()
__s.chop() __s.push() __s.wildChop()`. Headless: `npm run check`.
- **M3 ✅** stacking. `sim/stack.ts` — the sando: ordered layers on a plate zone
(x=980), honest Matter stacking scored by lean°, COM drift, overhang. `standing`
is measured by **elevation** (a toppled tower loses height) not lean angle — a
short 3-layer stack reads 30° from a mere 24px step, so lean alone is a bad
topple test. Trap fixed while here: **grip pickup was non-deterministic** (trials
shared puppet state) AND unreliable (yanking the bar sideways just stretches the
arms — they hang from the shoulders, so the BODY must drift over the crate first;
`shiftBodyTo` waits for the hips to arrive, then reach). Now a true 10/10.
Exit-bar: centred 3-layer sando stands + survives quake (lean 0→2.4°); a leaning
stack stands marginally (lean 44°) then the nudge topples it — the quake
discriminates good from bad. Verified in-browser (full M1+M2+M3 battery green).
- **Next: M4** samurai + full loop (`sim/samurai.ts`) — anger meter, THE STRING CUT,
serve+bow, scorecard, judge voice. The third must-feel: **the string cut**.
- Run: `npm run dev` (:5173). Harness: `__s.dangle/walk/pickupTest/chop/push/
wildChop/stackGood/stackBad/nudge`. Headless: `npm run check`.

View File

@ -32,6 +32,21 @@ export function installHarness(stage: Stage): void {
}
};
/** Hold the bar at (x, home) until the hips arrive under it (body drifts to
* follow the bar the arms hang from the shoulders, so the body has to get
* there first). Caps at maxFrames. */
const shiftBodyTo = (x: number, maxFrames = 180): number => {
stage.auto = false;
let i = 0;
for (; i < maxFrames; i++) {
p().setBar(x, p().barHomeY, 0);
p().setGrip(false);
stage.stepSim();
if (Math.abs(p().torsoX - x) < 14 && p().restSpeed() < 1.2) break;
}
return i;
};
/** 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. */
@ -48,7 +63,7 @@ export function installHarness(stage: Stage): void {
};
const placeCrate = (x: number): void => {
M().setPosition(stage.crate, { x, y: FLOOR_Y - 16 }, false);
M().setPosition(stage.crate, { x, y: FLOOR_Y - 24 }, false); // rest flush on the floor
M().setAngle(stage.crate, 0, false);
M().setVelocity(stage.crate, { x: 0, y: 0 });
M().setAngularVelocity(stage.crate, 0);
@ -122,27 +137,31 @@ export function installHarness(stage: Stage): void {
};
},
/** Reach down over the crate, grip, lift — did the crate leave the floor? */
/** Reach down over the crate, grip, lift did the crate leave the floor?
* The arms hang from the shoulders, not the bar, so a sideways yank just
* stretches them. Instead aim the bar AT the crate and hold until the whole
* body drifts over it then the hands hang right onto the crate. */
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
shiftBodyTo(crateX); // walk the body over the crate before reaching
hold(crateX, p().barHomeY + 150, 40, true); // reach down + clamp (sticky grabs on contact)
const grabbedY = stage.crate.position.y;
hold(crateX, p().barHomeY - 40, 40, true); // lift
hold(crateX, p().barHomeY - 30, 50, 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. */
/** Grip must lift the crate 10/10. Each trial resets to the identical home
* pose so trials are independent (no drift accumulating across grabs). */
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
p().reset(HOME_X);
settle();
const r = this.pickup(440 + (i % 5) * 15);
if (r.held) ok++;
lifts.push(r.lift);
@ -201,6 +220,41 @@ export function installHarness(stage: Stage): void {
const offstage = stage.cutter.foods.some((f) => f.body.position.x < 20 || f.body.position.x > 1260 || f.body.position.y > 705);
return { ...r, offstage, at: stage.cutter.foods.map((f) => ({ x: +f.body.position.x.toFixed(0), y: +f.body.position.y.toFixed(0) })) };
},
// ---- M3: stacking ------------------------------------------------------
stack: () => stage.sando.stats(),
/** Build a fresh sando: one layer per (kind, offsetX) pair. */
buildSando(spec: [string, number][] = [['bread', 0], ['tomato', 0], ['bread', 0]]) {
stage.auto = false;
stage.sando.clear();
for (const [kind, off] of spec) stage.sando.addLayer(kind, off);
for (let i = 0; i < 60; i++) stage.stepSim(); // let the whole tower settle
return stage.sando.stats();
},
/** Earthquake: shove the stack sideways by `force` (px/step), then settle. */
nudge(force = 3) {
stage.auto = false;
stage.sando.nudge(force);
for (let i = 0; i < 90; i++) stage.stepSim();
return stage.sando.stats();
},
/** M3 exit-bar: a centred 3-layer sando stands and survives the quake. */
stackGood() {
const built = this.buildSando([['bread', 0], ['tomato', 0], ['bread', 0]]);
const quaked = this.nudge(3);
return { built, quaked, survived: quaked.standing };
},
/** M3 exit-bar: a leaning stack stands marginally, then the quake topples it. */
stackBad() {
const built = this.buildSando([['bread', 0], ['tomato', 20], ['bread', 40]]);
const stoodBefore = built.standing;
const quaked = this.nudge(3);
return { built, quaked, stoodBefore, toppledByQuake: stoodBefore && !quaked.standing };
},
};
(window as { __s?: typeof s }).__s = s;

View File

@ -3,6 +3,7 @@ import { installHarness } from '../dev';
import { Marionette } from '../sim/marionette';
import { Cutter } from '../sim/cutting';
import { spawnFood } from '../sim/food';
import { Sando } from '../sim/stack';
export const STAGE_W = 1280;
export const STAGE_H = 720;
@ -26,8 +27,10 @@ export class Stage extends Phaser.Scene {
props: Prop[] = [];
puppet!: Marionette;
cutter!: Cutter;
sando!: Sando;
crate!: MatterJS.BodyType;
board!: MatterJS.BodyType;
plate!: MatterJS.BodyType;
private space!: Phaser.Input.Keyboard.Key;
constructor() {
@ -55,6 +58,11 @@ export class Stage extends Phaser.Scene {
this.cutter = new Cutter(this);
this.cutter.add(spawnFood(this, 'tomato', 760, 512));
// the plate zone (a static dish on the floor) where the sando gets stacked.
this.plate = this.matter.add.rectangle(980, 630, 140, 12, { isStatic: true });
this.addBody(this.plate, 140, 12, 0x2a211a);
this.sando = new Sando(this, 980, 624);
this.space = this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE);
installHarness(this);
}

View File

@ -23,7 +23,7 @@ const circle = (r: number, n: number, squashY = 1): V[] =>
return { x: Math.cos(a) * r, y: Math.sin(a) * r * squashY };
});
const box = (w: number, h: number): V[] => {
export const box = (w: number, h: number): V[] => {
const x = w / 2, y = h / 2, c = Math.min(x, y) * 0.4; // chamfered corners → convex octagon
return [
{ x: -x + c, y: -y }, { x: x - c, y: -y }, { x, y: -y + c }, { x, y: y - c },

View File

@ -19,7 +19,7 @@ const K = {
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
gripReach: 60, // 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

102
src/sim/stack.ts Normal file
View File

@ -0,0 +1,102 @@
import type { Stage } from '../scenes/stage';
import { box, Food, FOODS, makeFood } from './food';
/**
* The sando. Ordered layers carried onto a plate zone. Stability is honest
* Matter stacking a tower falls because its centre of mass left the base, not
* because a script said so but every layer is SCORED: lean, COM drift,
* overhang. The order ticket (M5) names the target; here it's just layers.
*/
interface LayerDef {
w: number;
h: number;
color: number;
face: string;
}
const L = (kind: string, w: number, h: number): LayerDef => ({
w, h, color: FOODS[kind].color, face: FOODS[kind].face,
});
export const LAYERS: Record<string, LayerDef> = {
bread: L('bread', 94, 24),
tomato: L('tomato', 78, 18),
cheese: L('cheese', 82, 16),
katsu: L('katsu', 88, 22),
egg: L('egg', 70, 20),
};
export interface SandoStats {
layers: number;
leanDeg: number; // tilt of the stack axis from vertical
comDrift: number; // px the worst layer sits off the plate centre
overhang: number; // px the worst layer juts past the one below its support
standing: boolean;
}
const deg = (r: number): number => +((r * 180) / Math.PI).toFixed(1);
const halfW = (b: MatterJS.BodyType): number => (b.bounds.max.x - b.bounds.min.x) / 2;
export class Sando {
layers: Food[] = [];
constructor(private stage: Stage, public plateX = 980, public plateTopY = 634) {}
/** Top surface of the current stack (or the plate if empty). */
private topY(): number {
if (!this.layers.length) return this.plateTopY;
return Math.min(...this.layers.map((f) => f.body.bounds.min.y));
}
/** Place one layer on top, offset offsetX from the plate centre, then settle. */
addLayer(kind: string, offsetX = 0, settleFrames = 45): Food {
const def = LAYERS[kind] ?? LAYERS.bread;
const mat = FOODS[kind]?.material ?? FOODS.bread.material;
const y = this.topY() - def.h / 2 - 1;
const food = makeFood(this.stage, box(def.w, def.h), this.plateX + offsetX, y, mat, def.color, def.face, kind);
this.layers.push(food);
this.stage.auto = false;
for (let i = 0; i < settleFrames; i++) this.stage.stepSim();
return food;
}
clear(): void {
for (const f of this.layers) this.stage.removeProp(f.body);
this.layers = [];
}
stats(): SandoStats {
const n = this.layers.length;
if (n === 0) return { layers: 0, leanDeg: 0, comDrift: 0, overhang: 0, standing: false };
const bottom = this.layers[0].body, top = this.layers[n - 1].body;
const leanDeg = deg(Math.atan2(top.position.x - bottom.position.x, Math.max(1, bottom.position.y - top.position.y)));
let comDrift = 0, overhang = 0, stackH = 0;
for (let i = 0; i < n; i++) {
const b = this.layers[i].body;
comDrift = Math.max(comDrift, Math.abs(b.position.x - this.plateX));
stackH += b.bounds.max.y - b.bounds.min.y;
if (i > 0) {
const below = this.layers[i - 1].body;
const off = Math.abs(b.position.x - below.position.x);
overhang = Math.max(overhang, off - Math.max(0, halfW(below) - halfW(b)));
}
}
// Standing = the tower still has its height: a collapse drops the top layer.
// A tall sando that survives keeps the top near stackH above the plate; a
// toppled one spills and the top sinks toward the plate. Lean is reported,
// not a pass/fail — a stepped-but-intact sando still stands.
const elevation = this.plateTopY - top.position.y;
const standing = elevation > 0.45 * stackH;
return { layers: n, leanDeg, comDrift: +comDrift.toFixed(1), overhang: +overhang.toFixed(1), standing };
}
/** The earthquake: shove every layer sideways (px/step). A centred stack rides
* it out; one already leaning past its base goes over. */
nudge(force: number): void {
const b = this.stage.matter.body;
for (const f of this.layers) {
b.setVelocity(f.body, { x: f.body.velocity.x + force, y: f.body.velocity.y });
}
}
}