M0: scaffold — Vite+TS+Three+Rapier, slice sim core, asset pipeline

The bread slice is the whole game's canvas, so it's built first and properly:

- Field: a 128x128 scalar field with bilinear sample, a mass-conserving brush,
  self-levelling relax, and the masked statistics the judge will read.
- Slice: extruded loaf silhouette (domed top), planar-projected UVs so the sim
  grid lines up with the bread, per-texel silhouette mask, and four fields
  (browning/dryness/spread/damage) packed into one RGBA8 texture per frame.
- Custom shader: browning ramp with blotchy char, crumb/crust/inclusions,
  spread wetting + opacity + thickness relief, gloss, gouge exposure, and
  flat data views for the judge screen.
- bread.ts / spreads.ts: the archetypes and the rheology the difficulty
  curve will live in.
- scripts/gen-assets.sh: reproducible fixed-seed asset generation through
  MODELBEAST (flux_local -> bg_remove_local -> hunyuan3d_mlx). All on-device.

Legibility work that mattered: butter rendered invisible at first. Fixes were
a gamma-style wetting darkening (a multiply *brightens* pale crumb), a normal
perturbation taken in UV units rather than raw texel deltas (~30x too small),
and pulling the lighting back under 1.0 so ACES stops desaturating yellow to
cream.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
monster 2026-07-16 21:16:18 +10:00
commit 10adb268a0
19 changed files with 2891 additions and 0 deletions

11
.claude/launch.json Normal file
View File

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

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules/
dist/
.DS_Store
*.log
.vite/

144
OPUS-BUILD-BRIEF.md Normal file
View File

