M8: crunchy vs smooth, and the jar that separates

Natural peanut butter stratifies while it sits. The jar carries the state:
strat 0..1, set per order (smooth 0.75, crunchy 0.9 — chunks sink). The first
unstirred dip takes the oil slick off the top; the ones after it dig into what
the oil left behind. Verified dip trace, no stirring: 0.875, 0.669, 0.462,
0.256. Swirl the knife in the jar (~2.5 circles of angular sweep; holding still
stirs nothing) and every dip is 0.5. Stirring costs time, and time costs toast
warmth — the mechanic pays the same tax as everything else.

Consistency pushes back through the physics that already exist: an oily load
multiplies the yield down (glides at any angle, goes on thin), a dry one
multiplies it up (fights like cold butter, tears). The shader shows it — slick
is dark and shiny, grout is pale and matte.

The judge reads what LANDED, not what the jar was: every deposit remembers the
oil it landed at, and the criterion scores the mass-weighted mean AND stdev.
The stdev is the design catch, found by verification: a slick strip and a grout
strip average to 0.54 — "just right" — and the first cut scored the lazy play
as if the jar had been stirred. Nobody eats the average:

  never stirred, strips   oil 0.54 ±0.23  "slick here, grout there"  C
  one slick dip, whole    oil 0.87 ±0.00  "a slick"
  stirred first           oil 0.50 ±0.00  "just right"

Crunchy reuses the particle system wholesale — chunks ride the knife, shed per
stroke-distance, get judged with the same Clark–Evans index ("Peanut chunks —
21 bits, evenly strewn (R 1.20)"), and the rind line bank is genericised via a
{bits} placeholder so he complains correctly about either.

Day 6 is crunchy now ("the PROPER kind"), the ticket warns when a jar has been
sitting, and the JAR bar shows how mixed it is. Five new judge lines, including
"You did not stir the jar. The jar knows. I know."

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
monster 2026-07-16 23:03:57 +10:00
parent 70922bd855
commit ff006e65db
10 changed files with 278 additions and 20 deletions

View File

@ -55,6 +55,17 @@ 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 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. wants to see the toast *through* it.
Peanut butter comes smooth or crunchy, and both are *natural* — the jar
separates while it sits. The oil rises; the paste underneath dries out. An
unstirred first dip is a slick (flows at almost no pressure, goes on thin and
shiny), the dips after it are grout (fights like cold butter). Swirl the knife
in the jar — about two and a half circles — to mix it back, and yes, stirring
costs time, and time costs toast warmth. The judge reads the mass-weighted mean
*and spread* of the consistency that actually landed: `oil 0.50 ±0.00 — just
right` versus `oil 0.54 ±0.23 — slick here, grout there`, because a slick strip
and a grout strip average to "correct" and nobody eats the average. Crunchy
additionally has chunks, and the chunks are judged like rind (below).
Marmalade adds a second axis: the rind. Bits drop off the blade **per unit of Marmalade adds a second axis: the rind. Bits drop off the blade **per unit of
stroke distance**, so a dabbing knife makes a pile and a moving knife lays a stroke distance**, so a dabbing knife makes a pile and a moving knife lays a
trail — and because the shed rate scales with what's left on the blade, one dip trail — and because the shed rate scales with what's left on the blade, one dip

View File

@ -87,6 +87,21 @@ export function installDevHarness(app: App, game: Game): void {
return harness.stats(); return harness.stats();
}, },
/** Swirl the knife in the jar — remixes a separated spread. */
swirl(circles = 3) {
const hit = (kitchen as unknown as { potHit: THREE.Mesh | null }).potHit;
const wp = new THREE.Vector3(3.15, 0.56, 0.45);
hit?.getWorldPosition(wp);
const steps = Math.round(circles * 48);
for (let i = 0; i <= steps; i++) {
const a = (i / 48) * Math.PI * 2;
point(wp.x + Math.cos(a) * 0.2, wp.y, wp.z + Math.sin(a) * 0.2, true);
app.step(1 / 60);
}
app.input.down = false;
app.step(1 / 60);
},
/** Park the cursor off the toast so a screenshot isn't full of knife. */ /** Park the cursor off the toast so a screenshot isn't full of knife. */
park() { park() {
point(2.5, kitchen.currentSlice.topY, 1.3, false); point(2.5, kitchen.currentSlice.topY, 1.3, false);

View File

@ -6,6 +6,7 @@ import { DrawerView } from '../scenes/drawer';
import { TOOLS, type ToolId } from '../sim/cutlery'; import { TOOLS, type ToolId } from '../sim/cutlery';
import { audio } from '../core/audio'; import { audio } from '../core/audio';
import { coolStep } from '../sim/toasting'; import { coolStep } from '../sim/toasting';
import { SPREADS } from '../sim/spreads';
import { judge, type Grade } from './judging'; import { judge, type Grade } from './judging';
import { DAYS, proceduralOrder, nameBrowning, type Order } from './orders'; import { DAYS, proceduralOrder, nameBrowning, type Order } from './orders';
import { el, Panel } from '../ui/hud'; import { el, Panel } from '../ui/hud';
@ -158,6 +159,9 @@ export class Game {
this.ticketEl, this.ticketEl,
`butter is ${o.butterSoftness > 0.6 ? 'soft' : o.butterSoftness > 0.3 ? 'firm' : 'fridge-hard'} today`, `butter is ${o.butterSoftness > 0.6 ? 'soft' : o.butterSoftness > 0.3 ? 'firm' : 'fridge-hard'} today`,
); );
if (SPREADS[o.spread].separates) {
el('div', 'ticket-hint', this.ticketEl, 'the jar has been sitting — stir it before you dip');
}
} }
/** Called when the player hands the plate over. */ /** Called when the player hands the plate over. */

