From 077e970b165fd3c941f1c665e8baf9a1bf7608d1 Mon Sep 17 00:00:00 2001 From: type-two Date: Fri, 17 Jul 2026 21:19:12 +1000 Subject: [PATCH] =?UTF-8?q?M6:=20juice=20=E2=80=94=20title=20screen,=20Web?= =?UTF-8?q?=20Audio=20SFX=20(all=206=20milestones=20ship)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- OPUS-BUILD-BRIEF.md | 25 ++++++++++--- src/dev.ts | 3 ++ src/game/audio.ts | 91 +++++++++++++++++++++++++++++++++++++++++++++ src/main.ts | 3 +- src/scenes/stage.ts | 28 +++++--------- src/scenes/title.ts | 43 +++++++++++++++++++++ 6 files changed, 168 insertions(+), 25 deletions(-) create mode 100644 src/game/audio.ts create mode 100644 src/scenes/title.ts diff --git a/OPUS-BUILD-BRIEF.md b/OPUS-BUILD-BRIEF.md index fa953b1..e9de0d0 100644 --- a/OPUS-BUILD-BRIEF.md +++ b/OPUS-BUILD-BRIEF.md @@ -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. diff --git a/src/dev.ts b/src/dev.ts index c22b558..3a7010e 100644 --- a/src/dev.ts +++ b/src/dev.ts @@ -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; diff --git a/src/game/audio.ts b/src/game/audio.ts new file mode 100644 index 0000000..499937f --- /dev/null +++ b/src/game/audio.ts @@ -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); + }, +}; diff --git a/src/main.ts b/src/main.ts index 495e079..7d39ccb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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], }); diff --git a/src/scenes/stage.ts b/src/scenes/stage.ts index 431c5ce..ac35d5d 100644 --- a/src/scenes/stage.ts +++ b/src/scenes/stage.ts @@ -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. */ diff --git a/src/scenes/title.ts b/src/scenes/title.ts new file mode 100644 index 0000000..d0066d8 --- /dev/null +++ b/src/scenes/title.ts @@ -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(); + }); + } +}