Build-out 2: the playable loop — detection stacking, live serve, string picker, 8 days

- Sando is zone-DETECTION now: the stack = food bodies physically resting in
  the plate zone, bottom->top. Hand-stacked and harness-stacked sandos are
  judged identically (sando.kinds() is what the judge reads).
- The full live loop: Game.state + updateLive() — ticket clock with late-anger
  (1 bump/3s overdue), bow-dwell serve (hold still 0.5s over a matching
  standing sando -> scorecard), click -> next day; string cut -> ragdoll ->
  fail card -> click retries the same day with fresh strings. setupDay stages
  bread + fillings per order; sins fire live off cutter.onCut + offstage sweep.
- String unlockables: HEMP/RUBBER/CHAIN/SILK picker on the title (stiffness/
  damping multipliers, registry-persisted). Rubber visibly stretches.
- Days 6-8: Tamago (it rolls), Tofu (fragile), THE TOWER (5 layers) + tofu food.
- samurai_bow (same seed): an S serve swaps in the single approving nod.
- Battery: M1 87f/8.41u/10-10, M2 cv0 clean, M3 both, days SSSSSSSS, live
  serve commits at 31f (~0.5s), death->retry + serve->advance verified.
- Known: embedded preview pane never fires rAF (visibilityState hidden) — live
  loop freezes there by browser design; harness steps manually. Real tabs fine.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-20 17:06:22 +10:00
parent 4f4b31b914
commit 26921eb4ad
15 changed files with 377 additions and 63 deletions

View File

@ -5,7 +5,8 @@
"name": "dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 5173
"port": 5173,
"autoPort": true
}
]
}

View File

@ -85,6 +85,36 @@
Attract ragdoll → the pulsing title. Verified: title screenshot, click path,
production `npm run build` clean.
## BUILD-OUT PASS 2 ✅ — the playable loop, string unlockables, 8 days
- **Physical stacking**`Sando` is now zone-DETECTION: the stack = whatever food
bodies rest in the plate zone (±82px, below-flight speed), sorted bottom→top.
A human stacking by marionette hand and the harness are judged identically.
`sando.kinds()` (strips piece `p` suffixes) is what the judge reads.
- **The full live loop**`Game.state` (playing/served/dead) + `updateLive()`:
ticket clock with late-anger ramp (1 bump / 3s overdue), the **bow-dwell serve**
(hold the bar still 0.5s over a matching standing sando → scorecard), click →
next day (`registry sando.day` → `scene.restart`), string cut → ragdoll →
fail scorecard → click retries the SAME day with fresh strings. Day setup
(`Stage.setupDay`) stages bread slices + one whole food per non-bread kind on
the board. Live sins wired off `cutter.onCut` (squash→splat, botch→crimeCut) +
offstage sweep. (ponytail: no floor-drop sin yet — add when a day needs it.)
- **String unlockables** — title-screen picker: HEMP/RUBBER/CHAIN/SILK
(`STRING_STYLES` k/d multipliers on the same rig; registry `sando.style`).
Rubber visibly stretches — verified.
- **Days 68**: Tamago (egg rolls), Tofu (fragile), THE TOWER (5 layers). All 8
days S via harness. Live serve: dwell commits at **31 frames ≈ 0.5s**, grade B
(no cut performed — honest), day 8 same; death→retry + serve→advance both
close the loop (verified by simulated clicks + manual queue flush).
- **samurai_bow.png** (same seed 320) wired: an S-grade serve swaps the portrait
to the single approving nod. counter_wood/day_stamp/face_cut_bread banked in
`public/assets/img/`, unwired.
- **Embedded-pane trap (KNOWN, not a bug)**: the preview pane reports
`document.visibilityState='hidden'` → rAF never fires → live loop + queued
scene ops freeze; harness drives frames manually, and
`game.scene.processQueue()` flushes ops by hand. Real visible tabs unaffected.
- Full battery green: M1 87f/8.41u/10-10 · M2 cv0-clean/splat/wild · M3 ✓✓ ·
days SSSSSSSS · live served@31f / d8 served / dead CUT · cleaver CUTS.
## 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 492 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 708 KiB

View File

