From 0cf615eb3df5bbf8de45a5ddabf916b61b3068d3 Mon Sep 17 00:00:00 2001 From: 44r0n7 <44r0n7+gitea@pm.me> Date: Sun, 25 Oct 2026 23:50:45 -0400 Subject: [PATCH] =?UTF-8?q?Sloped=20belts=20and=20filters=20drawn=20as=20o?= =?UTF-8?q?ne=20belt=20turned=20to=2045=C2=B0=20(t40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/PROJECT_MAP.md | 18 ++-- docs/agent-log.md | 8 ++ src/manifest.json | 1 + src/render/slope-belt-shader.js | 84 ++++++++++++++++++ src/render/slope-belts.js | 145 +++++++++++++++++++++++++++----- src/render/smooth-shaders.js | 36 ++------ src/render/world.js | 40 ++++++--- src/ui/build-menu.js | 4 +- src/ui/previews.js | 22 +++-- test/t_slope_belt_art.js | 100 ++++++++++++++++++++++ test/t_slope_belts.js | 4 +- 11 files changed, 383 insertions(+), 79 deletions(-) create mode 100644 src/render/slope-belt-shader.js create mode 100644 test/t_slope_belt_art.js diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index 4fc3457..102e69e 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -356,11 +356,12 @@ Drawing (src/render/): browser only. This file: the renderer state R, colour tab Propagated coloured light from glowing and burning cells. - functions: builtForLight, looseForLight, computeLight -### src/render/slope-belts.js (28 lines) -A slope's band uses world coordinates, so its stripes never restart at block joints. -- functions: slopeBeltAt, slopeBeltColor +### src/render/slope-belts.js (127 lines) +Drawing only: the face stays on its simulation cells; the band hangs below it. +- functions: slopeBeltConnections, flatBeltCaps, prepareSlopeBelts, slopeBeltSegment, slopeBeltCorner, slopeBeltShape, slopeBeltAt, slopeBeltColor +- variables: slopeBeltJoins, slopeBeltHalo, beltCaps, slopeAcross, slopeAlong, slopeDistance -### src/render/world.js (422 lines) +### src/render/world.js (440 lines) Scale colour without changing the smooth-look class. - functions: fogColor, diagonalTubeJoins, diagonalTubeShape, diagonalTubeBlock, drawWorld - variables: TUBE_CLASS, diagonalJoins, diagonalAcross, diagonalAlong, diagonalCap @@ -378,7 +379,11 @@ Smooth look: WebGL setup, texture uploads, drawing and adaptive resolution. - functions: smCompile, smoothAvailable, smUpload, smoothDraw, smoothPace - variables: SM, smBytes -### src/render/smooth-shaders.js (393 lines) +### src/render/slope-belt-shader.js (84 lines) +Smooth slope shading, kept beside the shared CPU geometry in slope-belts.js. +- variables: SM_SLOPE_HELPERS, SM_SLOPE_DRAW + +### src/render/smooth-shaders.js (369 lines) Smooth shaders: vertex and fragment programs for the rounded world look. - variables: SM_VS, SM_FS @@ -471,7 +476,7 @@ Gamepad input. - functions: padPressed, pollPad - variables: PAD -### src/ui/previews.js (227 lines) +### src/ui/previews.js (237 lines) Cursor previews: shared grid helpers, air streaks and tool preview dispatch. - functions: blockRectPx, drawGrid, previewColor, drawAirStreaks, drawReachCircle, drawPreviews, previewBlock @@ -522,6 +527,7 @@ The frame loop and boot. - `test/t_pipes.js`: Pipes, pumps and outlets move liquids; the Grinder grinds waste rock for trace ore. - `test/t_racks.js`: Rack intake, FIFO storage, gate, removal and saved-state validation. - `test/t_sim_optimizations.js`: Optimized paths must match the full roof scan and the original heat loop exactly. +- `test/t_slope_belt_art.js`: Drawing geometry against real parts, without changing transport. - `test/t_slope_belts.js`: Sloped belts: transport, joins, sorting, persistence and the actual drag controls. - `test/t_slopes.js`: Sloped walls: flow, sealed joints, building, refunds and persistence. - `test/t_smooth.py`: The smooth look: the WebGL pass compiles, draws, falls back cleanly, and the thermal lens still works. diff --git a/docs/agent-log.md b/docs/agent-log.md index b8c72a9..7539540 100644 --- a/docs/agent-log.md +++ b/docs/agent-log.md @@ -4,6 +4,14 @@ What agents have done, newest first. Add an entry for every piece of work: the d note or request it was for, what changed, the files touched, the tests run, and anything left open or not verified. Keep each entry short; the git history has the details. +## 2026-10-25: t40 art pass: sloped belts and filters drawn as one belt (Claude Code, Codex) + +- Sloped runs draw as the flat belt (rollers, studs) or filter (tinted mesh) band turned to 45°, + continuous, with elbows into flat belts, filters and tubes and rounded free ends, in both looks + (`src/render/slope-belts.js`, new `slope-belt-shader.js`, `world.js`, `smooth-shaders.js`); + ghosts and icons match. Drawing only. +- `test/t_slope_belt_art.js`; `run_all.py --browser` ALL PASSED. + ## 2026-10-25: t40: sloped belts and filters (Claude Code, Codex) - Block codes 19-22 (`BP_BELT_UP/DOWN`, `BP_FILTER_UP/DOWN`, `src/sim/pieces.js`): slope-shaped diff --git a/src/manifest.json b/src/manifest.json index 4a33a0e..7f8c8b8 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -46,6 +46,7 @@ "render/machines.js", "render/overlays.js", "render/smooth.js", + "render/slope-belt-shader.js", "render/smooth-shaders.js", "ui/audio.js", "ui/state.js", diff --git a/src/render/slope-belt-shader.js b/src/render/slope-belt-shader.js new file mode 100644 index 0000000..3c212d5 --- /dev/null +++ b/src/render/slope-belt-shader.js @@ -0,0 +1,84 @@ +// Smooth slope shading, kept beside the shared CPU geometry in slope-belts.js. +const SM_SLOPE_HELPERS = ` +// The same rounded strokes as slopeBeltShape; y/z carry distance along/across the band. +vec3 slopeStroke(vec2 p, vec2 a, vec2 b, float origin) { + vec2 ray = b - a; float len = length(ray); ray /= len; + float along = dot(p - a, ray), across = dot(p - a, vec2(-ray.y, ray.x)); + vec2 q = abs(vec2(along - len * 0.5, across)) - vec2(len * 0.5 - 0.75, 1.1); + float sd = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - 0.75; + return vec3(sd, origin + along, across + 2.0); +} +vec3 slopeCorner(vec2 p, vec2 a, float origin) { + vec2 d = p - a; float radius = length(d); + return vec3(radius - 1.85, origin + d.x, 2.0 + d.y); +} +vec3 slopeShape(vec2 p, vec2 worldOrigin, float grade, int joins) { + float sy = grade < 0.0 ? 4.0 : 0.0; + vec2 a = vec2(-grade * 1.30814755, sy + 1.30814755), b = vec2(4.0 - grade * 1.30814755, 4.0 - sy + 1.30814755); + if ((joins & 1) != 0) { a.y = sy + 2.0; a.x = (a.y - sy - 2.61629509) / grade; } + else if ((joins & 4) != 0) a -= vec2(4.0, grade * 4.0); + if ((joins & 2) != 0) { b.y = 6.0 - sy; b.x = (b.y - sy - 2.61629509) / grade; } + else if ((joins & 8) != 0) b += vec2(4.0, grade * 4.0); + vec2 axis = vec2(1.0, grade) * 0.70710678; + vec3 best = slopeStroke(p, a, b, dot(worldOrigin + a, axis)); + if ((joins & 1) != 0) { + vec3 h = slopeStroke(p, vec2(-2.0, a.y), a, worldOrigin.x - 2.0); + if (h.x < best.x) best = h; + h = slopeCorner(p, a, worldOrigin.x + a.x); + if (h.x < best.x) best = h; + } + if ((joins & 2) != 0) { + vec3 h = slopeStroke(p, b, vec2(6, b.y), worldOrigin.x + b.x); + if (h.x < best.x) best = h; + h = slopeCorner(p, b, worldOrigin.x + b.x); + if (h.x < best.x) best = h; + } + return best; +} +`; + +const SM_SLOPE_DRAW = ` + // 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 && + cellAt(tTree, c).r * 255.0 >= 22.5 && cellAt(tTree, c).r * 255.0 <= 30.5) { + int slopeClass = int(cellAt(tTree, c).r * 255.0 + 0.5); + int shape = (slopeClass - 23) / 2; + bool mesh = shape >= 2; + float grade = (shape == 0 || shape == 2) ? -1.0 : 1.0; + float dir = ((slopeClass - 23) % 2) == 1 ? 1.0 : -1.0; + vec4 tag = cellAt(tTree, c); + int joins = int(tag.a * 255.0 + 0.5); + vec2 local = floor(tag.gb * 255.0 + 0.5) - 8.0 + fract(w); + vec3 shapePos = slopeShape(local, w - local, grade, joins); + float sd = shapePos.x, axis = shapePos.y, depth = shapePos.z; + float time = (joins & 16) != 0 ? uTime : 0.0; + vec2 lb = vec2(axis, depth), alongWorld = vec2(axis, depth); + float rg = step(0.72, fract((axis - dir * (depth < 2.0 ? 1.0 : -1.0) * time * 30.0) * 0.5)); + vec3 belt; + if (-sd < 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 *= 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); + float bw2 = max(px * 0.9, 0.09); + float hole = 1.0 - smoothstep(0.5 - bw2 - px, 0.5 - bw2, max(cg.x, cg.y)); + vec3 steel = dot(own.rgb, vec3(1)) > 0.01 ? mix(vec3(0.42, 0.44, 0.5), own.rgb, 0.4) : vec3(0.42, 0.44, 0.5); + belt = mix(steel * 0.78, C * 0.8, hole); + } else { + vec2 dv = vec2(mod(axis, 2.0) - 1.0, depth - 2.0); + float rd = length(dv); + belt = vec3(0.15, 0.16, 0.18); + if (rd < 0.95) { + float ang = atan(dv.y, dv.x) - dir * time * 30.0 / 0.95; + float spoke = smoothstep(0.55, 0.85, cos(ang * 3.0)); + belt = mix(vec3(0.5, 0.36, 0.25), vec3(0.72, 0.53, 0.36), 0.5 + 0.5 * dot(normalize(dv + 1e-4), vec2(-0.6, -0.8)) * smoothstep(0.2, 0.95, rd)); + belt *= mix(1.0, 0.72, spoke * smoothstep(0.25, 0.4, rd)); + belt *= mix(1.0, 0.55, smoothstep(0.82, 0.95, rd)); + } + } + C = mix(C, belt, 1.0 - smoothstep(-px, px, sd)); + } +`; diff --git a/src/render/slope-belts.js b/src/render/slope-belts.js index 7e3f9db..599199b 100644 --- a/src/render/slope-belts.js +++ b/src/render/slope-belts.js @@ -1,28 +1,127 @@ -// 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; +// Drawing only: the face stays on its simulation cells; the band hangs below it. +let slopeBeltJoins = new Uint8Array(0), slopeBeltHalo = new Uint8Array(0), beltCaps = new Uint8Array(0); +let slopeAcross = 0, slopeAlong = 0, slopeDistance = 0; +function slopeBeltConnections(bx, by, code, planned = null) { + const grade = beltGrade(code); + let joins = 0; + for (let end = 0; end < 2; end++) { + const side = end ? 1 : -1, nx = bx + side, ny = by + side * grade; + const nb = ny * BW + nx, next = planned && planned.get(nb); + if (nx >= 0 && nx < BW && ny >= 0 && ny < BH && beltGrade(next ? next[0] : blockP[nb]) === grade) { + joins |= 4 << end; continue; + } + const fy = by + (end ? grade > 0 : grade < 0), fb = fy * BW + nx; + if (nx < 0 || nx >= BW || fy < 0 || fy >= BH) continue; + const flat = planned && planned.get(fb), c = flat ? flat[0] : blockP[fb], v = flat ? flat[1] : blockV[fb]; + if (c === BP_BELT || c === BP_FILTER || (c === BP_TUBE && (v === 1 || v === 3))) joins |= 1 << end; + } + return joins; +} +function flatBeltCaps(b) { + const bx = b % BW, by = (b / BW) | 0; + let caps = 0; + for (let end = 0; end < 2; end++) { + const x = bx + (end ? 1 : -1); + let joined = false; + if (x >= 0 && x < BW) for (let dy = -1; dy <= 0; dy++) { + if (by + dy < 0) continue; + const n = (by + dy) * BW + x, c = blockP[n], g = beltGrade(c); + if ((!dy && (c === BP_BELT || c === BP_FILTER || (c === BP_TUBE && (blockV[n] === 1 || blockV[n] === 3 || blockV[n] >= 4)))) || + (g && dy === -(end ? g < 0 : g > 0))) joined = true; + } + if (!joined) caps |= 1 << end; + } + return caps; +} +function prepareSlopeBelts(x0, y0, x1, y1) { + if (slopeBeltJoins.length !== NB) { slopeBeltJoins = new Uint8Array(NB); slopeBeltHalo = new Uint8Array(NB); beltCaps = new Uint8Array(NB); } + slopeBeltHalo.fill(0); + for (let by = Math.max(0, (y0 >> 2) - 2); by <= Math.min(BH - 1, (y1 >> 2) + 2); by++) { + for (let bx = Math.max(0, (x0 >> 2) - 2); bx <= Math.min(BW - 1, (x1 >> 2) + 2); bx++) { + const b = by * BW + bx, c = blockP[b]; + if (c === BP_BELT || c === BP_FILTER) beltCaps[b] = flatBeltCaps(b); + if (!isSlopeBelt(c)) continue; + const face = beltFaceY(c, bx, by, bx * BS) * W + bx * BS; + slopeBeltJoins[b] = slopeBeltConnections(bx, by, c) | (netSat(face) > 0 ? 16 : 0) | (filterCfg.get(b)?.inv ? 32 : 0); + for (let yy = Math.max(0, by - 1); yy <= Math.min(BH - 1, by + 1); yy++) + for (let xx = Math.max(0, bx - 1); xx <= Math.min(BW - 1, bx + 1); xx++) slopeBeltHalo[yy * BW + xx] = 1; + } + } +} +// Rounded stroke, with coordinates along/across the winning segment for rollers and mesh. +function slopeBeltSegment(x, y, ax, ay, bx, by, origin) { + const dx = bx - ax, dy = by - ay, len = Math.hypot(dx, dy), ux = dx / len, uy = dy / len; + const along = (x - ax) * ux + (y - ay) * uy, across = -(x - ax) * uy + (y - ay) * ux; + const qx = Math.abs(along - len / 2) - len / 2 + 0.75, qy = Math.abs(across) - 1.1; + const sd = Math.hypot(Math.max(0, qx), Math.max(0, qy)) + Math.min(Math.max(qx, qy), 0) - 0.75; + if (sd < slopeDistance) { slopeDistance = sd; slopeAcross = across + 2; slopeAlong = origin + along; } +} +function slopeBeltCorner(x, y, ax, ay, origin) { + const dx = x - ax, dy = y - ay, radius = Math.hypot(dx, dy), sd = radius - 1.85; + if (sd < slopeDistance) { slopeDistance = sd; slopeAcross = 2 + dy; slopeAlong = origin + dx; } +} +function slopeBeltShape(x, y, b, joins = slopeBeltJoins[b], code = blockP[b]) { + const grade = beltGrade(code), ox = (b % BW) * BS, oy = ((b / BW) | 0) * BS; + x -= ox; y -= oy; + const offset = 1.85 * Math.SQRT2, sy = grade < 0 ? 4 : 0; + const normal = 1.85 * Math.SQRT1_2; + let ax = -grade * normal, ay = sy + normal, bx = 4 - grade * normal, by = 4 - sy + normal; + if (joins & 1) { ay = sy + 2; ax = (ay - sy - offset) / grade; } + else if (joins & 4) { ax -= 4; ay -= grade * 4; } + if (joins & 2) { by = 6 - sy; bx = (by - sy - offset) / grade; } + else if (joins & 8) { bx += 4; by += grade * 4; } + slopeDistance = Infinity; + const origin = (ox + grade * oy) * Math.SQRT1_2; + slopeBeltSegment(x, y, ax, ay, bx, by, origin + (ax + grade * ay) * Math.SQRT1_2); + if (joins & 1) { + slopeBeltSegment(x, y, -2, ay, ax, ay, ox - 2); + slopeBeltCorner(x, y, ax, ay, ox + ax); + } + if (joins & 2) { + slopeBeltSegment(x, y, bx, by, 6, by, ox + bx); + slopeBeltCorner(x, y, bx, by, ox + bx); + } + return slopeDistance; +} +function slopeBeltAt(i, x, y, smooth = false) { + const own = (y >> 2) * BW + (x >> 2), code = blockP[own]; + if (!slopeBeltHalo[own]) return -1; + const slope = isSlopeBelt(code); + if (!slope && (mach[i] || occ[i] || (mat[i] !== EMPTY && mat[i] !== BELTL && mat[i] !== BELTR))) return -1; + let seal = !slope && (mat[i] === BELTL || mat[i] === BELTR || part[i] === P_FILTER) ? slopeSealOwner(i) : -1; + if (seal >= 0 && !isSlopeBelt(blockP[seal])) seal = -1; + if (!slope && seal < 0 && (mat[i] !== EMPTY || part[i] || mach[i] || occ[i] || code)) return -1; + let best = smooth ? 0.8 : 0, found = slope ? own : seal; + for (let yy = Math.max(0, (y >> 2) - 1); yy <= Math.min(BH - 1, (y >> 2) + 1); yy++) { + for (let xx = Math.max(0, (x >> 2) - 1); xx <= Math.min(BW - 1, (x >> 2) + 1); xx++) { + const b = yy * BW + xx; + if (!isSlopeBelt(blockP[b])) continue; + const sd = slopeBeltShape(x + 0.5, y + 0.5, b); + if (sd < best) { best = sd; found = b; } + } + } + return found; } 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; } + const sd = slopeBeltShape(x + 0.5, y + 0.5, b); + if (sd > 1e-8) return (br | 0) | ((bg | 0) << 8) | ((bb | 0) << 16); + const c = blockP[b], joins = slopeBeltJoins[b], mesh = isFilter(c); + const dir = mesh ? (blockV[b] & 128 ? 1 : -1) : (blockV[b] ? 1 : -1); + const a = slopeAlong, d = slopeAcross, time = joins & 16 ? anim : 0; + let r = 38, g = 41, blue = 46; + if (-sd < 0.8) { + const stripe = (Math.floor((a - dir * (d < 2 ? 1 : -1) * (time >> 1)) / 2) & 1), k = d < 2 ? 1 : 0.72; + r = (stripe ? 92 : 56) * k; g = (stripe ? 96 : 58) * k; blue = (stripe ? 104 : 66) * k; + } else if (mesh) { + const f = filterCfg.get(b), m = f && f.m.length ? f.m[(Math.floor(a / 2) & 65535) % f.m.length] : 0; + if ((Math.floor(a) + Math.floor(d)) & 1) { r = br * 0.8; g = bg * 0.8; blue = bb * 0.8; } + else { r = m ? COLR[m] * 0.75 : 64; g = m ? COLG[m] * 0.75 : 68; blue = m ? COLB[m] * 0.75 : 76; } + } else { + const dx = ((a % 2 + 2) % 2) - 1, dy = d - 2; + if (dx * dx + dy * dy < 0.95 * 0.95) { + const spin = Math.cos((Math.atan2(dy, dx) - dir * time / 1.9) * 3) > 0.6; + r = spin ? 104 : 132; g = spin ? 94 : 120; blue = spin ? 84 : 108; + } } return (r | 0) | ((g | 0) << 8) | ((blue | 0) << 16); } diff --git a/src/render/smooth-shaders.js b/src/render/smooth-shaders.js index bf5ae7b..07b3ac6 100644 --- a/src/render/smooth-shaders.js +++ b/src/render/smooth-shaders.js @@ -50,9 +50,8 @@ float beltSD(vec2 lb, bool endL, bool endR) { // The outline of the belt or filter in cell c. Belts and filters side by side are one belt, so // 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 <= 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); + int caps = int(cellAt(tTree, c).a * 255.0 + 0.5); + return beltSD(lb, (caps & 1) != 0, (caps & 2) != 0); } // 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. @@ -62,6 +61,8 @@ float bandRidge(vec2 w, vec2 lb, float dir) { } vec3 bandColor(vec2 lb, float rg) { return mix(vec3(0.2, 0.21, 0.24), vec3(0.34, 0.35, 0.39), rg) * (lb.y < 2.0 ? 1.0 : 0.72); } +${SM_SLOPE_HELPERS} + void main() { float zp = uView.z, px = 1.0 / zp; vec2 sp = vec2(gl_FragCoord.x, uView.w - gl_FragCoord.y); @@ -185,6 +186,8 @@ void main() { C = mix(C, tc, smoothstep(-0.6, 0.6, (F.r - 0.5) / fwT)); } +${SM_SLOPE_DRAW} + // ---- loose grains: little round pebbles; a pocket closed in by four grains is filled int np = int(isN(n0, 2) + isN(n1, 2) + isN(n2, 2) + isN(n3, 2)); bool closed = isM(n0) + isM(n1) + isM(n2) + isM(n3) > 3.5; @@ -352,33 +355,6 @@ 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) { diff --git a/src/render/world.js b/src/render/world.js index cc28eb3..d8ff782 100644 --- a/src/render/world.js +++ b/src/render/world.js @@ -92,6 +92,7 @@ function drawWorld() { if (blockP[b] === BP_TUBE && blockV[b] >= 4) diagonalJoins[b] = diagonalTubeJoins(bx, by, blockV[b]); } } + prepareSlopeBelts(vx0, vy0, vx0 + bw, vy0 + bh); const useFog = SET.fog; const OUT = sm ? 0x04000000 : 0xff000000; // unexplored and off the map: black const lighting = SET.lighting !== 'off' && !R.lens; @@ -224,7 +225,9 @@ function drawWorld() { } } // field parts drawn over contents + const sb = slopeBeltAt(i, x, y, sm); let p = part[i]; + if (p === P_FILTER && (sb >= 0 || isSlopeBelt(blockP[slopeSealOwner(i)]))) p = 0; const db = diagonalTubeBlock(x, y, sm); let diagonal = false; if (db >= 0) { @@ -281,8 +284,8 @@ 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 slopeArt = sb >= 0 && (m === EMPTY || PH[m] === PH_GAS || m === BELTL || m === BELTR); + if (slopeArt && !sm) { const color = slopeBeltColor(sb, x, y, anim, br, bgc, bb); r = color & 255; g = color >> 8 & 255; b = color >> 16 & 255; } @@ -307,9 +310,12 @@ function drawWorld() { // 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; - tpx[row + bx] = ((fc && fc.inv ? 0x30 : 0x20) << 24) | (fm ? (COLB[fm] << 16) | (COLG[fm] << 8) | COLR[fm] : 0); + tpx[row + bx] = ((0x20 | (fc && fc.inv ? 0x10 : 0) | beltCaps[fb]) << 24) | (fm ? (COLB[fm] << 16) | (COLG[fm] << 8) | COLR[fm] : 0); + } + else if (m === BELTL || m === BELTR) { + cl = m === BELTL ? 7 : 8; + tpx[row + bx] = beltCaps[(y >> 2) * BW + (x >> 2)] << 24; } - else if (m === BELTL || m === BELTR) cl = m === BELTL ? 7 : 8; else if (pt || PARTMAT[m] || mmask[row + bx]) cl = 4; else if (m !== EMPTY) { const ph = PH[m]; @@ -324,12 +330,14 @@ function drawWorld() { const wm = WALL_OF[blockV[(y >> 2) * BW + (x >> 2)]]; r = COLR[wm]; g = COLG[wm]; b = COLB[wm]; } - if (slopeArt) { + if (sb >= 0 && (slopeArt || PH[m] === PH_POWDER)) { 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); + const slopeClass = 23 + (code - BP_BELT_UP) * 2 + dir; + if (slopeArt) cl = slopeClass; + const f = isFilter(code) && filterCfg.get(sb), fm = f && f.m.length ? f.m[(Math.floor((x + beltGrade(code) * y) / 2) & 65535) % f.m.length] : 0; + tpx[row + bx] = ((slopeBeltJoins[sb] | 128) << 24) | slopeClass | + ((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; } } 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); @@ -380,12 +388,22 @@ function drawWorld() { let tr = 90, tg = 190, tb = 230; 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; } - for (let dy = 0; dy < BS; dy++) for (let dx = 0; dx < BS; dx++) { + 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++) { + let sd = 0; + if (slope) { + const x = gbx * BS + dx, y = gby * BS + dy; + if (x < 0 || y < 0 || x >= W || y >= H) continue; + 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; + } if (isSlope(code) && !slopeCell(code, dx, dy)) continue; const sx = gbx * BS + dx - vx0, sy = gby * BS + dy - vy0; if (sx < 0 || sy < 0 || sx >= bw || sy >= bh) continue; const k = sy * bw + sx, c = px[k]; - const edge = dx === 0 || dy === 0 || dx === BS - 1 || dy === BS - 1; + const edge = slope ? sd > -0.8 : dx === 0 || dy === 0 || dx === BS - 1 || dy === BS - 1; const a = edge ? (((dx + dy + pulse) & 3) === 0 ? 0.85 : 0.6) : 0.28; const pr = c & 255, pg = (c >> 8) & 255, pb = (c >> 16) & 255; px[k] = (sm ? 0x04000000 : 0xff000000) | ((pb + (tb - pb) * a) << 16) | ((pg + (tg - pg) * a) << 8) | (pr + (tr - pr) * a); diff --git a/src/ui/build-menu.js b/src/ui/build-menu.js index 380104e..edd351a 100644 --- a/src/ui/build-menu.js +++ b/src/ui/build-menu.js @@ -12,8 +12,8 @@ const PIECE_ICONS = { wall: '', grate: '', gate: '', - belt: '', - filter: '', + belt: '', + filter: '', tube: '', pipe: '', pump: '', diff --git a/src/ui/previews.js b/src/ui/previews.js index f8768b2..f74820f 100644 --- a/src/ui/previews.js +++ b/src/ui/previews.js @@ -207,14 +207,24 @@ 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)) { + if (isSlopeBelt(code)) { + const b = by * BW + bx, joins = bx >= 0 ? slopeBeltConnections(bx, by, code, planned) : 0; + const scale = w / BS, step = SET.look === 'smooth' ? 0.25 : 1; + const ox = bx * BS, oy = by * BS; 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); + for (let yy = -4; yy < 8; yy += step) for (let xx = -4; xx < 8; xx += step) { + const wx = ox + xx, wy = oy + yy, ix = Math.floor(wx), iy = Math.floor(wy); + if (bx >= 0 && (ix < ox || ix >= ox + BS || iy < oy || iy >= oy + BS)) { + if (ix < 0 || ix >= W || iy < 0 || iy >= H) continue; + const i = iy * W + ix, n = (iy >> 2) * BW + (ix >> 2), next = planned && planned.get(n); + if (mat[i] !== EMPTY || part[i] || mach[i] || occ[i] || (blockP[n] && !isSlopeBelt(blockP[n])) || (next && !isSlopeBelt(next[0]))) continue; + } + const sd = slopeBeltShape(wx + step / 2, wy + step / 2, b, joins, code); + if (sd <= 0 && (!stroke || sd > -step * 1.5)) ctx.rect(x + xx * scale, y + yy * scale, step * scale, step * scale); } - ctx.closePath(); if (stroke) ctx.stroke(); else ctx.fill(); return; + if (stroke) { const fill = ctx.fillStyle; ctx.fillStyle = ctx.strokeStyle; ctx.fill(); ctx.fillStyle = fill; } + 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; } diff --git a/test/t_slope_belt_art.js b/test/t_slope_belt_art.js new file mode 100644 index 0000000..3df4b94 --- /dev/null +++ b/test/t_slope_belt_art.js @@ -0,0 +1,100 @@ +// Drawing geometry against real parts, without changing transport. +const fs = require('fs'); +const path = require('path'); +const { load, suite, flatWorld } = require('./harness'); +const { run } = load(); +const { check, done } = suite(); +run(fs.readFileSync(path.join(__dirname, '../src/render/slope-belts.js'), 'utf8')); +flatWorld(run, 160, 120, 112); +for (const code of ['BP_BELT_UP', 'BP_BELT_DOWN', 'BP_FILTER_UP', 'BP_FILTER_DOWN']) { + run(` + mat.fill(EMPTY); part.fill(0); blockP.fill(0); blockV.fill(0); + var artCode = ${code}, artGrade = beltGrade(artCode), artB = 12 * BW + 12; + for (let k = -2; k <= 2; k++) placeBlock(artCode, 1, 12 + k, 12 + artGrade * k, true); + prepareSlopeBelts(0, 0, W, H); + `); + check(`${code}: diagonal neighbours continue the band`, run('(slopeBeltJoins[artB] & 12) === 12')); + check(`${code}: corner seals use the same band`, run(`(() => { + const i = slopeSeal(artCode, 12, 12); + return i < 0 || slopeBeltAt(i, i % W, (i / W) | 0) >= 0; + })()`)); + check(`${code}: band is as thick as a flat belt`, run(`(() => { + const g = artGrade, x = 50 - g * 1.85 * Math.SQRT1_2, y = 50 + 1.85 * Math.SQRT1_2; + return slopeBeltShape(x, y, artB) < -1.8 && + slopeBeltShape(x - g * 1.7 * Math.SQRT1_2, y + 1.7 * Math.SQRT1_2, artB) < 0 && + slopeBeltShape(x - g * 2.1 * Math.SQRT1_2, y + 2.1 * Math.SQRT1_2, artB) > 0; + })()`)); + check(`${code}: air below the triangle gets the continuous band`, run(`(() => { + for (let y = 48; y < 57; y++) for (let x = 48; x < 56; x++) { + const i = y * W + x, own = (y >> 2) * BW + (x >> 2); + if (!blockP[own] && mat[i] === EMPTY && slopeBeltAt(i, x, y) >= 0) return true; + } + return false; + })()`)); + run(` + for (let end = 0; end < 2; end++) { + const side = end ? 1 : -1; + removeBlock(12 + side, 12 + side * artGrade, true); + placeBlock(BP_BELT, 1, 12 + side, 12 + (end ? artGrade > 0 : artGrade < 0), true); + } + prepareSlopeBelts(0, 0, W, H); + `); + check(`${code}: both flat joints form elbows`, run('(slopeBeltJoins[artB] & 3) === 3')); + check(`${code}: adjoining flats do not round off at the joint`, run(`(() => { + const l = (12 + (artGrade < 0)) * BW + 11, r = (12 + (artGrade > 0)) * BW + 13; + return !(flatBeltCaps(l) & 2) && !(flatBeltCaps(r) & 1); + })()`)); + check(`${code}: overhang never covers terrain, parts, machines or bars`, run(`(() => { + for (let y = 48; y < 57; y++) for (let x = 48; x < 56; x++) { + const i = y * W + x, own = (y >> 2) * BW + (x >> 2); + if (blockP[own] || mat[i] !== EMPTY || slopeBeltAt(i, x, y) < 0) continue; + mat[i] = STONE; const ground = slopeBeltAt(i, x, y); mat[i] = EMPTY; + part[i] = P_GRATE; const built = slopeBeltAt(i, x, y); part[i] = 0; + mach[i] = 1; const machine = slopeBeltAt(i, x, y); mach[i] = 0; + occ[i] = 1; const bar = slopeBeltAt(i, x, y); occ[i] = 0; + return ground < 0 && built < 0 && machine < 0 && bar < 0; + } + return false; + })()`)); +} +check('free end rounds its corners and stops at the end', run(`(() => { + const b = artB, x = 48 - Math.SQRT2, y = 48 + Math.SQRT2; + blockP[b] = BP_BELT_DOWN; + return slopeBeltShape(x + 0.8, y + 0.8, b, 0) < 0 && slopeBeltShape(x - 1, y - 1, b, 0) > 0; +})()`)); +check('flat belts still meet diagonal tube elbows without an end cap', run(`(() => { + blockP[artB - 1] = BP_BELT; + blockP[artB] = BP_TUBE; blockV[artB] = 4; + const joined = !(flatBeltCaps(artB - 1) & 2); + blockP[artB] = BP_BELT_DOWN; + return joined; +})()`)); +check('unpowered rollers and studs stand still', run(`(() => { + slopeBeltJoins[artB] &= ~16; + for (let y = 48; y < 56; y++) for (let x = 48; x < 56; x++) + if (slopeBeltColor(artB, x, y, 0, 150, 170, 190) !== slopeBeltColor(artB, x, y, 20, 150, 170, 190)) return false; + return true; +})()`)); +check('powered rollers and studs reverse with the belt', run(`(() => { + slopeBeltJoins[artB] |= 16; + let moves = false; + for (let y = 48; y < 56; y++) for (let x = 48; x < 56; x++) { + blockV[artB] = 1; + const a = slopeBeltColor(artB, x, y, 12, 150, 170, 190), stopped = slopeBeltColor(artB, x, y, 0, 150, 170, 190); + blockV[artB] = 0; + if (a !== slopeBeltColor(artB, x, y, -12, 150, 170, 190)) return false; + if (a !== stopped) moves = true; + } + return moves; +})()`)); +check('drawing leaves the seed, cells and saved block data unchanged', run(`(() => { + const snapshot = () => JSON.stringify([RS, Array.from(mat), Array.from(part), Array.from(blockP), Array.from(blockV)]); + const before = snapshot(); + prepareSlopeBelts(0, 0, W, H); + for (let y = 40; y < 64; y++) for (let x = 40; x < 72; x++) { + const b = slopeBeltAt(y * W + x, x, y); + if (b >= 0) slopeBeltColor(b, x, y, 12, 150, 170, 190); + } + return snapshot() === before; +})()`)); +done(); diff --git a/test/t_slope_belts.js b/test/t_slope_belts.js index 3e12340..71caa67 100644 --- a/test/t_slope_belts.js +++ b/test/t_slope_belts.js @@ -138,7 +138,9 @@ fresh(); run('placeBlock(BP_BELT_UP,1,20,32,true);for(let l=1;l<=CARRY_DEPTH;l++)setCell((131-l)*W+80,SAND,20,0);tickStamp++;carryColumn(130*W+80,1,null);'); check('a slope carries the same full 96-grain column as a flat belt',run('Array.from({length:CARRY_DEPTH},(_,k)=>mat[(129-k)*W+81]===SAND).every(Boolean)')); run(fs.readFileSync('src/render/slope-belts.js','utf8')); -check('sloped stripes vary along the face and travel with the motor',run('(()=>{const b=32*BW+20,c=(x,y,t)=>slopeBeltColor(b,x,y,t,0,0,0);return c(80,131,0)!==c(82,129,0) && c(80,131,0)===c(82,129,4);})()')); +// The renderer prepares joins and power first; stripe pitch is measured along the rotated band. +run('prepareSlopeBelts(0,0,W,H);'); +check('sloped stripes vary along the face and travel with the motor',run('(()=>{const b=32*BW+20,c=(x,y,t)=>slopeBeltColor(b,x,y,t,0,0,0);return c(81,130,0)!==c(82,129,0) && c(81,130,0)===c(82,129,4);})()')); run(fs.readFileSync('src/ui/selection.js','utf8')); run(fs.readFileSync('src/ui/actions.js','utf8')); run("globalThis.GAME={pieceDir:{belt:1,filter:1},filterCfg:{m:[SAND],inv:0},wallMat:RSTONE};globalThis.hudDirty=false;globalThis.aimBlock=()=>({bx:25,by:15});globalThis.toolId=()=>'build';globalThis.playSfx=()=>{};");