View File

@ -90,6 +90,7 @@ export function judge(slice: Slice, order: Order, usedTool: ToolId, seconds: num
detail: d.mean < 0.002 ? 'intact' : `torn over ${Math.round(slice.damage.fraction(slice.mask, (v) => v > 0.02) * 100)}%`, detail: d.mean < 0.002 ? 'intact' : `torn over ${Math.round(slice.damage.fraction(slice.mask, (v) => v > 0.02) * 100)}%`,
}, },
...(def.particles ? [rindCriterion(slice, def.particles.name)] : []), ...(def.particles ? [rindCriterion(slice, def.particles.name)] : []),
...(def.separates ? [consistencyCriterion(slice)] : []),
{ {
key: 'amount', key: 'amount',
label: 'The Right Amount', label: 'The Right Amount',
@ -184,6 +185,40 @@ function rindCriterion(slice: Slice, name: string): Criterion {
}; };
} }
/**
* How the spread that actually landed compares to a properly stirred jar.
* 0.5 is right; the slick off the top of an unstirred jar pushes it up, the
* dried paste underneath pulls it down. The fix was always the same: stir.
*/
function consistencyCriterion(slice: Slice): Criterion {
const oil = slice.oilMean;
const spread = slice.oilStdev;
const off = Math.abs(oil - 0.5);
// Two ways to fail: wrong on average, or right on average and wrong in every
// individual bite. The stdev term is what stops "one oily strip, one dry
// strip" from scoring as though the jar had been stirred.
const score = clamp01(1 - off / 0.32 - spread / 0.24);
const word =
spread > 0.15
? 'slick here, grout there'
: oil > 0.78
? 'a slick'
: oil > 0.62
? 'oily'
: oil >= 0.38
? 'just right'
: oil >= 0.22
? 'dry'
: 'grout';
return {
key: 'consistency',
label: 'Consistency',
score,
weight: 1.1,
detail: `oil ${oil.toFixed(2)} ±${spread.toFixed(2)}${word}`,
};
}
function cap(s: string): string { function cap(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1); return s.charAt(0).toUpperCase() + s.slice(1);
} }

View File

