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:
parent
d784182d08
commit
21dedad96a
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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<MatterJS.BodyType, { bodies: MatterJS.BodyType[]; cons: MatterJS.ConstraintType[] }>();
|
||||
|
||||
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<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). */
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@ -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.
|
||||
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')) {
|
||||
|
||||
@ -32,6 +32,7 @@ export const LAYERS: Record<string, LayerDef> = {
|
||||
nori: L('nori', 96, 7),
|
||||
ebi: L('ebi', 72, 18),
|
||||
daikon: L('daikon', 46, 56),
|
||||
konnyaku: L('konnyaku', 62, 22),
|
||||
};
|
||||
|
||||
export interface SandoStats {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user