From 5b09f23c7e6afedad88bab4453b8eec1881cf440 Mon Sep 17 00:00:00 2001 From: monster Date: Thu, 16 Jul 2026 22:21:06 +1000 Subject: [PATCH] M5+M6: content, generated assets, title, sound, perf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Content (verified — same dial, same 18s, four breads that disagree): bread 4s 9s 18s char white 0.22 0.54 1.08 93% multigrain 0.20 0.52 1.06 92% raisin 0.30 0.76 1.14 99.8% sourdough 0.11 0.33 0.74 14% Sourdough stalls at half the rate then accelerates hardest (x3.0 vs white's x2.45) as its water boils off; raisin browns 40% faster and is essentially carbon by 18s. Seven handwritten days then procedural, day persisted. Assets, all generated on-device and free, all reproducible from fixed seeds: - the judge's five expressions (one seed, one description, only the expression phrase varying — he stays the same man while his face falls) - title art, wooden bench, blurred kitchen backdrop - butter dish / peanut butter / MITEY jars as GLBs, normalised on load (recentre, scale to fit, sit on y=0) rather than hand-fixed, so regenerating them doesn't mean re-fixing them The toaster stays procedural: the generated one is prettier but this one has a lever we can drive and a dial that turns. Judgment call, per the brief. Sound is synthesised, no samples: clatter is driven by real Rapier contact-force events and scaled by force, which is the only way a drawer of cutlery can sound like one. Plus lever clunk, knife scrape pitched by pressure, and the stamp. Perf, measured (16.7ms is the 60fps budget): kitchen 0.99ms/step · drawer 0.12ms · drawer while dragging 0.21ms Production build: 2.87MB / 1.0MB gzipped, mostly Rapier's inlined wasm + three. Textures carry colorSpace = SRGBColorSpace explicitly — same class of bug as the albedo gamma fix in M3, and it washes them out silently if you forget. Co-Authored-By: Claude Fable 5 --- README.md | 127 ++++++++++++++++++++++++++++++++++++++++ src/game/game.ts | 10 +++- src/scenes/assets.ts | 49 ++++++++++++++++ src/scenes/props.ts | 23 ++++++-- src/scenes/spreadrig.ts | 64 +++++++------------- src/style.css | 47 +++++++++++++++ src/ui/title.ts | 26 ++++++++ 7 files changed, 297 insertions(+), 49 deletions(-) create mode 100644 README.md create mode 100644 src/scenes/assets.ts create mode 100644 src/ui/title.ts diff --git a/README.md b/README.md new file mode 100644 index 0000000..a8407fe --- /dev/null +++ b/README.md @@ -0,0 +1,127 @@ +# TOASTSIM + +Bread goes in. You are judged. + +A browser game about making toast, where the inputs are analog and the output is +scored by a man who has been doing this for thirty-one years. + +```bash +npm install +npm run dev # http://localhost:5173 +``` + +## The loop + +**Order → toast → drawer → spread → verdict → next day.** + +1. **The ticket** tells you what someone wants: how dark, which spread, how much, + which utensil, and whether they'll tolerate burnt bits. +2. **The toaster** has a dial and a lever and no timer. You go by smell. +3. **The drawer** makes you find the right piece of cutlery in a tangle of + similar ones — while your toast goes cold. +4. **The bench** is where the game actually lives. See below. +5. **The judge** scores nine criteria and has something to say about the worst one. + +## The mechanic + +One control — **the knife's angle** (mouse wheel) — and three behaviours fall out +of it: + +| angle | contact | what happens | +|---|---|---| +| flat | wide, low pressure | spreads — *or tears the bread, if the spread is stiffer than the pressure you're allowed to make* | +| steep | narrow, high pressure | scrapes spread back off, or lifts char off burnt toast | +| steep + fast, on bread with nothing left to take | | **gouges** | + +The trap: pressure comes from steepness, but past the scrape threshold a steep +knife stops spreading. So a spread can demand more pressure than spreading mode +can physically give: + +| situation | yield | angle needed | scrape cliff at 0.62 | +|---|---|---|---| +| fridge butter / cold toast | 0.72 | 0.75 | **impossible** | +| bench butter / cold toast | 0.54 | 0.57 | one click from disaster | +| fridge butter / fresh toast | 0.36 | 0.38 | fine | +| soft butter / fresh toast | 0.16 | 0.12 | dream mode | + +**Cold toast with hard butter cannot be spread at any angle.** The way out isn't +technique — it's not dawdling in the drawer. That's why the drawer is there. The +pressure gauge draws both marks so you can watch the gold sit past the red and +understand exactly why you're losing. + +Scraping is the other tension: it rescues burnt toast and wrecks evenness doing +it (char 99.9% → 54.5%, evenness 0.029 → 0.226). The judge notices both. + +And MITEY is its own skill: spread it on, then take almost all of it back off. He +wants to see the toast *through* it. + +## The bread + +Same dial, same 18 seconds, four breads: + +| bread | 4s | 9s | 18s | char | +|---|---|---|---|---| +| white | 0.22 | 0.54 | 1.08 | 93% | +| multigrain | 0.20 | 0.52 | 1.06 | 92% | +| raisin | 0.30 | 0.76 | 1.14 | **99.8%** | +| sourdough | 0.11 | 0.33 | 0.74 | **14%** | + +Sourdough's water has to boil off before the crust can brown, so it stalls and +then accelerates hardest. Raisin bread is sugar: it browns early and burns early. + +## How it's built + +- **Vite + TypeScript + three.js**, **Rapier** for the drawer. No framework; the + UI is a DOM overlay over one WebGL canvas. +- The slice is the whole game's canvas: an extruded loaf silhouette with four + 128×128 scalar fields — `browning`, `dryness`, `spread`, `damage` — packed into + one RGBA8 texture per frame and composited by a custom shader. Everything the + player does writes a field; everything the judge reads is a statistic over one. +- Cutlery is procedural on purpose. The drawer asks you to tell a dessert fork + from a dinner fork, and that's only fair if the silhouettes are authored. +- Physics: compound boxes, never trimeshes. Grabbing is a capped PD spring, so a + snagged piece fights what's lying on it instead of tunnelling through. + +### Layout + +``` +src/ + core/ app + loop, input, seeded rng, 2D field, synthesised audio + sim/ bread, slice (geometry/fields/shader), toasting, spreads, + spreading (the star mechanic), cutlery + scenes/ kitchen (toast + spread), drawer (rapier), judge, props + game/ game loop, orders, judging rubric, the judge's lines + ui/ hud, title, cutlery silhouettes + dev.ts dev-only harness — drives real gestures deterministically +``` + +### Dev harness + +The game is driven by mouse gestures over a 3D scene, which makes "does it work" +hard to answer by inspection. In a dev build, `window.__t` drives the real code +paths at a chosen `dt`: + +```js +__t.toast(10, 6) // lever down, 10s at power 6, pop, land +__t.spread(9, 0.55) // dip and raster the slice at 0.55 units/sec +__t.stats() // every field statistic the judge will read +``` + +It's also the escape hatch for headless browsers that never fire rAF — +everything calls `app.step(dt)` directly. + +## Assets + +Every asset was generated on-device and free via +[MODELBEAST](../MODELBEAST/AGENTS.md), and is reproducible: + +```bash +./scripts/gen-assets.sh # fixed seeds; skips anything already present +``` + +`flux_local` → `bg_remove_local` → `hunyuan3d_mlx`. The judge's five expressions +are one fixed seed and one fixed description with only the expression phrase +varying, which is why he stays the same man while his face falls. + +The toaster is procedural rather than the generated GLB — it needs a lever we can +actually drive, and a dial that turns. diff --git a/src/game/game.ts b/src/game/game.ts index 646551f..4a3cbc6 100644 --- a/src/game/game.ts +++ b/src/game/game.ts @@ -9,6 +9,7 @@ import { coolStep } from '../sim/toasting'; import { judge, type Grade } from './judging'; import { DAYS, proceduralOrder, nameBrowning, type Order } from './orders'; import { el, Panel } from '../ui/hud'; +import { Title } from '../ui/title'; const SAVE_KEY = 'toastsim.save'; @@ -69,7 +70,14 @@ export class Game { this.drawer.warmth = k.currentSlice.warmth; }); - this.beginDay(); + // Sit on the title until the player clicks — which also gives us the user + // gesture the browser wants before any audio is allowed to make a sound. + this.kitchen.root.visible = false; + this.ticket.hide(); + new Title(this.save.day, () => { + audio.resume(); + this.beginDay(); + }); } get currentOrder(): Order { diff --git a/src/scenes/assets.ts b/src/scenes/assets.ts new file mode 100644 index 0000000..daa8857 --- /dev/null +++ b/src/scenes/assets.ts @@ -0,0 +1,49 @@ +import * as THREE from 'three'; +import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; + +const loader = new GLTFLoader(); +const cache = new Map>(); + +/** + * Load a generated GLB and make it usable without touching the file. + * + * Meshes that came out of an image-to-3D pipeline arrive at an arbitrary scale, + * with an arbitrary pivot, sitting wherever the mesher felt like — so every one + * is normalised on load: recentre on its own bounding box, scale so the longest + * axis is `size`, and drop it so it sits on y=0. Hand-fixing the assets instead + * would mean re-fixing them every time they're regenerated. + */ +export function loadProp(url: string, size: number): Promise { + const key = `${url}@${size}`; + let p = cache.get(key); + if (!p) { + p = loader.loadAsync(url).then((gltf) => { + const root = gltf.scene; + const box = new THREE.Box3().setFromObject(root); + const dim = box.getSize(new THREE.Vector3()); + const centre = box.getCenter(new THREE.Vector3()); + const longest = Math.max(dim.x, dim.y, dim.z) || 1; + const s = size / longest; + + // Wrap it: scaling the loaded scene directly fights whatever transforms + // the exporter already baked into it. + const wrap = new THREE.Group(); + root.position.set(-centre.x, -box.min.y, -centre.z); + root.scale.setScalar(1); + const inner = new THREE.Group(); + inner.add(root); + inner.scale.setScalar(s); + wrap.add(inner); + + root.traverse((o) => { + if ((o as THREE.Mesh).isMesh) { + o.castShadow = true; + o.receiveShadow = true; + } + }); + return wrap; + }); + cache.set(key, p); + } + return p; +} diff --git a/src/scenes/props.ts b/src/scenes/props.ts index 9f1568b..061616d 100644 --- a/src/scenes/props.ts +++ b/src/scenes/props.ts @@ -167,20 +167,31 @@ export function makePlate(): THREE.Group { export function makeBench(): THREE.Group { const g = new THREE.Group(); + const loader = new THREE.TextureLoader(); + + const wood = loader.load('/assets/img/bench_wood.png'); + wood.wrapS = wood.wrapT = THREE.RepeatWrapping; + wood.repeat.set(4, 2); + // The texture was generated as artwork, i.e. sRGB. Saying so is what keeps it + // from washing out once it's lit. + wood.colorSpace = THREE.SRGBColorSpace; + wood.anisotropy = 8; const top = new THREE.Mesh( new THREE.BoxGeometry(20, 0.5, 9), - new THREE.MeshStandardMaterial({ color: 0x9a6134, roughness: 0.72 }), + new THREE.MeshStandardMaterial({ map: wood, color: 0xbb9068, roughness: 0.68 }), ); top.position.y = -0.25; top.receiveShadow = true; g.add(top); - // splashback, so the scene has a horizon instead of fog + + // Splashback: a blurred kitchen so the scene has a horizon instead of fog. + const back = loader.load('/assets/img/kitchen_backdrop.png'); + back.colorSpace = THREE.SRGBColorSpace; const wall = new THREE.Mesh( - new THREE.BoxGeometry(20, 7, 0.3), - new THREE.MeshStandardMaterial({ color: 0x5d5348, roughness: 0.9 }), + new THREE.PlaneGeometry(22, 8), + new THREE.MeshBasicMaterial({ map: back, color: 0x6a6258 }), ); - wall.position.set(0, 3.5, -4.4); - wall.receiveShadow = true; + wall.position.set(0, 3.2, -4.4); g.add(wall); return g; } diff --git a/src/scenes/spreadrig.ts b/src/scenes/spreadrig.ts index 0312a01..fb6160e 100644 --- a/src/scenes/spreadrig.ts +++ b/src/scenes/spreadrig.ts @@ -1,5 +1,6 @@ import * as THREE from 'three'; import { makeCutleryMesh, type Tool } from '../sim/cutlery'; +import { loadProp } from './assets'; import type { SpreadDef } from '../sim/spreads'; /** @@ -64,50 +65,29 @@ export class KnifeRig { } } -/** The pot you dip into. One per order — you only ever get one spread. */ +/** + * The pot you dip into — one per order, because you only ever get one spread. + * + * The vessel is a generated GLB; the thing you actually dip into is an invisible + * box at its mouth. Raycasting the generated mesh directly would be at the mercy + * of whatever the mesher produced, and the dip target needs to be exactly where + * the player expects it. + */ export function makeSpreadPot(def: SpreadDef): { root: THREE.Group; hit: THREE.Mesh } { const g = new THREE.Group(); - const glass = new THREE.MeshStandardMaterial({ - color: 0xdfe6e6, - roughness: 0.12, - metalness: 0.02, - transparent: true, - opacity: 0.42, - }); - const contents = new THREE.MeshStandardMaterial({ - color: new THREE.Color(def.color[0], def.color[1], def.color[2]), - roughness: def.id === 'peanut' ? 0.85 : 0.4, - }); + const isButter = def.id === 'butter'; - if (def.id === 'butter') { - const dish = new THREE.Mesh(new THREE.BoxGeometry(1.15, 0.16, 0.75), glass); - dish.position.y = 0.08; - g.add(dish); - const block = new THREE.Mesh(new THREE.BoxGeometry(0.78, 0.3, 0.44), contents); - block.position.y = 0.29; - block.castShadow = true; - g.add(block); - return { root: g, hit: block }; - } - - const jar = new THREE.Mesh(new THREE.CylinderGeometry(0.42, 0.4, 0.66, 24), glass); - jar.position.y = 0.33; - g.add(jar); - const fill = new THREE.Mesh(new THREE.CylinderGeometry(0.36, 0.34, 0.46, 24), contents); - fill.position.y = 0.27; - g.add(fill); - const surface = new THREE.Mesh(new THREE.CylinderGeometry(0.36, 0.36, 0.02, 24), contents); - surface.position.y = 0.5; - g.add(surface); - const label = new THREE.Mesh( - new THREE.CylinderGeometry(0.425, 0.425, 0.3, 24, 1, true), - new THREE.MeshStandardMaterial({ - color: def.id === 'mitey' ? 0xc8102e : 0xf2e2c0, - roughness: 0.7, - side: THREE.DoubleSide, - }), + // material.visible = false, not object.visible = false: the renderer skips it + // either way, but the raycaster still needs a live object to hit. + const hit = new THREE.Mesh( + new THREE.BoxGeometry(isButter ? 0.8 : 0.62, 0.08, isButter ? 0.5 : 0.62), + new THREE.MeshBasicMaterial({ visible: false }), ); - label.position.y = 0.3; - g.add(label); - return { root: g, hit: surface }; + hit.position.y = isButter ? 0.34 : 0.56; + g.add(hit); + + const file = isButter ? 'butter_dish' : def.id === 'peanut' ? 'pb_jar' : 'mitey_jar'; + void loadProp(`/assets/models/${file}.glb`, isButter ? 1.5 : 1.15).then((prop) => g.add(prop)); + + return { root: g, hit }; } diff --git a/src/style.css b/src/style.css index 6670337..3a32717 100644 --- a/src/style.css +++ b/src/style.css @@ -514,3 +514,50 @@ canvas#c { color: var(--ink-dim); text-shadow: 0 2px 10px #000; } + +/* ---- title ---- */ +.title { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: radial-gradient(ellipse at 50% 45%, #241a15 0%, #0d0a08 75%); +} + +.title-wrap { + display: flex; + align-items: center; + gap: 40px; + max-width: 900px; + padding: 24px; +} + +.title-art { + width: 380px; + max-width: 44vw; + border-radius: 12px; + filter: drop-shadow(0 20px 44px rgba(0, 0, 0, 0.7)); +} + +.title-txt h1 { + font-size: 62px; + letter-spacing: 0.05em; + line-height: 1; + color: var(--paper); +} + +.title-sub { + margin: 10px 0 26px; + font-size: 16px; + font-style: italic; + color: var(--ink-dim); +} + +.title-keys { + margin-top: 26px; + font-size: 11px; + line-height: 1.9; + letter-spacing: 0.08em; + color: #6d6155; +} diff --git a/src/ui/title.ts b/src/ui/title.ts new file mode 100644 index 0000000..be94ea0 --- /dev/null +++ b/src/ui/title.ts @@ -0,0 +1,26 @@ +import { el, Panel } from './hud'; + +/** The front door. One button, one piece of art, one joke. */ +export class Title { + private panel: Panel; + + constructor(day: number, onStart: () => void) { + this.panel = new Panel('title'); + const wrap = el('div', 'title-wrap', this.panel.root); + const art = el('img', 'title-art', wrap); + art.src = '/assets/img/title_art.png'; + art.alt = ''; + const txt = el('div', 'title-txt', wrap); + el('h1', undefined, txt, 'TOASTSIM'); + el('p', 'title-sub', txt, 'Bread goes in. You are judged.'); + const btn = el('button', 'btn', txt, day > 1 ? `RESUME — DAY ${day}` : 'START DAY 1'); + btn.addEventListener('click', () => { + this.panel.dispose(); + onStart(); + }); + const keys = el('div', 'title-keys', txt); + el('div', undefined, keys, 'SPACE — lever down, then pop'); + el('div', undefined, keys, 'DRAG — spread · WHEEL — tilt the knife'); + el('div', undefined, keys, 'ENTER — serve it to him'); + } +}