Sloped belts hold their load with moving cleats (t42); sloped filters are downhill shaking screens (t43)

The magic grip is gone: cleats every 8 face cells, 6 grains tall, carry
pockets uphill (about 71 grains/s against 90 flat) and overflow rolls
back. Sloped filters always run downhill; grains travel by gravity and
a shake, and sorting stays exact. Drawing needs another pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 19:47:59 -04:00
co-authored by Claude Opus 5.5
parent 1f8a89db6a
commit 578b55316b
28 changed files with 425 additions and 89 deletions
+3 -3
View File
@@ -68,7 +68,7 @@ function placeBlock(code, v, bx, by, free, extra) {
}
if (isPipeCode(code)) pipesDirty = true;
blockP[by * BW + bx] = code; blockV[by * BW + bx] = v;
if (isFilter(code)) { blockV[by * BW + bx] = v & 128; filterCfg.set(by * BW + bx, filterRec(extra)); filtersDirty = true; }
if (isFilter(code)) { blockV[by * BW + bx] = filterVariant(code, v); filterCfg.set(by * BW + bx, filterRec(extra)); filtersDirty = true; }
if (isProcCode(code)) procDirty = true;
if (code === BP_RACK) {
const b = by * BW + bx, s = newRack();
@@ -197,7 +197,7 @@ function blockInReach(bx, by) { return inReach(bx * BS + BS / 2, by * BS + BS /
// Is block b already exactly this piece (same kind, variant and, for a filter, settings)?
function blockIs(b, code, v, extra) {
if (blockP[b] !== code) return false;
if (isFilter(code)) return (blockV[b] & 128) === (v & 128) && sameFilter(filterInfo(b), cleanFilterCfg(extra));
if (isFilter(code)) return (blockV[b] & 128) === filterVariant(code, v) && sameFilter(filterInfo(b), cleanFilterCfg(extra));
return blockV[b] === v;
}
// replace: a block already built in the way is taken down (and refunded) to make room
@@ -208,7 +208,7 @@ function placeBlocks(code, v, list, extra, replace) {
const b = by * BW + bx;
if (replace && blockP[b]) {
if (blockIs(b, code, v, extra)) continue;
if (blockP[b] === code && isFilter(code)) { undoNote({ t: 'filter', bx, by, v: blockV[b], extra: filterInfo(b) }); blockV[b] = v & 128; setFilter(b, extra); filtersDirty = true; n++; continue; } // just re-set it
if (blockP[b] === code && isFilter(code)) { undoNote({ t: 'filter', bx, by, v: blockV[b], extra: filterInfo(b) }); blockV[b] = filterVariant(code, v); setFilter(b, extra); filtersDirty = true; n++; continue; } // just re-set it
if (!canAfford(pieceCost(code, v), 1)) { last = needText(pieceCost(code, v), 1); break; }
removeBlock(bx, by);
}
+16 -6
View File
@@ -36,7 +36,7 @@ function move(i, j, x, y, nx, ny) {
// Can a grain of material m (phase ph) enter cell j moving by (dx,dy)?
// 0 = no, 1 = into empty, 2 = by displacing what is there.
function enter(m, ph, j, dx, dy) {
if (occ[j]) return 0;
if (occ[j] || (ph === PH_POWDER && !cleatCarrying && cleatCells.length && (cleatMask[j] || (dy > 0 && cleatMask[j - dx - dy * W])))) return 0;
const p = part[j];
if (p !== 0) {
if (IS_SCREW[p]) { if (ph === PH_POWDER && SCREW_DX[p] === -dx && SCREW_DY[p] === -dy) return 0; } // liquids and gases flow through freely
@@ -72,20 +72,30 @@ let LSTEP = 8, LFILM = 4;
let CORNER_SEAL = true;
function powder(i, x, y, m) {
if (y >= H - 1) return;
const b = i + W;
const b = i + W, barriers = cleatCells.length;
if (enter(m, PH_POWDER, b, 0, 1)) { move(i, b, x, y, x, y + 1); return; }
// a grain at least twice as dense works its way down through a loose pile. Unlit fuel
// packs too tight for that, so a mixed furnace charge stays mixed until the fire gets in.
const t = mat[b];
if (PH[t] === PH_POWDER && DENS[m] >= DENS[t] * 2 && (!FUEL[t] || (flags[b] & F_BURN)) && stamp[b] !== tickStamp && !occ[b] && part[b] !== P_GRATE && part[b] !== P_FILTER && rnd() < (FUEL[t] ? SORTP_FUEL : SORTP)) { move(i, b, x, y, x, y + 1); awake[(y >> 5) * CW + (x >> 5)] = 40; return; }
if (PH[t] === PH_POWDER && (!barriers || (!cleatMask[i] && !cleatMask[b])) && DENS[m] >= DENS[t] * 2 && (!FUEL[t] || (flags[b] & F_BURN)) && stamp[b] !== tickStamp && !occ[b] && part[b] !== P_GRATE && part[b] !== P_FILTER && rnd() < (FUEL[t] ? SORTP_FUEL : SORTP)) { move(i, b, x, y, x, y + 1); awake[(y >> 5) * CW + (x >> 5)] = 40; return; }
// a grain lying right on a belt or filter is held by it: the surface carries it, it does not slide off the edge
if (t === BELTL || t === BELTR || part[b] === P_FILTER) return;
if ((t === BELTL || t === BELTR || part[b] === P_FILTER) &&
!isSlopeBelt(blockP[((y + 1) >> 2) * BW + (x >> 2)])) return;
// Feed a receiving screw mouth sideways instead of falling under its bottom casing.
if (t === BELTL || t === BELTR) {
const b = ((y + 1) >> 2) * BW + (x >> 2);
const d = isSlopeBelt(blockP[b]) ? (blockV[b] & 1 ? 1 : -1) : 0;
if (d && x + d >= 0 && x + d < W && IS_SCREW[part[i + d]]) {
if (enter(m, PH_POWDER, i + d, d, 0)) move(i, i + d, x, y, x + d, y);
return;
}
}
if (rnd() < STICK[m]) {
const d = (RS & 2) ? 1 : -1;
let nx = x + d;
if (nx >= 0 && nx < W && (!CORNER_SEAL || PH[mat[b]] !== PH_SOLID || PH[mat[i + d]] !== PH_SOLID) && enter(m, PH_POWDER, b + d, d, 1)) { move(i, b + d, x, y, nx, y + 1); return; }
if (nx >= 0 && nx < W && (!CORNER_SEAL || (PH[mat[b]] !== PH_SOLID && (!barriers || !cleatMask[b])) || (PH[mat[i + d]] !== PH_SOLID && (!barriers || !cleatMask[i + d]))) && enter(m, PH_POWDER, b + d, d, 1)) { move(i, b + d, x, y, nx, y + 1); return; }
nx = x - d;
if (nx >= 0 && nx < W && (!CORNER_SEAL || PH[mat[b]] !== PH_SOLID || PH[mat[i - d]] !== PH_SOLID) && enter(m, PH_POWDER, b - d, -d, 1)) { move(i, b - d, x, y, nx, y + 1); return; }
if (nx >= 0 && nx < W && (!CORNER_SEAL || (PH[mat[b]] !== PH_SOLID && (!barriers || !cleatMask[b])) || (PH[mat[i - d]] !== PH_SOLID && (!barriers || !cleatMask[i - d]))) && enter(m, PH_POWDER, b - d, -d, 1)) { move(i, b - d, x, y, nx, y + 1); return; }
}
}
function passable(m, ph, j, dx, dy) {
+9 -14
View File
@@ -19,11 +19,11 @@ function conveyorStep(x, y, d) {
const b = (y >> 2) * BW + (x >> 2);
return beltGrade(blockP[b]) * d;
}
function carryColumn(a, d, pass) {
function carryColumn(a, d, pass, depth = CARRY_DEPTH) {
if (a < 0 || mat[a] === EMPTY) return;
const sy = ((a / W) | 0) + 1, step = conveyorStep(a % W, sy, d);
let blocked = false;
for (let L = 0; L < CARRY_DEPTH; L++, a -= W) {
for (let L = 0; L < depth; L++, a -= W) {
if (a < 0) return;
const m = mat[a];
if (m === EMPTY || (PH[m] === PH_SOLID && !part[a])) return; // the top of the heap, or a ceiling
@@ -53,14 +53,6 @@ function carryColumn(a, d, pass) {
stats.moved++;
}
}
// The running face grips its whole supported load, including on the ticks between steps.
function holdSlopeColumn(a, pass) {
for (let depth = 0; depth < CARRY_DEPTH && a >= 0; depth++, a -= W) {
const m = mat[a];
if (m === EMPTY || (PH[m] === PH_SOLID && !part[a])) return;
if (PH[m] === PH_POWDER && !occ[a] && !(pass && pass[m])) stamp[a] = tickStamp;
}
}
// Belts are perforated: liquid resting on one drips through to whatever open space is under it.
function beltDrain(b) {
const a = b - W, x = b % W;
@@ -93,8 +85,8 @@ function filterCarry(b, d, carrying = true) {
for (let k = 0; k < BS; k++) {
const x = d > 0 ? bx * BS + BS - 1 - k : bx * BS + k, top = beltFaceY(code, bx, by, x) * W + x;
if (part[top] === P_FILTER) {
if (carrying) carryColumn(top - W, d, pass);
if (isSlopeBelt(code)) holdSlopeColumn(top - W, pass);
if (isSlopeBelt(code)) screenNudge(top, d, pass);
else if (carrying) carryColumn(top - W, d, pass);
}
}
}
@@ -114,9 +106,12 @@ function carryBeltsAndFilters(belts, filters, d, bm, carrying) {
if (above === BELTL || above === BELTR || netSat(b) <= 0) continue;
if (carrying) {
if (PH[above] === PH_LIQUID) beltDrain(b);
carryColumn(b - W, d, null);
if (isSlopeBelt(code)) {
cleatCarrying = true;
carryColumn(b - W, d, null, pocketDepth(x, (y >> 2) * BW + (x >> 2)));
cleatCarrying = false;
} else carryColumn(b - W, d, null);
}
if (isSlopeBelt(code)) holdSlopeColumn(b - W, null);
}
}
// Change what a filter does; grains inside it that no longer pass are pushed out on top.
+4 -4
View File
@@ -134,12 +134,12 @@ function entDiagonalScrewTick(e, p) {
function entSlopeBeltTick(e) {
const bottom = e.iy + BAR_H;
if (bottom >= H) return false;
let d = 0, slope = false;
let d = 0, slope = false, screen = false;
for (let x = e.ix; x < e.ix + BAR_W; x++) {
const i = bottom * W + x, b = (bottom >> 2) * BW + (x >> 2), c = blockP[b];
if ((isBelt(c) || isFilter(c)) && entSolidCell(i)) {
d = isFilter(c) ? (blockV[b] & 128 ? 1 : -1) : (blockV[b] ? 1 : -1);
if (isSlopeBelt(c)) slope = true;
if (isSlopeBelt(c)) { slope = true; if (isFilter(c)) screen = true; }
}
}
if (!d) return false;
@@ -150,12 +150,12 @@ function entSlopeBeltTick(e) {
const b = (y >> 2) * BW + (x >> 2), c = blockP[b];
if (!(isBelt(c) || isFilter(c)) || (isFilter(c) && filterPassAt(e.m, y * W + x))) continue;
if (beltFaceY(c, x >> 2, y >> 2, x) !== y) continue;
if (isSlopeBelt(c)) slope = true;
if (isSlopeBelt(c)) { slope = true; if (isFilter(c)) screen = true; }
face = Math.min(face, y);
}
if (!slope) return false;
e.x = e.ix; e.y = e.iy; e.vx = e.vy = 0;
if (tick & 1) return true;
if (screen ? tick % SCREEN_NUDGE_TICKS : tick & 1) return true;
const y = face === H ? e.iy : face - BAR_H, x = e.ix + d;
if (entFreeAt(x, y)) { e.vx = d * 0.5; e.vy = (y - e.iy) * 0.5; e.x = e.ix = x; e.y = e.iy = y; stats.moved++; }
else if (!entScrewEnter(e, d, 0)) entScrewEnter(e, d, Math.sign(y - e.iy), true);
+2 -1
View File
@@ -87,10 +87,11 @@ function machinesTick() {
mm.hot = Math.round(hotT);
}
}
// Half a grain per tick; sloped faces grip their load on the intervening ticks too.
// Belts step every other tick; sloped belts carry only their finite pockets.
if (filtersDirty) rebuildFilters();
carryBeltsAndFilters(beltL, filterL, -1, BELTL, (tick & 1) === 0);
carryBeltsAndFilters(beltR, filterR, 1, BELTR, (tick & 1) === 0);
slopeCleatsTick();
// screws: every loose grain inside steps one cell along, front of the line first, on the same
// beat as belts (every other tick), so a block-wide screw carries as much as a block-deep belt
if ((tick & 1) === 0) for (let p = 1; p < IS_SCREW.length; p++) {
+4 -3
View File
@@ -14,8 +14,9 @@ function isBelt(c) { return c === BP_BELT || c === BP_BELT_UP || c === BP_BELT_D
function beltGrade(c) { return c === BP_BELT_UP || c === BP_FILTER_UP ? -1 : c === BP_BELT_DOWN || c === BP_FILTER_DOWN ? 1 : 0; }
function slopeShape(c) { return isSlopeBelt(c) ? (beltGrade(c) < 0 ? BP_SLOPE_BR : BP_SLOPE_BL) : c; }
function beltFaceY(c, bx, by, x) { return by * BS + (beltGrade(c) < 0 ? 3 - (x - bx * BS) : beltGrade(c) > 0 ? x - bx * BS : 0); }
function filterVariant(c, v) { return beltGrade(c) ? (beltGrade(c) > 0 ? 128 : 0) : v & 128; }
function beltDirection(c, v) {
const d = isFilter(c) ? (v & 128 ? 1 : -1) : (v ? 1 : -1), g = beltGrade(c);
const d = isFilter(c) ? (filterVariant(c, v) & 128 ? 1 : -1) : (v ? 1 : -1), g = beltGrade(c);
return g ? (g * d < 0 ? 'up-' : 'down-') + (d > 0 ? 'right' : 'left') : d > 0 ? 'right' : 'left';
}
function isSlope(c) { return c >= BP_SLOPE_BL && c <= BP_SLOPE_TR; }
@@ -70,11 +71,11 @@ const PIECES = [
{ id: 'wall', code: BP_WALL, name: 'Wall block', kind: 'block', variants: 'wall',
desc: 'A solid block. Diagonal drags make a sloped face; T flips it. For one block, T cycles square and four slope corners. R picks the material: a square costs 16 grains or 2 bars; a slope costs 8 grains or 1 bar.' },
{ id: 'belt', code: BP_BELT, name: 'Belt', kind: 'block', cost: [[CASH, 12]], dirs: ['left', 'right'],
desc: 'Carries loose grains and bars resting on top of it, left or right, in a layer up to a block deep. Water and other liquids drain through it. Drag diagonally to make a sloped belt that carries up or down.' },
desc: 'Carries loose grains and bars resting on top of it, left or right, in a layer up to a block deep. Water and other liquids drain through it. Drag diagonally for a sidewall belt. Moving cleats lift pockets of grains; extra grains roll back.' },
{ id: 'screw', code: BP_SCREW, name: 'Screw conveyor', kind: 'block', cost: [[CASH, 25]], dirs: ['up', 'right', 'down', 'left', 'up-right', 'down-right', 'down-left', 'up-left'],
desc: 'A turning screw inside a tube that draws in loose grains and whole bars and pushes them along, four grains wide. Water and other liquids run straight through it; the screw never carries them. Drag a line to set its direction, including diagonals. R turns a single screw conveyor. Join either end to screw conveyors or belts. Copper softens around 1000°C.' },
{ id: 'filter', code: BP_FILTER, name: 'Filter', kind: 'block', cost: [[CASH, 40]], dirs: ['left', 'right'], filter: true,
desc: 'A stretch of belt with a mesh in it. Put it in a line of belts and it joins them as one belt. Pick materials and a mode: drop them through and carry everything else along, or carry them and drop the rest. Liquids drain through. Drag diagonally to make a sloped filter that carries up or down.' },
desc: 'A shaking mesh that joins belts. Pick materials and a mode: drop them through and carry everything else along, or carry them and drop the rest. Liquids drain through. Drag diagonally for a downhill screen. Grains slide down while it shakes; R keeps a picked slope downhill.' },
{ id: 'shaft', code: BP_SHAFT, name: 'Shaft', kind: 'block', cost: [[LWOOD, 8]], hidden: POWER_FREE,
desc: 'Carries power between machines. Wooden, so keep it away from fire.' },
{ id: 'grate', code: BP_GRATE, name: 'Grate', kind: 'block', cost: [[LCLAY, 8]],
+3 -1
View File
@@ -9,6 +9,7 @@ function serializeWorld() {
const t16 = new Uint16Array(N);
for (let i = 0; i < N; i++) { const v = (temp[i] + TOFF) * 16; t16[i] = v < 0 ? 0 : v > 65535 ? 65535 : v; }
const meta = {
slopePhases: Array.from(slopePhase),
v: SAVE_VERSION, seed: worldSeed, W, H, tick, surfBase, underD, shallowD, lapse, tOff: TOFF,
saplings: saplings.map(s => [s.x, s.y, s.age, s.th, s.seed]),
economy: { money, debt, earned, recent: Array.from(mkRecent, v => Math.round(v)), log: shipLog, orders, ordersDone, nextOrderT, orderSeq },
@@ -102,6 +103,7 @@ function deserializeWorld(meta, buffers) {
if (!dock()) placeDock((W >> 1) + 34); // worlds from before the economy get their trade dock too
ghostBlocks = new Map(meta.ghostBlocks || []); ghostMachines = meta.ghostMachines || []; ghostMolds = meta.ghostMolds || [];
tick = meta.tick || 0;
if (meta.slopePhases) slopePhase.set(meta.slopePhases);
updateRoof(); resetWeather();
if (meta.weather) {
const mw = meta.weather;
@@ -117,7 +119,7 @@ function deserializeWorld(meta, buffers) {
seen = new Uint8Array(NMAT); meta.seen.forEach((v, k) => { if (k < NMAT) seen[k] = v; });
ledger = meta.ledger || {};
Object.assign(stats, meta.stats || {});
powerDirty = true; rebuildPower(); updateRoof();
powerDirty = true; rebuildPower(); slopeCleatsTick(false); updateRoof();
if (!weather.base) weather.base = worldWater(); // older saves: take the water it has now as its starting amount
if (!meta.specks) clearHardSpecks(true); // older saves: tidy away tiny specks of too-hard rock once
rebuildPipes();
+51
View File
@@ -0,0 +1,51 @@
// Cleats are moving barriers, not material cells. A cleat supports a finite pocket; the bare face never holds grains.
const CLEAT_SPACING = 8, CLEAT_HEIGHT = 6; // spacing in face cells, height in grains
const CLEAT_REACH = Math.floor(CLEAT_HEIGHT * Math.SQRT1_2);
const SCREEN_NUDGE_TICKS = 4; // one downhill contact step per four ticks (15 steps/s), plus gravity
let cleatCarrying = false;
let cleatMask = new Uint8Array(0), cleatCells = [], slopePhase = new Uint8Array(0);
function cleatFoot(x, b, phase = slopePhase[b]) {
const d = blockV[b] & 1 ? 1 : -1;
return ((x - d * phase) % CLEAT_SPACING + CLEAT_SPACING) % CLEAT_SPACING === 0;
}
function pocketDepth(x, b) {
const g = beltGrade(blockP[b]), d = blockV[b] & 1 ? 1 : -1;
// A periodic pocket envelope, clipped to two vertical projections of the cleat.
// End pockets keep moving while their cleat rounds the end of the band.
const distance = ((g * (d * slopePhase[b] - x)) % CLEAT_SPACING + CLEAT_SPACING) % CLEAT_SPACING;
return Math.max(0, CLEAT_REACH * 2 - distance);
}
function slopeCleatsTick(advance = true) {
for (const i of cleatCells) { cleatMask[i] = 0; wakeIdx(i); }
cleatCells.length = 0;
// Front first, just like flat transport. Each foot belongs to exactly one face cell.
for (const list of [beltR, beltL]) for (const i of list) {
const x = i % W, y = (i / W) | 0, b = (y >> 2) * BW + (x >> 2), c = blockP[b];
if (!isBelt(c) || !isSlopeBelt(c) || beltFaceY(c, x >> 2, y >> 2, x) !== y) continue;
const d = blockV[b] & 1 ? 1 : -1, g = beltGrade(c), running = netSat(i) > 0;
const phase = (slopePhase[b] + (advance && running && !(tick & 1) ? 1 : 0)) % CLEAT_SPACING;
if (!cleatFoot(x, b, phase)) continue;
for (let h = CLEAT_REACH; h >= 0; h--) {
const xx = x + g * h, yy = y - h - 1;
if (xx < 0 || xx >= W || yy < 0 || yy >= H) continue;
const j = yy * W + xx;
cleatCells.push(j);
}
}
if (advance && !(tick & 1)) for (const list of [beltR, beltL]) for (const i of list) {
const x = i % W, y = (i / W) | 0, b = (y >> 2) * BW + (x >> 2), c = blockP[b];
if ((x & 3) === 0 && isBelt(c) && isSlopeBelt(c) && beltFaceY(c,x>>2,y>>2,x)===y && netSat(i)>0)
slopePhase[b] = (slopePhase[b] + 1) % CLEAT_SPACING;
}
for (const i of cleatCells) { cleatMask[i] = 1; wakeIdx(i); }
}
// A screen shakes only the grains touching its mesh, never the whole column above it.
function screenNudge(top, d, pass) {
if (tick % SCREEN_NUDGE_TICKS) return;
const i = top - W, m = mat[i];
if (i < 0 || PH[m] !== PH_POWDER || stamp[i] === tickStamp || occ[i]) return;
if (pass && pass[m] && enter(m, PH_POWDER, top, 0, 1)) return;
const j = i + d + W;
if (i % W + d < 0 || i % W + d >= W || j >= N) return;
if (enter(m, PH_POWDER, j, d, 1)) move(i, j, i % W, (i / W) | 0, j % W, (j / W) | 0);
}
+2 -1
View File
@@ -42,10 +42,11 @@ function alloc(w, h) {
W = w; H = h; N = w * h;
fogAlloc();
CW = Math.ceil(w / 32); CH = Math.ceil(h / 32); NCH = CW * CH;
cleatMask = new Uint8Array(N); cleatCells = [];
mat = new Uint8Array(N); temp = new Float32Array(N); life = new Uint8Array(N); flags = new Uint8Array(N);
stamp = new Uint8Array(N); part = new Uint8Array(N); mach = new Uint16Array(N); digp = new Uint8Array(N);
noiseB = new Uint8Array(N); bgm = new Uint8Array(N); airS = new Uint16Array(N); netId = new Uint16Array(N); isPow = new Uint8Array(N); occ = new Uint8Array(N);
BW = Math.ceil(w / BS); BH = Math.ceil(h / BS); NB = BW * BH; blockP = new Uint8Array(NB); blockV = new Uint8Array(NB); filtersDirty = true; filterCfg = new Map(); procDirty = true; procState.clear(); rackState.clear();
BW = Math.ceil(w / BS); BH = Math.ceil(h / BS); NB = BW * BH; blockP = new Uint8Array(NB); blockV = new Uint8Array(NB); slopePhase = new Uint8Array(NB); filtersDirty = true; filterCfg = new Map(); procDirty = true; procState.clear(); rackState.clear();
ents = []; flyers = []; ghostBlocks = new Map(); ghostMolds = []; treeBases = []; saplings = [];
surfBase = Math.round(Math.min(h * 0.27, 175)); underD = h - surfBase; shallowD = 0; lapse = 0.36;
surfY = new Int16Array(w); roofY = new Int16Array(w); dirtY = new Int16Array(w); graniteY = new Int16Array(w); basaltY = new Int16Array(w); magmaY = new Int16Array(w);