@ -85,3 +85,9 @@ gen_2d face_egg 368 "a comical wide-eyed cartoon face for a boiled egg, s
gen_2d splat_wet 370 "a single messy wet food splat splatter shape, flat black silhouette, on transparent background" 512 512 cut
echo "done."
# ---- build-out batch 2 (seeds 400+): serve counter, S-grade bow, cutout faces v2 ----
gen_2d samurai_bow 320 "$SAMURAI, head bowed slightly in approval, eyes closed, serene" 768 900 cut
gen_2d counter_wood 402 "a low wooden serving counter table, edo period, side view, flat black silhouette on transparent background, hand-crafted paper cut-out style" 768 384 cut
gen_2d face_cut_bread 410 "simple face cut out of black paper: two round holes for eyes and a small worried oval mouth hole, white background, minimal, high contrast, paper-cut stencil" 512 512 cut
gen_2d day_stamp 420 "a round red japanese hanko seal stamp, blank center, worn ink edges, on transparent background" 512 512 cut

View File

@ -258,12 +258,39 @@ export function installHarness(stage: Stage): void {
return { built, quaked, survived: quaked.standing };
},
/** M3 exit-bar: a leaning stack stands marginally, then the quake topples it. */
/** M3 exit-bar: a leaning stack stands marginally, then the quake topples it.
* Toppled = it fell OR it shed a layer off the plate both are collapse. */
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 };
return { built, quaked, stoodBefore, toppledByQuake: stoodBefore && (!quaked.standing || quaked.layers < built.layers) };
},
/** Live serve flow: set up the day for real, materialise the ordered stack on
* the plate, then hold the bar still the dwell must commit the serve and
* put up the scorecard, exactly as a player bowing would. */
playServeFlow(n = 0) {
stage.auto = false;
stage.clearScorecard();
stage.session.reset();
stage.session.setDay(n);
stage.setupDay();
stage.auto = false; // setupDay re-arms live play; keep the harness driving
this.buildSando(stage.session.order.layers.map((k) => [k, 0] as [string, number]));
let frames = 0;
while (stage.session.state === 'playing' && frames < 180) {
stage.stepSim();
stage.session.updateLive(1 / 60, true);
frames++;
}
return {
day: n + 1,
state: stage.session.state,
framesToServe: frames,
grade: stage.session.lastVerdict?.grade ?? null,
matches: stage.session.matchesOrder(),
};
},
// ---- M4: the samurai + the full loop -----------------------------------

View File