@ -0,0 +1,144 @@
# TOASTSIM — Build Brief
**For: Claude Opus 4.8, executing in Claude Code on this Mac (M3 Ultra, cwd `/Users/m3ultra/Documents/toastsim`).**
**Approved by the owner. Build the whole thing; commit and push per milestone. Asset generation is free and pre-authorized — see §3.**
---
## 1. What we're building
**TOASTSIM** — a browser game about making toast, where the *inputs are analog* and the *output is judged*. Two connected activities:
1. **The Toast Round** — toast bread (timing + power), spread it (pressure/angle physics per spread type), scrape burnt bits (risk/reward), get scored by a deadpan judge.
2. **The Cutlery Drawer** — a 3D physics minigame: a tangled drawer of *similar-but-not-identical* metal cutlery; fish out the specific requested item against a timer, **while your toast cools** (cold toast = hard butter = harder spread round).
Tone: dry, characterful, slightly absurd. PowerWash-Simulator-style satisfaction with a scoring screen that makes you go "one more toast."
**Arcade loop:** a customer order appears ("dark toast, thin vegemite, NO burnt bits") → drawer → toast → spread/scrape → judgment → next order, escalating.
## 2. Stack & repo
- **Vite + TypeScript + Three.js** for everything (both scenes 3D — the toast slice is a real GLB you paint on). **@dimforge/rapier3d-compat** for drawer physics. No React needed; DOM overlay for UI is fine. Target 60fps in Chrome.
- Repo: the folder is empty. `git init`, build in it, remote is `https://gitea.partly.party/monster/toastsim.git` (exists, empty). Push `main` after each milestone with a clear message.
- Create `.claude/launch.json` with the Vite dev server so the app is previewable via the Browser pane. Verify visually with screenshots as you go — don't trust "it compiles."
- Generated assets go in `public/assets/` (committed — they're small GLBs/PNGs and reproducibility matters less than a working repo).
## 3. Asset factory — MODELBEAST (free, local, pre-authorized)
This Mac runs MODELBEAST, a job server wrapping local ML tools. **You are explicitly allowed to use it. Everything below is $0 (on-device).** Full reference: `~/Documents/MODELBEAST/AGENTS.md` — read it before your first job.
### Auth & basics
```bash
cd ~/Documents/MODELBEAST
source data/agent.env # sets MB_HOST + MB_TOKEN (guest: local ops only, free by design)
./mb ops -v # live operator catalog — trust this over docs
```
If the server is down (`curl http://localhost:8777/api/health` fails): `~/Documents/MODELBEAST/scripts/serve.sh`.
Guest tokens **cannot** run paid cloud ops (server-enforced 403) — never try to work around that; the local chain below is the intended path.
### The two pipelines you'll use
**Prompt → 2D image** (UI art, judge portraits, backdrops, labels, title screen):
```bash
./mb run flux_local -p prompt="..." -p model=flux2-klein-4b -p steps=4 -p seed=7 --wait --download out/
```
Fixed seeds = reproducible. 1024×1024 default; set `width`/`height` for backdrops.
**Prompt → 3D GLB** (bread, toaster, cutlery, jars, plate):
```bash
# 1. generate a clean "product shot" image of the object
./mb run flux_local -p prompt="<object>, single object centered on plain light grey background, soft studio lighting, 3/4 view, stylized chunky game asset" -p seed=<n> --wait
# 2. cut it out (biggest quality lever for image→3D)
./mb run bg_remove_local --asset <IMG_ID> --wait
# 3. mesh it (GLB + PBR texture, ~4-5 min, no login needed)
./mb run hunyuan3d_mlx --asset <CUT_ID> -p octree_resolution=256 -p texture_size=1024 --wait --download public/assets/models/
```
`blender_convert` exists if you need format conversion. `mflux_image_edit` for prompt-based image fixes.
### Job etiquette (hard rules)
- **GPU jobs run one at a time** (serial lane). Queue asset jobs early, then *keep writing code while they run* — check with `./mb jobs` / `./mb log <ID> -f`. Never spam retries on `queued`.
- First run of an operator may download weights (1030 min). Be patient; don't cancel.
- Don't delete assets you didn't create. `./mb rm-asset` your own intermediates when done.
### Art direction & asset manifest
Pick ONE style token and use it in every prompt for coherence. Recommended: **"chunky stylized claymation kitchen, soft matte plastic look, warm colors"** — chunky geometry hides image→3D artifacts, reads well at game scale, and gives cutlery thicker (physics-friendlier) forms.
Generate (3D via the pipeline above, one flux seed noted per asset so it's re-runnable):
| Asset | Notes |
|---|---|
| Bread slices ×4: white, thick-cut sourdough, multigrain, raisin | These are the paint canvases — need a clear flat top face. If Hunyuan's top-face UVs are unusable for painting, **fallback: procedural slice** (rounded-box extrusion in Three.js) with a flux-generated bread-crust texture — the paint tech matters more than the mesh source. |
| Toaster (2-slot, retro) | Hero prop of the toast scene |
| Cutlery: butter knife, dinner knife, steak knife, spreader; dinner fork, dessert fork; teaspoon, dessert spoon, soup spoon | The "similar but not identical" cast. If thin tines/blades come out mangled from image→3D, **model cutlery procedurally** (extruded 2D profiles) — silhouettes must be hand-controlled anyway for fair gameplay. Decide after one test generation. |
| Butter dish, peanut-butter jar, vegemite jar (fictional brand: "MITEY") | Bench props / selection UI |
| Plate, kitchen bench surface | Bench = one big flux texture on a plane is fine |
| Judge portraits ×5 expressions (neutral, intrigued, impressed, disappointed, horrified) | 2D, flux + bg_remove. Same character every time: describe them exactly, fix the seed, vary only the expression phrase. A stern older health-inspector type with a clipboard works. |
| Title art, order-ticket paper texture, UI icons | 2D |
**Normalize every GLB on load in code** (bounding-box → unit scale, recenter pivot) — don't hand-fix meshes.
## 4. Game design spec
### 4.1 Toasting
- Browning is a **2D scalar field** (~64×64) per slice face, advanced each tick by heat input; render it by compositing into the slice's diffuse texture (white → golden → brown → black, with char speckle past 0.85).
- Heat input = toaster power dial (110) × coil proximity map (hotspots near coil lines, cooler edges, slight random per-run bias so no two runs identical).
- Per-bread params: `thickness` (thermal lag), `moisture` (delays browning then accelerates — sourdough), `sugar` (raisin browns fast and burns faster).
- Player controls: power dial + lever down; **no timer readout** — you judge by smell cues (text wisps: "smells warm… toasty… SMOKY") and can pop early anytime. Popping early and re-toasting is allowed but re-toasting browns unevenly (field already has gradient).
### 4.2 Spreading & scraping — the star mechanic
- Knife follows the mouse over the slice's top face (raycast → UV → paint into spread field, a second 2D field with `amount` per texel).
- **Scroll wheel = knife angle.** Flat (low angle) = wide contact, gentle pressure, spreads. Steep = narrow contact, high pressure: scrapes spread *off* — or drags burnt char off (reduces browning field where >0.85, spawns crumb particles). Too steep + too fast on clean bread = **gouge** (permanent damage decal, integrity penalty). One unified control, three behaviors.
- Dip into the butter/jar to load the knife (visible blob on blade, depletes as you spread).
- **Spread rheology** per type: `{ yieldPressure, viscosity, pickupAmount, tempSoftening }`
- **Butter**: high yieldPressure when cold — below it the butter doesn't spread, it *tears the bread* (drags the surface, integrity damage). `effectiveHardness = f(butterTemp, toastWarmth)`; toast warmth decays in real time (drawer dawdling matters). Soft butter = dream mode.
- **Peanut butter**: high viscosity, big pickup — goes on thick, hard to get even, drags.
- **Vegemite**: low viscosity, tiny correct dose — the skill is spreading then **scraping most of it back off**. Judge wants a translucent film; opaque = massive penalty ("This is a hate crime").
- Wrong tool from the drawer applies modifiers: spoon = blotchy coverage, fork = tears, steak knife = gouge-prone.
### 4.3 The drawer
- Rapier scene: drawer box, 1014 cutlery pieces spawned tangled (drop-settle at load, cached). Order card shows a **silhouette** of the required item.
- Mouse grabs via spring joint (raycast to nearest piece, joint to grab point); pieces clatter and snag realistically. Lift the right item fully clear of the drawer rim to win.
- Timer = your toast cooling. Grabbing the *wrong* item and committing (button) lets you proceed with the wrong-tool modifier — sometimes the smart play.
- **Colliders: hand-authored compound primitives** (capsules/boxes) per archetype, NOT trimesh — stability and speed. Visual mesh can be the generated GLB.
- Start with 8 pieces; only raise the count once tangling feels good.
- Metal-on-metal clatter sounds are non-negotiable for feel (see §4.5).
### 4.4 Judging
Score 010, itemized so the player sees exactly why:
| Criterion | Measures |
|---|---|
| Browning match | mean browning vs. order target |
| Evenness | browning field variance |
| Coverage | % of face with spread in the target thickness band |
| Uniformity | spread field variance |
| Char | texels >0.85 remaining (scraping fixed it? no penalty) |
| Integrity | gouges, tears, over-scrape |
| Order compliance | right spread, right amount class (thin/normal/thick) |
| Time bonus | small — never dominant |
Judge delivers 12 dry lines keyed to the worst and best criterion (write a bank of ~40; e.g. "The coverage is admirable. The crater is not."). Show the toast rotating on a pedestal with a heatmap toggle. Grade stamp (S/A/B/C/F). **This screen is the game — make it excellent.**
### 4.5 Sound & feel
- Web Audio, synthesized where possible: toaster tick, lever clunk, pop (with slice launch physics!), knife scrape (pitch ∝ pressure), butter-tear rip, cutlery clatter (Rapier collision events → filtered noise bursts, velocity-scaled), judge stamp thunk.
- Screen-shake on pop, crumb particles on scrape, steam wisps off warm toast.
## 5. Milestones (commit + push each; verify in browser before moving on)
- **M0 — Scaffold**: Vite+TS+Three+Rapier boots, launch.json, placeholder cube scene, pushed. *Also: fire off the first MODELBEAST asset jobs now so weights download while you build M1.*
- **M1 — Toasting**: procedural slice + browning field + toaster controls + pop. Playable: make toast of any darkness.
- **M2 — Spreading**: knife, angle control, butter + vegemite + PB rheology, scraping, gouges. This milestone is where the fun lives — iterate until dragging cold butter genuinely feels bad in a funny way.
- **M3 — Judging**: full rubric, judge lines, grade screen, "next order" loop with 5 handwritten orders.
- **M4 — Drawer**: physics drawer with 8 pieces, silhouette orders, spring grab, toast-cooling link, wrong-tool modifiers.
- **M5 — Content & escalation**: 4 breads, order generator with difficulty curve (day 1: butter on white → day 7: thin vegemite on warm crumpet-thick sourdough with a steak knife because the drawer's a mess), persistent day/score.
- **M6 — Art & polish pass**: swap placeholders for generated assets, judge portraits, title screen, sound polish, perf check (60fps with drawer active).
At each milestone: run it, screenshot it, check the console is clean, then commit. If a milestone's feel is wrong, fixing feel beats advancing.
## 6. Judgment calls you're pre-authorized to make
- Procedural vs. generated meshes per asset (test generation first, choose per §3 fallbacks).
- Any tuning of the rheology/browning constants — the spec's numbers are starting points.
- Cutting scope inside a milestone to protect feel (e.g., 2 spreads that feel great > 3 that don't).
- Do **not** add paid/cloud dependencies, accounts, or external services. Do not touch anything in `~/Documents/MODELBEAST` except via the `mb` CLI as documented.

15
index.html Normal file
View File

@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TOASTSIM</title>
</head>
<body>
<div id="app">
<canvas id="c"></canvas>
<div id="ui"></div>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1353
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
package.json Normal file
View File

@ -0,0 +1,21 @@
{
"name": "toastsim",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@dimforge/rapier3d-compat": "^0.19.3",
"three": "^0.185.1"
},
"devDependencies": {
"@types/three": "^0.185.1",
"typescript": "^7.0.2",
"vite": "^8.1.5"
}
}

Binary file not shown.

Binary file not shown.

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

@ -0,0 +1,99 @@
#!/usr/bin/env bash
# TOASTSIM asset generation — 100% local, 100% free, via MODELBEAST on the M3 Ultra.
#
# ./scripts/gen-assets.sh 3d # product shot -> cutout -> GLB (slow: ~5 min each, gpu lane is serial)
# ./scripts/gen-assets.sh 2d # flux images (judge portraits, backdrops, ui)
# ./scripts/gen-assets.sh all
#
# Seeds are fixed so every asset is re-generatable. Style token is shared for coherence.
set -uo pipefail
MB_DIR="$HOME/Documents/MODELBEAST"
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
OUT_MODELS="$REPO/public/assets/models"
OUT_IMG="$REPO/public/assets/img"
mkdir -p "$OUT_MODELS" "$OUT_IMG"
cd "$MB_DIR" || exit 1
# shellcheck disable=SC1091
source data/agent.env
STYLE="chunky stylized claymation kitchen prop, soft matte plastic look, warm colors"
SHOT="single object centered on plain light grey background, soft studio lighting, 3/4 view, chunky stylized game asset"
# run_flux <seed> <prompt> [w] [h] -> asset id on stdout
run_flux() {
local seed="$1" prompt="$2" w="${3:-1024}" h="${4:-1024}"
./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
}
# gen_3d <name> <seed> <prompt>
gen_3d() {
local name="$1" seed="$2" prompt="$3"
if [ -f "$OUT_MODELS/$name.glb" ]; then echo " = $name.glb exists, skipping"; return; fi
echo "→ [$name] flux..."
local img cut
img=$(run_flux "$seed" "$prompt, $STYLE, $SHOT")
[ -z "$img" ] && { echo " ! $name: flux failed"; return 1; }
echo "→ [$name] cutout ($img)..."
cut=$(./mb run bg_remove_local --asset "$img" -p resolution=2048 --wait 2>/dev/null | awk '/image/{print $1}' | tail -1)
[ -z "$cut" ] && { echo " ! $name: bg_remove failed"; return 1; }
echo "→ [$name] mesh ($cut) — ~5 min..."
local tmp; tmp=$(mktemp -d)
./mb run hunyuan3d_mlx --asset "$cut" -p octree_resolution=256 -p texture_size=1024 \
-p max_num_view=6 --wait --download "$tmp" >/dev/null 2>&1
local glb; glb=$(find "$tmp" -iname '*.glb' | head -1)
if [ -n "$glb" ]; then mv "$glb" "$OUT_MODELS/$name.glb"; echo "$name.glb"; else echo " ! $name: no glb"; fi
rm -rf "$tmp"
}
# gen_2d <name> <seed> <prompt> [w] [h] [cutout]
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" "$w" "$h")
[ -z "$img" ] && { echo " ! $name: flux failed"; return 1; }
if [ "$cutout" = "cut" ]; then
echo "→ [$name] cutout..."
local c; c=$(./mb run bg_remove_local --asset "$img" -p resolution=2048 --wait 2>/dev/null | awk '/image/{print $1}' | tail -1)
[ -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 judge: same person every time, seed fixed, only the expression phrase varies ----
JUDGE="portrait of a stern elderly australian food inspector, deep wrinkles, bushy grey eyebrows, thin wire glasses, grey moustache, wearing a brown cardigan over a shirt and tie, holding a clipboard, plain light grey background, head and shoulders, claymation stop-motion character, soft matte clay texture, warm studio light"
do_3d() {
gen_3d toaster 11 "a retro two-slot pop-up toaster, cream and chrome, with a browning dial"
gen_3d butter_dish 23 "a ceramic butter dish with a thick block of yellow butter on it, lid off"
gen_3d pb_jar 31 "an open glass jar of peanut butter, chunky brown spread visible, cream label"
gen_3d mitey_jar 37 "a small squat glass jar of dark brown yeast extract spread with a bright red and yellow label reading MITEY, lid off"
gen_3d plate 43 "a simple round white ceramic side plate, empty, seen at an angle"
}
do_2d() {
gen_2d judge_neutral 101 "$JUDGE, neutral unimpressed expression" 768 768 cut
gen_2d judge_intrigued 101 "$JUDGE, one eyebrow raised, mildly intrigued expression" 768 768 cut
gen_2d judge_impressed 101 "$JUDGE, small approving smile, impressed expression" 768 768 cut
gen_2d judge_disappointed 101 "$JUDGE, deeply disappointed frowning expression, looking down at clipboard" 768 768 cut
gen_2d judge_horrified 101 "$JUDGE, horrified appalled expression, eyes wide, recoiling" 768 768 cut
gen_2d bench_wood 202 "seamless top-down texture of a warm honey-toned wooden kitchen benchtop, subtle grain and knife marks, claymation soft matte look, flat even lighting, no objects" 1024 1024
gen_2d kitchen_backdrop 205 "blurry cosy 1970s australian kitchen interior background, warm cream tiles, soft bokeh, claymation set, no people, no text" 1280 768
gen_2d ticket_paper 210 "seamless texture of a plain cream paper order docket, faint blue lines, slightly crumpled, flat even lighting, no text" 512 512
gen_2d title_art 220 "a single slice of golden toast with a pat of butter melting on it, dramatic hero shot, claymation stop-motion, soft matte clay, warm rim light, plain dark background" 1024 768 cut
}
case "${1:-all}" in
3d) do_3d ;;
2d) do_2d ;;
*) do_3d; do_2d ;;
esac
echo "done."

190
src/core/app.ts Normal file
View File

@ -0,0 +1,190 @@
import * as THREE from 'three';
/** Shared key light direction the slice shader rolls its own lighting, so it
* has to be told the same thing the real lights are doing. */
export const LIGHT_DIR = new THREE.Vector3(0.5, 0.9, 0.42).normalize();
export interface View {
readonly root: THREE.Object3D;
enter(): void;
exit(): void;
update(dt: number): void;
resize?(w: number, h: number): void;
dispose?(): void;
}
export class Input {
/** Normalised device coords (-1..1). */
readonly ndc = new THREE.Vector2();
/** Pixel coords. */
readonly px = new THREE.Vector2();
/** Pixel delta since last frame. */
readonly delta = new THREE.Vector2();
down = false;
rightDown = false;
/** Accumulated wheel since last frame (normalised-ish). */
wheel = 0;
private prev = new THREE.Vector2();
private first = true;
private keys = new Set<string>();
private pressed = new Set<string>();
constructor(el: HTMLElement) {
el.addEventListener('pointermove', (e) => {
const r = el.getBoundingClientRect();
this.px.set(e.clientX - r.left, e.clientY - r.top);
this.ndc.set((this.px.x / r.width) * 2 - 1, -(this.px.y / r.height) * 2 + 1);
if (this.first) {
this.prev.copy(this.px);
this.first = false;
}
});
el.addEventListener('pointerdown', (e) => {
if (e.button === 0) this.down = true;
if (e.button === 2) this.rightDown = true;
});
window.addEventListener('pointerup', (e) => {
if (e.button === 0) this.down = false;
if (e.button === 2) this.rightDown = false;
});
el.addEventListener('contextmenu', (e) => e.preventDefault());
el.addEventListener(
'wheel',
(e) => {
e.preventDefault();
this.wheel += Math.sign(e.deltaY) * Math.min(1, Math.abs(e.deltaY) / 50);
},
{ passive: false },
);
window.addEventListener('keydown', (e) => {
if (!this.keys.has(e.code)) this.pressed.add(e.code);
this.keys.add(e.code);
});
window.addEventListener('keyup', (e) => this.keys.delete(e.code));
window.addEventListener('blur', () => {
this.keys.clear();
this.down = false;
this.rightDown = false;
});
}
held(code: string): boolean {
return this.keys.has(code);
}
justPressed(code: string): boolean {
return this.pressed.has(code);
}
endFrame(): void {
this.delta.subVectors(this.px, this.prev);
this.prev.copy(this.px);
this.wheel = 0;
this.pressed.clear();
}
}
export class App {
readonly renderer: THREE.WebGLRenderer;
readonly scene = new THREE.Scene();
readonly camera: THREE.PerspectiveCamera;
readonly input: Input;
readonly timer = new THREE.Timer();
/** Additive screen shake, applied after the camera is positioned. */
shake = 0;
private view: View | null = null;
private tickers: ((dt: number) => void)[] = [];
constructor(canvas: HTMLCanvasElement) {
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
this.renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFShadowMap;
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
this.renderer.toneMappingExposure = 1.05;
this.camera = new THREE.PerspectiveCamera(42, 1, 0.05, 100);
this.input = new Input(canvas);
// Pauses the clock while the tab is hidden, so you don't come back to charcoal.
this.timer.connect(document);
this.scene.fog = new THREE.Fog(0x1b1512, 8, 22);
window.addEventListener('resize', () => this.resize());
this.resize();
}
onTick(fn: (dt: number) => void): void {
this.tickers.push(fn);
}
setView(v: View | null): void {
if (this.view === v) return;
if (this.view) {
this.view.exit();
this.view.root.visible = false;
}
this.view = v;
if (v) {
v.root.visible = true;
v.enter();
const s = this.renderer.getSize(new THREE.Vector2());
v.resize?.(s.x, s.y);
}
}
get currentView(): View | null {
return this.view;
}
resize(): void {
const w = window.innerWidth;
const h = window.innerHeight;
this.renderer.setSize(w, h, false);
this.camera.aspect = w / h;
this.camera.updateProjectionMatrix();
this.view?.resize?.(w, h);
}
start(): void {
const loop = (ts?: number) => {
requestAnimationFrame(loop);
this.timer.update(ts);
// Clamp so a single frame hitch can't teleport the sim forward.
const dt = Math.min(this.timer.getDelta(), 1 / 20);
for (const t of this.tickers) t(dt);
this.view?.update(dt);
if (this.shake > 0.0001) {
const s = this.shake;
this.camera.position.x += (Math.random() - 0.5) * s;
this.camera.position.y += (Math.random() - 0.5) * s;
this.camera.rotateZ((Math.random() - 0.5) * s * 0.35);
this.shake *= Math.pow(0.0015, dt);
if (this.shake < 0.0005) this.shake = 0;
}
this.renderer.render(this.scene, this.camera);
this.input.endFrame();
};
loop();
}
}
/** Standard kitchen lighting rig, shared by every view. */
export function makeLights(scene: THREE.Scene): THREE.DirectionalLight {
const key = new THREE.DirectionalLight(0xfff2dd, 2.6);
key.position.copy(LIGHT_DIR).multiplyScalar(6);
key.castShadow = true;
key.shadow.mapSize.set(2048, 2048);
key.shadow.camera.near = 0.5;
key.shadow.camera.far = 18;
const d = 3.2;
key.shadow.camera.left = -d;
key.shadow.camera.right = d;
key.shadow.camera.top = d;
key.shadow.camera.bottom = -d;
key.shadow.bias = -0.0016;
key.shadow.normalBias = 0.02;
scene.add(key);
scene.add(new THREE.HemisphereLight(0x9fb4d6, 0x3a2a20, 0.75));
const fill = new THREE.DirectionalLight(0xbdd6ff, 0.45);
fill.position.set(-3, 2.2, -2.5);
scene.add(fill);
return key;
}

130
src/core/field.ts Normal file
View File

@ -0,0 +1,130 @@
/**
* A square scalar field over the slice's UV space. Everything the player does to
* the toast browning, spread, damage is one of these, and everything the judge
* measures is a statistic over one of these.
*/
export class Field {
readonly n: number;
readonly data: Float32Array;
constructor(n: number, fill = 0) {
this.n = n;
this.data = new Float32Array(n * n);
if (fill !== 0) this.data.fill(fill);
}
idx(x: number, y: number): number {
return y * this.n + x;
}
/** Bilinear sample in UV space (0..1). */
sample(u: number, v: number): number {
const n = this.n;
const fx = Math.min(Math.max(u * (n - 1), 0), n - 1);
const fy = Math.min(Math.max(v * (n - 1), 0), n - 1);
const x0 = Math.floor(fx);
const y0 = Math.floor(fy);
const x1 = Math.min(x0 + 1, n - 1);
const y1 = Math.min(y0 + 1, n - 1);
const tx = fx - x0;
const ty = fy - y0;
const d = this.data;
const a = d[y0 * n + x0] * (1 - tx) + d[y0 * n + x1] * tx;
const b = d[y1 * n + x0] * (1 - tx) + d[y1 * n + x1] * tx;
return a * (1 - ty) + b * ty;
}
/**
* Apply `fn` to every texel within `radius` (UV units) of (u,v), weighted by a
* smooth falloff. Returns the total weight touched callers use it to conserve
* mass (a knife can only deposit what it's carrying).
*/
brush(u: number, v: number, radius: number, fn: (i: number, w: number) => void): number {
const n = this.n;
const cx = u * (n - 1);
const cy = v * (n - 1);
const r = radius * (n - 1);
const r2 = r * r;
const x0 = Math.max(0, Math.floor(cx - r));
const x1 = Math.min(n - 1, Math.ceil(cx + r));
const y0 = Math.max(0, Math.floor(cy - r));
const y1 = Math.min(n - 1, Math.ceil(cy + r));
let total = 0;
for (let y = y0; y <= y1; y++) {
for (let x = x0; x <= x1; x++) {
const dx = x - cx;
const dy = y - cy;
const d2 = dx * dx + dy * dy;
if (d2 > r2) continue;
const t = 1 - Math.sqrt(d2) / r;
const w = t * t * (3 - 2 * t); // smoothstep falloff
fn(y * n + x, w);
total += w;
}
}
return total;
}
/** Blur toward the neighbour average — how a spread self-levels. */
relax(amount: number, mask?: Field): void {
if (amount <= 0) return;
const n = this.n;
const d = this.data;
const src = d.slice();
for (let y = 0; y < n; y++) {
for (let x = 0; x < n; x++) {
const i = y * n + x;
if (mask && mask.data[i] < 0.5) continue;
const xm = x > 0 ? x - 1 : x;
const xp = x < n - 1 ? x + 1 : x;
const ym = y > 0 ? y - 1 : y;
const yp = y < n - 1 ? y + 1 : y;
const avg =
(src[y * n + xm] + src[y * n + xp] + src[ym * n + x] + src[yp * n + x]) * 0.25;
d[i] = src[i] + (avg - src[i]) * amount;
}
}
}
/** mean / stdev / max over the masked region. */
stats(mask: Field): { mean: number; stdev: number; max: number; count: number } {
const d = this.data;
const m = mask.data;
let sum = 0;
let count = 0;
let max = 0;
for (let i = 0; i < d.length; i++) {
if (m[i] < 0.5) continue;
sum += d[i];
if (d[i] > max) max = d[i];
count++;
}
if (count === 0) return { mean: 0, stdev: 0, max: 0, count: 0 };
const mean = sum / count;
let acc = 0;
for (let i = 0; i < d.length; i++) {
if (m[i] < 0.5) continue;
const dv = d[i] - mean;
acc += dv * dv;
}
return { mean, stdev: Math.sqrt(acc / count), max, count };
}
/** Fraction of the masked region for which `pred` holds. */
fraction(mask: Field, pred: (v: number) => boolean): number {
const d = this.data;
const m = mask.data;
let hit = 0;
let count = 0;
for (let i = 0; i < d.length; i++) {
if (m[i] < 0.5) continue;
if (pred(d[i])) hit++;
count++;
}
return count === 0 ? 0 : hit / count;
}
clear(v = 0): void {
this.data.fill(v);
}
}

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

@ -0,0 +1,58 @@
/** Small deterministic RNG (mulberry32) — seeded so a run can be replayed. */
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;
}
}
/** Cheap 2D value noise, used to give each toasting run its own character. */
export function valueNoise2D(rng: Rng, w: number, h: number, octaves = 3): Float32Array {
const out = new Float32Array(w * h);
let amp = 1;
let total = 0;
for (let o = 0; o < octaves; o++) {
const cells = 2 << o;
const grid = new Float32Array((cells + 1) * (cells + 1));
for (let i = 0; i < grid.length; i++) grid[i] = rng.next();
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const fx = (x / (w - 1)) * cells;
const fy = (y / (h - 1)) * cells;
const x0 = Math.floor(fx);
const y0 = Math.floor(fy);
const tx = fx - x0;
const ty = fy - y0;
const sx = tx * tx * (3 - 2 * tx);
const sy = ty * ty * (3 - 2 * ty);
const g = (gx: number, gy: number) => grid[gy * (cells + 1) + gx];
const a = g(x0, y0) * (1 - sx) + g(x0 + 1, y0) * sx;
const b = g(x0, y0 + 1) * (1 - sx) + g(x0 + 1, y0 + 1) * sx;
out[y * w + x] += (a * (1 - sy) + b * sy) * amp;
}
}
total += amp;
amp *= 0.5;
}
for (let i = 0; i < out.length; i++) out[i] /= total;
return out;
}

