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:
parent
26921eb4ad
commit
a3337e68e0
@ -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.
|
||||
|
||||
BIN
public/assets/img/bg_washi_dusk.png
Normal file
BIN
public/assets/img/bg_washi_dusk.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
BIN
public/assets/img/bg_washi_night.png
Normal file
BIN
public/assets/img/bg_washi_night.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
37
src/game/progress.ts
Normal 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);
|
||||
}
|
||||
@ -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 },
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user