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>
11
.claude/launch.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "dev",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 5173
|
||||
}
|
||||
]
|
||||
}
|
||||
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
*.local
|
||||
.DS_Store
|
||||
@ -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
@ -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
19
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
public/assets/img/bg_washi.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
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_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_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 |
87
scripts/gen-assets.sh
Executable 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
@ -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
@ -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
@ -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
@ -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
@ -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"]
|
||||
}
|
||||