Build-out 7: cutting matters + 無限 MUGEN endless mode

- THE CUT is judged on the PLATE now. It read cutter.last (any cut anywhere,
  even on a piece never served) and kinds() stripped the piece suffix, so a
  whole uncut tomato scored the same as a cut one — M2 was decorative in live
  play. Sando.cutStats() now reports how much of the SERVED filling met the
  cleaver; score = cutFrac x (0.6*quality + 0.4*evenness). Serving whole never
  blocks a serve, it just zeroes THE CUT and caps near B.
  Measured: same sando, cut pieces S(10.0) vs served whole B(7.39).
  Fairness trap caught mid-build: evenness must be measured WITHIN a kind, not
  across kinds (a katsu slice vs a tomato slice is the recipe, not a crime) —
  that silently cost both weeks a grade until fixed.
- 無限 MUGEN: endless orders from a seeded Rng, layers 3->6, clock 50s->16s,
  and the anger meter NEVER resets between orders — the only way a run ends is
  the strings getting cut. No scene restart between orders. Judge escalates
  samurai -> Shogun (5) -> the ONI (12, new MODELBEAST portrait set). Own
  leaderboard board; run-over card posts the score. Title cycles 表/裏/無限.
- Spawner gives one whole food per two required pieces (one cut = two halves).
- setDay now explicitly resets the meter instead of relying on scene restart.
- Battery unchanged: 87f / 8.41u / 10-10 / cv0 clean / stack both / disaster
  CUT / cleaver CUTS; weeks SSSSSSSS and AASSASSS; mugen escalation verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-27 13:38:33 +10:00
parent 820b559556
commit dfff55fd1c
17 changed files with 334 additions and 73 deletions

View File

@ -85,6 +85,37 @@
Attract ragdoll → the pulsing title. Verified: title screenshot, click path,
production `npm run build` clean.
## BUILD-OUT PASS 7 ✅ — cutting matters + 無限 MUGEN endless mode
- **THE CUT is now judged on the PLATE** (was a real design bug): it read
`cutter.last`*any* cut anywhere, even on a piece never served — and
`kinds()` stripped the `p` suffix, so a whole uncut tomato scored identically
to a cut one. M2 was decorative in live play. Now `Sando.cutStats()` reports
what fraction of the SERVED filling actually met the cleaver, and THE CUT
scores `cutFrac × (0.6·quality + 0.4·evenness)`. Serving whole never blocks a
serve — it just zeroes THE CUT and caps you near B. Measured: same sando,
**cut pieces S(10.0) vs served-whole B(7.39)**. Cutting is worth real points,
not a wall.
- Fairness trap caught mid-build: evenness must be measured WITHIN a kind
("are your tomato slices even"), not across kinds — comparing a katsu slice
to a tomato slice punished the chef for the recipe and silently dropped both
weeks a grade. Fixed → weeks back to SSSSSSSS / AASSASSS.
- **無限 MUGEN — the endless shift**: orders generated forever from a seeded Rng
(`mugenOrder`), escalating layers 3→6 and clock 50s→16s. **The anger meter
never resets between orders** (a clean serve is the only way to sheathe), so a
run ends exactly one way: the strings get cut. No scene restart between
orders — the stage, the meter and the run persist. Judge escalates with the
streak: samurai → **the Shogun** (5) → **the ONI** (12, MODELBEAST seed 720 —
horns, tusks, kanabo, lightning). Own `mugen` leaderboard; run-over card posts
the score. Unlocked with 裏; the title cycles 表 → 裏 → 無限.
- Spawner now gives one whole food per TWO required pieces of a kind — a
single clean cut yields two halves, so "2 tomato" is one tomato, well cut.
- `setDay` now explicitly resets the meter (a new day is a fresh judge) rather
than relying on the scene restart to do it — endless is the deliberate
opposite.
- Verified: full M1M6 battery unchanged (87f / 8.41u / 10-10 / cv0 clean / ✓✓ /
disaster CUT / cleaver CUTS), both weeks at original grades, endless escalation
+ judge ladder + anger-carry all measured, title mode-cycle exercised.
## BUILD-OUT PASS 6 ✅ — the audience + the global board
- **The crowd**: 11 seeded silhouette heads (shoulders/head/occasional topknot)
along the stage lip. Idle sway; trembling once anger>0.5; a hard duck for 110

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 730 KiB

View File