64
src/main.ts Normal file
View File

@ -0,0 +1,64 @@
import * as THREE from 'three';
import { App, makeLights } from './core/app';
import { Slice } from './sim/slice';
import { BREADS } from './sim/bread';
import { Rng } from './core/rng';
import './style.css';
const canvas = document.getElementById('c') as HTMLCanvasElement;
const app = new App(canvas);
makeLights(app.scene);
app.scene.background = new THREE.Color(0x1d1613);
const bench = new THREE.Mesh(
new THREE.BoxGeometry(9, 0.3, 5),
new THREE.MeshStandardMaterial({ color: 0x8a5a33, roughness: 0.85 }),
);
bench.position.y = -0.15;
bench.receiveShadow = true;
app.scene.add(bench);
const slice = new Slice(BREADS.white, new Rng(7));
slice.mesh.position.y = 0.12;
app.scene.add(slice.mesh);
// M0 smoke test: a browning gradient + a smear of butter, so the shader gets exercised.
for (let y = 0; y < slice.browning.n; y++) {
for (let x = 0; x < slice.browning.n; x++) {
const i = y * slice.browning.n + x;
slice.browning.data[i] = (x / slice.browning.n) * 1.0;
const dx = x / slice.spread.n - 0.5;
const dy = y / slice.spread.n - 0.55;
slice.spread.data[i] = Math.max(0, 0.45 - Math.hypot(dx, dy) * 1.2);
}
}
slice.setSpread({
id: 'butter',
name: 'Butter',
blurb: '',
color: [0.97, 0.72, 0.19],
gloss: 0.85,
opaqueAt: 0.5,
bump: 0.1,
baseYield: 0.72,
tempSoftening: 0.78,
viscosity: 0.72,
pickup: 0.55,
transferRate: 1.5,
drag: 0.5,
scrapeEase: 1,
amounts: { thin: [0.1, 0.22], normal: [0.25, 0.5], thick: [0.55, 0.9] },
});
slice.touch();
slice.sync();
(window as unknown as { __t: unknown }).__t = { app, slice, THREE };
app.camera.position.set(0, 1.5, 1.6);
app.camera.lookAt(0, 0.1, 0);
app.onTick((dt) => {
slice.mesh.rotation.y += dt * 0.4;
});
app.start();

