Compare commits

...

10 Commits

Author SHA1 Message Date
type-two
26921eb4ad Build-out 2: the playable loop — detection stacking, live serve, string picker, 8 days
- Sando is zone-DETECTION now: the stack = food bodies physically resting in
  the plate zone, bottom->top. Hand-stacked and harness-stacked sandos are
  judged identically (sando.kinds() is what the judge reads).
- The full live loop: Game.state + updateLive() — ticket clock with late-anger
  (1 bump/3s overdue), bow-dwell serve (hold still 0.5s over a matching
  standing sando -> scorecard), click -> next day; string cut -> ragdoll ->
  fail card -> click retries the same day with fresh strings. setupDay stages
  bread + fillings per order; sins fire live off cutter.onCut + offstage sweep.
- String unlockables: HEMP/RUBBER/CHAIN/SILK picker on the title (stiffness/
  damping multipliers, registry-persisted). Rubber visibly stretches.
- Days 6-8: Tamago (it rolls), Tofu (fragile), THE TOWER (5 layers) + tofu food.
- samurai_bow (same seed): an S serve swaps in the single approving nod.
- Battery: M1 87f/8.41u/10-10, M2 cv0 clean, M3 both, days SSSSSSSS, live
  serve commits at 31f (~0.5s), death->retry + serve->advance verified.
- Known: embedded preview pane never fires rAF (visibilityState hidden) — live
  loop freezes there by browser design; harness steps manually. Real tabs fine.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 17:06:22 +10:00
type-two
4f4b31b914 Deploy to partly.party/sandoniette
- vite.config.ts: dev base '/', build base '/sandoniette/' (subpath deploy).
- Asset loads use import.meta.env.BASE_URL so paths resolve at the subpath
  regardless of trailing slash. vite-env.d.ts for the env types.
- deploy.sh: build -> rsync dist/ -> docker cp into forum-nginx web root
  /usr/share/nginx/html/sandoniette (matches the partly.party games pattern).
- Verified LIVE: index+JS 200, JS served as application/javascript (not the
  HTML-fallback failure), PNGs image/png, title screen renders in-browser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 21:55:56 +10:00
type-two
506a57fa89 Polish: visible strings + control bar, cut-out faces, cleaver
- The soul of a marionette game: draw the 6 strings + the wooden control bar
  every frame (Marionette.stringSegments/barSegment, Stage.drawStrings). Gone
  once the strings are cut. It finally reads as a puppet on strings.
- Procedural cut-out faces: two eyes + a worried 'o' punched into each whole-food
  silhouette in the bright washi tone (backlit-hole look, fits the aesthetic).
  Pieces stay faceless. (The generated face PNGs were full-colour cartoons that
  clashed with the silhouettes — this replaces them.)
- Order ticket HUD (top-left) + a grabbable cleaver whose held edge drives the
  live cutter — swinging it chops food. __s.swingCleaver() proves the wiring:
  a held blade whipped through food cuts clean (tip speed ~150 >> threshold).
- All M1-M6 harness numbers unchanged; typecheck + npm run check + build pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 21:46:15 +10:00
type-two
077e970b16 M6: juice — title screen, Web Audio SFX (all 6 milestones ship)
- scenes/title.ts: sumi-brush title_art + サンドニエット + pulsing SHUBATTO!!
  SANDONIETTE!!! + click-to-begin. Launched as an overlay over the live stage
  so the harness is up from frame one; __s.startGame() dismisses it.
- game/audio.ts: 100% synthesized Web Audio SFX (gong / slash whoosh / wet
  splat / shamisen), lazy AudioContext, no-op when headless, unblocked on first
  click. Wired: slash+gong on the string cut, splat on squash, shamisen on start.
- Deferred (pre-authorized): food faces (generated art is full-colour cartoons
  that clash with the black-silhouette look — would need regen as cutouts);
  string unlockables (M5 lands fine without). Attract ragdoll -> pulsing title.
- Full M1-M6 battery green; typecheck + npm run check + production build pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 21:19:12 +10:00
type-two
cdde03bef4 M5: days + escalation — one new pressure per day
- DAYS table (game/game.ts): 5 days, each introduces exactly one pressure —
  basics / +1 ingredient / wetter (2x tomato) / a real cut (katsu) / patience
  ramp (maxSeconds 45->22, angerRamp 1.8). Game.setDay loads order + ramp.
- samurai.ramp scales every sin (sheathing unaffected) — day 5 the samurai
  runs out of patience faster.
- Exit-bar: __s.playAllDays() — every day beatable by a scripted-honest run
  (all S, standing). Ramp bites: day 1 = 7 crime-cuts to strike, day 5 = 4.
  Verified in-browser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 21:12:22 +10:00
type-two
58285712ef 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>
2026-07-17 21:08:37 +10:00
type-two
7116b71d17 M3: stacking — the sando, scored + earthquake nudge
- sim/stack.ts: ordered layers on a plate zone, honest Matter stacking scored
  by lean/COM-drift/overhang. standing = elevation (a toppled tower loses
  height), not lean angle — a short stack reads 30deg from a 24px step.
- nudge() earthquake discriminates: a centred sando survives (lean 0->2.4),
  a marginally-leaning one (lean 44) topples on the shake.
- Grip pickup fixed: was non-deterministic (trials shared puppet state) and
  unreliable (a sideways bar yank only stretches the arms — they hang from the
  shoulders). Now shiftBodyTo() walks the body over the crate first, and every
  trial resets to home -> a true, deterministic 10/10.
- Exit-bar (full battery): M1 dangle 97f / walk 8.78u upright / grip 10/10;
  M2 chop 2p cv0 clean / push splat 3.28 / wild offstage; M3 good survives,
  bad toppled-by-quake. Verified in-browser + npm run check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:52:05 +10:00
type-two
ea2c8c5089 M2: cutting — honest 2D polygon split, materials, splat
- sim/geom.ts: pure convex-polygon split by a line + pieceCV/cvWord ladder.
- sim/food.ts: convex polygon foods (bread/tomato/cheese/katsu/egg) with
  toastsim material params (skin.resistance, flesh.moisture, wobble).
- sim/cutting.ts: blade pass-through detection -> split food into 2 real Matter
  bodies along the chord; slow press on a wet food squashes into a splat decal
  instead. Cut quality clean/ragged/crime by peak speed x chord fraction.
- Two traps fixed: (1) cut line through a vertex made lopsided halves -> split
  now shares on-line verts (regression in geom.check.ts); (2) Matter setVelocity
  is px/STEP not px/s -> piece launch was 60x too fast, flung halves to y=26000.
- Exit-bar: chop -> 2 clean pieces CV 0 (<0.1); push -> 0 pieces + splat 3.28;
  wild swing -> piece offstage. Verified in-browser + npm run check (headless).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:38:27 +10:00
type-two
da840b531a M1: the marionette — dangle, walk, sticky grip
- sim/marionette.ts: control bar + 6 soft strings (head/back/2 hands/2 knees)
  over a 9-segment silhouette. Bar snaps to mouse; the felt lag is string
  stiffness<1 (worldConstraints), not an input buffer.
- Feet planted on floor + head+back strings keep the torso upright (no topple);
  earlier pure-pendulum rig spun past 360deg — the back string fixed it.
- Sticky grip: while gripped, any empty hand latches the nearest target on
  contact (puppet.update() each step). Reach = LMB drops the bar.
- Harness verbs: bar/sway/grip/pose/settle + dangle/walk/pickupTest + reset.
- Exit-bar measured: dangle settles 87 frames upright (lean 4.6); walk 8.84
  units upright (>6 req, lean 30<45); grip 10/10. Verified in-browser + shot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:18:56 +10:00
type-two
802f738a3d M0: vite+ts+phaser+Matter skeleton, harness, box drops flush
- Phaser 3.90 Matter, autoUpdate:false — deterministic stepSim(dt), harness-driven.
- Render contract: raw Matter body = truth, flat black silhouette follows.
- Rng (mulberry32) ported from toastsim; dev harness on window.__s.
- Exit-bar measured: box drops y=80 -> settles 68 frames -> rests y=608.05,
  gap -0.05px flush on floor, landed:true. Verified in-browser + screenshot.
- scripts/gen-assets.sh (2D-only flux via MODELBEAST) firing in background.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:02:27 +10:00
47 changed files with 3866 additions and 0 deletions

12
.claude/launch.json Normal file
View File

@ -0,0 +1,12 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 5173,
"autoPort": true
}
]
}

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules
dist
*.local
.DS_Store

View File