@ -108,16 +108,30 @@ const BANK: Bank = {
}, },
rind: { rind: {
bad: [ bad: [
'The rind is all in one postcode.', 'All the {bits}, in one postcode.',
'You have made a toast with a rind district.', 'You have made a toast with a {bits} district.',
'Every piece of rind, huddled together for warmth. Spread them OUT.', 'Every piece of {bits}, huddled together for warmth. Spread them OUT.',
'One bite of this is all rind. The others are an apology.', 'One bite of this is all {bits}. The others are an apology.',
'There is no rind on this. Did you eat it? You ate it.', 'There is no {bits} on this. Did you eat it? You ate it.',
], ],
good: [ good: [
'The rind is evenly strewn. That takes patience, and you had it.', 'Evenly strewn {bits}. That takes patience, and you had it.',
'Every bite gets its piece of rind. That is the entire craft.', 'Every bite gets its {bits}. That is the entire craft.',
'Rind distribution: textbook. I did not expect that today.', 'The {bits}: textbook distribution. I did not expect that today.',
],
},
consistency: {
bad: [
'It is weeping oil. I can hear it.',
'You did not stir the jar. The jar knows. I know.',
'This has the texture of grout. Grout with ambitions.',
'Half slick, half plaster. Pick a toast.',
'The oil went on first and the cement went on after. Stir. The. Jar.',
],
good: [
'Properly stirred. You can taste the difference, and I intend to.',
'The consistency is exactly right. Someone respected the jar.',
'Smooth, even, no slick. The jar was stirred by an adult.',
], ],
}, },
tool: { tool: {
@ -168,6 +182,7 @@ function fill(s: string, order: Order, v: Verdict, tool: string): string {
.replace('{got}', v.criteria.find((c) => c.key === 'amount')?.detail.split(' — ')[0] ?? 'that') .replace('{got}', v.criteria.find((c) => c.key === 'amount')?.detail.split(' — ')[0] ?? 'that')
// Mid-sentence, so lowercase — except MITEY, which is shouted on principle. // Mid-sentence, so lowercase — except MITEY, which is shouted on principle.
.replace('{spread}', spread.id === 'mitey' ? 'MITEY' : spread.name.toLowerCase()) .replace('{spread}', spread.id === 'mitey' ? 'MITEY' : spread.name.toLowerCase())
.replaceAll('{bits}', spread.particles?.name ?? 'rind')
.replace('{tool}', tool.toLowerCase()); .replace('{tool}', tool.toLowerCase());
} }

View File

@ -112,7 +112,7 @@ export const DAYS: Order[] = [
{ {
day: 6, day: 6,
bread: 'sourdough', bread: 'sourdough',
spread: 'peanut', spread: 'crunchy',
amount: 'normal', amount: 'normal',
browning: 0.72, browning: 0.72,
browningName: 'dark', browningName: 'dark',
@ -120,7 +120,7 @@ export const DAYS: Order[] = [
tool: 'steak_knife', tool: 'steak_knife',
butterSoftness: 0.25, butterSoftness: 0.25,
who: 'a man in a hurry', who: 'a man in a hurry',
text: "Dark. Sourdough. Don't care if it catches a bit. Peanut butter, even.", text: "Dark. Sourdough. Crunchy peanut butter — the PROPER kind. Don't care if it catches a bit.",
}, },
{ {
day: 7, day: 7,
@ -153,7 +153,7 @@ export const DAYS: Order[] = [
/** Days 9+ — keep going, with everything cranked. */ /** Days 9+ — keep going, with everything cranked. */
export function proceduralOrder(day: number, rand: () => number): Order { export function proceduralOrder(day: number, rand: () => number): Order {
const breads: BreadId[] = ['white', 'multigrain', 'raisin', 'sourdough']; const breads: BreadId[] = ['white', 'multigrain', 'raisin', 'sourdough'];
const spreads: SpreadId[] = ['butter', 'peanut', 'mitey', 'marmalade']; const spreads: SpreadId[] = ['butter', 'peanut', 'crunchy', 'mitey', 'marmalade'];
const amounts: AmountClass[] = ['thin', 'normal', 'thick']; const amounts: AmountClass[] = ['thin', 'normal', 'thick'];
const tools: ToolId[] = [ const tools: ToolId[] = [
'butter_knife', 'butter_knife',

View File

@ -60,6 +60,11 @@ export class KitchenView implements View {
private toastSeconds = 0; private toastSeconds = 0;
knife: Knife = newKnife(); knife: Knife = newKnife();
/** How badly the jar has separated right now. 0 = mixed, 1 = oil on top. */
strat = 0;
private dipsSinceStir = 0;
private stirPrev: number | null = null;
private wasDipping = false;
private knifeRig: KnifeRig; private knifeRig: KnifeRig;
private pot: THREE.Group | null = null; private pot: THREE.Group | null = null;
private potHit: THREE.Mesh | null = null; private potHit: THREE.Mesh | null = null;
@ -82,6 +87,8 @@ export class KitchenView implements View {
private gaugeScrape!: HTMLElement; private gaugeScrape!: HTMLElement;
private modeEl!: HTMLElement; private modeEl!: HTMLElement;
private loadFill!: HTMLElement; private loadFill!: HTMLElement;
private jarRow!: HTMLElement;
private jarFill!: HTMLElement;
private breadSel!: HTMLSelectElement; private breadSel!: HTMLSelectElement;
private spreadSel!: HTMLSelectElement; private spreadSel!: HTMLSelectElement;
private toolSel!: HTMLSelectElement; private toolSel!: HTMLSelectElement;
@ -184,6 +191,12 @@ export class KitchenView implements View {
this.butterSoftness > 0.66 ? 'soft' : this.butterSoftness > 0.33 ? 'ok' : 'hard'; this.butterSoftness > 0.66 ? 'soft' : this.butterSoftness > 0.33 ? 'ok' : 'hard';
}); });
this.jarRow = el('div', 'row', p);
el('label', 'lbl', this.jarRow, 'JAR');
const jarBar = el('div', 'load', this.jarRow);
jarBar.style.flex = '1';
this.jarFill = el('div', 'load-fill', jarBar);
// The pressure gauge — the mechanic made legible. The fill is the pressure // The pressure gauge — the mechanic made legible. The fill is the pressure
// your wrist angle is producing; the gold mark is what this spread needs in // your wrist angle is producing; the gold mark is what this spread needs in
// order to flow; the red mark is where the knife stops spreading and starts // order to flow; the red mark is where the knife stops spreading and starts
@ -206,6 +219,10 @@ export class KitchenView implements View {
setSpread(id: SpreadId): void { setSpread(id: SpreadId): void {
this.spreadId = id; this.spreadId = id;
const def = SPREADS[id]; const def = SPREADS[id];
this.strat = def.separates ?? 0;
this.dipsSinceStir = 0;
this.stirPrev = null;
this.wasDipping = false;
this.slice?.setSpread(def); this.slice?.setSpread(def);
this.knifeRig.setSpread(def); this.knifeRig.setSpread(def);
if (this.pot) this.root.remove(this.pot); if (this.pot) this.root.remove(this.pot);
@ -263,6 +280,19 @@ export class KitchenView implements View {
this.onServed?.(); this.onServed?.();
} }
/**
* What the next scoop out of the jar is like. The first dip off a split jar
* takes the oil slick; the ones after it dig into what the oil left behind.
* Stirred (strat 0), every dip is 0.5 exactly right.
*/
private jarOil(): number {
if (this.strat <= 0.001) return 0.5;
const swing = 0.5 * this.strat;
// dip 0: +swing (the slick) · dip 1: about even · dip 2+: increasingly dry
const t = 1 - this.dipsSinceStir * 0.55;
return 0.5 + swing * Math.max(-1, Math.min(1, t));
}
/** Whatever you managed to pull out of the drawer is what you're spreading with. */ /** Whatever you managed to pull out of the drawer is what you're spreading with. */
setTool(id: ToolId): void { setTool(id: ToolId): void {
this.toolId = id; this.toolId = id;
@ -395,10 +425,46 @@ export class KitchenView implements View {
if (hits.length) { if (hits.length) {
overPot = true; overPot = true;
this.knifeRig.update(hits[0].point, this.knife.angle * 0.3, this.knife.load, hits[0].point.y); this.knifeRig.update(hits[0].point, this.knife.angle * 0.3, this.knife.load, hits[0].point.y);
if (inp.down) dip(this.knife, def); if (inp.down) {
// Swirling: angular travel of the knife around the jar's centre. Only
// sweep counts — holding still in the jar stirs nothing.
if (def.separates && this.strat > 0.001) {
const ang = Math.atan2(hits[0].point.z - POT_Z, hits[0].point.x - POT_X);
if (this.stirPrev !== null) {
let d = ang - this.stirPrev;
if (d > Math.PI) d -= Math.PI * 2;
if (d < -Math.PI) d += Math.PI * 2;
// ~2.5 full circles takes the jar from fully split to mixed.
const sweep = Math.abs(d);
if (sweep > 0.002) {
this.strat = Math.max(0, this.strat - sweep / (5 * Math.PI));
this.dipsSinceStir = 0;
if (sweep > 0.02) audio.scrape(0.25, sweep * 6);
}
}
this.stirPrev = ang;
}
// A dip is an event, not a state: the oil you get depends on how many
// scoops have already come off the top since the last stir.
if (!this.wasDipping) {
dip(this.knife, def, this.jarOil());
this.dipsSinceStir++;
this.wasDipping = true;
} else {
// Holding the blade in the jar keeps it topped up at the same blend.
dip(this.knife, def, this.knife.oil);
}
} else {
this.stirPrev = null;
this.wasDipping = false;
}
this.knife.hasPrev = false; this.knife.hasPrev = false;
} }
} }
if (!overPot) {
this.stirPrev = null;
this.wasDipping = false;
}
if (!overPot) { if (!overPot) {
this.plane.constant = -this.slice.topY; this.plane.constant = -this.slice.topY;
@ -428,6 +494,7 @@ export class KitchenView implements View {
} }
} }
this.slice.uniforms.uOil.value = def.separates ? this.slice.oilMean : 0.5;
this.updateGauge(def); this.updateGauge(def);
} }
@ -447,6 +514,13 @@ export class KitchenView implements View {
? '#6cbf6c' ? '#6cbf6c'
: '#8a7f70'; : '#8a7f70';
this.jarRow.style.display = def.separates ? '' : 'none';
if (def.separates) {
const mixed = 1 - this.strat;
this.jarFill.style.width = `${Math.round(mixed * 100)}%`;
this.jarFill.style.background = mixed > 0.75 ? '#6cbf6c' : mixed > 0.4 ? '#e8a53a' : '#c0392b';
}
const fx = this.lastFx; const fx = this.lastFx;
let msg = scraping ? 'scraping' : 'spreading'; let msg = scraping ? 'scraping' : 'spreading';
if (fx) { if (fx) {
@ -456,7 +530,10 @@ export class KitchenView implements View {
else if (fx.mode === 'scrape') msg = 'taking it back off'; else if (fx.mode === 'scrape') msg = 'taking it back off';
else if (fx.mode === 'spread') msg = 'spreading nicely'; else if (fx.mode === 'spread') msg = 'spreading nicely';
} else if (this.knife.load <= 0.01) { } else if (this.knife.load <= 0.01) {
msg = 'the knife is empty — go and dip'; msg =
def.separates && this.strat > 0.45
? 'the oil has risen — swirl the knife in the jar first'
: 'the knife is empty — go and dip';
} }
this.modeEl.textContent = msg; this.modeEl.textContent = msg;
this.modeEl.style.color = this.modeEl.style.color =

View File

@ -56,6 +56,15 @@ export class Slice {
private rindMesh: THREE.InstancedMesh | null = null; private rindMesh: THREE.InstancedMesh | null = null;
private rindDirty = false; private rindDirty = false;
/**
* Consistency bookkeeping for spreads that separate: every deposit remembers
* the oil level it landed at, and the judge reads the mass-weighted mean.
* 0.5 is right; 1 is a slick; 0 is grout.
*/
private spreadMassSum = 0;
private oilMassSum = 0;
private oilSqSum = 0;
private tex: THREE.DataTexture; private tex: THREE.DataTexture;
private texData: Uint8Array; private texData: Uint8Array;
private dirty = true; private dirty = true;
@ -107,6 +116,36 @@ export class Slice {
return this.mesh.position.y + this.halfThickness; return this.mesh.position.y + this.halfThickness;
} }
recordDeposit(mass: number, oil: number): void {
this.spreadMassSum += mass;
this.oilMassSum += mass * oil;
this.oilSqSum += mass * oil * oil;
}
/** Scraping takes spread off at whatever the current blend is. */
recordRemoval(mass: number): void {
const mean = this.oilMean;
this.spreadMassSum = Math.max(0, this.spreadMassSum - mass);
this.oilMassSum = Math.max(0, this.oilMassSum - mass * mean);
this.oilSqSum = Math.max(0, this.oilSqSum - mass * mean * mean);
}
/** Mass-weighted consistency of everything on the toast. 0.5 until proven otherwise. */
get oilMean(): number {
return this.spreadMassSum < 1 ? 0.5 : this.oilMassSum / this.spreadMassSum;
}
/**
* Mass-weighted spread of consistencies. This is what catches the lazy play:
* a slick strip and a grout strip AVERAGE to "just right" but nobody wants
* to eat the average, they eat one bite at a time.
*/
get oilStdev(): number {
if (this.spreadMassSum < 1) return 0;
const mean = this.oilMean;
return Math.sqrt(Math.max(0, this.oilSqSum / this.spreadMassSum - mean * mean));
}
/** Drop a bit of rind at (u,v). It rides the slice from then on. */ /** Drop a bit of rind at (u,v). It rides the slice from then on. */
addRind(u: number, v: number): void { addRind(u: number, v: number): void {
if (this.rind.length >= RIND_MAX) return; if (this.rind.length >= RIND_MAX) return;
@ -412,6 +451,7 @@ uniform vec3 uSpreadColor;
uniform float uSpreadGloss; uniform float uSpreadGloss;
uniform float uSpreadOpaqueAt; // spread thickness at which it fully hides the toast uniform float uSpreadOpaqueAt; // spread thickness at which it fully hides the toast
uniform float uSpreadBump; uniform float uSpreadBump;
uniform float uOil;
uniform vec3 uLightDir; uniform vec3 uLightDir;
uniform vec3 uLightColor; uniform vec3 uLightColor;
uniform vec3 uAmbientSky; uniform vec3 uAmbientSky;
@ -542,6 +582,11 @@ void main() {
// a little grain in the film so thin spread doesn't look like flat paint // a little grain in the film so thin spread doesn't look like flat paint
float filmN = fbm(vUv * 70.0 + 13.0); float filmN = fbm(vUv * 70.0 + 13.0);
vec3 spreadCol = uSpreadColor * (0.85 + filmN * 0.3); vec3 spreadCol = uSpreadColor * (0.85 + filmN * 0.3);
// Consistency tint: an oily slick is darker and saturated; dried-out paste
// goes pale and grey. uOil sits at 0.5 for anything that doesn't separate.
float oilT = (uOil - 0.5) * 2.0;
spreadCol *= 1.0 - max(oilT, 0.0) * 0.28;
spreadCol = mix(spreadCol, vec3(dot(spreadCol, vec3(0.333))) * 1.45, max(-oilT, 0.0) * 0.45);
albedo = mix(albedo, spreadCol, cover); albedo = mix(albedo, spreadCol, cover);
// Thickness relief. Central differences in *UV* units, not texels — a smooth // Thickness relief. Central differences in *UV* units, not texels — a smooth
@ -573,6 +618,8 @@ void main() {
// so pair a broad sheen with a fresnel rim — that's the cue that says "wet". // 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; float gloss = uSpreadGloss * smoothstep(0.008, 0.09, spread) * topFace;
gloss *= 1.0 + uWarmth * 0.6; gloss *= 1.0 + uWarmth * 0.6;
// Oil is what shines. Dry paste barely does.
gloss *= 0.55 + uOil * 0.9;
// The lobe has to be TIGHT. The slice is flat and both the light and the camera // The lobe has to be TIGHT. The slice is flat and both the light and the camera
// are above it, so dot(N,H) is ~0.98 across the whole surface — any broad lobe // are above it, so dot(N,H) is ~0.98 across the whole surface — any broad lobe
// blankets it in white and lifts near-black MITEY to tan. Tight, and the only // blankets it in white and lifts near-black MITEY to tan. Tight, and the only
@ -629,6 +676,7 @@ function buildMaterial(bread: Bread, tex: THREE.DataTexture): THREE.ShaderMateri
uSpreadGloss: { value: 0.5 }, uSpreadGloss: { value: 0.5 },
uSpreadOpaqueAt: { value: 1e9 }, uSpreadOpaqueAt: { value: 1e9 },
uSpreadBump: { value: 0.1 }, uSpreadBump: { value: 0.1 },
uOil: { value: 0.5 },
uLightDir: { value: new THREE.Vector3(0.45, 0.85, 0.35).normalize() }, uLightDir: { value: new THREE.Vector3(0.45, 0.85, 0.35).normalize() },
uLightColor: { value: new THREE.Color(1.0, 0.95, 0.86) }, uLightColor: { value: new THREE.Color(1.0, 0.95, 0.86) },
uAmbientSky: { value: new THREE.Color(0.26, 0.27, 0.31) }, uAmbientSky: { value: new THREE.Color(0.26, 0.27, 0.31) },

View File

@ -45,6 +45,11 @@ export interface Knife {
rindLoad: number; rindLoad: number;
/** Fractional shed accumulator — pieces drop one at a time. */ /** Fractional shed accumulator — pieces drop one at a time. */
rindFrac: number; rindFrac: number;
/**
* Consistency of what's on the blade: 0.5 is properly mixed, 1 is the oil
* slick off the top of an unstirred jar, 0 is the dried-out paste under it.
*/
oil: number;
u: number; u: number;
v: number; v: number;
hasPrev: boolean; hasPrev: boolean;
@ -69,11 +74,13 @@ export interface StrokeFx {
export function newKnife(): Knife { export function newKnife(): Knife {
// Starts inside the window where warm-toast butter actually flows: the very // Starts inside the window where warm-toast butter actually flows: the very
// first stroke of the game shouldn't be a mysterious failure. // first stroke of the game shouldn't be a mysterious failure.
return { angle: 0.45, load: 0, rindLoad: 0, rindFrac: 0, u: 0.5, v: 0.5, hasPrev: false }; return { angle: 0.45, load: 0, rindLoad: 0, rindFrac: 0, oil: 0.5, u: 0.5, v: 0.5, hasPrev: false };
} }
export function dip(knife: Knife, def: SpreadDef): void { /** `oil` is what the jar handed over — 0.5 unless the jar has stratified. */
export function dip(knife: Knife, def: SpreadDef, oil = 0.5): void {
knife.load = def.pickup; knife.load = def.pickup;
knife.oil = oil;
if (def.particles) { if (def.particles) {
knife.rindLoad = def.particles.perDip; knife.rindLoad = def.particles.perDip;
knife.rindFrac = 0; knife.rindFrac = 0;
@ -169,7 +176,11 @@ function contact(
if (knife.angle < SCRAPE_ANGLE) { if (knife.angle < SCRAPE_ANGLE) {
if (knife.load <= 0.0005) return; if (knife.load <= 0.0005) return;
const yieldP = effectiveYield(def, softness, slice.warmth); // Consistency pushes back through the same physics as everything else:
// an oily load flows at almost no pressure, a dried-out one fights like
// cold butter. Only spreads that separate care.
const oilK = def.separates ? 1.25 - knife.oil * 0.5 : 1;
const yieldP = effectiveYield(def, softness, slice.warmth) * oilK;
const flowing = pressure >= yieldP; const flowing = pressure >= yieldP;
const deficit = flowing ? 0 : Math.min(1, (yieldP - pressure) / Math.max(yieldP, 0.001)); const deficit = flowing ? 0 : Math.min(1, (yieldP - pressure) / Math.max(yieldP, 0.001));
fx.deficit = Math.max(fx.deficit, deficit); fx.deficit = Math.max(fx.deficit, deficit);
@ -178,18 +189,25 @@ function contact(
// Below the yield pressure the spread won't flow, so the blade grabs the // Below the yield pressure the spread won't flow, so the blade grabs the
// crumb and takes it with it. Some still smears on, badly. // crumb and takes it with it. Some still smears on, badly.
const transfer = flowing ? 1 : 0.25; const transfer = flowing ? 1 : 0.25;
const rate = def.transferRate * tool.transferScale * dt * transfer; // Runny goes on thin, dry goes on in slabs.
const oilRate = def.separates ? 1.15 - knife.oil * 0.4 : 1;
const rate = def.transferRate * tool.transferScale * dt * transfer * oilRate;
let massLeft = knife.load * slice.mask.n * slice.mask.n; let massLeft = knife.load * slice.mask.n * slice.mask.n;
let laid = 0;
slice.spread.brush(u, v, radius, (i, w) => { slice.spread.brush(u, v, radius, (i, w) => {
if (mask[i] < 0.5 || massLeft <= 0) return; if (mask[i] < 0.5 || massLeft <= 0) return;
const blot = 1 - tool.blotch * blotchAt(i); const blot = 1 - tool.blotch * blotchAt(i);
const add = Math.min(rate * w * blot, massLeft); const add = Math.min(rate * w * blot, massLeft);
spread[i] += add; spread[i] += add;
massLeft -= add; massLeft -= add;
laid += add;
fx.deposited += add; fx.deposited += add;
}); });
knife.load = Math.max(0, massLeft / (slice.mask.n * slice.mask.n)); knife.load = Math.max(0, massLeft / (slice.mask.n * slice.mask.n));
// What lands remembers the consistency it landed at — the judge reads the
// mass-weighted mean at the end.
slice.recordDeposit(laid, knife.oil);
// Suspended solids drop off the blade as it works — per unit of stroke // Suspended solids drop off the blade as it works — per unit of stroke
// DISTANCE, not per second. That's the whole feel: a moving knife lays an // DISTANCE, not per second. That's the whole feel: a moving knife lays an
@ -259,6 +277,7 @@ function contact(
// What comes off the toast mostly stays on the blade. // What comes off the toast mostly stays on the blade.
knife.load += (removedSpread * 0.6) / (slice.mask.n * slice.mask.n); knife.load += (removedSpread * 0.6) / (slice.mask.n * slice.mask.n);
slice.recordRemoval(removedSpread);
fx.scrapedSpread += removedSpread; fx.scrapedSpread += removedSpread;
fx.scrapedChar += removedChar; fx.scrapedChar += removedChar;
fx.gouged += gouged; fx.gouged += gouged;

View File

@ -8,7 +8,7 @@
* better technique, it's warmer toast: don't dawdle in the drawer. * better technique, it's warmer toast: don't dawdle in the drawer.
*/ */
export type SpreadId = 'butter' | 'peanut' | 'mitey' | 'marmalade'; export type SpreadId = 'butter' | 'peanut' | 'crunchy' | 'mitey' | 'marmalade';
export type AmountClass = 'thin' | 'normal' | 'thick'; export type AmountClass = 'thin' | 'normal' | 'thick';
/** /**
@ -45,6 +45,13 @@ export interface SpreadDef {
bump: number; bump: number;
/** Suspended solids, if any. */ /** Suspended solids, if any. */
particles?: ParticleDef; particles?: ParticleDef;
/**
* Natural nut butters stratify: how badly the jar has separated when the
* order starts (0..1). The oil rises, the paste below dries out the first
* unstirred dip is a slick and the ones after it are grout. Swirl the knife
* in the jar to mix it back; the fix costs time, and time costs toast warmth.
*/
separates?: number;
/** Pressure needed to make it flow, before any warming. */ /** Pressure needed to make it flow, before any warming. */
baseYield: number; baseYield: number;
@ -84,12 +91,13 @@ export const SPREADS: Record<SpreadId, SpreadDef> = {
}, },
peanut: { peanut: {
id: 'peanut', id: 'peanut',
name: 'Peanut Butter', name: 'Smooth Peanut Butter',
blurb: 'Goes on thick, drags like wet sand, never evens itself out.', blurb: 'Goes on thick, drags like wet sand — and the jar has been sitting.',
color: [0.64, 0.41, 0.18], color: [0.64, 0.41, 0.18],
gloss: 0.3, gloss: 0.3,
opaqueAt: 0.16, opaqueAt: 0.16,
bump: 0.28, bump: 0.28,
separates: 0.75,
baseYield: 0.38, baseYield: 0.38,
tempSoftening: 0.3, tempSoftening: 0.3,
viscosity: 0.96, viscosity: 0.96,
@ -99,6 +107,32 @@ export const SPREADS: Record<SpreadId, SpreadDef> = {
scrapeEase: 0.75, scrapeEase: 0.75,
amounts: { thin: [0.18, 0.32], normal: [0.38, 0.68], thick: [0.75, 1.1] }, amounts: { thin: [0.18, 0.32], normal: [0.38, 0.68], thick: [0.75, 1.1] },
}, },
crunchy: {
id: 'crunchy',
name: 'Crunchy Peanut Butter',
blurb: 'Everything smooth is, plus chunks with opinions about where they sit.',
color: [0.6, 0.38, 0.16],
gloss: 0.28,
opaqueAt: 0.17,
bump: 0.32,
// Chunks sink; the jar splits worse than the smooth stuff.
separates: 0.9,
particles: {
name: 'peanut chunks',
color: [0.78, 0.6, 0.34],
size: 0.042,
perDip: 8,
shedRate: 0.85,
},
baseYield: 0.42,
tempSoftening: 0.3,
viscosity: 0.97,
pickup: 0.95,
transferRate: 4.3,
drag: 1.1,
scrapeEase: 0.7,
amounts: { thin: [0.18, 0.32], normal: [0.38, 0.68], thick: [0.75, 1.1] },
},
mitey: { mitey: {
id: 'mitey', id: 'mitey',
name: 'MITEY', name: 'MITEY',