@ -17,6 +17,9 @@ export const DAYS: Day[] = [
{ order: { name: 'Double Tomato', layers: ['bread', 'tomato', 'tomato', 'bread'], needsCut: true, maxSeconds: 45 }, pressure: 'wetter — twice the tomato', angerRamp: 1 },
{ order: { name: 'Katsu Sando', layers: ['bread', 'katsu', 'tomato', 'bread'], needsCut: true, maxSeconds: 45 }, pressure: 'a real cut — the katsu', angerRamp: 1 },
{ order: { name: 'Rush Hour', layers: ['bread', 'tomato', 'bread'], needsCut: true, maxSeconds: 22 }, pressure: 'the patience ramp', angerRamp: 1.8 },
{ order: { name: 'Tamago Sando', layers: ['bread', 'egg', 'bread'], needsCut: true, maxSeconds: 45 }, pressure: 'it rolls', angerRamp: 1 },
{ order: { name: 'Tofu, Gently', layers: ['bread', 'tofu', 'bread'], needsCut: true, maxSeconds: 45 }, pressure: 'fragile — pressing is death', angerRamp: 1.2 },
{ order: { name: 'THE TOWER', layers: ['bread', 'katsu', 'cheese', 'tomato', 'bread'], needsCut: true, maxSeconds: 60 }, pressure: 'five layers', angerRamp: 1.3 },
];
export const ORDERS: Order[] = DAYS.map((d) => d.order);
@ -26,15 +29,65 @@ export const ORDERS: Order[] = DAYS.map((d) => d.order);
* and turns a finished attempt into a Verdict. Kitchen sins feed the anger
* meter; the meter topping out fires the string cut (wired to the stage).
*/
export type RunState = 'playing' | 'served' | 'dead';
const BOW_SECS = 0.5; // the dwell commit, straight from toastsim
const LATE_TICK = 3; // one servedLate bump per this many seconds overdue
export class Game {
samurai = new Samurai();
day = 0;
order: Order = DAYS[0].order;
drops = 0;
seconds = 0; // ticket timer (seconds)
state: RunState = 'playing';
/** 0..1 bow-dwell progress while holding still over a correct sando. */
dwell = 0;
lastVerdict: Verdict | null = null;
private lateBumped = 0;
constructor(private stage: Stage) {
this.samurai.onStrike = () => this.stage.stringCut();
this.samurai.onStrike = () => {
this.state = 'dead';
this.stage.stringCut();
};
}
/** Is the physical stack exactly the ordered layers? */
matchesOrder(): boolean {
const kinds = this.stage.sando.kinds();
return kinds.length === this.order.layers.length && kinds.every((k, i) => k === this.order.layers[i]);
}
/** Live-play heartbeat: clock, overdue anger, and the bow-dwell commit.
* `barStill` = the player is holding the control bar still. */
updateLive(dtSec: number, barStill: boolean): void {
if (this.state !== 'playing' || this.samurai.struck) return;
this.seconds += dtSec;
const over = this.seconds - this.order.maxSeconds;
if (over > 0 && Math.floor(over / LATE_TICK) > this.lateBumped) {
this.lateBumped = Math.floor(over / LATE_TICK);
this.samurai.bump('servedLate');
}
const ready = this.matchesOrder() && this.stage.sando.stats().standing;
if (ready && barStill) {
this.dwell += dtSec / BOW_SECS;
if (this.dwell >= 1) {
this.state = 'served';
this.lastVerdict = this.serve(true);
this.stage.showScorecard(this.lastVerdict);
}
} else {
this.dwell = Math.max(0, this.dwell - dtSec * 2);
}
}
/** The string-cut post-mortem: judged with nothing served. */
failVerdict(): Verdict {
const run = this.buildRun(false, this.seconds);
run.service.served = false;
run.service.correct = false;
return judge(this.order, run);
}
/** Load day `n` (clamped): its order + its samurai patience ramp. */
@ -45,6 +98,10 @@ export class Game {
this.samurai.ramp = d.angerRamp;
this.drops = 0;
this.seconds = 0;
this.state = 'playing';
this.dwell = 0;
this.lateBumped = 0;
this.lastVerdict = null;
return d;
}
@ -53,10 +110,10 @@ export class Game {
this.order = order;
this.drops = 0;
this.seconds = 0;
}
tick(dtSec: number): void {
this.seconds += dtSec;
this.state = 'playing';
this.dwell = 0;
this.lateBumped = 0;
this.lastVerdict = null;
}
// ---- kitchen sins → anger ----
@ -77,8 +134,7 @@ export class Game {
private buildRun(bowHeld: boolean, seconds: number): RunResult {
const cut = this.stage.cutter.last;
const s = this.stage.sando.stats();
const kinds = this.stage.sando.layers.map((l) => l.kind.replace(/p+$/, ''));
const matches = kinds.length === this.order.layers.length && kinds.every((k, i) => k === this.order.layers[i]);
const matches = this.matchesOrder();
return {
cut: cut ? { cv: cut.cv, quality: cut.quality, pieces: cut.pieces } : { cv: 1, quality: 'none', pieces: 0 },
sando: { layers: s.layers, leanDeg: s.leanDeg, comDrift: s.comDrift, standing: s.standing, matches },

View File

@ -1,10 +1,10 @@
import Phaser from 'phaser';
import { installHarness } from '../dev';
import { Marionette } from '../sim/marionette';
import { Marionette, type StringStyle } from '../sim/marionette';
import { Cutter } from '../sim/cutting';
import { spawnFood } from '../sim/food';
import { Sando } from '../sim/stack';
import { Game } from '../game/game';
import { Sando, spawnLayer } from '../sim/stack';
import { Game, DAYS } from '../game/game';
import type { Verdict } from '../game/judging';
import { sfx } from '../game/audio';
@ -43,8 +43,14 @@ export class Stage extends Phaser.Scene {
private samuraiImg?: Phaser.GameObjects.Image;
private angerBar?: Phaser.GameObjects.Graphics;
private strings?: Phaser.GameObjects.Graphics;
private serveG?: Phaser.GameObjects.Graphics;
private serveHint?: Phaser.GameObjects.Text;
private advanceHint?: Phaser.GameObjects.Text;
private vignette?: Phaser.GameObjects.Rectangle;
private scorecard?: Phaser.GameObjects.Container;
private prevBar = { x: 0, y: 0 };
private deadFrames = 0;
private sweep = 0;
constructor() {
super('stage');
@ -55,7 +61,7 @@ export class Stage extends Phaser.Scene {
// asset paths resolve regardless of a trailing slash on the URL.
const base = import.meta.env.BASE_URL;
const img = (k: string) => this.load.image(k, `${base}assets/img/${k}.png`);
['bg_washi', 'title_art', ...PORTRAITS].forEach(img);
['bg_washi', 'title_art', 'samurai_bow', ...PORTRAITS].forEach(img);
}
create(): void {
@ -72,18 +78,20 @@ export class Stage extends Phaser.Scene {
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 });
this.puppet = new Marionette(this, 400);
// day + string style survive scene restarts (death retry / next day / title pick)
const day = (this.registry.get('sando.day') as number) ?? 0;
const style = (this.registry.get('sando.style') as StringStyle) ?? 'hemp';
this.puppet = new Marionette(this, 400, style);
// 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);
// cutting board (a static counter) + a food resting on it for M2.
// cutting board (a static counter). Day setup below stocks it.
this.board = this.matter.add.rectangle(760, 560, 240, 24, { isStatic: true });
this.addBody(this.board, 240, 24, 0x2a211a);
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 });
@ -96,6 +104,13 @@ export class Stage extends Phaser.Scene {
this.sando = new Sando(this, 980, 624);
this.session = new Game(this);
this.session.setDay(day);
// kitchen sins fire live off the cutter: a squash is a splat, a botched
// pass is a crime cut. (ponytail: no floor-drop sin yet — splat/crime/
// offstage/late are pressure enough; add drops when a day needs it.)
this.cutter.onCut = (r) => {
if (r.quality === 'crime') (r.pieces === 0 ? this.session.splat() : this.session.crimeCut());
};
// HUD: the samurai judge (top-right), the anger meter, the unsheath vignette.
if (this.textures.exists('samurai_calm')) {
@ -105,16 +120,56 @@ export class Stage extends Phaser.Scene {
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)
// order ticket (top-left) + serve prompt + advance hint
this.orderText = this.add.text(28, 34, '', { fontFamily: 'monospace', fontSize: '16px', color: '#2a1a0e' }).setDepth(6);
this.refreshOrder();
this.serveG = this.add.graphics().setDepth(6);
this.serveHint = this.add.text(980, 500, 'HOLD STILL — OJIGI', { fontFamily: 'serif', fontSize: '15px', color: '#e8b84a' })
.setOrigin(0.5).setDepth(6).setVisible(false);
this.advanceHint = this.add.text(STAGE_W / 2, 640, '', { fontFamily: 'serif', fontSize: '18px', color: '#e8b84a' })
.setOrigin(0.5).setDepth(31).setVisible(false);
this.setupDay();
this.space = this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE);
this.input.once('pointerdown', () => sfx.enable()); // unblock audio on first click
// click advances past a scorecard: next day after a serve, retry after death.
this.input.on('pointerdown', () => {
if (this.session.state === 'served') {
this.registry.set('sando.day', (this.session.day + 1) % DAYS.length);
this.scene.restart();
} else if (this.session.state === 'dead' && this.deadFrames > 70) {
this.scene.restart(); // same day, fresh strings
}
});
installHarness(this);
// title over the top; dismissed on click (the harness can skip it).
this.scene.launch('title');
// title overlay on first boot only (day/style picks restart this scene).
if (!this.registry.get('sando.started')) this.scene.launch('title');
}
/** Stock the kitchen for the current order: bread slices staged on the board's
* left end, one whole food per non-bread kind spread along the board (they
* need cutting), puppet + cleaver parked at their homes. */
setupDay(): void {
this.clearScorecard();
this.advanceHint?.setVisible(false);
this.deadFrames = 0;
for (const f of [...this.cutter.foods]) this.removeProp(f.body);
this.cutter.foods = [];
this.cutter.splats = [];
this.cutter.last = null;
const o = this.session.order;
const breadN = o.layers.filter((k) => k === 'bread').length;
for (let i = 0; i < breadN; i++) this.cutter.add(spawnLayer(this, 'bread', 672, 530 - i * 30));
const kinds = [...new Set(o.layers.filter((k) => k !== 'bread'))];
kinds.forEach((k, i) => this.cutter.add(spawnFood(this, k, 780 + i * 70 - (kinds.length - 1) * 35, 500)));
this.puppet.reset(400);
const b = this.matter.body;
b.setPosition(this.cleaver, { x: 632, y: FLOOR_Y - 42 }, false);
b.setAngle(this.cleaver, 0, false);
b.setVelocity(this.cleaver, { x: 0, y: 0 });
b.setAngularVelocity(this.cleaver, 0);
this.auto = true;
}
/** SHUBATTO the string cut. Ragdoll the puppet, fling every loose food with
@ -190,10 +245,41 @@ export class Stage extends Phaser.Scene {
g.strokePath();
}
/** Refresh the order ticket from the current day. */
/** Refresh the order ticket from the current day (timer line is live). */
refreshOrder(): void {
const o = this.session.order;
this.orderText?.setText(`DAY ${this.session.day + 1} ${o.name}\n${o.layers.join(' · ')}`);
const left = Math.max(0, Math.ceil(o.maxSeconds - this.session.seconds));
const late = this.session.seconds > o.maxSeconds;
this.orderText?.setText(
`DAY ${this.session.day + 1} ${o.name}\n${o.layers.join(' · ')}\n⏱ ${left}s${late ? ' — OSOI. LATE.' : ''}`,
);
}
/** The bow: prompt + dwell arc over the plate when the stack matches the order. */
private drawServe(): void {
const g = this.serveG;
if (!g) return;
g.clear();
const ready = this.session.state === 'playing' && this.session.matchesOrder();
this.serveHint?.setVisible(ready);
if (ready && this.session.dwell > 0) {
g.lineStyle(5, 0xe8b84a, 0.9);
g.beginPath();
g.arc(980, 530, 20, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * Math.min(1, this.session.dwell));
g.strokePath();
}
}
/** Bin anything that left the stage entirely (and anger the samurai). */
private offstageSweep(): void {
for (const f of [...this.cutter.foods]) {
const b = f.body;
if (b.position.y > STAGE_H + 80 || b.position.x < -60 || b.position.x > STAGE_W + 60) {
this.removeProp(b);
this.cutter.foods = this.cutter.foods.filter((x) => x !== f);
this.session.offstage();
}
}
}
/** World position of the cleaver's cutting edge (its lower tip). */
@ -211,7 +297,10 @@ export class Stage extends Phaser.Scene {
private updateHUD(): void {
const sam = this.session.samurai;
if (this.samuraiImg && this.textures.exists(sam.portrait)) this.samuraiImg.setTexture(sam.portrait);
// an S earns the single approving nod — the bowing portrait over the scorecard.
const bowed = this.session.state === 'served' && this.session.lastVerdict?.grade === 'S';
const key = bowed && this.textures.exists('samurai_bow') ? 'samurai_bow' : sam.portrait;
if (this.samuraiImg && this.textures.exists(key)) this.samuraiImg.setTexture(key);
const bar = this.angerBar;
if (bar) {
bar.clear();
@ -285,12 +374,31 @@ export class Stage extends Phaser.Scene {
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
// the bow dwell reads bar stillness, not pointer stillness — chain strings
// clamp the bar the same way the player's hand does.
const still = Math.hypot(this.puppet.barX - this.prevBar.x, this.puppet.barY - this.prevBar.y) < 1.6;
this.prevBar = { x: this.puppet.barX, y: this.puppet.barY };
this.session.updateLive(1 / 60, still);
this.sweep = (this.sweep + 1) % 30;
if (this.sweep === 0) this.offstageSweep();
// the string-cut post-mortem: let the ragdoll land, then the scorecard.
if (this.session.state === 'dead') {
this.deadFrames++;
if (this.deadFrames === 72) {
this.showScorecard(this.session.failVerdict());
this.advanceHint?.setText('click — mō ichido. (again.)').setVisible(true);
}
} else if (this.session.state === 'served' && this.scorecard && !this.advanceHint?.visible) {
this.advanceHint?.setText('click — the next day').setVisible(true);
}
}
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.drawServe();
this.refreshOrder();
this.updateHUD();
}
}

View File

@ -1,10 +1,12 @@
import Phaser from 'phaser';
import { sfx } from '../game/audio';
import { STRING_STYLES, type StringStyle } from '../sim/marionette';
/**
* Title card, launched as an overlay over the live stage so the dev harness is
* available underneath from frame one. Click anywhere to begin that click also
* unblocks audio, so the shamisen sting lands on entry.
* available underneath from frame one. Pick your strings (difficulty modifier),
* then click to begin the stage restarts with the chosen string style, and
* that first click also unblocks audio so the shamisen sting lands.
*/
export class Title extends Phaser.Scene {
constructor() {
@ -16,27 +18,60 @@ export class Title extends Phaser.Scene {
this.add.rectangle(W / 2, H / 2, W, H, 0x0a0705, 0.82);
if (this.textures.exists('title_art')) {
this.add.image(W / 2, H * 0.38, 'title_art').setScale(0.62).setDepth(1);
this.add.image(W / 2, H * 0.34, 'title_art').setScale(0.56).setDepth(1);
}
this.add.text(W / 2, H * 0.66, 'サンドニエット', { fontFamily: 'serif', fontSize: '56px', color: '#efe4cb' })
this.add.text(W / 2, H * 0.6, 'サンドニエット', { fontFamily: 'serif', fontSize: '52px', color: '#efe4cb' })
.setOrigin(0.5).setDepth(1);
this.add.text(W / 2, H * 0.72, 'S A N D O N I E T T E', { fontFamily: 'serif', fontSize: '20px', color: '#c8a24a' })
this.add.text(W / 2, H * 0.66, 'S A N D O N I E T T E', { fontFamily: 'serif', fontSize: '18px', color: '#c8a24a' })
.setOrigin(0.5).setDepth(1);
const shout = this.add.text(W / 2, H * 0.85, 'SHUBATTO!! SANDONIETTE!!!', {
// ---- string select: the unlockables-as-difficulty row ----
const styles = Object.keys(STRING_STYLES) as StringStyle[];
const picked = ((this.registry.get('sando.style') as StringStyle) ?? 'hemp');
const labels = new Map<StringStyle, Phaser.GameObjects.Text>();
const paint = (sel: StringStyle): void => {
for (const [k, t] of labels) {
t.setColor(k === sel ? '#e8b84a' : '#8a7a5a');
t.setText(k === sel ? `${k.toUpperCase()}` : k.toUpperCase());
}
blurb.setText(STRING_STYLES[sel].blurb);
};
styles.forEach((k, i) => {
const t = this.add.text(W / 2 + (i - (styles.length - 1) / 2) * 150, H * 0.76, k.toUpperCase(), {
fontFamily: 'monospace', fontSize: '18px', color: '#8a7a5a',
}).setOrigin(0.5).setDepth(2).setInteractive({ useHandCursor: true });
t.on('pointerdown', (_p: unknown, __x: unknown, ___y: unknown, ev?: { stopPropagation?: () => void }) => {
ev?.stopPropagation?.();
this.registry.set('sando.style', k);
sfx.enable();
sfx.shamisen();
paint(k);
});
labels.set(k, t);
});
const blurb = this.add.text(W / 2, H * 0.81, '', { fontFamily: 'serif', fontSize: '14px', color: '#9a8a6a', fontStyle: 'italic' })
.setOrigin(0.5).setDepth(1);
paint(picked);
const shout = this.add.text(W / 2, H * 0.89, 'SHUBATTO!! SANDONIETTE!!!', {
fontFamily: 'serif', fontSize: '22px', color: '#e8b84a', fontStyle: 'bold',
}).setOrigin(0.5).setDepth(1);
this.tweens.add({ targets: shout, scale: 1.12, duration: 600, yoyo: true, repeat: -1, ease: 'Sine.inOut' });
const hint = this.add.text(W / 2, H * 0.93, 'click to begin', { fontFamily: 'serif', fontSize: '15px', color: '#9a8a6a' })
const hint = this.add.text(W / 2, H * 0.95, 'click to begin', { fontFamily: 'serif', fontSize: '15px', color: '#9a8a6a' })
.setOrigin(0.5).setDepth(1);
this.tweens.add({ targets: hint, alpha: 0.3, duration: 900, yoyo: true, repeat: -1 });
this.input.once('pointerdown', () => {
// begin: anywhere that isn't a style button. Restart the stage so the puppet
// is strung with the chosen style, then drop the overlay.
this.input.on('pointerdown', (_p: Phaser.Input.Pointer, over: unknown[]) => {
if (over.length > 0) return; // that click was a style pick
sfx.enable();
sfx.gong();
sfx.shamisen();
this.registry.set('sando.started', true);
this.scene.get('stage').scene.restart();
this.scene.stop();
});
}

View File

@ -45,9 +45,16 @@ export class Cutter {
foods: Food[] = [];
splats: { mass: number }[] = [];
last: CutResult | null = null;
/** Fired on every committed cut/squash — the stage wires kitchen sins here. */
onCut?: (r: CutResult) => void;
private tip: V | null = null;
private track = new Map<Food, Track>();
private report(r: CutResult): void {
this.last = r;
this.onCut?.(r);
}
constructor(private stage: Stage) {}
add(food: Food): void {
@ -100,7 +107,7 @@ export class Cutter {
if (mat.moisture > 0.4 && power < CUT.squash) {
this.splat(food, peakSpeed);
} else {
this.last = { quality: 'crime', pieces: 1, cv: 0, speed: +peakSpeed.toFixed(2), chordFrac: +chordFrac.toFixed(2) };
this.report({ quality: 'crime', pieces: 1, cv: 0, speed: +peakSpeed.toFixed(2), chordFrac: +chordFrac.toFixed(2) });
}
return;
}
@ -127,13 +134,13 @@ export class Cutter {
});
const areas = parts.map(polyArea);
this.last = {
this.report({
quality,
pieces: 2,
cv: +pieceCV(areas).toFixed(3),
speed: +peakSpeed.toFixed(2),
chordFrac: +chordFrac.toFixed(2),
};
});
}
private splat(food: Food, peakSpeed: number): void {
@ -144,7 +151,7 @@ export class Cutter {
this.stage.removeProp(food.body);
this.foods = this.foods.filter((f) => f !== food);
this.stage.splat(c.x, c.y, Math.sqrt(polyArea(verts) / Math.PI) * 1.3, food.color);
this.last = { quality: 'crime', pieces: 0, cv: 0, speed: +peakSpeed.toFixed(2), chordFrac: 0 };
this.report({ quality: 'crime', pieces: 0, cv: 0, speed: +peakSpeed.toFixed(2), chordFrac: 0 });
// ponytail: slippery-floor patch deferred — no one carries over it until M4.
}

View File

@ -37,6 +37,9 @@ export const FOODS: Record<string, FoodDef> = {
cheese: { verts: box(60, 44), material: { resistance: 0.45, moisture: 0.15, wobble: 0.0 }, color: 0x191510, face: 'face_cheese' },
katsu: { verts: box(86, 46), material: { resistance: 0.55, moisture: 0.1, wobble: 0.0 }, color: 0x171008, face: 'face_katsu' },
egg: { verts: circle(28, 10, 1.15), material: { resistance: 0.3, moisture: 0.4, wobble: 0.1 }, color: 0x121212, face: 'face_egg' },
// ponytail: wobble is a material param only — real jelly-cluster jiggle waits
// until a day needs it to be funny. Tofu cuts easy but splats if pressed.
tofu: { verts: box(64, 46), material: { resistance: 0.12, moisture: 0.6, wobble: 0.4 }, color: 0x1a1a18, face: 'face_egg' },
};
let NEXT = 1;

View File

@ -33,6 +33,16 @@ type Seg =
| 'uArmL' | 'lArmL' | 'uArmR' | 'lArmR'
| 'legL' | 'legR';
/** Unlockable strings difficulty modifiers, not new mechanics. Multipliers on
* string stiffness/damping only; the puppet itself never changes. */
export type StringStyle = 'hemp' | 'rubber' | 'chain' | 'silk';
export const STRING_STYLES: Record<StringStyle, { k: number; d: number; blurb: string }> = {
hemp: { k: 1, d: 1, blurb: 'the honest string' },
rubber: { k: 0.45, d: 0.5, blurb: 'bouncy. +lag +comedy' },
chain: { k: 1.7, d: 2.4, blurb: 'heavy. slow. precise' },
silk: { k: 1.35, d: 1.15, blurb: 'tighter, for cowards' },
};
type Vec = { x: number; y: number };
export interface Pose {
@ -59,7 +69,7 @@ export class Marionette {
/** Bodies the hands can grab (crate, later food). Stage fills this. */
gripTargets: MatterJS.BodyType[] = [];
constructor(private stage: Stage, homeX = 400) {
constructor(private stage: Stage, homeX = 400, public style: StringStyle = 'hemp') {
this.barX = homeX;
this.prevBarX = homeX;
const m = stage.matter;
@ -107,10 +117,11 @@ export class Marionette {
* build and again after a string cut when the puppet is restrung. */
private buildStrings(): void {
const m = this.stage.matter;
const s = STRING_STYLES[this.style];
const string = (b: MatterJS.BodyType, pb: Vec, length: number, k: number, d: number) =>
m.add.worldConstraint(b, length, k, {
m.add.worldConstraint(b, length, Math.min(0.95, k * s.k), {
pointA: { x: this.barX, y: K.barHomeY },
pointB: pb, damping: d, render: { visible: false },
pointB: pb, damping: d * s.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.

View File

@ -2,10 +2,11 @@ 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.
* The sando. The stack is DETECTED, not declared: whatever food bodies are
* physically resting in the plate zone, sorted bottomtop, IS the sando so a
* human dropping pieces by marionette hand and the harness placing them are
* judged identically. Stability is honest Matter stacking; the judge scores
* lean, COM drift, overhang off the real bodies.
*/
interface LayerDef {
@ -25,6 +26,7 @@ export const LAYERS: Record<string, LayerDef> = {
cheese: L('cheese', 82, 16),
katsu: L('katsu', 88, 22),
egg: L('egg', 70, 20),
tofu: L('tofu', 74, 22),
};
export interface SandoStats {
@ -35,64 +37,92 @@ export interface SandoStats {
standing: boolean;
}
/** Materialise one slice-shaped layer food at (x, y). Caller registers it. */
export function spawnLayer(stage: Stage, kind: string, x: number, y: number): Food {
const def = LAYERS[kind] ?? LAYERS.bread;
const mat = FOODS[kind]?.material ?? FOODS.bread.material;
return makeFood(stage, box(def.w, def.h), x, y, mat, def.color, def.face, kind);
}
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;
const ZONE_HALF = 82; // px either side of plate centre that counts as "on the dish"
const IN_FLIGHT = 5; // px/step — anything faster is passing through, not stacked
export class Sando {
layers: Food[] = [];
constructor(private stage: Stage, public plateX = 980, public plateTopY = 634) {}
/** The physical stack: foods resting in the plate zone, bottom→top. */
get layers(): Food[] {
return this.stage.cutter.foods
.filter((f) => {
const b = f.body;
return (
Math.abs(b.position.x - this.plateX) < ZONE_HALF &&
b.position.y < this.plateTopY + 6 &&
Math.hypot(b.velocity.x, b.velocity.y) < IN_FLIGHT
);
})
.sort((a, b) => b.body.position.y - a.body.position.y);
}
/** Base kinds bottom→top ('tomatop' piece → 'tomato'). What the judge reads. */
kinds(): string[] {
return this.layers.map((f) => f.kind.replace(/p+$/, ''));
}
/** 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));
const ls = this.layers;
if (!ls.length) return this.plateTopY;
return Math.min(...ls.map((f) => f.body.bounds.min.y));
}
/** Place one layer on top, offset offsetX from the plate centre, then settle. */
/** Harness helper: materialise one layer on top and settle. The spawned food
* registers with the cutter's food list, so detection sees it like any other. */
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);
const food = spawnLayer(this.stage, kind, this.plateX + offsetX, y);
this.stage.cutter.add(food);
this.stage.auto = false;
for (let i = 0; i < settleFrames; i++) this.stage.stepSim();
return food;
}
/** Remove everything currently in the plate zone. */
clear(): void {
for (const f of this.layers) this.stage.removeProp(f.body);
this.layers = [];
for (const f of this.layers) {
this.stage.removeProp(f.body);
this.stage.cutter.foods = this.stage.cutter.foods.filter((x) => x !== f);
}
}
stats(): SandoStats {
const n = this.layers.length;
const ls = this.layers;
const n = ls.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 bottom = ls[0].body, top = ls[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;
const b = ls[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 below = ls[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.
// Standing = the tower still has its height: a collapse drops the top layer
// (and usually scatters pieces out of the zone entirely — also honest).
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. */
/** The earthquake: shove every stacked layer sideways (px/step). */
nudge(force: number): void {
const b = this.stage.matter.body;
for (const f of this.layers) {