@ -105,3 +105,13 @@ gen_2d shogun_angry 620 "$SHOGUN, one blade half drawn and gleaming" 768 900 c
gen_2d shogun_fury 620 "$SHOGUN, both blades drawn crossed overhead, explosive rage" 768 900 cut
gen_2d shogun_bow 620 "$SHOGUN, head inclined a fraction in rare approval, blades sheathed" 768 900 cut
gen_2d curtain_red 640 "closed kabuki theatre curtains, deep red heavy fabric with vertical folds and gold trim at the bottom edge, full frame, japanese theatre, flat graphic style, no text" 1280 768
# ---- batch 5 (seeds 700+): THE ONI (endless-mode third judge) + coach scroll ----
ONI="a monstrous japanese oni demon looming over a kitchen, wild mane of hair, two great horns, tusked snarling face, muscular arms, an enormous iron kanabo club, shown as a dramatic flat black paper-cut silhouette with sickly green accents against a stormy grey washi screen"
gen_2d oni_calm 720 "$ONI, arms folded, waiting, club resting on the floor" 768 900 cut
gen_2d oni_watch 720 "$ONI, head tilted, one eye narrowed, hefting the club slightly" 768 900 cut
gen_2d oni_annoyed 720 "$ONI, baring tusks, club lifted to the shoulder" 768 900 cut
gen_2d oni_angry 720 "$ONI, roaring, club raised high, veins bulging" 768 900 cut
gen_2d oni_fury 720 "$ONI, full berserk roar, club swung back for a devastating blow, lightning behind" 768 900 cut
gen_2d oni_bow 720 "$ONI, grunting a single grudging nod of approval, club lowered" 768 900 cut
gen_2d scroll_paper 740 "a hanging vertical japanese paper scroll, blank cream washi with dark wooden rollers top and bottom, plain, no text, on transparent background" 640 900 cut

View File

