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",
|
"name": "dev",
|
||||||
"runtimeExecutable": "npm",
|
"runtimeExecutable": "npm",
|
||||||
"runtimeArgs": ["run", "dev"],
|
"runtimeArgs": ["run", "dev"],
|
||||||
"port": 5173
|
"port": 5173,
|
||||||
|
"autoPort": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -85,6 +85,36 @@
|
|||||||
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 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"
|
## POLISH PASS (post-M6) ✅ — "make it even better"
|
||||||
- **Visible strings + control bar** — the biggest missed feel. `Marionette.stringSegments()`
|
- **Visible strings + control bar** — the biggest missed feel. `Marionette.stringSegments()`
|
||||||
/ `barSegment()`; `Stage.drawStrings()` redraws the 6 strings + the wooden bar each
|
/ `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
|
gen_2d splat_wet 370 "a single messy wet food splat splatter shape, flat black silhouette, on transparent background" 512 512 cut
|
||||||
|
|
||||||
echo "done."
|
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 };
|
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() {
|
stackBad() {
|
||||||
const built = this.buildSando([['bread', 0], ['tomato', 20], ['bread', 40]]);
|
const built = this.buildSando([['bread', 0], ['tomato', 20], ['bread', 40]]);
|
||||||
const stoodBefore = built.standing;
|
const stoodBefore = built.standing;
|
||||||
const quaked = this.nudge(3);
|
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 -----------------------------------
|
// ---- 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: '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: '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: '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);
|
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
|
* 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).
|
* 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 {
|
export class Game {
|
||||||
samurai = new Samurai();
|
samurai = new Samurai();
|
||||||
day = 0;
|
day = 0;
|
||||||
order: Order = DAYS[0].order;
|
order: Order = DAYS[0].order;
|
||||||
drops = 0;
|
drops = 0;
|
||||||
seconds = 0; // ticket timer (seconds)
|
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) {
|
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. */
|
/** Load day `n` (clamped): its order + its samurai patience ramp. */
|
||||||
@ -45,6 +98,10 @@ export class Game {
|
|||||||
this.samurai.ramp = d.angerRamp;
|
this.samurai.ramp = d.angerRamp;
|
||||||
this.drops = 0;
|
this.drops = 0;
|
||||||
this.seconds = 0;
|
this.seconds = 0;
|
||||||
|
this.state = 'playing';
|
||||||
|
this.dwell = 0;
|
||||||
|
this.lateBumped = 0;
|
||||||
|
this.lastVerdict = null;
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,10 +110,10 @@ export class Game {
|
|||||||
this.order = order;
|
this.order = order;
|
||||||
this.drops = 0;
|
this.drops = 0;
|
||||||
this.seconds = 0;
|
this.seconds = 0;
|
||||||
}
|
this.state = 'playing';
|
||||||
|
this.dwell = 0;
|
||||||
tick(dtSec: number): void {
|
this.lateBumped = 0;
|
||||||
this.seconds += dtSec;
|
this.lastVerdict = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- kitchen sins → anger ----
|
// ---- kitchen sins → anger ----
|
||||||
@ -77,8 +134,7 @@ export class Game {
|
|||||||
private buildRun(bowHeld: boolean, seconds: number): RunResult {
|
private buildRun(bowHeld: boolean, seconds: number): RunResult {
|
||||||
const cut = this.stage.cutter.last;
|
const cut = this.stage.cutter.last;
|
||||||
const s = this.stage.sando.stats();
|
const s = this.stage.sando.stats();
|
||||||
const kinds = this.stage.sando.layers.map((l) => l.kind.replace(/p+$/, ''));
|
const matches = this.matchesOrder();
|
||||||
const matches = kinds.length === this.order.layers.length && kinds.every((k, i) => k === this.order.layers[i]);
|
|
||||||
return {
|
return {
|
||||||
cut: cut ? { cv: cut.cv, quality: cut.quality, pieces: cut.pieces } : { cv: 1, quality: 'none', pieces: 0 },
|
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 },
|
sando: { layers: s.layers, leanDeg: s.leanDeg, comDrift: s.comDrift, standing: s.standing, matches },
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import Phaser from 'phaser';
|
import Phaser from 'phaser';
|
||||||
import { installHarness } from '../dev';
|
import { installHarness } from '../dev';
|
||||||
import { Marionette } from '../sim/marionette';
|
import { Marionette, type StringStyle } from '../sim/marionette';
|
||||||
import { Cutter } from '../sim/cutting';
|
import { Cutter } from '../sim/cutting';
|
||||||
import { spawnFood } from '../sim/food';
|
import { spawnFood } from '../sim/food';
|
||||||
import { Sando } from '../sim/stack';
|
import { Sando, spawnLayer } from '../sim/stack';
|
||||||
import { Game } from '../game/game';
|
import { Game, DAYS } from '../game/game';
|
||||||
import type { Verdict } from '../game/judging';
|
import type { Verdict } from '../game/judging';
|
||||||
import { sfx } from '../game/audio';
|
import { sfx } from '../game/audio';
|
||||||
|
|
||||||
@ -43,8 +43,14 @@ export class Stage extends Phaser.Scene {
|
|||||||
private samuraiImg?: Phaser.GameObjects.Image;
|
private samuraiImg?: Phaser.GameObjects.Image;
|
||||||
private angerBar?: Phaser.GameObjects.Graphics;
|
private angerBar?: Phaser.GameObjects.Graphics;
|
||||||
private strings?: 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 vignette?: Phaser.GameObjects.Rectangle;
|
||||||
private scorecard?: Phaser.GameObjects.Container;
|
private scorecard?: Phaser.GameObjects.Container;
|
||||||
|
private prevBar = { x: 0, y: 0 };
|
||||||
|
private deadFrames = 0;
|
||||||
|
private sweep = 0;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('stage');
|
super('stage');
|
||||||
@ -55,7 +61,7 @@ export class Stage extends Phaser.Scene {
|
|||||||
// asset paths resolve regardless of a trailing slash on the URL.
|
// asset paths resolve regardless of a trailing slash on the URL.
|
||||||
const base = import.meta.env.BASE_URL;
|
const base = import.meta.env.BASE_URL;
|
||||||
const img = (k: string) => this.load.image(k, `${base}assets/img/${k}.png`);
|
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 {
|
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(-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.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.
|
// 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.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.addBody(this.crate, 48, 48, 0x3a2d20);
|
||||||
this.puppet.gripTargets.push(this.crate);
|
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.board = this.matter.add.rectangle(760, 560, 240, 24, { isStatic: true });
|
||||||
this.addBody(this.board, 240, 24, 0x2a211a);
|
this.addBody(this.board, 240, 24, 0x2a211a);
|
||||||
this.cutter = new Cutter(this);
|
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.
|
// 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 });
|
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.sando = new Sando(this, 980, 624);
|
||||||
|
|
||||||
this.session = new Game(this);
|
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.
|
// HUD: the samurai judge (top-right), the anger meter, the unsheath vignette.
|
||||||
if (this.textures.exists('samurai_calm')) {
|
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.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);
|
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.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.space = this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE);
|
||||||
this.input.once('pointerdown', () => sfx.enable()); // unblock audio on first click
|
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);
|
installHarness(this);
|
||||||
|
|
||||||
// title over the top; dismissed on click (the harness can skip it).
|
// title overlay on first boot only (day/style picks restart this scene).
|
||||||
this.scene.launch('title');
|
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
|
/** SHUBATTO — the string cut. Ragdoll the puppet, fling every loose food with
|
||||||
@ -190,10 +245,41 @@ export class Stage extends Phaser.Scene {
|
|||||||
g.strokePath();
|
g.strokePath();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Refresh the order ticket from the current day. */
|
/** 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;
|
||||||
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). */
|
/** World position of the cleaver's cutting edge (its lower tip). */
|
||||||
@ -211,7 +297,10 @@ export class Stage extends Phaser.Scene {
|
|||||||
|
|
||||||
private updateHUD(): void {
|
private updateHUD(): void {
|
||||||
const sam = this.session.samurai;
|
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;
|
const bar = this.angerBar;
|
||||||
if (bar) {
|
if (bar) {
|
||||||
bar.clear();
|
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.puppet.applyInput(p.worldX, p.worldY || this.puppet.barHomeY, p.leftButtonDown(), grip);
|
||||||
this.stepSim();
|
this.stepSim();
|
||||||
this.driveCleaver(); // a gripped cleaver is the live blade — swinging cuts
|
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) {
|
for (const pr of this.props) {
|
||||||
pr.view.setPosition(pr.body.position.x, pr.body.position.y);
|
pr.view.setPosition(pr.body.position.x, pr.body.position.y);
|
||||||
pr.view.setRotation(pr.body.angle);
|
pr.view.setRotation(pr.body.angle);
|
||||||
}
|
}
|
||||||
this.drawStrings();
|
this.drawStrings();
|
||||||
|
this.drawServe();
|
||||||
|
this.refreshOrder();
|
||||||
this.updateHUD();
|
this.updateHUD();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
import Phaser from 'phaser';
|
import Phaser from 'phaser';
|
||||||
import { sfx } from '../game/audio';
|
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
|
* 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
|
* available underneath from frame one. Pick your strings (difficulty modifier),
|
||||||
* unblocks audio, so the shamisen sting lands on entry.
|
* 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 {
|
export class Title extends Phaser.Scene {
|
||||||
constructor() {
|
constructor() {
|
||||||
@ -16,27 +18,60 @@ export class Title extends Phaser.Scene {
|
|||||||
this.add.rectangle(W / 2, H / 2, W, H, 0x0a0705, 0.82);
|
this.add.rectangle(W / 2, H / 2, W, H, 0x0a0705, 0.82);
|
||||||
|
|
||||||
if (this.textures.exists('title_art')) {
|
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);
|
.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);
|
.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',
|
fontFamily: 'serif', fontSize: '22px', color: '#e8b84a', fontStyle: 'bold',
|
||||||
}).setOrigin(0.5).setDepth(1);
|
}).setOrigin(0.5).setDepth(1);
|
||||||
this.tweens.add({ targets: shout, scale: 1.12, duration: 600, yoyo: true, repeat: -1, ease: 'Sine.inOut' });
|
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);
|
.setOrigin(0.5).setDepth(1);
|
||||||
this.tweens.add({ targets: hint, alpha: 0.3, duration: 900, yoyo: true, repeat: -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.enable();
|
||||||
sfx.gong();
|
sfx.gong();
|
||||||
sfx.shamisen();
|
sfx.shamisen();
|
||||||
|
this.registry.set('sando.started', true);
|
||||||
|
this.scene.get('stage').scene.restart();
|
||||||
this.scene.stop();
|
this.scene.stop();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -45,9 +45,16 @@ export class Cutter {
|
|||||||
foods: Food[] = [];
|
foods: Food[] = [];
|
||||||
splats: { mass: number }[] = [];
|
splats: { mass: number }[] = [];
|
||||||
last: CutResult | null = null;
|
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 tip: V | null = null;
|
||||||
private track = new Map<Food, Track>();
|
private track = new Map<Food, Track>();
|
||||||
|
|
||||||
|
private report(r: CutResult): void {
|
||||||
|
this.last = r;
|
||||||
|
this.onCut?.(r);
|
||||||
|
}
|
||||||
|
|
||||||
constructor(private stage: Stage) {}
|
constructor(private stage: Stage) {}
|
||||||
|
|
||||||
add(food: Food): void {
|
add(food: Food): void {
|
||||||
@ -100,7 +107,7 @@ export class Cutter {
|
|||||||
if (mat.moisture > 0.4 && power < CUT.squash) {
|
if (mat.moisture > 0.4 && power < CUT.squash) {
|
||||||
this.splat(food, peakSpeed);
|
this.splat(food, peakSpeed);
|
||||||
} else {
|
} 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;
|
return;
|
||||||
}
|
}
|
||||||
@ -127,13 +134,13 @@ export class Cutter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const areas = parts.map(polyArea);
|
const areas = parts.map(polyArea);
|
||||||
this.last = {
|
this.report({
|
||||||
quality,
|
quality,
|
||||||
pieces: 2,
|
pieces: 2,
|
||||||
cv: +pieceCV(areas).toFixed(3),
|
cv: +pieceCV(areas).toFixed(3),
|
||||||
speed: +peakSpeed.toFixed(2),
|
speed: +peakSpeed.toFixed(2),
|
||||||
chordFrac: +chordFrac.toFixed(2),
|
chordFrac: +chordFrac.toFixed(2),
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private splat(food: Food, peakSpeed: number): void {
|
private splat(food: Food, peakSpeed: number): void {
|
||||||
@ -144,7 +151,7 @@ export class Cutter {
|
|||||||
this.stage.removeProp(food.body);
|
this.stage.removeProp(food.body);
|
||||||
this.foods = this.foods.filter((f) => f !== food);
|
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.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.
|
// 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' },
|
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' },
|
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' },
|
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;
|
let NEXT = 1;
|
||||||
|
|||||||
@ -33,6 +33,16 @@ type Seg =
|
|||||||
| 'uArmL' | 'lArmL' | 'uArmR' | 'lArmR'
|
| 'uArmL' | 'lArmL' | 'uArmR' | 'lArmR'
|
||||||
| 'legL' | 'legR';
|
| '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 };
|
type Vec = { x: number; y: number };
|
||||||
|
|
||||||
export interface Pose {
|
export interface Pose {
|
||||||
@ -59,7 +69,7 @@ export class Marionette {
|
|||||||
/** Bodies the hands can grab (crate, later food). Stage fills this. */
|
/** Bodies the hands can grab (crate, later food). Stage fills this. */
|
||||||
gripTargets: MatterJS.BodyType[] = [];
|
gripTargets: MatterJS.BodyType[] = [];
|
||||||
|
|
||||||
constructor(private stage: Stage, homeX = 400) {
|
constructor(private stage: Stage, homeX = 400, public style: StringStyle = 'hemp') {
|
||||||
this.barX = homeX;
|
this.barX = homeX;
|
||||||
this.prevBarX = homeX;
|
this.prevBarX = homeX;
|
||||||
const m = stage.matter;
|
const m = stage.matter;
|
||||||
@ -107,10 +117,11 @@ export class Marionette {
|
|||||||
* build and again after a string cut when the puppet is restrung. */
|
* build and again after a string cut when the puppet is restrung. */
|
||||||
private buildStrings(): void {
|
private buildStrings(): void {
|
||||||
const m = this.stage.matter;
|
const m = this.stage.matter;
|
||||||
|
const s = STRING_STYLES[this.style];
|
||||||
const string = (b: MatterJS.BodyType, pb: Vec, length: number, k: number, d: number) =>
|
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 },
|
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
|
// rest lengths match the standing pose so the strings hold posture without
|
||||||
// hauling the feet off the floor. head + back together fix torso rotation.
|
// 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';
|
import { box, Food, FOODS, makeFood } from './food';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The sando. Ordered layers carried onto a plate zone. Stability is honest
|
* The sando. The stack is DETECTED, not declared: whatever food bodies are
|
||||||
* Matter stacking — a tower falls because its centre of mass left the base, not
|
* physically resting in the plate zone, sorted bottom→top, IS the sando — so a
|
||||||
* because a script said so — but every layer is SCORED: lean, COM drift,
|
* human dropping pieces by marionette hand and the harness placing them are
|
||||||
* overhang. The order ticket (M5) names the target; here it's just layers.
|
* judged identically. Stability is honest Matter stacking; the judge scores
|
||||||
|
* lean, COM drift, overhang off the real bodies.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface LayerDef {
|
interface LayerDef {
|
||||||
@ -25,6 +26,7 @@ export const LAYERS: Record<string, LayerDef> = {
|
|||||||
cheese: L('cheese', 82, 16),
|
cheese: L('cheese', 82, 16),
|
||||||
katsu: L('katsu', 88, 22),
|
katsu: L('katsu', 88, 22),
|
||||||
egg: L('egg', 70, 20),
|
egg: L('egg', 70, 20),
|
||||||
|
tofu: L('tofu', 74, 22),
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface SandoStats {
|
export interface SandoStats {
|
||||||
@ -35,64 +37,92 @@ export interface SandoStats {
|
|||||||
standing: boolean;
|
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 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 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 {
|
export class Sando {
|
||||||
layers: Food[] = [];
|
|
||||||
|
|
||||||
constructor(private stage: Stage, public plateX = 980, public plateTopY = 634) {}
|
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). */
|
/** Top surface of the current stack (or the plate if empty). */
|
||||||
private topY(): number {
|
private topY(): number {
|
||||||
if (!this.layers.length) return this.plateTopY;
|
const ls = this.layers;
|
||||||
return Math.min(...this.layers.map((f) => f.body.bounds.min.y));
|
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 {
|
addLayer(kind: string, offsetX = 0, settleFrames = 45): Food {
|
||||||
const def = LAYERS[kind] ?? LAYERS.bread;
|
const def = LAYERS[kind] ?? LAYERS.bread;
|
||||||
const mat = FOODS[kind]?.material ?? FOODS.bread.material;
|
|
||||||
const y = this.topY() - def.h / 2 - 1;
|
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);
|
const food = spawnLayer(this.stage, kind, this.plateX + offsetX, y);
|
||||||
this.layers.push(food);
|
this.stage.cutter.add(food);
|
||||||
this.stage.auto = false;
|
this.stage.auto = false;
|
||||||
for (let i = 0; i < settleFrames; i++) this.stage.stepSim();
|
for (let i = 0; i < settleFrames; i++) this.stage.stepSim();
|
||||||
return food;
|
return food;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Remove everything currently in the plate zone. */
|
||||||
clear(): void {
|
clear(): void {
|
||||||
for (const f of this.layers) this.stage.removeProp(f.body);
|
for (const f of this.layers) {
|
||||||
this.layers = [];
|
this.stage.removeProp(f.body);
|
||||||
|
this.stage.cutter.foods = this.stage.cutter.foods.filter((x) => x !== f);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stats(): SandoStats {
|
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 };
|
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)));
|
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;
|
let comDrift = 0, overhang = 0, stackH = 0;
|
||||||
for (let i = 0; i < n; i++) {
|
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));
|
comDrift = Math.max(comDrift, Math.abs(b.position.x - this.plateX));
|
||||||
stackH += b.bounds.max.y - b.bounds.min.y;
|
stackH += b.bounds.max.y - b.bounds.min.y;
|
||||||
if (i > 0) {
|
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);
|
const off = Math.abs(b.position.x - below.position.x);
|
||||||
overhang = Math.max(overhang, off - Math.max(0, halfW(below) - halfW(b)));
|
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.
|
// 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
|
// (and usually scatters pieces out of the zone entirely — also honest).
|
||||||
// toppled one spills and the top sinks toward the plate. Lean is reported,
|
|
||||||
// not a pass/fail — a stepped-but-intact sando still stands.
|
|
||||||
const elevation = this.plateTopY - top.position.y;
|
const elevation = this.plateTopY - top.position.y;
|
||||||
const standing = elevation > 0.45 * stackH;
|
const standing = elevation > 0.45 * stackH;
|
||||||
return { layers: n, leanDeg, comDrift: +comDrift.toFixed(1), overhang: +overhang.toFixed(1), standing };
|
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
|
/** The earthquake: shove every stacked layer sideways (px/step). */
|
||||||
* it out; one already leaning past its base goes over. */
|
|
||||||
nudge(force: number): void {
|
nudge(force: number): void {
|
||||||
const b = this.stage.matter.body;
|
const b = this.stage.matter.body;
|
||||||
for (const f of this.layers) {
|
for (const f of this.layers) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user