Build-out 4: 裏 THE URA WEEK — the Shogun arrives

- Second week: URA_DAYS (8 harder orders, ramps 1.4-2.0, 18s rush, 7-layer
  SHOGUN'S TOWER finale), judged by the SHOGUN — new same-seed portrait set
  (oni mask, gold on blood-red washi; Samurai.who prefixes portraits, plus
  shogun_bow for an S). Ura is played entirely by moonlight.
- New pantry where the SHAPES are the difficulty: nori (96x8 paper-thin),
  ebi (asymmetric convex 7-gon), daikon (46x64 comically tall).
- Flow: KANSEI -> 'the SHOGUN is hungry' -> ura week; ura KANSEI = SHIN-KANSEI
  (true ending) -> quiet kitchen. Unlock persists; title gets a 表/裏 toggle +
  red ura stamp column (bests at keys 100+).
- Kabuki curtain_red reveal on day advances (procedural fallback; death
  retries skip it).
- Verified: base SSSSSSSS; ura A A S S A S S S (all beatable, honestly
  harder); full arc kansei->ura->shin-kansei->base in-browser; M1-M3 +
  disaster regression identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-21 02:45:00 +10:00
parent a3337e68e0
commit d784182d08
17 changed files with 177 additions and 42 deletions

View File

@ -85,6 +85,26 @@
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 4 ✅ — 裏 THE URA WEEK (the Shogun)
- **Second week**: beat KANSEI and *the Shogun arrives*`URA_DAYS` (8 harder
orders), judged by a new character via the same-seed portrait trick
(`shogun_*` set, seed 620, oni mask + gold on blood-red; `Samurai.who`
prefixes the portrait, `shogun_bow` for an S). Ura is all moonlight, red-
bordered day cards, ramps 1.42.0, rush day at 18s.
- **New pantry** (the SHAPES are the difficulty): nori box(96,8) paper-thin,
ebi asymmetric convex 7-gon, daikon box(46,64) comically tall. Orders climax
in THE SHOGUN'S TOWER (7 layers, 80s).
- **Flow**: base KANSEI card → "the URA WEEK. the SHOGUN is hungry." → ura day 1;
ura KANSEI = **真・完成 SHIN-KANSEI** (true ending) → back to the quiet kitchen.
Ura unlock persists (localStorage `ura:true`); title gets a 表/裏 week toggle +
a second red-tinted stamp column (ura bests live at keys 100+).
- **Kabuki curtain**: day-advance restarts start covered by `curtain_red` (or a
plain red rect fallback) sweeping up. Death retries skip the curtain.
- Verified: base week SSSSSSSS; **ura week A A S S A S S S — all beatable,
honestly harder**; full arc served→kansei→ura(shogun judge)→shin-kansei→base
exercised in-browser; M1-M3 + disaster regression identical. Screenshot: the
moonlit ura kitchen with the Shogun is the best image the game has produced.
## BUILD-OUT PASS 3 ✅ — mobile, progression, music, night shift ## BUILD-OUT PASS 3 ✅ — mobile, progression, music, night shift
- **Mobile/touch**: Phaser Scale FIT+CENTER (letterboxes on phones), 3 active - **Mobile/touch**: Phaser Scale FIT+CENTER (letterboxes on phones), 3 active
pointers, thumb **GRIP button** (touch devices only), drag-low = reach (no pointers, thumb **GRIP button** (touch devices only), drag-low = reach (no

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 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: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@ -95,3 +95,13 @@ gen_2d day_stamp 420 "a round red japanese hanko seal stamp, blank center,
# ---- batch 3 (seeds 500+): time-of-day backdrops for day escalation ---- # ---- batch 3 (seeds 500+): time-of-day backdrops for day escalation ----
gen_2d bg_washi_dusk 301 "empty kitchen stage, shoji paper screen backdrop lit deep amber and orange from behind, sunset glow, long shadows, wooden theatre framing, no characters, no text" 1280 768 gen_2d bg_washi_dusk 301 "empty kitchen stage, shoji paper screen backdrop lit deep amber and orange from behind, sunset glow, long shadows, wooden theatre framing, no characters, no text" 1280 768
gen_2d bg_washi_night 301 "empty kitchen stage, shoji paper screen backdrop lit cold blue moonlight from behind, night, dim lanterns, wooden theatre framing, no characters, no text" 1280 768 gen_2d bg_washi_night 301 "empty kitchen stage, shoji paper screen backdrop lit cold blue moonlight from behind, night, dim lanterns, wooden theatre framing, no characters, no text" 1280 768
# ---- batch 4 (seeds 600+): THE SHOGUN (ura-week judge, same-seed trick), kabuki curtains ----
SHOGUN="a colossal seated japanese shogun warlord wearing a horned kabuto helmet and a fierce oni demon mask, layered ornate armor, two katana at his hip, shown as a dramatic flat black paper-cut silhouette with gold accents against a deep red glowing washi screen"
gen_2d shogun_calm 620 "$SHOGUN, still and silent, swords sheathed" 768 900 cut
gen_2d shogun_watch 620 "$SHOGUN, leaning forward slightly, watching" 768 900 cut
gen_2d shogun_annoyed 620 "$SHOGUN, gripping one scabbard, knuckles tight" 768 900 cut
gen_2d shogun_angry 620 "$SHOGUN, one blade half drawn and gleaming" 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 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

View File

@ -270,11 +270,11 @@ export function installHarness(stage: Stage): void {
/** Live serve flow: set up the day for real, materialise the ordered stack on /** 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 * the plate, then hold the bar still the dwell must commit the serve and
* put up the scorecard, exactly as a player bowing would. */ * put up the scorecard, exactly as a player bowing would. */
playServeFlow(n = 0) { playServeFlow(n = 0, ura = false) {
stage.auto = false; stage.auto = false;
stage.clearScorecard(); stage.clearScorecard();
stage.session.reset(); stage.session.reset();
stage.session.setDay(n); stage.session.setDay(n, ura);
stage.setupDay(); stage.setupDay();
stage.auto = false; // setupDay re-arms live play; keep the harness driving 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])); this.buildSando(stage.session.order.layers.map((k) => [k, 0] as [string, number]));
@ -374,9 +374,9 @@ export function installHarness(stage: Stage): void {
// ---- M5: days + escalation --------------------------------------------- // ---- M5: days + escalation ---------------------------------------------
/** Play day `n` with a scripted-but-honest run: cut the filling, stack the /** Play day `n` with a scripted-but-honest run: cut the filling, stack the
* ordered layers, serve in time. Each day must stay beatable (B+). */ * ordered layers, serve in time. Each day must stay beatable (B+). */
playDay(n = 0) { playDay(n = 0, ura = false) {
this.freshRun(); this.freshRun();
const d = stage.session.setDay(n); const d = stage.session.setDay(n, ura);
const order = stage.session.order; const order = stage.session.order;
const fill = order.layers.find((k) => k !== 'bread' && k in FOODS) ?? 'tomato'; const fill = order.layers.find((k) => k !== 'bread' && k in FOODS) ?? 'tomato';
this.chop(fill); // a clean cut feeds THE CUT this.chop(fill); // a clean cut feeds THE CUT
@ -393,8 +393,8 @@ export function installHarness(stage: Stage): void {
}, },
/** M5 exit-bar: every day introduces its pressure and is beatable (B+). */ /** M5 exit-bar: every day introduces its pressure and is beatable (B+). */
playAllDays() { playAllDays(ura = false) {
return DAYS.map((_, i) => this.playDay(i)); return DAYS.map((_, i) => this.playDay(i, ura));
}, },
/** 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. */

View File

@ -23,6 +23,19 @@ export const DAYS: Day[] = [
{ order: { name: 'THE TOWER', layers: ['bread', 'katsu', 'cheese', 'tomato', 'bread'], needsCut: true, maxSeconds: 60 }, pressure: 'five layers', angerRamp: 1.3 }, { order: { name: 'THE TOWER', layers: ['bread', 'katsu', 'cheese', 'tomato', 'bread'], needsCut: true, maxSeconds: 60 }, pressure: 'five layers', angerRamp: 1.3 },
]; ];
/** the ura week. The Shogun arrives after KANSEI: new pantry, thinner
* patience, orders built to fight the stacker. Same physics, no mercy. */
export const URA_DAYS: Day[] = [
{ order: { name: 'Nori Sando', layers: ['bread', 'nori', 'bread'], needsCut: true, maxSeconds: 40 }, pressure: 'paper-thin', angerRamp: 1.4 },
{ order: { name: 'Ebi Katsu', layers: ['bread', 'ebi', 'katsu', 'bread'], needsCut: true, maxSeconds: 40 }, pressure: 'the shrimp is not symmetrical', angerRamp: 1.4 },
{ order: { name: 'Triple Tomato', layers: ['bread', 'tomato', 'tomato', 'tomato', 'bread'], needsCut: true, maxSeconds: 40 }, pressure: 'wetter than is wise', angerRamp: 1.4 },
{ order: { name: 'Daikon Pillar', layers: ['bread', 'daikon', 'bread'], needsCut: true, maxSeconds: 40 }, pressure: 'it is TALL', angerRamp: 1.5 },
{ order: { name: 'Ura Rush', layers: ['bread', 'nori', 'tomato', 'bread'], needsCut: true, maxSeconds: 18 }, pressure: 'the Shogun does not wait', angerRamp: 2 },
{ order: { name: 'Tamago Ebi', layers: ['bread', 'egg', 'ebi', 'bread'], needsCut: true, maxSeconds: 42 }, pressure: 'two round things', angerRamp: 1.5 },
{ order: { name: 'Tofu on the Pillar', layers: ['bread', 'tofu', 'daikon', 'bread'], needsCut: true, maxSeconds: 45 }, pressure: 'fragile, and TALL', angerRamp: 1.6 },
{ order: { name: "THE SHOGUN'S TOWER", layers: ['bread', 'katsu', 'ebi', 'cheese', 'tomato', 'nori', 'bread'], needsCut: true, maxSeconds: 80 }, pressure: 'seven layers. no mercy.', angerRamp: 1.6 },
];
export const ORDERS: Order[] = DAYS.map((d) => d.order); export const ORDERS: Order[] = DAYS.map((d) => d.order);
/** /**
@ -38,6 +51,7 @@ const LATE_TICK = 3; // one servedLate bump per this many seconds overdue
export class Game { export class Game {
samurai = new Samurai(); samurai = new Samurai();
day = 0; day = 0;
ura = false;
order: Order = DAYS[0].order; order: Order = DAYS[0].order;
drops = 0; drops = 0;
seconds = 0; // ticket timer (seconds) seconds = 0; // ticket timer (seconds)
@ -76,7 +90,7 @@ 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.lastVerdict.grade); recordGrade(this.day + (this.ura ? 100 : 0), this.lastVerdict.grade); // ura bests live at 100+
this.stage.showScorecard(this.lastVerdict); this.stage.showScorecard(this.lastVerdict);
} }
} else { } else {
@ -92,12 +106,20 @@ export class Game {
return judge(this.order, run); return judge(this.order, run);
} }
/** Load day `n` (clamped): its order + its samurai patience ramp. */ /** The current week's table. */
setDay(n: number): Day { get days(): Day[] {
this.day = Math.max(0, Math.min(DAYS.length - 1, n)); return this.ura ? URA_DAYS : DAYS;
const d = DAYS[this.day]; }
/** Load day `n` (clamped) from the current week: order + patience + judge. */
setDay(n: number, ura = this.ura): Day {
this.ura = ura;
const table = this.days;
this.day = Math.max(0, Math.min(table.length - 1, n));
const d = table[this.day];
this.order = d.order; this.order = d.order;
this.samurai.ramp = d.angerRamp; this.samurai.ramp = d.angerRamp;
this.samurai.who = ura ? 'shogun' : 'samurai';
this.drops = 0; this.drops = 0;
this.seconds = 0; this.seconds = 0;
this.state = 'playing'; this.state = 'playing';

View File

@ -7,6 +7,8 @@ const RANK: Record<string, number> = { S: 5, A: 4, B: 3, C: 2, D: 1 };
export interface Progress { export interface Progress {
best: Record<number, string>; best: Record<number, string>;
/** The Shogun has been met — the 裏 week is pickable from the title. */
ura?: boolean;
} }
export function loadProgress(): Progress { export function loadProgress(): Progress {
@ -18,6 +20,16 @@ export function loadProgress(): Progress {
} }
} }
export function unlockUra(): void {
const p = loadProgress();
p.ura = true;
try {
localStorage.setItem(KEY, JSON.stringify(p));
} catch {
/* fine */
}
}
/** Keep the better grade. Returns true if this was an improvement. */ /** Keep the better grade. Returns true if this was an improvement. */
export function recordGrade(day: number, grade: string): boolean { export function recordGrade(day: number, grade: string): boolean {
const p = loadProgress(); const p = loadProgress();

View File

@ -4,10 +4,10 @@ import { Marionette, type StringStyle } from '../sim/marionette';
import { Cutter } from '../sim/cutting'; import { Cutter } from '../sim/cutting';
import { spawnFood } from '../sim/food'; import { spawnFood } from '../sim/food';
import { Sando, spawnLayer } from '../sim/stack'; import { Sando, spawnLayer } from '../sim/stack';
import { Game, DAYS } from '../game/game'; import { Game } from '../game/game';
import type { Verdict } from '../game/judging'; import type { Verdict } from '../game/judging';
import { sfx, music } from '../game/audio'; import { sfx, music } from '../game/audio';
import { loadProgress } from '../game/progress'; import { loadProgress, unlockUra } from '../game/progress';
const PORTRAITS = ['samurai_calm', 'samurai_watch', 'samurai_annoyed', 'samurai_angry', 'samurai_fury']; const PORTRAITS = ['samurai_calm', 'samurai_watch', 'samurai_annoyed', 'samurai_angry', 'samurai_fury'];
@ -64,7 +64,8 @@ export class Stage extends Phaser.Scene {
// asset paths resolve regardless of a trailing slash on the URL. // asset paths resolve regardless of a trailing slash on the URL.
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`);
['bg_washi', 'bg_washi_dusk', 'bg_washi_night', 'title_art', 'samurai_bow', 'day_stamp', ...PORTRAITS].forEach(img); 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);
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`));
} }
@ -72,10 +73,11 @@ export class Stage extends Phaser.Scene {
this.matter.world.autoUpdate = false; this.matter.world.autoUpdate = false;
// washi backdrop, escalating with the week: days 1-3 noon, 4-6 dusk, 7-8 // washi backdrop, escalating with the week: days 1-3 noon, 4-6 dusk, 7-8
// night. (Generated art; the game never blocks on it — a plain fill shows // night. The ura week is played entirely by moonlight. (Generated art; the
// 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 wanted = dayIdx >= 6 ? 'bg_washi_night' : dayIdx >= 3 ? 'bg_washi_dusk' : 'bg_washi'; 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 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);
@ -112,7 +114,7 @@ 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); 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.)
@ -153,16 +155,24 @@ export class Stage extends Phaser.Scene {
this.space = this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE); this.space = this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE);
this.input.once('pointerdown', () => sfx.enable()); // unblock audio on first click this.input.once('pointerdown', () => sfx.enable()); // unblock audio on first click
// click advances past a scorecard: next day after a serve (the week ends in // click advances past a scorecard: next day after a serve, retry after
// KANSEI), retry after death. // death. The base week ends at the Shogun's arrival; the ura week ends the
// 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.day', 0); if (this.session.ura) {
this.registry.set('sando.ura', false);
this.registry.set('sando.day', 0);
} else {
this.registry.set('sando.ura', true);
this.registry.set('sando.day', 0);
}
this.scene.restart(); this.scene.restart();
} else if (this.session.state === 'served') { } else if (this.session.state === 'served') {
if (this.session.day + 1 >= DAYS.length) this.showKansei(); 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.scene.restart(); this.scene.restart();
} }
} else if (this.session.state === 'dead' && this.deadFrames > 70) { } else if (this.session.state === 'dead' && this.deadFrames > 70) {
@ -176,26 +186,38 @@ export class Stage extends Phaser.Scene {
else music.start(); else music.start();
} }
/** The week is done: the trophy card — every day's best stamp, over the stage. */ /** End of a week. Base week: KANSEI + the Shogun's arrival (unlocks ura).
* Ura week: SHIN-KANSEI the true ending. Stamps show the week's bests. */
private showKansei(): void { private showKansei(): void {
this.kansei = true; this.kansei = true;
this.clearScorecard(); this.clearScorecard();
music.stop(); music.stop();
sfx.gong(); sfx.gong();
const ura = this.session.ura;
if (!ura) 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, 720, 420, 0x120d08, 0.96).setStrokeStyle(3, 0x8a6a2a)); 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, 210, '完成', { fontFamily: 'serif', fontSize: '84px', color: '#e8b84a' }).setOrigin(0.5)); 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, 272, 'KANSEI — the week is served', { fontFamily: 'serif', fontSize: '18px', color: '#efe4cb' }).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 best = loadProgress().best;
DAYS.forEach((_, i) => { const off = ura ? 100 : 0;
const x = cx - ((DAYS.length - 1) * 74) / 2 + i * 74; 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)); 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)); else g.add(this.add.circle(x, 370, 27, 0x8a1a12, 0.85));
g.add(this.add.text(x, 370, best[i] ?? '·', { fontFamily: 'serif', fontSize: '26px', color: '#f4e8c4' }).setOrigin(0.5)); 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(this.add.text(x, 412, `${i + 1}`, { fontFamily: 'monospace', fontSize: '12px', color: '#9a8a6a' }).setOrigin(0.5));
}); });
g.add(this.add.text(cx, 480, 'click — begin the week again', { fontFamily: 'serif', fontSize: '15px', color: '#9a8a6a' }).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),
);
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',
}).setOrigin(0.5),
);
this.scorecard = g; // rides the same clear path this.scorecard = g; // rides the same clear path
} }
@ -208,14 +230,24 @@ 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 = DAYS[this.session.day]; const d = this.session.days[this.session.day];
const ura = this.session.ura;
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, 0x120d08, 0.9).setStrokeStyle(2, 0x8a6a2a)); 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, `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, `${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, 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)); 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() }); this.tweens.add({ targets: card, alpha: 0, delay: 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.)
if (this.registry.get('sando.curtain')) {
this.registry.set('sando.curtain', false);
const cur = this.textures.exists('curtain_red')
? this.add.image(STAGE_W / 2, STAGE_H / 2, 'curtain_red').setDisplaySize(STAGE_W, STAGE_H).setDepth(40)
: this.add.rectangle(STAGE_W / 2, STAGE_H / 2, STAGE_W, STAGE_H, 0x6a1410, 1).setDepth(40);
this.tweens.add({ targets: cur, y: -STAGE_H / 2 - 40, delay: 350, duration: 900, ease: 'Cubic.in', onComplete: () => cur.destroy() });
}
for (const f of [...this.cutter.foods]) this.removeProp(f.body); for (const f of [...this.cutter.foods]) this.removeProp(f.body);
this.cutter.foods = []; this.cutter.foods = [];
this.cutter.splats = []; this.cutter.splats = [];
@ -362,7 +394,8 @@ export class Stage extends Phaser.Scene {
const sam = this.session.samurai; const sam = this.session.samurai;
// an S earns the single approving nod — the bowing portrait over the scorecard. // 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 bowed = this.session.state === 'served' && this.session.lastVerdict?.grade === 'S';
const key = bowed && this.textures.exists('samurai_bow') ? 'samurai_bow' : sam.portrait; const bowKey = `${sam.who}_bow`;
const key = bowed && this.textures.exists(bowKey) ? bowKey : sam.portrait;
if (this.samuraiImg && this.textures.exists(key)) this.samuraiImg.setTexture(key); if (this.samuraiImg && this.textures.exists(key)) this.samuraiImg.setTexture(key);
const bar = this.angerBar; const bar = this.angerBar;
if (bar) { if (bar) {

View File

@ -56,6 +56,27 @@ 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
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' })
.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');
};
weekBtn.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);
this.registry.set('sando.day', 0);
sfx.enable();
sfx.shamisen();
paintWeek(next);
});
paintWeek(uraOn);
}
const shout = this.add.text(W / 2, H * 0.89, 'SHUBATTO!! SANDONIETTE!!!', { const shout = this.add.text(W / 2, H * 0.89, 'SHUBATTO!! SANDONIETTE!!!', {
fontFamily: 'serif', fontSize: '22px', color: '#e8b84a', fontStyle: 'bold', fontFamily: 'serif', fontSize: '22px', color: '#e8b84a', fontStyle: 'bold',
}).setOrigin(0.5).setDepth(1); }).setOrigin(0.5).setDepth(1);
@ -65,16 +86,21 @@ export class Title extends Phaser.Scene {
.setOrigin(0.5).setDepth(1); .setOrigin(0.5).setDepth(1);
this.tweens.add({ targets: hint, alpha: 0.3, duration: 900, yoyo: true, repeat: -1 }); this.tweens.add({ targets: hint, alpha: 0.3, duration: 900, yoyo: true, repeat: -1 });
// the trophy shelf: best-grade hanko stamps for cleared days (localStorage) // the trophy shelf: best-grade hanko stamps per day — base week right edge,
// ura week (keys 100+) one column in, tinted the Shogun's red.
const best = loadProgress().best; const best = loadProgress().best;
if (Object.keys(best).length > 0) { if (Object.keys(best).length > 0) {
DAYS.forEach((_, i) => { DAYS.forEach((_, i) => {
if (best[i] === undefined) return; for (const [off, x] of [[0, W - 46], [100, W - 104]] as [number, number][]) {
const x = W - 46, y = 90 + i * 56; const g = best[i + off];
if (this.textures.exists('day_stamp')) this.add.image(x, y, 'day_stamp').setDisplaySize(44, 44).setAlpha(0.85).setDepth(2); if (g === undefined) continue;
else this.add.circle(x, y, 20, 0x8a1a12, 0.8).setDepth(2); const y = 90 + i * 56;
this.add.text(x, y, best[i], { fontFamily: 'serif', fontSize: '20px', color: '#f4e8c4' }).setOrigin(0.5).setDepth(3); if (this.textures.exists('day_stamp')) {
this.add.text(x - 32, y, `${i + 1}`, { fontFamily: 'monospace', fontSize: '11px', color: '#8a7a5a' }).setOrigin(0.5).setDepth(2); this.add.image(x, y, 'day_stamp').setDisplaySize(44, 44).setAlpha(0.85).setDepth(2).setTint(off ? 0xff5040 : 0xffffff);
} else this.add.circle(x, y, 20, off ? 0x5a0a20 : 0x8a1a12, 0.8).setDepth(2);
this.add.text(x, y, g, { fontFamily: 'serif', fontSize: '20px', color: '#f4e8c4' }).setOrigin(0.5).setDepth(3);
if (!off) this.add.text(x - 90, y, `${i + 1}`, { fontFamily: 'monospace', fontSize: '11px', color: '#8a7a5a' }).setOrigin(0.5).setDepth(2);
}
}); });
} }

View File

@ -40,6 +40,10 @@ export const FOODS: Record<string, FoodDef> = {
// ponytail: wobble is a material param only — real jelly-cluster jiggle waits // 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. // 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' }, tofu: { verts: box(64, 46), material: { resistance: 0.12, moisture: 0.6, wobble: 0.4 }, color: 0x1a1a18, face: 'face_egg' },
// --- the ura pantry ---
nori: { verts: box(96, 8), material: { resistance: 0.06, moisture: 0.02, wobble: 0 }, color: 0x0d0f0c, face: 'face_bread' },
ebi: { verts: [{ x: -36, y: 2 }, { x: -22, y: -12 }, { x: 4, y: -15 }, { x: 27, y: -9 }, { x: 36, y: 3 }, { x: 18, y: 14 }, { x: -12, y: 15 }], material: { resistance: 0.4, moisture: 0.3, wobble: 0.05 }, color: 0x161210, face: 'face_katsu' },
daikon: { verts: box(46, 64), material: { resistance: 0.3, moisture: 0.5, wobble: 0 }, color: 0x181816, face: 'face_egg' },
}; };
let NEXT = 1; let NEXT = 1;

View File

@ -25,6 +25,9 @@ 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
* judge logic never changes; only the face at the window does. */
who: 'samurai' | 'shogun' = '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;
@ -50,7 +53,7 @@ export class Samurai {
} }
get portrait(): string { get portrait(): string {
return PORTRAITS[this.stage]; return `${this.who}_${PORTRAITS[this.stage].split('_')[1]}`;
} }
/** Vignette strength 0..1 for the screen darkening as he unsheaths. */ /** Vignette strength 0..1 for the screen darkening as he unsheaths. */

View File

@ -27,6 +27,11 @@ export const LAYERS: Record<string, LayerDef> = {
katsu: L('katsu', 88, 22), katsu: L('katsu', 88, 22),
egg: L('egg', 70, 20), egg: L('egg', 70, 20),
tofu: L('tofu', 74, 22), tofu: L('tofu', 74, 22),
// the ura pantry: nori is comically thin, daikon comically tall — the layer
// SHAPES are the difficulty, same physics as everything else.
nori: L('nori', 96, 7),
ebi: L('ebi', 72, 18),
daikon: L('daikon', 46, 56),
}; };
export interface SandoStats { export interface SandoStats {