diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index d5e1b22..564fc36 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -411,6 +411,27 @@ 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.
+- **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
+ to the face, spaced 8 horizontal face cells apart and 6 grains tall (integer normal reach
+ 4); named constants keep these tunable. A finite periodic pocket envelope carries only
+ what fits, stepping every other tick. End pockets continue while the cleat rounds the band.
+ Between steps no grains are stamped: bare face slides and excess rolls back. Water drains;
+ bars stay level. Dark corrugated far walls sit behind visible grains; drawing uses the
+ actual barrier mask, including after save/load. The envelope is a discrete transport model,
+ not a calculation of contact forces. A generous flat feed delivers about 71 grains/s uphill
+ versus 90 flat; accepting that reduction remains the owner's playtest decision.
+- **Inclined vibrating screens built (t43, 2026-10-26).** Diagonal filters always travel
+ downhill, even when dragged uphill. R leaves a picked slope unchanged; flipping a blueprint
+ mirrors its slope and keeps downhill travel. Placement and filter settings enforce the
+ same direction. Sloped filters have neither column carry nor grip: gravity moves grains,
+ with one mesh-contact nudge every 4 ticks (15 downhill steps/s plus gravity). Unselected
+ bars step on that same cadence; selected bars fall through. Flat filters retain their
+ linear transport. Sorting both slope shapes in either mode conserves the complete mix.
+ 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.
+
## 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 0eb941f..ad0ba99 100644
--- a/docs/PROJECT_MAP.md
+++ b/docs/PROJECT_MAP.md
@@ -171,7 +171,7 @@ dist/, shots/ build output and screenshots (not committed)
| Starter furnace designs | `src/sim/progress.js` (`STARTER_DESIGNS`, `smelterBlueprint`) |
| Falling sand, liquids, gases | `src/sim/cells.js` |
| Heat, melting, fire, reactions, alloys, steel | `src/sim/heat.js` |
-| Belts, filters, screw conveyors | `src/sim/conveyors.js`, `src/sim/machine-tick.js` |
+| Belts, filters, screw conveyors | `src/sim/conveyors.js`, `src/sim/slope-conveyors.js` (cleat barriers, pocket depth and screen nudges), `src/sim/machine-tick.js` |
| Fans | `src/sim/machine-tick.js` (`fanBlow`) |
| Refiner, Stone cutter, Silo | `src/sim/processors.js` |
| Pipes, pumps, outlets | `src/sim/pipes.js` |
@@ -205,9 +205,9 @@ Material definitions: ids, properties and metal families. ids, and drill/tank ti
Material tables: typed properties, casting groups, flags, parts and tool tiers. Typed property tables
- variables: PH, DENS, KC, ICAP, CASTMETAL, METAL_TIER, SOLIDMETAL, F_NAT, F_CAST, F_BURN, P_NONE, P_SCREW_U, P_SCREW_D, P_SCREW_L, P_SCREW_R, P_GRATE, P_GOPEN, P_WHEEL, P_INTAKE, P_FILTER, P_PIPE, P_SCREW_UR, P_SCREW_DR, P_SCREW_DL, P_SCREW_UL, SCREW_PARTS, SCREW_DIRS, IS_SCREW, SCREW_DX, SCREW_DY, BIT_TIERS, TANK_TIERS, REACH
-### src/sim/pieces.js (176 lines)
+### src/sim/pieces.js (177 lines)
Everything the player can build (PIECES): blocks, machines and molds, with costs and descriptions; filter settings; wall materials and their cost.
-- functions: isSlopeBelt, isFilter, isBelt, beltGrade, slopeShape, beltFaceY, beltDirection, isSlope, isWall, slopeCell, slopeCode, isPipeCode, filterable, cleanFilterCfg, filterRec, filterPassAt, filterInfo, sameFilter, wallCost
+- functions: isSlopeBelt, isFilter, isBelt, beltGrade, slopeShape, beltFaceY, filterVariant, beltDirection, isSlope, isWall, slopeCell, slopeCode, isPipeCode, filterable, cleanFilterCfg, filterRec, filterPassAt, filterInfo, sameFilter, wallCost
- variables: BS, BP_WALL, BP_BELT, BP_SCREW, BP_SHAFT, BP_GRATE, BP_GATE, BP_FILTER, BP_PIPE, BP_PUMP, BP_OUTLET, BP_GRIND, BP_SAW, BP_PRESS, BP_RACK, BP_SLOPE_BL, BP_SLOPE_BR, BP_SLOPE_TL, BP_SLOPE_TR, BP_BELT_UP, BP_BELT_DOWN, BP_FILTER_UP, BP_FILTER_DOWN, filterCfg, WALL_OF, WALL_LIST, BAR_OF, GRAINS_PER_BAR, POWER_FREE, CASH, PIECES, PIECE_BY_ID, PIECE_BY_CODE, BELT_POWER, SCREW_POWER, FAN_POWER, WHEEL_K, ENGINE_K, MOLDS
### src/sim/economy.js (152 lines)
@@ -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 (104 lines)
+### src/sim/world.js (105 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,7 +230,7 @@ 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 (208 lines)
+### src/sim/cells.js (218 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
- variables: SORTP, SORTP_FUEL, LSTEP, LFILM, CORNER_SEAL
@@ -260,15 +260,20 @@ Pipes, pumps and outlets: each joined line holds one liquid as a count, not as g
- functions: sideOf, pipeClosedSide, pipeJoins, rebuildPipes, pipeNetOf, pipeTakeShare, faceCell, pumpDraw, outletPour, pipesTick
- variables: PIPE_CAP, PUMP_RATE, OUTLET_RATE, PUMP_MAX_T, SIDE_V, PUMP_V, OUTLET_V, pipeNets, pipeNetAt, pipesDirty
-### src/sim/machine-tick.js (154 lines)
+### src/sim/machine-tick.js (155 lines)
Per-tick machine work: steam engines, fans, belts, filters, screw conveyors (machinesTick).
- functions: engineIntake, fanBlow, machinesTick
-### src/sim/conveyors.js (202 lines)
+### src/sim/conveyors.js (197 lines)
How belts, filters and screw conveyors move what is on or in them. What rests on a belt or filter rides along: the whole stack standing on the carrying surface steps one cell toward d together (belts run front of the line first, so the way ahead is clear). If a powder grain is blocked, the load above it stands still and the belt slips underneath. A filter's own material drops through it instead, unless it is already full.
-- functions: conveyorStep, carryColumn, holdSlopeColumn, beltDrain, launchGrain, rebuildFilters, filterCarry, carryBeltsAndFilters, setFilter, filterRun, roomFor, screwSpread, screwIntake, diagonalScrewNext
+- 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)
+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
+
### src/sim/blocks.js (219 lines)
Building blocks: placing, integrity, removing, lines and rectangles of blocks.
- functions: blockOf, blockCellList, pieceCost, setSlopeSeal, slopeSeal, slopeSealOwner, wallCells, canPlaceBlock, placeBlock, blockIntegrity, removeWallCell, removeBlock, diagonalDrag, blockLine, blockRect, blockInReach, blockIs, placeBlocks
@@ -342,7 +347,7 @@ World generation helpers: noise and removal of tiny hard rock specks.
World generation: staged terrain, ore, caves and placement, with synchronous and async entry points.
- functions: generateWorld, generateWorldAsync
-### src/sim/save.js (177 lines)
+### src/sim/save.js (179 lines)
Saving and loading a world (serializeWorld / deserializeWorld) and old-save migration.
- functions: serializeWorld, deserializeWorld, startLoadWake, wakeLoadArea, wakeLoadNearBot, loadWakeTick
- variables: SAVE_VERSION, TOFF
@@ -356,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 (127 lines)
+### src/render/slope-belts.js (150 lines)
Drawing only: the face stays on its simulation cells; the band hangs below it.
-- functions: slopeBeltConnections, flatBeltCaps, prepareSlopeBelts, slopeBeltSegment, slopeBeltCorner, slopeBeltShape, slopeBeltAt, slopeBeltColor
+- functions: slopeBeltConnections, flatBeltCaps, prepareSlopeBelts, slopeBeltSegment, slopeBeltCorner, slopeBeltShape, slopeBeltAt, slopeBeltColor, screenLoaded, slopeDetailAt
- variables: slopeBeltJoins, slopeBeltHalo, beltCaps, slopeAcross, slopeAlong, slopeDistance
-### src/render/world.js (440 lines)
+### src/render/world.js (447 lines)
Scale colour without changing the smooth-look class.
- functions: fogColor, diagonalScrewJoins, diagonalScrewShape, diagonalScrewBlock, drawWorld
- variables: SCREW_CLASS, diagonalJoins, diagonalAcross, diagonalAlong, diagonalCap
@@ -379,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 (84 lines)
+### src/render/slope-belt-shader.js (85 lines)
Smooth slope shading, kept beside the shared CPU geometry in slope-belts.js.
- variables: SM_SLOPE_HELPERS, SM_SLOPE_DRAW
@@ -444,7 +449,7 @@ The build menu along the bottom: categories, piece tiles and their icons.
- functions: pieceIcon, catOf, selectBuildCat, cycleBuildCat, buildVariantChips, renderBuildBar, setupBuildBar
- variables: BUILD_CATS, BUILD_ORDER, PIECE_ICONS, lastBuildKey, lastBuildMenu
-### src/ui/selection.js (119 lines)
+### src/ui/selection.js (120 lines)
Tool selection: cycling, drill tuning, piece variants and costs.
- functions: pieceSize, pieceUsesWall, setDrillWidth, tuneUnderCursor, cycleItem, cycleWall, rotate, toggleBlow, pickFilterUnderCursor, toggleFilterMat, setBrush, pieceV, pieceExtra, filterBlockAt, chipCost, pieceCostNow, pieceCode, turnSlope
@@ -476,7 +481,7 @@ Gamepad input.
- functions: padPressed, pollPad
- variables: PAD
-### src/ui/previews.js (234 lines)
+### src/ui/previews.js (248 lines)
Cursor previews: shared grid helpers, air streaks and tool preview dispatch.
- functions: blockRectPx, drawGrid, previewColor, drawAirStreaks, drawReachCircle, drawPreviews, previewBlock
@@ -549,6 +554,8 @@ The frame loop and boot.
- `tools/experiments/flow.js`: Throughput of belts and screw conveyors: grains delivered per second. Whatever lands past the outlet is taken away, so the output pile never backs up into the line.
- `tools/experiments/furnace.js`: Furnace scenarios on a flat test world: builds a chamber, charges it with ore and coal, lights it and prints temperatures, burning cells and what has melted or set at intervals.
- `tools/experiments/section.js`: ASCII cross-section of a stone furnace firing, with a temperature digit per cell (0-9), printed at intervals. Usage: node tools/experiments/section.js [WALL_MATERIAL] (default STONE).
+- `tools/experiments/slope-cost.js`: Many loaded slopes, with a fresh deterministic world for each timing sample.
+- `tools/experiments/slope-flow.js`: Reproducible generous-feed throughput and settled pile angles for t42.
- `tools/experiments/smelter.js`: Smelter design experiments, built with the same block pieces a player uses. Layout (block rows, 4x4 grains each), on flat ground at row 25: roof with a vent ........ row g-CH-1 chamber, CH blocks tall .. charge of coal + copper ore inside grate floor ............. row g = 23 (liquids pass, powders do not) ingot mold .............. row 24, sitting on the ground side walls run from the roof down past the mold. Usage: node t_smelter.js [key=value ...]
- `tools/experiments/starter.js`: Plays a starter design the way a player would: paste the blueprint, let the bot build it from the bag, blow an even ore/coal mix inside, light it, wait, knock out the castings. Usage: node tools/experiments/starter.js [design id] [ore] [coal]
- `tools/experiments/worldgen.js`: World generation report: time to generate, how many cells of each material, where the bot starts, and what idle ticks and ticks after digging cost.
@@ -557,6 +564,7 @@ The frame loop and boot.
- `tools/scenes/clear-sky.js`: A clear work area left of the bot's starting point (the trade dock is to its right): open sky 160 grains wide and 40 tall, over a flat stone floor. Money and a bag of building stuff to build with. Sets FLOOR (the first row of the floor), BX, BY (the leftmost block of the area that stands on the floor) and CAM (the middle of the area).
- `tools/scenes/mold-labels.js`: Run after clear-sky.js: filling, blocked, set, and hot machinery in one view.
- `tools/scenes/silo.js`: Run after clear-sky.js; leave paused so the open gate keeps its demonstration charge.
+- `tools/scenes/slope-conveyors.js`: Loaded sidewall conveyor and screen. Use --js 'slopeScene(false)' (belt) or 'slopeScene(true)' (screen).
- `tools/scenes/stone-uses.js`: The stone and sand machines: a half-full silo with its gate open over a belt, a stone cutter fed with rubble, and cut stone and glass brick walls. Run clear-sky.js first, e.g. python3 tools/shot.py /tmp/stone.png --setup tools/scenes/clear-sky.js --js "$(cat tools/scenes/stone-uses.js)" --ticks 240 --zoom 7
- `tools/scenes/t33-ground.js`: Paused dug pocket: copper ore, rubble and dirt against stone, granite and dirt. Use --js "..." to set daylight or R.lens after this setup; CAM frames the pocket.
- `tools/scenes/t35-diagonal.js`: Run after clear-sky.js: a belt takes sand from a pile, up a diagonal screw to an upper belt.
diff --git a/docs/agent-log.md b/docs/agent-log.md
index 289ac48..014fab4 100644
--- a/docs/agent-log.md
+++ b/docs/agent-log.md
@@ -4,6 +4,52 @@ 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: sidewall belts with moving cleats (Codex)
+
+- Continued the uncommitted prototype. `sim/slope-conveyors.js` supplies moving barrier
+ lines (8 face cells apart, 6 grains tall), finite periodic pocket capacity and saved phase.
+ Removed slope grip; natural powder movement runs between steps and surplus rolls back.
+ Fixed the shared slope-to-screw mouth boundary so grains cannot fall under its casing.
+ Pixel/smooth drawing uses the actual mask, continuous dark far walls, matching previews/icons.
+- Files: manifest; sim cells/conveyors/slope-conveyors/machine-tick/items/pieces/blocks/world/save;
+ render slope-belts/slope-belt-shader/world; UI actions/selection/selection-pickers/previews/
+ build-menu/notebook; slope test suites and play.py; slope-flow/slope-cost experiments,
+ slope-conveyors scene, PROJECT_MAP, DESIGN and t42/t43. Removed both requested scratch files.
+- Reproducible `node tools/experiments/slope-flow.js`: sand, gravel, copper ore 71.3 and
+ coal 71.2 grains/s uphill, each 90.0 flat; 20-second window after 220 warm-up ticks.
+ Measured settled pile angles 33.7 degrees for all four. Final HEAD -> current stress measurement: 0.486 -> 1.038 ms/tick for 576 loaded
+ slope blocks (1024x512); no-slope powder microbenchmark, 2.14M calls: 45.07 -> 65.55 ms.
+ Median of five samples after warm-up; scripts use DG_HARNESS for the HEAD snapshot in
+ /tmp/t42-baseline. Barriers skip mask reads when none exist, but still have a CPU cost.
+- Opened shots/t42-belt-{pixel,smooth}-z{4,8}.png. Headless checks pass, including pocket
+ overflow, collision masks, unstamped bare-face sliding, joins, screw mouths, water, bars,
+ save phase, all eight drawing phases and unchanged seed hashes 777/77/4242.
+ Full python3 test/run_all.py --browser: ALL PASSED (26 headless suites, 360 play
+ checks, 15 smooth checks). Rebuilt dist and regenerated PROJECT_MAP. Both tasks moved to
+ Review. The owner still decides whether the lower rate feels
+ right. Pocket movement is a discrete envelope, not a calculation of individual forces.
+ No commit.
+
+## 2026-10-26: t43: inclined vibrating screens (Codex)
+
+- Diagonal filters always run downhill through all placement/settings/undo/paste paths;
+ R leaves a picked slope unchanged. The picker shows fixed downhill travel rather than two
+ misleading direction buttons. Flat filters retain their original carry. Slope grains get
+ gravity plus one mesh-contact nudge every 4 ticks (15 steps/s); unselected bars use the
+ same cadence. No whole-column carry or between-step grip on slopes.
+- Pixel and smooth mesh frames shake a little while loaded, with stable idle drawing.
+ Updated ghosts, icons, build text, notebook and the relevant sim/UI/render files listed
+ in t42 above. Added real browser drag/R/picker checks and art shake checks.
+- Both shapes and both sorting modes deliver the complete 64 sand + 64 gravel mix exactly.
+ Generous-feed discharge is 60.0 grains/s down-left and 59.3 down-right; rest passes through.
+ Opened shots/t43-screen-{pixel,smooth}-z{4,8}.png. Headless and smooth suites pass; the
+ first browser run found a hover fixture above the mesh and a later timeout. Corrected the
+ coordinate and reset picked slope state between fixtures. All new browser checks pass.
+ A subsequent run caught an existing 200 ms silo HUD timing race; wait for the expected
+ HUD text before the same assertion. Final full browser run: ALL PASSED, 360 play
+ checks plus 15 smooth checks; all 26 headless suites pass. t43 moved to Review.
+ No uphill-save migration. No commit; owner playtest remains.
+
## 2026-10-26: t44: rename screw tubes to screw conveyors (Codex)
- Player text uses Screw conveyor; code uses screw/SCREW, including the piece id, part
diff --git a/src/manifest.json b/src/manifest.json
index 7f8c8b8..c3cb68b 100644
--- a/src/manifest.json
+++ b/src/manifest.json
@@ -20,6 +20,7 @@
"sim/pipes.js",
"sim/machine-tick.js",
"sim/conveyors.js",
+ "sim/slope-conveyors.js",
"sim/blocks.js",
"sim/molds.js",
"sim/items.js",
diff --git a/src/render/slope-belt-shader.js b/src/render/slope-belt-shader.js
index 3c212d5..9ebaf98 100644
--- a/src/render/slope-belt-shader.js
+++ b/src/render/slope-belt-shader.js
@@ -13,6 +13,7 @@ vec3 slopeCorner(vec2 p, vec2 a, float origin) {
return vec3(radius - 1.85, origin + d.x, 2.0 + d.y);
}
vec3 slopeShape(vec2 p, vec2 worldOrigin, float grade, int joins) {
+ if ((joins & 64) != 0) p.y -= 0.45;
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; }
@@ -52,11 +53,11 @@ const SM_SLOPE_DRAW = `
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;
+ float time = !mesh && (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) {
+ 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);
diff --git a/src/render/slope-belts.js b/src/render/slope-belts.js
index 7e373c6..2676e2e 100644
--- a/src/render/slope-belts.js
+++ b/src/render/slope-belts.js
@@ -42,8 +42,8 @@ function prepareSlopeBelts(x0, y0, x1, y1) {
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++)
+ slopeBeltJoins[b] = slopeBeltConnections(bx, by, c) | (netSat(face) > 0 ? 16 : 0) | (filterCfg.get(b)?.inv ? 32 : 0) | (isFilter(c) && netSat(face) > 0 && screenLoaded(b) && (tick & 2) ? 64 : 0);
+ for (let yy = Math.max(0, by - 2); 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;
}
}
@@ -63,6 +63,7 @@ function slopeBeltCorner(x, y, ax, ay, origin) {
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;
+ if (isFilter(code) && (joins & 64)) y -= 0.45;
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;
@@ -107,9 +108,9 @@ function slopeBeltColor(b, x, y, anim, br, bg, bb) {
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;
+ const a = slopeAlong, d = slopeAcross, time = !mesh && joins & 16 ? anim : 0;
let r = 38, g = 41, blue = 46;
- if (-sd < 0.8) {
+ if (-sd < (mesh ? 1.1 : 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) {
@@ -125,3 +126,25 @@ function slopeBeltColor(b, x, y, anim, br, bg, bb) {
}
return (r | 0) | ((g | 0) << 8) | ((blue | 0) << 16);
}
+
+function screenLoaded(b) {
+ const bx = b % BW, by = (b / BW) | 0, c = blockP[b];
+ for (let x = bx * BS; x < bx * BS + BS; x++) {
+ const i = (beltFaceY(c, bx, by, x) - 1) * W + x;
+ if (i >= 0 && (PH[mat[i]] === PH_POWDER || occ[i])) return true;
+ }
+ return false;
+}
+// Far sidewalls stay behind the load. The cleat raster is the simulation barrier raster.
+function slopeDetailAt(i, x, y) {
+ 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;
+ }
+ return 0;
+}
diff --git a/src/render/world.js b/src/render/world.js
index 53b46c8..bed0638 100644
--- a/src/render/world.js
+++ b/src/render/world.js
@@ -289,6 +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);
+ 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;
+ }
// incandescence
if (T > 450 && m !== EMPTY && incand) {
const gi = glowIndex(PH[m] === PH_SOLID ? T + nz * 0.9 : m === MAGMA ? T + Math.sin(x * 0.19 + y * 0.11 + anim * 0.03 + nz * 0.04) * 55 - 30 : T), w = GLOW_W[gi];
@@ -339,6 +345,7 @@ 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;
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);
}
diff --git a/src/sim/blocks.js b/src/sim/blocks.js
index 0b93eb3..9694aa5 100644
--- a/src/sim/blocks.js
+++ b/src/sim/blocks.js
@@ -68,7 +68,7 @@ function placeBlock(code, v, bx, by, free, extra) {
}
if (isPipeCode(code)) pipesDirty = true;
blockP[by * BW + bx] = code; blockV[by * BW + bx] = v;
- if (isFilter(code)) { blockV[by * BW + bx] = v & 128; filterCfg.set(by * BW + bx, filterRec(extra)); filtersDirty = true; }
+ if (isFilter(code)) { blockV[by * BW + bx] = filterVariant(code, v); filterCfg.set(by * BW + bx, filterRec(extra)); filtersDirty = true; }
if (isProcCode(code)) procDirty = true;
if (code === BP_RACK) {
const b = by * BW + bx, s = newRack();
@@ -197,7 +197,7 @@ function blockInReach(bx, by) { return inReach(bx * BS + BS / 2, by * BS + BS /
// Is block b already exactly this piece (same kind, variant and, for a filter, settings)?
function blockIs(b, code, v, extra) {
if (blockP[b] !== code) return false;
- if (isFilter(code)) return (blockV[b] & 128) === (v & 128) && sameFilter(filterInfo(b), cleanFilterCfg(extra));
+ if (isFilter(code)) return (blockV[b] & 128) === filterVariant(code, v) && sameFilter(filterInfo(b), cleanFilterCfg(extra));
return blockV[b] === v;
}
// replace: a block already built in the way is taken down (and refunded) to make room
@@ -208,7 +208,7 @@ function placeBlocks(code, v, list, extra, replace) {
const b = by * BW + bx;
if (replace && blockP[b]) {
if (blockIs(b, code, v, extra)) continue;
- if (blockP[b] === code && isFilter(code)) { undoNote({ t: 'filter', bx, by, v: blockV[b], extra: filterInfo(b) }); blockV[b] = v & 128; setFilter(b, extra); filtersDirty = true; n++; continue; } // just re-set it
+ if (blockP[b] === code && isFilter(code)) { undoNote({ t: 'filter', bx, by, v: blockV[b], extra: filterInfo(b) }); blockV[b] = filterVariant(code, v); setFilter(b, extra); filtersDirty = true; n++; continue; } // just re-set it
if (!canAfford(pieceCost(code, v), 1)) { last = needText(pieceCost(code, v), 1); break; }
removeBlock(bx, by);
}
diff --git a/src/sim/cells.js b/src/sim/cells.js
index 0b5c731..583dd82 100644
--- a/src/sim/cells.js
+++ b/src/sim/cells.js
@@ -36,7 +36,7 @@ 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]) return 0;
+ if (occ[j] || (ph === PH_POWDER && !cleatCarrying && cleatCells.length && (cleatMask[j] || (dy > 0 && cleatMask[j - dx - dy * W])))) 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
@@ -72,20 +72,30 @@ let LSTEP = 8, LFILM = 4;
let CORNER_SEAL = true;
function powder(i, x, y, m) {
if (y >= H - 1) return;
- const b = i + W;
+ const b = i + W, barriers = cleatCells.length;
if (enter(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];
- if (PH[t] === PH_POWDER && DENS[m] >= DENS[t] * 2 && (!FUEL[t] || (flags[b] & F_BURN)) && stamp[b] !== tickStamp && !occ[b] && part[b] !== P_GRATE && part[b] !== P_FILTER && rnd() < (FUEL[t] ? SORTP_FUEL : SORTP)) { move(i, b, x, y, x, y + 1); awake[(y >> 5) * CW + (x >> 5)] = 40; return; }
+ if (PH[t] === PH_POWDER && (!barriers || (!cleatMask[i] && !cleatMask[b])) && DENS[m] >= DENS[t] * 2 && (!FUEL[t] || (flags[b] & F_BURN)) && stamp[b] !== tickStamp && !occ[b] && part[b] !== P_GRATE && part[b] !== P_FILTER && rnd() < (FUEL[t] ? SORTP_FUEL : SORTP)) { move(i, b, x, y, x, y + 1); awake[(y >> 5) * CW + (x >> 5)] = 40; return; }
// a grain lying right on a belt or filter is held by it: the surface carries it, it does not slide off the edge
- if (t === BELTL || t === BELTR || part[b] === P_FILTER) return;
+ if ((t === BELTL || t === BELTR || part[b] === P_FILTER) &&
+ !isSlopeBelt(blockP[((y + 1) >> 2) * BW + (x >> 2)])) return;
+ // Feed a receiving screw mouth sideways instead of falling under its bottom casing.
+ if (t === BELTL || t === BELTR) {
+ 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);
+ 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 || PH[mat[i + d]] !== PH_SOLID) && 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]))) && enter(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 || PH[mat[i - d]] !== PH_SOLID) && 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]))) && enter(m, PH_POWDER, b - d, -d, 1)) { move(i, b - d, x, y, nx, y + 1); return; }
}
}
function passable(m, ph, j, dx, dy) {
diff --git a/src/sim/conveyors.js b/src/sim/conveyors.js
index 8180bd3..ffd3b31 100644
--- a/src/sim/conveyors.js
+++ b/src/sim/conveyors.js
@@ -19,11 +19,11 @@ function conveyorStep(x, y, d) {
const b = (y >> 2) * BW + (x >> 2);
return beltGrade(blockP[b]) * d;
}
-function carryColumn(a, d, pass) {
+function carryColumn(a, d, pass, depth = CARRY_DEPTH) {
if (a < 0 || mat[a] === EMPTY) return;
const sy = ((a / W) | 0) + 1, step = conveyorStep(a % W, sy, d);
let blocked = false;
- for (let L = 0; L < CARRY_DEPTH; L++, a -= W) {
+ for (let L = 0; L < depth; L++, a -= W) {
if (a < 0) return;
const m = mat[a];
if (m === EMPTY || (PH[m] === PH_SOLID && !part[a])) return; // the top of the heap, or a ceiling
@@ -53,14 +53,6 @@ function carryColumn(a, d, pass) {
stats.moved++;
}
}
-// The running face grips its whole supported load, including on the ticks between steps.
-function holdSlopeColumn(a, pass) {
- for (let depth = 0; depth < CARRY_DEPTH && a >= 0; depth++, a -= W) {
- const m = mat[a];
- if (m === EMPTY || (PH[m] === PH_SOLID && !part[a])) return;
- if (PH[m] === PH_POWDER && !occ[a] && !(pass && pass[m])) stamp[a] = tickStamp;
- }
-}
// Belts are perforated: liquid resting on one drips through to whatever open space is under it.
function beltDrain(b) {
const a = b - W, x = b % W;
@@ -93,8 +85,8 @@ function filterCarry(b, d, carrying = true) {
for (let k = 0; k < BS; k++) {
const x = d > 0 ? bx * BS + BS - 1 - k : bx * BS + k, top = beltFaceY(code, bx, by, x) * W + x;
if (part[top] === P_FILTER) {
- if (carrying) carryColumn(top - W, d, pass);
- if (isSlopeBelt(code)) holdSlopeColumn(top - W, pass);
+ if (isSlopeBelt(code)) screenNudge(top, d, pass);
+ else if (carrying) carryColumn(top - W, d, pass);
}
}
}
@@ -114,9 +106,12 @@ function carryBeltsAndFilters(belts, filters, d, bm, carrying) {
if (above === BELTL || above === BELTR || netSat(b) <= 0) continue;
if (carrying) {
if (PH[above] === PH_LIQUID) beltDrain(b);
- carryColumn(b - W, d, null);
+ if (isSlopeBelt(code)) {
+ cleatCarrying = true;
+ carryColumn(b - W, d, null, pocketDepth(x, (y >> 2) * BW + (x >> 2)));
+ cleatCarrying = false;
+ } else carryColumn(b - W, d, null);
}
- if (isSlopeBelt(code)) holdSlopeColumn(b - W, null);
}
}
// Change what a filter does; grains inside it that no longer pass are pushed out on top.
diff --git a/src/sim/items.js b/src/sim/items.js
index d0e478e..3135ab9 100644
--- a/src/sim/items.js
+++ b/src/sim/items.js
@@ -134,12 +134,12 @@ function entDiagonalScrewTick(e, p) {
function entSlopeBeltTick(e) {
const bottom = e.iy + BAR_H;
if (bottom >= H) return false;
- let d = 0, slope = false;
+ let d = 0, slope = false, screen = false;
for (let x = e.ix; x < e.ix + BAR_W; x++) {
const i = bottom * W + x, b = (bottom >> 2) * BW + (x >> 2), c = blockP[b];
if ((isBelt(c) || isFilter(c)) && entSolidCell(i)) {
d = isFilter(c) ? (blockV[b] & 128 ? 1 : -1) : (blockV[b] ? 1 : -1);
- if (isSlopeBelt(c)) slope = true;
+ if (isSlopeBelt(c)) { slope = true; if (isFilter(c)) screen = true; }
}
}
if (!d) return false;
@@ -150,12 +150,12 @@ function entSlopeBeltTick(e) {
const b = (y >> 2) * BW + (x >> 2), c = blockP[b];
if (!(isBelt(c) || isFilter(c)) || (isFilter(c) && filterPassAt(e.m, y * W + x))) continue;
if (beltFaceY(c, x >> 2, y >> 2, x) !== y) continue;
- if (isSlopeBelt(c)) slope = true;
+ if (isSlopeBelt(c)) { slope = true; if (isFilter(c)) screen = true; }
face = Math.min(face, y);
}
if (!slope) return false;
e.x = e.ix; e.y = e.iy; e.vx = e.vy = 0;
- if (tick & 1) return true;
+ if (screen ? tick % SCREEN_NUDGE_TICKS : tick & 1) return true;
const y = face === H ? e.iy : face - BAR_H, x = e.ix + d;
if (entFreeAt(x, y)) { e.vx = d * 0.5; e.vy = (y - e.iy) * 0.5; e.x = e.ix = x; e.y = e.iy = y; stats.moved++; }
else if (!entScrewEnter(e, d, 0)) entScrewEnter(e, d, Math.sign(y - e.iy), true);
diff --git a/src/sim/machine-tick.js b/src/sim/machine-tick.js
index 1b11019..29c0baf 100644
--- a/src/sim/machine-tick.js
+++ b/src/sim/machine-tick.js
@@ -87,10 +87,11 @@ function machinesTick() {
mm.hot = Math.round(hotT);
}
}
- // Half a grain per tick; sloped faces grip their load on the intervening ticks too.
+ // Belts step every other tick; sloped belts carry only their finite pockets.
if (filtersDirty) rebuildFilters();
carryBeltsAndFilters(beltL, filterL, -1, BELTL, (tick & 1) === 0);
carryBeltsAndFilters(beltR, filterR, 1, BELTR, (tick & 1) === 0);
+ slopeCleatsTick();
// screws: every loose grain inside steps one cell along, front of the line first, on the same
// beat as belts (every other tick), so a block-wide screw carries as much as a block-deep belt
if ((tick & 1) === 0) for (let p = 1; p < IS_SCREW.length; p++) {
diff --git a/src/sim/pieces.js b/src/sim/pieces.js
index 81e3a7d..67b4326 100644
--- a/src/sim/pieces.js
+++ b/src/sim/pieces.js
@@ -14,8 +14,9 @@ function isBelt(c) { return c === BP_BELT || c === BP_BELT_UP || c === BP_BELT_D
function beltGrade(c) { return c === BP_BELT_UP || c === BP_FILTER_UP ? -1 : c === BP_BELT_DOWN || c === BP_FILTER_DOWN ? 1 : 0; }
function slopeShape(c) { return isSlopeBelt(c) ? (beltGrade(c) < 0 ? BP_SLOPE_BR : BP_SLOPE_BL) : c; }
function beltFaceY(c, bx, by, x) { return by * BS + (beltGrade(c) < 0 ? 3 - (x - bx * BS) : beltGrade(c) > 0 ? x - bx * BS : 0); }
+function filterVariant(c, v) { return beltGrade(c) ? (beltGrade(c) > 0 ? 128 : 0) : v & 128; }
function beltDirection(c, v) {
- const d = isFilter(c) ? (v & 128 ? 1 : -1) : (v ? 1 : -1), g = beltGrade(c);
+ const d = isFilter(c) ? (filterVariant(c, v) & 128 ? 1 : -1) : (v ? 1 : -1), g = beltGrade(c);
return g ? (g * d < 0 ? 'up-' : 'down-') + (d > 0 ? 'right' : 'left') : d > 0 ? 'right' : 'left';
}
function isSlope(c) { return c >= BP_SLOPE_BL && c <= BP_SLOPE_TR; }
@@ -70,11 +71,11 @@ const PIECES = [
{ id: 'wall', code: BP_WALL, name: 'Wall block', kind: 'block', variants: 'wall',
desc: 'A solid block. Diagonal drags make a sloped face; T flips it. For one block, T cycles square and four slope corners. R picks the material: a square costs 16 grains or 2 bars; a slope costs 8 grains or 1 bar.' },
{ id: 'belt', code: BP_BELT, name: 'Belt', kind: 'block', cost: [[CASH, 12]], dirs: ['left', 'right'],
- desc: 'Carries loose grains and bars resting on top of it, left or right, in a layer up to a block deep. Water and other liquids drain through it. Drag diagonally to make a sloped belt that carries up or down.' },
+ desc: 'Carries loose grains and bars resting on top of it, left or right, in a layer up to a block deep. Water and other liquids drain through it. Drag diagonally for a sidewall belt. Moving cleats lift pockets of grains; extra grains roll back.' },
{ id: 'screw', code: BP_SCREW, name: 'Screw conveyor', kind: 'block', cost: [[CASH, 25]], dirs: ['up', 'right', 'down', 'left', 'up-right', 'down-right', 'down-left', 'up-left'],
desc: 'A turning screw inside a tube that draws in loose grains and whole bars and pushes them along, four grains wide. Water and other liquids run straight through it; the screw never carries them. Drag a line to set its direction, including diagonals. R turns a single screw conveyor. Join either end to screw conveyors or belts. Copper softens around 1000°C.' },
{ id: 'filter', code: BP_FILTER, name: 'Filter', kind: 'block', cost: [[CASH, 40]], dirs: ['left', 'right'], filter: true,
- desc: 'A stretch of belt with a mesh in it. Put it in a line of belts and it joins them as one belt. Pick materials and a mode: drop them through and carry everything else along, or carry them and drop the rest. Liquids drain through. Drag diagonally to make a sloped filter that carries up or down.' },
+ desc: 'A shaking mesh that joins belts. Pick materials and a mode: drop them through and carry everything else along, or carry them and drop the rest. Liquids drain through. Drag diagonally for a downhill screen. Grains slide down while it shakes; R keeps a picked slope downhill.' },
{ id: 'shaft', code: BP_SHAFT, name: 'Shaft', kind: 'block', cost: [[LWOOD, 8]], hidden: POWER_FREE,
desc: 'Carries power between machines. Wooden, so keep it away from fire.' },
{ id: 'grate', code: BP_GRATE, name: 'Grate', kind: 'block', cost: [[LCLAY, 8]],
diff --git a/src/sim/save.js b/src/sim/save.js
index fa69ced..2ce1fc6 100644
--- a/src/sim/save.js
+++ b/src/sim/save.js
@@ -9,6 +9,7 @@ function serializeWorld() {
const t16 = new Uint16Array(N);
for (let i = 0; i < N; i++) { const v = (temp[i] + TOFF) * 16; t16[i] = v < 0 ? 0 : v > 65535 ? 65535 : v; }
const meta = {
+ slopePhases: Array.from(slopePhase),
v: SAVE_VERSION, seed: worldSeed, W, H, tick, surfBase, underD, shallowD, lapse, tOff: TOFF,
saplings: saplings.map(s => [s.x, s.y, s.age, s.th, s.seed]),
economy: { money, debt, earned, recent: Array.from(mkRecent, v => Math.round(v)), log: shipLog, orders, ordersDone, nextOrderT, orderSeq },
@@ -102,6 +103,7 @@ function deserializeWorld(meta, buffers) {
if (!dock()) placeDock((W >> 1) + 34); // worlds from before the economy get their trade dock too
ghostBlocks = new Map(meta.ghostBlocks || []); ghostMachines = meta.ghostMachines || []; ghostMolds = meta.ghostMolds || [];
tick = meta.tick || 0;
+ if (meta.slopePhases) slopePhase.set(meta.slopePhases);
updateRoof(); resetWeather();
if (meta.weather) {
const mw = meta.weather;
@@ -117,7 +119,7 @@ function deserializeWorld(meta, buffers) {
seen = new Uint8Array(NMAT); meta.seen.forEach((v, k) => { if (k < NMAT) seen[k] = v; });
ledger = meta.ledger || {};
Object.assign(stats, meta.stats || {});
- powerDirty = true; rebuildPower(); updateRoof();
+ powerDirty = true; rebuildPower(); slopeCleatsTick(false); updateRoof();
if (!weather.base) weather.base = worldWater(); // older saves: take the water it has now as its starting amount
if (!meta.specks) clearHardSpecks(true); // older saves: tidy away tiny specks of too-hard rock once
rebuildPipes();
diff --git a/src/sim/slope-conveyors.js b/src/sim/slope-conveyors.js
new file mode 100644
index 0000000..ec7993d
--- /dev/null
+++ b/src/sim/slope-conveyors.js
@@ -0,0 +1,51 @@
+// Cleats are moving barriers, not material cells. A cleat supports a finite pocket; the bare face never holds grains.
+const CLEAT_SPACING = 8, CLEAT_HEIGHT = 6; // spacing in face cells, height in grains
+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 cleatMask = new Uint8Array(0), cleatCells = [], slopePhase = new Uint8Array(0);
+function cleatFoot(x, b, phase = slopePhase[b]) {
+ const d = blockV[b] & 1 ? 1 : -1;
+ return ((x - d * phase) % CLEAT_SPACING + CLEAT_SPACING) % CLEAT_SPACING === 0;
+}
+function pocketDepth(x, b) {
+ const g = beltGrade(blockP[b]), d = blockV[b] & 1 ? 1 : -1;
+ // A periodic pocket envelope, clipped to two vertical projections of the cleat.
+ // End pockets keep moving while their cleat rounds the end of the band.
+ const distance = ((g * (d * slopePhase[b] - x)) % CLEAT_SPACING + CLEAT_SPACING) % CLEAT_SPACING;
+ return Math.max(0, CLEAT_REACH * 2 - distance);
+}
+function slopeCleatsTick(advance = true) {
+ for (const i of cleatCells) { cleatMask[i] = 0; wakeIdx(i); }
+ 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) {
+ const x = i % W, y = (i / W) | 0, b = (y >> 2) * BW + (x >> 2), c = blockP[b];
+ if (!isBelt(c) || !isSlopeBelt(c) || beltFaceY(c, x >> 2, y >> 2, x) !== y) continue;
+ const d = blockV[b] & 1 ? 1 : -1, g = beltGrade(c), running = netSat(i) > 0;
+ const phase = (slopePhase[b] + (advance && running && !(tick & 1) ? 1 : 0)) % CLEAT_SPACING;
+ if (!cleatFoot(x, b, phase)) continue;
+ for (let h = CLEAT_REACH; h >= 0; h--) {
+ const xx = x + g * h, yy = y - h - 1;
+ if (xx < 0 || xx >= W || yy < 0 || yy >= H) continue;
+ const j = yy * W + xx;
+ cleatCells.push(j);
+ }
+ }
+ if (advance && !(tick & 1)) for (const list of [beltR, beltL]) for (const i of list) {
+ const x = i % W, y = (i / W) | 0, b = (y >> 2) * BW + (x >> 2), c = blockP[b];
+ 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); }
+}
+// A screen shakes only the grains touching its mesh, never the whole column above it.
+function screenNudge(top, d, pass) {
+ if (tick % SCREEN_NUDGE_TICKS) return;
+ const i = top - W, m = mat[i];
+ if (i < 0 || PH[m] !== PH_POWDER || stamp[i] === tickStamp || occ[i]) return;
+ if (pass && pass[m] && enter(m, PH_POWDER, top, 0, 1)) return;
+ const j = i + d + W;
+ 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);
+}
diff --git a/src/sim/world.js b/src/sim/world.js
index d60322d..198abd8 100644
--- a/src/sim/world.js
+++ b/src/sim/world.js
@@ -42,10 +42,11 @@ function alloc(w, h) {
W = w; H = h; N = w * h;
fogAlloc();
CW = Math.ceil(w / 32); CH = Math.ceil(h / 32); NCH = CW * CH;
+ cleatMask = new Uint8Array(N); cleatCells = [];
mat = new Uint8Array(N); temp = new Float32Array(N); life = new Uint8Array(N); flags = new Uint8Array(N);
stamp = new Uint8Array(N); part = new Uint8Array(N); mach = new Uint16Array(N); digp = new Uint8Array(N);
noiseB = new Uint8Array(N); bgm = new Uint8Array(N); airS = new Uint16Array(N); netId = new Uint16Array(N); isPow = new Uint8Array(N); occ = new Uint8Array(N);
- BW = Math.ceil(w / BS); BH = Math.ceil(h / BS); NB = BW * BH; blockP = new Uint8Array(NB); blockV = new Uint8Array(NB); filtersDirty = true; filterCfg = new Map(); procDirty = true; procState.clear(); rackState.clear();
+ BW = Math.ceil(w / BS); BH = Math.ceil(h / BS); NB = BW * BH; blockP = new Uint8Array(NB); blockV = new Uint8Array(NB); slopePhase = new Uint8Array(NB); filtersDirty = true; filterCfg = new Map(); procDirty = true; procState.clear(); rackState.clear();
ents = []; flyers = []; ghostBlocks = new Map(); ghostMolds = []; treeBases = []; saplings = [];
surfBase = Math.round(Math.min(h * 0.27, 175)); underD = h - surfBase; shallowD = 0; lapse = 0.36;
surfY = new Int16Array(w); roofY = new Int16Array(w); dirtY = new Int16Array(w); graniteY = new Int16Array(w); basaltY = new Int16Array(w); magmaY = new Int16Array(w);
diff --git a/src/ui/actions.js b/src/ui/actions.js
index af89df0..d60cd81 100644
--- a/src/ui/actions.js
+++ b/src/ui/actions.js
@@ -100,7 +100,7 @@ function dragDirFor(P, a, b) {
if (!a || !P.dirs || !(P.id === 'belt' || P.id === 'screw' || P.filter)) return -1;
const dx = b.bx - a.bx, dy = b.by - a.by;
if (!dx && !dy) return -1;
- if ((P.id === 'belt' || P.filter) && diagonalDrag(dx, dy)) return dx > 0 ? 1 : 0;
+ if ((P.id === 'belt' || P.filter) && diagonalDrag(dx, dy)) return P.filter ? (dx * dy > 0 ? 1 : 0) : (dx > 0 ? 1 : 0);
if (P.id === 'screw' && diagonalDrag(dx, dy)) return dx > 0 ? (dy < 0 ? 4 : 5) : (dy > 0 ? 6 : 7);
if (P.id === 'screw') return Math.abs(dx) >= Math.abs(dy) ? (dx > 0 ? 1 : 3) : (dy > 0 ? 2 : 0);
return Math.abs(dx) >= Math.abs(dy) ? (dx > 0 ? 1 : 0) : -1;
diff --git a/src/ui/build-menu.js b/src/ui/build-menu.js
index eb5ccc8..7d1dbcc 100644
--- a/src/ui/build-menu.js
+++ b/src/ui/build-menu.js
@@ -12,8 +12,8 @@ const PIECE_ICONS = {
wall: '
Find something loose first.
') + ``; dialog(title || 'Filter', body, [{ label: 'Clear', cls: 'ghost', fn: () => { sel.clear(); sync(); return true; } }, { label: 'Cancel' }, { label: 'Set filter', cls: 'primary', fn: () => { onDone({ m: [...sel].sort((a, b) => a - b), inv, dir }); hudDirty = true; } }]); @@ -36,7 +36,7 @@ function openFilterPicker(cur, onDone, title) { box.querySelectorAll('[data-dir]').forEach(b => { const on = +b.dataset.dir === dir; b.classList.toggle('on', on); b.setAttribute('aria-checked', on); }); box.querySelectorAll('[data-m]').forEach(b => { const on = sel.has(+b.dataset.m); b.classList.toggle('on', on); b.setAttribute('aria-checked', on); }); const f = { m: [...sel].sort((a, b) => a - b), inv }; - box.querySelector('#fp-sum').textContent = `${title && /in this row/.test(title) ? 'These filters' : 'This filter'} will ${filterSentence(f)}, ${dir ? 'right' : 'left'}.`; + box.querySelector('#fp-sum').textContent = `${title && /in this row/.test(title) ? 'These filters' : 'This filter'} will ${filterSentence(f)}, ${beltGrade(cur.code) ? beltDirection(cur.code, dir << 7) : dir ? 'right' : 'left'}.`; } box.querySelectorAll('[data-dir]').forEach(b => b.onclick = () => { dir = +b.dataset.dir; playSfx('ui'); sync(); }); box.querySelectorAll('[data-inv]').forEach(b => b.onclick = () => { inv = +b.dataset.inv; playSfx('ui'); sync(); }); diff --git a/src/ui/selection.js b/src/ui/selection.js index b8703ba..aba48f8 100644 --- a/src/ui/selection.js +++ b/src/ui/selection.js @@ -47,6 +47,7 @@ function rotate() { else if (t === 'build') { const P = piece(); if (pieceUsesWall(P)) cycleWall(1); + else if (P.filter && beltGrade(pieceCode(P))) return; else if (P.dirs) GAME.pieceDir[P.id] = ((GAME.pieceDir[P.id] || 0) + 1) % P.dirs.length; else return; } else if (t === 'drill') { tuneUnderCursor(); return; } @@ -83,7 +84,7 @@ function setBrush(d) { // materials and mode travel separately, as GAME.filterCfg) function pieceV(P) { if (isWall(P.code)) return GAME.wallMat; - if (P.filter) return (GAME.pieceDir[P.id] || 0) << 7; + if (P.filter) return filterVariant(pieceCode(P), (GAME.pieceDir[P.id] || 0) << 7); return GAME.pieceDir[P.id] || 0; } function pieceExtra(P) { return P.filter ? { m: GAME.filterCfg.m.slice(), inv: GAME.filterCfg.inv } : undefined; } diff --git a/test/play.py b/test/play.py index 68f60f7..874fe3c 100644 --- a/test/play.py +++ b/test/play.py @@ -698,6 +698,27 @@ with sync_playwright() as p: check('Wall diagonal defaults back to solid below', ev(f'Array.from({{length:4}}, (_,k)=>blockP[({bby}-12+k)*BW+{bbx}-5+k]).every(c=>c===BP_SLOPE_BL)')) pg.keyboard.press('Control+z'); settle(60) + # Cleated belts and downhill screens use real drags, previews, picking and R. + for piece_id in ['belt', 'filter']: + check('select ' + piece_id + ' for a diagonal drag', select('3', "piece().id === '" + piece_id + "'")) + ev('money=1e6') + drag(lambda: blk(bbx - 5, bby - 9), lambda: blk(bbx - 2, bby - 12)) + code = 'BP_BELT_UP' if piece_id == 'belt' else 'BP_FILTER_UP' + check(piece_id + ' uphill drag lays the expected shape', ev(f'Array.from({{length:4}},(_,k)=>blockP[({bby}-9-k)*BW+{bbx}-5+k]).every(c=>c==={code})')) + if piece_id == 'filter': + check('uphill filter drag runs downhill left', ev(f'Array.from({{length:4}},(_,k)=>blockV[({bby}-9-k)*BW+{bbx}-5+k]).every(v=>v===0)')) + pg.mouse.move(*blk(bbx - 3, bby - 11)); settle(60) + check('screen hover says down-left', ev(f"hoverInfo(({bbx}-3)*4+1,({bby}-11)*4+2).t1.includes('down-left')")) + pg.mouse.click(*blk(bbx - 3, bby - 11), button='middle'); settle(60) + pg.keyboard.press('r'); settle(60) + check('R leaves a picked screen downhill', ev('pieceCode(piece())===BP_FILTER_UP && pieceV(piece())===0')) + pg.keyboard.press('f'); settle(100) + check('screen picker offers only downhill travel', pg.locator('#dlg-body [data-dir]').count() == 0 and 'down-left' in pg.locator('#dlg-body').inner_text()) + pg.locator('#dlg-foot').get_by_role('button', name='Cancel', exact=True).click() + pg.keyboard.press('Control+z'); settle(60) + + ev('GAME.pieceShape = {}; GAME.pieceDir.filter = 1; GAME.editRun = null') + # Diagonal screws use real mouse placement, preview arrows, R and picking. check('select screw conveyor', select('3', "piece().id === 'screw'")) ev('GAME.pieceDir.screw = 0') @@ -1201,6 +1222,7 @@ with sync_playwright() as p: point = fp.evaluate(f'(() => {{ const p = worldToScreenPx(testSilo.x + {dx} + 0.5, testSilo.y + {dy} + 0.5); return [p.x * innerWidth / R.canvas.width, p.y * innerWidth / R.canvas.width]; }})()') fp.mouse.move(*point); fp.wait_for_timeout(200) want = 'sand 2,000 / 4,000' if amount else 'empty' + fp.wait_for_function(f"$('info').textContent.includes('{('holds 2,000 / 4,000 sand' if amount else 'empty')}')") check(f'silo {name} hover shows {want}', fp.evaluate(f"hoverInfo(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)).t1 === 'Silo' && hoverInfo(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)).contents === '{want}' && $('info').textContent.includes('{('holds 2,000 / 4,000 sand' if amount else 'empty')}')")) check(f'silo {name} cursor label draws {want} in normal colour', fp.evaluate("""() => { const calls = [], ctx = R.ctx, text = ctx.fillText; diff --git a/test/t_slope_belt_art.js b/test/t_slope_belt_art.js index e2423d7..e1e8c62 100644 --- a/test/t_slope_belt_art.js +++ b/test/t_slope_belt_art.js @@ -97,4 +97,24 @@ check('drawing leaves the seed, cells and saved block data unchanged', run(`(() } return snapshot() === before; })()`)); +for (const grade of [-1,1]) { + flatWorld(run,160,120,112); + run(`for(let k=0;k<6;k++)placeBlock(${grade<0?'BP_BELT_UP':'BP_BELT_DOWN'},1,12+k,${grade<0?20:12}+k*${grade},true);`); + for(let phase=0;phase<8;phase++) { + run(`rebuildPower();slopePhase.fill(${phase});slopeCleatsTick(false);prepareSlopeBelts(0,0,W,H);`); + check(`grade ${grade} phase ${phase} draws exactly the physical cleat raster`,run(`(()=>{ + let drawn=0; + for(let i=0;i