Build-out 5: live barks, anger-reactive music, real wobble, ambient weather

- 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 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-21 03:09:03 +10:00
parent d784182d08
commit 21dedad96a
6 changed files with 124 additions and 12 deletions

View File

@ -85,6 +85,26 @@
Attract ragdoll → the pulsing title. Verified: title screenshot, click path, Attract ragdoll → the pulsing title. Verified: title screenshot, click path,
production `npm run build` clean. 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) ## BUILD-OUT PASS 4 ✅ — 裏 THE URA WEEK (the Shogun)
- **Second week**: beat KANSEI and *the Shogun arrives*`URA_DAYS` (8 harder - **Second week**: beat KANSEI and *the Shogun arrives*`URA_DAYS` (8 harder
orders), judged by a new character via the same-seed portrait trick orders), judged by a new character via the same-seed portrait trick

View File

@ -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. * seeded LCG the tune wanders but never uses Math.random. Quiet on purpose.
*/ */
let musicTimer: number | null = null; let musicTimer: number | null = null;
let tension = 0; // 0..1, follows the anger meter
export const music = { 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 { start(): void {
if (musicTimer !== null || !ac()) return; if (musicTimer !== null || !ac()) return;
const scale = [147, 175, 196, 220, 262, 294, 349]; // D minor pentatonic-ish const scale = [147, 175, 196, 220, 262, 294, 349]; // D minor pentatonic-ish
let s = 42, step = 0; let s = 42, step = 0;
const rnd = () => { const tick = (): void => {
s = (s * 1103515245 + 12345) & 0x7fffffff;
return s / 0x7fffffff;
};
musicTimer = window.setInterval(() => {
step++; step++;
if (rnd() < 0.72) { if (rnd() < 0.72) {
const f = scale[Math.floor(rnd() * scale.length)]; const f = scale[Math.floor(rnd() * scale.length)];
tone(f, 0.9, 'triangle', 0.045, f * 0.985); // soft pluck with a tiny droop 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 const beat = tension > 0.6 ? 4 : 8; // the heartbeat quickens
}, 620); 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 { stop(): void {
if (musicTimer !== null) { if (musicTimer !== null) {
clearInterval(musicTimer); clearTimeout(musicTimer);
musicTimer = null; musicTimer = null;
} }
}, },

View File

@ -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: '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: '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: '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: '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 }, { 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) { if (over > 0 && Math.floor(over / LATE_TICK) > this.lateBumped) {
this.lateBumped = Math.floor(over / LATE_TICK); this.lateBumped = Math.floor(over / LATE_TICK);
this.samurai.bump('servedLate'); this.samurai.bump('servedLate');
this.stage.bark('SERVICE', `late${this.lateBumped}`);
} }
const ready = this.matchesOrder() && this.stage.sando.stats().standing; const ready = this.matchesOrder() && this.stage.sando.stats().standing;
if (ready && barStill) { if (ready && barStill) {
@ -140,19 +141,23 @@ export class Game {
this.lastVerdict = null; this.lastVerdict = null;
} }
// ---- kitchen sins → anger ---- // ---- kitchen sins → anger (each one earns a live bark) ----
drop(): void { drop(): void {
this.drops++; this.drops++;
this.samurai.bump('drop'); this.samurai.bump('drop');
this.stage.bark('FLOOR', `drop${this.drops}`);
} }
splat(): void { splat(): void {
this.samurai.bump('splat'); this.samurai.bump('splat');
this.stage.bark('FLOOR', `splat${this.samurai.anger}`);
} }
crimeCut(): void { crimeCut(): void {
this.samurai.bump('crimeCut'); this.samurai.bump('crimeCut');
this.stage.bark('CUT', `crime${this.samurai.anger}`);
} }
offstage(): void { offstage(): void {
this.samurai.bump('offstage'); this.samurai.bump('offstage');
this.stage.bark('FLOOR', `off${this.samurai.anger}`);
} }
private buildRun(bowHeld: boolean, seconds: number): RunResult { private buildRun(bowHeld: boolean, seconds: number): RunResult {

View File

@ -5,7 +5,8 @@ import { Cutter } from '../sim/cutting';
import { spawnFood } from '../sim/food'; import { spawnFood } from '../sim/food';
import { Sando, spawnLayer } from '../sim/stack'; import { Sando, spawnLayer } from '../sim/stack';
import { Game } from '../game/game'; 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 { sfx, music } from '../game/audio';
import { loadProgress, unlockUra } from '../game/progress'; import { loadProgress, unlockUra } from '../game/progress';
@ -54,6 +55,9 @@ export class Stage extends Phaser.Scene {
private sweep = 0; private sweep = 0;
private touchGrip = false; private touchGrip = false;
private kansei = false; // completion card is up private kansei = false; // completion card is up
private barkText?: Phaser.GameObjects.Text;
private barkSub?: Phaser.GameObjects.Text;
private wobbleParts = new Map<MatterJS.BodyType, { bodies: MatterJS.BodyType[]; cons: MatterJS.ConstraintType[] }>();
constructor() { constructor() {
super('stage'); super('stage');
@ -138,6 +142,36 @@ export class Stage extends Phaser.Scene {
.setOrigin(0.5).setDepth(6).setVisible(false); .setOrigin(0.5).setDepth(6).setVisible(false);
this.advanceHint = this.add.text(STAGE_W / 2, 640, '', { fontFamily: 'serif', fontSize: '18px', color: '#e8b84a' }) this.advanceHint = this.add.text(STAGE_W / 2, 640, '', { fontFamily: 'serif', fontSize: '18px', color: '#e8b84a' })
.setOrigin(0.5).setDepth(31).setVisible(false); .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(); this.setupDay();
@ -340,6 +374,17 @@ export class Stage extends Phaser.Scene {
g.strokePath(); 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<typeof barkFor>[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). */ /** Refresh the order ticket from the current day (timer line is live). */
refreshOrder(): void { refreshOrder(): void {
const o = this.session.order; 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); 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); 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. */ /** 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 }); 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 { removeProp(body: MatterJS.BodyType): void {
this.props = this.props.filter((p) => { this.props = this.props.filter((p) => {
if (p.body !== body) return true; if (p.body !== body) return true;
p.view.destroy(); p.view.destroy();
return false; 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); this.matter.world.remove(body);
} }

View File

@ -41,6 +41,7 @@ export const FOODS: Record<string, FoodDef> = {
// until a day needs it to be funny. Tofu cuts easy but splats if pressed. // 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' }, tofu: { verts: box(64, 46), material: { resistance: 0.12, moisture: 0.6, wobble: 0.4 }, color: 0x1a1a18, face: 'face_egg' },
// --- the ura pantry --- // --- 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' }, 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' }, 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' }, 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 c = centroid(local);
const rel = local.map((v) => ({ x: v.x - c.x, y: v.y - c.y })); const rel = local.map((v) => ({ x: v.x - c.x, y: v.y - c.y }));
stage.addPoly(body, rel, color); 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 // a comical face — whole foods only (pieces are pieces). `p`-suffixed kinds
// are cut pieces; don't give a severed half a startled face. // are cut pieces; don't give a severed half a startled face.
if (!kind.endsWith('p')) { if (!kind.endsWith('p')) {

View File

@ -32,6 +32,7 @@ export const LAYERS: Record<string, LayerDef> = {
nori: L('nori', 96, 7), nori: L('nori', 96, 7),
ebi: L('ebi', 72, 18), ebi: L('ebi', 72, 18),
daikon: L('daikon', 46, 56), daikon: L('daikon', 46, 56),
konnyaku: L('konnyaku', 62, 22),
}; };
export interface SandoStats { export interface SandoStats {