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
|
||||
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
|
||||
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
|
||||
|
||||
15
src/dev.ts
15
src/dev.ts
@ -87,6 +87,21 @@ export function installDevHarness(app: App, game: Game): void {
|
||||
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() {
|
||||
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 { audio } from '../core/audio';
|
||||
import { coolStep } from '../sim/toasting';
|
||||
import { SPREADS } from '../sim/spreads';
|
||||
import { judge, type Grade } from './judging';
|
||||
import { DAYS, proceduralOrder, nameBrowning, type Order } from './orders';
|
||||
import { el, Panel } from '../ui/hud';
|
||||
@ -158,6 +159,9 @@ export class Game {
|
||||
this.ticketEl,
|
||||
`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. */
|
||||
|
||||
@ -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)}%`,
|
||||
},
|
||||
...(def.particles ? [rindCriterion(slice, def.particles.name)] : []),
|
||||
...(def.separates ? [consistencyCriterion(slice)] : []),
|
||||
{
|
||||
key: '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 {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
@ -108,16 +108,30 @@ const BANK: Bank = {
|
||||
},
|
||||
rind: {
|
||||
bad: [
|
||||
'The rind is all in one postcode.',
|
||||
'You have made a toast with a rind district.',
|
||||
'Every piece of rind, huddled together for warmth. Spread them OUT.',
|
||||
'One bite of this is all rind. The others are an apology.',
|
||||
'There is no rind on this. Did you eat it? You ate it.',
|
||||
'All the {bits}, in one postcode.',
|
||||
'You have made a toast with a {bits} district.',
|
||||
'Every piece of {bits}, huddled together for warmth. Spread them OUT.',
|
||||
'One bite of this is all {bits}. The others are an apology.',
|
||||
'There is no {bits} on this. Did you eat it? You ate it.',
|
||||
],
|
||||
good: [
|
||||
'The rind is evenly strewn. That takes patience, and you had it.',
|
||||
'Every bite gets its piece of rind. That is the entire craft.',
|
||||
'Rind distribution: textbook. I did not expect that today.',
|
||||
'Evenly strewn {bits}. That takes patience, and you had it.',
|
||||
'Every bite gets its {bits}. That is the entire craft.',
|
||||
'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: {
|
||||
@ -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')
|
||||
// Mid-sentence, so lowercase — except MITEY, which is shouted on principle.
|
||||
.replace('{spread}', spread.id === 'mitey' ? 'MITEY' : spread.name.toLowerCase())
|
||||
.replaceAll('{bits}', spread.particles?.name ?? 'rind')
|
||||
.replace('{tool}', tool.toLowerCase());
|
||||
}
|
||||
|
||||
|
||||
@ -112,7 +112,7 @@ export const DAYS: Order[] = [
|
||||
{
|
||||
day: 6,
|
||||
bread: 'sourdough',
|
||||
spread: 'peanut',
|
||||
spread: 'crunchy',
|
||||
amount: 'normal',
|
||||
browning: 0.72,
|
||||
browningName: 'dark',
|
||||
@ -120,7 +120,7 @@ export const DAYS: Order[] = [
|
||||
tool: 'steak_knife',
|
||||
butterSoftness: 0.25,
|
||||
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,
|
||||
@ -153,7 +153,7 @@ export const DAYS: Order[] = [
|
||||
/** Days 9+ — keep going, with everything cranked. */
|
||||
export function proceduralOrder(day: number, rand: () => number): Order {
|
||||
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 tools: ToolId[] = [
|
||||
'butter_knife',
|
||||
|
||||
@ -60,6 +60,11 @@ export class KitchenView implements View {
|
||||
private toastSeconds = 0;
|
||||
|
||||
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 pot: THREE.Group | null = null;
|
||||
private potHit: THREE.Mesh | null = null;
|
||||
@ -82,6 +87,8 @@ export class KitchenView implements View {
|
||||
private gaugeScrape!: HTMLElement;
|
||||
private modeEl!: HTMLElement;
|
||||
private loadFill!: HTMLElement;
|
||||
private jarRow!: HTMLElement;
|
||||
private jarFill!: HTMLElement;
|
||||
private breadSel!: HTMLSelectElement;
|
||||
private spreadSel!: 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.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
|
||||
// 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
|
||||
@ -206,6 +219,10 @@ export class KitchenView implements View {
|
||||
setSpread(id: SpreadId): void {
|
||||
this.spreadId = id;
|
||||
const def = SPREADS[id];
|
||||
this.strat = def.separates ?? 0;
|
||||
this.dipsSinceStir = 0;
|
||||
this.stirPrev = null;
|
||||
this.wasDipping = false;
|
||||
this.slice?.setSpread(def);
|
||||
this.knifeRig.setSpread(def);
|
||||
if (this.pot) this.root.remove(this.pot);
|
||||
@ -263,6 +280,19 @@ export class KitchenView implements View {
|
||||
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. */
|
||||
setTool(id: ToolId): void {
|
||||
this.toolId = id;
|
||||
@ -395,10 +425,46 @@ export class KitchenView implements View {
|
||||
if (hits.length) {
|
||||
overPot = true;
|
||||
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;
|
||||
}
|
||||
}
|
||||
if (!overPot) {
|
||||
this.stirPrev = null;
|
||||
this.wasDipping = false;
|
||||
}
|
||||
|
||||
if (!overPot) {
|
||||
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);
|
||||
}
|
||||
|
||||
@ -447,6 +514,13 @@ export class KitchenView implements View {
|
||||
? '#6cbf6c'
|
||||
: '#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;
|
||||
let msg = scraping ? 'scraping' : 'spreading';
|
||||
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 === 'spread') msg = 'spreading nicely';
|
||||
} 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.style.color =
|
||||
|
||||
@ -56,6 +56,15 @@ export class Slice {
|
||||
private rindMesh: THREE.InstancedMesh | null = null;
|
||||
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 texData: Uint8Array;
|
||||
private dirty = true;
|
||||
@ -107,6 +116,36 @@ export class Slice {
|
||||
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. */
|
||||
addRind(u: number, v: number): void {
|
||||
if (this.rind.length >= RIND_MAX) return;
|
||||
@ -412,6 +451,7 @@ uniform vec3 uSpreadColor;
|
||||
uniform float uSpreadGloss;
|
||||
uniform float uSpreadOpaqueAt; // spread thickness at which it fully hides the toast
|
||||
uniform float uSpreadBump;
|
||||
uniform float uOil;
|
||||
uniform vec3 uLightDir;
|
||||
uniform vec3 uLightColor;
|
||||
uniform vec3 uAmbientSky;
|
||||
@ -542,6 +582,11 @@ void main() {
|
||||
// a little grain in the film so thin spread doesn't look like flat paint
|
||||
float filmN = fbm(vUv * 70.0 + 13.0);
|
||||
vec3 spreadCol = uSpreadColor * (0.85 + filmN * 0.3);
|
||||
// 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);
|
||||
|
||||
// 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".
|
||||
float gloss = uSpreadGloss * smoothstep(0.008, 0.09, spread) * topFace;
|
||||
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
|
||||
// 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
|
||||
@ -629,6 +676,7 @@ function buildMaterial(bread: Bread, tex: THREE.DataTexture): THREE.ShaderMateri
|
||||
uSpreadGloss: { value: 0.5 },
|
||||
uSpreadOpaqueAt: { value: 1e9 },
|
||||
uSpreadBump: { value: 0.1 },
|
||||
uOil: { value: 0.5 },
|
||||
uLightDir: { value: new THREE.Vector3(0.45, 0.85, 0.35).normalize() },
|
||||
uLightColor: { value: new THREE.Color(1.0, 0.95, 0.86) },
|
||||
uAmbientSky: { value: new THREE.Color(0.26, 0.27, 0.31) },
|
||||
|
||||
@ -45,6 +45,11 @@ export interface Knife {
|
||||
rindLoad: number;
|
||||
/** Fractional shed accumulator — pieces drop one at a time. */
|
||||
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;
|
||||
v: number;
|
||||
hasPrev: boolean;
|
||||
@ -69,11 +74,13 @@ export interface StrokeFx {
|
||||
export function newKnife(): Knife {
|
||||
// Starts inside the window where warm-toast butter actually flows: the very
|
||||
// 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.oil = oil;
|
||||
if (def.particles) {
|
||||
knife.rindLoad = def.particles.perDip;
|
||||
knife.rindFrac = 0;
|
||||
@ -169,7 +176,11 @@ function contact(
|
||||
|
||||
if (knife.angle < SCRAPE_ANGLE) {
|
||||
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 deficit = flowing ? 0 : Math.min(1, (yieldP - pressure) / Math.max(yieldP, 0.001));
|
||||
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
|
||||
// crumb and takes it with it. Some still smears on, badly.
|
||||
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 laid = 0;
|
||||
slice.spread.brush(u, v, radius, (i, w) => {
|
||||
if (mask[i] < 0.5 || massLeft <= 0) return;
|
||||
const blot = 1 - tool.blotch * blotchAt(i);
|
||||
const add = Math.min(rate * w * blot, massLeft);
|
||||
spread[i] += add;
|
||||
massLeft -= add;
|
||||
laid += add;
|
||||
fx.deposited += add;
|
||||
});
|
||||
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
|
||||
// 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.
|
||||
knife.load += (removedSpread * 0.6) / (slice.mask.n * slice.mask.n);
|
||||
slice.recordRemoval(removedSpread);
|
||||
fx.scrapedSpread += removedSpread;
|
||||
fx.scrapedChar += removedChar;
|
||||
fx.gouged += gouged;
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
* 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';
|
||||
|
||||
/**
|
||||
@ -45,6 +45,13 @@ export interface SpreadDef {
|
||||
bump: number;
|
||||
/** Suspended solids, if any. */
|
||||
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. */
|
||||
baseYield: number;
|
||||
@ -84,12 +91,13 @@ export const SPREADS: Record<SpreadId, SpreadDef> = {
|
||||
},
|
||||
peanut: {
|
||||
id: 'peanut',
|
||||
name: 'Peanut Butter',
|
||||
blurb: 'Goes on thick, drags like wet sand, never evens itself out.',
|
||||
name: 'Smooth Peanut Butter',
|
||||
blurb: 'Goes on thick, drags like wet sand — and the jar has been sitting.',
|
||||
color: [0.64, 0.41, 0.18],
|
||||
gloss: 0.3,
|
||||
opaqueAt: 0.16,
|
||||
bump: 0.28,
|
||||
separates: 0.75,
|
||||
baseYield: 0.38,
|
||||
tempSoftening: 0.3,
|
||||
viscosity: 0.96,
|
||||
@ -99,6 +107,32 @@ export const SPREADS: Record<SpreadId, SpreadDef> = {
|
||||
scrapeEase: 0.75,
|
||||
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: {
|
||||
id: 'mitey',
|
||||
name: 'MITEY',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user