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>
@ -85,6 +85,37 @@
|
|||||||
Attract ragdoll → the pulsing title. Verified: title screenshot, click path,
|
Attract ragdoll → the pulsing title. Verified: title screenshot, click path,
|
||||||
production `npm run build` clean.
|
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 M1–M6 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
|
## BUILD-OUT PASS 6 ✅ — the audience + the global board
|
||||||
- **The crowd**: 11 seeded silhouette heads (shoulders/head/occasional topknot)
|
- **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
|
along the stage lip. Idle sway; trembling once anger>0.5; a hard duck for 110
|
||||||
|
|||||||
BIN
public/assets/img/oni_angry.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
public/assets/img/oni_annoyed.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
public/assets/img/oni_bow.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
public/assets/img/oni_calm.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
public/assets/img/oni_fury.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
public/assets/img/oni_watch.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
public/assets/img/scroll_paper.png
Normal file
|
After Width: | Height: | Size: 730 KiB |
@ -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_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 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
|
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
|
||||||
|
|||||||
56
src/dev.ts
@ -235,10 +235,13 @@ export function installHarness(stage: Stage): void {
|
|||||||
stack: () => stage.sando.stats(),
|
stack: () => stage.sando.stats(),
|
||||||
|
|
||||||
/** Build a fresh sando: one layer per (kind, offsetX) pair. */
|
/** 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.auto = false;
|
||||||
stage.sando.clear();
|
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
|
for (let i = 0; i < 60; i++) stage.stepSim(); // let the whole tower settle
|
||||||
return stage.sando.stats();
|
return stage.sando.stats();
|
||||||
},
|
},
|
||||||
@ -397,6 +400,55 @@ export function installHarness(stage: Stage): void {
|
|||||||
return DAYS.map((_, i) => this.playDay(i, ura));
|
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. */
|
/** M4 exit-bar B: anger the samurai to the top → the string cut → ragdoll. */
|
||||||
playDisasterRun() {
|
playDisasterRun() {
|
||||||
this.freshRun();
|
this.freshRun();
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import type { Stage } from '../scenes/stage';
|
|||||||
import { Samurai } from '../sim/samurai';
|
import { Samurai } from '../sim/samurai';
|
||||||
import { judge, type Order, type RunResult, type Verdict } from './judging';
|
import { judge, type Order, type RunResult, type Verdict } from './judging';
|
||||||
import { recordGrade } from './progress';
|
import { recordGrade } from './progress';
|
||||||
|
import { Rng } from '../core/rng';
|
||||||
|
|
||||||
/** A day introduces exactly ONE new pressure (never two) and stays beatable by
|
/** 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
|
* 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);
|
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,
|
* 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
|
* and turns a finished attempt into a Verdict. Kitchen sins feed the anger
|
||||||
@ -52,6 +85,10 @@ export class Game {
|
|||||||
samurai = new Samurai();
|
samurai = new Samurai();
|
||||||
day = 0;
|
day = 0;
|
||||||
ura = false;
|
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;
|
order: Order = DAYS[0].order;
|
||||||
drops = 0;
|
drops = 0;
|
||||||
seconds = 0; // ticket timer (seconds)
|
seconds = 0; // ticket timer (seconds)
|
||||||
@ -91,7 +128,8 @@ export class Game {
|
|||||||
if (this.dwell >= 1) {
|
if (this.dwell >= 1) {
|
||||||
this.state = 'served';
|
this.state = 'served';
|
||||||
this.lastVerdict = this.serve(true);
|
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
|
// 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;
|
const run = ((this.stage.registry.get('sando.score') as number) ?? 0) + this.lastVerdict.total;
|
||||||
this.stage.registry.set('sando.score', +run.toFixed(2));
|
this.stage.registry.set('sando.score', +run.toFixed(2));
|
||||||
@ -116,9 +154,36 @@ export class Game {
|
|||||||
return this.ura ? URA_DAYS : DAYS;
|
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 {
|
setDay(n: number, ura = this.ura): Day {
|
||||||
this.ura = ura;
|
this.ura = ura;
|
||||||
|
this.mugen = false;
|
||||||
|
this.samurai.reset();
|
||||||
const table = this.days;
|
const table = this.days;
|
||||||
this.day = Math.max(0, Math.min(table.length - 1, n));
|
this.day = Math.max(0, Math.min(table.length - 1, n));
|
||||||
const d = table[this.day];
|
const d = table[this.day];
|
||||||
@ -165,11 +230,21 @@ export class Game {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildRun(bowHeld: boolean, seconds: number): RunResult {
|
private buildRun(bowHeld: boolean, seconds: number): RunResult {
|
||||||
const cut = this.stage.cutter.last;
|
|
||||||
const s = this.stage.sando.stats();
|
const s = this.stage.sando.stats();
|
||||||
const matches = this.matchesOrder();
|
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 {
|
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 },
|
sando: { layers: s.layers, leanDeg: s.leanDeg, comDrift: s.comDrift, standing: s.standing, matches },
|
||||||
service: { served: true, bowHeld, seconds, correct: matches && s.standing },
|
service: { served: true, bowHeld, seconds, correct: matches && s.standing },
|
||||||
floor: { splatMass: this.stage.cutter.stats().splatMass, drops: this.drops },
|
floor: { splatMass: this.stage.cutter.stats().splatMass, drops: this.drops },
|
||||||
|
|||||||
@ -10,7 +10,8 @@ export interface Order {
|
|||||||
|
|
||||||
/** Everything measured off one attempt — every number read off the sim. */
|
/** Everything measured off one attempt — every number read off the sim. */
|
||||||
export interface RunResult {
|
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 };
|
sando: { layers: number; leanDeg: number; comDrift: number; standing: boolean; matches: boolean };
|
||||||
service: { served: boolean; bowHeld: boolean; seconds: number; correct: boolean };
|
service: { served: boolean; bowHeld: boolean; seconds: number; correct: boolean };
|
||||||
floor: { splatMass: number; drops: number };
|
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 {
|
export function judge(order: Order, r: RunResult): Verdict {
|
||||||
const criteria: Criterion[] = [];
|
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) {
|
if (order.needsCut) {
|
||||||
const q = r.cut?.quality ?? 'none';
|
const q = r.cut?.quality ?? 'none';
|
||||||
const cv = r.cut?.cv ?? 1;
|
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 qScore = q === 'clean' ? 1 : q === 'ragged' ? 0.55 : q === 'crime' ? 0.1 : 0.15;
|
||||||
const evenScore = clamp01(1 - (cv - 0.05) / 0.32);
|
const evenScore = clamp01(1 - (cv - 0.05) / 0.32);
|
||||||
criteria.push({
|
criteria.push({
|
||||||
key: 'cut', group: 'CUT', label: 'THE CUT', weight: 1.2,
|
key: 'cut', group: 'CUT', label: 'THE CUT', weight: 1.2,
|
||||||
score: clamp01(0.6 * qScore + 0.4 * evenScore),
|
score: clamp01(frac * (0.6 * qScore + 0.4 * evenScore)),
|
||||||
detail: q === 'none' ? 'never cut' : `${q}, ${r.cut?.pieces ?? 0} pieces, CV ${cv.toFixed(2)}`,
|
detail:
|
||||||
|
frac === 0
|
||||||
|
? 'served whole — never cut'
|
||||||
|
: `${q}, ${r.cut?.pieces ?? 0} pieces${frac < 1 ? ` (${Math.round(frac * 100)}% cut)` : ''}, CV ${cv.toFixed(2)}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,8 @@
|
|||||||
* (In local dev there's no proxy; fetches just fail quietly.)
|
* (In local dev there's no proxy; fetches just fail quietly.)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export type Board = 'omote' | 'ura' | 'mugen';
|
||||||
|
|
||||||
export interface ScoreRow {
|
export interface ScoreRow {
|
||||||
name: string;
|
name: string;
|
||||||
score: number;
|
score: number;
|
||||||
@ -20,7 +22,7 @@ function withTimeout(ms: number): AbortSignal {
|
|||||||
return c.signal;
|
return c.signal;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchTop(week: 'omote' | 'ura'): Promise<ScoreRow[]> {
|
export async function fetchTop(week: Board): Promise<ScoreRow[]> {
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`${API}?week=${week}`, { signal: withTimeout(TIMEOUT) });
|
const r = await fetch(`${API}?week=${week}`, { signal: withTimeout(TIMEOUT) });
|
||||||
if (!r.ok) return [];
|
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 {
|
try {
|
||||||
const r = await fetch(API, {
|
const r = await fetch(API, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@ -74,7 +74,8 @@ export class Stage extends Phaser.Scene {
|
|||||||
const base = import.meta.env.BASE_URL;
|
const base = import.meta.env.BASE_URL;
|
||||||
const img = (k: string) => this.load.image(k, `${base}assets/img/${k}.png`);
|
const img = (k: string) => this.load.image(k, `${base}assets/img/${k}.png`);
|
||||||
const shogun = PORTRAITS.map((k) => k.replace('samurai', 'shogun'));
|
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`));
|
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.)
|
// 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 dayIdx = (this.registry.get('sando.day') as number) ?? 0;
|
||||||
const uraWeek = (this.registry.get('sando.ura') as boolean) ?? false;
|
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';
|
const bgKey = this.textures.exists(wanted) ? wanted : 'bg_washi';
|
||||||
if (this.textures.exists(bgKey)) {
|
if (this.textures.exists(bgKey)) {
|
||||||
this.add.image(STAGE_W / 2, STAGE_H / 2, bgKey).setDisplaySize(STAGE_W, STAGE_H).setDepth(-10);
|
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.sando = new Sando(this, 980, 624);
|
||||||
|
|
||||||
this.session = new Game(this);
|
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
|
// 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/
|
// 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.)
|
// 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.
|
// game for real (SHIN-KANSEI), then it's back to a quiet noon kitchen.
|
||||||
this.input.on('pointerdown', () => {
|
this.input.on('pointerdown', () => {
|
||||||
if (this.kansei) {
|
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.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();
|
this.scene.restart();
|
||||||
} else if (this.session.state === 'served') {
|
} 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 {
|
else {
|
||||||
this.registry.set('sando.day', this.session.day + 1);
|
this.registry.set('sando.day', this.session.day + 1);
|
||||||
this.registry.set('sando.curtain', true);
|
this.registry.set('sando.curtain', true);
|
||||||
this.scene.restart();
|
this.scene.restart();
|
||||||
}
|
}
|
||||||
} else if (this.session.state === 'dead' && this.deadFrames > 70) {
|
} 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);
|
installHarness(this);
|
||||||
@ -246,35 +257,46 @@ export class Stage extends Phaser.Scene {
|
|||||||
music.stop();
|
music.stop();
|
||||||
sfx.gong();
|
sfx.gong();
|
||||||
const ura = this.session.ura;
|
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 g = this.add.container(0, 0).setDepth(32);
|
||||||
const cx = STAGE_W / 2;
|
const cx = STAGE_W / 2;
|
||||||
g.add(this.add.rectangle(cx, 340, 760, 440, ura ? 0x0d0810 : 0x120d08, 0.96).setStrokeStyle(3, ura ? 0xa03020 : 0x8a6a2a));
|
const accent = mu ? 0x30a060 : 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.rectangle(cx, 340, 760, 440, mu ? 0x08100c : ura ? 0x0d0810 : 0x120d08, 0.96).setStrokeStyle(3, accent));
|
||||||
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));
|
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));
|
||||||
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));
|
|
||||||
});
|
|
||||||
g.add(
|
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(
|
g.add(
|
||||||
this.add.text(cx, 484, ura ? 'click — return to the quiet kitchen' : 'click — 裏 THE URA WEEK. the SHOGUN is hungry.', {
|
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: ura ? '#9a8a6a' : '#e04a30',
|
fontFamily: 'serif', fontSize: '16px', color: mu ? '#5ad89a' : ura ? '#9a8a6a' : '#e04a30',
|
||||||
}).setOrigin(0.5),
|
}).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)
|
// the server answers, and shows nothing if it never does)
|
||||||
const weekScore = (this.registry.get('sando.score') as number) ?? 0;
|
const weekScore = (this.registry.get('sando.score') as number) ?? 0;
|
||||||
const week = ura ? 'ura' : 'omote';
|
const week = mu ? 'mugen' : 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));
|
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);
|
const rankLine = this.add.text(cx, 328, '', { fontFamily: 'monospace', fontSize: '13px', color: '#9a8a6a' }).setOrigin(0.5);
|
||||||
g.add(rankLine);
|
g.add(rankLine);
|
||||||
const name = weekScore > 0 ? chefName() : null;
|
const name = weekScore > 0 ? chefName() : null;
|
||||||
@ -298,14 +320,19 @@ export class Stage extends Phaser.Scene {
|
|||||||
this.deadFrames = 0;
|
this.deadFrames = 0;
|
||||||
// the day-intro card: what today asks, in one breath (real play only)
|
// the day-intro card: what today asks, in one breath (real play only)
|
||||||
if (this.registry.get('sando.started')) {
|
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 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);
|
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.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, `${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, 262, head, { 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, 300, o.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));
|
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: 2400, duration: 600, onComplete: () => card.destroy() });
|
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
|
// kabuki curtain reveal on day changes: the new day starts covered, the
|
||||||
// curtain sweeps up. (Set by the advance click; skipped on death retries.)
|
// 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 o = this.session.order;
|
||||||
const breadN = o.layers.filter((k) => k === 'bread').length;
|
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));
|
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'))];
|
// one whole food per TWO required pieces of that kind — a single clean cut
|
||||||
kinds.forEach((k, i) => this.cutter.add(spawnFood(this, k, 780 + i * 70 - (kinds.length - 1) * 35, 500)));
|
// 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);
|
this.puppet.reset(400);
|
||||||
const b = this.matter.body;
|
const b = this.matter.body;
|
||||||
b.setPosition(this.cleaver, { x: 632, y: FLOOR_Y - 42 }, false);
|
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 o = this.session.order;
|
||||||
const left = Math.max(0, Math.ceil(o.maxSeconds - this.session.seconds));
|
const left = Math.max(0, Math.ceil(o.maxSeconds - this.session.seconds));
|
||||||
const late = this.session.seconds > o.maxSeconds;
|
const late = this.session.seconds > o.maxSeconds;
|
||||||
this.orderText?.setText(
|
const head = this.session.mugen
|
||||||
`DAY ${this.session.day + 1} ${o.name}\n${o.layers.join(' · ')}\n⏱ ${left}s${late ? ' — OSOI. LATE.' : ''}`,
|
? `無限 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. */
|
/** The bow: prompt + dwell arc over the plate when the stack matches the order. */
|
||||||
|
|||||||
@ -57,26 +57,31 @@ export class Title extends Phaser.Scene {
|
|||||||
.setOrigin(0.5).setDepth(1);
|
.setOrigin(0.5).setDepth(1);
|
||||||
paint(picked);
|
paint(picked);
|
||||||
|
|
||||||
// week select — only once the Shogun has been met
|
// mode select — only once a week has been cleared. Cycles 表 → 裏 → 無限.
|
||||||
if (loadProgress().ura) {
|
if (loadProgress().ura) {
|
||||||
const uraOn = ((this.registry.get('sando.ura') as boolean) ?? false);
|
const modeBtn = this.add.text(W / 2, H * 0.855, '', { fontFamily: 'monospace', fontSize: '16px', color: '#e04a30' })
|
||||||
const weekBtn = this.add.text(W / 2, H * 0.855, '', { fontFamily: 'monospace', fontSize: '16px', color: '#e04a30' })
|
|
||||||
.setOrigin(0.5).setDepth(2).setInteractive({ useHandCursor: true });
|
.setOrigin(0.5).setDepth(2).setInteractive({ useHandCursor: true });
|
||||||
const paintWeek = (on: boolean): void => {
|
const paintMode = (): void => {
|
||||||
weekBtn.setText(on ? '▸ 裏 URA WEEK — the Shogun ◂' : '表 the week — (click for 裏)');
|
const ura = (this.registry.get('sando.ura') as boolean) ?? false;
|
||||||
weekBtn.setColor(on ? '#e04a30' : '#8a7a5a');
|
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?.();
|
ev?.stopPropagation?.();
|
||||||
const next = !((this.registry.get('sando.ura') as boolean) ?? false);
|
const ura = (this.registry.get('sando.ura') as boolean) ?? false;
|
||||||
this.registry.set('sando.ura', next);
|
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.day', 0);
|
||||||
this.registry.set('sando.score', 0);
|
this.registry.set('sando.score', 0);
|
||||||
sfx.enable();
|
sfx.enable();
|
||||||
sfx.shamisen();
|
sfx.shamisen();
|
||||||
paintWeek(next);
|
paintMode();
|
||||||
|
refreshBoard();
|
||||||
});
|
});
|
||||||
paintWeek(uraOn);
|
paintMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
const shout = this.add.text(W / 2, H * 0.89, 'SHUBATTO!! SANDONIETTE!!!', {
|
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 })
|
const board = this.add.text(28, H - 150, '', { fontFamily: 'monospace', fontSize: '12px', color: '#8a7a5a', lineSpacing: 5 })
|
||||||
.setDepth(2);
|
.setDepth(2);
|
||||||
void fetchTop(((this.registry.get('sando.ura') as boolean) ?? false) ? 'ura' : 'omote').then((top) => {
|
const refreshBoard = (): void => {
|
||||||
if (!board.active || !top.length) return;
|
const mu = (this.registry.get('sando.mugen') as boolean) ?? false;
|
||||||
board.setText([' TOP CHEFS', ...top.slice(0, 5).map((r, i) => `${i + 1}. ${r.name.padEnd(13)} ${r.score.toFixed(1)}`)].join('\n'));
|
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
|
// begin: anywhere that isn't a style button. Restart the stage so the puppet
|
||||||
// is strung with the chosen style, then drop the overlay.
|
// is strung with the chosen style, then drop the overlay.
|
||||||
|
|||||||
@ -25,9 +25,10 @@ export const PORTRAITS = ['samurai_calm', 'samurai_watch', 'samurai_annoyed', 's
|
|||||||
export class Samurai {
|
export class Samurai {
|
||||||
anger = 0; // 0..1
|
anger = 0; // 0..1
|
||||||
ramp = 1; // day patience multiplier — sins hurt more on the rush-hour day
|
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
|
/** Portrait prefix — 'samurai' for the week, 'shogun' for the ura week, and
|
||||||
* judge logic never changes; only the face at the window does. */
|
* 'oni' deep into an endless run. The judge logic never changes; only the
|
||||||
who: 'samurai' | 'shogun' = 'samurai';
|
* face at the window does. */
|
||||||
|
who: 'samurai' | 'shogun' | 'oni' = 'samurai';
|
||||||
struck = false;
|
struck = false;
|
||||||
/** Fired once when the meter tops out — the stage wires the string cut here. */
|
/** Fired once when the meter tops out — the stage wires the string cut here. */
|
||||||
onStrike?: () => void;
|
onStrike?: () => void;
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import type { Stage } from '../scenes/stage';
|
import type { Stage } from '../scenes/stage';
|
||||||
import { box, Food, FOODS, makeFood } from './food';
|
import { box, Food, FOODS, makeFood } from './food';
|
||||||
|
import { pieceCV, polyArea } from './geom';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The sando. The stack is DETECTED, not declared: whatever food bodies are
|
* 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. */
|
/** 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 def = LAYERS[kind] ?? LAYERS.bread;
|
||||||
const mat = FOODS[kind]?.material ?? FOODS.bread.material;
|
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);
|
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+$/, ''));
|
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). */
|
/** Top surface of the current stack (or the plate if empty). */
|
||||||
private topY(): number {
|
private topY(): number {
|
||||||
const ls = this.layers;
|
const ls = this.layers;
|
||||||
@ -86,10 +117,10 @@ export class Sando {
|
|||||||
|
|
||||||
/** Harness helper: materialise one layer on top and settle. The spawned food
|
/** 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. */
|
* 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 def = LAYERS[kind] ?? LAYERS.bread;
|
||||||
const y = this.topY() - def.h / 2 - 1;
|
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.cutter.add(food);
|
||||||
this.stage.auto = false;
|
this.stage.auto = false;
|
||||||
for (let i = 0; i < settleFrames; i++) this.stage.stepSim();
|
for (let i = 0; i < settleFrames; i++) this.stage.stepSim();
|
||||||
|
|||||||