From 21dedad96a840f4ccc008319a39c3cd63165cb2c Mon Sep 17 00:00:00 2001 From: type-two Date: Tue, 21 Jul 2026 03:09:03 +1000 Subject: [PATCH] Build-out 5: live barks, anger-reactive music, real wobble, ambient weather MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Live barks: every sin earns a subtitled heckle at the foot of the stage, drawn from the existing line bank (seed-varied so repeats don't repeat). splat/crime -> CUT/FLOOR, late ticks -> SERVICE, offstage/drop -> FLOOR. - Anger-reactive music: setTension(anger) — tempo 620->380ms, heartbeat doubles past 0.6, plucks grow a sour minor-second shadow past half-drawn. - Real wobble (the last unused material param): jelly foods carry two hidden ghost masses (mask:0) on soft springs — pure slosh that honestly rocks the body and the stack above it. Cleaned up with the food in removeProp. New food konnyaku (wobble .85) replaces ura day 6: 'it will not stop moving'. Measured: same nudge -> cheese lean 1.1 deg, konnyaku 6.4 deg. - Ambient weather per hour: sun-dust motes / falling petals / fireflies from one generated 5px dot. - Battery identical; ura AASSASSS with the konnyaku day beatable. Verified in-browser: bark text fires ('Mottainai. / What a waste.'), wobble measured. Co-Authored-By: Claude Fable 5 --- OPUS-BUILD-BRIEF.md | 20 ++++++++++++ src/game/audio.ts | 27 +++++++++++----- src/game/game.ts | 9 ++++-- src/scenes/stage.ts | 75 +++++++++++++++++++++++++++++++++++++++++++-- src/sim/food.ts | 4 +++ src/sim/stack.ts | 1 + 6 files changed, 124 insertions(+), 12 deletions(-) diff --git a/OPUS-BUILD-BRIEF.md b/OPUS-BUILD-BRIEF.md index 56f9a2a..612e718 100644 --- a/OPUS-BUILD-BRIEF.md +++ b/OPUS-BUILD-BRIEF.md @@ -85,6 +85,26 @@ Attract ragdoll → the pulsing title. Verified: title screenshot, click path, production `npm run build` clean. +## BUILD-OUT PASS 5 ✅ — the judge speaks, the band worries, the jelly wobbles +- **Live barks**: every sin now earns a subtitled heckle at the foot of the stage + (`Stage.bark(group, seed)` → the existing line bank; seed varies the pick so + repeat sins don't repeat the insult). Wired: splat/crime→CUT-or-FLOOR lines, + late ticks→SERVICE, offstage/drop→FLOOR. Verified live: squash → "Mottainai. / + What a waste." +- **Anger-reactive music**: `music.setTension(anger)` (fed from updateHUD) — + tempo 620→380ms, heartbeat doubles past 0.6, plucks grow a sour minor-second + shadow past half-drawn. setTimeout-rescheduled so tempo shifts take hold. +- **Real wobble** (the last unused material param): jelly foods get two hidden + ghost masses (`mask:0`) on soft springs — pure slosh that honestly rocks the + body. `stage.addWobble` via makeFood when wobble>0.2; wobble parts cleaned up + in removeProp. New food **konnyaku** (wobble .85) replaces ura day 6 (Tamago + Ebi was the weakest concept): "it will not stop moving". Measured: same 2.2 + nudge → cheese sando lean 1.1°, konnyaku sando 6.4° — 6× the rock, honest. +- **Ambient weather** per hour (5px generated dot): sun-dust motes at noon, + falling petals at dusk, blinking fireflies at night. (Particles animate on + rAF — invisible in the throttled preview pane, alive in real tabs.) +- Battery: everything identical + ura now AASSASSS with konnyaku day beatable. + ## BUILD-OUT PASS 4 ✅ — 裏 THE URA WEEK (the Shogun) - **Second week**: beat KANSEI and *the Shogun arrives* — `URA_DAYS` (8 harder orders), judged by a new character via the same-seed portrait trick diff --git a/src/game/audio.ts b/src/game/audio.ts index 3b00bde..daac655 100644 --- a/src/game/audio.ts +++ b/src/game/audio.ts @@ -70,27 +70,38 @@ function burst(dur: number, gain: number, filterHz: number, sweepTo?: number): v * seeded LCG — the tune wanders but never uses Math.random. Quiet on purpose. */ let musicTimer: number | null = null; +let tension = 0; // 0..1, follows the anger meter export const music = { + /** The band watches the katana: tempo and dissonance follow the meter. */ + setTension(t: number): void { + tension = t < 0 ? 0 : t > 1 ? 1 : t; + }, 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(() => { + const tick = (): void => { 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 + // past half-drawn, the plucks pick up a sour minor-second shadow + if (tension > 0.5 && rnd() < tension) tone(f * 1.06, 0.5, 'triangle', 0.03, f); } - if (step % 8 === 0) tone(73, 1.2, 'sine', 0.07); // the heartbeat - }, 620); + const beat = tension > 0.6 ? 4 : 8; // the heartbeat quickens + if (step % beat === 0) tone(73, 1.2, 'sine', 0.07 + tension * 0.05); + // reschedule at the current tempo — angrier is faster + musicTimer = window.setTimeout(tick, 620 - tension * 240); + }; + const rnd = () => { + s = (s * 1103515245 + 12345) & 0x7fffffff; + return s / 0x7fffffff; + }; + musicTimer = window.setTimeout(tick, 620); }, stop(): void { if (musicTimer !== null) { - clearInterval(musicTimer); + clearTimeout(musicTimer); musicTimer = null; } }, diff --git a/src/game/game.ts b/src/game/game.ts index 84b0dd3..5717b63 100644 --- a/src/game/game.ts +++ b/src/game/game.ts @@ -31,7 +31,7 @@ export const URA_DAYS: Day[] = [ { order: { name: 'Triple Tomato', layers: ['bread', 'tomato', 'tomato', 'tomato', 'bread'], needsCut: true, maxSeconds: 40 }, pressure: 'wetter than is wise', angerRamp: 1.4 }, { order: { name: 'Daikon Pillar', layers: ['bread', 'daikon', 'bread'], needsCut: true, maxSeconds: 40 }, pressure: 'it is TALL', angerRamp: 1.5 }, { order: { name: 'Ura Rush', layers: ['bread', 'nori', 'tomato', 'bread'], needsCut: true, maxSeconds: 18 }, pressure: 'the Shogun does not wait', angerRamp: 2 }, - { order: { name: 'Tamago Ebi', layers: ['bread', 'egg', 'ebi', 'bread'], needsCut: true, maxSeconds: 42 }, pressure: 'two round things', angerRamp: 1.5 }, + { order: { name: 'Konnyaku', layers: ['bread', 'konnyaku', 'bread'], needsCut: true, maxSeconds: 42 }, pressure: 'it will not stop moving', angerRamp: 1.5 }, { order: { name: 'Tofu on the Pillar', layers: ['bread', 'tofu', 'daikon', 'bread'], needsCut: true, maxSeconds: 45 }, pressure: 'fragile, and TALL', angerRamp: 1.6 }, { order: { name: "THE SHOGUN'S TOWER", layers: ['bread', 'katsu', 'ebi', 'cheese', 'tomato', 'nori', 'bread'], needsCut: true, maxSeconds: 80 }, pressure: 'seven layers. no mercy.', angerRamp: 1.6 }, ]; @@ -83,6 +83,7 @@ export class Game { if (over > 0 && Math.floor(over / LATE_TICK) > this.lateBumped) { this.lateBumped = Math.floor(over / LATE_TICK); this.samurai.bump('servedLate'); + this.stage.bark('SERVICE', `late${this.lateBumped}`); } const ready = this.matchesOrder() && this.stage.sando.stats().standing; if (ready && barStill) { @@ -140,19 +141,23 @@ export class Game { this.lastVerdict = null; } - // ---- kitchen sins → anger ---- + // ---- kitchen sins → anger (each one earns a live bark) ---- drop(): void { this.drops++; this.samurai.bump('drop'); + this.stage.bark('FLOOR', `drop${this.drops}`); } splat(): void { this.samurai.bump('splat'); + this.stage.bark('FLOOR', `splat${this.samurai.anger}`); } crimeCut(): void { this.samurai.bump('crimeCut'); + this.stage.bark('CUT', `crime${this.samurai.anger}`); } offstage(): void { this.samurai.bump('offstage'); + this.stage.bark('FLOOR', `off${this.samurai.anger}`); } private buildRun(bowHeld: boolean, seconds: number): RunResult { diff --git a/src/scenes/stage.ts b/src/scenes/stage.ts index aface4b..72c1ccb 100644 --- a/src/scenes/stage.ts +++ b/src/scenes/stage.ts @@ -5,7 +5,8 @@ import { Cutter } from '../sim/cutting'; import { spawnFood } from '../sim/food'; import { Sando, spawnLayer } from '../sim/stack'; import { Game } from '../game/game'; -import type { Verdict } from '../game/judging'; +import type { Group, Verdict } from '../game/judging'; +import { barkFor } from '../game/lines'; import { sfx, music } from '../game/audio'; import { loadProgress, unlockUra } from '../game/progress'; @@ -54,6 +55,9 @@ export class Stage extends Phaser.Scene { private sweep = 0; private touchGrip = false; private kansei = false; // completion card is up + private barkText?: Phaser.GameObjects.Text; + private barkSub?: Phaser.GameObjects.Text; + private wobbleParts = new Map(); constructor() { super('stage'); @@ -138,6 +142,36 @@ export class Stage extends Phaser.Scene { .setOrigin(0.5).setDepth(6).setVisible(false); this.advanceHint = this.add.text(STAGE_W / 2, 640, '', { fontFamily: 'serif', fontSize: '18px', color: '#e8b84a' }) .setOrigin(0.5).setDepth(31).setVisible(false); + // the judge's live voice — subtitled barks at the foot of the stage + this.barkText = this.add.text(STAGE_W / 2, 672, '', { fontFamily: 'serif', fontSize: '19px', color: '#e8b84a' }) + .setOrigin(0.5).setDepth(8).setAlpha(0); + this.barkSub = this.add.text(STAGE_W / 2, 696, '', { fontFamily: 'serif', fontSize: '13px', color: '#9a8a6a', fontStyle: 'italic' }) + .setOrigin(0.5).setDepth(8).setAlpha(0); + + // ambient weather for the hour: dust motes at noon, petals at dusk, + // fireflies by night. A 5px generated dot is the whole particle budget. + if (!this.textures.exists('mote')) { + const g = this.make.graphics({ x: 0, y: 0 }, false); + g.fillStyle(0xffffff, 1).fillCircle(3, 3, 3); + g.generateTexture('mote', 6, 6); + g.destroy(); + } + const weather = bgKey === 'bg_washi_night' + ? { tint: 0xcfe8a0, alpha: { start: 0, end: 0.8, ease: 'Sine.inOut' }, speedY: { min: -8, max: 8 }, lifespan: 4200, scale: 0.7, freq: 700 } // fireflies + : bgKey === 'bg_washi_dusk' + ? { tint: 0x8a2f22, alpha: { start: 0.7, end: 0 }, speedY: { min: 18, max: 42 }, lifespan: 6000, scale: 0.9, freq: 420 } // falling petals + : { tint: 0xf4e0b0, alpha: { start: 0.35, end: 0 }, speedY: { min: -14, max: -4 }, lifespan: 7000, scale: 0.5, freq: 520 }; // sun-dust + this.add.particles(0, 0, 'mote', { + x: { min: 60, max: STAGE_W - 60 }, + y: { min: 60, max: FLOOR_Y - 60 }, + speedX: { min: -6, max: 6 }, + speedY: weather.speedY, + lifespan: weather.lifespan, + alpha: weather.alpha, + scale: weather.scale, + tint: weather.tint, + frequency: weather.freq, + }).setDepth(-5); this.setupDay(); @@ -340,6 +374,17 @@ export class Stage extends Phaser.Scene { g.strokePath(); } + /** The judge speaks mid-shift: a bilingual bark keyed to the sin's group. + * `seed` varies the line pick so repeat sins don't repeat the insult. */ + bark(group: Group, seed: string): void { + const line = barkFor({ group, detail: seed } as Parameters[0], false); + if (!this.barkText || !this.barkSub) return; + this.barkText.setText(line.jp).setAlpha(1); + this.barkSub.setText(line.en).setAlpha(1); + this.tweens.killTweensOf([this.barkText, this.barkSub]); + this.tweens.add({ targets: [this.barkText, this.barkSub], alpha: 0, delay: 2300, duration: 500 }); + } + /** Refresh the order ticket from the current day (timer line is live). */ refreshOrder(): void { const o = this.session.order; @@ -404,6 +449,7 @@ export class Stage extends Phaser.Scene { bar.fillStyle(sam.anger < 0.5 ? 0xc8a24a : 0x9a2020, 1).fillRect(STAGE_W - 268, 42, 220 * sam.anger, 14); } this.vignette?.setFillStyle(0x1a0000, sam.vignette * 0.42); + music.setTension(sam.anger); // the band watches the katana } /** Register a body with a flat rectangular silhouette that tracks it. */ @@ -448,13 +494,38 @@ export class Stage extends Phaser.Scene { this.props.push({ body, view: c }); } - /** Destroy every view tracking a body (silhouette + any face) and remove it. */ + /** Two hidden sloshing masses on soft springs inside a jelly food — they + * collide with nothing, but their inertia genuinely rocks the body. */ + addWobble(body: MatterJS.BodyType, wobble: number): void { + const m = this.matter; + const bodies: MatterJS.BodyType[] = []; + const cons: MatterJS.ConstraintType[] = []; + for (const side of [-1, 1]) { + const sat = m.add.circle(body.position.x + side * 12, body.position.y, 7, { + density: 0.0016 * wobble, + frictionAir: 0.004, + collisionFilter: { mask: 0 }, // ghost mass — pure slosh + }); + cons.push(m.add.constraint(body, sat, 0, 0.018, { pointA: { x: side * 12, y: 0 }, damping: 0.015 })); + bodies.push(sat); + } + this.wobbleParts.set(body, { bodies, cons }); + } + + /** Destroy every view tracking a body (silhouette + any face) and remove it, + * plus any hidden wobble masses riding inside it. */ removeProp(body: MatterJS.BodyType): void { this.props = this.props.filter((p) => { if (p.body !== body) return true; p.view.destroy(); return false; }); + const w = this.wobbleParts.get(body); + if (w) { + for (const c of w.cons) this.matter.world.remove(c); + for (const b of w.bodies) this.matter.world.remove(b); + this.wobbleParts.delete(body); + } this.matter.world.remove(body); } diff --git a/src/sim/food.ts b/src/sim/food.ts index ad9edff..ac016d4 100644 --- a/src/sim/food.ts +++ b/src/sim/food.ts @@ -41,6 +41,7 @@ export const FOODS: Record = { // until a day needs it to be funny. Tofu cuts easy but splats if pressed. tofu: { verts: box(64, 46), material: { resistance: 0.12, moisture: 0.6, wobble: 0.4 }, color: 0x1a1a18, face: 'face_egg' }, // --- the ura pantry --- + konnyaku: { verts: box(62, 40), material: { resistance: 0.1, moisture: 0.3, wobble: 0.85 }, color: 0x14161a, face: 'face_egg' }, nori: { verts: box(96, 8), material: { resistance: 0.06, moisture: 0.02, wobble: 0 }, color: 0x0d0f0c, face: 'face_bread' }, ebi: { verts: [{ x: -36, y: 2 }, { x: -22, y: -12 }, { x: 4, y: -15 }, { x: 27, y: -9 }, { x: 36, y: 3 }, { x: 18, y: 14 }, { x: -12, y: 15 }], material: { resistance: 0.4, moisture: 0.3, wobble: 0.05 }, color: 0x161210, face: 'face_katsu' }, daikon: { verts: box(46, 64), material: { resistance: 0.3, moisture: 0.5, wobble: 0 }, color: 0x181816, face: 'face_egg' }, @@ -72,6 +73,9 @@ export function makeFood(stage: Stage, local: V[], x: number, y: number, mat: Ma const c = centroid(local); const rel = local.map((v) => ({ x: v.x - c.x, y: v.y - c.y })); stage.addPoly(body, rel, color); + // jelly-class food gets hidden sloshing masses — honest wobble that really + // rocks the body (and really destabilises a stack built on it). + if (mat.wobble > 0.2) stage.addWobble(body, mat.wobble); // a comical face — whole foods only (pieces are pieces). `p`-suffixed kinds // are cut pieces; don't give a severed half a startled face. if (!kind.endsWith('p')) { diff --git a/src/sim/stack.ts b/src/sim/stack.ts index 0b3f925..b3bf7ef 100644 --- a/src/sim/stack.ts +++ b/src/sim/stack.ts @@ -32,6 +32,7 @@ export const LAYERS: Record = { nori: L('nori', 96, 7), ebi: L('ebi', 72, 18), daikon: L('daikon', 46, 56), + konnyaku: L('konnyaku', 62, 22), }; export interface SandoStats {