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:
parent
ea2c8c5089
commit
7116b71d17
@ -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`.
|
||||
|
||||
|
||||
|
||||
|
||||
70
src/dev.ts
70
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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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
102
src/sim/stack.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user