Sidewall belt and screen drawing, cheaper cleat checks (t42, t43)

Far sidewall drawn behind the load with cleat ribs at their physics
positions; sloped screens get a tinted mesh between rails and shake only
when loaded. Powder skips cleat checks outside chunks that have cleats,
and empty slopes let their chunks sleep. Record the screw conveyor and
stall/carry-depth decisions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 20:08:21 -04:00
co-authored by Claude Opus 5.5
parent 578b55316b
commit 3ee85429fd
14 changed files with 248 additions and 59 deletions
+17 -3
View File
@@ -39,6 +39,22 @@ vec3 slopeShape(vec2 p, vec2 worldOrigin, float grade, int joins) {
`;
const SM_SLOPE_DRAW = `
if (nc == 31 || nc == 32) {
vec4 tag = cellAt(tTree, c);
float grade = nc == 31 ? -1.0 : 1.0;
vec2 local = floor(tag.gb * 255.0 + 0.5) - 8.0 + fract(w);
float sy = grade < 0.0 ? 4.0 : 0.0;
float foot = (local.x + grade * (local.y - sy)) * 0.5;
float depth = (grade * local.x - local.y + sy) * 0.70710678;
int wallFlags = int(tag.a * 255.0 + 0.5);
float ends = max((wallFlags & 8) == 0 ? -foot : -100.0, (wallFlags & 16) == 0 ? foot - 4.0 : -100.0);
float sd = max(ends * 1.41421356, max(-depth, depth - ${CLEAT_HEIGHT.toFixed(1)}));
float phase = float(wallFlags & 7);
float wave = 0.5 + 0.5 * sin((foot + w.x - local.x - phase) * 3.14159265);
vec3 rubber = mix(vec3(0.16,0.18,0.21), vec3(0.21,0.23,0.27), wave);
C = mix(C, rubber, 1.0 - smoothstep(-px, px, sd));
}
// Slopes use the flat belt's band, rollers and mesh in coordinates along the run.
if ((nc == 2 || (nc >= 23 && nc <= 30)) &&
(int(cellAt(tTree, c).a * 255.0 + 0.5) & 128) != 0 &&
@@ -58,9 +74,7 @@ const SM_SLOPE_DRAW = `
float rg = step(0.72, fract((axis - dir * (depth < 2.0 ? 1.0 : -1.0) * time * 30.0) * 0.5));
vec3 belt;
if (-sd < (mesh ? 1.1 : 0.8)) {
belt = bandColor(lb, rg);
if (mesh && ((joins & 32) != 0) == (depth < 2.0) && dot(own.rgb, vec3(1)) > 0.01)
belt = mix(belt, own.rgb * (depth < 2.0 ? 1.0 : 0.8), 0.75 * rg);
belt = mesh ? mix(vec3(0.22,0.25,0.29), vec3(0.48,0.53,0.59), depth < 2.0 ? 0.85 : 0.35) : bandColor(lb, rg);
belt *= mix(0.55, 1.0, smoothstep(0.0, max(px * 1.4, 0.12), -sd));
} else if (mesh) {
vec2 cg = abs(fract(alongWorld) - 0.5);
+36 -10
View File
@@ -135,16 +135,42 @@ function screenLoaded(b) {
}
return false;
}
// Far sidewalls stay behind the load. The cleat raster is the simulation barrier raster.
function slopeDetailAt(i, x, y) {
// Filled far wall, in belt coordinates. Project onto the face so ends stop at the slope ends.
let slopeWallOwner = -1;
function slopeWallShape(x, y, b, code = blockP[b]) {
const g = beltGrade(code), ox = b % BW * BS, oy = ((b / BW) | 0) * BS;
const sy = g < 0 ? BS : 0, lx = x - ox, ly = y - oy - sy;
const foot = (lx + g * ly) / 2, depth = (g * lx - ly) * Math.SQRT1_2;
return Math.max(-foot * Math.SQRT2, (foot - BS) * Math.SQRT2, -depth, depth - CLEAT_HEIGHT);
}
function slopeWallAt(x, y, smooth = false) {
slopeWallOwner = -1;
let best = smooth ? 0.8 : 0;
for (let by = Math.max(0, (y >> 2) - 1); by <= Math.min(BH - 1, (y >> 2) + 3); by++)
for (let bx = Math.max(0, (x >> 2) - 2); bx <= Math.min(BW - 1, (x >> 2) + 2); bx++) {
const b = by * BW + bx;
if (!isBelt(blockP[b]) || !isSlopeBelt(blockP[b])) continue;
const sd = slopeWallShape(x + 0.5, y + 0.5, b);
if (sd <= best) { best = sd; slopeWallOwner = b; }
}
return slopeWallOwner;
}
// The load occludes the far wall; ribs use exactly the simulation barrier raster.
function slopeDetailAt(i, x, y, smooth = false) {
slopeWallOwner = -1;
if (!slopeBeltHalo[(y >> 2) * BW + (x >> 2)] && !cleatMask[i]) return 0;
if (occ[i] || mach[i] || (mat[i] !== EMPTY && PH[mat[i]] !== PH_GAS) || part[i]) return 0;
if (cleatMask[i]) return 1;
for (let by = y >> 2; by <= Math.min(BH - 1, (y + 9) >> 2); by++) {
const b = by * BW + (x >> 2), c = blockP[b];
if (!isSlopeBelt(c)) continue;
const depth = beltFaceY(c, x >> 2, by, x) - y;
if (isFilter(c) ? depth === 2 : depth >= 6 && depth <= 7 + ((x + slopePhase[b]) & 1)) return isFilter(c) ? 3 : 2;
if (occ[i] || mach[i] || part[i]) return 0;
if (cleatMask[i] && PH[mat[i]] !== PH_SOLID) return 1;
if (mat[i] !== EMPTY && PH[mat[i]] !== PH_GAS) return 0;
return slopeWallAt(x, y, smooth) >= 0 ? 2 : 0;
}
function slopePreviewCleat(x, y, b, code) {
const bx = b % BW, by = (b / BW) | 0, g = beltGrade(code);
for (let h = 0; h <= CLEAT_REACH; h++) {
const foot = x - g * h;
if (foot >= bx * BS && foot < (bx + 1) * BS && foot % CLEAT_SPACING === 0 &&
y + h + 1 === beltFaceY(code, bx, by, foot)) return true;
}
return 0;
return false;
}
+16 -7
View File
@@ -289,11 +289,12 @@ function drawWorld() {
const color = slopeBeltColor(sb, x, y, anim, br, bgc, bb);
r = color & 255; g = color >> 8 & 255; b = color >> 16 & 255;
}
const slopeDetail = slopeDetailAt(i, x, y);
const slopeDetail = slopeDetailAt(i, x, y, sm);
if (slopeDetail) {
r = slopeDetail === 1 ? 145 : slopeDetail === 3 ? 96 : 48;
g = slopeDetail === 1 ? 125 : slopeDetail === 3 ? 106 : 52;
b = slopeDetail === 1 ? 94 : slopeDetail === 3 ? 120 : 60;
const wave = slopeDetail === 2 && ((x + beltGrade(blockP[slopeWallOwner]) * y - slopePhase[slopeWallOwner]) & 3) < 2;
r = slopeDetail === 1 ? 100 : wave ? 51 : 43;
g = slopeDetail === 1 ? 104 : wave ? 55 : 47;
b = slopeDetail === 1 ? 112 : wave ? 63 : 55;
}
// incandescence
if (T > 450 && m !== EMPTY && incand) {
@@ -345,7 +346,13 @@ function drawWorld() {
((y - ((sb / BW) | 0) * BS + 8) << 16) | ((x - (sb % BW) * BS + 8) << 8);
if (slopeArt) { r = fm ? COLR[fm] : 0; g = fm ? COLG[fm] : 0; b = fm ? COLB[fm] : 0; }
}
if (slopeDetail) cl = 4;
if (slopeDetail === 1) cl = 4;
else if (slopeDetail === 2) {
const wb = slopeWallOwner;
cl = beltGrade(blockP[wb]) < 0 ? 31 : 32;
tpx[row + bx] = ((128 | slopePhase[wb] | ((slopeBeltJoins[wb] & 12) << 1)) << 24) | cl |
((y - ((wb / BW) | 0) * BS + 8) << 16) | ((x - wb % BW * BS + 8) << 8);
}
top = cl << 24;
fpx[row + bx] = (tb ? 0xff000000 : 0) | (cl === 5 ? 0xff0000 : 0) | (cl === 3 ? 0xff00 : 0) | (cl === 1 && !builtForLight(i, x, y) ? 0xff : 0);
}
@@ -396,7 +403,7 @@ function drawWorld() {
if (blocked) { tr = 230; tg = 70; tb = 60; }
else if (isWall(code) && WALL_OF[v]) { const wm = WALL_OF[v]; tr = (COLR[wm] + 90) / 2; tg = (COLG[wm] + 190) / 2; tb = (COLB[wm] + 230) / 2; }
const slope = isSlopeBelt(code), joins = slope ? slopeBeltConnections(gbx, gby, code, ghostBlocks) : 0;
for (let dy = slope ? -4 : 0; dy < (slope ? 8 : BS); dy++) for (let dx = slope ? -4 : 0; dx < (slope ? 8 : BS); dx++) {
for (let dy = slope ? -9 : 0; dy < (slope ? 8 : BS); dy++) for (let dx = slope ? -5 : 0; dx < (slope ? 9 : BS); dx++) {
let sd = 0;
if (slope) {
const x = gbx * BS + dx, y = gby * BS + dy;
@@ -404,7 +411,9 @@ function drawWorld() {
const i = y * W + x, n = (y >> 2) * BW + (x >> 2);
if (n !== b && (mat[i] !== EMPTY || part[i] || mach[i] || occ[i] || blockP[n])) continue;
sd = slopeBeltShape(x + 0.5, y + 0.5, b, joins, code);
if (sd > 0) continue;
const rib = !isFilter(code) && slopePreviewCleat(x, y, b, code);
if (sd > 0 && !rib && (isFilter(code) || slopeWallShape(x + 0.5, y + 0.5, b, code) > 0)) continue;
if (rib) sd = -0.1;
}
if (isSlope(code) && !slopeCell(code, dx, dy)) continue;
const sx = gbx * BS + dx - vx0, sy = gby * BS + dy - vy0;
+14 -9
View File
@@ -36,7 +36,12 @@ 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] || (ph === PH_POWDER && !cleatCarrying && cleatCells.length && (cleatMask[j] || (dy > 0 && cleatMask[j - dx - dy * W])))) return 0;
if (ph === PH_POWDER && !cleatCarrying && cleatCells.length && (cleatMask[j] || (dy > 0 && cleatMask[j - dx - dy * W]))) return 0;
return enterFree(m, ph, j, dx, dy);
}
// Powder already knows whether its chunk has barriers; other callers use enter.
function enterFree(m, ph, j, dx, dy) {
if (occ[j]) 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
@@ -70,10 +75,10 @@ const SORTP_FUEL = 0.003;
// World generation settles with both at 1 and corner sealing off, so seeds keep the same world.
let LSTEP = 8, LFILM = 4;
let CORNER_SEAL = true;
function powder(i, x, y, m) {
function powder(i, x, y, m, barriers = cleatChunks[(y >> 5) * CW + (x >> 5)]) {
if (y >= H - 1) return;
const b = i + W, barriers = cleatCells.length;
if (enter(m, PH_POWDER, b, 0, 1)) { move(i, b, x, y, x, y + 1); return; }
const b = i + W;
if ((!barriers || (!cleatMask[i] && !cleatMask[b])) && enterFree(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];
@@ -86,16 +91,16 @@ function powder(i, x, y, m) {
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);
if ((!barriers || !cleatMask[i + d]) && enterFree(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 && (!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; }
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]))) && (!barriers || (!cleatMask[i] && !cleatMask[b + d])) && enterFree(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 && (!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; }
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]))) && (!barriers || (!cleatMask[i] && !cleatMask[b - d])) && enterFree(m, PH_POWDER, b - d, -d, 1)) { move(i, b - d, x, y, nx, y + 1); return; }
}
}
function passable(m, ph, j, dx, dy) {
@@ -198,7 +203,7 @@ function simMove() {
const cx = ltr ? k : CW - 1 - k;
if (!awake[rowBase + cx]) continue;
roofDirty[cx] = 1;
const x0 = cx << 5, x1 = Math.min(W, x0 + 32);
const x0 = cx << 5, x1 = Math.min(W, x0 + 32), barriers = cleatChunks[rowBase + cx];
// alternate rows scan left to right and right to left, so nothing drifts one way
const dx = ltr ? 1 : -1, xEnd = ltr ? x1 : x0 - 1;
for (let x = ltr ? x0 : x1 - 1; x !== xEnd; x += dx) {
@@ -210,7 +215,7 @@ function simMove() {
if (ph === PH_SOLID) continue;
const p = part[i];
if (IS_SCREW[p] && ph === PH_POWDER) continue; // a screw conveyor moves its own grains
if (ph === PH_POWDER) powder(i, x, y, m); else if (ph === PH_LIQUID) liquid(i, x, y, m); else gas(i, x, y, m);
if (ph === PH_POWDER) powder(i, x, y, m, barriers); else if (ph === PH_LIQUID) liquid(i, x, y, m); else gas(i, x, y, m);
}
}
}
+17 -2
View File
@@ -3,6 +3,7 @@ const CLEAT_SPACING = 8, CLEAT_HEIGHT = 6; // spacing in face cells, height in g
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 cleatChunks = new Uint8Array(0);
let cleatMask = new Uint8Array(0), cleatCells = [], slopePhase = new Uint8Array(0);
function cleatFoot(x, b, phase = slopePhase[b]) {
const d = blockV[b] & 1 ? 1 : -1;
@@ -16,7 +17,8 @@ function pocketDepth(x, b) {
return Math.max(0, CLEAT_REACH * 2 - distance);
}
function slopeCleatsTick(advance = true) {
for (const i of cleatCells) { cleatMask[i] = 0; wakeIdx(i); }
for (const i of cleatCells) { cleatMask[i] = 0; wakeCleatLoad(i); }
cleatChunks.fill(0);
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) {
@@ -37,7 +39,12 @@ function slopeCleatsTick(advance = true) {
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); }
for (const i of cleatCells) {
cleatMask[i] = 1; wakeCleatLoad(i);
const x = i % W, y = (i / W) | 0;
for (let cy = Math.max(0, (y - 1) >> 5); cy <= Math.min(CH - 1, (y + 1) >> 5); cy++)
for (let cx = Math.max(0, (x - 1) >> 5); cx <= Math.min(CW - 1, (x + 1) >> 5); cx++) cleatChunks[cy * CW + cx] = 1;
}
}
// A screen shakes only the grains touching its mesh, never the whole column above it.
function screenNudge(top, d, pass) {
@@ -49,3 +56,11 @@ function screenNudge(top, d, pass) {
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);
}
// Only loose load can react to a barrier appearing or disappearing.
function wakeCleatLoad(i) {
const x = i % W, y = (i / W) | 0;
for (let yy = Math.max(0, y - 1); yy <= Math.min(H - 1, y + 1); yy++)
for (let xx = Math.max(0, x - 1); xx <= Math.min(W - 1, x + 1); xx++)
if (PH[mat[yy * W + xx]] === PH_POWDER || occ[yy * W + xx]) wakeXY(xx, yy);
}
+1
View File
@@ -52,6 +52,7 @@ function alloc(w, h) {
surfY = new Int16Array(w); roofY = new Int16Array(w); dirtY = new Int16Array(w); graniteY = new Int16Array(w); basaltY = new Int16Array(w); magmaY = new Int16Array(w);
AMB = new Float32Array(h);
loadWakeOrder = loadWakePending = null; loadWakeIndex = 0;
cleatChunks = new Uint8Array(NCH);
awake = new Uint8Array(NCH); hot = new Uint8Array(NCH); hotLvl = new Uint8Array(NCH);
roofDirty = new Uint8Array(CW).fill(1);
powderArea = new Uint8Array(NCH).fill(1); // unknown areas get one visit before being ruled out
+2 -2
View File
@@ -12,8 +12,8 @@ const PIECE_ICONS = {
wall: '<rect x="3" y="5" width="18" height="14" rx="1"/><path d="M3 12h18M9 5v7M15 12v7"/>',
grate: '<rect x="3" y="5" width="18" height="14" rx="1"/><path d="M7.5 5v14M12 5v14M16.5 5v14"/>',
gate: '<rect x="3" y="5" width="18" height="14" rx="1"/><path d="M9 5v14"/><path d="M12 12h6M16 9.5l2.5 2.5-2.5 2.5"/>',
belt: '<g transform="rotate(-45 12 12)"><rect x="2" y="7" width="20" height="10" rx="2"/><path d="M4 9h16M4 15h16M7 7V3M13 7V3M19 7V3"/><circle cx="6" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="18" cy="12" r="2"/></g>',
filter: '<g transform="rotate(35 12 12)"><rect x="2" y="7" width="20" height="10" rx="2"/><path d="M4 9h16M4 15h16M6 9v6M10 9v6M14 9v6M18 9v6M4 12h16"/></g>',
belt: '<g transform="rotate(-45 12 12)"><rect x="2" y="7" width="20" height="10" rx="2"/><path d="M4 9h16M4 15h16M3 7V3Q5 1 7 3T11 3T15 3T19 3H21V7M7 7V3M13 7V3M19 7V3"/><circle cx="6" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="18" cy="12" r="2"/></g>',
filter: '<g transform="rotate(45 12 12)"><rect x="2" y="7" width="20" height="10" rx="2"/><path d="M4 9h16M4 15h16M6 9v6M10 9v6M14 9v6M18 9v6M4 12h16"/></g>',
screw: '<rect x="3" y="6" width="18" height="12" rx="2"/><path d="M6.5 18l3.5-12M11.5 18l3.5-12M16.5 18l3-10"/>',
pipe: '<path d="M2 10h20M2 14h20"/><path d="M6 7.5v9M18 7.5v9"/>',
pump: '<circle cx="11" cy="14" r="6"/><path d="M11 14l3.5-2.5M11 14l-1 4.2M11 14l-4-1.2"/><path d="M15.5 9.5L20 5M16 5h4v4"/>',
+13 -11
View File
@@ -221,20 +221,22 @@ function previewBlock(code, x, y, w, h, stroke = false, direction = -1, bx = -1,
}
if (stroke) { const fill = ctx.fillStyle; ctx.fillStyle = ctx.strokeStyle; ctx.fill(); ctx.fillStyle = fill; }
else ctx.fill();
// Preview cleats stand perpendicular to the face; screens have a raised far rail.
ctx.beginPath();
for (let k = 0; k < BS; k++) {
const fy = beltFaceY(code, 0, 0, k);
if (isFilter(code)) ctx.rect(x + k * scale, y + (fy - 2) * scale, scale, scale);
else {
ctx.rect(x + k * scale, y + (fy - 7 - ((ox + k) & 1)) * scale, scale, (2 + ((ox + k) & 1)) * scale);
}
if (!isFilter(code) && (((ox + k) % CLEAT_SPACING + CLEAT_SPACING) % CLEAT_SPACING === 0))
// Use the same filled wall profile as placed slopes; ribs stay on the physics feet.
if (!isFilter(code)) {
ctx.beginPath();
for (let yy = -9; yy < 8; yy += step) for (let xx = -5; xx < 9; xx += step)
if (slopeWallShape(ox + xx + step / 2, oy + yy + step / 2, b, code) <= 0)
ctx.rect(x + xx * scale, y + yy * scale, step * scale, step * scale);
const alpha = ctx.globalAlpha; ctx.globalAlpha *= 0.45; ctx.fill(); ctx.globalAlpha = alpha;
ctx.beginPath();
for (let k = 0; k < BS; k++) if (((ox + k) % CLEAT_SPACING + CLEAT_SPACING) % CLEAT_SPACING === 0) {
const fy = beltFaceY(code, 0, 0, k);
for (let h = 0; h <= CLEAT_REACH; h++)
ctx.rect(x + (k + beltGrade(code) * h) * scale, y + (fy - h - 1) * scale, scale, scale);
}
if (stroke) { const fill = ctx.fillStyle; ctx.fillStyle = ctx.strokeStyle; ctx.fill(); ctx.fillStyle = fill; }
else ctx.fill();
}
if (stroke) { const fill = ctx.fillStyle; ctx.fillStyle = ctx.strokeStyle; ctx.fill(); ctx.fillStyle = fill; }
else ctx.fill();
return;
}
code = slopeShape(code);