@ -1,5 +1,144 @@
# サンドニエット SANDONIETTE — Build Brief 1: The Puppet, The Food, The Samurai
## STATUS (update this as you go — a cold session reads here first)
- **LIVE 🌐** https://partly.party/sandoniette/ — `bash deploy.sh` (build base
`/sandoniette/` → rsync dist/ → docker cp into `forum-nginx:/usr/share/nginx/html/sandoniette`).
Games VPS `humanjing@100.71.119.27`, Cloudflare-fronted (purge cache after big
changes). Verified live: index+JS 200, JS is application/javascript, PNGs image/png,
title renders. `vite.config.ts` bases dev at `/`, build at `/sandoniette/`; asset
loads use `import.meta.env.BASE_URL`. Local preview needs `vite preview --base=/sandoniette/`.
- **M0 ✅** vite+ts+phaser(3.90)+Matter skeleton, harness (`window.__s`), typecheck green.
Box drops → settles 68 frames, rests flush on floor.
- **M1 ✅** `sim/marionette.ts` — control bar + 6 strings (head, **back**, 2 hands,
2 knees) over a 9-segment silhouette. Bar snaps to mouse; lag comes from soft
worldConstraints (stiffness<1). Feet planted on floor + head/back strings = stable
upright stand, no topple. Grip is **sticky** (latches on contact via `puppet.update()`
each step). Exit-bar measured: **dangle** settles 87 frames upright (lean 4.6°);
**walk** 8.84 units upright (>6 req, lean 30° < 45 topple); **grip 10/10**. Verified
in-browser + walk screenshot.
- Stack: Phaser Matter, `autoUpdate:false` — sim steps only via `stage.stepSim(dt)`
(which also calls `puppet.update()`). Render contract: Matter body = truth, flat
silhouette `Prop.view` follows. `Rng` = mulberry32. Tuning knobs: `K` at top of
marionette.ts. Harness `reset(homeX)` teleports to a known pose for back-to-back tests.
- Assets: all 20 2D props rendered on MODELBEAST → `public/assets/img/` (samurai×5,
bg_washi, floor_boards, title, tickets, slash, 5 food faces, splat). Not wired in
yet — procedural silhouettes carry gameplay; art is backdrop/faces/juice (M4/M6).
- **M2 ✅** cutting. `sim/geom.ts` (pure convex-split + pieceCV/cvWord), `sim/food.ts`
(convex polygon foods: bread/tomato/cheese/katsu/egg, material params
resistance/moisture/wobble), `sim/cutting.ts` (blade pass-through → split food
into 2 real bodies along the chord, or squash→splat on a slow wet press).
Honest geometry, no canned slice anim. Traps hit + fixed: (a) cut line through a
vertex made lopsided halves — split now treats on-line verts as shared (regression
guarded in `geom.check.ts`); (b) **Matter `setVelocity` is px/STEP not px/s**
piece launch was 60× too fast and flung halves offstage. Exit-bar measured:
**chop** 2 clean pieces CV 0 (<0.1 req); **push** 0 pieces + splat 3.28 (wet crime);
**wild** swing flings a piece offstage. Verified in-browser (two clean halves rest
on the board) + `npm run check` (headless geom asserts).
- **M3 ✅** stacking. `sim/stack.ts` — the sando: ordered layers on a plate zone
(x=980), honest Matter stacking scored by lean°, COM drift, overhang. `standing`
is measured by **elevation** (a toppled tower loses height) not lean angle — a
short 3-layer stack reads 30° from a mere 24px step, so lean alone is a bad
topple test. Trap fixed while here: **grip pickup was non-deterministic** (trials
shared puppet state) AND unreliable (yanking the bar sideways just stretches the
arms — they hang from the shoulders, so the BODY must drift over the crate first;
`shiftBodyTo` waits for the hips to arrive, then reach). Now a true 10/10.
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).
- **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).
- **M5 ✅** days + escalation. `DAYS` in `game/game.ts` — 5 days, each ONE new
pressure: (1) basics, (2) +1 ingredient (4 layers), (3) wetter (2× tomato),
(4) a real cut (katsu), (5) patience ramp (maxSeconds 45→22, angerRamp 1.8).
`Game.setDay(n)` loads order + ramp; sins scale by `samurai.ramp` (sheathing
doesn't). Exit-bar: `__s.playAllDays()` — every day beatable by a scripted-
honest run (all S, standing). Ramp bites: day 1 needs 7 crime-cuts to strike,
day 5 only 4. Verified in-browser.
- **M6 ✅** juice. Title scene (`scenes/title.ts`) — sumi brush `title_art` +
「サンドニエット」+ pulsing **SHUBATTO!! SANDONIETTE!!!** + click-to-begin;
launched as an overlay over the live stage so the harness is up from frame one
(`__s.startGame()` dismisses it). `game/audio.ts` — 100% synth Web Audio SFX
(gong / slash whoosh / wet splat / shamisen), lazy AudioContext, no-op headless,
unblocked on first click. Wired: slash+gong on the string cut, splat on wet
squash, shamisen on title start. **Deferred (pre-authorized judgment):** food
faces — the generated faces are full-colour cartoons on paper, they clash with
the coherent black-silhouette look; would need regen as true cutouts. String
unlockables (rubber/chain/silk) — pre-authorized defer, M5 lands fine without.
Attract ragdoll → the pulsing title. Verified: title screenshot, click path,
production `npm run build` clean.
## BUILD-OUT PASS 2 ✅ — the playable loop, string unlockables, 8 days
- **Physical stacking**`Sando` is now zone-DETECTION: the stack = whatever food
bodies rest in the plate zone (±82px, below-flight speed), sorted bottom→top.
A human stacking by marionette hand and the harness are judged identically.
`sando.kinds()` (strips piece `p` suffixes) is what the judge reads.
- **The full live loop**`Game.state` (playing/served/dead) + `updateLive()`:
ticket clock with late-anger ramp (1 bump / 3s overdue), the **bow-dwell serve**
(hold the bar still 0.5s over a matching standing sando → scorecard), click →
next day (`registry sando.day` → `scene.restart`), string cut → ragdoll →
fail scorecard → click retries the SAME day with fresh strings. Day setup
(`Stage.setupDay`) stages bread slices + one whole food per non-bread kind on
the board. Live sins wired off `cutter.onCut` (squash→splat, botch→crimeCut) +
offstage sweep. (ponytail: no floor-drop sin yet — add when a day needs it.)
- **String unlockables** — title-screen picker: HEMP/RUBBER/CHAIN/SILK
(`STRING_STYLES` k/d multipliers on the same rig; registry `sando.style`).
Rubber visibly stretches — verified.
- **Days 68**: Tamago (egg rolls), Tofu (fragile), THE TOWER (5 layers). All 8
days S via harness. Live serve: dwell commits at **31 frames ≈ 0.5s**, grade B
(no cut performed — honest), day 8 same; death→retry + serve→advance both
close the loop (verified by simulated clicks + manual queue flush).
- **samurai_bow.png** (same seed 320) wired: an S-grade serve swaps the portrait
to the single approving nod. counter_wood/day_stamp/face_cut_bread banked in
`public/assets/img/`, unwired.
- **Embedded-pane trap (KNOWN, not a bug)**: the preview pane reports
`document.visibilityState='hidden'` → rAF never fires → live loop + queued
scene ops freeze; harness drives frames manually, and
`game.scene.processQueue()` flushes ops by hand. Real visible tabs unaffected.
- Full battery green: M1 87f/8.41u/10-10 · M2 cv0-clean/splat/wild · M3 ✓✓ ·
days SSSSSSSS · live served@31f / d8 served / dead CUT · cleaver CUTS.
## POLISH PASS (post-M6) ✅ — "make it even better"
- **Visible strings + control bar** — the biggest missed feel. `Marionette.stringSegments()`
/ `barSegment()`; `Stage.drawStrings()` redraws the 6 strings + the wooden bar each
frame (gone when cut). It now unmistakably reads as a marionette.
- **Cut-out faces** — procedural: two eyes + a worried "o" punched into each whole-food
silhouette in the bright washi tone (backlit-hole look). Pieces get no face.
(The generated face PNGs were full-colour cartoons that clashed — this fits.)
- **Order ticket HUD** (top-left) + **the cleaver**: a grabbable blade; held in a hand,
its edge drives the live cutter — swinging chops. Proven: `__s.swingCleaver()` cuts
clean (tip speed ~150, well over threshold). Full manual play (walk to blade, carry,
chop, stack, serve across the stage) is the intended-hard game; the wiring is there.
- All M1M6 harness numbers unchanged (strings/faces/cleaver don't perturb the sim):
dangle 87f · walk 8.83u up · grip 10/10 · chop cv0 clean · days SSSSS · disaster→cut.
## ALL SIX MILESTONES SHIP ✅ — full loop plays, three must-feels landed
(the dangle M1, the clean chop M2, the string cut M4). Final battery green:
M1 dangle 97f / walk 8.83u up / grip 10/10 · M2 chop cv0 clean / splat / offstage ·
M3 good survives + bad toppled · M4 disaster→string-cut ragdoll · M5 days 1-5 all
beatable. `npm run typecheck` + `npm run check` + `npm run build` all pass.
Possible next (Brief 2 territory): food faces as true silhouette cutouts, string
unlockables, more days/orders, real attract-mode, mobile/touch controls.
*"SHUBATTO! SANDO-NI-ETTE!!!" — read this file and execute it. It is written to
work cold: a fresh session with no other context should be able to build this
game from here. Sibling project: `~/Documents/toastsim` — do NOT touch it, but

39
deploy.sh Executable file
View File

@ -0,0 +1,39 @@
#!/usr/bin/env bash
# サンドニエット SANDONIETTE — deploy to partly.party/sandoniette/
# Games VPS: humanjing@100.71.119.27 (tailscale), docker container forum-nginx,
# nginx serves /usr/share/nginx/html/<game>/ at partly.party/<game>/.
#
# Usage: bash deploy.sh (builds fresh, then ships dist/)
set -euo pipefail
VPS="humanjing@100.71.119.27"
CONTAINER="forum-nginx"
WEB_ROOT="/usr/share/nginx/html/sandoniette"
STAGING="/tmp/sandoniette_deploy"
DIR="$(cd "$(dirname "$0")" && pwd)"
echo "[1/5] Building (base=/sandoniette/) ..."
( cd "$DIR" && npm run build >/dev/null )
echo " ✓ dist/ ready"
echo "[2/5] Rsync dist/ → VPS staging ..."
ssh "$VPS" "rm -rf '$STAGING' && mkdir -p '$STAGING'"
rsync -az --delete "$DIR/dist/" "$VPS:$STAGING/"
echo " ✓ synced"
echo "[3/5] Reset container web root ..."
ssh "$VPS" "docker exec $CONTAINER sh -c 'rm -rf ${WEB_ROOT:?} && mkdir -p ${WEB_ROOT:?}'"
echo "[4/5] Copy bundle into container ..."
ssh "$VPS" "docker cp $STAGING/. $CONTAINER:$WEB_ROOT/"
ssh "$VPS" "rm -rf '$STAGING'"
echo " ✓ live files in place"
echo "[5/5] Verify ..."
code=$(curl -s -o /dev/null -w '%{http_code}' https://partly.party/sandoniette/ || true)
js=$(curl -s https://partly.party/sandoniette/ | grep -o '/sandoniette/assets/[^"]*\.js' | head -1)
jscode=$(curl -s -o /dev/null -w '%{http_code}' "https://partly.party$js" || true)
echo " index: $code $js: $jscode"
echo ""
echo "✓ Live at https://partly.party/sandoniette/ (Cloudflare-fronted; purge cache after big changes)"

17
index.html Normal file
View File

@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>サンドニエット SANDONIETTE</title>
<style>
html, body { margin: 0; height: 100%; background: #1a1410; overflow: hidden; }
#app { width: 100vw; height: 100vh; display: grid; place-items: center; }
canvas { max-width: 100%; max-height: 100%; }
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1176
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
package.json Normal file
View File

@ -0,0 +1,20 @@
{
"name": "sandoniette",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"check": "node src/sim/geom.check.ts"
},
"dependencies": {
"phaser": "^3.90.0"
},
"devDependencies": {
"typescript": "^5.6.0",
"vite": "^6.0.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 503 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 546 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 492 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 513 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 498 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 559 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 752 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 664 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 720 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 708 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 700 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 824 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 725 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 486 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

93
scripts/gen-assets.sh Executable file
View File

@ -0,0 +1,93 @@
#!/usr/bin/env bash
# SANDONIETTE asset generation — 2D ONLY, 100% local/free, via MODELBEAST.
# ./scripts/gen-assets.sh # generate all missing 2D assets
# Seeds fixed so every asset is re-generatable. Style token shared for coherence.
# The game NEVER blocks on these — procedural fallbacks cover every prop.
set -uo pipefail
MB_DIR="$HOME/MODELBEAST"
[ -d "$MB_DIR" ] || MB_DIR="$HOME/Documents/MODELBEAST"
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
OUT_IMG="$REPO/public/assets/img"
mkdir -p "$OUT_IMG"
cd "$MB_DIR" || exit 1
# shellcheck disable=SC1091
if [ -f data/agent.env ]; then source data/agent.env
else set -a; source "$HOME/Documents/backnforth/.env"; set +a; fi
export MB_HOST="${MB_HOST:-http://100.89.131.57:8777}"
STYLE="japanese shadow puppet theatre, flat black silhouette with cut-out details, warm washi paper backdrop, edo period, high contrast, hand-crafted paper texture"
# ponytail: m4pro venvs dead (2026-07-17) — jobs there fail in ~1s, so retry up
# to 10x with 60s pauses until a healthy node takes it. Drop once m4pro is fixed.
MB_TRIES=10 MB_PAUSE=60
run_flux() {
local seed="$1" prompt="$2" w="${3:-1024}" h="${4:-1024}" i out
for i in $(seq 1 "$MB_TRIES"); do
out=$(./mb run flux_local -p prompt="$prompt" -p model=flux2-klein-4b -p steps=4 \
-p seed="$seed" -p width="$w" -p height="$h" --wait 2>/dev/null \
| awk '/image/{print $1}' | tail -1)
[ -n "$out" ] && { echo "$out"; return; }
sleep "$MB_PAUSE"
done
}
run_cutout() {
local asset="$1" i out
for i in $(seq 1 "$MB_TRIES"); do
out=$(./mb run bg_remove_local --asset "$asset" -p resolution=2048 --wait 2>/dev/null \
| awk '/image/{print $1}' | tail -1)
[ -n "$out" ] && { echo "$out"; return; }
sleep "$MB_PAUSE"
done
}
# gen_2d <name> <seed> <prompt> [w] [h] [cut]
gen_2d() {
local name="$1" seed="$2" prompt="$3" w="${4:-1024}" h="${5:-1024}" cutout="${6:-no}"
if [ -f "$OUT_IMG/$name.png" ]; then echo " = $name.png exists, skipping"; return; fi
echo "→ [$name] flux..."
local img; img=$(run_flux "$seed" "$prompt, $STYLE" "$w" "$h")
[ -z "$img" ] && { echo " ! $name: flux failed"; return 1; }
if [ "$cutout" = "cut" ]; then
echo "→ [$name] cutout..."
local c; c=$(run_cutout "$img"); [ -n "$c" ] && img="$c"
fi
local tmp; tmp=$(mktemp -d)
./mb get "$img" -o "$tmp" >/dev/null 2>&1
local f; f=$(find "$tmp" -iname '*.png' -o -iname '*.jpg' | head -1)
[ -n "$f" ] && { mv "$f" "$OUT_IMG/$name.png"; echo "$name.png"; }
rm -rf "$tmp"
}
# ---- the samurai: SAME seed, only the expression phrase varies (judge-portrait trick) ----
SAMURAI="a seated japanese samurai looming over a kitchen, ornate armor and helmet, one hand near a katana at his hip, shown as a dramatic flat black paper-cut silhouette against a warm glowing washi paper screen"
gen_2d bg_washi 301 "empty kitchen stage, shoji paper screen backdrop lit warm from behind, wooden theatre framing, no characters, no text" 1280 768
gen_2d floor_boards 303 "seamless dark wooden stage floor boards texture, worn edo-period timber, flat even light, no objects, no text" 1024 512
gen_2d samurai_calm 320 "$SAMURAI, calm neutral expression, katana fully sheathed" 768 900 cut
gen_2d samurai_watch 320 "$SAMURAI, one eyebrow raised, watchful, hand resting on hilt" 768 900 cut
gen_2d samurai_annoyed 320 "$SAMURAI, annoyed frown, thumb pushing the katana guard, ten centimeters of blade showing" 768 900 cut
gen_2d samurai_angry 320 "$SAMURAI, angry glare, half the blade drawn and gleaming" 768 900 cut
gen_2d samurai_fury 320 "$SAMURAI, furious wide-eyed glare, katana fully drawn overhead ready to slash" 768 900 cut
gen_2d title_art 330 "the word SANDONIETTE in giant dramatic japanese calligraphy brush strokes, black sumi ink" 1024 768 cut
gen_2d ticket_paper 340 "a single blank narrow paper order ticket, faint fold marks, plain, no text" 512 640
gen_2d slash_frame 350 "one single diagonal white katana slash streak on transparent background, motion blur, sharp" 1024 1024 cut
# food faces — comical, slightly alarmed
gen_2d face_bread 360 "a comical slightly alarmed cartoon face for a slice of bread, simple cut-out paper eyes and mouth, worried" 512 512 cut
gen_2d face_tomato 362 "a comical nervous cartoon face for a round tomato, simple cut-out paper features, sweating" 512 512 cut
gen_2d face_cheese 364 "a comical dopey cartoon face for a wedge of cheese, simple cut-out paper features" 512 512 cut
gen_2d face_katsu 366 "a comical brave cartoon face for a breaded pork katsu cutlet, simple cut-out paper features" 512 512 cut
gen_2d face_egg 368 "a comical wide-eyed cartoon face for a boiled egg, simple cut-out paper features, startled" 512 512 cut
# splat decals
gen_2d splat_wet 370 "a single messy wet food splat splatter shape, flat black silhouette, on transparent background" 512 512 cut
echo "done."
# ---- build-out batch 2 (seeds 400+): serve counter, S-grade bow, cutout faces v2 ----
gen_2d samurai_bow 320 "$SAMURAI, head bowed slightly in approval, eyes closed, serene" 768 900 cut
gen_2d counter_wood 402 "a low wooden serving counter table, edo period, side view, flat black silhouette on transparent background, hand-crafted paper cut-out style" 768 384 cut
gen_2d face_cut_bread 410 "simple face cut out of black paper: two round holes for eyes and a small worried oval mouth hole, white background, minimal, high contrast, paper-cut stencil" 512 512 cut
gen_2d day_stamp 420 "a round red japanese hanko seal stamp, blank center, worn ink edges, on transparent background" 512 512 cut

27
src/core/rng.ts Normal file
View File

@ -0,0 +1,27 @@
/** Small deterministic RNG (mulberry32) seeded so a run can be replayed.
* Ported from toastsim: sim code uses this, never Math.random. */
export class Rng {
private s: number;
constructor(seed = 1) {
this.s = seed >>> 0;
}
next(): number {
this.s = (this.s + 0x6d2b79f5) >>> 0;
let t = this.s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
range(a: number, b: number): number {
return a + this.next() * (b - a);
}
int(a: number, b: number): number {
return Math.floor(this.range(a, b + 1));
}
pick<T>(arr: readonly T[]): T {
return arr[Math.floor(this.next() * arr.length)];
}
chance(p: number): boolean {
return this.next() < p;
}
}

424
src/dev.ts Normal file
View File

@ -0,0 +1,424 @@
import type { Stage } from './scenes/stage';
import { FLOOR_Y } from './scenes/stage';
import { spawnFood, FOODS } from './sim/food';
import { DAYS } from './game/game';
/**
* Dev harness toastsim's single most valuable organ, transplanted.
*
* Drives the REAL sim deterministically at a fixed dt so "does it actually work"
* is answered by measured numbers, not by squinting. Every helper returns a
* stats object. Installed on window.__s in dev builds. Milestones add verbs;
* nothing ships without a harness number behind it.
*/
const STAGE_UNIT = 40; // px per "stage-unit" — the walk bar is measured in these
const HOME_X = 400;
export function installHarness(stage: Stage): void {
const p = () => stage.puppet;
const M = () => stage.matter.body;
const step = (frames: number, dt = 1000 / 60): void => {
stage.auto = false;
for (let i = 0; i < frames; i++) stage.stepSim(dt);
};
/** Hold the bar at (x,y) for `frames`, stepping each frame (no tilt). */
const hold = (x: number, y: number, frames: number, grip = false): void => {
stage.auto = false;
for (let i = 0; i < frames; i++) {
p().setBar(x, y, 0);
p().setGrip(grip);
stage.stepSim();
}
};
/** Hold the bar at (x, home) until the hips arrive under it (body drifts to
* follow the bar the arms hang from the shoulders, so the body has to get
* there first). Caps at maxFrames. */
const shiftBodyTo = (x: number, maxFrames = 180): number => {
stage.auto = false;
let i = 0;
for (; i < maxFrames; i++) {
p().setBar(x, p().barHomeY, 0);
p().setGrip(false);
stage.stepSim();
if (Math.abs(p().torsoX - x) < 14 && p().restSpeed() < 1.2) break;
}
return i;
};
/** Frames until the swing steadies. Soft strings keep a marionette gently
* alive forever, so "settled" means the energy has plateaued (stopped
* falling), not literal zero. Returns frames-to-steady. */
const settle = (maxFrames = 600): number => {
stage.auto = false;
let f = 0, low = Infinity, plateau = 0;
for (; f < maxFrames; f++) {
stage.stepSim();
const v = p().restSpeed();
if (v < low - 0.02) { low = v; plateau = 0; } else if (++plateau > 45) break;
if (v < 0.15) break;
}
return f;
};
const placeCrate = (x: number): void => {
M().setPosition(stage.crate, { x, y: FLOOR_Y - 24 }, false); // rest flush on the floor
M().setAngle(stage.crate, 0, false);
M().setVelocity(stage.crate, { x: 0, y: 0 });
M().setAngularVelocity(stage.crate, 0);
};
const s = {
stage,
step,
settle,
pose: () => p().pose(),
/** Dismiss the title overlay (for gameplay screenshots / headless drives). */
startGame: () => stage.scene.stop('title'),
/** Set the control bar to an absolute position and step one frame. */
bar(x: number, y = p().barHomeY) {
stage.auto = false;
p().setBar(x, y, 0);
stage.stepSim();
return p().pose();
},
grip(on: boolean) {
stage.auto = false;
p().setGrip(on);
stage.stepSim();
return { gripping: p().gripping, hands: p().handWorld() };
},
/** Oscillate the bar around home: freq Hz, amp px, for secs seconds. */
sway(freq = 1.2, amp = 90, secs = 3) {
stage.auto = false;
const frames = Math.round(secs * 60);
for (let i = 0; i < frames; i++) {
const x = HOME_X + amp * Math.sin((2 * Math.PI * freq * i) / 60);
p().applyInput(x, p().barHomeY, false, false);
stage.stepSim();
}
return p().pose();
},
/** THE DANGLE (M1 feel gate): yank the bar aside, let go, measure the swing
* home. Long settle + a clean decay = a puppet that has real pendulum. */
dangle() {
hold(HOME_X, p().barHomeY, 30); // start at rest
hold(HOME_X + 220, p().barHomeY, 12); // yank right
p().setBar(HOME_X, p().barHomeY, 0); // release to home
const frames = settle();
return { settleFrames: frames, ...p().pose() };
},
/** THE WALK (M1 exit-bar): a rhythmic sway whose centre drifts `dir`, so the
* legs swing and the puppet is dragged along. Reports units travelled and
* whether it stayed upright the whole way. */
walk(dir = 1, secs = 8, freq = 1.2, amp = 45, advance = 460) {
hold(HOME_X, p().barHomeY, 20);
const x0 = p().torsoX;
const frames = Math.round(secs * 60);
// 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();
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,
faceplant,
lean: finalPose.lean,
lowestHead: +lowestHead.toFixed(0),
};
},
/** Reach down over the crate, grip, lift did the crate leave the floor?
* The arms hang from the shoulders, not the bar, so a sideways yank just
* stretches them. Instead aim the bar AT the crate and hold until the whole
* body drifts over it then the hands hang right onto the crate. */
pickup(crateX = 470) {
placeCrate(crateX);
shiftBodyTo(crateX); // walk the body over the crate before reaching
hold(crateX, p().barHomeY + 150, 40, true); // reach down + clamp (sticky grabs on contact)
const grabbedY = stage.crate.position.y;
hold(crateX, p().barHomeY - 30, 50, true); // lift
const lift = grabbedY - stage.crate.position.y;
const held = stage.crate.position.y < FLOOR_Y - 40;
p().setGrip(false);
return { lift: +lift.toFixed(1), crateY: +stage.crate.position.y.toFixed(1), held };
},
/** Grip must lift the crate 10/10. Each trial resets to the identical home
* pose so trials are independent (no drift accumulating across grabs). */
pickupTest(n = 10) {
let ok = 0;
const lifts: number[] = [];
for (let i = 0; i < n; i++) {
stage.auto = false;
p().reset(HOME_X);
settle();
const r = this.pickup(440 + (i % 5) * 15);
if (r.held) ok++;
lifts.push(r.lift);
}
return { ok, n, rate: `${ok}/${n}`, lifts };
},
// ---- M2: cutting -------------------------------------------------------
cutStats: () => stage.cutter.stats(),
/** Clear the board and drop a fresh food, settled on it. */
spawnFood(kind = 'tomato', x = 760) {
stage.auto = false;
for (const f of [...stage.cutter.foods]) stage.removeProp(f.body);
stage.cutter.foods = [];
stage.cutter.splats = [];
stage.cutter.last = null;
stage.cutter.add(spawnFood(stage, kind, x, 512));
for (let i = 0; i < 45; i++) stage.stepSim();
return stage.cutter.stats();
},
/** Drive the blade tip [x,y]→[x,y] over `secs`, then settle. Returns cutStats. */
swing(from: [number, number], to: [number, number], secs = 0.18, settleAfter = 30) {
stage.auto = false;
stage.cutter.resetBlade({ x: from[0], y: from[1] });
const frames = Math.max(2, Math.round(secs * 60));
for (let i = 1; i <= frames; i++) {
const t = i / frames;
stage.cutter.moveBlade({ x: from[0] + (to[0] - from[0]) * t, y: from[1] + (to[1] - from[1]) * t });
stage.stepSim();
}
for (let i = 0; i < settleAfter; i++) stage.stepSim();
return stage.cutter.stats();
},
/** M2 exit-bar A: a practiced fast chop through the centre → 2 clean pieces, CV < 0.1. */
chop(kind = 'tomato') {
this.spawnFood(kind);
const c = stage.cutter.foods[0].body.position;
return this.swing([c.x, c.y - 70], [c.x, c.y + 80], 0.18);
},
/** M2 exit-bar B: a slow press → no cut, a wet splat. */
push(kind = 'tomato') {
this.spawnFood(kind);
const c = stage.cutter.foods[0].body.position;
return this.swing([c.x, c.y - 70], [c.x, c.y + 80], 1.5);
},
/** M2 exit-bar C: a wild fast swing → a piece flung offstage (and that's CORRECT). */
wildChop(kind = 'tomato') {
this.spawnFood(kind);
const c = stage.cutter.foods[0].body.position;
const r = this.swing([c.x - 110, c.y], [c.x + 320, c.y], 0.08, 55);
const offstage = stage.cutter.foods.some((f) => f.body.position.x < 20 || f.body.position.x > 1260 || f.body.position.y > 705);
return { ...r, offstage, at: stage.cutter.foods.map((f) => ({ x: +f.body.position.x.toFixed(0), y: +f.body.position.y.toFixed(0) })) };
},
// ---- M3: stacking ------------------------------------------------------
stack: () => stage.sando.stats(),
/** Build a fresh sando: one layer per (kind, offsetX) pair. */
buildSando(spec: [string, number][] = [['bread', 0], ['tomato', 0], ['bread', 0]]) {
stage.auto = false;
stage.sando.clear();
for (const [kind, off] of spec) stage.sando.addLayer(kind, off);
for (let i = 0; i < 60; i++) stage.stepSim(); // let the whole tower settle
return stage.sando.stats();
},
/** Earthquake: shove the stack sideways by `force` (px/step), then settle. */
nudge(force = 3) {
stage.auto = false;
stage.sando.nudge(force);
for (let i = 0; i < 90; i++) stage.stepSim();
return stage.sando.stats();
},
/** M3 exit-bar: a centred 3-layer sando stands and survives the quake. */
stackGood() {
const built = this.buildSando([['bread', 0], ['tomato', 0], ['bread', 0]]);
const quaked = this.nudge(3);
return { built, quaked, survived: quaked.standing };
},
/** M3 exit-bar: a leaning stack stands marginally, then the quake topples it.
* Toppled = it fell OR it shed a layer off the plate both are collapse. */
stackBad() {
const built = this.buildSando([['bread', 0], ['tomato', 20], ['bread', 40]]);
const stoodBefore = built.standing;
const quaked = this.nudge(3);
return { built, quaked, stoodBefore, toppledByQuake: stoodBefore && (!quaked.standing || quaked.layers < built.layers) };
},
/** Live serve flow: set up the day for real, materialise the ordered stack on
* the plate, then hold the bar still the dwell must commit the serve and
* put up the scorecard, exactly as a player bowing would. */
playServeFlow(n = 0) {
stage.auto = false;
stage.clearScorecard();
stage.session.reset();
stage.session.setDay(n);
stage.setupDay();
stage.auto = false; // setupDay re-arms live play; keep the harness driving
this.buildSando(stage.session.order.layers.map((k) => [k, 0] as [string, number]));
let frames = 0;
while (stage.session.state === 'playing' && frames < 180) {
stage.stepSim();
stage.session.updateLive(1 / 60, true);
frames++;
}
return {
day: n + 1,
state: stage.session.state,
framesToServe: frames,
grade: stage.session.lastVerdict?.grade ?? null,
matches: stage.session.matchesOrder(),
};
},
// ---- M4: the samurai + the full loop -----------------------------------
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 };
},
/** Proves the held-cleaver live-cut wiring: with the cleaver in the hand,
* a swing through food cuts it (a marionette arm easily clears the cut
* speed). In play the challenge is getting the blade there and steady. */
swingCleaver() {
this.freshRun();
const M = stage.matter;
const arm = stage.puppet.seg.lArmR;
M.body.setPosition(stage.cleaver, { x: arm.position.x, y: arm.position.y + 40 }, false);
const weld = M.add.constraint(arm, stage.cleaver, 0, 0.9, { pointA: { x: 0, y: 20 }, pointB: { x: 0, y: -32 } });
for (const f of [...stage.cutter.foods]) stage.removeProp(f.body);
stage.cutter.foods = [];
stage.cutter.last = null;
const foodX = stage.puppet.seg.hips.position.x + 55;
this.spawnFood('tomato', foodX);
let peak = 0, prev = stage.cleaverTip();
const whip = (bx: number, by: number, n: number) => {
for (let i = 0; i < n; i++) {
p().setBar(bx, by, 0);
stage.stepSim();
stage.cutter.moveBlade(stage.cleaverTip());
const t = stage.cleaverTip();
peak = Math.max(peak, Math.hypot(t.x - prev.x, t.y - prev.y));
prev = t;
}
};
for (let r = 0; r < 6 && stage.cutter.foods.length < 2; r++) {
whip(foodX + 20, 60, 6);
whip(foodX, 330, 6);
}
M.world.remove(weld);
const st = stage.cutter.stats();
return { cut: stage.cutter.foods.length >= 2, pieces: st.pieces, peakTipSpeed: +peak.toFixed(1), quality: st.last?.quality };
},
// ---- M5: days + escalation ---------------------------------------------
/** Play day `n` with a scripted-but-honest run: cut the filling, stack the
* ordered layers, serve in time. Each day must stay beatable (B+). */
playDay(n = 0) {
this.freshRun();
const d = stage.session.setDay(n);
const order = stage.session.order;
const fill = order.layers.find((k) => k !== 'bread' && k in FOODS) ?? 'tomato';
this.chop(fill); // a clean cut feeds THE CUT
this.buildSando(order.layers.map((k) => [k, 0] as [string, number]));
const v = this.serve(true, Math.max(6, order.maxSeconds - 6));
return {
day: n + 1,
name: order.name,
pressure: d.pressure,
grade: v.grade,
total: v.total,
standing: stage.sando.stats().standing,
};
},
/** M5 exit-bar: every day introduces its pressure and is beatable (B+). */
playAllDays() {
return DAYS.map((_, i) => this.playDay(i));
},
/** 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;
// eslint-disable-next-line no-console
console.log('[dev] __s ready — __s.dangle() __s.walk() __s.pickupTest()');
}

91
src/game/audio.ts Normal file
View File

@ -0,0 +1,91 @@
/**
* SFX, 100% synthesized Web Audio no paid assets, nothing to load. Lazy
* AudioContext (browsers block it until a user gesture; call sfx.enable() from a
* click). Every call is a no-op if audio isn't available (headless harness),
* so gameplay never depends on it.
*/
let ctx: AudioContext | null = null;
let noise: AudioBuffer | null = null;
function ac(): AudioContext | null {
try {
ctx ??= new AudioContext();
if (ctx.state === 'suspended') void ctx.resume();
return ctx;
} catch {
return null;
}
}
/** One second of white noise, built once, reused for whooshes and splats. */
function noiseBuffer(c: AudioContext): AudioBuffer {
if (noise) return noise;
const buf = c.createBuffer(1, c.sampleRate, c.sampleRate);
const d = buf.getChannelData(0);
// deterministic-ish LCG so it isn't Math.random (keeps sim files clean of it)
let s = 1234567;
for (let i = 0; i < d.length; i++) {
s = (s * 1103515245 + 12345) & 0x7fffffff;
d[i] = (s / 0x3fffffff) - 1;
}
noise = buf;
return buf;
}
function tone(freq: number, dur: number, type: OscillatorType, gain: number, sweepTo?: number): void {
const c = ac();
if (!c) return;
const o = c.createOscillator(), g = c.createGain();
o.type = type;
o.frequency.setValueAtTime(freq, c.currentTime);
if (sweepTo) o.frequency.exponentialRampToValueAtTime(sweepTo, c.currentTime + dur);
g.gain.setValueAtTime(gain, c.currentTime);
g.gain.exponentialRampToValueAtTime(0.0001, c.currentTime + dur);
o.connect(g).connect(c.destination);
o.start();
o.stop(c.currentTime + dur);
}
function burst(dur: number, gain: number, filterHz: number, sweepTo?: number): void {
const c = ac();
if (!c) return;
const src = c.createBufferSource();
src.buffer = noiseBuffer(c);
const f = c.createBiquadFilter();
f.type = 'lowpass';
f.frequency.setValueAtTime(filterHz, c.currentTime);
if (sweepTo) f.frequency.exponentialRampToValueAtTime(sweepTo, c.currentTime + dur);
const g = c.createGain();
g.gain.setValueAtTime(gain, c.currentTime);
g.gain.exponentialRampToValueAtTime(0.0001, c.currentTime + dur);
src.connect(f).connect(g).connect(c.destination);
src.start();
src.stop(c.currentTime + dur);
}
export const sfx = {
/** Call from a user gesture to unblock audio. */
enable(): void {
ac();
},
/** The strike — a deep temple gong. */
gong(): void {
tone(68, 1.4, 'sine', 0.4);
tone(136, 1.1, 'sine', 0.12);
},
/** The katana — a bright noise whoosh sweeping down. */
slash(): void {
burst(0.32, 0.5, 9000, 400);
tone(1400, 0.18, 'sawtooth', 0.14, 300);
},
/** Wet food hitting the board — a short damp thud. */
splat(): void {
burst(0.16, 0.45, 900, 200);
},
/** Title / serve sting — a plucked shamisen twang. */
shamisen(): void {
tone(330, 0.5, 'sawtooth', 0.16, 220);
tone(660, 0.35, 'triangle', 0.06, 500);
},
};

155
src/game/game.ts Normal file
View File

@ -0,0 +1,155 @@
import type { Stage } from '../scenes/stage';
import { Samurai } from '../sim/samurai';
import { judge, type Order, type RunResult, type Verdict } from './judging';
/** A day introduces exactly ONE new pressure (never two) and stays beatable by
* a scripted-but-honest run. angerRamp scales every sin the samurai's
* patience shortens on the rush-hour day. */
export interface Day {
order: Order;
pressure: string;
angerRamp: number;
}
export const DAYS: Day[] = [
{ order: { name: 'Open Tomato Sando', layers: ['bread', 'tomato', 'bread'], needsCut: true, maxSeconds: 45 }, pressure: 'the basics', angerRamp: 1 },
{ order: { name: 'Cheese & Tomato', layers: ['bread', 'cheese', 'tomato', 'bread'], needsCut: true, maxSeconds: 45 }, pressure: '+1 ingredient', angerRamp: 1 },
{ order: { name: 'Double Tomato', layers: ['bread', 'tomato', 'tomato', 'bread'], needsCut: true, maxSeconds: 45 }, pressure: 'wetter — twice the tomato', angerRamp: 1 },
{ order: { name: 'Katsu Sando', layers: ['bread', 'katsu', 'tomato', 'bread'], needsCut: true, maxSeconds: 45 }, pressure: 'a real cut — the katsu', angerRamp: 1 },
{ order: { name: 'Rush Hour', layers: ['bread', 'tomato', 'bread'], needsCut: true, maxSeconds: 22 }, pressure: 'the patience ramp', angerRamp: 1.8 },
{ order: { name: 'Tamago Sando', layers: ['bread', 'egg', 'bread'], needsCut: true, maxSeconds: 45 }, pressure: 'it rolls', angerRamp: 1 },
{ order: { name: 'Tofu, Gently', layers: ['bread', 'tofu', 'bread'], needsCut: true, maxSeconds: 45 }, pressure: 'fragile — pressing is death', angerRamp: 1.2 },
{ order: { name: 'THE TOWER', layers: ['bread', 'katsu', 'cheese', 'tomato', 'bread'], needsCut: true, maxSeconds: 60 }, pressure: 'five layers', angerRamp: 1.3 },
];
export const ORDERS: Order[] = DAYS.map((d) => d.order);
/**
* 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 type RunState = 'playing' | 'served' | 'dead';
const BOW_SECS = 0.5; // the dwell commit, straight from toastsim
const LATE_TICK = 3; // one servedLate bump per this many seconds overdue
export class Game {
samurai = new Samurai();
day = 0;
order: Order = DAYS[0].order;
drops = 0;
seconds = 0; // ticket timer (seconds)
state: RunState = 'playing';
/** 0..1 bow-dwell progress while holding still over a correct sando. */
dwell = 0;
lastVerdict: Verdict | null = null;
private lateBumped = 0;
constructor(private stage: Stage) {
this.samurai.onStrike = () => {
this.state = 'dead';
this.stage.stringCut();
};
}
/** Is the physical stack exactly the ordered layers? */
matchesOrder(): boolean {
const kinds = this.stage.sando.kinds();
return kinds.length === this.order.layers.length && kinds.every((k, i) => k === this.order.layers[i]);
}
/** Live-play heartbeat: clock, overdue anger, and the bow-dwell commit.
* `barStill` = the player is holding the control bar still. */
updateLive(dtSec: number, barStill: boolean): void {
if (this.state !== 'playing' || this.samurai.struck) return;
this.seconds += dtSec;
const over = this.seconds - this.order.maxSeconds;
if (over > 0 && Math.floor(over / LATE_TICK) > this.lateBumped) {
this.lateBumped = Math.floor(over / LATE_TICK);
this.samurai.bump('servedLate');
}
const ready = this.matchesOrder() && this.stage.sando.stats().standing;
if (ready && barStill) {
this.dwell += dtSec / BOW_SECS;
if (this.dwell >= 1) {
this.state = 'served';
this.lastVerdict = this.serve(true);
this.stage.showScorecard(this.lastVerdict);
}
} else {
this.dwell = Math.max(0, this.dwell - dtSec * 2);
}
}
/** The string-cut post-mortem: judged with nothing served. */
failVerdict(): Verdict {
const run = this.buildRun(false, this.seconds);
run.service.served = false;
run.service.correct = false;
return judge(this.order, run);
}
/** Load day `n` (clamped): its order + its samurai patience ramp. */
setDay(n: number): Day {
this.day = Math.max(0, Math.min(DAYS.length - 1, n));
const d = DAYS[this.day];
this.order = d.order;
this.samurai.ramp = d.angerRamp;
this.drops = 0;
this.seconds = 0;
this.state = 'playing';
this.dwell = 0;
this.lateBumped = 0;
this.lastVerdict = null;
return d;
}
reset(order: Order = this.order): void {
this.samurai.reset();
this.order = order;
this.drops = 0;
this.seconds = 0;
this.state = 'playing';
this.dwell = 0;
this.lateBumped = 0;
this.lastVerdict = null;
}
// ---- 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 matches = this.matchesOrder();
return {
cut: cut ? { cv: cut.cv, quality: cut.quality, pieces: cut.pieces } : { cv: 1, quality: 'none', pieces: 0 },
sando: { layers: s.layers, leanDeg: s.leanDeg, comDrift: s.comDrift, standing: s.standing, matches },
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];
}

16
src/main.ts Normal file
View File

@ -0,0 +1,16 @@
import Phaser from 'phaser';
import { Stage, STAGE_W, STAGE_H } from './scenes/stage';
import { Title } from './scenes/title';
new Phaser.Game({
type: Phaser.AUTO,
width: STAGE_W,
height: STAGE_H,
backgroundColor: '#efe4cb', // warm washi paper
parent: 'app',
physics: {
default: 'matter',
matter: { gravity: { x: 0, y: 1 }, autoUpdate: false, debug: false },
},
scene: [Stage, Title],
});

404
src/scenes/stage.ts Normal file
View File

@ -0,0 +1,404 @@
import Phaser from 'phaser';
import { installHarness } from '../dev';
import { Marionette, type StringStyle } from '../sim/marionette';
import { Cutter } from '../sim/cutting';
import { spawnFood } from '../sim/food';
import { Sando, spawnLayer } from '../sim/stack';
import { Game, DAYS } from '../game/game';
import type { Verdict } from '../game/judging';
import { sfx } from '../game/audio';
const PORTRAITS = ['samurai_calm', 'samurai_watch', 'samurai_annoyed', 'samurai_angry', 'samurai_fury'];
export const STAGE_W = 1280;
export const STAGE_H = 720;
export const FLOOR_Y = 640;
/** A physics body paired with the flat black silhouette that renders it.
* This is the whole game's render contract: the body is truth, the shape follows. */
export interface Prop {
body: MatterJS.BodyType;
view: Phaser.GameObjects.Shape | Phaser.GameObjects.Image | Phaser.GameObjects.Container;
}
/**
* The kitchen stage. Matter's auto-step is OFF so the sim advances only when we
* call stepSim deterministic, drivable frame-by-frame from the dev harness.
* In normal play scene.update maps the pointer to the bar and steps once; the
* harness sets auto=false and drives the puppet itself.
*/
export class Stage extends Phaser.Scene {
auto = true;
props: Prop[] = [];
puppet!: Marionette;
cutter!: Cutter;
sando!: Sando;
session!: Game;
crate!: MatterJS.BodyType;
cleaver!: MatterJS.BodyType;
board!: MatterJS.BodyType;
plate!: MatterJS.BodyType;
private orderText?: Phaser.GameObjects.Text;
private space!: Phaser.Input.Keyboard.Key;
private samuraiImg?: Phaser.GameObjects.Image;
private angerBar?: Phaser.GameObjects.Graphics;
private strings?: Phaser.GameObjects.Graphics;
private serveG?: Phaser.GameObjects.Graphics;
private serveHint?: Phaser.GameObjects.Text;
private advanceHint?: Phaser.GameObjects.Text;
private vignette?: Phaser.GameObjects.Rectangle;
private scorecard?: Phaser.GameObjects.Container;
private prevBar = { x: 0, y: 0 };
private deadFrames = 0;
private sweep = 0;
constructor() {
super('stage');
}
preload(): void {
// BASE_URL is '/' in dev, '/sandoniette/' in the built subpath deploy — so
// asset paths resolve regardless of a trailing slash on the URL.
const base = import.meta.env.BASE_URL;
const img = (k: string) => this.load.image(k, `${base}assets/img/${k}.png`);
['bg_washi', 'title_art', 'samurai_bow', ...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 });
this.matter.add.rectangle(STAGE_W + 20, STAGE_H / 2, 40, STAGE_H * 2, { isStatic: true });
// day + string style survive scene restarts (death retry / next day / title pick)
const day = (this.registry.get('sando.day') as number) ?? 0;
const style = (this.registry.get('sando.style') as StringStyle) ?? 'hemp';
this.puppet = new Marionette(this, 400, style);
// a crate on the floor for the M1 grip test — low mass so a hand can lift it.
this.crate = this.matter.add.rectangle(470, FLOOR_Y - 16, 48, 48, { restitution: 0.1, friction: 0.8, density: 0.0006 });
this.addBody(this.crate, 48, 48, 0x3a2d20);
this.puppet.gripTargets.push(this.crate);
// cutting board (a static counter). Day setup below stocks it.
this.board = this.matter.add.rectangle(760, 560, 240, 24, { isStatic: true });
this.addBody(this.board, 240, 24, 0x2a211a);
this.cutter = new Cutter(this);
// the cleaver — a grabbable blade. Held in a hand, its edge cuts on the swing.
this.cleaver = this.matter.add.rectangle(632, FLOOR_Y - 42, 12, 78, { density: 0.0008, friction: 0.6 });
this.addBody(this.cleaver, 12, 78, 0x151515);
this.puppet.gripTargets.push(this.cleaver);
// the plate zone (a static dish on the floor) where the sando gets stacked.
this.plate = this.matter.add.rectangle(980, 630, 140, 12, { isStatic: true });
this.addBody(this.plate, 140, 12, 0x2a211a);
this.sando = new Sando(this, 980, 624);
this.session = new Game(this);
this.session.setDay(day);
// kitchen sins fire live off the cutter: a squash is a splat, a botched
// pass is a crime cut. (ponytail: no floor-drop sin yet — splat/crime/
// offstage/late are pressure enough; add drops when a day needs it.)
this.cutter.onCut = (r) => {
if (r.quality === 'crime') (r.pieces === 0 ? this.session.splat() : this.session.crimeCut());
};
// HUD: the samurai judge (top-right), the anger meter, the unsheath vignette.
if (this.textures.exists('samurai_calm')) {
this.samuraiImg = this.add.image(STAGE_W - 150, 210, 'samurai_calm').setScale(0.42).setDepth(5);
}
this.angerBar = this.add.graphics().setDepth(6);
this.strings = this.add.graphics().setDepth(3); // strings + control bar, over the silhouettes
this.vignette = this.add.rectangle(STAGE_W / 2, STAGE_H / 2, STAGE_W, STAGE_H, 0x1a0000, 0).setDepth(4);
// order ticket (top-left) + serve prompt + advance hint
this.orderText = this.add.text(28, 34, '', { fontFamily: 'monospace', fontSize: '16px', color: '#2a1a0e' }).setDepth(6);
this.serveG = this.add.graphics().setDepth(6);
this.serveHint = this.add.text(980, 500, 'HOLD STILL — OJIGI', { fontFamily: 'serif', fontSize: '15px', color: '#e8b84a' })
.setOrigin(0.5).setDepth(6).setVisible(false);
this.advanceHint = this.add.text(STAGE_W / 2, 640, '', { fontFamily: 'serif', fontSize: '18px', color: '#e8b84a' })
.setOrigin(0.5).setDepth(31).setVisible(false);
this.setupDay();
this.space = this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE);
this.input.once('pointerdown', () => sfx.enable()); // unblock audio on first click
// click advances past a scorecard: next day after a serve, retry after death.
this.input.on('pointerdown', () => {
if (this.session.state === 'served') {
this.registry.set('sando.day', (this.session.day + 1) % DAYS.length);
this.scene.restart();
} else if (this.session.state === 'dead' && this.deadFrames > 70) {
this.scene.restart(); // same day, fresh strings
}
});
installHarness(this);
// title overlay on first boot only (day/style picks restart this scene).
if (!this.registry.get('sando.started')) this.scene.launch('title');
}
/** Stock the kitchen for the current order: bread slices staged on the board's
* left end, one whole food per non-bread kind spread along the board (they
* need cutting), puppet + cleaver parked at their homes. */
setupDay(): void {
this.clearScorecard();
this.advanceHint?.setVisible(false);
this.deadFrames = 0;
for (const f of [...this.cutter.foods]) this.removeProp(f.body);
this.cutter.foods = [];
this.cutter.splats = [];
this.cutter.last = null;
const o = this.session.order;
const breadN = o.layers.filter((k) => k === 'bread').length;
for (let i = 0; i < breadN; i++) this.cutter.add(spawnLayer(this, 'bread', 672, 530 - i * 30));
const kinds = [...new Set(o.layers.filter((k) => k !== 'bread'))];
kinds.forEach((k, i) => this.cutter.add(spawnFood(this, k, 780 + i * 70 - (kinds.length - 1) * 35, 500)));
this.puppet.reset(400);
const b = this.matter.body;
b.setPosition(this.cleaver, { x: 632, y: FLOOR_Y - 42 }, false);
b.setAngle(this.cleaver, 0, false);
b.setVelocity(this.cleaver, { x: 0, y: 0 });
b.setAngularVelocity(this.cleaver, 0);
this.auto = true;
}
/** SHUBATTO the string cut. Ragdoll the puppet, fling every loose food with
* 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();
sfx.slash();
sfx.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() });
}
/** 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;
}
/** Redraw the strings from the control bar to the puppet, plus the bar. */
private drawStrings(): void {
const g = this.strings;
if (!g) return;
g.clear();
const segs = this.puppet.stringSegments();
if (!segs.length) return; // cut — no strings to draw
g.lineStyle(1.5, 0x1a1206, 0.55);
for (const s of segs) {
g.beginPath();
g.moveTo(s.ax, s.ay);
g.lineTo(s.bx, s.by);
g.strokePath();
}
// the wooden control bar
const b = this.puppet.barSegment();
g.lineStyle(7, 0x3a2612, 0.95);
g.beginPath();
g.moveTo(b.x1, b.y1);
g.lineTo(b.x2, b.y2);
g.strokePath();
}
/** Refresh the order ticket from the current day (timer line is live). */
refreshOrder(): void {
const o = this.session.order;
const left = Math.max(0, Math.ceil(o.maxSeconds - this.session.seconds));
const late = this.session.seconds > o.maxSeconds;
this.orderText?.setText(
`DAY ${this.session.day + 1} ${o.name}\n${o.layers.join(' · ')}\n⏱ ${left}s${late ? ' — OSOI. LATE.' : ''}`,
);
}
/** The bow: prompt + dwell arc over the plate when the stack matches the order. */
private drawServe(): void {
const g = this.serveG;
if (!g) return;
g.clear();
const ready = this.session.state === 'playing' && this.session.matchesOrder();
this.serveHint?.setVisible(ready);
if (ready && this.session.dwell > 0) {
g.lineStyle(5, 0xe8b84a, 0.9);
g.beginPath();
g.arc(980, 530, 20, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * Math.min(1, this.session.dwell));
g.strokePath();
}
}
/** Bin anything that left the stage entirely (and anger the samurai). */
private offstageSweep(): void {
for (const f of [...this.cutter.foods]) {
const b = f.body;
if (b.position.y > STAGE_H + 80 || b.position.x < -60 || b.position.x > STAGE_W + 60) {
this.removeProp(b);
this.cutter.foods = this.cutter.foods.filter((x) => x !== f);
this.session.offstage();
}
}
}
/** World position of the cleaver's cutting edge (its lower tip). */
cleaverTip(): { x: number; y: number } {
const a = this.cleaver.angle, half = 39;
return { x: this.cleaver.position.x - Math.sin(a) * half, y: this.cleaver.position.y + Math.cos(a) * half };
}
/** A gripped cleaver is the live blade; otherwise the blade parks. Called each
* frame in play, and by the harness after its own steps. */
driveCleaver(): void {
if (this.puppet.held().includes(this.cleaver)) this.cutter.moveBlade(this.cleaverTip());
else this.cutter.resetBlade(this.cleaverTip());
}
private updateHUD(): void {
const sam = this.session.samurai;
// an S earns the single approving nod — the bowing portrait over the scorecard.
const bowed = this.session.state === 'served' && this.session.lastVerdict?.grade === 'S';
const key = bowed && this.textures.exists('samurai_bow') ? 'samurai_bow' : sam.portrait;
if (this.samuraiImg && this.textures.exists(key)) this.samuraiImg.setTexture(key);
const bar = this.angerBar;
if (bar) {
bar.clear();
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);
this.props.push({ body, view });
return body;
}
/** Register a body with a circular silhouette (the head). */
addCircle(body: MatterJS.BodyType, r: number, color: number): MatterJS.BodyType {
const view = this.add.circle(body.position.x, body.position.y, r, color);
this.props.push({ body, view });
return body;
}
/** Register a body with a polygon silhouette (food, food pieces).
* localVerts are centred on the body origin. */
addPoly(body: MatterJS.BodyType, localVerts: { x: number; y: number }[], color: number): MatterJS.BodyType {
const view = this.add.polygon(body.position.x, body.position.y, localVerts, color);
this.props.push({ body, view });
return body;
}
/** A wet-food splat decal — a dark blob left on the stage (no body). */
splat(x: number, y: number, r: number, color: number): void {
const blob = this.add.ellipse(x, y, r * 2.2, r * 1.3, color, 0.85);
blob.setDepth(-1);
sfx.splat();
}
/** Punch a comical cut-out face into a food silhouette two eyes + a worried
* little mouth, in the bright washi tone so they read as backlit holes. */
addFace(body: MatterJS.BodyType, w: number, h: number): void {
const hole = 0xf4e8c4;
const r = Math.max(2.8, Math.min(w, h) * 0.11);
const ex = Math.min(w * 0.2, 18), ey = -h * 0.1;
const c = this.add.container(body.position.x, body.position.y).setDepth(1);
c.add(this.add.circle(-ex, ey, r, hole));
c.add(this.add.circle(ex, ey, r, hole));
c.add(this.add.circle(0, h * 0.18, r * 0.8, hole)); // small "o" of alarm
this.props.push({ body, view: c });
}
/** Destroy every view tracking a body (silhouette + any face) and remove it. */
removeProp(body: MatterJS.BodyType): void {
this.props = this.props.filter((p) => {
if (p.body !== body) return true;
p.view.destroy();
return false;
});
this.matter.world.remove(body);
}
stepSim(dt = 1000 / 60): void {
this.matter.world.step(dt);
this.puppet?.update();
}
update(): void {
if (this.auto) {
const p = this.input.activePointer;
const grip = p.rightButtonDown() || this.space.isDown;
this.puppet.applyInput(p.worldX, p.worldY || this.puppet.barHomeY, p.leftButtonDown(), grip);
this.stepSim();
this.driveCleaver(); // a gripped cleaver is the live blade — swinging cuts
// the bow dwell reads bar stillness, not pointer stillness — chain strings
// clamp the bar the same way the player's hand does.
const still = Math.hypot(this.puppet.barX - this.prevBar.x, this.puppet.barY - this.prevBar.y) < 1.6;
this.prevBar = { x: this.puppet.barX, y: this.puppet.barY };
this.session.updateLive(1 / 60, still);
this.sweep = (this.sweep + 1) % 30;
if (this.sweep === 0) this.offstageSweep();
// the string-cut post-mortem: let the ragdoll land, then the scorecard.
if (this.session.state === 'dead') {
this.deadFrames++;
if (this.deadFrames === 72) {
this.showScorecard(this.session.failVerdict());
this.advanceHint?.setText('click — mō ichido. (again.)').setVisible(true);
}
} else if (this.session.state === 'served' && this.scorecard && !this.advanceHint?.visible) {
this.advanceHint?.setText('click — the next day').setVisible(true);
}
}
for (const pr of this.props) {
pr.view.setPosition(pr.body.position.x, pr.body.position.y);
pr.view.setRotation(pr.body.angle);
}
this.drawStrings();
this.drawServe();
this.refreshOrder();
this.updateHUD();
}
}

78
src/scenes/title.ts Normal file
View File

@ -0,0 +1,78 @@
import Phaser from 'phaser';
import { sfx } from '../game/audio';
import { STRING_STYLES, type StringStyle } from '../sim/marionette';
/**
* Title card, launched as an overlay over the live stage so the dev harness is
* available underneath from frame one. Pick your strings (difficulty modifier),
* then click to begin the stage restarts with the chosen string style, and
* that first click also unblocks audio so the shamisen sting lands.
*/
export class Title extends Phaser.Scene {
constructor() {
super('title');
}
create(): void {
const W = this.scale.width, H = this.scale.height;
this.add.rectangle(W / 2, H / 2, W, H, 0x0a0705, 0.82);
if (this.textures.exists('title_art')) {
this.add.image(W / 2, H * 0.34, 'title_art').setScale(0.56).setDepth(1);
}
this.add.text(W / 2, H * 0.6, 'サンドニエット', { fontFamily: 'serif', fontSize: '52px', color: '#efe4cb' })
.setOrigin(0.5).setDepth(1);
this.add.text(W / 2, H * 0.66, 'S A N D O N I E T T E', { fontFamily: 'serif', fontSize: '18px', color: '#c8a24a' })
.setOrigin(0.5).setDepth(1);
// ---- string select: the unlockables-as-difficulty row ----
const styles = Object.keys(STRING_STYLES) as StringStyle[];
const picked = ((this.registry.get('sando.style') as StringStyle) ?? 'hemp');
const labels = new Map<StringStyle, Phaser.GameObjects.Text>();
const paint = (sel: StringStyle): void => {
for (const [k, t] of labels) {
t.setColor(k === sel ? '#e8b84a' : '#8a7a5a');
t.setText(k === sel ? `${k.toUpperCase()}` : k.toUpperCase());
}
blurb.setText(STRING_STYLES[sel].blurb);
};
styles.forEach((k, i) => {
const t = this.add.text(W / 2 + (i - (styles.length - 1) / 2) * 150, H * 0.76, k.toUpperCase(), {
fontFamily: 'monospace', fontSize: '18px', color: '#8a7a5a',
}).setOrigin(0.5).setDepth(2).setInteractive({ useHandCursor: true });
t.on('pointerdown', (_p: unknown, __x: unknown, ___y: unknown, ev?: { stopPropagation?: () => void }) => {
ev?.stopPropagation?.();
this.registry.set('sando.style', k);
sfx.enable();
sfx.shamisen();
paint(k);
});
labels.set(k, t);
});
const blurb = this.add.text(W / 2, H * 0.81, '', { fontFamily: 'serif', fontSize: '14px', color: '#9a8a6a', fontStyle: 'italic' })
.setOrigin(0.5).setDepth(1);
paint(picked);
const shout = this.add.text(W / 2, H * 0.89, 'SHUBATTO!! SANDONIETTE!!!', {
fontFamily: 'serif', fontSize: '22px', color: '#e8b84a', fontStyle: 'bold',
}).setOrigin(0.5).setDepth(1);
this.tweens.add({ targets: shout, scale: 1.12, duration: 600, yoyo: true, repeat: -1, ease: 'Sine.inOut' });
const hint = this.add.text(W / 2, H * 0.95, 'click to begin', { fontFamily: 'serif', fontSize: '15px', color: '#9a8a6a' })
.setOrigin(0.5).setDepth(1);
this.tweens.add({ targets: hint, alpha: 0.3, duration: 900, yoyo: true, repeat: -1 });
// begin: anywhere that isn't a style button. Restart the stage so the puppet
// is strung with the chosen style, then drop the overlay.
this.input.on('pointerdown', (_p: Phaser.Input.Pointer, over: unknown[]) => {
if (over.length > 0) return; // that click was a style pick
sfx.enable();
sfx.gong();
sfx.shamisen();
this.registry.set('sando.started', true);
this.scene.get('stage').scene.restart();
this.scene.stop();
});
}
}

170
src/sim/cutting.ts Normal file
View File

@ -0,0 +1,170 @@
import type { Stage } from '../scenes/stage';
import { Food, makeFood } from './food';
import {
add, centroid, cvWord, len, norm, perp, pieceCV, pointInPoly, polyArea, polyDiameter,
scale, segPolyCrossings, splitConvexByLine, sub, type V,
} from './geom';
export type CutQuality = 'clean' | 'ragged' | 'crime';
// cut thresholds, px/frame at 60fps (the calibration surface — tuned by harness)
const CUT = {
clean: 11, // peak blade speed for a clean pass
squash: 4.5, // below this a wet food splats instead of cutting
minChordFrac: 0.45, // the pass must span this much of the food to count as a cut
cleanChordFrac: 0.6, // and this much to be graded clean
bladeTransfer: 0.42, // fraction of blade speed the pieces carry off (px/step)
separate: 1.4, // px/step the halves spring apart (Matter velocity is per-step, not per-sec)
gap: 4, // px each half is nudged off the cut line so they don't spawn overlapping
};
const worldVerts = (b: MatterJS.BodyType): V[] => (b.vertices ?? []).map((v) => ({ x: v.x, y: v.y }));
interface Track {
entry: V;
pathLen: number;
frames: number;
peakSpeed: number;
}
export interface CutResult {
quality: CutQuality;
pieces: number;
cv: number;
speed: number;
chordFrac: number;
}
/**
* The cut. A blade tip is driven across the board (by the harness now, by the
* gripped hand at M4). When the tip passes THROUGH a food fast enough the food
* splits along the tip's chord into two real bodies; a slow press on a wet food
* squashes into a splat instead. Honest geometry no canned "sliced" anim.
*/
export class Cutter {
foods: Food[] = [];
splats: { mass: number }[] = [];
last: CutResult | null = null;
/** Fired on every committed cut/squash — the stage wires kitchen sins here. */
onCut?: (r: CutResult) => void;
private tip: V | null = null;
private track = new Map<Food, Track>();
private report(r: CutResult): void {
this.last = r;
this.onCut?.(r);
}
constructor(private stage: Stage) {}
add(food: Food): void {
this.foods.push(food);
}
/** Start a fresh stroke from `p` (clears any in-progress pass). */
resetBlade(p: V): void {
this.tip = p;
this.track.clear();
}
/** Advance the blade tip to `p` for one frame and run cut detection. */
moveBlade(p: V): void {
const prev = this.tip ?? p;
this.tip = p;
const stepLen = len(sub(p, prev));
for (const food of [...this.foods]) {
const verts = worldVerts(food.body);
const inNow = pointInPoly(p, verts);
const st = this.track.get(food);
if (!st) {
const crossings = segPolyCrossings(prev, p, verts);
if (inNow || crossings.length > 0) {
this.track.set(food, { entry: crossings[0] ?? prev, pathLen: stepLen, frames: 1, peakSpeed: stepLen });
}
} else {
st.pathLen += stepLen;
st.frames++;
st.peakSpeed = Math.max(st.peakSpeed, stepLen);
if (!inNow) {
const crossings = segPolyCrossings(prev, p, verts);
const exit = crossings.length ? crossings[crossings.length - 1] : prev;
this.commit(food, st.entry, exit, st.peakSpeed);
this.track.delete(food);
}
}
}
}
private commit(food: Food, entry: V, exit: V, peakSpeed: number): void {
const verts = worldVerts(food.body);
const diam = polyDiameter(verts) || 1;
const chordFrac = len(sub(exit, entry)) / diam;
const mat = food.material;
const power = peakSpeed * (1 - 0.5 * mat.resistance); // tougher skin, harder to cut
// too slow or too shallow → not a clean split
if (power < CUT.squash || chordFrac < CUT.minChordFrac) {
if (mat.moisture > 0.4 && power < CUT.squash) {
this.splat(food, peakSpeed);
} else {
this.report({ quality: 'crime', pieces: 1, cv: 0, speed: +peakSpeed.toFixed(2), chordFrac: +chordFrac.toFixed(2) });
}
return;
}
const parts = splitConvexByLine(verts, entry, exit);
if (!parts) return;
const quality: CutQuality = power >= CUT.clean && chordFrac >= CUT.cleanChordFrac ? 'clean' : 'ragged';
this.stage.removeProp(food.body);
this.foods = this.foods.filter((f) => f !== food);
const dir = norm(sub(exit, entry));
const n = perp(dir); // separation normal (splitConvexByLine returns [left, right] of dir)
const bladeVel = scale(dir, peakSpeed * CUT.bladeTransfer); // peakSpeed is px/step already
parts.forEach((pv, i) => {
const c = centroid(pv);
const side = i === 0 ? 1 : -1;
// spawn each half nudged off the cut line so the two bodies never overlap
// at birth — coincident bodies make Matter eject them across the county.
const pos = add(c, scale(n, side * CUT.gap));
const piece = makeFood(this.stage, pv, pos.x, pos.y, mat, food.color, food.face, food.kind + 'p');
this.stage.matter.body.setVelocity(piece.body, add(bladeVel, scale(n, side * CUT.separate)));
this.foods.push(piece);
});
const areas = parts.map(polyArea);
this.report({
quality,
pieces: 2,
cv: +pieceCV(areas).toFixed(3),
speed: +peakSpeed.toFixed(2),
chordFrac: +chordFrac.toFixed(2),
});
}
private splat(food: Food, peakSpeed: number): void {
const verts = worldVerts(food.body);
const c = centroid(verts);
const mass = +((polyArea(verts) / 900) * food.material.moisture).toFixed(2);
this.splats.push({ mass });
this.stage.removeProp(food.body);
this.foods = this.foods.filter((f) => f !== food);
this.stage.splat(c.x, c.y, Math.sqrt(polyArea(verts) / Math.PI) * 1.3, food.color);
this.report({ quality: 'crime', pieces: 0, cv: 0, speed: +peakSpeed.toFixed(2), chordFrac: 0 });
// ponytail: slippery-floor patch deferred — no one carries over it until M4.
}
/** Everything the judge and the harness read off a cut. */
stats() {
const areas = this.foods.map((f) => polyArea(worldVerts(f.body)));
const cv = pieceCV(areas);
return {
pieces: this.foods.length,
cv: +cv.toFixed(3),
cvWord: cvWord(cv),
splatMass: +this.splats.reduce((a, b) => a + b.mass, 0).toFixed(2),
last: this.last,
};
}
}

84
src/sim/food.ts Normal file
View File

@ -0,0 +1,84 @@
import type { Stage } from '../scenes/stage';
import { centroid, type V } from './geom';
/** toastsim-style material params. resistance raises the cut threshold; moisture
* drives the splat; wobble is jelly-class jiggle (soft, deferred to when a
* jelly food actually ships). */
export interface Material {
resistance: number; // 0..1 skin toughness
moisture: number; // 0..1 wet food splats
wobble: number; // 0..1 jiggle
}
export interface FoodDef {
verts: V[]; // convex, centred on origin
material: Material;
color: number;
face: string; // generated face sprite id (rendered later; gameplay is the silhouette)
}
const circle = (r: number, n: number, squashY = 1): V[] =>
Array.from({ length: n }, (_, i) => {
const a = (i / n) * Math.PI * 2;
return { x: Math.cos(a) * r, y: Math.sin(a) * r * squashY };
});
export const box = (w: number, h: number): V[] => {
const x = w / 2, y = h / 2, c = Math.min(x, y) * 0.4; // chamfered corners → convex octagon
return [
{ x: -x + c, y: -y }, { x: x - c, y: -y }, { x, y: -y + c }, { x, y: y - c },
{ x: x - c, y }, { x: -x + c, y }, { x: -x, y: y - c }, { x: -x, y: -y + c },
];
};
export const FOODS: Record<string, FoodDef> = {
tomato: { verts: circle(34, 12), material: { resistance: 0.25, moisture: 0.85, wobble: 0.15 }, color: 0x151515, face: 'face_tomato' },
bread: { verts: box(78, 52), material: { resistance: 0.35, moisture: 0.05, wobble: 0.0 }, color: 0x1c150e, face: 'face_bread' },
cheese: { verts: box(60, 44), material: { resistance: 0.45, moisture: 0.15, wobble: 0.0 }, color: 0x191510, face: 'face_cheese' },
katsu: { verts: box(86, 46), material: { resistance: 0.55, moisture: 0.1, wobble: 0.0 }, color: 0x171008, face: 'face_katsu' },
egg: { verts: circle(28, 10, 1.15), material: { resistance: 0.3, moisture: 0.4, wobble: 0.1 }, color: 0x121212, face: 'face_egg' },
// ponytail: wobble is a material param only — real jelly-cluster jiggle waits
// until a day needs it to be funny. Tofu cuts easy but splats if pressed.
tofu: { verts: box(64, 46), material: { resistance: 0.12, moisture: 0.6, wobble: 0.4 }, color: 0x1a1a18, face: 'face_egg' },
};
let NEXT = 1;
/** A food body + its material and origin colour. Pieces are Foods too. */
export class Food {
readonly id: string;
constructor(
readonly body: MatterJS.BodyType,
readonly material: Material,
readonly color: number,
readonly face: string,
readonly kind: string,
) {
this.id = `${kind}${NEXT++}`;
}
}
/** Build a food body from centred local verts placed at world (x,y). Registers
* the silhouette on the stage. Shared by spawn and by the cutter's pieces. */
export function makeFood(stage: Stage, local: V[], x: number, y: number, mat: Material, color: number, face: string, kind: string): Food {
const body = stage.matter.add.fromVertices(x, y, local, {
friction: 0.6, frictionAir: 0.01, density: 0.0009, restitution: 0.05,
}) as MatterJS.BodyType;
// fromVertices recentres COM; render verts relative to that centre.
const c = centroid(local);
const rel = local.map((v) => ({ x: v.x - c.x, y: v.y - c.y }));
stage.addPoly(body, rel, color);
// a comical face — whole foods only (pieces are pieces). `p`-suffixed kinds
// are cut pieces; don't give a severed half a startled face.
if (!kind.endsWith('p')) {
const xs = local.map((v) => v.x), ys = local.map((v) => v.y);
stage.addFace(body, Math.max(...xs) - Math.min(...xs), Math.max(...ys) - Math.min(...ys));
}
return new Food(body, mat, color, face, kind);
}
/** Spawn a whole food of `kind` at x, resting just above the floor. */
export function spawnFood(stage: Stage, kind: string, x: number, y: number): Food {
const def = FOODS[kind] ?? FOODS.tomato;
return makeFood(stage, def.verts, x, y, def.material, def.color, def.face, kind);
}

35
src/sim/geom.check.ts Normal file
View File

@ -0,0 +1,35 @@
/** Runnable self-check for the cut geometry the honest core, and the part
* that hid a degenerate-vertex bug (a cut line through two verts made lopsided
* halves). Run: `node src/sim/geom.check.ts` (Node strips the types). */
import { polyArea, pieceCV, cvWord, splitConvexByLine, type V } from './geom.ts';
import assert from 'node:assert/strict';
const circle = (r: number, n: number): V[] =>
Array.from({ length: n }, (_, i) => ({ x: Math.cos((i / n) * 2 * Math.PI) * r, y: Math.sin((i / n) * 2 * Math.PI) * r }));
// 1. centred vertical cut of a symmetric polygon → equal halves
{
const c = circle(34, 12);
const [L, R] = splitConvexByLine(c, { x: 0, y: -50 }, { x: 0, y: 50 })!;
assert(Math.abs(polyArea(L) - polyArea(R)) < 1e-6, 'off-vertex split should be symmetric');
}
// 2. THE REGRESSION: cut line passing exactly through two vertices (top+bottom
// of a 12-gon at (0,±r)) must still give equal halves, not lopsided ones.
{
const c = circle(34, 12);
const [L, R] = splitConvexByLine(c, { x: 0, y: -34 }, { x: 0, y: 34 })!;
assert(Math.abs(polyArea(L) - polyArea(R)) < 1e-6, 'through-vertex split must be symmetric');
}
// 3. a line that misses the polygon returns null
{
assert(splitConvexByLine(circle(10, 8), { x: 100, y: -10 }, { x: 100, y: 10 }) === null, 'miss → null');
}
// 4. pieceCV: equal pieces → 0; the ladder words line up
assert(pieceCV([100, 100]) === 0, 'equal areas → CV 0');
assert(pieceCV([50, 150]) > 0.4, 'lopsided pieces → high CV');
assert(cvWord(0) === 'even' && cvWord(0.5) === 'a lottery', 'cvWord ladder');
console.log('geom.check: all assertions passed');

124
src/sim/geom.ts Normal file
View File

@ -0,0 +1,124 @@
/** 2D polygon helpers for the cut. Pure math, no engine the honest core of
* the cutting sim (a convex food polygon split by the blade's line). */
export interface V {
x: number;
y: number;
}
export const sub = (a: V, b: V): V => ({ x: a.x - b.x, y: a.y - b.y });
export const add = (a: V, b: V): V => ({ x: a.x + b.x, y: a.y + b.y });
export const cross = (a: V, b: V): number => a.x * b.y - a.y * b.x;
export const len = (a: V): number => Math.hypot(a.x, a.y);
export const scale = (a: V, s: number): V => ({ x: a.x * s, y: a.y * s });
export const norm = (a: V): V => {
const l = len(a) || 1;
return { x: a.x / l, y: a.y / l };
};
export const perp = (a: V): V => ({ x: -a.y, y: a.x });
/** Shoelace area (always positive). */
export function polyArea(vs: V[]): number {
let a = 0;
for (let i = 0; i < vs.length; i++) {
const j = (i + 1) % vs.length;
a += cross(vs[i], vs[j]);
}
return Math.abs(a) / 2;
}
/** Area-weighted centroid. */
export function centroid(vs: V[]): V {
let a = 0, cx = 0, cy = 0;
for (let i = 0; i < vs.length; i++) {
const j = (i + 1) % vs.length;
const c = cross(vs[i], vs[j]);
a += c;
cx += (vs[i].x + vs[j].x) * c;
cy += (vs[i].y + vs[j].y) * c;
}
a *= 0.5;
if (Math.abs(a) < 1e-6) return { ...vs[0] };
return { x: cx / (6 * a), y: cy / (6 * a) };
}
/** Coefficient of variation of piece areas toastsim's pieceCV, straight port.
* 0 = identical pieces, higher = a lottery. */
export function pieceCV(areas: number[]): number {
if (areas.length < 2) return 0;
const mean = areas.reduce((a, b) => a + b, 0) / areas.length;
if (mean <= 0) return 0;
const v = areas.reduce((a, b) => a + (b - mean) ** 2, 0) / areas.length;
return Math.sqrt(v) / mean;
}
export function cvWord(cv: number): string {
return cv < 0.1 ? 'even' : cv < 0.22 ? 'respectable' : cv < 0.4 ? 'uneven' : 'a lottery';
}
export function pointInPoly(p: V, vs: V[]): boolean {
let inside = false;
for (let i = 0, j = vs.length - 1; i < vs.length; j = i++) {
const a = vs[i], b = vs[j];
if ((a.y > p.y) !== (b.y > p.y) && p.x < ((b.x - a.x) * (p.y - a.y)) / (b.y - a.y) + a.x) {
inside = !inside;
}
}
return inside;
}
/** Longest span across the polygon (diameter). */
export function polyDiameter(vs: V[]): number {
let d = 0;
for (let i = 0; i < vs.length; i++)
for (let j = i + 1; j < vs.length; j++) d = Math.max(d, len(sub(vs[i], vs[j])));
return d;
}
/** Where segment a→b crosses the polygon boundary, sorted along a→b. */
export function segPolyCrossings(a: V, b: V, vs: V[]): V[] {
const dir = sub(b, a);
const hits: { p: V; t: number }[] = [];
for (let i = 0; i < vs.length; i++) {
const c = vs[i], d = vs[(i + 1) % vs.length];
const e = sub(d, c);
const denom = cross(dir, e);
if (Math.abs(denom) < 1e-9) continue;
const t = cross(sub(c, a), e) / denom; // along a→b
const u = cross(sub(c, a), dir) / denom; // along c→d
if (t >= -1e-6 && t <= 1 + 1e-6 && u >= -1e-6 && u <= 1 + 1e-6) {
hits.push({ p: { x: a.x + dir.x * t, y: a.y + dir.y * t }, t });
}
}
hits.sort((x, y) => x.t - y.t);
return hits.map((h) => h.p);
}
/**
* Split a convex polygon by the infinite line through A and B. Returns the two
* halves (each 3 verts), or null if the line misses the polygon.
*/
export function splitConvexByLine(vs: V[], A: V, B: V): [V[], V[]] | null {
const dir = sub(B, A);
const EPS = 1e-6;
const sgn = (p: V): number => {
const s = cross(dir, sub(p, A));
return s > EPS ? 1 : s < -EPS ? -1 : 0; // 0 = on the cut line → shared by both
};
const left: V[] = [], right: V[] = [];
for (let i = 0; i < vs.length; i++) {
const cur = vs[i], nxt = vs[(i + 1) % vs.length];
const sc = sgn(cur), sn = sgn(nxt);
if (sc >= 0) left.push(cur);
if (sc <= 0) right.push(cur);
if (sc * sn < 0) {
// strictly opposite sides: split the edge at the crossing, add to both
const e = sub(nxt, cur);
const t = cross(sub(A, cur), dir) / cross(e, dir);
const p = { x: cur.x + e.x * t, y: cur.y + e.y * t };
left.push(p);
right.push(p);
}
}
if (left.length < 3 || right.length < 3) return null;
return [left, right];
}

325
src/sim/marionette.ts Normal file
View File

@ -0,0 +1,325 @@
import type { Stage } from '../scenes/stage';
/**
* The marionette. A control bar (the player's proxy) hangs 5 strings head,
* two hands, two knees over a 9-segment silhouette puppet. The bar snaps to
* the mouse instantly; the LAG the whole game is built on comes from soft
* string constraints (stiffness < 1), not from any input buffer. The head
* string bears the posture; hands and knees are floppy on purpose.
*
* Bodies live in the Stage's Matter world; the sim owns the control + readouts.
*/
// --- tuning knobs (the calibration surface — tune by harness, not by guess) ---
const K = {
// string stiffness / damping. head + back hold posture; limbs dangle.
headK: 0.14, headD: 0.09,
backK: 0.11, backD: 0.08,
handK: 0.045, handD: 0.06,
kneeK: 0.07, kneeD: 0.06,
jointK: 0.8, // near-rigid pin joints between segments
gripK: 0.75, // clamp stiffness when a hand grabs
gripReach: 60, // px a hand can grab across
barHomeY: 175,
reachDrop: 130, // extra bar lowering while LMB held
group: -7, // shared negative collision group: puppet never collides with itself
railMin: 30, railMax: 1250, // bar x clamp
barCeil: 40, barFloor: 380, // bar y clamp (before reach)
tiltGain: 0.012, tiltMax: 0.5, // horizontal bar speed -> lean radians
};
type Seg =
| 'head' | 'chest' | 'hips'
| 'uArmL' | 'lArmL' | 'uArmR' | 'lArmR'
| 'legL' | 'legR';
/** Unlockable strings difficulty modifiers, not new mechanics. Multipliers on
* string stiffness/damping only; the puppet itself never changes. */
export type StringStyle = 'hemp' | 'rubber' | 'chain' | 'silk';
export const STRING_STYLES: Record<StringStyle, { k: number; d: number; blurb: string }> = {
hemp: { k: 1, d: 1, blurb: 'the honest string' },
rubber: { k: 0.45, d: 0.5, blurb: 'bouncy. +lag +comedy' },
chain: { k: 1.7, d: 2.4, blurb: 'heavy. slow. precise' },
silk: { k: 1.35, d: 1.15, blurb: 'tighter, for cowards' },
};
type Vec = { x: number; y: number };
export interface Pose {
head: number; chest: number; hips: number;
armL: number; armR: number; legL: number; legR: number;
lean: number; // chest tilt from vertical, degrees
upright: boolean;
}
export class Marionette {
readonly seg = {} as Record<Seg, MatterJS.BodyType>;
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;
barY = K.barHomeY;
tilt = 0;
readonly barHomeY = K.barHomeY;
private prevBarX: number;
private home0 = {} as Record<Seg, { x: number; y: number }>;
/** Bodies the hands can grab (crate, later food). Stage fills this. */
gripTargets: MatterJS.BodyType[] = [];
constructor(private stage: Stage, homeX = 400, public style: StringStyle = 'hemp') {
this.barX = homeX;
this.prevBarX = homeX;
const m = stage.matter;
const g = { collisionFilter: { group: K.group } };
const rect = (x: number, y: number, w: number, h: number) =>
m.add.rectangle(x, y, w, h, { ...g, frictionAir: 0.05, friction: 0.7 });
const view = (b: MatterJS.BodyType, w: number, h: number) => stage.addBody(b, w, h, 0x111111);
// 9 segments laid out standing, feet planted on the floor (~642) so the rig
// has traction and self-rights against the strings instead of pure-pendulum.
const hx = homeX;
this.seg.head = m.add.circle(hx, 388, 20, { ...g, frictionAir: 0.05 });
stage.addCircle(this.seg.head, 20, 0x111111);
this.seg.chest = rect(hx, 437, 44, 46); view(this.seg.chest, 44, 46);
this.seg.hips = rect(hx, 482, 44, 40); view(this.seg.hips, 44, 40);
this.seg.uArmL = rect(hx - 30, 439, 13, 46); view(this.seg.uArmL, 13, 46);
this.seg.lArmL = rect(hx - 30, 486, 11, 46); view(this.seg.lArmL, 11, 46);
this.seg.uArmR = rect(hx + 30, 439, 13, 46); view(this.seg.uArmR, 13, 46);
this.seg.lArmR = rect(hx + 30, 486, 11, 46); view(this.seg.lArmR, 11, 46);
this.seg.legL = rect(hx - 12, 587, 16, 110); view(this.seg.legL, 16, 110);
this.seg.legR = rect(hx + 12, 587, 16, 110); view(this.seg.legR, 16, 110);
// pin joints (length 0 = hinge around shared point)
const pin = (a: MatterJS.BodyType, pa: Vec, b: MatterJS.BodyType, pb: Vec) =>
m.add.constraint(a, b, 0, K.jointK, { pointA: pa, pointB: pb, damping: 0.1 });
pin(this.seg.chest, { x: 0, y: -23 }, this.seg.head, { x: 0, y: 20 }); // neck
pin(this.seg.hips, { x: 0, y: -20 }, this.seg.chest, { x: 0, y: 23 }); // spine
pin(this.seg.chest, { x: -22, y: -16 }, this.seg.uArmL, { x: 0, y: -23 });
pin(this.seg.uArmL, { x: 0, y: 23 }, this.seg.lArmL, { x: 0, y: -23 });
pin(this.seg.chest, { x: 22, y: -16 }, this.seg.uArmR, { x: 0, y: -23 });
pin(this.seg.uArmR, { x: 0, y: 23 }, this.seg.lArmR, { x: 0, y: -23 });
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 });
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 s = STRING_STYLES[this.style];
const string = (b: MatterJS.BodyType, pb: Vec, length: number, k: number, d: number) =>
m.add.worldConstraint(b, length, Math.min(0.95, k * s.k), {
pointA: { x: this.barX, y: K.barHomeY },
pointB: pb, damping: d * s.d, render: { visible: false },
});
// rest lengths match the standing pose so the strings hold posture without
// hauling the feet off the floor. head + back together fix torso rotation.
this.strings.head = string(this.seg.head, { x: 0, y: -20 }, 195, K.headK, K.headD);
this.strings.back = string(this.seg.chest, { x: 0, y: -23 }, 240, K.backK, K.backD);
this.strings.handL = string(this.seg.lArmL, { 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.kneeR = string(this.seg.legR, { x: 0, y: -15 }, 395, K.kneeK, K.kneeD);
this.cut = false;
}
/** 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. 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[]) {
b.setPosition(this.seg[key], { x: this.home0[key].x + dx, y: this.home0[key].y }, false);
b.setAngle(this.seg[key], 0, false);
b.setVelocity(this.seg[key], { x: 0, y: 0 });
b.setAngularVelocity(this.seg[key], 0);
}
this.prevBarX = homeX;
this.setBar(homeX, K.barHomeY, 0);
}
/** Anchor offsets from the bar centre, before tilt rotation. */
private static ANCHOR: Record<keyof Marionette['strings'], Vec> = {
head: { x: 0, y: -8 }, back: { x: 0, y: 8 },
handL: { x: -46, y: 12 }, handR: { x: 46, y: 12 },
kneeL: { x: -22, y: 26 }, kneeR: { x: 22, y: 26 },
};
/** Move the control bar. tilt (radians) rotates the whole string array a
* fast horizontal move tilts the bar and the puppet leans into it. */
setBar(x: number, y: number, tilt: number): void {
this.barX = x;
this.barY = y;
this.tilt = tilt;
const c = Math.cos(tilt), s = Math.sin(tilt);
for (const key of Object.keys(this.strings) as (keyof Marionette['strings'])[]) {
const o = Marionette.ANCHOR[key];
const p = this.strings[key].pointA as Vec;
p.x = x + o.x * c - o.y * s;
p.y = y + o.x * s + o.y * c;
}
}
/** 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);
const tilt = clamp((x - this.prevBarX) * K.tiltGain, -K.tiltMax, K.tiltMax);
this.prevBarX = x;
this.setBar(x, y, tilt);
this.setGrip(grip);
}
/** hand tip world positions. */
handWorld(): { L: Vec; R: Vec } {
return { L: this.handTipWorld(this.seg.lArmL), R: this.handTipWorld(this.seg.lArmR) };
}
/** The 6 strings as bar-anchor body-attach world segments, for drawing.
* Empty once the strings are cut. */
stringSegments(): { ax: number; ay: number; bx: number; by: number }[] {
if (this.cut) return [];
const out: { ax: number; ay: number; bx: number; by: number }[] = [];
for (const key of Object.keys(this.strings) as (keyof Marionette['strings'])[]) {
const c = this.strings[key] as unknown as { pointA: Vec; pointB: Vec; bodyB: MatterJS.BodyType };
const a = c.pointA, pb = c.pointB, body = c.bodyB;
const co = Math.cos(body.angle), si = Math.sin(body.angle);
out.push({ ax: a.x, ay: a.y, bx: body.position.x + pb.x * co - pb.y * si, by: body.position.y + pb.x * si + pb.y * co });
}
return out;
}
/** The wooden control bar as a segment (centre ± half-width, tilted). */
barSegment(half = 58): { x1: number; y1: number; x2: number; y2: number } {
const c = Math.cos(this.tilt), s = Math.sin(this.tilt);
return { x1: this.barX - half * c, y1: this.barY - half * s, x2: this.barX + half * c, y2: this.barY + half * s };
}
get gripping(): boolean {
return this.gripped;
}
get holding(): boolean {
return this.gripCon.some((c) => c !== null);
}
/** The bodies currently clamped in the hands (grab constraints are armtarget,
* so the target is bodyB). */
held(): MatterJS.BodyType[] {
const out: MatterJS.BodyType[] = [];
for (const c of this.gripCon) {
const t = (c as unknown as { bodyB?: MatterJS.BodyType } | null)?.bodyB;
if (t) out.push(t);
}
return out;
}
/** Both hands want to clamp. Turning grip OFF drops whatever's held. The
* actual grab happens in update(): while gripped, any empty hand latches
* onto the nearest target the instant it comes within reach so you can
* lower a closed hand onto food and it catches on contact. */
setGrip(on: boolean): void {
if (on === this.gripped) return;
this.gripped = on;
if (!on) {
for (const c of this.gripCon) if (c) this.stage.matter.world.remove(c);
this.gripCon = [null, null];
}
}
/** Per-frame: latch empty gripping hands onto anything in reach. */
update(): void {
if (!this.gripped) return;
const arms = [this.seg.lArmL, this.seg.lArmR];
for (let i = 0; i < 2; i++) {
if (this.gripCon[i]) continue;
const tipW = this.handTipWorld(arms[i]);
const t = this.nearestTarget(tipW);
if (!t) continue;
this.gripCon[i] = this.stage.matter.add.constraint(arms[i], t, 0, K.gripK, {
pointA: { x: 0, y: 23 }, pointB: this.toLocal(t, tipW), damping: 0.1,
});
}
}
private handTipWorld(arm: MatterJS.BodyType): Vec {
const a = arm.angle;
return { x: arm.position.x - Math.sin(a) * 23, y: arm.position.y + Math.cos(a) * 23 };
}
private nearestTarget(p: Vec): MatterJS.BodyType | null {
let best: MatterJS.BodyType | null = null, bd = K.gripReach;
for (const t of this.gripTargets) {
const d = Math.hypot(t.position.x - p.x, t.position.y - p.y);
if (d < bd) { bd = d; best = t; }
}
return best;
}
private toLocal(body: MatterJS.BodyType, world: Vec): Vec {
const dx = world.x - body.position.x, dy = world.y - body.position.y;
const c = Math.cos(-body.angle), s = Math.sin(-body.angle);
return { x: dx * c - dy * s, y: dx * s + dy * c };
}
private deg(rad: number): number {
return +((rad * 180) / Math.PI).toFixed(1);
}
pose(): Pose {
const lean = this.deg(this.seg.chest.angle);
const upright = this.seg.head.position.y < this.seg.hips.position.y && Math.abs(lean) < 45;
return {
head: this.deg(this.seg.head.angle),
chest: this.deg(this.seg.chest.angle),
hips: this.deg(this.seg.hips.angle),
armL: this.deg(this.seg.lArmL.angle),
armR: this.deg(this.seg.lArmR.angle),
legL: this.deg(this.seg.legL.angle),
legR: this.deg(this.seg.legR.angle),
lean,
upright,
};
}
/** hips x — the thing that moves when the puppet walks. */
get torsoX(): number {
return this.seg.hips.position.x;
}
/** peak speed of any segment — 0 ≈ fully settled. */
restSpeed(): number {
let v = 0;
for (const key of Object.keys(this.seg) as Seg[]) {
v = Math.max(v, Math.hypot(this.seg[key].velocity.x, this.seg[key].velocity.y));
}
return v;
}
}

73
src/sim/samurai.ts Normal file
View File

@ -0,0 +1,73 @@
/**
* 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
ramp = 1; // day patience multiplier — sins hurt more on the rush-hour day
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).
* Sins scale by the day ramp; sheathing (a clean serve) does not. */
bump(ev: AngerEvent, amount = ANGER[ev]): void {
this.last = ev;
const scaled = amount > 0 ? amount * this.ramp : amount;
this.anger = Math.max(0, Math.min(1, this.anger + scaled));
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);
}
}

132
src/sim/stack.ts Normal file
View File

@ -0,0 +1,132 @@
import type { Stage } from '../scenes/stage';
import { box, Food, FOODS, makeFood } from './food';
/**
* The sando. The stack is DETECTED, not declared: whatever food bodies are
* physically resting in the plate zone, sorted bottomtop, IS the sando so a
* human dropping pieces by marionette hand and the harness placing them are
* judged identically. Stability is honest Matter stacking; the judge scores
* lean, COM drift, overhang off the real bodies.
*/
interface LayerDef {
w: number;
h: number;
color: number;
face: string;
}
const L = (kind: string, w: number, h: number): LayerDef => ({
w, h, color: FOODS[kind].color, face: FOODS[kind].face,
});
export const LAYERS: Record<string, LayerDef> = {
bread: L('bread', 94, 24),
tomato: L('tomato', 78, 18),
cheese: L('cheese', 82, 16),
katsu: L('katsu', 88, 22),
egg: L('egg', 70, 20),
tofu: L('tofu', 74, 22),
};
export interface SandoStats {
layers: number;
leanDeg: number; // tilt of the stack axis from vertical
comDrift: number; // px the worst layer sits off the plate centre
overhang: number; // px the worst layer juts past the one below its support
standing: boolean;
}
/** Materialise one slice-shaped layer food at (x, y). Caller registers it. */
export function spawnLayer(stage: Stage, kind: string, x: number, y: number): Food {
const def = LAYERS[kind] ?? LAYERS.bread;
const mat = FOODS[kind]?.material ?? FOODS.bread.material;
return makeFood(stage, box(def.w, def.h), x, y, mat, def.color, def.face, kind);
}
const deg = (r: number): number => +((r * 180) / Math.PI).toFixed(1);
const halfW = (b: MatterJS.BodyType): number => (b.bounds.max.x - b.bounds.min.x) / 2;
const ZONE_HALF = 82; // px either side of plate centre that counts as "on the dish"
const IN_FLIGHT = 5; // px/step — anything faster is passing through, not stacked
export class Sando {
constructor(private stage: Stage, public plateX = 980, public plateTopY = 634) {}
/** The physical stack: foods resting in the plate zone, bottom→top. */
get layers(): Food[] {
return this.stage.cutter.foods
.filter((f) => {
const b = f.body;
return (
Math.abs(b.position.x - this.plateX) < ZONE_HALF &&
b.position.y < this.plateTopY + 6 &&
Math.hypot(b.velocity.x, b.velocity.y) < IN_FLIGHT
);
})
.sort((a, b) => b.body.position.y - a.body.position.y);
}
/** Base kinds bottom→top ('tomatop' piece → 'tomato'). What the judge reads. */
kinds(): string[] {
return this.layers.map((f) => f.kind.replace(/p+$/, ''));
}
/** Top surface of the current stack (or the plate if empty). */
private topY(): number {
const ls = this.layers;
if (!ls.length) return this.plateTopY;
return Math.min(...ls.map((f) => f.body.bounds.min.y));
}
/** Harness helper: materialise one layer on top and settle. The spawned food
* registers with the cutter's food list, so detection sees it like any other. */
addLayer(kind: string, offsetX = 0, settleFrames = 45): Food {
const def = LAYERS[kind] ?? LAYERS.bread;
const y = this.topY() - def.h / 2 - 1;
const food = spawnLayer(this.stage, kind, this.plateX + offsetX, y);
this.stage.cutter.add(food);
this.stage.auto = false;
for (let i = 0; i < settleFrames; i++) this.stage.stepSim();
return food;
}
/** Remove everything currently in the plate zone. */
clear(): void {
for (const f of this.layers) {
this.stage.removeProp(f.body);
this.stage.cutter.foods = this.stage.cutter.foods.filter((x) => x !== f);
}
}
stats(): SandoStats {
const ls = this.layers;
const n = ls.length;
if (n === 0) return { layers: 0, leanDeg: 0, comDrift: 0, overhang: 0, standing: false };
const bottom = ls[0].body, top = ls[n - 1].body;
const leanDeg = deg(Math.atan2(top.position.x - bottom.position.x, Math.max(1, bottom.position.y - top.position.y)));
let comDrift = 0, overhang = 0, stackH = 0;
for (let i = 0; i < n; i++) {
const b = ls[i].body;
comDrift = Math.max(comDrift, Math.abs(b.position.x - this.plateX));
stackH += b.bounds.max.y - b.bounds.min.y;
if (i > 0) {
const below = ls[i - 1].body;
const off = Math.abs(b.position.x - below.position.x);
overhang = Math.max(overhang, off - Math.max(0, halfW(below) - halfW(b)));
}
}
// Standing = the tower still has its height: a collapse drops the top layer
// (and usually scatters pieces out of the zone entirely — also honest).
const elevation = this.plateTopY - top.position.y;
const standing = elevation > 0.45 * stackH;
return { layers: n, leanDeg, comDrift: +comDrift.toFixed(1), overhang: +overhang.toFixed(1), standing };
}
/** The earthquake: shove every stacked layer sideways (px/step). */
nudge(force: number): void {
const b = this.stage.matter.body;
for (const f of this.layers) {
b.setVelocity(f.body, { x: f.body.velocity.x + force, y: f.body.velocity.y });
}
}
}

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

17
tsconfig.json Normal file
View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"skipLibCheck": true,
"esModuleInterop": true,
"types": [],
"lib": ["ES2022", "DOM", "DOM.Iterable"]
},
"include": ["src"],
"exclude": ["src/**/*.check.ts"]
}

7
vite.config.ts Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite';
// Dev serves at '/', the production build is rooted at '/sandoniette/' so it can
// live at partly.party/sandoniette/. Keep in sync with the deploy web root.
export default defineConfig(({ command }) => ({
base: command === 'build' ? '/sandoniette/' : '/',
}));