101
src/sim/bread.ts Normal file
View File

@ -0,0 +1,101 @@
/**
* Bread archetypes. The three numbers that matter for the toasting sim:
* thickness thermal lag (slow to brown) but holds warmth longer, which
* matters later because warm toast is what softens hard butter.
* moisture water must boil off before the crust can brown, so a wet crumb
* stalls, then catches up fast once it dries. Sourdough's trap.
* sugar browns early and burns early. Raisin bread is a countdown.
*/
export interface Bread {
id: BreadId;
name: string;
blurb: string;
thickness: number; // world units
width: number;
height: number;
domeH: number; // how much of the top is the loaf's dome
moisture: number; // 0..1
sugar: number; // 0..1
crumb: [number, number, number];
crust: [number, number, number];
crumbScale: number; // crumb noise frequency
crumbContrast: number;
/** Speckles baked into the crumb: raisins, grain kibble. */
inclusions: number;
inclusionColor: [number, number, number];
}
export type BreadId = 'white' | 'sourdough' | 'multigrain' | 'raisin';
export const BREADS: Record<BreadId, Bread> = {
white: {
id: 'white',
name: 'Supermarket White',
blurb: 'Forgiving. Browns like it wants to be liked.',
thickness: 0.13,
width: 1.0,
height: 1.0,
domeH: 0.26,
moisture: 0.18,
sugar: 0.22,
crumb: [0.95, 0.9, 0.78],
crust: [0.78, 0.6, 0.36],
crumbScale: 34,
crumbContrast: 0.07,
inclusions: 0,
inclusionColor: [0, 0, 0],
},
sourdough: {
id: 'sourdough',
name: 'Thick-Cut Sourdough',
blurb: 'Wet, dense, and stubborn. Stalls, then catches you out.',
thickness: 0.23,
width: 1.06,
height: 1.02,
domeH: 0.3,
moisture: 0.72,
sugar: 0.1,
crumb: [0.93, 0.87, 0.72],
crust: [0.62, 0.42, 0.22],
crumbScale: 16,
crumbContrast: 0.16,
inclusions: 0,
inclusionColor: [0, 0, 0],
},
multigrain: {
id: 'multigrain',
name: 'Multigrain',
blurb: 'The seeds catch first. They always catch first.',
thickness: 0.15,
width: 1.0,
height: 0.98,
domeH: 0.24,
moisture: 0.34,
sugar: 0.3,
crumb: [0.82, 0.72, 0.55],
crust: [0.55, 0.4, 0.24],
crumbScale: 30,
crumbContrast: 0.13,
inclusions: 0.55,
inclusionColor: [0.36, 0.26, 0.15],
},
raisin: {
id: 'raisin',
name: 'Raisin Loaf',
blurb: 'Sugar. Everywhere. You have less time than you think.',
thickness: 0.14,
width: 0.98,
height: 0.98,
domeH: 0.26,
moisture: 0.3,
sugar: 0.92,
crumb: [0.93, 0.84, 0.66],
crust: [0.7, 0.48, 0.26],
crumbScale: 26,
crumbContrast: 0.09,
inclusions: 0.85,
inclusionColor: [0.22, 0.11, 0.12],
},
};
export const BREAD_ORDER: BreadId[] = ['white', 'multigrain', 'raisin', 'sourdough'];

