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
|
# サンドニエット 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
|
*"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
|
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
|
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"]
|
||||||
|
}
|
||||||