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:
parent
cdde03bef4
commit
077e970b16
@ -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 M1–M3 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.
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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
91
src/game/audio.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
@ -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],
|
||||
});
|
||||
|
||||
@ -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
43
src/scenes/title.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user