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>
This commit is contained in:
parent
802f738a3d
commit
da840b531a
@ -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()`.
|
||||
|
||||
|
||||
|
||||
|
||||
143
src/dev.ts
143
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()');
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
255
src/sim/marionette.ts
Normal file
255
src/sim/marionette.ts
Normal file
@ -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<Seg, MatterJS.BodyType>;
|
||||
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<Seg, { x: number; y: number }>;
|
||||
|
||||
/** 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<keyof Marionette['strings'], Vec> = {
|
||||
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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user