497
src/sim/slice.ts Normal file
View File

@ -0,0 +1,497 @@
import * as THREE from 'three';
import { Field } from '../core/field';
import { Rng, valueNoise2D } from '../core/rng';
import type { Bread } from './bread';
import type { SpreadDef } from './spreads';
export const FIELD_N = 128;
/** Past this, the surface has stopped being toast and started being charcoal. */
export const CHAR_THRESHOLD = 0.85;
/**
* One slice of bread: the mesh, the four scalar fields the whole game reads and
* writes, and the shader that turns them into something appetising (or not).
*
* Fields are packed into a single RGBA8 texture one small upload per frame
* instead of four, and the shader gets free bilinear smoothing over the 128x128
* sim grid, which is what makes knife strokes look continuous.
*/
export class Slice {
readonly bread: Bread;
readonly mesh: THREE.Mesh;
readonly material: THREE.ShaderMaterial;
/** 0 = raw, 0.85 = char threshold, 1 = carbon. */
readonly browning: Field;
/** Water that must boil off before browning gets going. */
readonly dryness: Field;
/** Spread thickness. ~0.12 thin, ~0.4 normal, ~0.8 thick. */
readonly spread: Field;
/** Torn/gouged surface. Permanent. */
readonly damage: Field;
/** 1 inside the bread silhouette. Every judged statistic is masked by this. */
readonly mask: Field;
/** Per-run heat bias — why no two slices toast identically. */
readonly heatBias: Float32Array;
/** 1 right out of the toaster, decays to 0. Softens butter. */
warmth = 0;
/** Which spread is currently on the slice (for judging + shading). */
spreadDef: SpreadDef | null = null;
private tex: THREE.DataTexture;
private texData: Uint8Array;
private dirty = true;
constructor(bread: Bread, rng: Rng) {
this.bread = bread;
this.browning = new Field(FIELD_N);
this.dryness = new Field(FIELD_N);
this.spread = new Field(FIELD_N);
this.damage = new Field(FIELD_N);
this.mask = new Field(FIELD_N);
const shape = breadShape(bread);
buildMask(this.mask, shape, bread);
this.heatBias = valueNoise2D(rng, FIELD_N, FIELD_N, 3);
this.texData = new Uint8Array(FIELD_N * FIELD_N * 4);
this.tex = new THREE.DataTexture(this.texData, FIELD_N, FIELD_N, THREE.RGBAFormat);
this.tex.minFilter = THREE.LinearFilter;
this.tex.magFilter = THREE.LinearFilter;
this.tex.wrapS = THREE.ClampToEdgeWrapping;
this.tex.wrapT = THREE.ClampToEdgeWrapping;
this.tex.needsUpdate = true;
const geo = buildGeometry(shape, bread);
this.material = buildMaterial(bread, this.tex);
this.mesh = new THREE.Mesh(geo, this.material);
this.mesh.castShadow = true;
this.mesh.receiveShadow = true;
this.sync();
}
get uniforms() {
return this.material.uniforms;
}
/** Push the sim fields into the GPU texture. Cheap: 64KB. */
sync(): void {
if (!this.dirty) return;
const d = this.texData;
const b = this.browning.data;
const s = this.spread.data;
const g = this.damage.data;
const m = this.mask.data;
for (let i = 0, j = 0; i < b.length; i++, j += 4) {
d[j] = clamp255(b[i] * 255);
d[j + 1] = clamp255(s[i] * 255);
d[j + 2] = clamp255(g[i] * 255);
d[j + 3] = clamp255(m[i] * 255);
}
this.tex.needsUpdate = true;
this.dirty = false;
}
touch(): void {
this.dirty = true;
}
setSpread(def: SpreadDef | null): void {
this.spreadDef = def;
const u = this.material.uniforms;
if (def) {
u.uSpreadColor.value.setRGB(def.color[0], def.color[1], def.color[2]);
u.uSpreadGloss.value = def.gloss;
u.uSpreadOpaqueAt.value = def.opaqueAt;
u.uSpreadBump.value = def.bump;
} else {
u.uSpreadOpaqueAt.value = 1e9;
}
}
setHeatGlow(v: number): void {
this.material.uniforms.uHeatGlow.value = v;
}
setHeatmap(mode: 0 | 1 | 2): void {
this.material.uniforms.uHeatmap.value = mode;
}
dispose(): void {
this.mesh.geometry.dispose();
this.material.dispose();
this.tex.dispose();
}
}
function clamp255(v: number): number {
return v < 0 ? 0 : v > 255 ? 255 : v | 0;
}
/** The classic sandwich-loaf silhouette: square-ish body, domed top. */
function breadShape(bread: Bread): THREE.Shape {
const hw = bread.width / 2;
const hh = bread.height / 2;
const dome = bread.domeH;
const r = 0.09;
const bodyTop = hh - dome;
const s = new THREE.Shape();
s.moveTo(-hw + r, -hh);
s.lineTo(hw - r, -hh);
s.quadraticCurveTo(hw, -hh, hw, -hh + r);
s.lineTo(hw, bodyTop);
s.bezierCurveTo(hw, bodyTop + dome * 0.86, hw * 0.62, hh, 0, hh);
s.bezierCurveTo(-hw * 0.62, hh, -hw, bodyTop + dome * 0.86, -hw, bodyTop);
s.lineTo(-hw, -hh + r);
s.quadraticCurveTo(-hw, -hh, -hw + r, -hh);
s.closePath();
return s;
}
/**
* Extrude the silhouette, then planar-project UVs from the shape's own XY before
* we lay the slice flat so the field grid lines up with the bread exactly, and
* both faces share it.
*/
function buildGeometry(shape: THREE.Shape, bread: Bread): THREE.BufferGeometry {
const bevel = Math.min(0.03, bread.thickness * 0.28);
const geo = new THREE.ExtrudeGeometry(shape, {
depth: bread.thickness - bevel * 2,
bevelEnabled: true,
bevelThickness: bevel,
bevelSize: bevel,
bevelSegments: 3,
curveSegments: 32,
});
geo.computeBoundingBox();
const bb = geo.boundingBox!;
const w = bb.max.x - bb.min.x;
const h = bb.max.y - bb.min.y;
const pos = geo.attributes.position;
const uv = new Float32Array(pos.count * 2);
for (let i = 0; i < pos.count; i++) {
uv[i * 2] = (pos.getX(i) - bb.min.x) / w;
uv[i * 2 + 1] = (pos.getY(i) - bb.min.y) / h;
}
geo.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
// Lay it flat: the far extrude cap becomes the top face (+Y).
geo.rotateX(-Math.PI / 2);
geo.center();
geo.computeVertexNormals();
return geo;
}
/** Rasterise the silhouette into the mask field (point-in-polygon per texel). */
function buildMask(mask: Field, shape: THREE.Shape, bread: Bread): void {
const pts = shape.getPoints(96);
const hw = bread.width / 2;
const hh = bread.height / 2;
// Same normalisation the geometry uses (shape bbox ≈ the slice bbox).
let minX = Infinity;
let maxX = -Infinity;
let minY = Infinity;
let maxY = -Infinity;
for (const p of pts) {
minX = Math.min(minX, p.x);
maxX = Math.max(maxX, p.x);
minY = Math.min(minY, p.y);
maxY = Math.max(maxY, p.y);
}
void hw;
void hh;
const n = mask.n;
for (let y = 0; y < n; y++) {
for (let x = 0; x < n; x++) {
const px = minX + ((x + 0.5) / n) * (maxX - minX);
const py = minY + ((y + 0.5) / n) * (maxY - minY);
mask.data[y * n + x] = pointInPoly(px, py, pts) ? 1 : 0;
}
}
// Pull the mask in by ~1 texel so brushes can't paint into the crust seam.
erode(mask);
}
function pointInPoly(x: number, y: number, pts: THREE.Vector2[]): boolean {
let inside = false;
for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
const xi = pts[i].x;
const yi = pts[i].y;
const xj = pts[j].x;
const yj = pts[j].y;
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
}
return inside;
}
function erode(mask: Field): void {
const n = mask.n;
const src = mask.data.slice();
for (let y = 0; y < n; y++) {
for (let x = 0; x < n; x++) {
if (src[y * n + x] < 0.5) continue;
const xm = Math.max(0, x - 1);
const xp = Math.min(n - 1, x + 1);
const ym = Math.max(0, y - 1);
const yp = Math.min(n - 1, y + 1);
if (
src[y * n + xm] < 0.5 ||
src[y * n + xp] < 0.5 ||
src[ym * n + x] < 0.5 ||
src[yp * n + x] < 0.5
) {
mask.data[y * n + x] = 0;
}
}
}
}
const VERT = /* glsl */ `
varying vec2 vUv;
varying vec3 vNormalW;
varying vec3 vPosW;
varying vec3 vPosL;
void main() {
vUv = uv;
vPosL = position;
vNormalW = normalize(mat3(modelMatrix) * normal);
vec4 wp = modelMatrix * vec4(position, 1.0);
vPosW = wp.xyz;
gl_Position = projectionMatrix * viewMatrix * wp;
}
`;
const FRAG = /* glsl */ `
precision highp float;
uniform sampler2D uField; // R browning, G spread, B damage, A mask
uniform float uFieldN;
uniform vec3 uCrumb;
uniform vec3 uCrust;
uniform float uCrumbScale;
uniform float uCrumbContrast;
uniform float uInclusions;
uniform vec3 uInclusionColor;
uniform vec3 uSpreadColor;
uniform float uSpreadGloss;
uniform float uSpreadOpaqueAt; // spread thickness at which it fully hides the toast
uniform float uSpreadBump;
uniform vec3 uLightDir;
uniform vec3 uLightColor;
uniform vec3 uAmbientSky;
uniform vec3 uAmbientGround;
uniform float uHeatGlow;
uniform float uWarmth;
uniform int uHeatmap;
varying vec2 vUv;
varying vec3 vNormalW;
varying vec3 vPosW;
varying vec3 vPosL;
float hash21(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float vnoise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
f = f * f * (3.0 - 2.0 * f);
float a = hash21(i);
float b = hash21(i + vec2(1.0, 0.0));
float c = hash21(i + vec2(0.0, 1.0));
float d = hash21(i + vec2(1.0, 1.0));
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}
float fbm(vec2 p) {
float v = 0.0;
float a = 0.5;
for (int i = 0; i < 4; i++) {
v += a * vnoise(p);
p *= 2.03;
a *= 0.5;
}
return v;
}
vec3 brownRamp(float b) {
vec3 c0 = vec3(0.95, 0.91, 0.79);
vec3 c1 = vec3(0.91, 0.77, 0.48);
vec3 c2 = vec3(0.79, 0.54, 0.24);
vec3 c3 = vec3(0.50, 0.28, 0.11);
vec3 c4 = vec3(0.19, 0.11, 0.06);
vec3 c5 = vec3(0.05, 0.04, 0.04);
if (b < 0.20) return mix(c0, c1, b / 0.20);
if (b < 0.45) return mix(c1, c2, (b - 0.20) / 0.25);
if (b < 0.70) return mix(c2, c3, (b - 0.45) / 0.25);
if (b < 0.88) return mix(c3, c4, (b - 0.70) / 0.18);
return mix(c4, c5, clamp((b - 0.88) / 0.12, 0.0, 1.0));
}
vec3 heatRamp(float t) {
vec3 a = vec3(0.10, 0.05, 0.35);
vec3 b = vec3(0.15, 0.55, 0.75);
vec3 c = vec3(0.95, 0.85, 0.25);
vec3 d = vec3(0.85, 0.15, 0.10);
if (t < 0.33) return mix(a, b, t / 0.33);
if (t < 0.66) return mix(b, c, (t - 0.33) / 0.33);
return mix(c, d, clamp((t - 0.66) / 0.34, 0.0, 1.0));
}
void main() {
vec4 f = texture2D(uField, vUv);
float browning = f.r;
float spread = f.g;
float damage = f.b;
vec3 N = normalize(vNormalW);
// 1 on the two flat faces, 0 around the crust edge.
float faceness = smoothstep(0.45, 0.85, abs(N.y));
// Spread only ever lands on the top.
float topFace = smoothstep(0.45, 0.85, N.y);
// --- crumb ---
float crumbN = fbm(vUv * uCrumbScale);
vec3 crumb = uCrumb * (1.0 + (crumbN - 0.5) * uCrumbContrast * 2.0);
// holes in the crumb
float holes = smoothstep(0.62, 0.78, fbm(vUv * uCrumbScale * 0.55 + 11.0));
crumb *= 1.0 - holes * 0.35;
// seeds / raisins
float inc = smoothstep(0.80, 0.92, vnoise(vUv * uCrumbScale * 0.9 + 37.0));
crumb = mix(crumb, uInclusionColor, inc * uInclusions);
// --- browning ---
vec3 toastFace = brownRamp(browning) * (0.86 + crumbN * 0.28);
// inclusions scorch first and stay dark
toastFace = mix(toastFace, toastFace * 0.45, inc * uInclusions * smoothstep(0.15, 0.6, browning));
toastFace = mix(crumb, toastFace, smoothstep(0.0, 0.06, browning));
// char blotches, not a clean gradient
float charN = fbm(vUv * 46.0 + 5.0);
float char = smoothstep(0.82, 1.0, browning + (charN - 0.5) * 0.22);
toastFace = mix(toastFace, vec3(0.035, 0.03, 0.03), char * 0.92);
// crust: always browner than the face, and it browns too
vec3 crust = uCrust * (0.75 + fbm(vUv * 26.0 + 3.0) * 0.5);
crust = mix(crust, crust * 0.35, smoothstep(0.4, 1.0, browning));
vec3 albedo = mix(crust, toastFace, faceness);
// --- damage: torn/gouged surface exposes pale raw crumb in a dark crevice ---
float dmgN = fbm(vUv * 60.0 + 21.0);
float dmg = clamp(damage * (0.7 + dmgN * 0.6), 0.0, 1.0);
albedo = mix(albedo, uCrumb * 0.88, dmg * 0.8);
albedo *= 1.0 - dmg * 0.28; // crevice shading
// --- spread ---
float sTex = 1.0 / uFieldN;
float sL = texture2D(uField, vUv - vec2(sTex, 0.0)).g;
float sR = texture2D(uField, vUv + vec2(sTex, 0.0)).g;
float sD = texture2D(uField, vUv - vec2(0.0, sTex)).g;
float sU = texture2D(uField, vUv + vec2(0.0, sTex)).g;
// Wetting. This is what actually makes a spread readable: long before a film is
// thick enough to hide the toast, it soaks in and saturates it, the way oil
// darkens paper. Without this, yellow butter on pale crumb is invisible.
// Must be a gamma-style darkening — a multiply would *brighten* pale crumb.
float wet = clamp(spread / max(uSpreadOpaqueAt * 0.35, 0.0001), 0.0, 1.0) * topFace;
albedo = mix(albedo, pow(albedo, vec3(1.65)) * 0.88, wet * 0.8);
float cover = clamp(spread / max(uSpreadOpaqueAt, 0.0001), 0.0, 1.0);
cover *= topFace;
// a little grain in the film so thin spread doesn't look like flat paint
float filmN = fbm(vUv * 70.0 + 13.0);
vec3 spreadCol = uSpreadColor * (0.85 + filmN * 0.3);
albedo = mix(albedo, spreadCol, cover);
// Thickness relief. Central differences in *UV* units, not texels — a smooth
// smear only changes ~0.01 per texel, so the raw difference is far too small
// to bend a normal with.
vec2 grad = vec2(sR - sL, sU - sD) / (2.0 * sTex);
N = normalize(N + vec3(-grad.x, 0.0, grad.y) * uSpreadBump * topFace);
// torn bread is rough
N = normalize(N + vec3(dmgN - 0.5, 0.0, fbm(vUv * 60.0 + 44.0) - 0.5) * dmg * 0.9);
// --- lighting ---
vec3 L = normalize(uLightDir);
vec3 V = normalize(cameraPosition - vPosW);
vec3 H = normalize(L + V);
float ndl = max(dot(N, L), 0.0);
float wrap = (ndl + 0.35) / 1.35; // soft claymation falloff
vec3 ambient = mix(uAmbientGround, uAmbientSky, N.y * 0.5 + 0.5);
// Gloss: spread is shiny, bread is not; melting butter is shinier still.
// A tight lobe puts one hotspot somewhere off the slice and reads as nothing,
// so pair a broad sheen with a fresnel rim — that's the cue that says "wet".
float gloss = uSpreadGloss * smoothstep(0.008, 0.09, spread) * topFace;
gloss *= 1.0 + uWarmth * 0.6;
// Keep the lobe tight: a broad one just washes the whole slice white and drowns
// the colour. Tight + the thickness ridges = glints along the knife marks.
float shin = mix(18.0, 52.0, uSpreadGloss);
float spec = pow(max(dot(N, H), 0.0), shin) * gloss * 0.6;
float fres = pow(1.0 - max(dot(N, V), 0.0), 3.0);
spec += fres * gloss * 0.22;
spec *= 1.0 - char * 0.7;
spec *= 1.0 - dmg * 0.6;
vec3 color = albedo * (ambient + uLightColor * wrap * 0.68) + uLightColor * spec;
// element glow while it's down in the toaster
if (uHeatGlow > 0.001) {
float coil = 0.5 + 0.5 * sin(vPosL.x * 34.0);
color += vec3(1.0, 0.28, 0.06) * uHeatGlow * (0.16 + coil * 0.16) * faceness;
}
// a hot slice keeps a faint warmth in the crumb
color += vec3(0.05, 0.015, 0.0) * uWarmth * faceness;
// Judge-screen data views: flat, unlit, deliberately not appetising.
if (uHeatmap == 1) {
color = mix(vec3(0.10), heatRamp(clamp(browning, 0.0, 1.0)), faceness * 0.88 + 0.12);
} else if (uHeatmap == 2) {
color = mix(vec3(0.10), heatRamp(clamp(spread * 2.0, 0.0, 1.0)), topFace * 0.88 + 0.12);
}
gl_FragColor = vec4(color, 1.0);
// Match the tone mapping + output colour space the standard materials get,
// otherwise the toast reads as a different render from everything around it.
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`;
function buildMaterial(bread: Bread, tex: THREE.DataTexture): THREE.ShaderMaterial {
return new THREE.ShaderMaterial({
vertexShader: VERT,
fragmentShader: FRAG,
uniforms: {
uField: { value: tex },
uFieldN: { value: FIELD_N },
uCrumb: { value: new THREE.Color(...bread.crumb) },
uCrust: { value: new THREE.Color(...bread.crust) },
uCrumbScale: { value: bread.crumbScale },
uCrumbContrast: { value: bread.crumbContrast },
uInclusions: { value: bread.inclusions },
uInclusionColor: { value: new THREE.Color(...bread.inclusionColor) },
uSpreadColor: { value: new THREE.Color(1, 0.85, 0.3) },
uSpreadGloss: { value: 0.5 },
uSpreadOpaqueAt: { value: 1e9 },
uSpreadBump: { value: 0.1 },
uLightDir: { value: new THREE.Vector3(0.45, 0.85, 0.35).normalize() },
uLightColor: { value: new THREE.Color(1.0, 0.95, 0.86) },
uAmbientSky: { value: new THREE.Color(0.26, 0.27, 0.31) },
uAmbientGround: { value: new THREE.Color(0.14, 0.11, 0.09) },
uHeatGlow: { value: 0 },
uWarmth: { value: 0 },
uHeatmap: { value: 0 },
},
});
}

