From 3ee85429fdd798f2ad50bc0e9b773f5ce43b424c Mon Sep 17 00:00:00 2001 From: 44r0n7 <44r0n7+gitea@pm.me> Date: Mon, 26 Oct 2026 20:08:21 -0400 Subject: [PATCH] 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 --- docs/DESIGN.md | 17 ++++++++++++ docs/PROJECT_MAP.md | 24 ++++++++--------- docs/agent-log.md | 26 +++++++++++++++++++ src/render/slope-belt-shader.js | 20 +++++++++++--- src/render/slope-belts.js | 46 ++++++++++++++++++++++++++------- src/render/world.js | 23 ++++++++++++----- src/sim/cells.js | 23 ++++++++++------- src/sim/slope-conveyors.js | 19 ++++++++++++-- src/sim/world.js | 1 + src/ui/build-menu.js | 4 +-- src/ui/previews.js | 24 +++++++++-------- test/t_slope_belt_art.js | 38 ++++++++++++++++++++++++++- test/t_smooth.py | 21 +++++++++++++++ tools/experiments/slope-cost.js | 21 +++++++++++++-- 14 files changed, 248 insertions(+), 59 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 564fc36..085a457 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -411,6 +411,12 @@ above, including: shake; none climbs a 45° slope. So filters are flat or downhill only; to sort while lifting, run a sloped belt up, then a filter. Not built yet. +- **Conveyor drawing and sleep follow-up (t42/t43, 2026-10-26).** The owner wants visible + rubber ribs on the actual cleat raster, filled corrugated far walls behind the load, and + smooth continuous mesh with rails for inclined screens. Walls stop at the slope ends; + only loaded screens shake. Empty moving cleats must let chunks sleep. Keep pocket transport, + uphill throughput near 71 grains/s and exact sorting unchanged. + - **Sidewall belts with moving cleats (t42, 2026-10-26).** To replace the old full-column grip, use moving barrier lines rather than material cells: the barrier mask blocks normal powder movement without overwriting grains, terrain or parts. Cleats stand perpendicular @@ -432,6 +438,17 @@ above, including: Both looks use a sturdy mesh frame with a small tick-based shake only while loaded; idle mesh has no running belt animation. No old uphill-save migration was added. +- **Screw conveyors stay, in every direction (2026-10-26).** The owner asked whether screw + conveyors are still needed now that belts climb 45°, whether they're realistic, and what a + downward screw is for. Kept as they are: real plants use both (screws for steep, vertical and + enclosed runs; belts for long, cheap, high-volume ones), and a downward screw is the same + machine turning the other way, useful for letting material down at a steady rate. +- **Stalls are the player's to fix, but visible; belts carry a realistic depth (t45, + 2026-10-26).** An overloaded belt line jammed against a screw conveyor above it. The owner + agreed that's realistic and stays the player's problem, but wants a warning on the stalled + belt and the animation stopped on everything affected, and belts limited to a realistic + load depth instead of carrying 96-grain stacks. Not built yet. + ## Ideas not built yet - **Concrete**, as a stronger wall material from rubble. diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index ad0ba99..a546d0f 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -220,7 +220,7 @@ Starter furnace designs and the milestone list shown in the notebook. - functions: smelterBlueprint - variables: STARTER_DESIGNS, MILESTONES -### src/sim/world.js (105 lines) +### src/sim/world.js (106 lines) World state: the per-cell typed arrays, alloc(), ambient temperature by depth, waking. - functions: emit, bagCap, discover, alloc, depthU, depthY, meanSurface, computeAmbient, fillNoise, wakeXY, wakeHeatXY, wakeIdx, wakeHeatIdx - variables: W, H, N, CW, CH, NCH, mat, temp, life, flags, stamp, part, mach, digp, noiseB, airS, netId, isPow, occ, surfY, dirtY, graniteY, basaltY, magmaY, roofY, AMB, worldSeed, surfBase, underD, shallowD, lapse, BW, BH, NB, blockP, blockV, ents, bgm, flyers, ghostBlocks, ghostMolds, awake, hot, hotLvl, powderArea, roofDirty, loadWakeOrder, loadWakePending, loadWakeIndex, tick, tickStamp, machines, molds, nets, pcells, powerDirty, beltL, beltR, screwLists, brokenMachines, ghostMachines, ghostBlocked, EV, bot, bag, bagTotal, player, seen, ledger, stats @@ -230,9 +230,9 @@ Exploration only: never changes grains or the random stream. - functions: fogAlloc, fogMark, fogSeen, fogInit, fogLook, fogRevealAround, fogRevealAll - variables: FOG, FOG_SENSE, FOG_SENSE_FADE, fog, FW, FH -### src/sim/cells.js (218 lines) +### src/sim/cells.js (223 lines) Cell helpers (setCell, move, enter) and the falling-sand movement of powders, liquids and gases (simMove). -- functions: setCell, swapCells, move, enter, powder, passable, liquid, gas, wakeBotPowder, simMove +- functions: setCell, swapCells, move, enter, enterFree, powder, passable, liquid, gas, wakeBotPowder, simMove - variables: SORTP, SORTP_FUEL, LSTEP, LFILM, CORNER_SEAL ### src/sim/heat.js (265 lines) @@ -269,10 +269,10 @@ How belts, filters and screw conveyors move what is on or in them. What rests on - functions: conveyorStep, carryColumn, beltDrain, launchGrain, rebuildFilters, filterCarry, carryBeltsAndFilters, setFilter, filterRun, roomFor, screwSpread, screwIntake, diagonalScrewNext - variables: CARRY_DEPTH, filterR, filterL, filtersDirty -### src/sim/slope-conveyors.js (51 lines) +### src/sim/slope-conveyors.js (66 lines) Cleats are moving barriers, not material cells. A cleat supports a finite pocket; the bare face never holds grains. -- functions: cleatFoot, pocketDepth, slopeCleatsTick, screenNudge -- variables: CLEAT_SPACING, CLEAT_HEIGHT, CLEAT_REACH, SCREEN_NUDGE_TICKS, cleatCarrying, cleatMask, cleatCells, slopePhase +- functions: cleatFoot, pocketDepth, slopeCleatsTick, screenNudge, wakeCleatLoad +- variables: CLEAT_SPACING, CLEAT_HEIGHT, CLEAT_REACH, SCREEN_NUDGE_TICKS, cleatCarrying, cleatChunks, cleatMask, cleatCells, slopePhase ### src/sim/blocks.js (219 lines) Building blocks: placing, integrity, removing, lines and rectangles of blocks. @@ -361,12 +361,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 (150 lines) +### src/render/slope-belts.js (176 lines) Drawing only: the face stays on its simulation cells; the band hangs below it. -- functions: slopeBeltConnections, flatBeltCaps, prepareSlopeBelts, slopeBeltSegment, slopeBeltCorner, slopeBeltShape, slopeBeltAt, slopeBeltColor, screenLoaded, slopeDetailAt -- variables: slopeBeltJoins, slopeBeltHalo, beltCaps, slopeAcross, slopeAlong, slopeDistance +- functions: slopeBeltConnections, flatBeltCaps, prepareSlopeBelts, slopeBeltSegment, slopeBeltCorner, slopeBeltShape, slopeBeltAt, slopeBeltColor, screenLoaded, slopeWallShape, slopeWallAt, slopeDetailAt, slopePreviewCleat +- variables: slopeBeltJoins, slopeBeltHalo, beltCaps, slopeAcross, slopeAlong, slopeDistance, slopeWallOwner -### src/render/world.js (447 lines) +### src/render/world.js (456 lines) Scale colour without changing the smooth-look class. - functions: fogColor, diagonalScrewJoins, diagonalScrewShape, diagonalScrewBlock, drawWorld - variables: SCREW_CLASS, diagonalJoins, diagonalAcross, diagonalAlong, diagonalCap @@ -384,7 +384,7 @@ Smooth look: WebGL setup, texture uploads, drawing and adaptive resolution. - functions: smCompile, smoothAvailable, smUpload, smoothDraw, smoothPace - variables: SM, smBytes -### src/render/slope-belt-shader.js (85 lines) +### src/render/slope-belt-shader.js (99 lines) Smooth slope shading, kept beside the shared CPU geometry in slope-belts.js. - variables: SM_SLOPE_HELPERS, SM_SLOPE_DRAW @@ -481,7 +481,7 @@ Gamepad input. - functions: padPressed, pollPad - variables: PAD -### src/ui/previews.js (248 lines) +### src/ui/previews.js (250 lines) Cursor previews: shared grid helpers, air streaks and tool preview dispatch. - functions: blockRectPx, drawGrid, previewColor, drawAirStreaks, drawReachCircle, drawPreviews, previewBlock diff --git a/docs/agent-log.md b/docs/agent-log.md index 014fab4..fda7735 100644 --- a/docs/agent-log.md +++ b/docs/agent-log.md @@ -4,6 +4,32 @@ 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-26: t42/t43 drawing and performance follow-up (Codex) + +- Filled the far sidewall down to the belt, behind grains, with two-tone pixel corrugation + and a continuous smooth 45-degree band. Rubber ribs use the physics mask in all eight + phases. Fixed smooth wall seams; matched ghosts, cursor previews and menu icons. + Smooth screens retain tinted mesh, now between solid rails without the pixel overlay; + loaded-only shake and transport are unchanged. +- Empty cleats only wake nearby load; per-chunk flags bypass mask reads for distant powder. + Empty eight-block slope: 3 -> 0 moving areas after 120 ticks. Uphill sand/gravel/copper ore + 71.3 grains/s, coal 71.2; exact sorting retained. Isolated 1f8a89d -> current, median five + generated falling-world samples: no slopes 2.006 -> 2.063 ms/tick; 50 slopes 1.952 -> 2.076. + Powder probe with cached zero flag: 45.02 -> 50.56 ms (2.14M calls); residual 12 percent + microbenchmark overhead, about 3 percent in the generated no-slope world. Existing loaded + 576-block stress: 0.515 -> 1.064 ms/tick. slope-cost.js records the reproducible methodology; + raw results /tmp/t42-perf-{pre,after}-isolated.txt. Pre-t42 loaded slopes use old grip physics. +- Files: sim cells/slope-conveyors/world; render slope-belts/slope-belt-shader/world; + UI previews/build-menu; t_slope_belt_art.js, t_smooth.py, slope-cost.js; PROJECT_MAP, + DESIGN and t42/t43 reports. Reused enterFree for the ordinary entry rules. +- Final python3 test/run_all.py --browser: ALL PASSED (26 headless suites, 360 play, + 16 smooth checks; art suite 57). Before/after suite outputs differ only by five new + checks (/tmp/t42-output.diff). Opened shots/t{42,43}-follow-{belt,screen}-{pixel,smooth}-z{4,8}.png; + rechecked smooth belt after fixing seams; opened t42-follow-ghosts-{pixel,smooth}-z8.png + with both updated menu icons visible. Added sleep, border/load wake, wall geometry, + grain colour and smooth grain-class checks. Rebuilt dist, regenerated map; both tasks Review. + Owner playtest and hardware GPU appearance remain unverified. No commit. + ## 2026-10-26: t42: sidewall belts with moving cleats (Codex) - Continued the uncommitted prototype. `sim/slope-conveyors.js` supplies moving barrier diff --git a/src/render/slope-belt-shader.js b/src/render/slope-belt-shader.js index 9ebaf98..116e5e3 100644 --- a/src/render/slope-belt-shader.js +++ b/src/render/slope-belt-shader.js @@ -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); diff --git a/src/render/slope-belts.js b/src/render/slope-belts.js index 2676e2e..1e6f7af 100644 --- a/src/render/slope-belts.js +++ b/src/render/slope-belts.js @@ -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; } diff --git a/src/render/world.js b/src/render/world.js index bed0638..fa0e002 100644 --- a/src/render/world.js +++ b/src/render/world.js @@ -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; diff --git a/src/sim/cells.js b/src/sim/cells.js index 583dd82..4b61faf 100644 --- a/src/sim/cells.js +++ b/src/sim/cells.js @@ -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); } } } diff --git a/src/sim/slope-conveyors.js b/src/sim/slope-conveyors.js index ec7993d..eb1342c 100644 --- a/src/sim/slope-conveyors.js +++ b/src/sim/slope-conveyors.js @@ -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); +} diff --git a/src/sim/world.js b/src/sim/world.js index 198abd8..ea9e6f5 100644 --- a/src/sim/world.js +++ b/src/sim/world.js @@ -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 diff --git a/src/ui/build-menu.js b/src/ui/build-menu.js index 7d1dbcc..9dd1605 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: '', screw: '', pipe: '', pump: '', diff --git a/src/ui/previews.js b/src/ui/previews.js index c37bb80..04447fb 100644 --- a/src/ui/previews.js +++ b/src/ui/previews.js @@ -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); diff --git a/test/t_slope_belt_art.js b/test/t_slope_belt_art.js index e1e8c62..12447e7 100644 --- a/test/t_slope_belt_art.js +++ b/test/t_slope_belt_art.js @@ -105,7 +105,7 @@ for (const grade of [-1,1]) { check(`grade ${grade} phase ${phase} draws exactly the physical cleat raster`,run(`(()=>{ let drawn=0; for(let i=0;i0; })()`)); } @@ -117,4 +117,40 @@ run('setCell(47*W+48,SAND,20,0);prepareSlopeBelts(0,0,W,H);'); check('loaded screen shakes the frame with the simulation tick',run('(slopeBeltJoins[12*BW+12]&64)!==0')); run('tick=4;prepareSlopeBelts(0,0,W,H);'); check('loaded frame returns on the next shake beat',run('(slopeBeltJoins[12*BW+12]&64)===0')); +flatWorld(run,160,120,112); +run('for(let k=0;k<6;k++)placeBlock(BP_BELT_UP,1,12+k,20-k,true);rebuildPower();slopeCleatsTick(false);'); +check('wall reaches the belt and cleat height, and clips its free ends',run(`(()=>{ + const b=20*BW+12,g=-1,sy=84; + for(const depth of [0.5,3,5.5]) + if(slopeWallShape(50+g*depth*Math.SQRT1_2,sy-2-depth*Math.SQRT1_2,b)>0)return false; + return slopeWallShape(46,sy+2,b)>0 && slopeWallShape(54,sy-6,b)>0; +})()`)); +check('far wall is filled down to the belt and grains cover it',run(`(()=>{ + prepareSlopeBelts(0,0,W,H); + let wall=0; + for(let y=8;y<100;y++)for(let x=8;x<140;x++) { + const i=y*W+x; + if(slopeDetailAt(i,x,y)!==2)continue; + wall++; + mat[i]=SAND;const covered=slopeDetailAt(i,x,y)===0;mat[i]=EMPTY; + if(!covered)return false; + } + return wall>20; +})()`)); +flatWorld(run,320,240,232); +run('for(let k=0;k<8;k++)placeBlock(BP_BELT_UP,1,12+k,30-k,true);for(let t=0;t<120;t++)simTick();'); +check('idle empty slope lets every moving area sleep',run('awake.every(v=>v===0)'),run('awake.reduce((n,v)=>n+(v>0),0)')); +check('cleat chunk flags include neighbours across chunk borders',run(`(()=>{ + for(const i of cleatCells) { + const x=i%W,y=(i/W)|0; + for(let yy=y-1;yy<=y+1;yy++)for(let xx=x-1;xx<=x+1;xx++) + if(xx>=0 && xx=0 && yy>5)*CW+(xx>>5)])return false; + } + return cleatCells.length>0 && cleatChunks.some(v=>v===0); +})()`)); +check('a load next to a cleat wakes after the empty line slept',run(`(()=>{ + const i=cleatCells[0];setCell(i,SAND,20,0);awake.fill(0); + slopeCleatsTick(false); + return awake.some(v=>v>0) && awake[0]===0; +})()`)); done(); diff --git a/test/t_smooth.py b/test/t_smooth.py index ea91c1e..0d10257 100644 --- a/test/t_smooth.py +++ b/test/t_smooth.py @@ -76,6 +76,27 @@ with sync_playwright() as p: check('built solids get no loose-grain outline', ground['built'][0] == ground['built'][1], ground['built']) check('smooth: a thin darker seam separates ground and rubble', ground['smooth'][0] < ground['smooth'][1] * 0.9, ground['smooth']) check('smooth: packed rubble keeps pebble shading', ground['texture'] > 30, ground['texture']) + wall = pg.evaluate('''() => { + GAME.paused = true; R.lens = false; SET.fog = false; + SET.lighting = 'off'; SET.glow = 'off'; SET.grain = false; + for(let y=20;y<150;y++)for(let x=40;x<240;x++)setCell(y*W+x,EMPTY,22,0); + for(let k=0;k<6;k++)placeBlock(BP_BELT_UP,1,25+k,25-k,true); + rebuildPower();slopeCleatsTick(false);prepareSlopeBelts(40,20,240,150); + let cell=-1; + for(let y=40;y<100 && cell<0;y++)for(let x=100;x<124;x++) + if(slopeDetailAt(y*W+x,x,y)===2){cell=y*W+x;break;} + if(cell<0)return null; + const x=cell%W,y=(cell/W)|0,control=80*W+180; + bot.x=118;bot.y=78;snapCamera(); + setCell(cell,SAND,22,0);setCell(control,SAND,22,0); + noiseB[cell]=noiseB[control]=128; + const at=i=>R.px[(((i/W)|0)-R.vy0)*R.bw+i%W-R.vx0]; + SET.look='pixel';drawWorld();const colors=[at(cell)&0xffffff,at(control)&0xffffff]; + SET.look='smooth';drawWorld();const grainClass=at(cell)>>>24; + return {colors,grainClass}; + }''') + check('sidewall stays behind the grain colour in pixels and smooth', + wall and wall['colors'][0] == wall['colors'][1] and wall['grainClass'] == 2, wall) # a browser without hardware drawing gets the pixel look and a note pg2 = b.new_page(viewport={'width': 800, 'height': 500}) pg2.add_init_script('localStorage.clear();') diff --git a/tools/experiments/slope-cost.js b/tools/experiments/slope-cost.js index 9ec0ed1..0e03632 100644 --- a/tools/experiments/slope-cost.js +++ b/tools/experiments/slope-cost.js @@ -20,15 +20,32 @@ assert(samples.length===5 && samples.every(n=>Number.isFinite(n) && n>0)); samples.sort((a,b)=>a-b); console.log(`576 loaded slope blocks, median ${samples[2].toFixed(3)} ms/tick; samples ${samples.map(v=>v.toFixed(3)).join(', ')}`); -// No slopes: isolate the extra guards in the shared powder hot loop. +// No slopes: use the zero flag supplied once per chunk by simMove. Older powder ignores it. flatWorld(run,512,384,376); run('RS=7;for(let y=32;y<350;y+=2)for(let x=32;x<480;x++){mat[y*W+x]=SAND;mat[(y+1)*W+x]=STONE;}'); const powderSamples=[]; for(let sample=0;sample<6;sample++) { const start=performance.now(); - run('for(let k=0;k<30;k++)for(let y=32;y<350;y+=2)for(let x=32;x<480;x++)powder(y*W+x,x,y,SAND);'); + run('for(let k=0;k<30;k++)for(let y=32;y<350;y+=2)for(let x=32;x<480;x++)powder(y*W+x,x,y,SAND,0);'); if(sample)powderSamples.push(performance.now()-start); } assert(powderSamples.length===5 && powderSamples.every(n=>Number.isFinite(n) && n>0)); powderSamples.sort((a,b)=>a-b); console.log(`2.14M powder calls without cleats, median ${powderSamples[2].toFixed(2)} ms`); + +// Generated terrain plus a replenished falling sand curtain, with zero or 50 slope blocks. +for (const slopes of [0, 50]) { + const times = []; + for (let sample = 0; sample < 6; sample++) { + run(`generateWorld(12345, 1024, 640); weather.next=1e15; money=1e9; + for(let y=16;y<160;y++)for(let x=80;x<480;x++)setCell(y*W+x,EMPTY,AMB[y],0); + for(let k=0;k<${slopes};k++) {const bx=25+(k%10),by=32+Math.floor(k/10)*5-(k%10); + placeBlock(BP_BELT_UP,1,bx,by,true);} + for(let t=0;t<60;t++){for(let x=80;x<480;x++)if(mat[16*W+x]===EMPTY)setCell(16*W+x,SAND,AMB[16],0);simTick();}`); + const start = performance.now(); + run(`for(let t=0;t<180;t++){for(let x=80;x<480;x++)if(mat[16*W+x]===EMPTY)setCell(16*W+x,SAND,AMB[16],0);simTick();}`); + if (sample) times.push((performance.now()-start)/180); + } + assert(times.every(n=>Number.isFinite(n) && n>0)); times.sort((a,b)=>a-b); + console.log(`generated falling world, ${slopes} slopes: ${times[2].toFixed(3)} ms/tick; ${times.map(n=>n.toFixed(3)).join(', ')}`); +}