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,
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
- **Mobile/touch**: Phaser Scale FIT+CENTER (letterboxes on phones), 3 active
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 ----
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
# ---- 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
* the plate, then hold the bar still the dwell must commit the serve and
* put up the scorecard, exactly as a player bowing would. */
playServeFlow(n = 0) {
playServeFlow(n = 0, ura = false) {
stage.auto = false;
stage.clearScorecard();
stage.session.reset();
stage.session.setDay(n);
stage.session.setDay(n, ura);
stage.setupDay();
stage.auto = false; // setupDay re-arms live play; keep the harness driving
this.buildSando(stage.session.order.layers.map((k) => [k, 0] as [string, number]));
@ -374,9 +374,9 @@ export function installHarness(stage: Stage): void {
// ---- M5: days + escalation ---------------------------------------------
/** Play day `n` with a scripted-but-honest run: cut the filling, stack the
* ordered layers, serve in time. Each day must stay beatable (B+). */
playDay(n = 0) {
playDay(n = 0, ura = false) {
this.freshRun();
const d = stage.session.setDay(n);
const d = stage.session.setDay(n, ura);
const order = stage.session.order;
const fill = order.layers.find((k) => k !== 'bread' && k in FOODS) ?? 'tomato';
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+). */
playAllDays() {
return DAYS.map((_, i) => this.playDay(i));
playAllDays(ura = false) {
return DAYS.map((_, i) => this.playDay(i, ura));
},
/** 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 },
];
/** 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);
/**
@ -38,6 +51,7 @@ const LATE_TICK = 3; // one servedLate bump per this many seconds overdue
export class Game {
samurai = new Samurai();
day = 0;
ura = false;
order: Order = DAYS[0].order;
drops = 0;
seconds = 0; // ticket timer (seconds)
@ -76,7 +90,7 @@ export class Game {
if (this.dwell >= 1) {
this.state = 'served';
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);
}
} else {
@ -92,12 +106,20 @@ export class Game {
return judge(this.order, run);
}
/** Load day `n` (clamped): its order + its samurai patience ramp. */
setDay(n: number): Day {
this.day = Math.max(0, Math.min(DAYS.length - 1, n));
const d = DAYS[this.day];
/** The current week's table. */
get days(): Day[] {
return this.ura ? URA_DAYS : DAYS;
}
/** 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.samurai.ramp = d.angerRamp;
this.samurai.who = ura ? 'shogun' : 'samurai';
this.drops = 0;
this.seconds = 0;
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 {
best: Record<number, string>;
/** The Shogun has been met — the 裏 week is pickable from the title. */
ura?: boolean;
}
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. */
export function recordGrade(day: number, grade: string): boolean {
const p = loadProgress();

View File

@ -4,10 +4,10 @@ import { Marionette, type StringStyle } from '../sim/marionette';
import { Cutter } from '../sim/cutting';
import { spawnFood } from '../sim/food';
import { Sando, spawnLayer } from '../sim/stack';
import { Game, DAYS } from '../game/game';
import { Game } from '../game/game';
import type { Verdict } from '../game/judging';
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'];
@ -64,7 +64,8 @@ export class Stage extends Phaser.Scene {
// asset paths resolve regardless of a trailing slash on the URL.
const base = import.meta.env.BASE_URL;
const img = (k: string) => this.load.image(k, `${base}assets/img/${k}.png`);
['bg_washi', '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`));
}
@ -72,10 +73,11 @@ export class Stage extends Phaser.Scene {
this.matter.world.autoUpdate = false;
// 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
// if it failed to load.)
// night. The ura week is played entirely by moonlight. (Generated art; the
// 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 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';
if (this.textures.exists(bgKey)) {
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.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
// 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.)
@ -153,16 +155,24 @@ export class Stage extends Phaser.Scene {
this.space = this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE);
this.input.once('pointerdown', () => sfx.enable()); // unblock audio on first click
// click advances past a scorecard: next day after a serve (the week ends in
// KANSEI), retry after death.
// click advances past a scorecard: next day after a serve, retry after
// 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', () => {
if (this.kansei) {
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();
} 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 {
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) {
@ -176,26 +186,38 @@ export class Stage extends Phaser.Scene {
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 {
this.kansei = true;
this.clearScorecard();
music.stop();
sfx.gong();
const ura = this.session.ura;
if (!ura) unlockUra();
const g = this.add.container(0, 0).setDepth(32);
const cx = STAGE_W / 2;
g.add(this.add.rectangle(cx, 340, 720, 420, 0x120d08, 0.96).setStrokeStyle(3, 0x8a6a2a));
g.add(this.add.text(cx, 210, '完成', { fontFamily: 'serif', fontSize: '84px', color: '#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.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;
DAYS.forEach((_, i) => {
const x = cx - ((DAYS.length - 1) * 74) / 2 + i * 74;
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] ?? '·', { 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(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
}
@ -208,14 +230,24 @@ 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 = 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);
card.add(this.add.rectangle(STAGE_W / 2, 300, 520, 150, 0x120d08, 0.9).setStrokeStyle(2, 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.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() });
}
// 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);
this.cutter.foods = [];
this.cutter.splats = [];
@ -362,7 +394,8 @@ export class Stage extends Phaser.Scene {
const sam = this.session.samurai;
// an S earns the single approving nod — the bowing portrait over the scorecard.
const bowed = this.session.state === 'served' && this.session.lastVerdict?.grade === 'S';
const key = bowed && this.textures.exists('samurai_bow') ? 'samurai_bow' : sam.portrait;
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);
const bar = this.angerBar;
if (bar) {

View File

@ -56,6 +56,27 @@ export class Title extends Phaser.Scene {
.setOrigin(0.5).setDepth(1);
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!!!', {
fontFamily: 'serif', fontSize: '22px', color: '#e8b84a', fontStyle: 'bold',
}).setOrigin(0.5).setDepth(1);
@ -65,16 +86,21 @@ export class Title extends Phaser.Scene {
.setOrigin(0.5).setDepth(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;
if (Object.keys(best).length > 0) {
DAYS.forEach((_, i) => {
if (best[i] === undefined) return;
const x = W - 46, y = 90 + i * 56;
if (this.textures.exists('day_stamp')) this.add.image(x, y, 'day_stamp').setDisplaySize(44, 44).setAlpha(0.85).setDepth(2);
else this.add.circle(x, y, 20, 0x8a1a12, 0.8).setDepth(2);
this.add.text(x, y, best[i], { fontFamily: 'serif', fontSize: '20px', color: '#f4e8c4' }).setOrigin(0.5).setDepth(3);
this.add.text(x - 32, y, `${i + 1}`, { fontFamily: 'monospace', fontSize: '11px', color: '#8a7a5a' }).setOrigin(0.5).setDepth(2);
for (const [off, x] of [[0, W - 46], [100, W - 104]] as [number, number][]) {
const g = best[i + off];
if (g === undefined) continue;
const y = 90 + i * 56;
if (this.textures.exists('day_stamp')) {
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
// 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' },
// --- 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;

View File

@ -25,6 +25,9 @@ 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';
struck = false;
/** Fired once when the meter tops out — the stage wires the string cut here. */
onStrike?: () => void;
@ -50,7 +53,7 @@ export class Samurai {
}
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. */

View File

@ -27,6 +27,11 @@ export const LAYERS: Record<string, LayerDef> = {
katsu: L('katsu', 88, 22),
egg: L('egg', 70, 20),
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 {