Sloped belts and filters, laid by a 45° drag (t40)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 23:17:55 -04:00
co-authored by Claude Opus 5.5
parent 24eb9559f8
commit e19cb784d2
26 changed files with 531 additions and 110 deletions
+1
View File
@@ -41,6 +41,7 @@
"client": [
"render/setup.js",
"render/light.js",
"render/slope-belts.js",
"render/world.js",
"render/machines.js",
"render/overlays.js",
+1 -1
View File
@@ -117,7 +117,7 @@ function thinPipeColor(x, y, anim) {
PC[0] = r; PC[1] = g; PC[2] = bl;
}
// a block that is a belt or a filter: they join up into one belt
function beltLike(b) { if (b < 0 || b >= NB) return false; const c = blockP[b]; return c === BP_BELT || c === BP_FILTER; }
function beltLike(b) { if (b < 0 || b >= NB) return false; const c = blockP[b]; return isBelt(c) || isFilter(c); }
// standing trees: the trunk shows its dark bark, so it reads apart from the pale cut wood it drops
const TREE_R = new Uint8Array(NMAT), TREE_G = new Uint8Array(NMAT), TREE_B = new Uint8Array(NMAT);
for (let m = 0; m < NMAT; m++) { TREE_R[m] = COLR[m]; TREE_G[m] = COLG[m]; TREE_B[m] = COLB[m]; }
+28
View File
@@ -0,0 +1,28 @@
// A slope's band uses world coordinates, so its stripes never restart at block joints.
function slopeBeltAt(i, x, y) {
const b = (y >> 2) * BW + (x >> 2);
if (isSlopeBelt(blockP[b])) return b;
if ((y & 3) || ((x & 3) !== 0 && (x & 3) !== 3)) return -1;
const owner = slopeSealOwner(i);
return owner >= 0 && isSlopeBelt(blockP[owner]) ? owner : -1;
}
function slopeBeltColor(b, x, y, anim, br, bg, bb) {
const c = blockP[b], grade = beltGrade(c), d = y - beltFaceY(c, b % BW, (b / BW) | 0, x);
let r = br, g = bg, blue = bb;
if (d >= 0) {
const dir = isFilter(c) ? (blockV[b] & 128 ? 1 : -1) : (blockV[b] ? 1 : -1);
const axis = (x + grade * y) * 0.5, stripe = (Math.floor((axis - dir * (anim >> 1)) / 2) & 1);
if (d === 0) {
r = stripe ? 92 : 56; g = stripe ? 96 : 58; blue = stripe ? 104 : 66;
if (isFilter(c) && !stripe) {
const f = filterCfg.get(b), m = f && f.m[0];
if (m) { r = COLR[m] * 0.7; g = COLG[m] * 0.7; blue = COLB[m] * 0.7; }
}
} else if (isFilter(c)) {
if (d === 1 && ((x + (grade < 0 ? 0 : 1)) & 1)) { r = 64; g = 68; blue = 76; }
} else if (d === 1) {
r = stripe ? 132 : 104; g = stripe ? 120 : 94; blue = stripe ? 108 : 84;
} else { r = 46; g = 48; blue = 54; }
}
return (r | 0) | ((g | 0) << 8) | ((blue | 0) << 16);
}
+29 -2
View File
@@ -51,8 +51,8 @@ float beltSD(vec2 lb, bool endL, bool endR) {
// the line only rounds off where the whole run ends.
float beltLineSD(ivec2 c, vec2 lb) {
int nr = cls(cellAt(tCol, c + ivec2(4, 0))), nl = cls(cellAt(tCol, c - ivec2(4, 0)));
return beltSD(lb, isBelt(nl) + isF(nl) < 0.5 && !(nl >= 19 && nl <= 22),
isBelt(nr) + isF(nr) < 0.5 && !(nr >= 19 && nr <= 22));
return beltSD(lb, isBelt(nl) + isF(nl) < 0.5 && !(nl >= 19 && nl <= 30) && cls(cellAt(tCol, c + ivec2(-4, -4))) < 23,
isBelt(nr) + isF(nr) < 0.5 && !(nr >= 19 && nr <= 30) && cls(cellAt(tCol, c + ivec2(4, -4))) < 23);
}
// The rubber band of a belt or filter: 1 on a ridge. Ridges travel along the top run one way and
// back along the bottom.
@@ -352,6 +352,33 @@ void main() {
}
}
// Sloped belts and filters: a continuous running band over a triangular frame.
if (nc >= 23 && nc <= 30) {
int shape = (nc - 23) / 2;
bool mesh = shape >= 2;
float grade = (shape == 0 || shape == 2) ? -1.0 : 1.0;
float dir = ((nc - 23) % 2) == 1 ? 1.0 : -1.0;
vec4 tag = cellAt(tTree, c);
vec2 lb = mod(w, 4.0);
if (tag.a > 0.4) lb.y += 4.0;
float face = grade < 0.0 ? 4.0 - lb.x : lb.x;
float depth = (lb.y - face) * 0.70710678;
float inside = smoothstep(-px, px, depth);
float axis = (w.x + grade * w.y) * 0.5;
float rg = step(0.72, fract((axis - dir * uTime * 30.0) * 0.5));
vec3 band = bandColor(vec2(axis, 0.5), rg);
vec3 frame = vec3(0.18, 0.19, 0.21);
float roller = 1.0 - smoothstep(0.32, 0.5, length(vec2(mod(axis, 2.0) - 1.0, depth - 0.95)));
frame = mix(frame, vec3(0.50, 0.40, 0.31), roller);
if (mesh) {
float hole = step(0.48, fract(axis)) * step(0.35, fract(depth * 1.8));
band = mix(band, dot(tag.rgb, vec3(1)) > 0.01 ? tag.rgb * 0.7 : vec3(0.28, 0.32, 0.33), hole);
frame = mix(C, vec3(0.25, 0.27, 0.30), roller * 0.7);
}
vec3 belt = mix(band, frame, smoothstep(0.55, 0.75, depth));
C = mix(C, belt, inside);
}
// ---- light, then the glow of hot things on top
if (uLightMap.w > 0.5) C *= textureLod(tLight, (q - uLightMap.xy) / uLightMap.z / uLightSize, 0.0).rgb;
if (uGlowK > 0.0) {
+14 -2
View File
@@ -281,6 +281,11 @@ function drawWorld() {
if (pipeBodyCell(x, y)) { thinPipeColor(x, y, anim); r = PC[0]; g = PC[1]; b = PC[2]; }
}
}
const sb = slopeBeltAt(i, x, y), slopeArt = sb >= 0 && (m === EMPTY || PH[m] === PH_GAS || m === BELTL || m === BELTR);
if (slopeArt) {
const color = slopeBeltColor(sb, x, y, anim, br, bgc, bb);
r = color & 255; g = color >> 8 & 255; b = color >> 16 & 255;
}
// incandescence
if (T > 450 && m !== EMPTY && incand) {
const gi = glowIndex(PH[m] === PH_SOLID ? T + nz * 0.9 : m === MAGMA ? T + Math.sin(x * 0.19 + y * 0.11 + anim * 0.03 + nz * 0.04) * 55 - 30 : T), w = GLOW_W[gi];
@@ -296,9 +301,9 @@ function drawWorld() {
// what kind of thing this grain is drawn as: 0 open, 1 terrain, 2 loose grain, 3 liquid,
// 4 built piece (drawn crisp), 5 smoke and flame, 6 built wall block (drawn as a tile)
let cl = 0;
const pt = p === P_PIPE && !pipeBodyCell(x, y) ? 0 : p; // the open parts of a pipe block show what is in them
const pt = (p === P_PIPE && !pipeBodyCell(x, y)) || (p === P_FILTER && sb >= 0) ? 0 : p; // the open parts of a pipe block show what is in them
if (IS_TUBE[pt]) cl = TUBE_CLASS[pt];
else if (p === P_FILTER) {
else if (p === P_FILTER && sb < 0) {
// filter: 13 carries left, 14 right; the tree buffer carries its colour tag (first material) and mode
const fb = (y >> 2) * BW + (x >> 2), fc = filterCfg.get(fb), fm = fc && fc.m.length ? fc.m[0] : 0;
cl = blockV[fb] & 128 ? 14 : 13;
@@ -319,6 +324,13 @@ function drawWorld() {
const wm = WALL_OF[blockV[(y >> 2) * BW + (x >> 2)]];
r = COLR[wm]; g = COLG[wm]; b = COLB[wm];
}
if (slopeArt) {
const code = blockP[sb], dir = isFilter(code) ? (blockV[sb] & 128 ? 1 : 0) : blockV[sb];
cl = 23 + (code - BP_BELT_UP) * 2 + dir;
const f = isFilter(code) && filterCfg.get(sb), fm = f && f.m[0];
tpx[row + bx] = ((sb !== (y >> 2) * BW + (x >> 2) ? 0x80 : 0x40) << 24) |
(fm ? (COLB[fm] << 16) | (COLG[fm] << 8) | COLR[fm] : 0);
}
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);
}
+32 -21
View File
@@ -3,33 +3,42 @@
function blockOf(x, y) { return { bx: Math.floor(x / BS), by: Math.floor(y / BS) }; }
function blockCellList(bx, by) { const out = []; for (let y = by * BS; y < by * BS + BS; y++) for (let x = bx * BS; x < bx * BS + BS; x++) out.push(y * W + x); return out; }
function pieceCost(code, v) { return isWall(code) ? wallCost(v, isSlope(code) ? 8 : BS * BS) : PIECE_BY_CODE[code].cost; }
function setSlopeSeal(i, code, v) {
if (isFilter(code)) { part[i] = P_FILTER; wakeIdx(i); }
else {
setCell(i, isBelt(code) ? (v ? BELTR : BELTL) : WALL_OF[v], AMB[(i / W) | 0], 0);
if (isBelt(code)) markPow(i, true);
}
}
// One extra cell on the solid side seals diagonal crossings between successive triangles.
function slopeSeal(code, bx, by) {
code = slopeShape(code);
const x = bx * BS + (code === BP_SLOPE_BR || code === BP_SLOPE_TR ? 0 : 3);
const y = by * BS + (code >= BP_SLOPE_TL ? -1 : 4);
return y < 0 || y >= H ? -1 : y * W + x;
}
function slopeSealOwner(i) {
const x = i % W, y = (i / W) | 0, bx = x >> 2;
for (const by of [(y >> 2) - 1, (y >> 2) + 1]) {
if (((x & 3) !== 0 && (x & 3) !== 3) || ((y & 3) !== 0 && (y & 3) !== 3)) return -1;
for (let by = (y >> 2) - 1; by <= (y >> 2) + 1; by += 2) {
if (by < 0 || by >= BH) continue;
const b = by * BW + bx, c = blockP[b];
if (isSlope(c) && slopeSeal(c, bx, by) === i) return b;
if ((isSlope(c) || isSlopeBelt(c)) && slopeSeal(c, bx, by) === i) return b;
}
return -1;
}
function wallCells(code, bx, by) {
return blockCellList(bx, by).filter(i => !isSlope(code) || slopeCell(code, (i % W) & 3, ((i / W) | 0) & 3));
return blockCellList(bx, by).filter(i => !(isSlope(code) || isSlopeBelt(code)) || slopeCell(code, (i % W) & 3, ((i / W) | 0) & 3));
}
function canPlaceBlock(bx, by, code = BP_WALL) {
if (bx < 0 || by < 0 || bx >= BW || by >= BH) return 'Out of bounds';
if (blockP[by * BW + bx]) return 'Something is already built there';
for (const i of blockCellList(bx, by)) {
if (mach[i] || part[i]) return 'Something is already built there';
if (mach[i] || (part[i] && slopeSealOwner(i) < 0)) return 'Something is already built there';
if (PH[mat[i]] === PH_SOLID && ((flags[i] & F_NAT) || slopeSealOwner(i) < 0)) return 'Needs open space; clear the rock first.';
if (occ[i]) return 'A bar is in the way';
}
if (isSlope(code)) {
if (isSlope(code) || isSlopeBelt(code)) {
const i = slopeSeal(code, bx, by);
if (i >= 0 && PH[mat[i]] !== PH_SOLID && (mach[i] || part[i] || occ[i])) return 'The slope corner is blocked.';
}
@@ -41,25 +50,25 @@ function placeBlock(code, v, bx, by, free, extra) {
if (isWall(code) && !WALL_OF[v]) return 'Pick a wall material with R';
const cost = pieceCost(code, v);
if (!free) { if (!canAfford(cost, 1)) return needText(cost, 1); payCost(cost, 1); }
const cells = isSlope(code) ? wallCells(code, bx, by) : blockCellList(bx, by);
const cells = isSlope(code) || isSlopeBelt(code) ? wallCells(code, bx, by) : blockCellList(bx, by);
if (code !== BP_PIPE) evictCells(cells, by * BS); // a pipe is thin: what is there stays and flows past it
for (const i of cells) {
const y = (i / W) | 0, x = i % W;
if (isWall(code)) setCell(i, WALL_OF[v], AMB[y], 0);
else if (code === BP_BELT) { setCell(i, v === 0 ? BELTL : BELTR, AMB[y], 0); markPow(i, true); }
else if (isBelt(code)) { setCell(i, v === 0 ? BELTL : BELTR, AMB[y], 0); markPow(i, true); }
else if (code === BP_SHAFT) { setCell(i, SHAFT, AMB[y], 0); markPow(i, true); }
else if (code === BP_RACK) setCell(i, RACKB, AMB[y], 0);
else if (isProcCode(code)) setCell(i, code === BP_GRIND ? GRINDER : code === BP_PRESS ? PRESSB : SAWB, AMB[y], 0);
else if (code === BP_GATE) setCell(i, GATE, AMB[y], 0);
else if (code === BP_GRATE) { part[i] = P_GRATE; wakeXY(x, y); }
else if (code === BP_TUBE) { part[i] = TUBE_PARTS[v & 7]; markPow(i, true); wakeXY(x, y); }
else if (code === BP_FILTER) { part[i] = P_FILTER; wakeXY(x, y); }
else if (isFilter(code)) { part[i] = P_FILTER; wakeXY(x, y); }
else if (code === BP_PIPE) { part[i] = P_PIPE; wakeXY(x, y); }
else if (isPipeCode(code)) setCell(i, PIPE, AMB[y], 0);
}
if (isPipeCode(code)) pipesDirty = true;
blockP[by * BW + bx] = code; blockV[by * BW + bx] = v;
if (code === BP_FILTER) { blockV[by * BW + bx] = v & 128; filterCfg.set(by * BW + bx, filterRec(extra)); filtersDirty = true; }
if (isFilter(code)) { blockV[by * BW + bx] = v & 128; 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();
@@ -67,9 +76,9 @@ function placeBlock(code, v, bx, by, free, extra) {
rackState.set(b, s);
for (let q = rackTop(b); q <= rackBottom(b); q += BW) rackState.get(q).open = s.open;
}
if (isSlope(code)) {
if (isSlope(code) || isSlopeBelt(code)) {
const i = slopeSeal(code, bx, by);
if (i >= 0 && PH[mat[i]] !== PH_SOLID) { evictCells([i], by * BS); setCell(i, WALL_OF[v], AMB[(i / W) | 0], 0); }
if (i >= 0 && PH[mat[i]] !== PH_SOLID && !part[i]) { evictCells([i], by * BS); setSlopeSeal(i, code, v); }
}
stats.placed++;
undoNote({ t: 'block+', bx, by, code });
@@ -84,14 +93,14 @@ function blockIntegrity(b) {
for (const i of blockCellList(bx, by)) {
const m = mat[i], p = part[i];
if (isWall(code) && (!isSlope(code) || slopeCell(code, (i % W) & 3, ((i / W) | 0) & 3))) { if (PH[m] === PH_SOLID && !(flags[i] & F_NAT) && !PARTMAT[m]) n++; }
else if (code === BP_BELT) { if (m === BELTL || m === BELTR) n++; }
else if (isBelt(code)) { if (m === BELTL || m === BELTR) n++; }
else if (code === BP_SHAFT) { if (m === SHAFT) n++; }
else if (code === BP_RACK) { if (m === RACKB) n++; }
else if (isProcCode(code)) { if (m === (code === BP_GRIND ? GRINDER : code === BP_PRESS ? PRESSB : SAWB)) n++; }
else if (code === BP_GATE) { if (m === GATE || p === P_GOPEN) n++; }
else if (code === BP_GRATE) { if (p === P_GRATE) n++; }
else if (code === BP_TUBE) { if (IS_TUBE[p]) n++; }
else if (code === BP_FILTER) { if (p === P_FILTER) n++; }
else if (isFilter(code)) { if (p === P_FILTER) n++; }
else if (isPipeCode(code)) { if (m === PIPE || p === P_PIPE) n++; }
}
void v;
@@ -109,7 +118,7 @@ function removeWallCell(i, wall) {
function removeBlock(bx, by) {
const b = by * BW + bx, code = blockP[b], v = blockV[b];
if (!code) return 0;
undoNote({ t: 'block-', bx, by, code, v, extra: code === BP_FILTER ? filterInfo(b) : undefined });
undoNote({ t: 'block-', bx, by, code, v, extra: isFilter(code) ? filterInfo(b) : undefined });
// a pipe taken out spills its share of what the line holds
const spill = isPipeCode(code) ? pipeTakeShare(b) : null;
let n = 0, metal = 0;
@@ -125,7 +134,7 @@ function removeBlock(bx, by) {
} else if (isPipeCode(code)) {
if (p === P_PIPE) { part[i] = 0; wakeIdx(i); n++; }
else if (m === PIPE) { setCell(i, EMPTY, temp[i], 0); n++; }
} else if (code === BP_BELT || code === BP_SHAFT || code === BP_GATE || code === BP_RACK || isProcCode(code)) {
} else if (isBelt(code) || code === BP_SHAFT || code === BP_GATE || code === BP_RACK || isProcCode(code)) {
if (m === BELTL || m === BELTR || m === SHAFT || m === GATE || m === GRINDER || m === SAWB || m === PRESSB || m === RACKB) { setCell(i, EMPTY, temp[i], 0); markPow(i, false); n++; }
if (p === P_GOPEN) { part[i] = 0; n++; wakeIdx(i); }
} else if (p) { part[i] = 0; markPow(i, false); wakeIdx(i); n++; }
@@ -137,7 +146,7 @@ function removeBlock(bx, by) {
}
if (code === BP_WALL && metal) giveOrDrop(BAR_OF[WALL_OF[v]] || BARCU, Math.floor(metal / GRAINS_PER_BAR), bx * BS + 2, by * BS + 2);
else if (!isWall(code) && n >= 8) refundAt(PIECE_BY_CODE[code].cost, 1, bx * BS + 2, by * BS + 2);
if (code === BP_FILTER) { filtersDirty = true; filterCfg.delete(b); }
if (isFilter(code)) { filtersDirty = true; filterCfg.delete(b); }
if (isProcCode(code)) { procDirty = true; procState.delete(b); }
if (code === BP_RACK) {
const s = rackState.get(b);
@@ -146,17 +155,19 @@ function removeBlock(bx, by) {
}
blockP[b] = 0; blockV[b] = 0;
const cleared = blockCellList(bx, by);
if (isSlope(code)) {
if (isSlope(code) || isSlopeBelt(code)) {
const i = slopeSeal(code, bx, by);
if (i >= 0) {
const nb = (((i / W) | 0) >> 2) * BW + ((i % W) >> 2), nc = blockP[nb];
if ((!nc || (isSlope(nc) && !slopeCell(nc, (i % W) & 3, ((i / W) | 0) & 3))) && mat[i] === WALL_OF[v] && !(flags[i] & F_NAT)) setCell(i, EMPTY, temp[i], 0);
if ((!nc || (isSlope(nc) && !slopeCell(nc, (i % W) & 3, ((i / W) | 0) & 3))) && (mat[i] === (isBelt(code) ? (v ? BELTR : BELTL) : WALL_OF[v]) || (isFilter(code) && part[i] === P_FILTER)) && !(flags[i] & F_NAT)) { if (isFilter(code)) { part[i] = 0; wakeIdx(i); } else { setCell(i, EMPTY, temp[i], 0); if (isBelt(code)) markPow(i, false); } }
cleared.push(i);
}
}
for (const i of cleared) {
const owner = slopeSealOwner(i);
if (owner >= 0 && mat[i] === EMPTY) setCell(i, WALL_OF[blockV[owner]], AMB[(i / W) | 0], 0);
if (owner >= 0 && mat[i] === EMPTY && !part[i]) {
setSlopeSeal(i, blockP[owner], blockV[owner]);
}
}
if (isPipeCode(code)) {
pipesDirty = true;
@@ -186,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 (code === BP_FILTER) return (blockV[b] & 128) === (v & 128) && sameFilter(filterInfo(b), cleanFilterCfg(extra));
if (isFilter(code)) return (blockV[b] & 128) === (v & 128) && 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
@@ -197,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] === BP_FILTER && code === BP_FILTER) { 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] = v & 128; 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);
}
+3 -3
View File
@@ -7,7 +7,7 @@ function copyRegion(x0, y0, x1, y1) {
const blocks = [], ms = [], ols = [];
for (let by = by0; by <= by1; by++) for (let bx = bx0; bx <= bx1; bx++) {
const b = by * BW + bx;
if (blockP[b] && blockIntegrity(b) >= 8) blocks.push(blockP[b] === BP_FILTER ? [bx - bx0, by - by0, blockP[b], blockV[b], filterInfo(b)] : [bx - bx0, by - by0, blockP[b], blockV[b]]);
if (blockP[b] && blockIntegrity(b) >= 8) blocks.push(isFilter(blockP[b]) ? [bx - bx0, by - by0, blockP[b], blockV[b], filterInfo(b)] : [bx - bx0, by - by0, blockP[b], blockV[b]]);
}
for (const mm of machines) {
if (!mm || PIECE_BY_ID[mm.type].fixed) continue;
@@ -23,11 +23,11 @@ function copyRegion(x0, y0, x1, y1) {
}
function flipBlueprint(bp) {
const bw = bp.bw;
const flipV = (code, v) => code === BP_BELT ? 1 - v : code === BP_FILTER ? v ^ 128 : code === BP_TUBE ? [0, 3, 2, 1, 7, 6, 5, 4][v & 7]
const flipV = (code, v) => isBelt(code) ? 1 - v : isFilter(code) ? v ^ 128 : code === BP_TUBE ? [0, 3, 2, 1, 7, 6, 5, 4][v & 7]
: code === BP_PUMP || code === BP_OUTLET ? (((v & 3) === 1 ? 3 : (v & 3) === 3 ? 1 : v & 3) | (v & 4)) : v;
return {
v: 2, bw, bh: bp.bh, name: bp.name,
blocks: bp.blocks.map(([x, y, c, v, e]) => e ? [bw - 1 - x, y, isSlope(c) ? BP_SLOPE_BL + ((c - BP_SLOPE_BL) ^ 1) : c, flipV(c, v), e] : [bw - 1 - x, y, isSlope(c) ? BP_SLOPE_BL + ((c - BP_SLOPE_BL) ^ 1) : c, flipV(c, v)]),
blocks: bp.blocks.map(([x, y, c, v, e]) => e ? [bw - 1 - x, y, isSlopeBelt(c) ? c + (c & 1 ? 1 : -1) : isSlope(c) ? BP_SLOPE_BL + ((c - BP_SLOPE_BL) ^ 1) : c, flipV(c, v), e] : [bw - 1 - x, y, isSlopeBelt(c) ? c + (c & 1 ? 1 : -1) : isSlope(c) ? BP_SLOPE_BL + ((c - BP_SLOPE_BL) ^ 1) : c, flipV(c, v)]),
machines: bp.machines.map(([t, x, y, d]) => {
const P = PIECE_BY_ID[t];
let nd = d;
+68 -14
View File
@@ -4,7 +4,24 @@
// clear). If a powder grain is blocked, the load above it stands still and the belt slips
// underneath. A filter's own material drops through it instead, unless it is already full.
const CARRY_DEPTH = 96;
// Find the next carrying face at the same height or one grain above/below it.
function conveyorStep(x, y, d) {
const nx = x + d;
if (nx < 0 || nx >= W) return 0;
if ((x >> 2) === (nx >> 2)) return beltGrade(blockP[(y >> 2) * BW + (x >> 2)]) * d;
for (let dy = -1; dy <= 1; dy++) {
const yy = y + dy;
if (yy < 0 || yy >= H) continue;
const b = (yy >> 2) * BW + (nx >> 2), c = blockP[b], i = yy * W + nx;
if ((isBelt(c) || isFilter(c)) && beltFaceY(c, nx >> 2, yy >> 2, nx) === yy &&
(mat[i] === BELTL || mat[i] === BELTR || part[i] === P_FILTER)) return dy;
}
const b = (y >> 2) * BW + (x >> 2);
return beltGrade(blockP[b]) * d;
}
function carryColumn(a, d, pass) {
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) {
if (a < 0) return;
@@ -20,20 +37,30 @@ function carryColumn(a, d, pass) {
if (blocked) continue; // filters still sift above a stopped load
const x = a % W, nx = x + d;
if (nx < 0 || nx >= W) return;
const j = a + d, tj = mat[j];
const j = a + d + step * W;
if (j < 0 || j >= N) return;
const tj = mat[j];
// grains on a wet belt push through the water rather than stopping at it
const wet = PH[tj] === PH_LIQUID && !occ[j] && stamp[j] !== tickStamp && (!part[j] || part[j] === P_FILTER || part[j] === P_GOPEN);
if (!wet && !enter(m, PH_POWDER, j, d, 0)) {
if (!wet && !enter(m, PH_POWDER, j, d, step)) {
if (!pass) return;
blocked = true; continue;
}
const y = (a / W) | 0, bj = j + W;
// carried off the end with nothing under it: it leaves at belt speed and falls
if (mat[j] === EMPTY && !part[j] && bj < N && (mat[bj] === EMPTY || PH[mat[bj]] === PH_GAS) && !occ[bj] && !part[bj]) launchGrain(a, nx, y, d * 0.5, 0);
else move(a, j, x, y, nx, y);
if (mat[j] === EMPTY && !part[j] && bj < N && (mat[bj] === EMPTY || PH[mat[bj]] === PH_GAS) && !occ[bj] && !part[bj]) launchGrain(a, nx, y + step, d * 0.5, step * 0.5);
else move(a, j, x, y, nx, y + step);
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;
@@ -53,23 +80,48 @@ function launchGrain(i, x, y, vx, vy) {
let filterR = [], filterL = [], filtersDirty = true;
function rebuildFilters() {
filtersDirty = false; filterR = []; filterL = [];
for (let b = 0; b < NB; b++) if (blockP[b] === BP_FILTER) (blockV[b] & 128 ? filterR : filterL).push(b);
for (let b = 0; b < NB; b++) if (isFilter(blockP[b])) (blockV[b] & 128 ? filterR : filterL).push(b);
// front of the line first, so a full run moves as one
filterR.sort((a, c) => (c % BW) - (a % BW)); filterL.sort((a, c) => (a % BW) - (c % BW));
}
function filterCarry(b, d) {
if (blockP[b] !== BP_FILTER) return;
const bx = b % BW, y = ((b / BW) | 0) * BS;
function filterCarry(b, d, carrying = true) {
if (!isFilter(blockP[b])) return;
const bx = b % BW, by = (b / BW) | 0, code = blockP[b], y = by * BS;
if (y < 1) return;
if (!carrying && !isSlopeBelt(code)) return;
const c = filterCfg.get(b), pass = c ? c.pass : null;
for (let k = 0; k < BS; k++) {
const x = d > 0 ? bx * BS + BS - 1 - k : bx * BS + k, top = y * W + x;
if (part[top] === P_FILTER) carryColumn(top - W, d, pass);
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);
}
}
}
// Belts and filters share one front-first order, including their flat/slope joins.
function carryBeltsAndFilters(belts, filters, d, bm, carrying) {
let k = 0, f = 0;
while (k < belts.length || f < filters.length) {
const fx = f < filters.length ? (filters[f] % BW) * BS + (d > 0 ? BS - 1 : 0) : 0;
if (f < filters.length && (k >= belts.length || (fx - belts[k] % W) * d > 0)) { filterCarry(filters[f++], d, carrying); continue; }
const b = belts[k++];
if (mat[b] !== bm || b < W) continue;
const x = b % W, y = (b / W) | 0, code = blockP[(y >> 2) * BW + (x >> 2)];
if (!carrying && !isSlopeBelt(code)) continue;
if (isSlopeBelt(code) && beltFaceY(code, x >> 2, y >> 2, x) !== y) continue;
if (slopeSealOwner(b) >= 0) continue;
const above = mat[b - W];
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)) holdSlopeColumn(b - W, null);
}
}
// Change what a filter does; grains inside it that no longer pass are pushed out on top.
function setFilter(b, cfg) {
if (blockP[b] !== BP_FILTER) return false;
if (!isFilter(blockP[b])) return false;
const rec = filterRec(cfg);
filterCfg.set(b, rec);
const bx = b % BW, by = (b / BW) | 0;
@@ -79,11 +131,13 @@ function setFilter(b, cfg) {
}
// The run of filters in a row that touch block b and do the same thing it does
function filterRun(b) {
if (blockP[b] !== BP_FILTER) return [];
if (!isFilter(blockP[b])) return [];
const f = filterInfo(b), dir = blockV[b] & 128, by = (b / BW) | 0, out = [b];
for (const s of [-1, 1]) for (let bx = (b % BW) + s; bx >= 0 && bx < BW; bx += s) {
const c = by * BW + bx;
if (blockP[c] !== BP_FILTER || (blockV[c] & 128) !== dir || !sameFilter(filterInfo(c), f)) break;
const yy = by + beltGrade(blockP[b]) * (bx - (b % BW));
if (yy < 0 || yy >= BH) break;
const c = yy * BW + bx;
if (blockP[c] !== blockP[b] || (blockV[c] & 128) !== dir || !sameFilter(filterInfo(c), f)) break;
out.push(c);
}
return out.sort((a, c) => a - c);
+38 -6
View File
@@ -59,9 +59,10 @@ function entTubeFree(x, y, tubeOnly = false) {
function entTubeMove(e, x, y, tubeOnly = false, mouth = false) {
if (!entTubeFree(x, y, tubeOnly)) return false;
if (mouth) {
// Lift clear of the receiving belt before sliding over it; check both legs before moving.
for (let yy = Math.min(e.iy, y); yy <= Math.max(e.iy, y); yy++) if (!entTubeFree(e.ix, yy)) return false;
for (let xx = Math.min(e.ix, x); xx <= Math.max(e.ix, x); xx++) if (!entTubeFree(xx, y)) return false;
// Go around the receiving corner, checking both legs before moving.
const acrossY = y > e.iy ? e.iy : y, downX = y > e.iy ? x : e.ix;
for (let yy = Math.min(e.iy, y); yy <= Math.max(e.iy, y); yy++) if (!entTubeFree(downX, yy)) return false;
for (let xx = Math.min(e.ix, x); xx <= Math.max(e.ix, x); xx++) if (!entTubeFree(xx, acrossY)) return false;
} else for (let yy = Math.min(e.iy, y); yy <= Math.max(e.iy, y); yy++)
for (let xx = Math.min(e.ix, x); xx <= Math.max(e.ix, x); xx++) if (!entTubeFree(xx, yy)) return false;
e.x = e.ix = x; e.y = e.iy = y; e.vx = e.vy = 0; return true;
@@ -71,7 +72,7 @@ function entTubeEdge(e, dx, dy, k) {
const x = e.ix + (dx > 0 ? BAR_W : dx < 0 ? -1 : k), y = e.iy + (dy > 0 ? BAR_H : dy < 0 ? -1 : k);
return x < 0 || y < 0 || x >= W || y >= H ? -1 : y * W + x;
}
function entTubeEnter(e, dx, dy) {
function entTubeEnter(e, dx, dy, mouth = false) {
for (let k = 0; k < (dx ? BAR_H : BAR_W); k++) {
const i = entTubeEdge(e, dx, dy, k);
if (i < 0) continue;
@@ -79,7 +80,7 @@ function entTubeEnter(e, dx, dy) {
const p = part[i]; if (!IS_TUBE[p] || (!dx && TDX[p]) || dx * TDX[p] + dy * TDY[p] < 0) continue;
// Vertical screws require the full width; horizontal screws use the nearest two-row lane.
const x = (xx & ~3), y = entTubeLane(yy, e.iy);
if (entTubeMove(e, x, TDX[p] || dy ? y : e.iy, true)) return true;
if (entTubeMove(e, x, TDX[p] || dy ? y : e.iy, true, mouth)) return true;
}
return false;
}
@@ -129,6 +130,37 @@ function entDiagonalTubeTick(e, p) {
for (let yy = y; yy < y + BAR_H; yy++) for (let xx = x; xx < x + BAR_W; xx++) if (part[yy * W + xx]) return;
if (entTubeMove(e, x, y, false, dy < 0)) { e.vx = dx * 0.5; e.vy = dy * 0.6; stats.moved++; }
}
// Bars stay level; their highest contact follows the face one grain per belt beat.
function entSlopeBeltTick(e) {
const bottom = e.iy + BAR_H;
if (bottom >= H) return false;
let d = 0, slope = 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 (!d) return false;
let face = H;
for (let x = e.ix + d; x < e.ix + d + BAR_W; x++) for (let dy = -1; dy <= 1; dy++) {
const y = bottom + dy;
if (x < 0 || x >= W || y < 0 || y >= H) continue;
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;
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;
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 (!entTubeEnter(e, d, 0)) entTubeEnter(e, d, Math.sign(y - e.iy), true);
return true;
}
function entTick() {
for (let k = ents.length - 1; k >= 0; k--) {
const e = ents[k];
@@ -137,7 +169,7 @@ function entTick() {
let ix = e.ix; const iy = e.iy;
const tube = entTubeAt(ix, iy);
if (tube) entTubeTick(e, tube);
else {
else if (!entSlopeBeltTick(e)) {
// what is underneath?
let belt = 0, bsat = 0, transport = false, support = iy + BAR_H >= H;
if (!support) {
+4 -18
View File
@@ -87,24 +87,10 @@ function machinesTick() {
mm.hot = Math.round(hotT);
}
}
// belts and filters move what rests on them every other tick: half a grain per tick
if ((tick & 1) === 0) {
for (let pass = 0; pass < 2; pass++) {
const list = pass ? beltR : beltL, d = pass ? 1 : -1, bm = pass ? BELTR : BELTL;
for (let k = 0; k < list.length; k++) {
const b = list[k];
if (mat[b] !== bm || b < W) continue;
const above = mat[b - W];
if (above === BELTL || above === BELTR) continue; // only the top surface carries
if (netSat(b) <= 0) continue;
if (PH[above] === PH_LIQUID) beltDrain(b);
carryColumn(b - W, d, null);
}
}
if (filtersDirty) rebuildFilters();
for (const b of filterR) filterCarry(b, 1);
for (const b of filterL) filterCarry(b, -1);
}
// Half a grain per tick; sloped faces grip their load on the intervening ticks too.
if (filtersDirty) rebuildFilters();
carryBeltsAndFilters(beltL, filterL, -1, BELTL, (tick & 1) === 0);
carryBeltsAndFilters(beltR, filterR, 1, BELTR, (tick & 1) === 0);
// tubes: 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_TUBE.length; p++) {
+1 -1
View File
@@ -44,7 +44,7 @@ function rebuildPower() {
if (!net) continue;
if (mm.type === 'fan') net.fans.push(mm); else if (mm.type === 'wheel' || mm.type === 'engine') net.prods.push(mm);
}
beltL.sort((a, b) => a - b); beltR.sort((a, b) => b - a);
beltL.sort((a, b) => (a % W) - (b % W) || a - b); beltR.sort((a, b) => (b % W) - (a % W) || b - a);
for (const p of TUBE_PARTS) {
tubeLists[p].sort((a, b) => {
if (TDX[p] && TDY[p]) return ((b % W) - (a % W)) * TDX[p];
+23 -3
View File
@@ -6,9 +6,22 @@
const BS = 4;
const BP_WALL = 1, BP_BELT = 2, BP_TUBE = 3, BP_SHAFT = 4, BP_GRATE = 5, BP_GATE = 6, BP_FILTER = 7, BP_PIPE = 8, BP_PUMP = 9, BP_OUTLET = 10, BP_GRIND = 11, BP_SAW = 12, BP_PRESS = 13, BP_RACK = 14;
const BP_SLOPE_BL = 15, BP_SLOPE_BR = 16, BP_SLOPE_TL = 17, BP_SLOPE_TR = 18;
// The shape is independent of the direction: reversing a belt never flips its face.
const BP_BELT_UP = 19, BP_BELT_DOWN = 20, BP_FILTER_UP = 21, BP_FILTER_DOWN = 22;
function isSlopeBelt(c) { return c >= BP_BELT_UP && c <= BP_FILTER_DOWN; }
function isFilter(c) { return c === BP_FILTER || c === BP_FILTER_UP || c === BP_FILTER_DOWN; }
function isBelt(c) { return c === BP_BELT || c === BP_BELT_UP || c === BP_BELT_DOWN; }
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 beltDirection(c, v) {
const d = isFilter(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; }
function isWall(c) { return c === BP_WALL || isSlope(c); }
function slopeCell(c, x, y) {
c = slopeShape(c);
if (c === BP_SLOPE_BR || c === BP_SLOPE_TR) x = 3 - x;
if (c === BP_SLOPE_TL || c === BP_SLOPE_TR) y = 3 - y;
return y >= x;
@@ -30,7 +43,12 @@ function filterRec(c) {
for (let m = 1; m < NMAT; m++) if (filterable(m)) pass[m] = (k.m.includes(m) ? 1 : 0) ^ k.inv;
return { m: k.m, inv: k.inv, pass };
}
function filterPassAt(m, j) { const c = filterCfg.get((((j / W) | 0) >> 2) * BW + ((j % W) >> 2)); return c ? c.pass[m] : 0; }
function filterPassAt(m, j) {
let b = (((j / W) | 0) >> 2) * BW + ((j % W) >> 2);
if (!isFilter(blockP[b])) { const owner = slopeSealOwner(j); if (owner >= 0) b = owner; }
const c = filterCfg.get(b);
return c ? c.pass[m] : 0;
}
function filterInfo(b) { const c = filterCfg.get(b); return c ? { m: c.m.slice(), inv: c.inv } : { m: [], inv: 0 }; }
function sameFilter(a, b) { return a.inv === b.inv && a.m.length === b.m.length && a.m.every((m, k) => m === b.m[k]); }
// loose material -> the solid it becomes in a wall block
@@ -52,11 +70,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.' },
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.' },
{ id: 'tube', code: BP_TUBE, name: 'Screw tube', kind: 'block', cost: [[CASH, 25]], dirs: ['up', 'right', 'down', 'left', 'up-right', 'down-right', 'down-left', 'up-left'],
desc: 'A turning screw 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 tube. Join either end to tubes 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.' },
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.' },
{ 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]],
@@ -97,6 +115,8 @@ const PIECES = [
const PIECE_BY_ID = {}, PIECE_BY_CODE = [];
PIECES.forEach((p, i) => { p.index = i; PIECE_BY_ID[p.id] = p; if (p.code) PIECE_BY_CODE[p.code] = p; if (p.kind === 'machine') { p.w = p.bw * BS; p.h = p.bh * BS; } });
for (let c = BP_SLOPE_BL; c <= BP_SLOPE_TR; c++) PIECE_BY_CODE[c] = PIECE_BY_ID.wall;
for (const c of [BP_BELT_UP, BP_BELT_DOWN]) PIECE_BY_CODE[c] = PIECE_BY_ID.belt;
for (const c of [BP_FILTER_UP, BP_FILTER_DOWN]) PIECE_BY_CODE[c] = PIECE_BY_ID.filter;
// power per grain of belt / tube (a block is 16 grains)
const BELT_POWER = 0.03, TUBE_POWER = 0.125, FAN_POWER = 6;
const WHEEL_K = 0.32, ENGINE_K = 40;
+2 -2
View File
@@ -55,8 +55,8 @@ function deserializeWorld(meta, buffers) {
}
// pipes used to be solid copper blocks; now they are thin and let things past
for (let b = 0; b < NB; b++) if (blockP[b] === BP_PIPE) for (const i of blockCellList(b % BW, (b / BW) | 0)) if (mat[i] === PIPE) { mat[i] = EMPTY; part[i] = P_PIPE; }
for (const [b, m, inv] of meta.filters || []) if (blockP[b] === BP_FILTER) filterCfg.set(b, filterRec({ m, inv }));
for (let b = 0; b < NB; b++) if (blockP[b] === BP_FILTER) {
for (const [b, m, inv] of meta.filters || []) if (isFilter(blockP[b])) filterCfg.set(b, filterRec({ m, inv }));
for (let b = 0; b < NB; b++) if (isFilter(blockP[b])) {
// the first filters kept one material in their block variant
if (!filterCfg.has(b)) filterCfg.set(b, filterRec({ m: [blockV[b] & 127], inv: 0 }));
blockV[b] &= 128;
+4 -4
View File
@@ -22,8 +22,8 @@ function undoLast() {
for (let k = rec.ops.length - 1; k >= 0; k--) {
const o = rec.ops[k];
if (o.t === 'block+') { if (blockP[o.by * BW + o.bx] === o.code) removeBlock(o.bx, o.by); }
else if (o.t === 'block-') { const r = placeBlock(o.code, o.code === BP_FILTER ? o.v & 128 : o.v, o.bx, o.by, false, o.extra); if (r) fail(r); }
else if (o.t === 'filter') { const b = o.by * BW + o.bx; if (blockP[b] === BP_FILTER) { blockV[b] = o.v & 128; setFilter(b, o.extra); filtersDirty = true; } }
else if (o.t === 'block-') { const r = placeBlock(o.code, isFilter(o.code) ? o.v & 128 : o.v, o.bx, o.by, false, o.extra); if (r) fail(r); }
else if (o.t === 'filter') { const b = o.by * BW + o.bx; if (isFilter(blockP[b])) { blockV[b] = o.v & 128; setFilter(b, o.extra); filtersDirty = true; } }
else if (o.t === 'mach+') { const mm = machines.find(m => m && m.type === o.type && m.x === o.bx * BS && m.y === o.by * BS); if (mm && siloFull(mm)) fail('empty the silo first'); else if (mm) removeMachine(mm.id, true, false); }
else if (o.t === 'mach-') { const r = placeMachineAt(o.type, o.dir, o.bx, o.by); if (r) fail(r); else { const mm = machines.find(m => m && m.type === o.type && m.x === o.bx * BS && m.y === o.by * BS); if (mm) mm.on = o.on !== false; } }
else if (o.t === 'mold+') { const mo = molds.find(m => m.kind === o.kind && m.x === o.bx * BS && m.y === o.by * BS); if (mo) removeMold(mo); }
@@ -56,8 +56,8 @@ function ghostTick0() {
if (nx.done) { ghostIter = ghostBlocks.entries(); nx = ghostIter.next(); if (nx.done) break; }
const [b, [code, v, e]] = nx.value;
const bx = b % BW, by = (b / BW) | 0;
if (blockP[b] === code && blockV[b] === (code === BP_FILTER ? v & 128 : v)) {
if (code === BP_FILTER && e && !sameFilter(filterInfo(b), cleanFilterCfg(e))) setFilter(b, e); // same piece, new settings
if (blockP[b] === code && blockV[b] === (isFilter(code) ? v & 128 : v)) {
if (isFilter(code) && e && !sameFilter(filterInfo(b), cleanFilterCfg(e))) setFilter(b, e); // same piece, new settings
ghostBlocks.delete(b); ghostBlocked.delete(b); continue;
}
if (!blockInReach(bx, by)) continue;
+6 -4
View File
@@ -27,16 +27,16 @@ function doInteract() {
if (fb >= 0) {
if (!inReach(ax, ay)) { setStatus('Out of reach.'); playSfx('error'); return; }
const run = filterRun(fb);
openFilterPicker(Object.assign(filterInfo(fb), { dir: blockV[fb] & 128 ? 1 : 0 }), f => {
openFilterPicker(Object.assign(filterInfo(fb), { dir: blockV[fb] & 128 ? 1 : 0, code: blockP[fb] }), f => {
applyFilterRun(run, f);
GAME.filterCfg = { m: f.m.slice(), inv: f.inv }; GAME.pieceDir.filter = f.dir;
setStatus(`${run.length} ${run.length === 1 ? 'filter' : 'filters'} updated, carrying ${f.dir ? 'right' : 'left'}.`, 'info');
setStatus(`${run.length} ${run.length === 1 ? 'filter' : 'filters'} updated, carrying ${beltDirection(blockP[fb], f.dir ? 128 : 0)}.`, 'info');
}, run.length > 1 ? `Filter (${run.length} in this row)` : 'Filter');
GAME.editRun = run;
return;
}
if (toolId() === 'build' && pieceUsesWall(piece())) { openWallPicker(); return; }
if (toolId() === 'build' && piece().filter) { openFilterPicker(Object.assign({}, GAME.filterCfg, { dir: GAME.pieceDir.filter || 0 }), f => { GAME.filterCfg = { m: f.m, inv: f.inv }; GAME.pieceDir.filter = f.dir; }, 'New filters'); return; }
if (toolId() === 'build' && piece().filter) { openFilterPicker(Object.assign({}, GAME.filterCfg, { dir: GAME.pieceDir.filter || 0, code: pieceCode(piece()) }), f => { GAME.filterCfg = { m: f.m, inv: f.inv }; GAME.pieceDir.filter = f.dir; }, 'New filters'); return; }
let msg = '';
outer: for (let r = 0; r <= 2; r++) for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {
if (!inReach(ax + dx, ay + dy)) continue;
@@ -59,6 +59,7 @@ function pickUnderCursor() {
else if (blockP[bi] || ghost) {
const code = blockP[bi] || ghost[0], v = blockP[bi] ? blockV[bi] : ghost[1];
const P = PIECE_BY_CODE[code]; pid = P.id;
if (isBelt(code) || isFilter(code)) { GAME.pieceShape = GAME.pieceShape || {}; GAME.pieceShape[P.id] = code; }
if (isWall(code)) { GAME.wallMat = v; GAME.pieceDir.wall = isSlope(code) ? code - BP_SLOPE_BL + 1 : 0; }
else if (P.filter) { GAME.filterCfg = blockP[bi] ? filterInfo(bi) : cleanFilterCfg(ghost[2]); GAME.pieceDir[P.id] = v >> 7; }
else if (P.dirs) GAME.pieceDir[P.id] = v % P.dirs.length;
@@ -99,6 +100,7 @@ function dragDirFor(P, a, b) {
if (!a || !P.dirs || !(P.id === 'belt' || P.id === 'tube' || P.filter)) return -1;
const dx = b.bx - a.bx, dy = b.by - a.by;
if (!dx && !dy) return -1;
if ((P.id === 'belt' || P.filter) && diagonalDrag(dx, dy)) return dx > 0 ? 1 : 0;
if (P.id === 'tube' && diagonalDrag(dx, dy)) return dx > 0 ? (dy < 0 ? 4 : 5) : (dy > 0 ? 6 : 7);
if (P.id === 'tube') return Math.abs(dx) >= Math.abs(dy) ? (dx > 0 ? 1 : 3) : (dy > 0 ? 2 : 0);
return Math.abs(dx) >= Math.abs(dy) ? (dx > 0 ? 1 : 0) : -1;
@@ -163,7 +165,7 @@ function primaryRelease(shift) {
}
if (t === 'build' && GAME.drag) {
const P = piece(), b = aimBlock(), code = pieceCode(P, b);
const list = shift ? blockRect(GAME.drag.bx, GAME.drag.by, b.bx, b.by) : blockLine(GAME.drag.bx, GAME.drag.by, b.bx, b.by, isWall(code) || code === BP_TUBE);
const list = shift ? blockRect(GAME.drag.bx, GAME.drag.by, b.bx, b.by) : blockLine(GAME.drag.bx, GAME.drag.by, b.bx, b.by, isWall(code) || isSlopeBelt(code) || code === BP_TUBE);
const dd = dragDirFor(P, GAME.drag, b);
if (dd >= 0) GAME.pieceDir[P.id] = dd;
const v = pieceV(P), ex = pieceExtra(P);
+5 -3
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: '<rect x="2" y="9" width="20" height="8" rx="2"/><circle cx="7" cy="13" r="2"/><circle cx="17" cy="13" r="2"/><path d="M9 5h7M14 3l2 2-2 2"/>',
filter: '<rect x="2" y="10" width="20" height="8" rx="2"/><path d="M7 10v8M12 10v8M17 10v8M2 14h20"/><path d="M12 2v5M10 5l2 2 2-2"/>',
belt: '<path d="M3 20V16L19 4h3v16z"/><path d="M4 17L20 5"/><circle cx="8" cy="16" r="1.5"/><circle cx="17" cy="9" r="1.5"/>',
filter: '<path d="M3 17L19 5l3 4L6 21zM7 15l3 3M11 12l3 3M15 9l3 3M7 5v5M5 8l2 2 2-2"/>',
tube: '<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"/>',
@@ -52,7 +52,9 @@ function buildVariantChips(P) {
// while dragging a line of belts, filters or tubes, the direction is the drag's
const dragging = GAME.drag && GAME.dragDir >= 0 && P.dirs;
const glyph = d => ({ left: '←', right: '→', up: '↑', down: '↓', 'up-right': '↗', 'down-right': '↘', 'down-left': '↙', 'up-left': '↖' })[d] || '';
const dirChip = !P.dirs ? '' : dragging ? `<span class="chip ok">${glyph(P.dirs[GAME.dragDir])} ${esc(P.dirs[GAME.dragDir])} · from your drag</span>` : `<span class="chip hot">${glyph(dir)} ${esc(dir)} · R</span>`;
const code = pieceCode(P), direction = dragging ? GAME.dragDir : GAME.pieceDir[P.id] || 0;
const slopeDir = isSlopeBelt(code) ? beltDirection(code, P.filter ? direction << 7 : direction) : '';
const dirChip = !P.dirs ? '' : dragging ? `<span class="chip ok">${glyph(slopeDir || P.dirs[GAME.dragDir])} ${esc(slopeDir || P.dirs[GAME.dragDir])} · from your drag</span>` : `<span class="chip hot">${glyph(slopeDir || dir)} ${esc(slopeDir || dir)} · R</span>`;
const fc = GAME.filterCfg;
const sws = ms => ms.slice(0, 6).map(m => `<span class="sw" style="display:inline-block;vertical-align:-2px;width:9px;height:9px;margin-right:2px;background:${rgbOf(m)}"></span>`).join('') + (ms.length > 6 ? `+${ms.length - 6}` : '');
return pieceUsesWall(P) ? `<button class="chip hot" data-wall-picker><span class="sw" style="display:inline-block;vertical-align:-2px;width:9px;height:9px;margin-right:4px;background:${rgbOf(WALL_OF[GAME.wallMat])}"></span>${esc(MD[WALL_OF[GAME.wallMat]].name.toLowerCase())} · R · F</button>${P.code === BP_WALL ? `<span class="chip hot">${['square', '◣ lower left', '◢ lower right', '◤ upper left', '◥ upper right'][isSlope(pieceCode(P)) ? pieceCode(P) - BP_SLOPE_BL + 1 : 0]} · T · turn / flip</span>` : ''}`
+2 -2
View File
@@ -109,7 +109,7 @@ function hoverInfo(ax, ay) {
t1 = press ? 'Brick press' : saw ? 'Saw' : 'Grinder';
t2.push(s && s.blocked ? saw || press ? 'stopped: no room underneath; a belt there carries them away' : 'stopped: clear the space under it' : s && tick - s.busyT < 30 ? press ? 'pressing' : saw ? 'cutting' : 'grinding' : press ? 'idle: drop clay lumps on top' : saw ? 'idle: drop stone rubble or wood chunks on top' : 'idle: drop rubble, gravel, sand or dirt on top', press ? `${stats.pressed || 0} green bricks pressed` : saw ? `${(stats.bricks || 0).toLocaleString('en-US')} stone blocks, ${(stats.planks || 0).toLocaleString('en-US')} planks cut` : `${(stats.refined || 0).toLocaleString('en-US')} ground, ${(stats.refinedOre || 0).toLocaleString('en-US')} ore found`);
} else if (p === P_FILTER) {
const fb = ((ay / BS) | 0) * BW + ((ax / BS) | 0), pr = filterParts(filterInfo(fb)), ar = blockV[fb] & 128 ? '→' : '←';
const owner = slopeSealOwner(i), fb = owner >= 0 ? owner : ((ay / BS) | 0) * BW + ((ax / BS) | 0), pr = filterParts(filterInfo(fb)), ar = beltDirection(blockP[fb], blockV[fb]);
t1 = 'Filter ' + ar; t2.push(`drops ${pr.drops}`, `carries ${pr.rides} ${ar}`, 'F to change · I shows all');
} else if (p === P_PIPE) {
const pb = ((ay / BS) | 0) * BW + ((ax / BS) | 0), pn = pipeNetOf(pb);
@@ -117,7 +117,7 @@ function hoverInfo(ax, ay) {
t2.push(pipeContentsText(pn));
} else if (IS_TUBE[p]) t1 = 'Screw tube ' + PIECE_BY_ID.tube.dirs[TUBE_PARTS.indexOf(p)] + (m !== EMPTY ? ` · ${MD[m].name}` : '');
else if (p) t1 = ({ 5: 'Grate', 6: 'Gate (open)', 7: 'Water wheel', 8: 'Engine intake' })[p] + (m !== EMPTY ? ` · ${MD[m].name}` : '');
else if (m === BELTL || m === BELTR) t1 = 'Belt ' + (m === BELTL ? '←' : '→');
else if (m === BELTL || m === BELTR) { const owner = slopeSealOwner(i), fb = owner >= 0 ? owner : (ay >> 2) * BW + (ax >> 2); t1 = 'Belt ' + beltDirection(blockP[fb], blockV[fb]); }
else if (m === PIPE && isPipeCode(b)) {
const pb = ((ay / BS) | 0) * BW + ((ax / BS) | 0), pn = pipeNetOf(pb), v = blockV[pb], arrows = ['↑', '→', '↓', '←'];
t1 = b === BP_PUMP ? `Pump ${arrows[v & 3]}` : b === BP_OUTLET ? `Outlet ${['↓', '←', '↑', '→'][v & 3]}` : 'Pipe';
+1 -1
View File
@@ -71,7 +71,7 @@ function controlsHtml() {
const tools = [
['1 Resonance drill', 'Hold left click to send pulses from the bot toward the cursor: rock they shake hard enough shatters and falls as rubble, ore, coal or scrap, and trees come apart into wood chunks and acorns. Q and E set the pulse width: narrow bores a tunnel fast, wide clears a big cone slowly, medium is in between. R over a material tunes the drill to it, so only that shatters (great for pulling a vein out of the rock); R again untunes. Built blocks are never harmed. Better bits dig harder rock, faster and further.'],
['2 Pneumatic nozzle', "Left drag picks up the selection, or what you click if nothing is selected. Right click blows the selection. Click a bag name or Q/E selects one; Ctrl+click mixes. Hold Ctrl to see everything found. Clear removes the selection. Bars blow alone. Blowing out the last of a material deselects it. Shift while not flying takes everything; Shift + right click blows everything loose in the bag."],
['3 Build', 'Places pieces on a grid of 4×4 blocks: wall blocks, belts, tubes, filters, grates, gates, pipes, pumps, outlets, Grinders, Saws, Brick presses and Bar racks, machines (fans and silos) and molds. Drag for a line, hold Shift while dragging for a filled rectangle. A diagonal wall drag makes a sealed sloped face, solid below; T flips it. For one wall block, T cycles square and four slope corners. Right click takes a piece back. Your bot flies through anything you build.'],
['3 Build', 'Places pieces on a grid of 4×4 blocks: wall blocks, belts, tubes, filters, grates, gates, pipes, pumps, outlets, Grinders, Saws, Brick presses and Bar racks, machines (fans and silos) and molds. Drag for a line, hold Shift while dragging for a filled rectangle. A diagonal wall drag makes a sealed sloped face, solid below; T flips it. For one wall block, T cycles square and four slope corners. Diagonal belt and filter drags make sloped runs that carry up or down, following the drag. Their faces always point up. Right click takes a piece back. Your bot flies through anything you build.'],
['4 Igniter', 'Heats a small spot to 640°C, and its flame reaches a few grains into a pile. Enough to light coal and wood that touch air.'],
['Copy, cut and paste', 'Hold C (copy) or X (cut) and drag a box over what you built; let go and it is picked up, ready to paste. Cut takes it down and refunds it to your bag. Click to place ghosts: your bot builds them from your bag when it is close. R flips, V pastes again, right click or Esc stops. Ctrl+Z undoes building. The notebook (B) keeps saved blueprints and a few starter designs.'],
];
+14 -3
View File
@@ -15,7 +15,8 @@ function drawGrid(cbx, cby, radius) {
}
function previewColor(code, v) {
if (isWall(code)) { const m = WALL_OF[v]; return `${COLR[m]},${COLG[m]},${COLB[m]}`; }
if (code === BP_FILTER) return '96,100,110';
if (isFilter(code)) return '96,100,110';
if (isBelt(code)) return '110,114,124';
return { [BP_BELT]: '110,114,124', [BP_TUBE]: '120,132,150', [BP_SHAFT]: '160,118,70', [BP_GRATE]: '170,90,66', [BP_GATE]: '160,84,64', [BP_PIPE]: '176,112,72', [BP_PUMP]: '104,122,142', [BP_OUTLET]: '170,106,66' }[code] || '120,200,190';
}
// Air swirls into the pickup ring or spreads from the nozzle toward the cursor.
@@ -107,7 +108,7 @@ function drawPreviews() {
if (P.kind === 'block') {
const v = pieceV(P);
col = previewColor(code, v);
cells = GAME.drag ? (modShift() ? blockRect(GAME.drag.bx, GAME.drag.by, b.bx, b.by) : blockLine(GAME.drag.bx, GAME.drag.by, b.bx, b.by, isWall(code) || code === BP_TUBE)) : [[b.bx, b.by]];
cells = GAME.drag ? (modShift() ? blockRect(GAME.drag.bx, GAME.drag.by, b.bx, b.by) : blockLine(GAME.drag.bx, GAME.drag.by, b.bx, b.by, isWall(code) || isSlopeBelt(code) || code === BP_TUBE)) : [[b.bx, b.by]];
const dd = GAME.drag ? dragDirFor(P, GAME.drag, b) : -1;
if (dd !== GAME.dragDir) { GAME.dragDir = dd; hudDirty = true; }
const dIdx = dd >= 0 ? dd : (GAME.pieceDir[P.id] || 0);
@@ -126,7 +127,7 @@ function drawPreviews() {
previewBlock(code, x, y, w, h, true, P.id === 'tube' ? dIdx : -1, bx, by, planned); ctx.setLineDash([]);
if (P.dirs && (P.id === 'belt' || P.id === 'tube' || P.filter || P.id === 'pump' || P.id === 'outlet')) {
// a chevron on every block, pointing the way it will carry (or pump, or pour)
const [dx, dy] = P.id === 'tube' ? TUBE_DIRS[dIdx] : P.id === 'pump' ? PUMP_V[dIdx] : P.id === 'outlet' ? OUTLET_V[dIdx] : [[-1, 0], [1, 0]][dIdx];
const [dx, dy] = isSlopeBelt(code) ? [dIdx ? 1 : -1, beltGrade(code) * (dIdx ? 1 : -1)] : P.id === 'tube' ? TUBE_DIRS[dIdx] : P.id === 'pump' ? PUMP_V[dIdx] : P.id === 'outlet' ? OUTLET_V[dIdx] : [[-1, 0], [1, 0]][dIdx];
const cx = x + w / 2, cy = y + h / 2, k = w * 0.22, px_ = -dy, py_ = dx;
ctx.strokeStyle = 'rgba(20,16,12,0.55)'; ctx.lineWidth = Math.max(2, zp * 0.9); ctx.lineCap = 'round'; ctx.lineJoin = 'round';
const chev = () => { ctx.beginPath(); ctx.moveTo(cx - dx * k * 0.6 + px_ * k, cy - dy * k * 0.6 + py_ * k); ctx.lineTo(cx + dx * k * 0.7, cy + dy * k * 0.7); ctx.lineTo(cx - dx * k * 0.6 - px_ * k, cy - dy * k * 0.6 - py_ * k); ctx.stroke(); };
@@ -206,6 +207,16 @@ function previewBlock(code, x, y, w, h, stroke = false, direction = -1, bx = -1,
if (stroke) ctx.stroke(); else ctx.fill();
ctx.restore(); return;
}
if (isSlopeBelt(code) && isFilter(code)) {
ctx.beginPath();
if (beltGrade(code) < 0) {
ctx.moveTo(x, y + h); ctx.lineTo(x + w, y); ctx.lineTo(x + w, y + h * 0.25); ctx.lineTo(x + w * 0.25, y + h);
} else {
ctx.moveTo(x, y); ctx.lineTo(x + w, y + h); ctx.lineTo(x + w * 0.75, y + h); ctx.lineTo(x, y + h * 0.25);
}
ctx.closePath(); if (stroke) ctx.stroke(); else ctx.fill(); return;
}
code = slopeShape(code);
if (!isSlope(code)) { if (stroke) ctx.strokeRect(x, y, w, h); else ctx.fillRect(x, y, w, h); return; }
const right = code === BP_SLOPE_BR || code === BP_SLOPE_TR, above = code >= BP_SLOPE_TL;
ctx.beginPath();
+4 -2
View File
@@ -13,7 +13,9 @@ function filterParts(f) {
function filterSentence(f) { const p = filterParts(f); return `drop ${p.drops} and carry ${p.rides}`; }
// Change the whole run of matching filters at once
function applyFilterRun(run, f) {
for (const b of run) { blockV[b] = f.dir ? 128 : 0; setFilter(b, f); }
undoRecord('filter settings', () => {
for (const b of run) { undoNote({ t: 'filter', bx: b % BW, by: (b / BW) | 0, v: blockV[b], extra: filterInfo(b) }); blockV[b] = f.dir ? 128 : 0; setFilter(b, f); }
});
filtersDirty = true;
}
function openFilterPicker(cur, onDone, title) {
@@ -24,7 +26,7 @@ function openFilterPicker(cur, onDone, title) {
<button class="fm" role="radio" data-inv="0"><b>↓ Drop these</b><span>They fall through; the rest rides along</span></button>
<button class="fm" role="radio" data-inv="1"><b>→ Carry these</b><span>They ride along; the rest falls through</span></button>
</div>
<div class="fdir" role="radiogroup" aria-label="Rollers turn"><span class="label">Rollers turn</span><button class="fd" role="radio" data-dir="0">← Left</button><button class="fd" role="radio" data-dir="1">Right →</button></div>`
<div class="fdir" role="radiogroup" aria-label="Rollers turn"><span class="label">Rollers turn</span><button class="fd" role="radio" data-dir="0">${cur.code ? esc(beltDirection(cur.code, 0)) : '← Left'}</button><button class="fd" role="radio" data-dir="1">${cur.code ? esc(beltDirection(cur.code, 128)) : 'Right →'}</button></div>`
+ (list.length ? `<div class="fpick">${list.map(m => `<button class="fp" role="checkbox" data-m="${m}"><span class="ck" aria-hidden="true"></span><span class="sw" style="background:${rgbOf(m)}"></span><span>${esc(MD[m].name)}</span></button>`).join('')}</div>` : '<p class="note">Find something loose first.</p>')
+ `<p class="note fp-sum" id="fp-sum"></p>`;
dialog(title || 'Filter', body, [{ label: 'Clear', cls: 'ghost', fn: () => { sel.clear(); sync(); return true; } }, { label: 'Cancel' }, { label: 'Set filter', cls: 'primary', fn: () => { onDone({ m: [...sel].sort((a, b) => a - b), inv, dir }); hudDirty = true; } }]);
+8 -1
View File
@@ -87,7 +87,7 @@ function pieceV(P) {
return GAME.pieceDir[P.id] || 0;
}
function pieceExtra(P) { return P.filter ? { m: GAME.filterCfg.m.slice(), inv: GAME.filterCfg.inv } : undefined; }
function filterBlockAt(x, y) { if (x < 0 || y < 0 || x >= W || y >= H) return -1; const b = ((y / BS) | 0) * BW + ((x / BS) | 0); return blockP[b] === BP_FILTER ? b : -1; }
function filterBlockAt(x, y) { if (x < 0 || y < 0 || x >= W || y >= H) return -1; const b = ((y / BS) | 0) * BW + ((x / BS) | 0); return isFilter(blockP[b]) ? b : -1; }
function chipCost(cost) { const ok = canAfford(cost, 1); return `<span class="chip ${ok ? 'ok' : 'bad'}">${esc(costText(cost, 1))}</span>`; }
function pieceCostNow(P, m = GAME.wallMat) {
if (P.kind === 'mold') return wallCost(m, MOLDS[P.mold].walls);
@@ -96,6 +96,13 @@ function pieceCostNow(P, m = GAME.wallMat) {
}
function pieceCode(P, end = aimBlock()) {
if (P.id === 'belt' || P.filter) {
const a = GAME.drag || end;
if (GAME.drag && diagonalDrag(end.bx - a.bx, end.by - a.by) && !(typeof modShift === 'function' && modShift()))
return (P.filter ? BP_FILTER_UP : BP_BELT_UP) + ((end.bx - a.bx) * (end.by - a.by) > 0 ? 1 : 0);
if (end.bx === a.bx && end.by === a.by) return GAME.pieceShape && GAME.pieceShape[P.id] || P.code;
return P.code;
}
if (P.code !== BP_WALL) return P.code;
const a = GAME.drag || end, dx = end.bx - a.bx, dy = end.by - a.by;
if (GAME.drag && (dx || dy)) {