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
|
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
|
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).
|
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,
|
- **M4 ✅** samurai + full loop. `sim/samurai.ts` (anger meter 0..1, events
|
||||||
serve+bow, scorecard, judge voice. The third must-feel: **the string cut**.
|
drop/splat/crimeCut/servedWrong/servedLate/offstage/cleanServe, 5 unsheath
|
||||||
- Run: `npm run dev` (:5173). Harness: `__s.dangle/walk/pickupTest/chop/push/
|
stages → the generated portraits, vignette), `game/judging.ts` (4 groups
|
||||||
wildChop/stackGood/stackBad/nudge`. Headless: `npm run check`.
|
**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);
|
hold(HOME_X, p().barHomeY, 20);
|
||||||
const x0 = p().torsoX;
|
const x0 = p().torsoX;
|
||||||
const frames = Math.round(secs * 60);
|
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++) {
|
for (let i = 0; i < frames; i++) {
|
||||||
const center = HOME_X + dir * advance * (i / frames);
|
const center = HOME_X + dir * advance * (i / frames);
|
||||||
const x = center + amp * Math.sin((2 * Math.PI * freq * i) / 60);
|
const x = center + amp * Math.sin((2 * Math.PI * freq * i) / 60);
|
||||||
p().applyInput(x, p().barHomeY, false, false);
|
p().applyInput(x, p().barHomeY, false, false);
|
||||||
stage.stepSim();
|
stage.stepSim();
|
||||||
if (!p().pose().upright) upright = false;
|
lowestHead = Math.max(lowestHead, p().seg.head.position.y);
|
||||||
}
|
}
|
||||||
const dx = p().torsoX - x0;
|
const dx = p().torsoX - x0;
|
||||||
|
const finalPose = p().pose();
|
||||||
|
const faceplant = lowestHead > FLOOR_Y - 70 || !finalPose.upright;
|
||||||
return {
|
return {
|
||||||
units: +(dx / STAGE_UNIT).toFixed(2),
|
units: +(dx / STAGE_UNIT).toFixed(2),
|
||||||
dx: +dx.toFixed(1),
|
dx: +dx.toFixed(1),
|
||||||
upright,
|
upright: !faceplant,
|
||||||
faceplant: !upright,
|
faceplant,
|
||||||
lean: p().pose().lean,
|
lean: finalPose.lean,
|
||||||
|
lowestHead: +lowestHead.toFixed(0),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -255,6 +261,70 @@ export function installHarness(stage: Stage): void {
|
|||||||
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 };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ---- 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;
|
(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 { Cutter } from '../sim/cutting';
|
||||||
import { spawnFood } from '../sim/food';
|
import { spawnFood } from '../sim/food';
|
||||||
import { Sando } from '../sim/stack';
|
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_W = 1280;
|
||||||
export const STAGE_H = 720;
|
export const STAGE_H = 720;
|
||||||
@ -28,18 +32,34 @@ export class Stage extends Phaser.Scene {
|
|||||||
puppet!: Marionette;
|
puppet!: Marionette;
|
||||||
cutter!: Cutter;
|
cutter!: Cutter;
|
||||||
sando!: Sando;
|
sando!: Sando;
|
||||||
|
session!: Game;
|
||||||
crate!: MatterJS.BodyType;
|
crate!: MatterJS.BodyType;
|
||||||
board!: MatterJS.BodyType;
|
board!: MatterJS.BodyType;
|
||||||
plate!: MatterJS.BodyType;
|
plate!: MatterJS.BodyType;
|
||||||
private space!: Phaser.Input.Keyboard.Key;
|
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() {
|
constructor() {
|
||||||
super('stage');
|
super('stage');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
preload(): void {
|
||||||
|
const img = (k: string) => this.load.image(k, `assets/img/${k}.png`);
|
||||||
|
['bg_washi', ...PORTRAITS].forEach(img);
|
||||||
|
}
|
||||||
|
|
||||||
create(): void {
|
create(): void {
|
||||||
this.matter.world.autoUpdate = false;
|
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
|
// 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.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 });
|
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.addBody(this.plate, 140, 12, 0x2a211a);
|
||||||
this.sando = new Sando(this, 980, 624);
|
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);
|
this.space = this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE);
|
||||||
installHarness(this);
|
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. */
|
/** Register a body with a flat rectangular silhouette that tracks it. */
|
||||||
addBody(body: MatterJS.BodyType, w: number, h: number, color: number): MatterJS.BodyType {
|
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);
|
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.setPosition(pr.body.position.x, pr.body.position.y);
|
||||||
pr.view.setRotation(pr.body.angle);
|
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 strings = {} as Record<'head' | 'back' | 'handL' | 'handR' | 'kneeL' | 'kneeR', MatterJS.ConstraintType>;
|
||||||
private gripCon: (MatterJS.ConstraintType | null)[] = [null, null]; // [L, R]
|
private gripCon: (MatterJS.ConstraintType | null)[] = [null, null]; // [L, R]
|
||||||
private gripped = false;
|
private gripped = false;
|
||||||
|
cut = false; // strings severed → ragdoll
|
||||||
|
|
||||||
barX: number;
|
barX: number;
|
||||||
readonly barHomeY = K.barHomeY;
|
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.legL, { x: 0, y: -55 });
|
||||||
pin(this.seg.hips, { x: 14, y: 20 }, this.seg.legR, { 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.
|
this.buildStrings();
|
||||||
const string = (b: MatterJS.BodyType, pb: Vec, len: number, k: number, d: number) =>
|
|
||||||
m.add.worldConstraint(b, len, k, {
|
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 },
|
pointA: { x: this.barX, y: K.barHomeY },
|
||||||
pointB: pb, damping: d, render: { visible: false },
|
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.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.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.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[]) {
|
/** SHUBATTO. Every control string deleted in one frame — the puppet becomes a
|
||||||
this.home0[key] = { x: this.seg[key].position.x, y: this.seg[key].position.y };
|
* 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.
|
/** 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 {
|
reset(homeX = 400): void {
|
||||||
this.setGrip(false);
|
this.setGrip(false);
|
||||||
|
if (this.cut) this.buildStrings();
|
||||||
const b = this.stage.matter.body;
|
const b = this.stage.matter.body;
|
||||||
const dx = homeX - 400;
|
const dx = homeX - 400;
|
||||||
for (const key of Object.keys(this.seg) as Seg[]) {
|
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
|
/** 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. */
|
* bar is travelling sideways; LMB (reach) drops the bar to grab low. */
|
||||||
applyInput(mouseX: number, mouseY: number, reach: boolean, grip: boolean): void {
|
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 clamp = (v: number, a: number, b: number) => (v < a ? a : v > b ? b : v);
|
||||||
const x = clamp(mouseX, K.railMin, K.railMax);
|
const x = clamp(mouseX, K.railMin, K.railMax);
|
||||||
const y = clamp(mouseY, K.barCeil, K.barFloor) + (reach ? K.reachDrop : 0);
|
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