Build-out 2: the playable loop — detection stacking, live serve, string picker, 8 days
- Sando is zone-DETECTION now: the stack = food bodies physically resting in the plate zone, bottom->top. Hand-stacked and harness-stacked sandos are judged identically (sando.kinds() is what the judge reads). - The full live loop: Game.state + updateLive() — ticket clock with late-anger (1 bump/3s overdue), bow-dwell serve (hold still 0.5s over a matching standing sando -> scorecard), click -> next day; string cut -> ragdoll -> fail card -> click retries the same day with fresh strings. setupDay stages bread + fillings per order; sins fire live off cutter.onCut + offstage sweep. - String unlockables: HEMP/RUBBER/CHAIN/SILK picker on the title (stiffness/ damping multipliers, registry-persisted). Rubber visibly stretches. - Days 6-8: Tamago (it rolls), Tofu (fragile), THE TOWER (5 layers) + tofu food. - samurai_bow (same seed): an S serve swaps in the single approving nod. - Battery: M1 87f/8.41u/10-10, M2 cv0 clean, M3 both, days SSSSSSSS, live serve commits at 31f (~0.5s), death->retry + serve->advance verified. - Known: embedded preview pane never fires rAF (visibilityState hidden) — live loop freezes there by browser design; harness steps manually. Real tabs fine. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
4f4b31b914
commit
26921eb4ad
@ -5,7 +5,8 @@
|
||||
"name": "dev",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 5173
|
||||
"port": 5173,
|
||||
"autoPort": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
BIN
public/assets/img/counter_wood.png
Normal file
BIN
public/assets/img/counter_wood.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 460 KiB |
BIN
public/assets/img/day_stamp.png
Normal file
BIN
public/assets/img/day_stamp.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 440 KiB |
BIN
public/assets/img/face_cut_bread.png
Normal file
BIN
public/assets/img/face_cut_bread.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 492 KiB |
BIN
public/assets/img/samurai_bow.png
Normal file
BIN
public/assets/img/samurai_bow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 708 KiB |
@ -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
|
||||
|
||||
31
src/dev.ts
31
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 -----------------------------------
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<StringStyle, Phaser.GameObjects.Text>();
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
@ -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<Food, Track>();
|
||||
|
||||
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.
|
||||
}
|
||||
|
||||
|
||||
@ -37,6 +37,9 @@ export const FOODS: Record<string, FoodDef> = {
|
||||
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;
|
||||
|
||||
@ -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<StringStyle, { k: number; d: number; blurb: string }> = {
|
||||
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.
|
||||
|
||||
@ -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<string, LayerDef> = {
|
||||
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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user