Compare commits
10 Commits
0eb48b87e6
...
26921eb4ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26921eb4ad | ||
|
|
4f4b31b914 | ||
|
|
506a57fa89 | ||
|
|
077e970b16 | ||
|
|
cdde03bef4 | ||
|
|
58285712ef | ||
|
|
7116b71d17 | ||
|
|
ea2c8c5089 | ||
|
|
da840b531a | ||
|
|
802f738a3d |
12
.claude/launch.json
Normal 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
@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
*.local
|
||||
.DS_Store
|
||||
@ -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 S–D), `game/lines.ts`
|
||||
(bilingual samurai barks, 8+/group, deterministic pick). **THE STRING CUT**:
|
||||
`Marionette.cutStrings()` deletes all 6 strings in one frame → ragdoll (pins
|
||||
survive); stage flings loose food with the slash impulse + white slash FX +
|
||||
synth gong. Backdrop = `bg_washi`, samurai portrait HUD + anger bar swap with
|
||||
the meter. Assets now wired in — the stage reads as the shadow-puppet theatre.
|
||||
Exit-bar: clean run **grade S(10)** standing; bad run **grade D**; disaster run
|
||||
**strings cut → ragdoll head falls ~180px, samurai fury**. Judge discriminates
|
||||
S↔D. Verified in-browser (scorecard + string-cut screenshots) + full battery.
|
||||
Trap fixed: field named `game` collided with `Phaser.Scene.game` → renamed
|
||||
`session`; walk faceplant test was per-frame lean>45 (a stride swings past 45
|
||||
and recovers) → now "did the head hit the floor / end collapsed".
|
||||
- Note: a string cut is game-over; real restart should rebuild the scene. Harness
|
||||
`reset()` restrings in place — Matter's constraint solve-order makes the
|
||||
restrung puppet marginally different but still walks fine (head stays ~400,
|
||||
floor 640).
|
||||
- **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 6–8**: Tamago (egg rolls), Tofu (fragile), THE TOWER (5 layers). All 8
|
||||
days S via harness. Live serve: dwell commits at **31 frames ≈ 0.5s**, grade B
|
||||
(no cut performed — honest), day 8 same; death→retry + serve→advance both
|
||||
close the loop (verified by simulated clicks + manual queue flush).
|
||||
- **samurai_bow.png** (same seed 320) wired: an S-grade serve swaps the portrait
|
||||
to the single approving nod. counter_wood/day_stamp/face_cut_bread banked in
|
||||
`public/assets/img/`, unwired.
|
||||
- **Embedded-pane trap (KNOWN, not a bug)**: the preview pane reports
|
||||
`document.visibilityState='hidden'` → rAF never fires → live loop + queued
|
||||
scene ops freeze; harness drives frames manually, and
|
||||
`game.scene.processQueue()` flushes ops by hand. Real visible tabs unaffected.
|
||||
- Full battery green: M1 87f/8.41u/10-10 · M2 cv0-clean/splat/wild · M3 ✓✓ ·
|
||||
days SSSSSSSS · live served@31f / d8 served / dead CUT · cleaver CUTS.
|
||||
|
||||
## POLISH PASS (post-M6) ✅ — "make it even better"
|
||||
- **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 M1–M6 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
@ -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
@ -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
20
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
public/assets/img/bg_washi.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
public/assets/img/counter_wood.png
Normal file
|
After Width: | Height: | Size: 460 KiB |
BIN
public/assets/img/day_stamp.png
Normal file
|
After Width: | Height: | Size: 440 KiB |
BIN
public/assets/img/face_bread.png
Normal file
|
After Width: | Height: | Size: 503 KiB |
BIN
public/assets/img/face_cheese.png
Normal file
|
After Width: | Height: | Size: 546 KiB |
BIN
public/assets/img/face_cut_bread.png
Normal file
|
After Width: | Height: | Size: 492 KiB |
BIN
public/assets/img/face_egg.png
Normal file
|
After Width: | Height: | Size: 513 KiB |
BIN
public/assets/img/face_katsu.png
Normal file
|
After Width: | Height: | Size: 498 KiB |
BIN
public/assets/img/face_tomato.png
Normal file
|
After Width: | Height: | Size: 559 KiB |
BIN
public/assets/img/floor_boards.png
Normal file
|
After Width: | Height: | Size: 752 KiB |
BIN
public/assets/img/samurai_angry.png
Normal file
|
After Width: | Height: | Size: 664 KiB |
BIN
public/assets/img/samurai_annoyed.png
Normal file
|
After Width: | Height: | Size: 720 KiB |
BIN
public/assets/img/samurai_bow.png
Normal file
|
After Width: | Height: | Size: 708 KiB |
BIN
public/assets/img/samurai_calm.png
Normal file
|
After Width: | Height: | Size: 700 KiB |
BIN
public/assets/img/samurai_fury.png
Normal file
|
After Width: | Height: | Size: 824 KiB |
BIN
public/assets/img/samurai_watch.png
Normal file
|
After Width: | Height: | Size: 725 KiB |
BIN
public/assets/img/slash_frame.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
public/assets/img/splat_wet.png
Normal file
|
After Width: | Height: | Size: 372 KiB |
BIN
public/assets/img/ticket_paper.png
Normal file
|
After Width: | Height: | Size: 486 KiB |
BIN
public/assets/img/title_art.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
93
scripts/gen-assets.sh
Executable 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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 arm→target,
|
||||
* 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
@ -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
@ -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 bottom→top, 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
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
17
tsconfig.json
Normal 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
@ -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/' : '/',
|
||||
}));
|
||||