M5+M6: content, generated assets, title, sound, perf
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 <noreply@anthropic.com>
This commit is contained in:
parent
1ba66f0cdf
commit
5b09f23c7e
127
README.md
Normal file
127
README.md
Normal file
@ -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.
|
||||
@ -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 {
|
||||
|
||||
49
src/scenes/assets.ts
Normal file
49
src/scenes/assets.ts
Normal file
@ -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<string, Promise<THREE.Group>>();
|
||||
|
||||
/**
|
||||
* 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<THREE.Group> {
|
||||
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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
26
src/ui/title.ts
Normal file
26
src/ui/title.ts
Normal file
@ -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');
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user