125
src/sim/spreads.ts Normal file
View File

@ -0,0 +1,125 @@
/**
* Spread rheology. The whole difficulty curve of this game lives in these numbers.
*
* The central tension: you need PRESSURE to make a stiff spread flow, but pressure
* comes from tilting the knife steeper and past `SCRAPE_ANGLE` a steep knife stops
* spreading and starts scraping. So cold butter can want more pressure than you are
* physically allowed to apply without flipping into scrape mode. The way out isn't a
* better technique, it's warmer toast: don't dawdle in the drawer.
*/
export type SpreadId = 'butter' | 'peanut' | 'mitey';
export type AmountClass = 'thin' | 'normal' | 'thick';
export interface SpreadDef {
id: SpreadId;
name: string;
blurb: string;
color: [number, number, number];
gloss: number;
/** Thickness at which the film fully hides the toast underneath. */
opaqueAt: number;
/** Surface relief strength. */
bump: number;
/** Pressure needed to make it flow, before any warming. */
baseYield: number;
/** How much heat (soft butter + hot toast) reduces that. */
tempSoftening: number;
/** 0 self-levels instantly, 1 stays exactly where you put it. */
viscosity: number;
/** How much the knife picks up per dip. */
pickup: number;
/** How fast it leaves the knife once it IS flowing. */
transferRate: number;
/** How much the spread resists the knife (drag on the cursor). */
drag: number;
/** How readily it comes back off when you scrape. */
scrapeEase: number;
amounts: Record<AmountClass, [number, number]>;
}
export const SPREADS: Record<SpreadId, SpreadDef> = {
butter: {
id: 'butter',
name: 'Butter',
blurb: 'Straightforward — as long as something in this scene is warm.',
color: [0.97, 0.72, 0.19],
gloss: 0.85,
opaqueAt: 0.5,
bump: 0.10,
baseYield: 0.72,
tempSoftening: 0.78,
viscosity: 0.72,
pickup: 0.55,
transferRate: 1.5,
drag: 0.5,
scrapeEase: 1.0,
amounts: { thin: [0.1, 0.22], normal: [0.25, 0.5], thick: [0.55, 0.9] },
},
peanut: {
id: 'peanut',
name: 'Peanut Butter',
blurb: 'Goes on thick, drags like wet sand, never evens itself out.',
color: [0.64, 0.41, 0.18],
gloss: 0.3,
opaqueAt: 0.16,
bump: 0.28,
baseYield: 0.38,
tempSoftening: 0.3,
viscosity: 0.96,
pickup: 0.95,
transferRate: 2.2,
drag: 1.0,
scrapeEase: 0.75,
amounts: { thin: [0.18, 0.32], normal: [0.38, 0.68], thick: [0.75, 1.1] },
},
mitey: {
id: 'mitey',
name: 'MITEY',
blurb: 'Spreads like nothing. The skill is taking almost all of it back off.',
color: [0.14, 0.08, 0.05],
gloss: 0.62,
opaqueAt: 0.26,
bump: 0.03,
baseYield: 0.14,
tempSoftening: 0.5,
viscosity: 0.34,
pickup: 0.5,
transferRate: 2.6,
drag: 0.25,
scrapeEase: 1.25,
amounts: { thin: [0.03, 0.11], normal: [0.14, 0.28], thick: [0.32, 0.6] },
},
};
/** Knife angle past which contact narrows enough to scrape instead of spread. */
export const SCRAPE_ANGLE = 0.62;
/** Angle (0 flat .. 1 on-edge) -> contact pressure. */
export function pressureFromAngle(angle: number): number {
return 0.1 + 0.9 * Math.pow(Math.max(0, Math.min(1, angle)), 1.3);
}
/** Angle -> contact patch radius in UV units. Flat knife = wide, steep = a line. */
export function contactRadius(angle: number): number {
return 0.115 - 0.078 * Math.max(0, Math.min(1, angle));
}
/**
* What it actually takes to make this spread flow right now. Butter on a cold
* slice with fridge-hard butter is the worst case in the game, by design.
*/
export function effectiveYield(def: SpreadDef, softness: number, toastWarmth: number): number {
const heat = Math.min(1, softness * 0.55 + toastWarmth * 0.65);
return def.baseYield * (1 - def.tempSoftening * heat);
}
export function amountClassOf(def: SpreadDef, mean: number): AmountClass | 'none' | 'obscene' {
if (mean < def.amounts.thin[0] * 0.5) return 'none';
if (mean <= def.amounts.thin[1]) return 'thin';
if (mean <= def.amounts.normal[1]) return 'normal';
if (mean <= def.amounts.thick[1]) return 'thick';
return 'obscene';
}

51
src/style.css Normal file
View File

@ -0,0 +1,51 @@
:root {
--ink: #f3e9dc;
--ink-dim: #a9998a;
--paper: #efe4cf;
--char: #16110e;
--gold: #e8a53a;
--red: #c0392b;
--font: 'Helvetica Neue', Helvetica, Arial, sans-serif;
--mono: ui-monospace, 'SF Mono', Menlo, monospace;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
height: 100%;
overflow: hidden;
background: var(--char);
color: var(--ink);
font-family: var(--font);
-webkit-font-smoothing: antialiased;
}
#app {
position: fixed;
inset: 0;
}
canvas#c {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
touch-action: none;
}
#ui {
position: absolute;
inset: 0;
pointer-events: none;
user-select: none;
}
#ui > * {
pointer-events: auto;
}

19
tsconfig.json Normal file
View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src"]
}

8
vite.config.ts Normal file
View File

@ -0,0 +1,8 @@
import { defineConfig } from 'vite';
export default defineConfig({
server: { port: 5173, strictPort: true },
// rapier3d-compat ships wasm inlined as base64, but it still wants to be pre-bundled
optimizeDeps: { exclude: [] },
build: { target: 'es2022', chunkSizeWarningLimit: 1500 },
});