M0: vite+ts+phaser+Matter skeleton, harness, box drops flush

- Phaser 3.90 Matter, autoUpdate:false — deterministic stepSim(dt), harness-driven.
- Render contract: raw Matter body = truth, flat black silhouette follows.
- Rng (mulberry32) ported from toastsim; dev harness on window.__s.
- Exit-bar measured: box drops y=80 -> settles 68 frames -> rests y=608.05,
  gap -0.05px flush on floor, landed:true. Verified in-browser + screenshot.
- scripts/gen-assets.sh (2D-only flux via MODELBEAST) firing in background.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-17 20:02:27 +10:00
parent 0eb48b87e6
commit 802f738a3d
28 changed files with 1510 additions and 0 deletions

11
.claude/launch.json Normal file
View File

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

4
.gitignore vendored Normal file
View File

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

View File

@ -1,5 +1,19 @@
# サンドニエット SANDONIETTE — Build Brief 1: The Puppet, The Food, The Samurai
## STATUS (update this as you go — a cold session reads here first)
- **M0 ✅** vite+ts+phaser(3.90)+Matter skeleton, typecheck green, harness (`window.__s`)
installed. Box drops from y=80, settles in **68 frames**, rests **y=608.05**
flush on the floor (gap 0.05px, `landed:true`). Verified in-browser + screenshot.
- Stack: Phaser Matter, `autoUpdate:false` — sim steps only via `stage.stepSim(dt)`,
so the harness drives frame-by-frame. Render contract: raw Matter body = truth,
flat silhouette `Prop.view` follows it (`Stage.addBody`). `Rng` = mulberry32.
- Assets: `scripts/gen-assets.sh` (2D flux only) firing on MODELBEAST →
`public/assets/img/`. Log: `~/.jobs/sandoniette-assets.log`. Game never blocks on art.
- **Next: M1** puppet (`sim/marionette.ts`) — the dangle.
- Run: `npm run dev` (:5173). Harness verbs live in `src/dev.ts`.
*"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

17
index.html Normal file
View File

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

1176
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
package.json Normal file
View File

@ -0,0 +1,19 @@
{
"name": "sandoniette",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"phaser": "^3.90.0"
},
"devDependencies": {
"typescript": "^5.6.0",
"vite": "^6.0.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 503 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 546 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 513 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 498 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 559 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 752 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 664 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 720 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 700 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 824 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 725 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 486 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

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

@ -0,0 +1,87 @@
#!/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."

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

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

61
src/dev.ts Normal file
View File

@ -0,0 +1,61 @@
import type { Stage } from './scenes/stage';
import { FLOOR_Y } from './scenes/stage';
/**
* 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 here; nothing ships without a harness number behind it.
*/
export function installHarness(stage: Stage): void {
const rest = (body: MatterJS.BodyType) =>
Math.hypot(body.velocity.x, body.velocity.y) < 0.05 && Math.abs(body.angularVelocity) < 0.01;
const step = (frames: number, dt = 1000 / 60): void => {
stage.auto = false;
for (let i = 0; i < frames; i++) stage.stepSim(dt);
};
/** Step until the box stops moving (or maxFrames). Returns frames elapsed. */
const settle = (body: MatterJS.BodyType, maxFrames = 600): number => {
stage.auto = false;
let f = 0;
for (; f < maxFrames; f++) {
stage.stepSim();
if (rest(body)) break;
}
return f;
};
const s = {
stage,
step,
settle,
/** M0 exit-bar: drop the box from height, let it settle, report where it landed. */
drop(x = 640, y = 80) {
stage.auto = false;
const body = stage.matter.body;
body.setPosition(stage.box, { x, y }, false);
body.setVelocity(stage.box, { x: 0, y: 0 });
body.setAngle(stage.box, 0, false);
body.setAngularVelocity(stage.box, 0);
const frames = settle(stage.box);
const restY = +stage.box.position.y.toFixed(2);
return {
frames,
restY,
floorTopY: FLOOR_Y,
gap: +(FLOOR_Y - 32 - restY).toFixed(2), // 0 ≈ sitting flush (half the 64px box)
landed: rest(stage.box) && restY < FLOOR_Y + 8,
};
},
};
(window as { __s?: typeof s }).__s = s;
// eslint-disable-next-line no-console
console.log('[dev] __s ready — try __s.drop()');
}

15
src/main.ts Normal file
View File

@ -0,0 +1,15 @@
import Phaser from 'phaser';
import { Stage, STAGE_W, STAGE_H } from './scenes/stage';
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],
});

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

@ -0,0 +1,63 @@
import Phaser from 'phaser';
import { installHarness } from '../dev';
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;
}
/**
* 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 drives one fixed step per frame; the harness sets
* auto=false and steps itself.
*/
export class Stage extends Phaser.Scene {
auto = true;
props: Prop[] = [];
box!: MatterJS.BodyType;
constructor() {
super('stage');
}
create(): void {
this.matter.world.autoUpdate = false;
// floor + side walls so nothing wanders off before there are stage edges to care about
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 });
// M0: a single box that drops onto the floor and comes to rest.
this.box = this.matter.add.rectangle(STAGE_W / 2, 80, 64, 64, { restitution: 0.15, friction: 0.6 });
this.addBody(this.box, 64, 64, 0x111111);
installHarness(this);
}
/** Register a body with a flat 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;
}
stepSim(dt = 1000 / 60): void {
this.matter.world.step(dt);
}
update(): void {
if (this.auto) this.stepSim();
for (const p of this.props) {
p.view.setPosition(p.body.position.x, p.body.position.y);
p.view.setRotation(p.body.angle);
}
}
}

16
tsconfig.json Normal file
View File

@ -0,0 +1,16 @@
{
"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"]
}