M6: juice — title screen, Web Audio SFX (all 6 milestones ship)

- scenes/title.ts: sumi-brush title_art + サンドニエット + pulsing SHUBATTO!!
  SANDONIETTE!!! + click-to-begin. Launched as an overlay over the live stage
  so the harness is up from frame one; __s.startGame() dismisses it.
- game/audio.ts: 100% synthesized Web Audio SFX (gong / slash whoosh / wet
  splat / shamisen), lazy AudioContext, no-op when headless, unblocked on first
  click. Wired: slash+gong on the string cut, splat on squash, shamisen on start.
- Deferred (pre-authorized): food faces (generated art is full-colour cartoons
  that clash with the black-silhouette look — would need regen as cutouts);
  string unlockables (M5 lands fine without). Attract ragdoll -> pulsing title.
- Full M1-M6 battery green; typecheck + npm run check + production build pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-17 21:19:12 +10:00
parent cdde03bef4
commit 077e970b16
6 changed files with 168 additions and 25 deletions

View File

@ -65,11 +65,26 @@
doesn't). Exit-bar: `__s.playAllDays()` — every day beatable by a scripted-
honest run (all S, standing). Ramp bites: day 1 needs 7 crime-cuts to strike,
day 5 only 4. Verified in-browser.
- **Next: M6** juice — title screen (「サンドニエット」brush art + SHUBATTO crawl +
attract ragdoll), Web Audio (shamisen sting / slash whoosh / wet splat), food
faces on pieces, string unlockables (rubber/chain/silk — pre-authorized defer).
- Run: `npm run dev` (:5173). Harness: `__s.playDay(n) __s.playAllDays()
__s.playCleanRun() __s.playDisasterRun()` + all M1M3 verbs. Headless: `npm run check`.
- **M6 ✅** juice. Title scene (`scenes/title.ts`) — sumi brush `title_art` +
「サンドニエット」+ pulsing **SHUBATTO!! SANDONIETTE!!!** + click-to-begin;
launched as an overlay over the live stage so the harness is up from frame one
(`__s.startGame()` dismisses it). `game/audio.ts` — 100% synth Web Audio SFX
(gong / slash whoosh / wet splat / shamisen), lazy AudioContext, no-op headless,
unblocked on first click. Wired: slash+gong on the string cut, splat on wet
squash, shamisen on title start. **Deferred (pre-authorized judgment):** food
faces — the generated faces are full-colour cartoons on paper, they clash with
the coherent black-silhouette look; would need regen as true cutouts. String
unlockables (rubber/chain/silk) — pre-authorized defer, M5 lands fine without.
Attract ragdoll → the pulsing title. Verified: title screenshot, click path,
production `npm run build` clean.
## ALL SIX MILESTONES SHIP ✅ — full loop plays, three must-feels landed
(the dangle M1, the clean chop M2, the string cut M4). Final battery green:
M1 dangle 97f / walk 8.83u up / grip 10/10 · M2 chop cv0 clean / splat / offstage ·
M3 good survives + bad toppled · M4 disaster→string-cut ragdoll · M5 days 1-5 all
beatable. `npm run typecheck` + `npm run check` + `npm run build` all pass.
Possible next (Brief 2 territory): food faces as true silhouette cutouts, string
unlockables, more days/orders, real attract-mode, mobile/touch controls.

View File

