Polish: visible strings + control bar, cut-out faces, cleaver

- The soul of a marionette game: draw the 6 strings + the wooden control bar
  every frame (Marionette.stringSegments/barSegment, Stage.drawStrings). Gone
  once the strings are cut. It finally reads as a puppet on strings.
- Procedural cut-out faces: two eyes + a worried 'o' punched into each whole-food
  silhouette in the bright washi tone (backlit-hole look, fits the aesthetic).
  Pieces stay faceless. (The generated face PNGs were full-colour cartoons that
  clashed with the silhouettes — this replaces them.)
- Order ticket HUD (top-left) + a grabbable cleaver whose held edge drives the
  live cutter — swinging it chops food. __s.swingCleaver() proves the wiring:
  a held blade whipped through food cuts clean (tip speed ~150 >> threshold).
- All M1-M6 harness numbers unchanged; typecheck + npm run check + build pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-17 21:46:15 +10:00
parent 077e970b16
commit 506a57fa89
5 changed files with 166 additions and 7 deletions

View File

@ -78,6 +78,20 @@
Attract ragdoll → the pulsing title. Verified: title screenshot, click path,
production `npm run build` clean.
## POLISH PASS (post-M6) ✅ — "make it even better"
- **Visible strings + control bar** — the biggest missed feel. `Marionette.stringSegments()`
/ `barSegment()`; `Stage.drawStrings()` redraws the 6 strings + the wooden bar each
frame (gone when cut). It now unmistakably reads as a marionette.
- **Cut-out faces** — procedural: two eyes + a worried "o" punched into each whole-food
silhouette in the bright washi tone (backlit-hole look). Pieces get no face.
(The generated face PNGs were full-colour cartoons that clashed — this fits.)
- **Order ticket HUD** (top-left) + **the cleaver**: a grabbable blade; held in a hand,
its edge drives the live cutter — swinging chops. Proven: `__s.swingCleaver()` cuts
clean (tip speed ~150, well over threshold). Full manual play (walk to blade, carry,
chop, stack, serve across the stage) is the intended-hard game; the wiring is there.
- All M1M6 harness numbers unchanged (strings/faces/cleaver don't perturb the sim):
dangle 87f · walk 8.83u up · grip 10/10 · chop cv0 clean · days SSSSS · disaster→cut.
## ALL SIX MILESTONES SHIP ✅ — full loop plays, three must-feels landed
(the dangle M1, the clean chop M2, the string cut M4). Final battery green:
M1 dangle 97f / walk 8.83u up / grip 10/10 · M2 chop cv0 clean / splat / offstage ·

View File

@ -310,6 +310,40 @@ export function installHarness(stage: Stage): void {
return { ...v, standing: stage.sando.stats().standing, order: stage.session.order.name };
},
/** Proves the held-cleaver live-cut wiring: with the cleaver in the hand,
* a swing through food cuts it (a marionette arm easily clears the cut
* speed). In play the challenge is getting the blade there and steady. */
swingCleaver() {
this.freshRun();
const M = stage.matter;
const arm = stage.puppet.seg.lArmR;
M.body.setPosition(stage.cleaver, { x: arm.position.x, y: arm.position.y + 40 }, false);
const weld = M.add.constraint(arm, stage.cleaver, 0, 0.9, { pointA: { x: 0, y: 20 }, pointB: { x: 0, y: -32 } });
for (const f of [...stage.cutter.foods]) stage.removeProp(f.body);
stage.cutter.foods = [];
stage.cutter.last = null;
const foodX = stage.puppet.seg.hips.position.x + 55;
this.spawnFood('tomato', foodX);
let peak = 0, prev = stage.cleaverTip();
const whip = (bx: number, by: number, n: number) => {
for (let i = 0; i < n; i++) {
p().setBar(bx, by, 0);
stage.stepSim();
stage.cutter.moveBlade(stage.cleaverTip());
const t = stage.cleaverTip();
peak = Math.max(peak, Math.hypot(t.x - prev.x, t.y - prev.y));
prev = t;
}
};
for (let r = 0; r < 6 && stage.cutter.foods.length < 2; r++) {
whip(foodX + 20, 60, 6);
whip(foodX, 330, 6);
}
M.world.remove(weld);
const st = stage.cutter.stats();
return { cut: stage.cutter.foods.length >= 2, pieces: st.pieces, peakTipSpeed: +peak.toFixed(1), quality: st.last?.quality };
},
// ---- M5: days + escalation ---------------------------------------------
/** Play day `n` with a scripted-but-honest run: cut the filling, stack the
* ordered layers, serve in time. Each day must stay beatable (B+). */

View File

@ -18,7 +18,7 @@ export const FLOOR_Y = 640;
* This is the whole game's render contract: the body is truth, the shape follows. */
export interface Prop {
body: MatterJS.BodyType;
view: Phaser.GameObjects.Shape;
view: Phaser.GameObjects.Shape | Phaser.GameObjects.Image | Phaser.GameObjects.Container;
}
/**
@ -35,11 +35,14 @@ export class Stage extends Phaser.Scene {
sando!: Sando;
session!: Game;
crate!: MatterJS.BodyType;
cleaver!: MatterJS.BodyType;
board!: MatterJS.BodyType;
plate!: MatterJS.BodyType;
private orderText?: Phaser.GameObjects.Text;
private space!: Phaser.Input.Keyboard.Key;
private samuraiImg?: Phaser.GameObjects.Image;
private angerBar?: Phaser.GameObjects.Graphics;
private strings?: Phaser.GameObjects.Graphics;
private vignette?: Phaser.GameObjects.Rectangle;
private scorecard?: Phaser.GameObjects.Container;
@ -79,6 +82,11 @@ export class Stage extends Phaser.Scene {
this.cutter = new Cutter(this);
this.cutter.add(spawnFood(this, 'tomato', 760, 512));
// the cleaver — a grabbable blade. Held in a hand, its edge cuts on the swing.
this.cleaver = this.matter.add.rectangle(632, FLOOR_Y - 42, 12, 78, { density: 0.0008, friction: 0.6 });
this.addBody(this.cleaver, 12, 78, 0x151515);
this.puppet.gripTargets.push(this.cleaver);
// 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);
@ -91,8 +99,13 @@ export class Stage extends Phaser.Scene {
this.samuraiImg = this.add.image(STAGE_W - 150, 210, 'samurai_calm').setScale(0.42).setDepth(5);
}
this.angerBar = this.add.graphics().setDepth(6);
this.strings = this.add.graphics().setDepth(3); // strings + control bar, over the silhouettes
this.vignette = this.add.rectangle(STAGE_W / 2, STAGE_H / 2, STAGE_W, STAGE_H, 0x1a0000, 0).setDepth(4);
// order ticket (top-left)
this.orderText = this.add.text(28, 34, '', { fontFamily: 'monospace', fontSize: '16px', color: '#2a1a0e' }).setDepth(6);
this.refreshOrder();
this.space = this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE);
this.input.once('pointerdown', () => sfx.enable()); // unblock audio on first click
installHarness(this);
@ -151,6 +164,48 @@ export class Stage extends Phaser.Scene {
this.scorecard = undefined;
}
/** Redraw the strings from the control bar to the puppet, plus the bar. */
private drawStrings(): void {
const g = this.strings;
if (!g) return;
g.clear();
const segs = this.puppet.stringSegments();
if (!segs.length) return; // cut — no strings to draw
g.lineStyle(1.5, 0x1a1206, 0.55);
for (const s of segs) {
g.beginPath();
g.moveTo(s.ax, s.ay);
g.lineTo(s.bx, s.by);
g.strokePath();
}
// the wooden control bar
const b = this.puppet.barSegment();
g.lineStyle(7, 0x3a2612, 0.95);
g.beginPath();
g.moveTo(b.x1, b.y1);
g.lineTo(b.x2, b.y2);
g.strokePath();
}
/** Refresh the order ticket from the current day. */
refreshOrder(): void {
const o = this.session.order;
this.orderText?.setText(`DAY ${this.session.day + 1} ${o.name}\n${o.layers.join(' · ')}`);
}
/** World position of the cleaver's cutting edge (its lower tip). */
cleaverTip(): { x: number; y: number } {
const a = this.cleaver.angle, half = 39;
return { x: this.cleaver.position.x - Math.sin(a) * half, y: this.cleaver.position.y + Math.cos(a) * half };
}
/** A gripped cleaver is the live blade; otherwise the blade parks. Called each
* frame in play, and by the harness after its own steps. */
driveCleaver(): void {
if (this.puppet.held().includes(this.cleaver)) this.cutter.moveBlade(this.cleaverTip());
else this.cutter.resetBlade(this.cleaverTip());
}
private updateHUD(): void {
const sam = this.session.samurai;
if (this.samuraiImg && this.textures.exists(sam.portrait)) this.samuraiImg.setTexture(sam.portrait);
@ -192,13 +247,26 @@ export class Stage extends Phaser.Scene {
sfx.splat();
}
/** Destroy a body's silhouette and remove it from the world. */
/** Punch a comical cut-out face into a food silhouette two eyes + a worried
* little mouth, in the bright washi tone so they read as backlit holes. */
addFace(body: MatterJS.BodyType, w: number, h: number): void {
const hole = 0xf4e8c4;
const r = Math.max(2.8, Math.min(w, h) * 0.11);
const ex = Math.min(w * 0.2, 18), ey = -h * 0.1;
const c = this.add.container(body.position.x, body.position.y).setDepth(1);
c.add(this.add.circle(-ex, ey, r, hole));
c.add(this.add.circle(ex, ey, r, hole));
c.add(this.add.circle(0, h * 0.18, r * 0.8, hole)); // small "o" of alarm
this.props.push({ body, view: c });
}
/** Destroy every view tracking a body (silhouette + any face) and remove it. */
removeProp(body: MatterJS.BodyType): void {
const i = this.props.findIndex((p) => p.body === body);
if (i >= 0) {
this.props[i].view.destroy();
this.props.splice(i, 1);
}
this.props = this.props.filter((p) => {
if (p.body !== body) return true;
p.view.destroy();
return false;
});
this.matter.world.remove(body);
}
@ -213,11 +281,13 @@ export class Stage extends Phaser.Scene {
const grip = p.rightButtonDown() || this.space.isDown;
this.puppet.applyInput(p.worldX, p.worldY || this.puppet.barHomeY, p.leftButtonDown(), grip);
this.stepSim();
this.driveCleaver(); // a gripped cleaver is the live blade — swinging cuts
}
for (const pr of this.props) {
pr.view.setPosition(pr.body.position.x, pr.body.position.y);
pr.view.setRotation(pr.body.angle);
}
this.drawStrings();
this.updateHUD();
}
}

