Build-out 3: mobile touch, progression + KANSEI, music, night shift

- Mobile: Scale.FIT letterboxing, 3 pointers, thumb GRIP button (touch only),
  drag-low = reach. Desktop input and all battery numbers unchanged.
- Progression: localStorage best-grade per day (game/progress.ts), red hanko
  trophy shelf on the title, KANSEI completion card after day 8 (all stamps,
  click restarts the week). Serve->save->shelf->kansei->day0 verified.
- Day-intro card each live day (name/layers/pressure, fades out).
- Music: seeded pentatonic plucks + taiko heartbeat on setInterval, starts at
  begin, stops dead at the string cut. sfx.chop() thunk on real cuts.
- Night shift: days 4-6 dusk / 7-8 night backdrops (MODELBEAST batch 3 — moon,
  bonsai, lanterns), guarded fallback to noon washi.
- Battery green: M1 87f/8.41u/10-10, M2 cv0 clean, M3 both, days SSSSSSSS,
  live serve@31f, disaster CUT, saved best {0:B} confirmed in localStorage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-20 17:30:27 +10:00
parent 26921eb4ad
commit a3337e68e0
10 changed files with 200 additions and 12 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 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@ -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

View File

@ -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);

View File

@ -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 {

37
src/game/progress.ts Normal file
View File

@ -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<string, number> = { S: 5, A: 4, B: 3, C: 2, D: 1 };
export interface Progress {
best: Record<number, string>;
}
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);
}

View File

@ -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 },

View File

@ -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

View File

@ -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();