@ -76,6 +76,9 @@ export function installHarness(stage: Stage): void {
settle,
pose: () => p().pose(),
/** Dismiss the title overlay (for gameplay screenshots / headless drives). */
startGame: () => stage.scene.stop('title'),
/** Set the control bar to an absolute position and step one frame. */
bar(x: number, y = p().barHomeY) {
stage.auto = false;

91
src/game/audio.ts Normal file
View File

@ -0,0 +1,91 @@
/**
* SFX, 100% synthesized Web Audio no paid assets, nothing to load. Lazy
* AudioContext (browsers block it until a user gesture; call sfx.enable() from a
* click). Every call is a no-op if audio isn't available (headless harness),
* so gameplay never depends on it.
*/
let ctx: AudioContext | null = null;
let noise: AudioBuffer | null = null;
function ac(): AudioContext | null {
try {
ctx ??= new AudioContext();
if (ctx.state === 'suspended') void ctx.resume();
return ctx;
} catch {
return null;
}
}
/** One second of white noise, built once, reused for whooshes and splats. */
function noiseBuffer(c: AudioContext): AudioBuffer {
if (noise) return noise;
const buf = c.createBuffer(1, c.sampleRate, c.sampleRate);
const d = buf.getChannelData(0);
// deterministic-ish LCG so it isn't Math.random (keeps sim files clean of it)
let s = 1234567;
for (let i = 0; i < d.length; i++) {
s = (s * 1103515245 + 12345) & 0x7fffffff;
d[i] = (s / 0x3fffffff) - 1;
}
noise = buf;
return buf;
}
function tone(freq: number, dur: number, type: OscillatorType, gain: number, sweepTo?: number): void {
const c = ac();
if (!c) return;
const o = c.createOscillator(), g = c.createGain();
o.type = type;
o.frequency.setValueAtTime(freq, c.currentTime);
if (sweepTo) o.frequency.exponentialRampToValueAtTime(sweepTo, c.currentTime + dur);
g.gain.setValueAtTime(gain, c.currentTime);
g.gain.exponentialRampToValueAtTime(0.0001, c.currentTime + dur);
o.connect(g).connect(c.destination);
o.start();
o.stop(c.currentTime + dur);
}
function burst(dur: number, gain: number, filterHz: number, sweepTo?: number): void {
const c = ac();
if (!c) return;
const src = c.createBufferSource();
src.buffer = noiseBuffer(c);
const f = c.createBiquadFilter();
f.type = 'lowpass';
f.frequency.setValueAtTime(filterHz, c.currentTime);
if (sweepTo) f.frequency.exponentialRampToValueAtTime(sweepTo, c.currentTime + dur);
const g = c.createGain();
g.gain.setValueAtTime(gain, c.currentTime);
g.gain.exponentialRampToValueAtTime(0.0001, c.currentTime + dur);
src.connect(f).connect(g).connect(c.destination);
src.start();
src.stop(c.currentTime + dur);
}
export const sfx = {
/** Call from a user gesture to unblock audio. */
enable(): void {
ac();
},
/** The strike — a deep temple gong. */
gong(): void {
tone(68, 1.4, 'sine', 0.4);
tone(136, 1.1, 'sine', 0.12);
},
/** The katana — a bright noise whoosh sweeping down. */
slash(): void {
burst(0.32, 0.5, 9000, 400);
tone(1400, 0.18, 'sawtooth', 0.14, 300);
},
/** Wet food hitting the board — a short damp thud. */
splat(): void {
burst(0.16, 0.45, 900, 200);
},
/** Title / serve sting — a plucked shamisen twang. */
shamisen(): void {
tone(330, 0.5, 'sawtooth', 0.16, 220);
tone(660, 0.35, 'triangle', 0.06, 500);
},
};

View File

@ -1,5 +1,6 @@
import Phaser from 'phaser';
import { Stage, STAGE_W, STAGE_H } from './scenes/stage';
import { Title } from './scenes/title';
new Phaser.Game({
type: Phaser.AUTO,
@ -11,5 +12,5 @@ new Phaser.Game({
default: 'matter',
matter: { gravity: { x: 0, y: 1 }, autoUpdate: false, debug: false },
},
scene: [Stage],
scene: [Stage, Title],
});

View File

@ -6,6 +6,7 @@ import { spawnFood } from '../sim/food';
import { Sando } from '../sim/stack';
import { Game } from '../game/game';
import type { Verdict } from '../game/judging';
import { sfx } from '../game/audio';
const PORTRAITS = ['samurai_calm', 'samurai_watch', 'samurai_annoyed', 'samurai_angry', 'samurai_fury'];
@ -48,7 +49,7 @@ export class Stage extends Phaser.Scene {
preload(): void {
const img = (k: string) => this.load.image(k, `assets/img/${k}.png`);
['bg_washi', ...PORTRAITS].forEach(img);
['bg_washi', 'title_art', ...PORTRAITS].forEach(img);
}
create(): void {
@ -93,7 +94,11 @@ export class Stage extends Phaser.Scene {
this.vignette = this.add.rectangle(STAGE_W / 2, STAGE_H / 2, STAGE_W, STAGE_H, 0x1a0000, 0).setDepth(4);
this.space = this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE);
this.input.once('pointerdown', () => sfx.enable()); // unblock audio on first click
installHarness(this);
// title over the top; dismissed on click (the harness can skip it).
this.scene.launch('title');
}
/** SHUBATTO the string cut. Ragdoll the puppet, fling every loose food with
@ -108,7 +113,8 @@ export class Stage extends Phaser.Scene {
this.matter.body.setAngularVelocity(b, dir * 0.3);
}
this.slashFx();
this.gong();
sfx.slash();
sfx.gong();
}
private slashFx(): void {
@ -116,23 +122,6 @@ export class Stage extends Phaser.Scene {
this.tweens.add({ targets: line, alpha: 0, duration: 450, onComplete: () => line.destroy() });
}
private gong(): void {
try {
const ctx = new AudioContext();
const o = ctx.createOscillator(), g = ctx.createGain();
o.type = 'sine';
o.frequency.value = 68;
o.connect(g);
g.connect(ctx.destination);
g.gain.setValueAtTime(0.4, ctx.currentTime);
g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 1.3);
o.start();
o.stop(ctx.currentTime + 1.3);
} catch {
/* no audio (headless / no gesture) — the cut still lands visually */
}
}
/** The scorecard over the wreckage: grade, the four groups, the bark. */
showScorecard(v: Verdict): Phaser.GameObjects.Container {
this.scorecard?.destroy();
@ -200,6 +189,7 @@ export class Stage extends Phaser.Scene {
splat(x: number, y: number, r: number, color: number): void {
const blob = this.add.ellipse(x, y, r * 2.2, r * 1.3, color, 0.85);
blob.setDepth(-1);
sfx.splat();
}
/** Destroy a body's silhouette and remove it from the world. */

43
src/scenes/title.ts Normal file
View File

@ -0,0 +1,43 @@
import Phaser from 'phaser';
import { sfx } from '../game/audio';
/**
* Title card, launched as an overlay over the live stage so the dev harness is
* available underneath from frame one. Click anywhere to begin that click also
* unblocks audio, so the shamisen sting lands on entry.
*/
export class Title extends Phaser.Scene {
constructor() {
super('title');
}
create(): void {
const W = this.scale.width, H = this.scale.height;
this.add.rectangle(W / 2, H / 2, W, H, 0x0a0705, 0.82);
if (this.textures.exists('title_art')) {
this.add.image(W / 2, H * 0.38, 'title_art').setScale(0.62).setDepth(1);
}
this.add.text(W / 2, H * 0.66, 'サンドニエット', { fontFamily: 'serif', fontSize: '56px', color: '#efe4cb' })
.setOrigin(0.5).setDepth(1);
this.add.text(W / 2, H * 0.72, 'S A N D O N I E T T E', { fontFamily: 'serif', fontSize: '20px', color: '#c8a24a' })
.setOrigin(0.5).setDepth(1);
const shout = this.add.text(W / 2, H * 0.85, 'SHUBATTO!! SANDONIETTE!!!', {
fontFamily: 'serif', fontSize: '22px', color: '#e8b84a', fontStyle: 'bold',
}).setOrigin(0.5).setDepth(1);
this.tweens.add({ targets: shout, scale: 1.12, duration: 600, yoyo: true, repeat: -1, ease: 'Sine.inOut' });
const hint = this.add.text(W / 2, H * 0.93, 'click to begin', { fontFamily: 'serif', fontSize: '15px', color: '#9a8a6a' })
.setOrigin(0.5).setDepth(1);
this.tweens.add({ targets: hint, alpha: 0.3, duration: 900, yoyo: true, repeat: -1 });
this.input.once('pointerdown', () => {
sfx.enable();
sfx.gong();
sfx.shamisen();
this.scene.stop();
});
}
}