M7: marmalade — rind, and a judge who scores its distribution
A particulate spread. The jelly is ordinary rheology; the rind is discrete solids that ride the knife and drop off per unit of stroke DISTANCE, not per second — a dabbing knife makes a pile, a moving knife lays a trail, and since the shed rate scales with what's left on the blade, one dip cannot cover the slice. Even rind takes several dips placed with intent. Scraping picks bits back up (60% survive the blade), so a clump can be argued with. The judge scores the scatter with the Clark–Evans nearest-neighbour index over the bread's own area, and quotes it: R near 0 is one clump, 1 is random, above 1 is deliberate. Verified end to end on day 8: dab in a corner 8 bits "one clump (R 0.24)" drags grade to B three placed dips 24 bits "evenly strewn (R 1.17)" A, 8.9/10 First cut shed per-second; even careful strokes dumped the whole load in the first half-second and scored "clumped (R 0.50)". Distance-based shedding is what makes evenness the reward for technique rather than luck. Rind renders as instanced chunks parented to the slice mesh, so it rides the pop, the landing, and the pedestal turn. Points, not a field: the judge wants nearest-neighbour statistics, and those want points. Same sRGB trap as M3, new spot: the instanced material's colour was passed as raw floats, which the renderer reads as linear — candied orange rendered as pale butter. setRGB(..., SRGBColorSpace). New content: day 8 handwritten (Deidre wants a piece in every bite), marmalade in the procedural pool, five rind-district lines for the judge, and a marmalade jar GLB generated on-device like everything else, with a plain-glass fallback while it loads. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
5b09f23c7e
commit
70922bd855
@ -55,6 +55,15 @@ 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.
|
||||||
|
|
||||||
|
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
|
||||||
|
can't cover the slice. The judge scores the scatter with the Clark–Evans
|
||||||
|
nearest-neighbour index (R ≈ 0 one clump, 1 random, above 1 deliberately even)
|
||||||
|
and quotes it on the card: a corner dab reads *"one clump (R 0.24)"*, three
|
||||||
|
well-placed dips read *"evenly strewn (R 1.17)"*. Scraping picks rind back up,
|
||||||
|
so a clump can be argued with rather than started over.
|
||||||
|
|
||||||
## The bread
|
## The bread
|
||||||
|
|
||||||
Same dial, same 18 seconds, four breads:
|
Same dial, same 18 seconds, four breads:
|
||||||
|
|||||||
BIN
public/assets/models/marmalade_jar.glb
Normal file
BIN
public/assets/models/marmalade_jar.glb
Normal file
Binary file not shown.
@ -54,7 +54,10 @@ export function installDevHarness(app: App, game: Game): void {
|
|||||||
},
|
},
|
||||||
|
|
||||||
dip() {
|
dip() {
|
||||||
point(3.15, 0.29, 0.45, true);
|
const hit = (kitchen as unknown as { potHit: THREE.Mesh | null }).potHit;
|
||||||
|
const wp = new THREE.Vector3(3.15, 0.29, 0.45);
|
||||||
|
hit?.getWorldPosition(wp);
|
||||||
|
point(wp.x, wp.y, wp.z, true);
|
||||||
run(4);
|
run(4);
|
||||||
app.input.down = false;
|
app.input.down = false;
|
||||||
app.step(1 / 60);
|
app.step(1 / 60);
|
||||||
|
|||||||
@ -89,6 +89,7 @@ export function judge(slice: Slice, order: Order, usedTool: ToolId, seconds: num
|
|||||||
weight: 1.2,
|
weight: 1.2,
|
||||||
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)] : []),
|
||||||
{
|
{
|
||||||
key: 'amount',
|
key: 'amount',
|
||||||
label: 'The Right Amount',
|
label: 'The Right Amount',
|
||||||
@ -136,6 +137,57 @@ export function judge(slice: Slice, order: Order, usedTool: ToolId, seconds: num
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How evenly the rind is strewn, scored with the Clark–Evans index: the mean
|
||||||
|
* nearest-neighbour distance between bits, over what that distance would be if
|
||||||
|
* the same number of bits were scattered at random. R near 0 = one clump,
|
||||||
|
* R = 1 = random scatter, R above 1 = deliberately even. The judge accepts
|
||||||
|
* random; he rewards deliberate.
|
||||||
|
*/
|
||||||
|
function rindCriterion(slice: Slice, name: string): Criterion {
|
||||||
|
const pts = slice.rind;
|
||||||
|
const n = pts.length;
|
||||||
|
if (n < 4) {
|
||||||
|
return {
|
||||||
|
key: 'rind',
|
||||||
|
label: cap(name),
|
||||||
|
score: n === 0 ? 0.05 : 0.3,
|
||||||
|
weight: 1.1,
|
||||||
|
detail: n === 0 ? `no ${name} at all` : `only ${n} bits of ${name}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Mask area in UV units — the bread is the study region, not the unit square.
|
||||||
|
const area = slice.mask.data.reduce((a, v) => a + (v > 0.5 ? 1 : 0), 0) / (slice.mask.n * slice.mask.n);
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
let best = Infinity;
|
||||||
|
for (let j = 0; j < n; j++) {
|
||||||
|
if (i === j) continue;
|
||||||
|
const du = pts[i].u - pts[j].u;
|
||||||
|
const dv = pts[i].v - pts[j].v;
|
||||||
|
const d2 = du * du + dv * dv;
|
||||||
|
if (d2 < best) best = d2;
|
||||||
|
}
|
||||||
|
sum += Math.sqrt(best);
|
||||||
|
}
|
||||||
|
const observed = sum / n;
|
||||||
|
const expected = 0.5 * Math.sqrt(area / n);
|
||||||
|
const R = observed / expected;
|
||||||
|
const score = clamp01(R / 0.95);
|
||||||
|
const word = R < 0.45 ? 'one clump' : R < 0.75 ? 'clumped' : R < 1.05 ? 'scattered' : 'evenly strewn';
|
||||||
|
return {
|
||||||
|
key: 'rind',
|
||||||
|
label: cap(name),
|
||||||
|
score,
|
||||||
|
weight: 1.1,
|
||||||
|
detail: `${n} bits, ${word} (R ${R.toFixed(2)})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cap(s: string): string {
|
||||||
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
function spreadStdevOverCovered(slice: Slice, floor: number): number {
|
function spreadStdevOverCovered(slice: Slice, floor: number): number {
|
||||||
const sp = slice.spread.data;
|
const sp = slice.spread.data;
|
||||||
const m = slice.mask.data;
|
const m = slice.mask.data;
|
||||||
|
|||||||
@ -106,6 +106,20 @@ const BANK: Bank = {
|
|||||||
'The quantity is correct. Thank you.',
|
'The quantity is correct. Thank you.',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
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.',
|
||||||
|
],
|
||||||
|
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.',
|
||||||
|
],
|
||||||
|
},
|
||||||
tool: {
|
tool: {
|
||||||
bad: [
|
bad: [
|
||||||
'You did this with a {tool}. I can tell. Everyone can tell.',
|
'You did this with a {tool}. I can tell. Everyone can tell.',
|
||||||
|
|||||||
@ -135,12 +135,25 @@ export const DAYS: Order[] = [
|
|||||||
who: 'the inspector himself',
|
who: 'the inspector himself',
|
||||||
text: "Thick sourdough, well done, and a *scrape* of MITEY. I want to see the toast through it.",
|
text: "Thick sourdough, well done, and a *scrape* of MITEY. I want to see the toast through it.",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
day: 8,
|
||||||
|
bread: 'white',
|
||||||
|
spread: 'marmalade',
|
||||||
|
amount: 'normal',
|
||||||
|
browning: 0.55,
|
||||||
|
browningName: 'golden',
|
||||||
|
noChar: true,
|
||||||
|
tool: 'spreader',
|
||||||
|
butterSoftness: 0.5,
|
||||||
|
who: 'Deidre',
|
||||||
|
text: 'Marmalade! And mind the rind, love — a piece in every bite, not a pile in one corner.',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Days 8+ — 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'];
|
const spreads: SpreadId[] = ['butter', 'peanut', 'mitey', 'marmalade'];
|
||||||
const amounts: AmountClass[] = ['thin', 'normal', 'thick'];
|
const amounts: AmountClass[] = ['thin', 'normal', 'thick'];
|
||||||
const tools: ToolId[] = [
|
const tools: ToolId[] = [
|
||||||
'butter_knife',
|
'butter_knife',
|
||||||
@ -155,7 +168,8 @@ export function proceduralOrder(day: number, rand: () => number): Order {
|
|||||||
];
|
];
|
||||||
const pick = <T>(a: T[]): T => a[Math.floor(rand() * a.length)];
|
const pick = <T>(a: T[]): T => a[Math.floor(rand() * a.length)];
|
||||||
const spread = pick(spreads);
|
const spread = pick(spreads);
|
||||||
const amount: AmountClass = spread === 'mitey' ? (rand() < 0.75 ? 'thin' : 'normal') : pick(amounts);
|
const amount: AmountClass =
|
||||||
|
spread === 'mitey' ? (rand() < 0.75 ? 'thin' : 'normal') : pick(amounts);
|
||||||
const browning = 0.3 + rand() * 0.5;
|
const browning = 0.3 + rand() * 0.5;
|
||||||
return {
|
return {
|
||||||
day,
|
day,
|
||||||
@ -167,7 +181,7 @@ export function proceduralOrder(day: number, rand: () => number): Order {
|
|||||||
noChar: rand() < 0.8,
|
noChar: rand() < 0.8,
|
||||||
tool: pick(tools),
|
tool: pick(tools),
|
||||||
// It only gets colder from here.
|
// It only gets colder from here.
|
||||||
butterSoftness: Math.max(0, 0.4 - (day - 8) * 0.08) * rand(),
|
butterSoftness: Math.max(0, 0.4 - (day - 9) * 0.08) * rand(),
|
||||||
who: pick(['Deidre', 'Ray', 'a regular', 'someone new', 'the inspector himself']),
|
who: pick(['Deidre', 'Ray', 'a regular', 'someone new', 'the inspector himself']),
|
||||||
text: '',
|
text: '',
|
||||||
};
|
};
|
||||||
|
|||||||
@ -86,8 +86,36 @@ export function makeSpreadPot(def: SpreadDef): { root: THREE.Group; hit: THREE.M
|
|||||||
hit.position.y = isButter ? 0.34 : 0.56;
|
hit.position.y = isButter ? 0.34 : 0.56;
|
||||||
g.add(hit);
|
g.add(hit);
|
||||||
|
|
||||||
const file = isButter ? 'butter_dish' : def.id === 'peanut' ? 'pb_jar' : 'mitey_jar';
|
const file =
|
||||||
void loadProp(`/assets/models/${file}.glb`, isButter ? 1.5 : 1.15).then((prop) => g.add(prop));
|
isButter ? 'butter_dish'
|
||||||
|
: def.id === 'peanut' ? 'pb_jar'
|
||||||
|
: def.id === 'marmalade' ? 'marmalade_jar'
|
||||||
|
: 'mitey_jar';
|
||||||
|
loadProp(`/assets/models/${file}.glb`, isButter ? 1.5 : 1.15)
|
||||||
|
.then((prop) => g.add(prop))
|
||||||
|
.catch(() => {
|
||||||
|
// Asset still generating (or missing): a plain glass jar of the right
|
||||||
|
// colour keeps the order playable.
|
||||||
|
const jar = new THREE.Mesh(
|
||||||
|
new THREE.CylinderGeometry(0.42, 0.4, 0.66, 24),
|
||||||
|
new THREE.MeshStandardMaterial({
|
||||||
|
color: 0xdfe6e6,
|
||||||
|
roughness: 0.12,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.42,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
jar.position.y = 0.33;
|
||||||
|
const fill = new THREE.Mesh(
|
||||||
|
new THREE.CylinderGeometry(0.36, 0.34, 0.5, 24),
|
||||||
|
new THREE.MeshStandardMaterial({
|
||||||
|
color: new THREE.Color(def.color[0], def.color[1], def.color[2]),
|
||||||
|
roughness: 0.35,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
fill.position.y = 0.29;
|
||||||
|
g.add(jar, fill);
|
||||||
|
});
|
||||||
|
|
||||||
return { root: g, hit };
|
return { root: g, hit };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,9 @@ export const FIELD_N = 128;
|
|||||||
/** Past this, the surface has stopped being toast and started being charcoal. */
|
/** Past this, the surface has stopped being toast and started being charcoal. */
|
||||||
export const CHAR_THRESHOLD = 0.85;
|
export const CHAR_THRESHOLD = 0.85;
|
||||||
|
|
||||||
|
/** Hard cap on rind bits — also the instanced mesh's allocation. */
|
||||||
|
export const RIND_MAX = 96;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One slice of bread: the mesh, the four scalar fields the whole game reads and
|
* One slice of bread: the mesh, the four scalar fields the whole game reads and
|
||||||
* writes, and the shader that turns them into something appetising (or not).
|
* writes, and the shader that turns them into something appetising (or not).
|
||||||
@ -44,6 +47,15 @@ export class Slice {
|
|||||||
readonly sizeZ: number;
|
readonly sizeZ: number;
|
||||||
readonly halfThickness: number;
|
readonly halfThickness: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Solid bits sitting on the spread (marmalade rind), in UV space. Discrete
|
||||||
|
* points rather than a fifth field: the judge scores their *distribution*, and
|
||||||
|
* nearest-neighbour statistics want points, not a raster.
|
||||||
|
*/
|
||||||
|
readonly rind: { u: number; v: number }[] = [];
|
||||||
|
private rindMesh: THREE.InstancedMesh | null = null;
|
||||||
|
private rindDirty = false;
|
||||||
|
|
||||||
private tex: THREE.DataTexture;
|
private tex: THREE.DataTexture;
|
||||||
private texData: Uint8Array;
|
private texData: Uint8Array;
|
||||||
private dirty = true;
|
private dirty = true;
|
||||||
@ -95,12 +107,91 @@ export class Slice {
|
|||||||
return this.mesh.position.y + this.halfThickness;
|
return this.mesh.position.y + this.halfThickness;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
this.rind.push({ u, v });
|
||||||
|
this.rindDirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scrape rind off within `radius` (UV units) of (u,v). Returns how many went. */
|
||||||
|
removeRindNear(u: number, v: number, radius: number): number {
|
||||||
|
const r2 = radius * radius;
|
||||||
|
let removed = 0;
|
||||||
|
for (let i = this.rind.length - 1; i >= 0; i--) {
|
||||||
|
const p = this.rind[i];
|
||||||
|
const du = p.u - u;
|
||||||
|
const dv = p.v - v;
|
||||||
|
if (du * du + dv * dv <= r2) {
|
||||||
|
this.rind.splice(i, 1);
|
||||||
|
removed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (removed) this.rindDirty = true;
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearRind(): void {
|
||||||
|
if (this.rind.length) this.rindDirty = true;
|
||||||
|
this.rind.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuild the rind instances. The instanced mesh is a child of the slice mesh
|
||||||
|
* and positioned in its local space, so rind rides along when the toast flies,
|
||||||
|
* lands, and turns on the judge's pedestal.
|
||||||
|
*/
|
||||||
|
private syncRind(): void {
|
||||||
|
if (!this.rindDirty) return;
|
||||||
|
this.rindDirty = false;
|
||||||
|
if (!this.rindMesh) {
|
||||||
|
if (this.rind.length === 0) return;
|
||||||
|
const def = this.spreadDef?.particles;
|
||||||
|
const s = def?.size ?? 0.05;
|
||||||
|
const geo = new THREE.BoxGeometry(s * 1.6, s * 0.55, s * 0.9);
|
||||||
|
// The palette is authored in sRGB; passing the floats raw would have the
|
||||||
|
// material read them as linear and render candied orange as pale butter —
|
||||||
|
// the same trap as the slice shader's albedo.
|
||||||
|
const col = new THREE.Color().setRGB(
|
||||||
|
...(def?.color ?? ([0.6, 0.25, 0.05] as [number, number, number])),
|
||||||
|
THREE.SRGBColorSpace,
|
||||||
|
);
|
||||||
|
const mat = new THREE.MeshStandardMaterial({ color: col, roughness: 0.38 });
|
||||||
|
this.rindMesh = new THREE.InstancedMesh(geo, mat, RIND_MAX);
|
||||||
|
this.rindMesh.castShadow = true;
|
||||||
|
this.mesh.add(this.rindMesh);
|
||||||
|
}
|
||||||
|
const m = new THREE.Matrix4();
|
||||||
|
const q = new THREE.Quaternion();
|
||||||
|
const pos = new THREE.Vector3();
|
||||||
|
const scl = new THREE.Vector3();
|
||||||
|
const axis = new THREE.Vector3(0, 1, 0);
|
||||||
|
for (let i = 0; i < this.rind.length; i++) {
|
||||||
|
const p = this.rind[i];
|
||||||
|
pos.set(
|
||||||
|
(p.u - 0.5) * this.sizeX,
|
||||||
|
this.halfThickness + 0.008,
|
||||||
|
(0.5 - p.v) * this.sizeZ,
|
||||||
|
);
|
||||||
|
// Deterministic per-index jitter so the pieces read as strewn, not stamped.
|
||||||
|
const h = Math.sin(i * 127.1) * 43758.5453;
|
||||||
|
const r = h - Math.floor(h);
|
||||||
|
q.setFromAxisAngle(axis, r * Math.PI * 2);
|
||||||
|
scl.setScalar(0.75 + r * 0.6);
|
||||||
|
m.compose(pos, q, scl);
|
||||||
|
this.rindMesh.setMatrixAt(i, m);
|
||||||
|
}
|
||||||
|
this.rindMesh.count = this.rind.length;
|
||||||
|
this.rindMesh.instanceMatrix.needsUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
get uniforms() {
|
get uniforms() {
|
||||||
return this.material.uniforms;
|
return this.material.uniforms;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Push the sim fields into the GPU texture. Cheap: 64KB. */
|
/** Push the sim fields into the GPU texture. Cheap: 64KB. */
|
||||||
sync(): void {
|
sync(): void {
|
||||||
|
this.syncRind();
|
||||||
if (!this.dirty) return;
|
if (!this.dirty) return;
|
||||||
const d = this.texData;
|
const d = this.texData;
|
||||||
const b = this.browning.data;
|
const b = this.browning.data;
|
||||||
@ -163,6 +254,10 @@ export class Slice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
|
if (this.rindMesh) {
|
||||||
|
this.rindMesh.geometry.dispose();
|
||||||
|
(this.rindMesh.material as THREE.Material).dispose();
|
||||||
|
}
|
||||||
this.mesh.geometry.dispose();
|
this.mesh.geometry.dispose();
|
||||||
this.material.dispose();
|
this.material.dispose();
|
||||||
this.tex.dispose();
|
this.tex.dispose();
|
||||||
|
|||||||
@ -41,6 +41,10 @@ export interface Knife {
|
|||||||
angle: number;
|
angle: number;
|
||||||
/** Spread carried on the blade, in mean-thickness units (same as the field). */
|
/** Spread carried on the blade, in mean-thickness units (same as the field). */
|
||||||
load: number;
|
load: number;
|
||||||
|
/** Solid bits (rind) riding the blade, in whole pieces. */
|
||||||
|
rindLoad: number;
|
||||||
|
/** Fractional shed accumulator — pieces drop one at a time. */
|
||||||
|
rindFrac: number;
|
||||||
u: number;
|
u: number;
|
||||||
v: number;
|
v: number;
|
||||||
hasPrev: boolean;
|
hasPrev: boolean;
|
||||||
@ -65,11 +69,15 @@ 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, u: 0.5, v: 0.5, hasPrev: false };
|
return { angle: 0.45, load: 0, rindLoad: 0, rindFrac: 0, u: 0.5, v: 0.5, hasPrev: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function dip(knife: Knife, def: SpreadDef): void {
|
export function dip(knife: Knife, def: SpreadDef): void {
|
||||||
knife.load = def.pickup;
|
knife.load = def.pickup;
|
||||||
|
if (def.particles) {
|
||||||
|
knife.rindLoad = def.particles.perDip;
|
||||||
|
knife.rindFrac = 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cheap per-texel hash, for blotchy tools. */
|
/** Cheap per-texel hash, for blotchy tools. */
|
||||||
@ -183,6 +191,26 @@ function contact(
|
|||||||
});
|
});
|
||||||
knife.load = Math.max(0, massLeft / (slice.mask.n * slice.mask.n));
|
knife.load = Math.max(0, massLeft / (slice.mask.n * slice.mask.n));
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// even trail, a dabbing knife piles everything into one spot, and because
|
||||||
|
// the rate scales with what's left on the blade, one dip can't cover the
|
||||||
|
// slice — even rind takes several dips, placed with intent.
|
||||||
|
if (def.particles && knife.rindLoad > 0.01) {
|
||||||
|
knife.rindFrac += knife.rindLoad * def.particles.shedRate * speed * dt;
|
||||||
|
while (knife.rindFrac >= 1 && knife.rindLoad >= 1) {
|
||||||
|
knife.rindFrac -= 1;
|
||||||
|
knife.rindLoad -= 1;
|
||||||
|
const h1 = blotchAt(slice.rind.length * 7 + 1);
|
||||||
|
const h2 = blotchAt(slice.rind.length * 13 + 5);
|
||||||
|
const ang = h1 * Math.PI * 2;
|
||||||
|
const dist = Math.sqrt(h2) * radius * 0.8;
|
||||||
|
const ru = u + Math.cos(ang) * dist;
|
||||||
|
const rv = v + Math.sin(ang) * dist;
|
||||||
|
if (slice.mask.sample(ru, rv) > 0.5) slice.addRind(ru, rv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!flowing && moving > 0.05) {
|
if (!flowing && moving > 0.05) {
|
||||||
const tear = TEAR_RATE * deficit * moving * tool.tearProne * dt;
|
const tear = TEAR_RATE * deficit * moving * tool.tearProne * dt;
|
||||||
slice.damage.brush(u, v, radius, (i, w) => {
|
slice.damage.brush(u, v, radius, (i, w) => {
|
||||||
@ -197,6 +225,11 @@ function contact(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- scrape ---
|
// --- scrape ---
|
||||||
|
// The blade takes rind with it too — some of it survives the trip and can be
|
||||||
|
// redistributed, which is how you fix a clump without starting over.
|
||||||
|
const rindOff = slice.removeRindNear(u, v, radius * 1.15);
|
||||||
|
if (rindOff > 0) knife.rindLoad += rindOff * 0.6;
|
||||||
|
|
||||||
let removedSpread = 0;
|
let removedSpread = 0;
|
||||||
let removedChar = 0;
|
let removedChar = 0;
|
||||||
let gouged = 0;
|
let gouged = 0;
|
||||||
|
|||||||
@ -8,9 +8,31 @@
|
|||||||
* 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';
|
export type SpreadId = 'butter' | 'peanut' | 'mitey' | 'marmalade';
|
||||||
export type AmountClass = 'thin' | 'normal' | 'thick';
|
export type AmountClass = 'thin' | 'normal' | 'thick';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Solid bits suspended in a spread — marmalade rind. They ride the knife with
|
||||||
|
* the jelly, drop off as the blade travels, and the judge scores how evenly
|
||||||
|
* they ended up scattered. Dabbing makes a pile; a moving knife lays a trail;
|
||||||
|
* covering the whole slice takes several dips placed with intent.
|
||||||
|
*/
|
||||||
|
export interface ParticleDef {
|
||||||
|
/** What the judge calls them. */
|
||||||
|
name: string;
|
||||||
|
color: [number, number, number];
|
||||||
|
/** World-space size of a bit. */
|
||||||
|
size: number;
|
||||||
|
/** How many the knife picks up per dip. */
|
||||||
|
perDip: number;
|
||||||
|
/**
|
||||||
|
* Fraction of the carried bits shed per WORLD-UNIT of stroke, at a full load.
|
||||||
|
* Distance-based on purpose: a moving knife lays an even trail, a dabbing one
|
||||||
|
* makes a pile.
|
||||||
|
*/
|
||||||
|
shedRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SpreadDef {
|
export interface SpreadDef {
|
||||||
id: SpreadId;
|
id: SpreadId;
|
||||||
name: string;
|
name: string;
|
||||||
@ -21,6 +43,8 @@ export interface SpreadDef {
|
|||||||
opaqueAt: number;
|
opaqueAt: number;
|
||||||
/** Surface relief strength. */
|
/** Surface relief strength. */
|
||||||
bump: number;
|
bump: number;
|
||||||
|
/** Suspended solids, if any. */
|
||||||
|
particles?: ParticleDef;
|
||||||
|
|
||||||
/** Pressure needed to make it flow, before any warming. */
|
/** Pressure needed to make it flow, before any warming. */
|
||||||
baseYield: number;
|
baseYield: number;
|
||||||
@ -92,6 +116,34 @@ export const SPREADS: Record<SpreadId, SpreadDef> = {
|
|||||||
scrapeEase: 1.25,
|
scrapeEase: 1.25,
|
||||||
amounts: { thin: [0.03, 0.11], normal: [0.14, 0.28], thick: [0.32, 0.6] },
|
amounts: { thin: [0.03, 0.11], normal: [0.14, 0.28], thick: [0.32, 0.6] },
|
||||||
},
|
},
|
||||||
|
marmalade: {
|
||||||
|
id: 'marmalade',
|
||||||
|
name: 'Marmalade',
|
||||||
|
blurb: 'The jelly is easy. The rind goes where it wants — your job is to argue.',
|
||||||
|
color: [0.91, 0.45, 0.08],
|
||||||
|
gloss: 0.95,
|
||||||
|
// Jelly stays translucent even laid on thick — you always see toast through it.
|
||||||
|
opaqueAt: 0.85,
|
||||||
|
bump: 0.05,
|
||||||
|
particles: {
|
||||||
|
name: 'rind',
|
||||||
|
color: [0.62, 0.24, 0.04],
|
||||||
|
size: 0.055,
|
||||||
|
perDip: 9,
|
||||||
|
// ~0.12 world-units between bits at a full load — and wider as the blade
|
||||||
|
// empties, so one dip can't do the whole slice. Several dips, placed with
|
||||||
|
// intent, is the skill.
|
||||||
|
shedRate: 0.95,
|
||||||
|
},
|
||||||
|
baseYield: 0.2,
|
||||||
|
tempSoftening: 0.45,
|
||||||
|
viscosity: 0.55,
|
||||||
|
pickup: 0.62,
|
||||||
|
transferRate: 3.2,
|
||||||
|
drag: 0.35,
|
||||||
|
scrapeEase: 1.1,
|
||||||
|
amounts: { thin: [0.08, 0.18], normal: [0.22, 0.42], thick: [0.48, 0.8] },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Knife angle past which contact narrows enough to scrape instead of spread. */
|
/** Knife angle past which contact narrows enough to scrape instead of spread. */
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user