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:
type-two 2026-07-17 21:08:37 +10:00
parent 7116b71d17
commit 58285712ef
8 changed files with 583 additions and 16 deletions

View File

@ -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 SD), `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 M1M3 verbs. Headless: `npm run check`.

View File

@ -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
View 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
View 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
View 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];
}

View File

@ -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();
}
}

View File

@ -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);
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.cut = false;
}
this.setBar(this.barX, K.barHomeY, 0);
/** 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.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
View 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);
}
}