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(', ')}`); +}