View File

@ -65,6 +65,12 @@ export function makeFood(stage: Stage, local: V[], x: number, y: number, mat: Ma
const c = centroid(local);
const rel = local.map((v) => ({ x: v.x - c.x, y: v.y - c.y }));
stage.addPoly(body, rel, color);
// a comical face — whole foods only (pieces are pieces). `p`-suffixed kinds
// are cut pieces; don't give a severed half a startled face.
if (!kind.endsWith('p')) {
const xs = local.map((v) => v.x), ys = local.map((v) => v.y);
stage.addFace(body, Math.max(...xs) - Math.min(...xs), Math.max(...ys) - Math.min(...ys));
}
return new Food(body, mat, color, face, kind);
}

View File

@ -50,6 +50,8 @@ export class Marionette {
cut = false; // strings severed → ragdoll
barX: number;
barY = K.barHomeY;
tilt = 0;
readonly barHomeY = K.barHomeY;
private prevBarX: number;
private home0 = {} as Record<Seg, { x: number; y: number }>;
@ -163,6 +165,8 @@ export class Marionette {
* fast horizontal move tilts the bar and the puppet leans into it. */
setBar(x: number, y: number, tilt: number): void {
this.barX = x;
this.barY = y;
this.tilt = tilt;
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];
@ -190,6 +194,26 @@ export class Marionette {
return { L: this.handTipWorld(this.seg.lArmL), R: this.handTipWorld(this.seg.lArmR) };
}
/** The 6 strings as bar-anchor body-attach world segments, for drawing.
* Empty once the strings are cut. */
stringSegments(): { ax: number; ay: number; bx: number; by: number }[] {
if (this.cut) return [];
const out: { ax: number; ay: number; bx: number; by: number }[] = [];
for (const key of Object.keys(this.strings) as (keyof Marionette['strings'])[]) {
const c = this.strings[key] as unknown as { pointA: Vec; pointB: Vec; bodyB: MatterJS.BodyType };
const a = c.pointA, pb = c.pointB, body = c.bodyB;
const co = Math.cos(body.angle), si = Math.sin(body.angle);
out.push({ ax: a.x, ay: a.y, bx: body.position.x + pb.x * co - pb.y * si, by: body.position.y + pb.x * si + pb.y * co });
}
return out;
}
/** The wooden control bar as a segment (centre ± half-width, tilted). */
barSegment(half = 58): { x1: number; y1: number; x2: number; y2: number } {
const c = Math.cos(this.tilt), s = Math.sin(this.tilt);
return { x1: this.barX - half * c, y1: this.barY - half * s, x2: this.barX + half * c, y2: this.barY + half * s };
}
get gripping(): boolean {
return this.gripped;
}
@ -197,6 +221,17 @@ export class Marionette {
return this.gripCon.some((c) => c !== null);
}
/** The bodies currently clamped in the hands (grab constraints are armtarget,
* so the target is bodyB). */
held(): MatterJS.BodyType[] {
const out: MatterJS.BodyType[] = [];
for (const c of this.gripCon) {
const t = (c as unknown as { bodyB?: MatterJS.BodyType } | null)?.bodyB;
if (t) out.push(t);
}
return out;
}
/** 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