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:
parent
077e970b16
commit
506a57fa89
@ -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 M1–M6 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 ·
|
||||
|
||||
34
src/dev.ts
34
src/dev.ts
@ -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+). */
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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 arm→target,
|
||||
* 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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user