M4: the samurai + the full loop — THE STRING CUT
- sim/samurai.ts: anger meter (0..1), events (drop/splat/crimeCut/servedWrong/ offstage/cleanServe), 5 unsheath stages -> generated portraits, vignette. - game/judging.ts: 4 groups (THE CUT/SANDO/SERVICE/FLOOR), banded scores, grade S-D. game/lines.ts: bilingual samurai barks, 8+/group, deterministic. - game/game.ts: the run controller — order, serve+verdict, sins -> anger, onStrike -> the string cut. - THE STRING CUT: Marionette.cutStrings() deletes all 6 strings in one frame -> ragdoll (pins survive); stage flings loose food with the slash impulse + white slash FX + synth gong. Restringable (reset rebuilds). - Assets wired: bg_washi backdrop + samurai portrait HUD + anger bar. The stage now reads as the shadow-puppet theatre. - Exit-bar: clean run grade S(10) standing; bad run grade D (judge discriminates); disaster run strings cut -> ragdoll falls ~180px, samurai fury. Verified in-browser (scorecard + string-cut shots) + full M1-M4 battery + npm run check. - Traps: field 'game' collided with Phaser.Scene.game -> 'session'; walk faceplant was per-frame lean>45 (a stride recovers) -> now head-hit-floor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
7116b71d17
commit
58285712ef
@ -39,10 +39,29 @@
|
||||
Exit-bar: centred 3-layer sando stands + survives quake (lean 0→2.4°); a leaning
|
||||
stack stands marginally (lean 44°) then the nudge topples it — the quake
|
||||
discriminates good from bad. Verified in-browser (full M1+M2+M3 battery green).
|
||||
- **Next: M4** samurai + full loop (`sim/samurai.ts`) — anger meter, THE STRING CUT,
|
||||
serve+bow, scorecard, judge voice. The third must-feel: **the string cut**.
|
||||
- Run: `npm run dev` (:5173). Harness: `__s.dangle/walk/pickupTest/chop/push/
|
||||
wildChop/stackGood/stackBad/nudge`. Headless: `npm run check`.
|
||||
- **M4 ✅** samurai + full loop. `sim/samurai.ts` (anger meter 0..1, events
|
||||
drop/splat/crimeCut/servedWrong/servedLate/offstage/cleanServe, 5 unsheath
|
||||
stages → the generated portraits, vignette), `game/judging.ts` (4 groups
|
||||
**THE CUT / SANDO / SERVICE / FLOOR**, banded scores, grade S–D), `game/lines.ts`
|
||||
(bilingual samurai barks, 8+/group, deterministic pick). **THE STRING CUT**:
|
||||
`Marionette.cutStrings()` deletes all 6 strings in one frame → ragdoll (pins
|
||||
survive); stage flings loose food with the slash impulse + white slash FX +
|
||||
synth gong. Backdrop = `bg_washi`, samurai portrait HUD + anger bar swap with
|
||||
the meter. Assets now wired in — the stage reads as the shadow-puppet theatre.
|
||||
Exit-bar: clean run **grade S(10)** standing; bad run **grade D**; disaster run
|
||||
**strings cut → ragdoll head falls ~180px, samurai fury**. Judge discriminates
|
||||
S↔D. Verified in-browser (scorecard + string-cut screenshots) + full battery.
|
||||
Trap fixed: field named `game` collided with `Phaser.Scene.game` → renamed
|
||||
`session`; walk faceplant test was per-frame lean>45 (a stride swings past 45
|
||||
and recovers) → now "did the head hit the floor / end collapsed".
|
||||
- Note: a string cut is game-over; real restart should rebuild the scene. Harness
|
||||
`reset()` restrings in place — Matter's constraint solve-order makes the
|
||||
restrung puppet marginally different but still walks fine (head stays ~400,
|
||||
floor 640).
|
||||
- **Next: M5** orders/days escalation (`game/orders.ts`-ish) — one new pressure
|
||||
per day. Then **M6** juice (title, audio, string unlockables).
|
||||
- Run: `npm run dev` (:5173). Harness: `__s.playCleanRun() __s.playDisasterRun()
|
||||
__s.serve() __s.anger(ev)` + all M1–M3 verbs. Headless: `npm run check`.
|
||||
|
||||
|
||||
|
||||
|
||||
80
src/dev.ts
80
src/dev.ts
@ -119,21 +119,27 @@ export function installHarness(stage: Stage): void {
|
||||
hold(HOME_X, p().barHomeY, 20);
|
||||
const x0 = p().torsoX;
|
||||
const frames = Math.round(secs * 60);
|
||||
let upright = true;
|
||||
// A faceplant is the head hitting the floor, not a transient stride-lean —
|
||||
// a lively marionette swings past 45° mid-step and recovers. Track how low
|
||||
// the head ever got; the end pose says whether it's still walking.
|
||||
let lowestHead = 0;
|
||||
for (let i = 0; i < frames; i++) {
|
||||
const center = HOME_X + dir * advance * (i / frames);
|
||||
const x = center + amp * Math.sin((2 * Math.PI * freq * i) / 60);
|
||||
p().applyInput(x, p().barHomeY, false, false);
|
||||
stage.stepSim();
|
||||
if (!p().pose().upright) upright = false;
|
||||
lowestHead = Math.max(lowestHead, p().seg.head.position.y);
|
||||
}
|
||||
const dx = p().torsoX - x0;
|
||||
const finalPose = p().pose();
|
||||
const faceplant = lowestHead > FLOOR_Y - 70 || !finalPose.upright;
|
||||
return {
|
||||
units: +(dx / STAGE_UNIT).toFixed(2),
|
||||
dx: +dx.toFixed(1),
|
||||
upright,
|
||||
faceplant: !upright,
|
||||
lean: p().pose().lean,
|
||||
upright: !faceplant,
|
||||
faceplant,
|
||||
lean: finalPose.lean,
|
||||
lowestHead: +lowestHead.toFixed(0),
|
||||
};
|
||||
},
|
||||
|
||||
@ -255,6 +261,70 @@ export function installHarness(stage: Stage): void {
|
||||
const quaked = this.nudge(3);
|
||||
return { built, quaked, stoodBefore, toppledByQuake: stoodBefore && !quaked.standing };
|
||||
},
|
||||
|
||||
// ---- M4: the samurai + the full loop -----------------------------------
|
||||
samurai: () => ({
|
||||
anger: +stage.session.samurai.anger.toFixed(2),
|
||||
stage: stage.session.samurai.stage,
|
||||
portrait: stage.session.samurai.portrait,
|
||||
struck: stage.session.samurai.struck,
|
||||
}),
|
||||
|
||||
/** Feed the anger meter a kitchen sin. */
|
||||
anger(ev: 'drop' | 'splat' | 'crimeCut' | 'servedWrong' | 'servedLate' | 'offstage' | 'cleanServe') {
|
||||
stage.session.samurai.bump(ev);
|
||||
return this.samurai();
|
||||
},
|
||||
|
||||
/** Score the current sando as served + show the scorecard. */
|
||||
serve(bowHeld = true, seconds = 18) {
|
||||
const v = stage.session.serve(bowHeld, seconds);
|
||||
stage.showScorecard(v);
|
||||
return { grade: v.grade, total: v.total, lines: v.lines, anger: +stage.session.samurai.anger.toFixed(2) };
|
||||
},
|
||||
|
||||
/** Wipe the board and stack, reset the samurai + puppet to a clean start. */
|
||||
freshRun() {
|
||||
stage.auto = false;
|
||||
stage.clearScorecard();
|
||||
stage.session.reset();
|
||||
stage.puppet.reset(400);
|
||||
settle();
|
||||
for (const f of [...stage.cutter.foods]) stage.removeProp(f.body);
|
||||
stage.cutter.foods = [];
|
||||
stage.cutter.splats = [];
|
||||
stage.cutter.last = null;
|
||||
stage.sando.clear();
|
||||
},
|
||||
|
||||
/** M4 exit-bar A: order → cut → stack → serve, scored. Should grade B+. */
|
||||
playCleanRun() {
|
||||
this.freshRun();
|
||||
this.chop('tomato'); // 2 clean pieces
|
||||
this.buildSando([['bread', 0], ['tomato', 0], ['bread', 0]]);
|
||||
const v = this.serve(true, 18);
|
||||
return { ...v, standing: stage.sando.stats().standing, order: stage.session.order.name };
|
||||
},
|
||||
|
||||
/** M4 exit-bar B: anger the samurai to the top → the string cut → ragdoll. */
|
||||
playDisasterRun() {
|
||||
this.freshRun();
|
||||
const headY0 = stage.puppet.seg.head.position.y;
|
||||
let bumps = 0;
|
||||
while (!stage.session.samurai.struck && bumps < 30) {
|
||||
stage.session.samurai.bump('crimeCut');
|
||||
bumps++;
|
||||
}
|
||||
for (let i = 0; i < 120; i++) stage.stepSim(); // let the ragdoll collapse + food fly
|
||||
const headY = stage.puppet.seg.head.position.y;
|
||||
return {
|
||||
struck: stage.session.samurai.struck,
|
||||
stringsCut: stage.puppet.cut,
|
||||
bumps,
|
||||
ragdollFell: +(headY - headY0).toFixed(0),
|
||||
anger: +stage.session.samurai.anger.toFixed(2),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
(window as { __s?: typeof s }).__s = s;
|
||||
|
||||
73
src/game/game.ts
Normal file
73
src/game/game.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import type { Stage } from '../scenes/stage';
|
||||
import { Samurai } from '../sim/samurai';
|
||||
import { judge, type Order, type RunResult, type Verdict } from './judging';
|
||||
|
||||
/** Day 1: one open tomato sando. Escalation is M5. */
|
||||
export const ORDERS: Order[] = [
|
||||
{ name: 'Open Tomato Sando', layers: ['bread', 'tomato', 'bread'], needsCut: true, maxSeconds: 40 },
|
||||
];
|
||||
|
||||
/**
|
||||
* The run controller. Holds the samurai + the current ticket, tallies the floor,
|
||||
* 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 class Game {
|
||||
samurai = new Samurai();
|
||||
order: Order = ORDERS[0];
|
||||
drops = 0;
|
||||
seconds = 0; // ticket timer (seconds)
|
||||
|
||||
constructor(private stage: Stage) {
|
||||
this.samurai.onStrike = () => this.stage.stringCut();
|
||||
}
|
||||
|
||||
reset(order: Order = ORDERS[0]): void {
|
||||
this.samurai.reset();
|
||||
this.order = order;
|
||||
this.drops = 0;
|
||||
this.seconds = 0;
|
||||
}
|
||||
|
||||
tick(dtSec: number): void {
|
||||
this.seconds += dtSec;
|
||||
}
|
||||
|
||||
// ---- kitchen sins → anger ----
|
||||
drop(): void {
|
||||
this.drops++;
|
||||
this.samurai.bump('drop');
|
||||
}
|
||||
splat(): void {
|
||||
this.samurai.bump('splat');
|
||||
}
|
||||
crimeCut(): void {
|
||||
this.samurai.bump('crimeCut');
|
||||
}
|
||||
offstage(): void {
|
||||
this.samurai.bump('offstage');
|
||||
}
|
||||
|
||||
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]);
|
||||
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 },
|
||||
service: { served: true, bowHeld, seconds, correct: matches && s.standing },
|
||||
floor: { splatMass: this.stage.cutter.stats().splatMass, drops: this.drops },
|
||||
};
|
||||
}
|
||||
|
||||
/** Score the current sando as served. Sheathes on a clean correct serve,
|
||||
* angers on the wrong dish. Returns the verdict for the scorecard. */
|
||||
serve(bowHeld = true, seconds = this.seconds): Verdict {
|
||||
const run = this.buildRun(bowHeld, seconds);
|
||||
const v = judge(this.order, run);
|
||||
if (run.service.correct && run.floor.splatMass < 1) this.samurai.bump('cleanServe');
|
||||
else if (!run.sando.matches || !run.sando.standing) this.samurai.bump('servedWrong');
|
||||
return v;
|
||||
}
|
||||
}
|
||||
106
src/game/judging.ts
Normal file
106
src/game/judging.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import { barkFor } from './lines';
|
||||
|
||||
/** What the ticket asked for. */
|
||||
export interface Order {
|
||||
name: string;
|
||||
layers: string[]; // required kinds, bottom → top (e.g. ['bread','tomato','bread'])
|
||||
needsCut: boolean; // did a food need cutting first
|
||||
maxSeconds: number; // serve by this or it's late
|
||||
}
|
||||
|
||||
/** Everything measured off one attempt — every number read off the sim. */
|
||||
export interface RunResult {
|
||||
cut?: { cv: number; quality: 'clean' | 'ragged' | 'crime' | 'none'; pieces: number };
|
||||
sando: { layers: number; leanDeg: number; comDrift: number; standing: boolean; matches: boolean };
|
||||
service: { served: boolean; bowHeld: boolean; seconds: number; correct: boolean };
|
||||
floor: { splatMass: number; drops: number };
|
||||
}
|
||||
|
||||
export type Group = 'CUT' | 'SANDO' | 'SERVICE' | 'FLOOR';
|
||||
export type Grade = 'S' | 'A' | 'B' | 'C' | 'D';
|
||||
|
||||
export interface Criterion {
|
||||
key: string;
|
||||
group: Group;
|
||||
label: string;
|
||||
score: number; // 0..1
|
||||
weight: number;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface Verdict {
|
||||
criteria: Criterion[];
|
||||
total: number; // 0..10
|
||||
grade: Grade;
|
||||
best: Criterion;
|
||||
worst: Criterion;
|
||||
lines: { jp: string; en: string }[]; // subtitled barks, worst-first
|
||||
}
|
||||
|
||||
const clamp01 = (v: number): number => (v < 0 ? 0 : v > 1 ? 1 : v);
|
||||
|
||||
export function judge(order: Order, r: RunResult): Verdict {
|
||||
const criteria: Criterion[] = [];
|
||||
|
||||
// THE CUT — clean, even pieces
|
||||
if (order.needsCut) {
|
||||
const q = r.cut?.quality ?? 'none';
|
||||
const cv = r.cut?.cv ?? 1;
|
||||
const qScore = q === 'clean' ? 1 : q === 'ragged' ? 0.55 : q === 'crime' ? 0.1 : 0.15;
|
||||
const evenScore = clamp01(1 - (cv - 0.05) / 0.32);
|
||||
criteria.push({
|
||||
key: 'cut', group: 'CUT', label: 'THE CUT', weight: 1.2,
|
||||
score: clamp01(0.6 * qScore + 0.4 * evenScore),
|
||||
detail: q === 'none' ? 'never cut' : `${q}, ${r.cut?.pieces ?? 0} pieces, CV ${cv.toFixed(2)}`,
|
||||
});
|
||||
}
|
||||
|
||||
// THE SANDO — right layers, standing, plumb
|
||||
const lean = Math.abs(r.sando.leanDeg);
|
||||
const sandoScore =
|
||||
(r.sando.matches ? 0.4 : 0.05) +
|
||||
(r.sando.standing ? 0.35 : 0) +
|
||||
0.25 * clamp01(1 - lean / 45);
|
||||
criteria.push({
|
||||
key: 'sando', group: 'SANDO', label: 'THE SANDO', weight: 1.4,
|
||||
score: clamp01(sandoScore),
|
||||
detail: `${r.sando.layers} layers, ${r.sando.matches ? 'as ordered' : 'wrong build'}, lean ${lean.toFixed(0)}°, ${r.sando.standing ? 'standing' : 'collapsed'}`,
|
||||
});
|
||||
|
||||
// THE SERVICE — served, correct, on time, bowed
|
||||
const late = Math.max(0, r.service.seconds - order.maxSeconds);
|
||||
const serviceScore = !r.service.served
|
||||
? 0.05
|
||||
: (r.service.correct ? 0.5 : 0.15) + (r.service.bowHeld ? 0.25 : 0) + 0.25 * clamp01(1 - late / 20);
|
||||
criteria.push({
|
||||
key: 'service', group: 'SERVICE', label: 'THE SERVICE', weight: 1.1,
|
||||
score: clamp01(serviceScore),
|
||||
detail: !r.service.served
|
||||
? 'never served'
|
||||
: `${r.service.correct ? 'correct' : 'wrong dish'}, ${r.service.seconds.toFixed(0)}s${late > 0 ? ` (${late.toFixed(0)}s late)` : ''}, ${r.service.bowHeld ? 'bowed' : 'no bow'}`,
|
||||
});
|
||||
|
||||
// THE FLOOR — mess and drops (the bench criterion reborn)
|
||||
const floorScore = clamp01(1 - r.floor.splatMass / 6 - r.floor.drops / 4);
|
||||
criteria.push({
|
||||
key: 'floor', group: 'FLOOR', label: 'THE FLOOR', weight: 0.9,
|
||||
score: floorScore,
|
||||
detail: `splat ${r.floor.splatMass.toFixed(1)}, ${r.floor.drops} drop${r.floor.drops === 1 ? '' : 's'}`,
|
||||
});
|
||||
|
||||
const wsum = criteria.reduce((a, c) => a + c.weight, 0);
|
||||
const total = +((criteria.reduce((a, c) => a + c.score * c.weight, 0) / wsum) * 10).toFixed(2);
|
||||
const grade: Grade = total >= 9.2 ? 'S' : total >= 8 ? 'A' : total >= 6.5 ? 'B' : total >= 4.5 ? 'C' : 'D';
|
||||
|
||||
const sorted = [...criteria].sort((a, b) => a.score - b.score);
|
||||
const worst = sorted[0], best = sorted[sorted.length - 1];
|
||||
|
||||
// barks: an S earns the nod; a real weak spot gets named; an otherwise-clean
|
||||
// run gets a grudging good word. Never praise and insult the same plate.
|
||||
const lines: { jp: string; en: string }[] = [];
|
||||
if (grade === 'S') lines.push({ jp: '…umai.', en: '…good.' });
|
||||
if (worst.score < 0.6) lines.push(barkFor(worst, false));
|
||||
else if (grade !== 'S' && best.score > 0.75) lines.push(barkFor(best, true));
|
||||
|
||||
return { criteria, total, grade, best, worst, lines };
|
||||
}
|
||||
97
src/game/lines.ts
Normal file
97
src/game/lines.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import type { Criterion, Group } from './judging';
|
||||
|
||||
/**
|
||||
* The samurai's voice. Deadpan, specific, never quite cruel. Each line is a
|
||||
* subtitled Japanese bark + its broken-Engrish translation — 1997 bargain-bin
|
||||
* PS1 import energy. Keyed to the criterion that decided the verdict, so the
|
||||
* bark always names the same thing the scorecard does.
|
||||
*/
|
||||
|
||||
interface Bark {
|
||||
jp: string;
|
||||
en: string;
|
||||
}
|
||||
type Bank = Record<Group, { bad: Bark[]; good: Bark[] }>;
|
||||
|
||||
const BANK: Bank = {
|
||||
CUT: {
|
||||
bad: [
|
||||
{ jp: 'Kore wa… kizu da. Kire de wa nai.', en: 'This is a wound. Not a cut.' },
|
||||
{ jp: 'Katana ga naite iru.', en: 'The blade is weeping.' },
|
||||
{ jp: 'Tsubushita na.', en: 'You have crushed it.' },
|
||||
{ jp: 'Ao ga chigau. Hayasa ga chigau.', en: 'Wrong angle. Wrong speed.' },
|
||||
{ jp: 'Ryōri de wa naku, jiko da.', en: 'Not cooking. An accident.' },
|
||||
{ jp: 'Kono kire wa… fukōhei da.', en: 'This slice. It is unfair.' },
|
||||
{ jp: 'Hōchō o osore-sugi da.', en: 'You fear the knife too much.' },
|
||||
{ jp: 'Migoto ni, shippai da.', en: 'A splendid failure.' },
|
||||
],
|
||||
good: [
|
||||
{ jp: 'Kire wa… tadashii.', en: 'The cut. It is correct.' },
|
||||
{ jp: 'Ii ha-oto datta.', en: 'That was a good blade-sound.' },
|
||||
{ jp: 'Sore de ii.', en: 'That will do.' },
|
||||
],
|
||||
},
|
||||
SANDO: {
|
||||
bad: [
|
||||
{ jp: 'Sando… ga… yugande iru.', en: 'The sandwich. It leans.' },
|
||||
{ jp: 'Tō da. Sando de wa nai.', en: 'A tower. Not a sandwich.' },
|
||||
{ jp: 'Jūryoku o wasureta ka.', en: 'Have you forgotten gravity.' },
|
||||
{ jp: 'Chūshin ga… doko ni mo nai.', en: 'The centre is nowhere.' },
|
||||
{ jp: 'Kuzure-ochiru no o mita.', en: 'I watched it fall.' },
|
||||
{ jp: 'Chūmon to chigau.', en: 'This is not what was ordered.' },
|
||||
{ jp: 'Kaisō ga… hanran shite iru.', en: 'The layers are in revolt.' },
|
||||
{ jp: 'Kono katamuki wa… geijutsu ka.', en: 'This lean. Is it art.' },
|
||||
],
|
||||
good: [
|
||||
{ jp: 'Massugu da. Yoi.', en: 'Plumb. Good.' },
|
||||
{ jp: 'Chūmon-dōri da.', en: 'Exactly as ordered.' },
|
||||
{ jp: 'Sando ga… tatte iru.', en: 'The sandwich stands.' },
|
||||
],
|
||||
},
|
||||
SERVICE: {
|
||||
bad: [
|
||||
{ jp: 'Osoi. Kyaku wa toshi o totta.', en: 'Late. The customer has aged.' },
|
||||
{ jp: 'Rei o shiranu ka.', en: 'Do you know no manners.' },
|
||||
{ jp: 'Chigau sara da.', en: 'Wrong dish.' },
|
||||
{ jp: 'Ojigi wa… doko da.', en: 'Where. Was the bow.' },
|
||||
{ jp: 'Machikutabireta.', en: 'I have grown tired of waiting.' },
|
||||
{ jp: 'Dashinasai. …mada ka.', en: 'Serve it. …still.' },
|
||||
{ jp: 'Kono ma wa… mujō da.', en: 'This delay. It is merciless.' },
|
||||
{ jp: 'Teishutsu sarezu.', en: 'Never delivered.' },
|
||||
],
|
||||
good: [
|
||||
{ jp: 'Ojigi… uketotta.', en: 'The bow. Received.' },
|
||||
{ jp: 'Jikan-dōri da.', en: 'On time.' },
|
||||
{ jp: 'Reigi tadashii.', en: 'Properly mannered.' },
|
||||
],
|
||||
},
|
||||
FLOOR: {
|
||||
bad: [
|
||||
{ jp: 'Yuka o miyo.', en: 'Look at the floor.' },
|
||||
{ jp: 'Kore wa… genba da.', en: 'This is a crime scene.' },
|
||||
{ jp: 'Nani ka ga… shinda.', en: 'Something has died.' },
|
||||
{ jp: 'Suberu zo.', en: 'It is slippery.' },
|
||||
{ jp: 'Sōji wa dareka ga suru.', en: 'Someone will have to clean that.' },
|
||||
{ jp: 'Otoshi-sugi da.', en: 'Too much dropped.' },
|
||||
{ jp: 'Mottainai.', en: 'What a waste.' },
|
||||
{ jp: 'Yuka ni mo… sando ga aru.', en: 'There is sandwich on the floor too.' },
|
||||
],
|
||||
good: [
|
||||
{ jp: 'Yuka wa… kirei da.', en: 'The floor is clean.' },
|
||||
{ jp: 'Nani mo kobosazu.', en: 'Nothing spilled.' },
|
||||
{ jp: 'Seiketsu da.', en: 'Tidy.' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/** Deterministic index from a string — same detail → same bark, no RNG. */
|
||||
const hash = (s: string): number => {
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619);
|
||||
return (h >>> 0);
|
||||
};
|
||||
|
||||
export function barkFor(c: Criterion, good: boolean): Bark {
|
||||
const pool = BANK[c.group][good ? 'good' : 'bad'];
|
||||
return pool[hash(c.detail) % pool.length];
|
||||
}
|
||||
@ -4,6 +4,10 @@ import { Marionette } from '../sim/marionette';
|
||||
import { Cutter } from '../sim/cutting';
|
||||
import { spawnFood } from '../sim/food';
|
||||
import { Sando } from '../sim/stack';
|
||||
import { Game } from '../game/game';
|
||||
import type { Verdict } from '../game/judging';
|
||||
|
||||
const PORTRAITS = ['samurai_calm', 'samurai_watch', 'samurai_annoyed', 'samurai_angry', 'samurai_fury'];
|
||||
|
||||
export const STAGE_W = 1280;
|
||||
export const STAGE_H = 720;
|
||||
@ -28,18 +32,34 @@ export class Stage extends Phaser.Scene {
|
||||
puppet!: Marionette;
|
||||
cutter!: Cutter;
|
||||
sando!: Sando;
|
||||
session!: Game;
|
||||
crate!: MatterJS.BodyType;
|
||||
board!: MatterJS.BodyType;
|
||||
plate!: MatterJS.BodyType;
|
||||
private space!: Phaser.Input.Keyboard.Key;
|
||||
private samuraiImg?: Phaser.GameObjects.Image;
|
||||
private angerBar?: Phaser.GameObjects.Graphics;
|
||||
private vignette?: Phaser.GameObjects.Rectangle;
|
||||
private scorecard?: Phaser.GameObjects.Container;
|
||||
|
||||
constructor() {
|
||||
super('stage');
|
||||
}
|
||||
|
||||
preload(): void {
|
||||
const img = (k: string) => this.load.image(k, `assets/img/${k}.png`);
|
||||
['bg_washi', ...PORTRAITS].forEach(img);
|
||||
}
|
||||
|
||||
create(): void {
|
||||
this.matter.world.autoUpdate = false;
|
||||
|
||||
// washi backdrop (generated art; the game never blocks on it — a plain fill
|
||||
// shows if it failed to load).
|
||||
if (this.textures.exists('bg_washi')) {
|
||||
this.add.image(STAGE_W / 2, STAGE_H / 2, 'bg_washi').setDisplaySize(STAGE_W, STAGE_H).setDepth(-10);
|
||||
}
|
||||
|
||||
// floor + side walls
|
||||
this.addBody(this.matter.add.rectangle(STAGE_W / 2, FLOOR_Y + 20, STAGE_W, 40, { isStatic: true }), STAGE_W, 40, 0x2a211a);
|
||||
this.matter.add.rectangle(-20, STAGE_H / 2, 40, STAGE_H * 2, { isStatic: true });
|
||||
@ -63,10 +83,97 @@ export class Stage extends Phaser.Scene {
|
||||
this.addBody(this.plate, 140, 12, 0x2a211a);
|
||||
this.sando = new Sando(this, 980, 624);
|
||||
|
||||
this.session = new Game(this);
|
||||
|
||||
// HUD: the samurai judge (top-right), the anger meter, the unsheath vignette.
|
||||
if (this.textures.exists('samurai_calm')) {
|
||||
this.samuraiImg = this.add.image(STAGE_W - 150, 210, 'samurai_calm').setScale(0.42).setDepth(5);
|
||||
}
|
||||
this.angerBar = this.add.graphics().setDepth(6);
|
||||
this.vignette = this.add.rectangle(STAGE_W / 2, STAGE_H / 2, STAGE_W, STAGE_H, 0x1a0000, 0).setDepth(4);
|
||||
|
||||
this.space = this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE);
|
||||
installHarness(this);
|
||||
}
|
||||
|
||||
/** SHUBATTO — the string cut. Ragdoll the puppet, fling every loose food with
|
||||
* the slash impulse, flash the slash, sound the gong. Deterministic launch
|
||||
* (direction from each body's side) so the harness reproduces it. */
|
||||
stringCut(): void {
|
||||
this.puppet.cutStrings();
|
||||
const loose = [...this.cutter.foods.map((f) => f.body), ...this.sando.layers.map((l) => l.body)];
|
||||
for (const b of loose) {
|
||||
const dir = b.position.x < STAGE_W / 2 ? -1 : 1;
|
||||
this.matter.body.setVelocity(b, { x: dir * 7, y: -9 });
|
||||
this.matter.body.setAngularVelocity(b, dir * 0.3);
|
||||
}
|
||||
this.slashFx();
|
||||
this.gong();
|
||||
}
|
||||
|
||||
private slashFx(): void {
|
||||
const line = this.add.rectangle(STAGE_W / 2, STAGE_H / 2, STAGE_W * 1.5, 7, 0xffffff).setDepth(20).setRotation(-0.5);
|
||||
this.tweens.add({ targets: line, alpha: 0, duration: 450, onComplete: () => line.destroy() });
|
||||
}
|
||||
|
||||
private gong(): void {
|
||||
try {
|
||||
const ctx = new AudioContext();
|
||||
const o = ctx.createOscillator(), g = ctx.createGain();
|
||||
o.type = 'sine';
|
||||
o.frequency.value = 68;
|
||||
o.connect(g);
|
||||
g.connect(ctx.destination);
|
||||
g.gain.setValueAtTime(0.4, ctx.currentTime);
|
||||
g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 1.3);
|
||||
o.start();
|
||||
o.stop(ctx.currentTime + 1.3);
|
||||
} catch {
|
||||
/* no audio (headless / no gesture) — the cut still lands visually */
|
||||
}
|
||||
}
|
||||
|
||||
/** The scorecard over the wreckage: grade, the four groups, the bark. */
|
||||
showScorecard(v: Verdict): Phaser.GameObjects.Container {
|
||||
this.scorecard?.destroy();
|
||||
const g = this.add.container(0, 0).setDepth(30);
|
||||
const cx = STAGE_W / 2;
|
||||
g.add(this.add.rectangle(cx, 360, 640, 440, 0x120d08, 0.95).setStrokeStyle(3, 0x8a6a2a));
|
||||
g.add(this.add.text(cx, 190, v.grade, { fontFamily: 'serif', fontSize: '72px', color: '#efe4cb' }).setOrigin(0.5));
|
||||
let y = 262;
|
||||
for (const c of v.criteria) {
|
||||
const stars = '★'.repeat(Math.round(c.score * 5)).padEnd(5, '·');
|
||||
g.add(this.add.text(cx - 280, y, `${c.label.padEnd(12)} ${stars} ${c.detail}`, { fontFamily: 'monospace', fontSize: '14px', color: '#d8c8a8' }));
|
||||
y += 30;
|
||||
}
|
||||
y += 12;
|
||||
for (const l of v.lines) {
|
||||
g.add(this.add.text(cx, y, l.jp, { fontFamily: 'serif', fontSize: '18px', color: '#e8b84a' }).setOrigin(0.5));
|
||||
y += 24;
|
||||
g.add(this.add.text(cx, y, l.en, { fontFamily: 'serif', fontSize: '14px', color: '#9a8a6a', fontStyle: 'italic' }).setOrigin(0.5));
|
||||
y += 28;
|
||||
}
|
||||
this.scorecard = g;
|
||||
return g;
|
||||
}
|
||||
|
||||
clearScorecard(): void {
|
||||
this.scorecard?.destroy();
|
||||
this.scorecard = undefined;
|
||||
}
|
||||
|
||||
private updateHUD(): void {
|
||||
const sam = this.session.samurai;
|
||||
if (this.samuraiImg && this.textures.exists(sam.portrait)) this.samuraiImg.setTexture(sam.portrait);
|
||||
const bar = this.angerBar;
|
||||
if (bar) {
|
||||
bar.clear();
|
||||
bar.fillStyle(0x2a211a, 0.6).fillRect(STAGE_W - 270, 40, 224, 18);
|
||||
bar.fillStyle(sam.anger < 0.5 ? 0xc8a24a : 0x9a2020, 1).fillRect(STAGE_W - 268, 42, 220 * sam.anger, 14);
|
||||
}
|
||||
this.vignette?.setFillStyle(0x1a0000, sam.vignette * 0.42);
|
||||
}
|
||||
|
||||
/** Register a body with a flat rectangular silhouette that tracks it. */
|
||||
addBody(body: MatterJS.BodyType, w: number, h: number, color: number): MatterJS.BodyType {
|
||||
const view = this.add.rectangle(body.position.x, body.position.y, w, h, color);
|
||||
@ -121,5 +228,6 @@ export class Stage extends Phaser.Scene {
|
||||
pr.view.setPosition(pr.body.position.x, pr.body.position.y);
|
||||
pr.view.setRotation(pr.body.angle);
|
||||
}
|
||||
this.updateHUD();
|
||||
}
|
||||
}
|
||||
|
||||
@ -47,6 +47,7 @@ export class Marionette {
|
||||
private strings = {} as Record<'head' | 'back' | 'handL' | 'handR' | 'kneeL' | 'kneeR', MatterJS.ConstraintType>;
|
||||
private gripCon: (MatterJS.ConstraintType | null)[] = [null, null]; // [L, R]
|
||||
private gripped = false;
|
||||
cut = false; // strings severed → ragdoll
|
||||
|
||||
barX: number;
|
||||
readonly barHomeY = K.barHomeY;
|
||||
@ -92,9 +93,20 @@ export class Marionette {
|
||||
pin(this.seg.hips, { x: -14, y: 20 }, this.seg.legL, { x: 0, y: -55 });
|
||||
pin(this.seg.hips, { x: 14, y: 20 }, this.seg.legR, { x: 0, y: -55 });
|
||||
|
||||
// strings — soft, world-anchored to the control bar. This is the lag.
|
||||
const string = (b: MatterJS.BodyType, pb: Vec, len: number, k: number, d: number) =>
|
||||
m.add.worldConstraint(b, len, k, {
|
||||
this.buildStrings();
|
||||
|
||||
for (const key of Object.keys(this.seg) as Seg[]) {
|
||||
this.home0[key] = { x: this.seg[key].position.x, y: this.seg[key].position.y };
|
||||
}
|
||||
this.setBar(this.barX, K.barHomeY, 0);
|
||||
}
|
||||
|
||||
/** (Re)create the 6 control strings from the bar to the puppet. Called at
|
||||
* build and again after a string cut when the puppet is restrung. */
|
||||
private buildStrings(): void {
|
||||
const m = this.stage.matter;
|
||||
const string = (b: MatterJS.BodyType, pb: Vec, length: number, k: number, d: number) =>
|
||||
m.add.worldConstraint(b, length, k, {
|
||||
pointA: { x: this.barX, y: K.barHomeY },
|
||||
pointB: pb, damping: d, render: { visible: false },
|
||||
});
|
||||
@ -106,17 +118,28 @@ export class Marionette {
|
||||
this.strings.handR = string(this.seg.lArmR, { x: 0, y: 23 }, 335, K.handK, K.handD);
|
||||
this.strings.kneeL = string(this.seg.legL, { x: 0, y: -15 }, 395, K.kneeK, K.kneeD);
|
||||
this.strings.kneeR = string(this.seg.legR, { x: 0, y: -15 }, 395, K.kneeK, K.kneeD);
|
||||
this.cut = false;
|
||||
}
|
||||
|
||||
for (const key of Object.keys(this.seg) as Seg[]) {
|
||||
this.home0[key] = { x: this.seg[key].position.x, y: this.seg[key].position.y };
|
||||
/** SHUBATTO. Every control string deleted in one frame — the puppet becomes a
|
||||
* ragdoll (pin joints survive, so it stays connected as it collapses). Drops
|
||||
* anything held. The samurai's fail-state; the whole game points at this. */
|
||||
cutStrings(): void {
|
||||
if (this.cut) return;
|
||||
this.setGrip(false);
|
||||
for (const key of Object.keys(this.strings) as (keyof Marionette['strings'])[]) {
|
||||
this.stage.matter.world.remove(this.strings[key]);
|
||||
}
|
||||
this.setBar(this.barX, K.barHomeY, 0);
|
||||
this.strings = {} as Marionette['strings'];
|
||||
this.cut = true;
|
||||
}
|
||||
|
||||
/** Teleport every segment back to its start pose and drop anything held.
|
||||
* Harness-only — lets tests run back to back from a known state. */
|
||||
* Harness-only — lets tests run back to back from a known state. Restrings
|
||||
* the puppet if a string cut had happened. */
|
||||
reset(homeX = 400): void {
|
||||
this.setGrip(false);
|
||||
if (this.cut) this.buildStrings();
|
||||
const b = this.stage.matter.body;
|
||||
const dx = homeX - 400;
|
||||
for (const key of Object.keys(this.seg) as Seg[]) {
|
||||
@ -152,6 +175,7 @@ export class Marionette {
|
||||
/** Raw pointer/harness input → bar move + grip. Tilt comes from how fast the
|
||||
* bar is travelling sideways; LMB (reach) drops the bar to grab low. */
|
||||
applyInput(mouseX: number, mouseY: number, reach: boolean, grip: boolean): void {
|
||||
if (this.cut) return; // no strings to pull
|
||||
const clamp = (v: number, a: number, b: number) => (v < a ? a : v > b ? b : v);
|
||||
const x = clamp(mouseX, K.railMin, K.railMax);
|
||||
const y = clamp(mouseY, K.barCeil, K.barFloor) + (reach ? K.reachDrop : 0);
|
||||
|
||||
70
src/sim/samurai.ts
Normal file
70
src/sim/samurai.ts
Normal file
@ -0,0 +1,70 @@
|
||||
/**
|
||||
* The samurai — toastsim's judge wearing armour. An anger meter that fills on
|
||||
* every kitchen sin and ebbs on a clean serve. Each quarter unsheaths the
|
||||
* katana one stage further; at full, he strikes: the string cut. He is the
|
||||
* fail-state AND the voice.
|
||||
*/
|
||||
|
||||
export type AngerEvent =
|
||||
| 'drop' | 'splat' | 'crimeCut' | 'servedWrong' | 'servedLate' | 'offstage' | 'cleanServe';
|
||||
|
||||
/** How much each sin moves the meter (0..1). cleanServe sheathes. */
|
||||
export const ANGER: Record<AngerEvent, number> = {
|
||||
drop: 0.06,
|
||||
splat: 0.14,
|
||||
crimeCut: 0.16,
|
||||
servedWrong: 0.34,
|
||||
servedLate: 0.03, // per ramp tick
|
||||
offstage: 0.3,
|
||||
cleanServe: -0.14,
|
||||
};
|
||||
|
||||
/** 0 calm → 4 fury(strike). The 5 generated portraits map to these. */
|
||||
export const PORTRAITS = ['samurai_calm', 'samurai_watch', 'samurai_annoyed', 'samurai_angry', 'samurai_fury'] as const;
|
||||
|
||||
export class Samurai {
|
||||
anger = 0; // 0..1
|
||||
struck = false;
|
||||
/** Fired once when the meter tops out — the stage wires the string cut here. */
|
||||
onStrike?: () => void;
|
||||
/** Fired on every meter change for the HUD/portrait. */
|
||||
onChange?: (anger: number, stage: number) => void;
|
||||
/** The last event, for barks. */
|
||||
last: AngerEvent | null = null;
|
||||
|
||||
/** Apply an anger event (optionally overriding the amount, e.g. ramped late). */
|
||||
bump(ev: AngerEvent, amount = ANGER[ev]): void {
|
||||
this.last = ev;
|
||||
this.anger = Math.max(0, Math.min(1, this.anger + amount));
|
||||
this.onChange?.(this.anger, this.stage);
|
||||
if (this.anger >= 1 && !this.struck) this.strike();
|
||||
}
|
||||
|
||||
/** 0..3 unsheath stage while alive; 4 once he has struck. */
|
||||
get stage(): number {
|
||||
if (this.struck) return 4;
|
||||
return this.anger < 0.25 ? 0 : this.anger < 0.5 ? 1 : this.anger < 0.75 ? 2 : 3;
|
||||
}
|
||||
|
||||
get portrait(): string {
|
||||
return PORTRAITS[this.stage];
|
||||
}
|
||||
|
||||
/** Vignette strength 0..1 for the screen darkening as he unsheaths. */
|
||||
get vignette(): number {
|
||||
return this.anger < 0.5 ? 0 : (this.anger - 0.5) / 0.5;
|
||||
}
|
||||
|
||||
private strike(): void {
|
||||
this.struck = true;
|
||||
this.onChange?.(this.anger, 4);
|
||||
this.onStrike?.();
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.anger = 0;
|
||||
this.struck = false;
|
||||
this.last = null;
|
||||
this.onChange?.(0, 0);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user