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-
|
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,
|
honest run (all S, standing). Ramp bites: day 1 needs 7 crime-cuts to strike,
|
||||||
day 5 only 4. Verified in-browser.
|
day 5 only 4. Verified in-browser.
|
||||||
- **Next: M6** juice — title screen (「サンドニエット」brush art + SHUBATTO crawl +
|
- **M6 ✅** juice. Title scene (`scenes/title.ts`) — sumi brush `title_art` +
|
||||||
attract ragdoll), Web Audio (shamisen sting / slash whoosh / wet splat), food
|
「サンドニエット」+ pulsing **SHUBATTO!! SANDONIETTE!!!** + click-to-begin;
|
||||||
faces on pieces, string unlockables (rubber/chain/silk — pre-authorized defer).
|
launched as an overlay over the live stage so the harness is up from frame one
|
||||||
- Run: `npm run dev` (:5173). Harness: `__s.playDay(n) __s.playAllDays()
|
(`__s.startGame()` dismisses it). `game/audio.ts` — 100% synth Web Audio SFX
|
||||||
__s.playCleanRun() __s.playDisasterRun()` + all M1–M3 verbs. Headless: `npm run check`.
|
(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,
|
settle,
|
||||||
pose: () => p().pose(),
|
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. */
|
/** Set the control bar to an absolute position and step one frame. */
|
||||||
bar(x: number, y = p().barHomeY) {
|
bar(x: number, y = p().barHomeY) {
|
||||||
stage.auto = false;
|
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 Phaser from 'phaser';
|
||||||
import { Stage, STAGE_W, STAGE_H } from './scenes/stage';
|
import { Stage, STAGE_W, STAGE_H } from './scenes/stage';
|
||||||
|
import { Title } from './scenes/title';
|
||||||
|
|
||||||
new Phaser.Game({
|
new Phaser.Game({
|
||||||
type: Phaser.AUTO,
|
type: Phaser.AUTO,
|
||||||
@ -11,5 +12,5 @@ new Phaser.Game({
|
|||||||
default: 'matter',
|
default: 'matter',
|
||||||
matter: { gravity: { x: 0, y: 1 }, autoUpdate: false, debug: false },
|
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 { Sando } from '../sim/stack';
|
||||||
import { Game } from '../game/game';
|
import { Game } from '../game/game';
|
||||||
import type { Verdict } from '../game/judging';
|
import type { Verdict } from '../game/judging';
|
||||||
|
import { sfx } from '../game/audio';
|
||||||
|
|
||||||
const PORTRAITS = ['samurai_calm', 'samurai_watch', 'samurai_annoyed', 'samurai_angry', 'samurai_fury'];
|
const PORTRAITS = ['samurai_calm', 'samurai_watch', 'samurai_annoyed', 'samurai_angry', 'samurai_fury'];
|
||||||
|
|
||||||
@ -48,7 +49,7 @@ export class Stage extends Phaser.Scene {
|
|||||||
|
|
||||||
preload(): void {
|
preload(): void {
|
||||||
const img = (k: string) => this.load.image(k, `assets/img/${k}.png`);
|
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 {
|
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.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.space = this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE);
|
||||||
|
this.input.once('pointerdown', () => sfx.enable()); // unblock audio on first click
|
||||||
installHarness(this);
|
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
|
/** 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.matter.body.setAngularVelocity(b, dir * 0.3);
|
||||||
}
|
}
|
||||||
this.slashFx();
|
this.slashFx();
|
||||||
this.gong();
|
sfx.slash();
|
||||||
|
sfx.gong();
|
||||||
}
|
}
|
||||||
|
|
||||||
private slashFx(): void {
|
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() });
|
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. */
|
/** The scorecard over the wreckage: grade, the four groups, the bark. */
|
||||||
showScorecard(v: Verdict): Phaser.GameObjects.Container {
|
showScorecard(v: Verdict): Phaser.GameObjects.Container {
|
||||||
this.scorecard?.destroy();
|
this.scorecard?.destroy();
|
||||||
@ -200,6 +189,7 @@ export class Stage extends Phaser.Scene {
|
|||||||
splat(x: number, y: number, r: number, color: number): void {
|
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);
|
const blob = this.add.ellipse(x, y, r * 2.2, r * 1.3, color, 0.85);
|
||||||
blob.setDepth(-1);
|
blob.setDepth(-1);
|
||||||
|
sfx.splat();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Destroy a body's silhouette and remove it from the world. */
|
/** 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