diff --git a/OPUS-BUILD-BRIEF.md b/OPUS-BUILD-BRIEF.md index d3a3754..b2a0d46 100644 --- a/OPUS-BUILD-BRIEF.md +++ b/OPUS-BUILD-BRIEF.md @@ -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`. diff --git a/src/dev.ts b/src/dev.ts index d778dbe..d43672d 100644 --- a/src/dev.ts +++ b/src/dev.ts @@ -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; diff --git a/src/scenes/stage.ts b/src/scenes/stage.ts index 4c63f95..af70abb 100644 --- a/src/scenes/stage.ts +++ b/src/scenes/stage.ts @@ -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); } diff --git a/src/sim/food.ts b/src/sim/food.ts index 65ca6d4..6273481 100644 --- a/src/sim/food.ts +++ b/src/sim/food.ts @@ -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 }, diff --git a/src/sim/marionette.ts b/src/sim/marionette.ts index 68cac49..bd81bfe 100644 --- a/src/sim/marionette.ts +++ b/src/sim/marionette.ts @@ -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 diff --git a/src/sim/stack.ts b/src/sim/stack.ts new file mode 100644 index 0000000..9329a12 --- /dev/null +++ b/src/sim/stack.ts @@ -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 = { + 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 }); + } + } +}