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:
parent
70922bd855
commit
ff006e65db
11
README.md
11
README.md
@ -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
|
||||||
|
|||||||
15
src/dev.ts
15
src/dev.ts
@ -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);
|
||||||
|
|||||||
@ -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. */
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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 =
|
||||||
|
|||||||
@ -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) },
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user