@ -235,10 +235,13 @@ export function installHarness(stage: Stage): void {
stack: () => stage.sando.stats(),
/** Build a fresh sando: one layer per (kind, offsetX) pair. */
buildSando(spec: [string, number][] = [['bread', 0], ['tomato', 0], ['bread', 0]]) {
/** Fillings are stacked as PIECES (bread stays a slice) standing in for
* "the player cut it, then stacked it", which is what THE CUT now judges.
* Pass raw=true to stack whole uncut food (the never-cut regression case). */
buildSando(spec: [string, number][] = [['bread', 0], ['tomato', 0], ['bread', 0]], raw = false) {
stage.auto = false;
stage.sando.clear();
for (const [kind, off] of spec) stage.sando.addLayer(kind, off);
for (const [kind, off] of spec) stage.sando.addLayer(kind, off, 45, !raw && kind !== 'bread');
for (let i = 0; i < 60; i++) stage.stepSim(); // let the whole tower settle
return stage.sando.stats();
},
@ -397,6 +400,55 @@ export function installHarness(stage: Stage): void {
return DAYS.map((_, i) => this.playDay(i, ura));
},
// ---- 無限 MUGEN -----------------------------------------------------------
/** Play `n` endless orders end to end: the anger must CARRY between them and
* the judge must escalate. Returns per-order grade + the running meter. */
playMugen(n = 14) {
stage.auto = false;
stage.clearScorecard();
stage.session.startMugen();
stage.registry.set('sando.score', 0);
const out: { order: number; layers: number; grade: string; anger: number; judge: string; secs: number }[] = [];
for (let i = 0; i < n; i++) {
const o = stage.session.order;
stage.setupDay();
stage.auto = false;
this.buildSando(o.layers.map((k) => [k, 0] as [string, number]));
let f = 0;
while (stage.session.state === 'playing' && f < 200) {
stage.stepSim();
stage.session.updateLive(1 / 60, true);
f++;
}
out.push({
order: i + 1,
layers: o.layers.length,
grade: stage.session.lastVerdict?.grade ?? '—',
anger: +stage.session.samurai.anger.toFixed(2),
judge: stage.session.samurai.who,
secs: o.maxSeconds,
});
if (stage.session.state !== 'served') break;
stage.session.loadMugenOrder();
}
return { served: stage.session.streak, runScore: stage.registry.get('sando.score'), orders: out };
},
/** THE CUT must be judged on the PLATE: an uncut sando scores 0 there and
* caps the grade; the same sando built from cut pieces scores full. */
cutMattersTest() {
const build = (raw: boolean) => {
this.freshRun();
stage.session.setDay(0, false);
this.chop('tomato'); // a real, clean cut happens either way
this.buildSando([['bread', 0], ['tomato', 0], ['bread', 0]], raw);
const v = stage.session.serve(true, 18);
const cut = v.criteria.find((c) => c.key === 'cut');
return { grade: v.grade, total: v.total, cutScore: +(cut?.score ?? -1).toFixed(2), detail: cut?.detail };
};
return { cutPieces: build(false), servedWhole: build(true) };
},
/** M4 exit-bar B: anger the samurai to the top → the string cut → ragdoll. */
playDisasterRun() {
this.freshRun();

View File

@ -2,6 +2,7 @@ import type { Stage } from '../scenes/stage';
import { Samurai } from '../sim/samurai';
import { judge, type Order, type RunResult, type Verdict } from './judging';
import { recordGrade } from './progress';
import { Rng } from '../core/rng';
/** A day introduces exactly ONE new pressure (never two) and stays beatable by
* a scripted-but-honest run. angerRamp scales every sin the samurai's
@ -38,6 +39,38 @@ export const URA_DAYS: Day[] = [
export const ORDERS: Order[] = DAYS.map((d) => d.order);
/**
* MUGEN the endless shift. Orders are generated forever and get worse;
* the anger meter NEVER resets between them (a clean serve is the only way to
* sheathe), so a run ends exactly one way: the strings get cut. The judge
* escalates with your streak samurai, then the Shogun, then the Oni.
*/
const MUGEN_POOL = [
['tomato', 'cheese'], // 0-2 serves: the easy shelf
['tomato', 'cheese', 'egg', 'katsu'],
['tomato', 'cheese', 'egg', 'katsu', 'ebi', 'nori'],
['tomato', 'cheese', 'egg', 'katsu', 'ebi', 'nori', 'tofu', 'daikon', 'konnyaku'],
];
export function mugenJudge(streak: number): 'samurai' | 'shogun' | 'oni' {
return streak >= 12 ? 'oni' : streak >= 5 ? 'shogun' : 'samurai';
}
/** Build order #n of an endless run from a seeded RNG (replayable per seed). */
export function mugenOrder(n: number, rng: Rng): Order {
const pool = MUGEN_POOL[Math.min(MUGEN_POOL.length - 1, Math.floor(n / 4))];
const fillings = Math.min(4, 1 + Math.floor(n / 3));
const layers = ['bread'];
for (let i = 0; i < fillings; i++) layers.push(rng.pick(pool));
layers.push('bread');
return {
name: `ORDER ${n + 1}`,
layers,
needsCut: true,
maxSeconds: Math.max(16, 50 - n * 2),
};
}
/**
* The run controller. Holds the samurai + the current ticket, tallies the floor,
* and turns a finished attempt into a Verdict. Kitchen sins feed the anger
@ -52,6 +85,10 @@ export class Game {
samurai = new Samurai();
day = 0;
ura = false;
/** 無限 endless run: orders forever, anger carries between them. */
mugen = false;
streak = 0; // orders served this endless run
private rng = new Rng(1);
order: Order = DAYS[0].order;
drops = 0;
seconds = 0; // ticket timer (seconds)
@ -91,7 +128,8 @@ export class Game {
if (this.dwell >= 1) {
this.state = 'served';
this.lastVerdict = this.serve(true);
recordGrade(this.day + (this.ura ? 100 : 0), this.lastVerdict.grade); // ura bests live at 100+
if (this.mugen) this.streak++;
else recordGrade(this.day + (this.ura ? 100 : 0), this.lastVerdict.grade); // ura bests live at 100+
// the week's running score, headed for the leaderboard at KANSEI
const run = ((this.stage.registry.get('sando.score') as number) ?? 0) + this.lastVerdict.total;
this.stage.registry.set('sando.score', +run.toFixed(2));
@ -116,9 +154,36 @@ export class Game {
return this.ura ? URA_DAYS : DAYS;
}
/** Load day `n` (clamped) from the current week: order + patience + judge. */
/** Start an endless run at `seed` (0 streak, calm judge, fresh clock). */
startMugen(seed = 20260722): void {
this.mugen = true;
this.ura = false;
this.streak = 0;
this.rng = new Rng(seed);
this.samurai.reset();
this.loadMugenOrder();
}
/** Load the next endless order. Anger deliberately survives — that's the mode. */
loadMugenOrder(): void {
this.order = mugenOrder(this.streak, this.rng);
this.samurai.ramp = 1 + this.streak * 0.12;
this.samurai.who = mugenJudge(this.streak);
this.seconds = 0;
this.lateBumped = 0;
this.dwell = 0;
this.state = 'playing';
this.lastVerdict = null;
}
/** Load day `n` (clamped) from the current week: order + patience + judge.
* A new day is a fresh judge the meter resets. (Live play also rebuilds the
* scene, but say it here so the rule doesn't depend on that.) Endless mode is
* the deliberate opposite: see loadMugenOrder. */
setDay(n: number, ura = this.ura): Day {
this.ura = ura;
this.mugen = false;
this.samurai.reset();
const table = this.days;
this.day = Math.max(0, Math.min(table.length - 1, n));
const d = table[this.day];
@ -165,11 +230,21 @@ export class Game {
}
private buildRun(bowHeld: boolean, seconds: number): RunResult {
const cut = this.stage.cutter.last;
const s = this.stage.sando.stats();
const matches = this.matchesOrder();
// THE CUT is judged on what's ON THE PLATE, not on "a cut happened once".
// Technique (clean/ragged/crime) comes from the session's last real cut;
// how much of the sando actually met the cleaver comes from the sando.
const cs = this.stage.sando.cutStats();
const last = this.stage.cutter.last;
const cut = {
cv: cs.pieces > 1 ? cs.cv : last?.cv ?? 0,
quality: cs.pieces === 0 ? ('none' as const) : last?.quality ?? ('ragged' as const),
pieces: cs.pieces,
cutFrac: cs.cutFrac,
};
return {
cut: cut ? { cv: cut.cv, quality: cut.quality, pieces: cut.pieces } : { cv: 1, quality: 'none', pieces: 0 },
cut,
sando: { layers: s.layers, leanDeg: s.leanDeg, comDrift: s.comDrift, standing: s.standing, matches },
service: { served: true, bowHeld, seconds, correct: matches && s.standing },
floor: { splatMass: this.stage.cutter.stats().splatMass, drops: this.drops },

View File

@ -10,7 +10,8 @@ export interface Order {
/** Everything measured off one attempt — every number read off the sim. */
export interface RunResult {
cut?: { cv: number; quality: 'clean' | 'ragged' | 'crime' | 'none'; pieces: number };
/** cutFrac = how much of the SERVED sando's filling actually met the cleaver. */
cut?: { cv: number; quality: 'clean' | 'ragged' | 'crime' | 'none'; pieces: number; cutFrac?: number };
sando: { layers: number; leanDeg: number; comDrift: number; standing: boolean; matches: boolean };
service: { served: boolean; bowHeld: boolean; seconds: number; correct: boolean };
floor: { splatMass: number; drops: number };
@ -42,16 +43,22 @@ const clamp01 = (v: number): number => (v < 0 ? 0 : v > 1 ? 1 : v);
export function judge(order: Order, r: RunResult): Verdict {
const criteria: Criterion[] = [];
// THE CUT — clean, even pieces
// THE CUT — did the food ON THE PLATE meet the cleaver, and meet it well.
// Serving whole, uncut filling scores zero here (and so caps the grade around
// B) but never blocks the serve — cutting is worth real points, not a wall.
if (order.needsCut) {
const q = r.cut?.quality ?? 'none';
const cv = r.cut?.cv ?? 1;
const frac = r.cut?.cutFrac ?? 0;
const qScore = q === 'clean' ? 1 : q === 'ragged' ? 0.55 : q === 'crime' ? 0.1 : 0.15;
const evenScore = clamp01(1 - (cv - 0.05) / 0.32);
criteria.push({
key: 'cut', group: 'CUT', label: 'THE CUT', weight: 1.2,
score: clamp01(0.6 * qScore + 0.4 * evenScore),
detail: q === 'none' ? 'never cut' : `${q}, ${r.cut?.pieces ?? 0} pieces, CV ${cv.toFixed(2)}`,
score: clamp01(frac * (0.6 * qScore + 0.4 * evenScore)),
detail:
frac === 0
? 'served whole — never cut'
: `${q}, ${r.cut?.pieces ?? 0} pieces${frac < 1 ? ` (${Math.round(frac * 100)}% cut)` : ''}, CV ${cv.toFixed(2)}`,
});
}

View File

@ -5,6 +5,8 @@
* (In local dev there's no proxy; fetches just fail quietly.)
*/
export type Board = 'omote' | 'ura' | 'mugen';
export interface ScoreRow {
name: string;
score: number;
@ -20,7 +22,7 @@ function withTimeout(ms: number): AbortSignal {
return c.signal;
}
export async function fetchTop(week: 'omote' | 'ura'): Promise<ScoreRow[]> {
export async function fetchTop(week: Board): Promise<ScoreRow[]> {
try {
const r = await fetch(`${API}?week=${week}`, { signal: withTimeout(TIMEOUT) });
if (!r.ok) return [];
@ -31,7 +33,7 @@ export async function fetchTop(week: 'omote' | 'ura'): Promise<ScoreRow[]> {
}
}
export async function submitScore(name: string, score: number, week: 'omote' | 'ura'): Promise<number | null> {
export async function submitScore(name: string, score: number, week: Board): Promise<number | null> {
try {
const r = await fetch(API, {
method: 'POST',

View File

@ -74,7 +74,8 @@ export class Stage extends Phaser.Scene {
const base = import.meta.env.BASE_URL;
const img = (k: string) => this.load.image(k, `${base}assets/img/${k}.png`);
const shogun = PORTRAITS.map((k) => k.replace('samurai', 'shogun'));
['bg_washi', 'bg_washi_dusk', 'bg_washi_night', 'title_art', 'samurai_bow', 'shogun_bow', 'day_stamp', 'curtain_red', ...PORTRAITS, ...shogun].forEach(img);
const oni = PORTRAITS.map((k) => k.replace('samurai', 'oni'));
['bg_washi', 'bg_washi_dusk', 'bg_washi_night', 'title_art', 'samurai_bow', 'shogun_bow', 'oni_bow', 'day_stamp', 'curtain_red', ...PORTRAITS, ...shogun, ...oni].forEach(img);
this.load.on('loaderror', (f: { key: string }) => console.warn(`[assets] ${f.key} missing — procedural fallback`));
}
@ -86,7 +87,8 @@ export class Stage extends Phaser.Scene {
// game never blocks on it — a plain fill shows if it failed to load.)
const dayIdx = (this.registry.get('sando.day') as number) ?? 0;
const uraWeek = (this.registry.get('sando.ura') as boolean) ?? false;
const wanted = uraWeek || dayIdx >= 6 ? 'bg_washi_night' : dayIdx >= 3 ? 'bg_washi_dusk' : 'bg_washi';
const mugenMode = (this.registry.get('sando.mugen') as boolean) ?? false;
const wanted = uraWeek || mugenMode || dayIdx >= 6 ? 'bg_washi_night' : dayIdx >= 3 ? 'bg_washi_dusk' : 'bg_washi';
const bgKey = this.textures.exists(wanted) ? wanted : 'bg_washi';
if (this.textures.exists(bgKey)) {
this.add.image(STAGE_W / 2, STAGE_H / 2, bgKey).setDisplaySize(STAGE_W, STAGE_H).setDepth(-10);
@ -123,7 +125,8 @@ export class Stage extends Phaser.Scene {
this.sando = new Sando(this, 980, 624);
this.session = new Game(this);
this.session.setDay(day, uraWeek);
if (mugenMode) this.session.startMugen();
else this.session.setDay(day, uraWeek);
// 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.)
@ -216,19 +219,27 @@ export class Stage extends Phaser.Scene {
// game for real (SHIN-KANSEI), then it's back to a quiet noon kitchen.
this.input.on('pointerdown', () => {
if (this.kansei) {
this.registry.set('sando.ura', !this.session.ura);
// endless: the card is the run-over card — click starts a fresh run.
if (!this.session.mugen) this.registry.set('sando.ura', !this.session.ura);
this.registry.set('sando.day', 0);
this.registry.set('sando.score', 0); // fresh week, fresh run
this.registry.set('sando.score', 0); // fresh week/run, fresh score
this.scene.restart();
} else if (this.session.state === 'served') {
if (this.session.day + 1 >= this.session.days.length) this.showKansei();
if (this.session.mugen) {
// no restart: keep the anger, the stage, and the run going.
this.clearScorecard();
this.advanceHint?.setVisible(false);
this.session.loadMugenOrder();
this.setupDay();
} else if (this.session.day + 1 >= this.session.days.length) this.showKansei();
else {
this.registry.set('sando.day', this.session.day + 1);
this.registry.set('sando.curtain', true);
this.scene.restart();
}
} else if (this.session.state === 'dead' && this.deadFrames > 70) {
this.scene.restart(); // same day, fresh strings
if (this.session.mugen) this.showKansei(); // the run is over — post the score
else this.scene.restart(); // same day, fresh strings
}
});
installHarness(this);
@ -246,35 +257,46 @@ export class Stage extends Phaser.Scene {
music.stop();
sfx.gong();
const ura = this.session.ura;
if (!ura) unlockUra();
const mu = this.session.mugen;
if (!ura && !mu) unlockUra();
const g = this.add.container(0, 0).setDepth(32);
const cx = STAGE_W / 2;
g.add(this.add.rectangle(cx, 340, 760, 440, ura ? 0x0d0810 : 0x120d08, 0.96).setStrokeStyle(3, ura ? 0xa03020 : 0x8a6a2a));
g.add(this.add.text(cx, 205, ura ? '真・完成' : '完成', { fontFamily: 'serif', fontSize: ura ? '64px' : '84px', color: ura ? '#e04a30' : '#e8b84a' }).setOrigin(0.5));
g.add(this.add.text(cx, 268, ura ? 'SHIN-KANSEI — even the Shogun eats' : 'KANSEI — the week is served', { fontFamily: 'serif', fontSize: '18px', color: '#efe4cb' }).setOrigin(0.5));
const best = loadProgress().best;
const off = ura ? 100 : 0;
const table = this.session.days;
table.forEach((_, i) => {
const x = cx - ((table.length - 1) * 74) / 2 + i * 74;
if (this.textures.exists('day_stamp')) g.add(this.add.image(x, 370, 'day_stamp').setDisplaySize(58, 58).setAlpha(0.9));
else g.add(this.add.circle(x, 370, 27, 0x8a1a12, 0.85));
g.add(this.add.text(x, 370, best[i + off] ?? '·', { fontFamily: 'serif', fontSize: '26px', color: '#f4e8c4' }).setOrigin(0.5));
g.add(this.add.text(x, 412, `${i + 1}`, { fontFamily: 'monospace', fontSize: '12px', color: '#9a8a6a' }).setOrigin(0.5));
});
const accent = mu ? 0x30a060 : ura ? 0xa03020 : 0x8a6a2a;
g.add(this.add.rectangle(cx, 340, 760, 440, mu ? 0x08100c : ura ? 0x0d0810 : 0x120d08, 0.96).setStrokeStyle(3, accent));
g.add(this.add.text(cx, 205, mu ? '無限' : ura ? '真・完成' : '完成', { fontFamily: 'serif', fontSize: mu || ura ? '64px' : '84px', color: mu ? '#5ad89a' : ura ? '#e04a30' : '#e8b84a' }).setOrigin(0.5));
g.add(
this.add.text(cx, 452, ura ? '' : 'a colder shadow falls across the screen…', { fontFamily: 'serif', fontSize: '14px', color: '#9a8a6a', fontStyle: 'italic' }).setOrigin(0.5),
this.add.text(cx, 268, mu
? `MUGEN — ${this.session.streak} order${this.session.streak === 1 ? '' : 's'} served before the strings went`
: ura ? 'SHIN-KANSEI — even the Shogun eats' : 'KANSEI — the week is served',
{ fontFamily: 'serif', fontSize: '18px', color: '#efe4cb' }).setOrigin(0.5),
);
if (!mu) {
const best = loadProgress().best;
const off = ura ? 100 : 0;
const table = this.session.days;
table.forEach((_, i) => {
const x = cx - ((table.length - 1) * 74) / 2 + i * 74;
if (this.textures.exists('day_stamp')) g.add(this.add.image(x, 370, 'day_stamp').setDisplaySize(58, 58).setAlpha(0.9));
else g.add(this.add.circle(x, 370, 27, 0x8a1a12, 0.85));
g.add(this.add.text(x, 370, best[i + off] ?? '·', { fontFamily: 'serif', fontSize: '26px', color: '#f4e8c4' }).setOrigin(0.5));
g.add(this.add.text(x, 412, `${i + 1}`, { fontFamily: 'monospace', fontSize: '12px', color: '#9a8a6a' }).setOrigin(0.5));
});
} else {
g.add(this.add.text(cx, 380, `judged by the ${this.session.samurai.who}`, { fontFamily: 'serif', fontSize: '16px', color: '#9a8a6a', fontStyle: 'italic' }).setOrigin(0.5));
}
g.add(
this.add.text(cx, 452, mu || ura ? '' : 'a colder shadow falls across the screen…', { fontFamily: 'serif', fontSize: '14px', color: '#9a8a6a', fontStyle: 'italic' }).setOrigin(0.5),
);
g.add(
this.add.text(cx, 484, ura ? 'click — return to the quiet kitchen' : 'click — 裏 THE URA WEEK. the SHOGUN is hungry.', {
fontFamily: 'serif', fontSize: '16px', color: ura ? '#9a8a6a' : '#e04a30',
this.add.text(cx, 484, mu ? 'click — go again' : ura ? 'click — return to the quiet kitchen' : 'click — 裏 THE URA WEEK. the SHOGUN is hungry.', {
fontFamily: 'serif', fontSize: '16px', color: mu ? '#5ad89a' : ura ? '#9a8a6a' : '#e04a30',
}).setOrigin(0.5),
);
// the week's score → the global board (fire-and-forget; card updates when
// the run's score → the global board (fire-and-forget; card updates when
// the server answers, and shows nothing if it never does)
const weekScore = (this.registry.get('sando.score') as number) ?? 0;
const week = ura ? 'ura' : 'omote';
g.add(this.add.text(cx, 305, `week score ${weekScore.toFixed(1)} / 80`, { fontFamily: 'monospace', fontSize: '15px', color: '#c8a24a' }).setOrigin(0.5));
const week = mu ? 'mugen' : ura ? 'ura' : 'omote';
g.add(this.add.text(cx, 305, mu ? `run score ${weekScore.toFixed(1)}` : `week score ${weekScore.toFixed(1)} / 80`, { fontFamily: 'monospace', fontSize: '15px', color: '#c8a24a' }).setOrigin(0.5));
const rankLine = this.add.text(cx, 328, '', { fontFamily: 'monospace', fontSize: '13px', color: '#9a8a6a' }).setOrigin(0.5);
g.add(rankLine);
const name = weekScore > 0 ? chefName() : null;
@ -298,14 +320,19 @@ export class Stage extends Phaser.Scene {
this.deadFrames = 0;
// the day-intro card: what today asks, in one breath (real play only)
if (this.registry.get('sando.started')) {
const d = this.session.days[this.session.day];
const mu = this.session.mugen;
const ura = this.session.ura;
const o = this.session.order;
const head = mu
? `無限 ORDER ${this.session.streak + 1}`
: `${ura ? '裏 ' : ''}DAY ${this.session.day + 1}${o.name}`;
const note = mu ? `${this.session.samurai.who} watching · ${o.maxSeconds}s` : `today: ${this.session.days[this.session.day].pressure}`;
const card = this.add.container(0, 0).setDepth(25);
card.add(this.add.rectangle(STAGE_W / 2, 300, 520, 150, ura ? 0x1a0808 : 0x120d08, 0.9).setStrokeStyle(2, ura ? 0xa03020 : 0x8a6a2a));
card.add(this.add.text(STAGE_W / 2, 262, `${ura ? '裏 ' : ''}DAY ${this.session.day + 1}${d.order.name}`, { fontFamily: 'serif', fontSize: '26px', color: '#efe4cb' }).setOrigin(0.5));
card.add(this.add.text(STAGE_W / 2, 300, d.order.layers.join(' · '), { fontFamily: 'monospace', fontSize: '15px', color: '#c8a24a' }).setOrigin(0.5));
card.add(this.add.text(STAGE_W / 2, 334, `today: ${d.pressure}`, { fontFamily: 'serif', fontSize: '14px', color: '#9a8a6a', fontStyle: 'italic' }).setOrigin(0.5));
this.tweens.add({ targets: card, alpha: 0, delay: 2400, duration: 600, onComplete: () => card.destroy() });
card.add(this.add.rectangle(STAGE_W / 2, 300, 520, 150, ura || mu ? 0x1a0808 : 0x120d08, 0.9).setStrokeStyle(2, ura || mu ? 0xa03020 : 0x8a6a2a));
card.add(this.add.text(STAGE_W / 2, 262, head, { fontFamily: 'serif', fontSize: '26px', color: '#efe4cb' }).setOrigin(0.5));
card.add(this.add.text(STAGE_W / 2, 300, o.layers.join(' · '), { fontFamily: 'monospace', fontSize: '15px', color: '#c8a24a' }).setOrigin(0.5));
card.add(this.add.text(STAGE_W / 2, 334, note, { fontFamily: 'serif', fontSize: '14px', color: '#9a8a6a', fontStyle: 'italic' }).setOrigin(0.5));
this.tweens.add({ targets: card, alpha: 0, delay: mu ? 1500 : 2400, duration: 600, onComplete: () => card.destroy() });
}
// kabuki curtain reveal on day changes: the new day starts covered, the
// curtain sweeps up. (Set by the advance click; skipped on death retries.)
@ -323,8 +350,19 @@ export class Stage extends Phaser.Scene {
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)));
// one whole food per TWO required pieces of that kind — a single clean cut
// yields two halves, so an order for 2 tomato is one tomato, well cut.
const need = new Map<string, number>();
for (const k of o.layers) if (k !== 'bread') need.set(k, (need.get(k) ?? 0) + 1);
const kinds = [...need.keys()];
let slot = 0;
const slots = kinds.reduce((a, k) => a + Math.ceil((need.get(k) ?? 1) / 2), 0);
for (const k of kinds) {
for (let j = 0; j < Math.ceil((need.get(k) ?? 1) / 2); j++) {
this.cutter.add(spawnFood(this, k, 780 + slot * 70 - (slots - 1) * 35, 500));
slot++;
}
}
this.puppet.reset(400);
const b = this.matter.body;
b.setPosition(this.cleaver, { x: 632, y: FLOOR_Y - 42 }, false);
@ -456,9 +494,10 @@ export class Stage extends Phaser.Scene {
const o = this.session.order;
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.' : ''}`,
);
const head = this.session.mugen
? `無限 MUGEN served ${this.session.streak}`
: `${this.session.ura ? '裏 ' : ''}DAY ${this.session.day + 1} ${o.name}`;
this.orderText?.setText(`${head}\n${o.layers.join(' · ')}\n⏱ ${left}s${late ? ' — OSOI. LATE.' : ''}`);
}
/** The bow: prompt + dwell arc over the plate when the stack matches the order. */

View File

@ -57,26 +57,31 @@ export class Title extends Phaser.Scene {
.setOrigin(0.5).setDepth(1);
paint(picked);
// week select — only once the Shogun has been met
// mode select — only once a week has been cleared. Cycles 表 → 裏 → 無限.
if (loadProgress().ura) {
const uraOn = ((this.registry.get('sando.ura') as boolean) ?? false);
const weekBtn = this.add.text(W / 2, H * 0.855, '', { fontFamily: 'monospace', fontSize: '16px', color: '#e04a30' })
const modeBtn = this.add.text(W / 2, H * 0.855, '', { fontFamily: 'monospace', fontSize: '16px', color: '#e04a30' })
.setOrigin(0.5).setDepth(2).setInteractive({ useHandCursor: true });
const paintWeek = (on: boolean): void => {
weekBtn.setText(on ? '▸ 裏 URA WEEK — the Shogun ◂' : '表 the week — (click for 裏)');
weekBtn.setColor(on ? '#e04a30' : '#8a7a5a');
const paintMode = (): void => {
const ura = (this.registry.get('sando.ura') as boolean) ?? false;
const mu = (this.registry.get('sando.mugen') as boolean) ?? false;
modeBtn.setText(mu ? '▸ 無限 MUGEN — the endless shift ◂' : ura ? '▸ 裏 URA WEEK — the Shogun ◂' : '表 the week — (click to change)');
modeBtn.setColor(mu ? '#5ad89a' : ura ? '#e04a30' : '#8a7a5a');
};
weekBtn.on('pointerdown', (_p: unknown, __x: unknown, ___y: unknown, ev?: { stopPropagation?: () => void }) => {
modeBtn.on('pointerdown', (_p: unknown, __x: unknown, ___y: unknown, ev?: { stopPropagation?: () => void }) => {
ev?.stopPropagation?.();
const next = !((this.registry.get('sando.ura') as boolean) ?? false);
this.registry.set('sando.ura', next);
const ura = (this.registry.get('sando.ura') as boolean) ?? false;
const mu = (this.registry.get('sando.mugen') as boolean) ?? false;
// 表 → 裏 → 無限 → 表
this.registry.set('sando.ura', !ura && !mu);
this.registry.set('sando.mugen', ura && !mu);
this.registry.set('sando.day', 0);
this.registry.set('sando.score', 0);
sfx.enable();
sfx.shamisen();
paintWeek(next);
paintMode();
refreshBoard();
});
paintWeek(uraOn);
paintMode();
}
const shout = this.add.text(W / 2, H * 0.89, 'SHUBATTO!! SANDONIETTE!!!', {
@ -106,13 +111,21 @@ export class Title extends Phaser.Scene {
});
}
// the global board, bottom-left — top 5 of the week (async; blank offline)
// the global board, bottom-left — top 5 of the selected mode (async; blank
// offline). Refreshed whenever the mode changes.
const board = this.add.text(28, H - 150, '', { fontFamily: 'monospace', fontSize: '12px', color: '#8a7a5a', lineSpacing: 5 })
.setDepth(2);
void fetchTop(((this.registry.get('sando.ura') as boolean) ?? false) ? 'ura' : 'omote').then((top) => {
if (!board.active || !top.length) return;
board.setText([' TOP CHEFS', ...top.slice(0, 5).map((r, i) => `${i + 1}. ${r.name.padEnd(13)} ${r.score.toFixed(1)}`)].join('\n'));
});
const refreshBoard = (): void => {
const mu = (this.registry.get('sando.mugen') as boolean) ?? false;
const ura = (this.registry.get('sando.ura') as boolean) ?? false;
const which = mu ? 'mugen' : ura ? 'ura' : 'omote';
board.setText('');
void fetchTop(which).then((top) => {
if (!board.active || !top.length) return;
board.setText([` TOP CHEFS · ${which.toUpperCase()}`, ...top.slice(0, 5).map((r, i) => `${i + 1}. ${r.name.padEnd(13)} ${r.score.toFixed(1)}`)].join('\n'));
});
};
refreshBoard();
// begin: anywhere that isn't a style button. Restart the stage so the puppet
// is strung with the chosen style, then drop the overlay.

View File

@ -25,9 +25,10 @@ export const PORTRAITS = ['samurai_calm', 'samurai_watch', 'samurai_annoyed', 's
export class Samurai {
anger = 0; // 0..1
ramp = 1; // day patience multiplier — sins hurt more on the rush-hour day
/** Portrait prefix 'samurai' for the week, 'shogun' for the ura week. The
* judge logic never changes; only the face at the window does. */
who: 'samurai' | 'shogun' = 'samurai';
/** Portrait prefix 'samurai' for the week, 'shogun' for the ura week, and
* 'oni' deep into an endless run. The judge logic never changes; only the
* face at the window does. */
who: 'samurai' | 'shogun' | 'oni' = 'samurai';
struck = false;
/** Fired once when the meter tops out — the stage wires the string cut here. */
onStrike?: () => void;

View File

@ -1,5 +1,6 @@
import type { Stage } from '../scenes/stage';
import { box, Food, FOODS, makeFood } from './food';
import { pieceCV, polyArea } from './geom';
/**
* The sando. The stack is DETECTED, not declared: whatever food bodies are
@ -44,10 +45,13 @@ export interface SandoStats {
}
/** Materialise one slice-shaped layer food at (x, y). Caller registers it. */
export function spawnLayer(stage: Stage, kind: string, x: number, y: number): Food {
/** Materialise one slice-shaped layer food at (x, y). `asPiece` marks it as
* having come off the cleaver (the `p` suffix the judge reads) the harness
* uses it to stand in for "the player cut this, then stacked it". */
export function spawnLayer(stage: Stage, kind: string, x: number, y: number, asPiece = false): 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);
return makeFood(stage, box(def.w, def.h), x, y, mat, def.color, def.face, kind + (asPiece ? 'p' : ''));
}
const deg = (r: number): number => +((r * 180) / Math.PI).toFixed(1);
@ -77,6 +81,33 @@ export class Sando {
return this.layers.map((f) => f.kind.replace(/p+$/, ''));
}
/**
* What the judge should actually read off THE CUT: not "did a cut happen
* somewhere", but "is the food ON THIS PLATE cut?". Bread arrives as slices;
* every other layer is a filling that ought to have met the cleaver. A `p`
* suffix means the body was born from a real split (sim/cutting).
*/
cutStats(): { fillings: number; pieces: number; cutFrac: number; cv: number } {
const fills = this.layers.filter((f) => !f.kind.startsWith('bread'));
const pieces = fills.filter((f) => /p+$/.test(f.kind));
// Evenness is measured WITHIN a kind — "are your tomato slices even", not
// "is a tomato slice the same size as a katsu slice". Comparing across
// kinds punishes the chef for the recipe, which is not their crime.
const byKind = new Map<string, number[]>();
for (const f of pieces) {
const k = f.kind.replace(/p+$/, '');
const area = polyArea((f.body.vertices ?? []).map((v) => ({ x: v.x, y: v.y })));
byKind.set(k, [...(byKind.get(k) ?? []), area]);
}
const cvs = [...byKind.values()].filter((a) => a.length > 1).map(pieceCV);
return {
fillings: fills.length,
pieces: pieces.length,
cutFrac: fills.length ? pieces.length / fills.length : 1,
cv: +(cvs.length ? cvs.reduce((a, b) => a + b, 0) / cvs.length : 0).toFixed(3),
};
}
/** Top surface of the current stack (or the plate if empty). */
private topY(): number {
const ls = this.layers;
@ -86,10 +117,10 @@ export class Sando {
/** 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 {
addLayer(kind: string, offsetX = 0, settleFrames = 45, asPiece = false): Food {
const def = LAYERS[kind] ?? LAYERS.bread;
const y = this.topY() - def.h / 2 - 1;
const food = spawnLayer(this.stage, kind, this.plateX + offsetX, y);
const food = spawnLayer(this.stage, kind, this.plateX + offsetX, y, asPiece);
this.stage.cutter.add(food);
this.stage.auto = false;
for (let i = 0; i < settleFrames; i++) this.stage.stepSim();