diff --git a/.claude/launch.json b/.claude/launch.json index 8b30f3b..e8f8d41 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -5,7 +5,8 @@ "name": "dev", "runtimeExecutable": "npm", "runtimeArgs": ["run", "dev"], - "port": 5173 + "port": 5173, + "autoPort": true } ] } diff --git a/OPUS-BUILD-BRIEF.md b/OPUS-BUILD-BRIEF.md index 61b5ba5..155c64d 100644 --- a/OPUS-BUILD-BRIEF.md +++ b/OPUS-BUILD-BRIEF.md @@ -85,6 +85,36 @@ Attract ragdoll → the pulsing title. Verified: title screenshot, click path, production `npm run build` clean. +## BUILD-OUT PASS 2 ✅ — the playable loop, string unlockables, 8 days +- **Physical stacking** — `Sando` is now zone-DETECTION: the stack = whatever food + bodies rest in the plate zone (±82px, below-flight speed), sorted bottom→top. + A human stacking by marionette hand and the harness are judged identically. + `sando.kinds()` (strips piece `p` suffixes) is what the judge reads. +- **The full live loop** — `Game.state` (playing/served/dead) + `updateLive()`: + ticket clock with late-anger ramp (1 bump / 3s overdue), the **bow-dwell serve** + (hold the bar still 0.5s over a matching standing sando → scorecard), click → + next day (`registry sando.day` → `scene.restart`), string cut → ragdoll → + fail scorecard → click retries the SAME day with fresh strings. Day setup + (`Stage.setupDay`) stages bread slices + one whole food per non-bread kind on + the board. Live sins wired off `cutter.onCut` (squash→splat, botch→crimeCut) + + offstage sweep. (ponytail: no floor-drop sin yet — add when a day needs it.) +- **String unlockables** — title-screen picker: HEMP/RUBBER/CHAIN/SILK + (`STRING_STYLES` k/d multipliers on the same rig; registry `sando.style`). + Rubber visibly stretches — verified. +- **Days 6–8**: Tamago (egg rolls), Tofu (fragile), THE TOWER (5 layers). All 8 + days S via harness. Live serve: dwell commits at **31 frames ≈ 0.5s**, grade B + (no cut performed — honest), day 8 same; death→retry + serve→advance both + close the loop (verified by simulated clicks + manual queue flush). +- **samurai_bow.png** (same seed 320) wired: an S-grade serve swaps the portrait + to the single approving nod. counter_wood/day_stamp/face_cut_bread banked in + `public/assets/img/`, unwired. +- **Embedded-pane trap (KNOWN, not a bug)**: the preview pane reports + `document.visibilityState='hidden'` → rAF never fires → live loop + queued + scene ops freeze; harness drives frames manually, and + `game.scene.processQueue()` flushes ops by hand. Real visible tabs unaffected. +- Full battery green: M1 87f/8.41u/10-10 · M2 cv0-clean/splat/wild · M3 ✓✓ · + days SSSSSSSS · live served@31f / d8 served / dead CUT · cleaver CUTS. + ## POLISH PASS (post-M6) ✅ — "make it even better" - **Visible strings + control bar** — the biggest missed feel. `Marionette.stringSegments()` / `barSegment()`; `Stage.drawStrings()` redraws the 6 strings + the wooden bar each diff --git a/public/assets/img/counter_wood.png b/public/assets/img/counter_wood.png new file mode 100644 index 0000000..9851823 Binary files /dev/null and b/public/assets/img/counter_wood.png differ diff --git a/public/assets/img/day_stamp.png b/public/assets/img/day_stamp.png new file mode 100644 index 0000000..a410d8f Binary files /dev/null and b/public/assets/img/day_stamp.png differ diff --git a/public/assets/img/face_cut_bread.png b/public/assets/img/face_cut_bread.png new file mode 100644 index 0000000..1f843b1 Binary files /dev/null and b/public/assets/img/face_cut_bread.png differ diff --git a/public/assets/img/samurai_bow.png b/public/assets/img/samurai_bow.png new file mode 100644 index 0000000..df14550 Binary files /dev/null and b/public/assets/img/samurai_bow.png differ diff --git a/scripts/gen-assets.sh b/scripts/gen-assets.sh index 716fcc9..f7210e1 100755 --- a/scripts/gen-assets.sh +++ b/scripts/gen-assets.sh @@ -85,3 +85,9 @@ gen_2d face_egg 368 "a comical wide-eyed cartoon face for a boiled egg, s gen_2d splat_wet 370 "a single messy wet food splat splatter shape, flat black silhouette, on transparent background" 512 512 cut echo "done." + +# ---- build-out batch 2 (seeds 400+): serve counter, S-grade bow, cutout faces v2 ---- +gen_2d samurai_bow 320 "$SAMURAI, head bowed slightly in approval, eyes closed, serene" 768 900 cut +gen_2d counter_wood 402 "a low wooden serving counter table, edo period, side view, flat black silhouette on transparent background, hand-crafted paper cut-out style" 768 384 cut +gen_2d face_cut_bread 410 "simple face cut out of black paper: two round holes for eyes and a small worried oval mouth hole, white background, minimal, high contrast, paper-cut stencil" 512 512 cut +gen_2d day_stamp 420 "a round red japanese hanko seal stamp, blank center, worn ink edges, on transparent background" 512 512 cut diff --git a/src/dev.ts b/src/dev.ts index fc534d6..2bebfbe 100644 --- a/src/dev.ts +++ b/src/dev.ts @@ -258,12 +258,39 @@ export function installHarness(stage: Stage): void { return { built, quaked, survived: quaked.standing }; }, - /** M3 exit-bar: a leaning stack stands marginally, then the quake topples it. */ + /** M3 exit-bar: a leaning stack stands marginally, then the quake topples it. + * Toppled = it fell OR it shed a layer off the plate — both are collapse. */ stackBad() { const built = this.buildSando([['bread', 0], ['tomato', 20], ['bread', 40]]); const stoodBefore = built.standing; const quaked = this.nudge(3); - return { built, quaked, stoodBefore, toppledByQuake: stoodBefore && !quaked.standing }; + return { built, quaked, stoodBefore, toppledByQuake: stoodBefore && (!quaked.standing || quaked.layers < built.layers) }; + }, + + /** Live serve flow: set up the day for real, materialise the ordered stack on + * the plate, then hold the bar still — the dwell must commit the serve and + * put up the scorecard, exactly as a player bowing would. */ + playServeFlow(n = 0) { + stage.auto = false; + stage.clearScorecard(); + stage.session.reset(); + stage.session.setDay(n); + stage.setupDay(); + stage.auto = false; // setupDay re-arms live play; keep the harness driving + this.buildSando(stage.session.order.layers.map((k) => [k, 0] as [string, number])); + let frames = 0; + while (stage.session.state === 'playing' && frames < 180) { + stage.stepSim(); + stage.session.updateLive(1 / 60, true); + frames++; + } + return { + day: n + 1, + state: stage.session.state, + framesToServe: frames, + grade: stage.session.lastVerdict?.grade ?? null, + matches: stage.session.matchesOrder(), + }; }, // ---- M4: the samurai + the full loop ----------------------------------- diff --git a/src/game/game.ts b/src/game/game.ts index a938f49..9eadc8a 100644 --- a/src/game/game.ts +++ b/src/game/game.ts @@ -17,6 +17,9 @@ export const DAYS: Day[] = [ { order: { name: 'Double Tomato', layers: ['bread', 'tomato', 'tomato', 'bread'], needsCut: true, maxSeconds: 45 }, pressure: 'wetter — twice the tomato', angerRamp: 1 }, { order: { name: 'Katsu Sando', layers: ['bread', 'katsu', 'tomato', 'bread'], needsCut: true, maxSeconds: 45 }, pressure: 'a real cut — the katsu', angerRamp: 1 }, { order: { name: 'Rush Hour', layers: ['bread', 'tomato', 'bread'], needsCut: true, maxSeconds: 22 }, pressure: 'the patience ramp', angerRamp: 1.8 }, + { order: { name: 'Tamago Sando', layers: ['bread', 'egg', 'bread'], needsCut: true, maxSeconds: 45 }, pressure: 'it rolls', angerRamp: 1 }, + { order: { name: 'Tofu, Gently', layers: ['bread', 'tofu', 'bread'], needsCut: true, maxSeconds: 45 }, pressure: 'fragile — pressing is death', angerRamp: 1.2 }, + { order: { name: 'THE TOWER', layers: ['bread', 'katsu', 'cheese', 'tomato', 'bread'], needsCut: true, maxSeconds: 60 }, pressure: 'five layers', angerRamp: 1.3 }, ]; export const ORDERS: Order[] = DAYS.map((d) => d.order); @@ -26,15 +29,65 @@ export const ORDERS: Order[] = DAYS.map((d) => d.order); * and turns a finished attempt into a Verdict. Kitchen sins feed the anger * meter; the meter topping out fires the string cut (wired to the stage). */ +export type RunState = 'playing' | 'served' | 'dead'; + +const BOW_SECS = 0.5; // the dwell commit, straight from toastsim +const LATE_TICK = 3; // one servedLate bump per this many seconds overdue + export class Game { samurai = new Samurai(); day = 0; order: Order = DAYS[0].order; drops = 0; seconds = 0; // ticket timer (seconds) + state: RunState = 'playing'; + /** 0..1 bow-dwell progress while holding still over a correct sando. */ + dwell = 0; + lastVerdict: Verdict | null = null; + private lateBumped = 0; constructor(private stage: Stage) { - this.samurai.onStrike = () => this.stage.stringCut(); + this.samurai.onStrike = () => { + this.state = 'dead'; + this.stage.stringCut(); + }; + } + + /** Is the physical stack exactly the ordered layers? */ + matchesOrder(): boolean { + const kinds = this.stage.sando.kinds(); + return kinds.length === this.order.layers.length && kinds.every((k, i) => k === this.order.layers[i]); + } + + /** Live-play heartbeat: clock, overdue anger, and the bow-dwell commit. + * `barStill` = the player is holding the control bar still. */ + updateLive(dtSec: number, barStill: boolean): void { + if (this.state !== 'playing' || this.samurai.struck) return; + this.seconds += dtSec; + const over = this.seconds - this.order.maxSeconds; + if (over > 0 && Math.floor(over / LATE_TICK) > this.lateBumped) { + this.lateBumped = Math.floor(over / LATE_TICK); + this.samurai.bump('servedLate'); + } + const ready = this.matchesOrder() && this.stage.sando.stats().standing; + if (ready && barStill) { + this.dwell += dtSec / BOW_SECS; + if (this.dwell >= 1) { + this.state = 'served'; + this.lastVerdict = this.serve(true); + this.stage.showScorecard(this.lastVerdict); + } + } else { + this.dwell = Math.max(0, this.dwell - dtSec * 2); + } + } + + /** The string-cut post-mortem: judged with nothing served. */ + failVerdict(): Verdict { + const run = this.buildRun(false, this.seconds); + run.service.served = false; + run.service.correct = false; + return judge(this.order, run); } /** Load day `n` (clamped): its order + its samurai patience ramp. */ @@ -45,6 +98,10 @@ export class Game { this.samurai.ramp = d.angerRamp; this.drops = 0; this.seconds = 0; + this.state = 'playing'; + this.dwell = 0; + this.lateBumped = 0; + this.lastVerdict = null; return d; } @@ -53,10 +110,10 @@ export class Game { this.order = order; this.drops = 0; this.seconds = 0; - } - - tick(dtSec: number): void { - this.seconds += dtSec; + this.state = 'playing'; + this.dwell = 0; + this.lateBumped = 0; + this.lastVerdict = null; } // ---- kitchen sins → anger ---- @@ -77,8 +134,7 @@ export class Game { private buildRun(bowHeld: boolean, seconds: number): RunResult { const cut = this.stage.cutter.last; const s = this.stage.sando.stats(); - const kinds = this.stage.sando.layers.map((l) => l.kind.replace(/p+$/, '')); - const matches = kinds.length === this.order.layers.length && kinds.every((k, i) => k === this.order.layers[i]); + const matches = this.matchesOrder(); return { cut: cut ? { cv: cut.cv, quality: cut.quality, pieces: cut.pieces } : { cv: 1, quality: 'none', pieces: 0 }, sando: { layers: s.layers, leanDeg: s.leanDeg, comDrift: s.comDrift, standing: s.standing, matches }, diff --git a/src/scenes/stage.ts b/src/scenes/stage.ts index 9900ce5..e40c3fb 100644 --- a/src/scenes/stage.ts +++ b/src/scenes/stage.ts @@ -1,10 +1,10 @@ import Phaser from 'phaser'; import { installHarness } from '../dev'; -import { Marionette } from '../sim/marionette'; +import { Marionette, type StringStyle } from '../sim/marionette'; import { Cutter } from '../sim/cutting'; import { spawnFood } from '../sim/food'; -import { Sando } from '../sim/stack'; -import { Game } from '../game/game'; +import { Sando, spawnLayer } from '../sim/stack'; +import { Game, DAYS } from '../game/game'; import type { Verdict } from '../game/judging'; import { sfx } from '../game/audio'; @@ -43,8 +43,14 @@ export class Stage extends Phaser.Scene { private samuraiImg?: Phaser.GameObjects.Image; private angerBar?: Phaser.GameObjects.Graphics; private strings?: Phaser.GameObjects.Graphics; + private serveG?: Phaser.GameObjects.Graphics; + private serveHint?: Phaser.GameObjects.Text; + private advanceHint?: Phaser.GameObjects.Text; private vignette?: Phaser.GameObjects.Rectangle; private scorecard?: Phaser.GameObjects.Container; + private prevBar = { x: 0, y: 0 }; + private deadFrames = 0; + private sweep = 0; constructor() { super('stage'); @@ -55,7 +61,7 @@ export class Stage extends Phaser.Scene { // asset paths resolve regardless of a trailing slash on the URL. const base = import.meta.env.BASE_URL; const img = (k: string) => this.load.image(k, `${base}assets/img/${k}.png`); - ['bg_washi', 'title_art', ...PORTRAITS].forEach(img); + ['bg_washi', 'title_art', 'samurai_bow', ...PORTRAITS].forEach(img); } create(): void { @@ -72,18 +78,20 @@ export class Stage extends Phaser.Scene { this.matter.add.rectangle(-20, STAGE_H / 2, 40, STAGE_H * 2, { isStatic: true }); this.matter.add.rectangle(STAGE_W + 20, STAGE_H / 2, 40, STAGE_H * 2, { isStatic: true }); - this.puppet = new Marionette(this, 400); + // day + string style survive scene restarts (death retry / next day / title pick) + const day = (this.registry.get('sando.day') as number) ?? 0; + const style = (this.registry.get('sando.style') as StringStyle) ?? 'hemp'; + this.puppet = new Marionette(this, 400, style); // a crate on the floor for the M1 grip test — low mass so a hand can lift it. this.crate = this.matter.add.rectangle(470, FLOOR_Y - 16, 48, 48, { restitution: 0.1, friction: 0.8, density: 0.0006 }); this.addBody(this.crate, 48, 48, 0x3a2d20); this.puppet.gripTargets.push(this.crate); - // cutting board (a static counter) + a food resting on it for M2. + // cutting board (a static counter). Day setup below stocks it. this.board = this.matter.add.rectangle(760, 560, 240, 24, { isStatic: true }); this.addBody(this.board, 240, 24, 0x2a211a); this.cutter = new Cutter(this); - this.cutter.add(spawnFood(this, 'tomato', 760, 512)); // the cleaver — a grabbable blade. Held in a hand, its edge cuts on the swing. this.cleaver = this.matter.add.rectangle(632, FLOOR_Y - 42, 12, 78, { density: 0.0008, friction: 0.6 }); @@ -96,6 +104,13 @@ export class Stage extends Phaser.Scene { this.sando = new Sando(this, 980, 624); this.session = new Game(this); + this.session.setDay(day); + // kitchen sins fire live off the cutter: a squash is a splat, a botched + // pass is a crime cut. (ponytail: no floor-drop sin yet — splat/crime/ + // offstage/late are pressure enough; add drops when a day needs it.) + this.cutter.onCut = (r) => { + if (r.quality === 'crime') (r.pieces === 0 ? this.session.splat() : this.session.crimeCut()); + }; // HUD: the samurai judge (top-right), the anger meter, the unsheath vignette. if (this.textures.exists('samurai_calm')) { @@ -105,16 +120,56 @@ export class Stage extends Phaser.Scene { this.strings = this.add.graphics().setDepth(3); // strings + control bar, over the silhouettes this.vignette = this.add.rectangle(STAGE_W / 2, STAGE_H / 2, STAGE_W, STAGE_H, 0x1a0000, 0).setDepth(4); - // order ticket (top-left) + // order ticket (top-left) + serve prompt + advance hint this.orderText = this.add.text(28, 34, '', { fontFamily: 'monospace', fontSize: '16px', color: '#2a1a0e' }).setDepth(6); - this.refreshOrder(); + this.serveG = this.add.graphics().setDepth(6); + this.serveHint = this.add.text(980, 500, 'HOLD STILL — OJIGI', { fontFamily: 'serif', fontSize: '15px', color: '#e8b84a' }) + .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); + + this.setupDay(); this.space = this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE); this.input.once('pointerdown', () => sfx.enable()); // unblock audio on first click + // click advances past a scorecard: next day after a serve, retry after death. + this.input.on('pointerdown', () => { + if (this.session.state === 'served') { + this.registry.set('sando.day', (this.session.day + 1) % DAYS.length); + this.scene.restart(); + } else if (this.session.state === 'dead' && this.deadFrames > 70) { + this.scene.restart(); // same day, fresh strings + } + }); installHarness(this); - // title over the top; dismissed on click (the harness can skip it). - this.scene.launch('title'); + // title overlay on first boot only (day/style picks restart this scene). + if (!this.registry.get('sando.started')) this.scene.launch('title'); + } + + /** Stock the kitchen for the current order: bread slices staged on the board's + * left end, one whole food per non-bread kind spread along the board (they + * need cutting), puppet + cleaver parked at their homes. */ + setupDay(): void { + this.clearScorecard(); + this.advanceHint?.setVisible(false); + this.deadFrames = 0; + for (const f of [...this.cutter.foods]) this.removeProp(f.body); + this.cutter.foods = []; + this.cutter.splats = []; + this.cutter.last = null; + const o = this.session.order; + const breadN = o.layers.filter((k) => k === 'bread').length; + for (let i = 0; i < breadN; i++) this.cutter.add(spawnLayer(this, 'bread', 672, 530 - i * 30)); + const kinds = [...new Set(o.layers.filter((k) => k !== 'bread'))]; + kinds.forEach((k, i) => this.cutter.add(spawnFood(this, k, 780 + i * 70 - (kinds.length - 1) * 35, 500))); + this.puppet.reset(400); + const b = this.matter.body; + b.setPosition(this.cleaver, { x: 632, y: FLOOR_Y - 42 }, false); + b.setAngle(this.cleaver, 0, false); + b.setVelocity(this.cleaver, { x: 0, y: 0 }); + b.setAngularVelocity(this.cleaver, 0); + this.auto = true; } /** SHUBATTO — the string cut. Ragdoll the puppet, fling every loose food with @@ -190,10 +245,41 @@ export class Stage extends Phaser.Scene { g.strokePath(); } - /** Refresh the order ticket from the current day. */ + /** Refresh the order ticket from the current day (timer line is live). */ refreshOrder(): void { const o = this.session.order; - this.orderText?.setText(`DAY ${this.session.day + 1} ${o.name}\n${o.layers.join(' · ')}`); + const left = Math.max(0, Math.ceil(o.maxSeconds - this.session.seconds)); + const late = this.session.seconds > o.maxSeconds; + this.orderText?.setText( + `DAY ${this.session.day + 1} ${o.name}\n${o.layers.join(' · ')}\n⏱ ${left}s${late ? ' — OSOI. LATE.' : ''}`, + ); + } + + /** The bow: prompt + dwell arc over the plate when the stack matches the order. */ + private drawServe(): void { + const g = this.serveG; + if (!g) return; + g.clear(); + const ready = this.session.state === 'playing' && this.session.matchesOrder(); + this.serveHint?.setVisible(ready); + if (ready && this.session.dwell > 0) { + g.lineStyle(5, 0xe8b84a, 0.9); + g.beginPath(); + g.arc(980, 530, 20, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * Math.min(1, this.session.dwell)); + g.strokePath(); + } + } + + /** Bin anything that left the stage entirely (and anger the samurai). */ + private offstageSweep(): void { + for (const f of [...this.cutter.foods]) { + const b = f.body; + if (b.position.y > STAGE_H + 80 || b.position.x < -60 || b.position.x > STAGE_W + 60) { + this.removeProp(b); + this.cutter.foods = this.cutter.foods.filter((x) => x !== f); + this.session.offstage(); + } + } } /** World position of the cleaver's cutting edge (its lower tip). */ @@ -211,7 +297,10 @@ export class Stage extends Phaser.Scene { private updateHUD(): void { const sam = this.session.samurai; - if (this.samuraiImg && this.textures.exists(sam.portrait)) this.samuraiImg.setTexture(sam.portrait); + // an S earns the single approving nod — the bowing portrait over the scorecard. + const bowed = this.session.state === 'served' && this.session.lastVerdict?.grade === 'S'; + const key = bowed && this.textures.exists('samurai_bow') ? 'samurai_bow' : sam.portrait; + if (this.samuraiImg && this.textures.exists(key)) this.samuraiImg.setTexture(key); const bar = this.angerBar; if (bar) { bar.clear(); @@ -285,12 +374,31 @@ export class Stage extends Phaser.Scene { this.puppet.applyInput(p.worldX, p.worldY || this.puppet.barHomeY, p.leftButtonDown(), grip); this.stepSim(); this.driveCleaver(); // a gripped cleaver is the live blade — swinging cuts + // the bow dwell reads bar stillness, not pointer stillness — chain strings + // clamp the bar the same way the player's hand does. + const still = Math.hypot(this.puppet.barX - this.prevBar.x, this.puppet.barY - this.prevBar.y) < 1.6; + this.prevBar = { x: this.puppet.barX, y: this.puppet.barY }; + this.session.updateLive(1 / 60, still); + this.sweep = (this.sweep + 1) % 30; + if (this.sweep === 0) this.offstageSweep(); + // the string-cut post-mortem: let the ragdoll land, then the scorecard. + if (this.session.state === 'dead') { + this.deadFrames++; + if (this.deadFrames === 72) { + this.showScorecard(this.session.failVerdict()); + this.advanceHint?.setText('click — mō ichido. (again.)').setVisible(true); + } + } else if (this.session.state === 'served' && this.scorecard && !this.advanceHint?.visible) { + this.advanceHint?.setText('click — the next day').setVisible(true); + } } for (const pr of this.props) { pr.view.setPosition(pr.body.position.x, pr.body.position.y); pr.view.setRotation(pr.body.angle); } this.drawStrings(); + this.drawServe(); + this.refreshOrder(); this.updateHUD(); } } diff --git a/src/scenes/title.ts b/src/scenes/title.ts index d0066d8..87a6aee 100644 --- a/src/scenes/title.ts +++ b/src/scenes/title.ts @@ -1,10 +1,12 @@ import Phaser from 'phaser'; import { sfx } from '../game/audio'; +import { STRING_STYLES, type StringStyle } from '../sim/marionette'; /** * 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. + * available underneath from frame one. Pick your strings (difficulty modifier), + * then click to begin — the stage restarts with the chosen string style, and + * that first click also unblocks audio so the shamisen sting lands. */ export class Title extends Phaser.Scene { constructor() { @@ -16,27 +18,60 @@ export class Title extends Phaser.Scene { 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.image(W / 2, H * 0.34, 'title_art').setScale(0.56).setDepth(1); } - this.add.text(W / 2, H * 0.66, 'サンドニエット', { fontFamily: 'serif', fontSize: '56px', color: '#efe4cb' }) + this.add.text(W / 2, H * 0.6, 'サンドニエット', { fontFamily: 'serif', fontSize: '52px', 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' }) + this.add.text(W / 2, H * 0.66, 'S A N D O N I E T T E', { fontFamily: 'serif', fontSize: '18px', color: '#c8a24a' }) .setOrigin(0.5).setDepth(1); - const shout = this.add.text(W / 2, H * 0.85, 'SHUBATTO!! SANDONIETTE!!!', { + // ---- string select: the unlockables-as-difficulty row ---- + const styles = Object.keys(STRING_STYLES) as StringStyle[]; + const picked = ((this.registry.get('sando.style') as StringStyle) ?? 'hemp'); + const labels = new Map(); + const paint = (sel: StringStyle): void => { + for (const [k, t] of labels) { + t.setColor(k === sel ? '#e8b84a' : '#8a7a5a'); + t.setText(k === sel ? `▸ ${k.toUpperCase()} ◂` : k.toUpperCase()); + } + blurb.setText(STRING_STYLES[sel].blurb); + }; + styles.forEach((k, i) => { + const t = this.add.text(W / 2 + (i - (styles.length - 1) / 2) * 150, H * 0.76, k.toUpperCase(), { + fontFamily: 'monospace', fontSize: '18px', color: '#8a7a5a', + }).setOrigin(0.5).setDepth(2).setInteractive({ useHandCursor: true }); + t.on('pointerdown', (_p: unknown, __x: unknown, ___y: unknown, ev?: { stopPropagation?: () => void }) => { + ev?.stopPropagation?.(); + this.registry.set('sando.style', k); + sfx.enable(); + sfx.shamisen(); + paint(k); + }); + labels.set(k, t); + }); + const blurb = this.add.text(W / 2, H * 0.81, '', { fontFamily: 'serif', fontSize: '14px', color: '#9a8a6a', fontStyle: 'italic' }) + .setOrigin(0.5).setDepth(1); + paint(picked); + + const shout = this.add.text(W / 2, H * 0.89, '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' }) + const hint = this.add.text(W / 2, H * 0.95, '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', () => { + // begin: anywhere that isn't a style button. Restart the stage so the puppet + // is strung with the chosen style, then drop the overlay. + this.input.on('pointerdown', (_p: Phaser.Input.Pointer, over: unknown[]) => { + if (over.length > 0) return; // that click was a style pick sfx.enable(); sfx.gong(); sfx.shamisen(); + this.registry.set('sando.started', true); + this.scene.get('stage').scene.restart(); this.scene.stop(); }); } diff --git a/src/sim/cutting.ts b/src/sim/cutting.ts index a9b5955..a3bedf6 100644 --- a/src/sim/cutting.ts +++ b/src/sim/cutting.ts @@ -45,9 +45,16 @@ export class Cutter { foods: Food[] = []; splats: { mass: number }[] = []; last: CutResult | null = null; + /** Fired on every committed cut/squash — the stage wires kitchen sins here. */ + onCut?: (r: CutResult) => void; private tip: V | null = null; private track = new Map(); + private report(r: CutResult): void { + this.last = r; + this.onCut?.(r); + } + constructor(private stage: Stage) {} add(food: Food): void { @@ -100,7 +107,7 @@ export class Cutter { if (mat.moisture > 0.4 && power < CUT.squash) { this.splat(food, peakSpeed); } else { - this.last = { quality: 'crime', pieces: 1, cv: 0, speed: +peakSpeed.toFixed(2), chordFrac: +chordFrac.toFixed(2) }; + this.report({ quality: 'crime', pieces: 1, cv: 0, speed: +peakSpeed.toFixed(2), chordFrac: +chordFrac.toFixed(2) }); } return; } @@ -127,13 +134,13 @@ export class Cutter { }); const areas = parts.map(polyArea); - this.last = { + this.report({ quality, pieces: 2, cv: +pieceCV(areas).toFixed(3), speed: +peakSpeed.toFixed(2), chordFrac: +chordFrac.toFixed(2), - }; + }); } private splat(food: Food, peakSpeed: number): void { @@ -144,7 +151,7 @@ export class Cutter { this.stage.removeProp(food.body); this.foods = this.foods.filter((f) => f !== food); this.stage.splat(c.x, c.y, Math.sqrt(polyArea(verts) / Math.PI) * 1.3, food.color); - this.last = { quality: 'crime', pieces: 0, cv: 0, speed: +peakSpeed.toFixed(2), chordFrac: 0 }; + this.report({ quality: 'crime', pieces: 0, cv: 0, speed: +peakSpeed.toFixed(2), chordFrac: 0 }); // ponytail: slippery-floor patch deferred — no one carries over it until M4. } diff --git a/src/sim/food.ts b/src/sim/food.ts index e1ad2db..d0946b2 100644 --- a/src/sim/food.ts +++ b/src/sim/food.ts @@ -37,6 +37,9 @@ export const FOODS: Record = { cheese: { verts: box(60, 44), material: { resistance: 0.45, moisture: 0.15, wobble: 0.0 }, color: 0x191510, face: 'face_cheese' }, katsu: { verts: box(86, 46), material: { resistance: 0.55, moisture: 0.1, wobble: 0.0 }, color: 0x171008, face: 'face_katsu' }, egg: { verts: circle(28, 10, 1.15), material: { resistance: 0.3, moisture: 0.4, wobble: 0.1 }, color: 0x121212, face: 'face_egg' }, + // ponytail: wobble is a material param only — real jelly-cluster jiggle waits + // 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' }, }; let NEXT = 1; diff --git a/src/sim/marionette.ts b/src/sim/marionette.ts index e27a0c4..181bfb3 100644 --- a/src/sim/marionette.ts +++ b/src/sim/marionette.ts @@ -33,6 +33,16 @@ type Seg = | 'uArmL' | 'lArmL' | 'uArmR' | 'lArmR' | 'legL' | 'legR'; +/** Unlockable strings — difficulty modifiers, not new mechanics. Multipliers on + * string stiffness/damping only; the puppet itself never changes. */ +export type StringStyle = 'hemp' | 'rubber' | 'chain' | 'silk'; +export const STRING_STYLES: Record = { + hemp: { k: 1, d: 1, blurb: 'the honest string' }, + rubber: { k: 0.45, d: 0.5, blurb: 'bouncy. +lag +comedy' }, + chain: { k: 1.7, d: 2.4, blurb: 'heavy. slow. precise' }, + silk: { k: 1.35, d: 1.15, blurb: 'tighter, for cowards' }, +}; + type Vec = { x: number; y: number }; export interface Pose { @@ -59,7 +69,7 @@ export class Marionette { /** Bodies the hands can grab (crate, later food). Stage fills this. */ gripTargets: MatterJS.BodyType[] = []; - constructor(private stage: Stage, homeX = 400) { + constructor(private stage: Stage, homeX = 400, public style: StringStyle = 'hemp') { this.barX = homeX; this.prevBarX = homeX; const m = stage.matter; @@ -107,10 +117,11 @@ export class Marionette { * build and again after a string cut when the puppet is restrung. */ private buildStrings(): void { const m = this.stage.matter; + const s = STRING_STYLES[this.style]; const string = (b: MatterJS.BodyType, pb: Vec, length: number, k: number, d: number) => - m.add.worldConstraint(b, length, k, { + m.add.worldConstraint(b, length, Math.min(0.95, k * s.k), { pointA: { x: this.barX, y: K.barHomeY }, - pointB: pb, damping: d, render: { visible: false }, + pointB: pb, damping: d * s.d, render: { visible: false }, }); // rest lengths match the standing pose so the strings hold posture without // hauling the feet off the floor. head + back together fix torso rotation. diff --git a/src/sim/stack.ts b/src/sim/stack.ts index 9329a12..876e6ce 100644 --- a/src/sim/stack.ts +++ b/src/sim/stack.ts @@ -2,10 +2,11 @@ import type { Stage } from '../scenes/stage'; import { box, Food, FOODS, makeFood } from './food'; /** - * The sando. Ordered layers carried onto a plate zone. Stability is honest - * Matter stacking — a tower falls because its centre of mass left the base, not - * because a script said so — but every layer is SCORED: lean, COM drift, - * overhang. The order ticket (M5) names the target; here it's just layers. + * The sando. The stack is DETECTED, not declared: whatever food bodies are + * physically resting in the plate zone, sorted bottom→top, IS the sando — so a + * human dropping pieces by marionette hand and the harness placing them are + * judged identically. Stability is honest Matter stacking; the judge scores + * lean, COM drift, overhang off the real bodies. */ interface LayerDef { @@ -25,6 +26,7 @@ export const LAYERS: Record = { cheese: L('cheese', 82, 16), katsu: L('katsu', 88, 22), egg: L('egg', 70, 20), + tofu: L('tofu', 74, 22), }; export interface SandoStats { @@ -35,64 +37,92 @@ export interface SandoStats { standing: boolean; } +/** Materialise one slice-shaped layer food at (x, y). Caller registers it. */ +export function spawnLayer(stage: Stage, kind: string, x: number, y: number): Food { + const def = LAYERS[kind] ?? LAYERS.bread; + const mat = FOODS[kind]?.material ?? FOODS.bread.material; + return makeFood(stage, box(def.w, def.h), x, y, mat, def.color, def.face, kind); +} + const deg = (r: number): number => +((r * 180) / Math.PI).toFixed(1); const halfW = (b: MatterJS.BodyType): number => (b.bounds.max.x - b.bounds.min.x) / 2; +const ZONE_HALF = 82; // px either side of plate centre that counts as "on the dish" +const IN_FLIGHT = 5; // px/step — anything faster is passing through, not stacked export class Sando { - layers: Food[] = []; - constructor(private stage: Stage, public plateX = 980, public plateTopY = 634) {} + /** The physical stack: foods resting in the plate zone, bottom→top. */ + get layers(): Food[] { + return this.stage.cutter.foods + .filter((f) => { + const b = f.body; + return ( + Math.abs(b.position.x - this.plateX) < ZONE_HALF && + b.position.y < this.plateTopY + 6 && + Math.hypot(b.velocity.x, b.velocity.y) < IN_FLIGHT + ); + }) + .sort((a, b) => b.body.position.y - a.body.position.y); + } + + /** Base kinds bottom→top ('tomatop' piece → 'tomato'). What the judge reads. */ + kinds(): string[] { + return this.layers.map((f) => f.kind.replace(/p+$/, '')); + } + /** Top surface of the current stack (or the plate if empty). */ private topY(): number { - if (!this.layers.length) return this.plateTopY; - return Math.min(...this.layers.map((f) => f.body.bounds.min.y)); + const ls = this.layers; + if (!ls.length) return this.plateTopY; + return Math.min(...ls.map((f) => f.body.bounds.min.y)); } - /** Place one layer on top, offset offsetX from the plate centre, then settle. */ + /** Harness helper: materialise one layer on top and settle. The spawned food + * registers with the cutter's food list, so detection sees it like any other. */ addLayer(kind: string, offsetX = 0, settleFrames = 45): Food { const def = LAYERS[kind] ?? LAYERS.bread; - const mat = FOODS[kind]?.material ?? FOODS.bread.material; const y = this.topY() - def.h / 2 - 1; - const food = makeFood(this.stage, box(def.w, def.h), this.plateX + offsetX, y, mat, def.color, def.face, kind); - this.layers.push(food); + const food = spawnLayer(this.stage, kind, this.plateX + offsetX, y); + this.stage.cutter.add(food); this.stage.auto = false; for (let i = 0; i < settleFrames; i++) this.stage.stepSim(); return food; } + /** Remove everything currently in the plate zone. */ clear(): void { - for (const f of this.layers) this.stage.removeProp(f.body); - this.layers = []; + for (const f of this.layers) { + this.stage.removeProp(f.body); + this.stage.cutter.foods = this.stage.cutter.foods.filter((x) => x !== f); + } } stats(): SandoStats { - const n = this.layers.length; + const ls = this.layers; + const n = ls.length; if (n === 0) return { layers: 0, leanDeg: 0, comDrift: 0, overhang: 0, standing: false }; - const bottom = this.layers[0].body, top = this.layers[n - 1].body; + const bottom = ls[0].body, top = ls[n - 1].body; const leanDeg = deg(Math.atan2(top.position.x - bottom.position.x, Math.max(1, bottom.position.y - top.position.y))); let comDrift = 0, overhang = 0, stackH = 0; for (let i = 0; i < n; i++) { - const b = this.layers[i].body; + const b = ls[i].body; comDrift = Math.max(comDrift, Math.abs(b.position.x - this.plateX)); stackH += b.bounds.max.y - b.bounds.min.y; if (i > 0) { - const below = this.layers[i - 1].body; + const below = ls[i - 1].body; const off = Math.abs(b.position.x - below.position.x); overhang = Math.max(overhang, off - Math.max(0, halfW(below) - halfW(b))); } } - // Standing = the tower still has its height: a collapse drops the top layer. - // A tall sando that survives keeps the top near stackH above the plate; a - // toppled one spills and the top sinks toward the plate. Lean is reported, - // not a pass/fail — a stepped-but-intact sando still stands. + // Standing = the tower still has its height: a collapse drops the top layer + // (and usually scatters pieces out of the zone entirely — also honest). const elevation = this.plateTopY - top.position.y; const standing = elevation > 0.45 * stackH; return { layers: n, leanDeg, comDrift: +comDrift.toFixed(1), overhang: +overhang.toFixed(1), standing }; } - /** The earthquake: shove every layer sideways (px/step). A centred stack rides - * it out; one already leaning past its base goes over. */ + /** The earthquake: shove every stacked layer sideways (px/step). */ nudge(force: number): void { const b = this.stage.matter.body; for (const f of this.layers) {