diff --git a/OPUS-BUILD-BRIEF.md b/OPUS-BUILD-BRIEF.md index 155c64d..2c604fb 100644 --- a/OPUS-BUILD-BRIEF.md +++ b/OPUS-BUILD-BRIEF.md @@ -85,6 +85,26 @@ Attract ragdoll → the pulsing title. Verified: title screenshot, click path, production `npm run build` clean. +## 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 + button needed). Desktop input unchanged — battery numbers identical. +- **Progression** (`game/progress.ts`, localStorage `sandoniette.v1`): best grade + per day recorded on every live serve; **trophy shelf** of red hanko stamps + (day_stamp.png) on the title; **KANSEI** completion card after serving day 8 + (all 8 stamps + grades, click → week restarts). Verified: serve→save→shelf→ + kansei→day-0 all exercised in-browser. +- **Day-intro card** on each live day start (name + layers + pressure, fades). +- **Music** (`audio.ts music`): sparse seeded pentatonic plucks + taiko heartbeat + on setInterval (survives rAF throttling), starts at begin, **stops dead at the + string cut**, resumes next day. `sfx.chop()` thunk on every real cut. +- **Night shift**: days 1-3 noon washi, 4-6 `bg_washi_dusk`, 7-8 `bg_washi_night` + (moon + bonsai + lanterns — MODELBEAST batch 3, seeds 301 re-lit). Guarded + fallback to bg_washi if missing. +- Pushed to Gitea (`origin/main`). Battery unchanged-green after all of it: + M1 87f/8.41u/10-10 · M2 cv0-clean · M3 ✓✓ · days SSSSSSSS · live serve@31f · + dead CUT. localStorage save verified ({0:"B"} after one live serve). + ## BUILD-OUT PASS 2 ✅ — the playable loop, string unlockables, 8 days - **Physical stacking** — `Sando` is now zone-DETECTION: the stack = whatever food bodies rest in the plate zone (±82px, below-flight speed), sorted bottom→top. diff --git a/public/assets/img/bg_washi_dusk.png b/public/assets/img/bg_washi_dusk.png new file mode 100644 index 0000000..e9439da Binary files /dev/null and b/public/assets/img/bg_washi_dusk.png differ diff --git a/public/assets/img/bg_washi_night.png b/public/assets/img/bg_washi_night.png new file mode 100644 index 0000000..3847268 Binary files /dev/null and b/public/assets/img/bg_washi_night.png differ diff --git a/scripts/gen-assets.sh b/scripts/gen-assets.sh index f7210e1..f377541 100755 --- a/scripts/gen-assets.sh +++ b/scripts/gen-assets.sh @@ -91,3 +91,7 @@ gen_2d samurai_bow 320 "$SAMURAI, head bowed slightly in approval, eyes clos gen_2d counter_wood 402 "a low wooden serving counter table, edo period, side view, flat black silhouette on transparent background, hand-crafted paper cut-out style" 768 384 cut gen_2d face_cut_bread 410 "simple face cut out of black paper: two round holes for eyes and a small worried oval mouth hole, white background, minimal, high contrast, paper-cut stencil" 512 512 cut gen_2d day_stamp 420 "a round red japanese hanko seal stamp, blank center, worn ink edges, on transparent background" 512 512 cut + +# ---- 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 diff --git a/src/game/audio.ts b/src/game/audio.ts index 499937f..3b00bde 100644 --- a/src/game/audio.ts +++ b/src/game/audio.ts @@ -64,6 +64,38 @@ function burst(dur: number, gain: number, filterHz: number, sweepTo?: number): v src.stop(c.currentTime + dur); } +/** + * Ambient loop: sparse pentatonic koto plucks over a slow taiko heartbeat, + * scheduled on setInterval (so it breathes even when rAF throttles) with a + * seeded LCG — the tune wanders but never uses Math.random. Quiet on purpose. + */ +let musicTimer: number | null = null; +export const music = { + start(): void { + if (musicTimer !== null || !ac()) return; + const scale = [147, 175, 196, 220, 262, 294, 349]; // D minor pentatonic-ish + let s = 42, step = 0; + const rnd = () => { + s = (s * 1103515245 + 12345) & 0x7fffffff; + return s / 0x7fffffff; + }; + musicTimer = window.setInterval(() => { + step++; + if (rnd() < 0.72) { + const f = scale[Math.floor(rnd() * scale.length)]; + tone(f, 0.9, 'triangle', 0.045, f * 0.985); // soft pluck with a tiny droop + } + if (step % 8 === 0) tone(73, 1.2, 'sine', 0.07); // the heartbeat + }, 620); + }, + stop(): void { + if (musicTimer !== null) { + clearInterval(musicTimer); + musicTimer = null; + } + }, +}; + export const sfx = { /** Call from a user gesture to unblock audio. */ enable(): void { @@ -83,6 +115,11 @@ export const sfx = { splat(): void { burst(0.16, 0.45, 900, 200); }, + /** The blade landing a real cut — a firm little chop-thunk. */ + chop(): void { + burst(0.09, 0.5, 2400, 500); + tone(180, 0.12, 'square', 0.08, 90); + }, /** Title / serve sting — a plucked shamisen twang. */ shamisen(): void { tone(330, 0.5, 'sawtooth', 0.16, 220); diff --git a/src/game/game.ts b/src/game/game.ts index 9eadc8a..42ddb09 100644 --- a/src/game/game.ts +++ b/src/game/game.ts @@ -1,6 +1,7 @@ import type { Stage } from '../scenes/stage'; import { Samurai } from '../sim/samurai'; import { judge, type Order, type RunResult, type Verdict } from './judging'; +import { recordGrade } from './progress'; /** 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 @@ -75,6 +76,7 @@ export class Game { if (this.dwell >= 1) { this.state = 'served'; this.lastVerdict = this.serve(true); + recordGrade(this.day, this.lastVerdict.grade); this.stage.showScorecard(this.lastVerdict); } } else { diff --git a/src/game/progress.ts b/src/game/progress.ts new file mode 100644 index 0000000..0d35c11 --- /dev/null +++ b/src/game/progress.ts @@ -0,0 +1,37 @@ +import { DAYS } from './game'; + +/** Best grade per day, in localStorage. The whole save system — nothing else + * persists, by design: runs are the game, stamps are the trophy shelf. */ +const KEY = 'sandoniette.v1'; +const RANK: Record = { S: 5, A: 4, B: 3, C: 2, D: 1 }; + +export interface Progress { + best: Record; +} + +export function loadProgress(): Progress { + try { + const p = JSON.parse(localStorage.getItem(KEY) ?? '') as Progress; + return p && typeof p.best === 'object' ? p : { best: {} }; + } catch { + return { best: {} }; + } +} + +/** Keep the better grade. Returns true if this was an improvement. */ +export function recordGrade(day: number, grade: string): boolean { + const p = loadProgress(); + if ((RANK[grade] ?? 0) <= (RANK[p.best[day]] ?? 0)) return false; + p.best[day] = grade; + try { + localStorage.setItem(KEY, JSON.stringify(p)); + } catch { + /* private-mode etc. — the run still played */ + } + return true; +} + +export function clearedAll(): boolean { + const p = loadProgress(); + return DAYS.every((_, i) => p.best[i] !== undefined); +} diff --git a/src/main.ts b/src/main.ts index 7d39ccb..a681ba5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,6 +8,12 @@ new Phaser.Game({ height: STAGE_H, backgroundColor: '#efe4cb', // warm washi paper parent: 'app', + scale: { + // letterbox onto phones/tablets; pointer coords stay in stage space. + mode: Phaser.Scale.FIT, + autoCenter: Phaser.Scale.CENTER_BOTH, + }, + input: { activePointers: 3 }, // drag + grip button + spare finger physics: { default: 'matter', matter: { gravity: { x: 0, y: 1 }, autoUpdate: false, debug: false }, diff --git a/src/scenes/stage.ts b/src/scenes/stage.ts index e40c3fb..dd6de4f 100644 --- a/src/scenes/stage.ts +++ b/src/scenes/stage.ts @@ -6,7 +6,8 @@ import { spawnFood } from '../sim/food'; import { Sando, spawnLayer } from '../sim/stack'; import { Game, DAYS } from '../game/game'; import type { Verdict } from '../game/judging'; -import { sfx } from '../game/audio'; +import { sfx, music } from '../game/audio'; +import { loadProgress } from '../game/progress'; const PORTRAITS = ['samurai_calm', 'samurai_watch', 'samurai_annoyed', 'samurai_angry', 'samurai_fury']; @@ -51,6 +52,8 @@ export class Stage extends Phaser.Scene { private prevBar = { x: 0, y: 0 }; private deadFrames = 0; private sweep = 0; + private touchGrip = false; + private kansei = false; // completion card is up constructor() { super('stage'); @@ -61,16 +64,21 @@ export class Stage extends Phaser.Scene { // asset paths resolve regardless of a trailing slash on the URL. const base = import.meta.env.BASE_URL; const img = (k: string) => this.load.image(k, `${base}assets/img/${k}.png`); - ['bg_washi', 'title_art', 'samurai_bow', ...PORTRAITS].forEach(img); + ['bg_washi', 'bg_washi_dusk', 'bg_washi_night', 'title_art', 'samurai_bow', 'day_stamp', ...PORTRAITS].forEach(img); + this.load.on('loaderror', (f: { key: string }) => console.warn(`[assets] ${f.key} missing — procedural fallback`)); } create(): void { this.matter.world.autoUpdate = false; - // washi backdrop (generated art; the game never blocks on it — a plain fill - // shows if it failed to load). - if (this.textures.exists('bg_washi')) { - this.add.image(STAGE_W / 2, STAGE_H / 2, 'bg_washi').setDisplaySize(STAGE_W, STAGE_H).setDepth(-10); + // 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.) + 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 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); } // floor + side walls @@ -110,6 +118,7 @@ export class Stage extends Phaser.Scene { // offstage/late are pressure enough; add drops when a day needs it.) this.cutter.onCut = (r) => { if (r.quality === 'crime') (r.pieces === 0 ? this.session.splat() : this.session.crimeCut()); + else sfx.chop(); // a real cut lands with a thunk }; // HUD: the samurai judge (top-right), the anger meter, the unsheath vignette. @@ -130,13 +139,32 @@ export class Stage extends Phaser.Scene { this.setupDay(); + // touch controls: drag = the bar (dragging low reaches); a thumb GRIP button + // stands in for RMB/Space. Mouse users never see it. + if (this.sys.game.device.input.touch) { + const btn = this.add.circle(86, 610, 54, 0x2a211a, 0.55).setDepth(7).setStrokeStyle(2, 0x8a6a2a) + .setInteractive({ useHandCursor: false }); + this.add.text(86, 610, 'GRIP\n掴む', { fontFamily: 'serif', fontSize: '16px', color: '#e8b84a', align: 'center' }) + .setOrigin(0.5).setDepth(8); + btn.on('pointerdown', () => (this.touchGrip = true)); + btn.on('pointerup', () => (this.touchGrip = false)); + btn.on('pointerout', () => (this.touchGrip = false)); + } + this.space = this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE); this.input.once('pointerdown', () => sfx.enable()); // unblock audio on first click - // click advances past a scorecard: next day after a serve, retry after death. + // click advances past a scorecard: next day after a serve (the week ends in + // KANSEI), retry after death. this.input.on('pointerdown', () => { - if (this.session.state === 'served') { - this.registry.set('sando.day', (this.session.day + 1) % DAYS.length); + if (this.kansei) { + this.registry.set('sando.day', 0); this.scene.restart(); + } else if (this.session.state === 'served') { + if (this.session.day + 1 >= DAYS.length) this.showKansei(); + else { + this.registry.set('sando.day', this.session.day + 1); + this.scene.restart(); + } } else if (this.session.state === 'dead' && this.deadFrames > 70) { this.scene.restart(); // same day, fresh strings } @@ -145,6 +173,30 @@ export class Stage extends Phaser.Scene { // title overlay on first boot only (day/style picks restart this scene). if (!this.registry.get('sando.started')) this.scene.launch('title'); + else music.start(); + } + + /** The week is done: the trophy card — every day's best stamp, over the stage. */ + private showKansei(): void { + this.kansei = true; + this.clearScorecard(); + music.stop(); + sfx.gong(); + 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)); + const best = loadProgress().best; + DAYS.forEach((_, i) => { + const x = cx - ((DAYS.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, 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)); + this.scorecard = g; // rides the same clear path } /** Stock the kitchen for the current order: bread slices staged on the board's @@ -154,6 +206,16 @@ export class Stage extends Phaser.Scene { this.clearScorecard(); this.advanceHint?.setVisible(false); 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 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.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() }); + } for (const f of [...this.cutter.foods]) this.removeProp(f.body); this.cutter.foods = []; this.cutter.splats = []; @@ -184,6 +246,7 @@ export class Stage extends Phaser.Scene { this.matter.body.setAngularVelocity(b, dir * 0.3); } this.slashFx(); + music.stop(); // the band stops when the blade comes out sfx.slash(); sfx.gong(); } @@ -370,8 +433,11 @@ export class Stage extends Phaser.Scene { update(): void { if (this.auto) { const p = this.input.activePointer; - const grip = p.rightButtonDown() || this.space.isDown; - this.puppet.applyInput(p.worldX, p.worldY || this.puppet.barHomeY, p.leftButtonDown(), grip); + const touch = p.wasTouch; + const grip = touch ? this.touchGrip : p.rightButtonDown() || this.space.isDown; + // touch reach: dragging the finger below the mouse floor IS the reach. + const reach = touch ? p.worldY > 380 : p.leftButtonDown(); + this.puppet.applyInput(p.worldX, p.worldY || this.puppet.barHomeY, reach, grip); this.stepSim(); this.driveCleaver(); // a gripped cleaver is the live blade — swinging cuts // the bow dwell reads bar stillness, not pointer stillness — chain strings diff --git a/src/scenes/title.ts b/src/scenes/title.ts index 87a6aee..6d11edf 100644 --- a/src/scenes/title.ts +++ b/src/scenes/title.ts @@ -1,6 +1,8 @@ import Phaser from 'phaser'; -import { sfx } from '../game/audio'; +import { sfx, music } from '../game/audio'; import { STRING_STYLES, type StringStyle } from '../sim/marionette'; +import { loadProgress } from '../game/progress'; +import { DAYS } from '../game/game'; /** * Title card, launched as an overlay over the live stage so the dev harness is @@ -63,6 +65,19 @@ 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) + 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); + }); + } + // begin: anywhere that isn't a style button. Restart the stage so the puppet // is strung with the chosen style, then drop the overlay. this.input.on('pointerdown', (_p: Phaser.Input.Pointer, over: unknown[]) => { @@ -70,6 +85,7 @@ export class Title extends Phaser.Scene { sfx.enable(); sfx.gong(); sfx.shamisen(); + music.start(); this.registry.set('sando.started', true); this.scene.get('stage').scene.restart(); this.scene.stop();