From 578b55316b1efc431087c1d98d01c4f20f9cf159 Mon Sep 17 00:00:00 2001 From: 44r0n7 <44r0n7+gitea@pm.me> Date: Mon, 26 Oct 2026 19:47:59 -0400 Subject: [PATCH] Sloped belts hold their load with moving cleats (t42); sloped filters are downhill shaking screens (t43) The magic grip is gone: cleats every 8 face cells, 6 grains tall, carry pockets uphill (about 71 grains/s against 90 flat) and overflow rolls back. Sloped filters always run downhill; grains travel by gravity and a shake, and sorting stays exact. Drawing needs another pass. Co-Authored-By: Claude Opus 5.5 --- docs/DESIGN.md | 21 +++++++++ docs/PROJECT_MAP.md | 38 +++++++++------- docs/agent-log.md | 46 +++++++++++++++++++ src/manifest.json | 1 + src/render/slope-belt-shader.js | 5 ++- src/render/slope-belts.js | 31 +++++++++++-- src/render/world.js | 7 +++ src/sim/blocks.js | 6 +-- src/sim/cells.js | 22 ++++++--- src/sim/conveyors.js | 23 ++++------ src/sim/items.js | 8 ++-- src/sim/machine-tick.js | 3 +- src/sim/pieces.js | 7 +-- src/sim/save.js | 4 +- src/sim/slope-conveyors.js | 51 +++++++++++++++++++++ src/sim/world.js | 3 +- src/ui/actions.js | 2 +- src/ui/build-menu.js | 4 +- src/ui/notebook.js | 2 +- src/ui/previews.js | 14 ++++++ src/ui/selection-pickers.js | 8 ++-- src/ui/selection.js | 3 +- test/play.py | 22 +++++++++ test/t_slope_belt_art.js | 20 +++++++++ test/t_slope_belts.js | 79 ++++++++++++++++++++++----------- tools/experiments/slope-cost.js | 34 ++++++++++++++ tools/experiments/slope-flow.js | 29 ++++++++++++ tools/scenes/slope-conveyors.js | 21 +++++++++ 28 files changed, 425 insertions(+), 89 deletions(-) create mode 100644 src/sim/slope-conveyors.js create mode 100644 tools/experiments/slope-cost.js create mode 100644 tools/experiments/slope-flow.js create mode 100644 tools/scenes/slope-conveyors.js 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: '', grate: '', gate: '', - belt: '', - filter: '', + belt: '', + filter: '', screw: '', pipe: '', pump: '', diff --git a/src/ui/notebook.js b/src/ui/notebook.js index 16865f9..746897d 100644 --- a/src/ui/notebook.js +++ b/src/ui/notebook.js @@ -71,7 +71,7 @@ function controlsHtml() { const tools = [ ['1 Resonance drill', 'Hold left click to send pulses from the bot toward the cursor: rock they shake hard enough shatters and falls as rubble, ore, coal or scrap, and trees come apart into wood chunks and acorns. Q and E set the pulse width: narrow bores a tunnel fast, wide clears a big cone slowly, medium is in between. R over a material tunes the drill to it, so only that shatters (great for pulling a vein out of the rock); R again untunes. Built blocks are never harmed. Better bits dig harder rock, faster and further.'], ['2 Pneumatic nozzle', "Left drag picks up the selection, or what you click if nothing is selected. Right click blows the selection. Click a bag name or Q/E selects one; Ctrl+click mixes. Hold Ctrl to see everything found. Clear removes the selection. Bars blow alone. Blowing out the last of a material deselects it. Shift while not flying takes everything; Shift + right click blows everything loose in the bag."], - ['3 Build', 'Places pieces on a grid of 4×4 blocks: wall blocks, belts, screw conveyors, filters, grates, gates, pipes, pumps, outlets, Grinders, Saws, Brick presses and Bar racks, machines (fans and silos) and molds. Drag for a line, hold Shift while dragging for a filled rectangle. A diagonal wall drag makes a sealed sloped face, solid below; T flips it. For one wall block, T cycles square and four slope corners. Diagonal belt and filter drags make sloped runs that carry up or down, following the drag. Their faces always point up. Right click takes a piece back. Your bot flies through anything you build.'], + ['3 Build', 'Places pieces on a grid of 4×4 blocks: wall blocks, belts, screw conveyors, filters, grates, gates, pipes, pumps, outlets, Grinders, Saws, Brick presses and Bar racks, machines (fans and silos) and molds. Drag for a line, hold Shift while dragging for a filled rectangle. A diagonal wall drag makes a sealed sloped face, solid below; T flips it. For one wall block, T cycles square and four slope corners. Diagonal belts use cleats to carry pockets up or down. Extra grains roll back. Diagonal filters always screen downhill, whichever way you drag. R leaves a picked sloped filter downhill. Right click takes a piece back. Your bot flies through anything you build.'], ['4 Igniter', 'Heats a small spot to 640°C, and its flame reaches a few grains into a pile. Enough to light coal and wood that touch air.'], ['Copy, cut and paste', 'Hold C (copy) or X (cut) and drag a box over what you built; let go and it is picked up, ready to paste. Cut takes it down and refunds it to your bag. Click to place ghosts: your bot builds them from your bag when it is close. R flips, V pastes again, right click or Esc stops. Ctrl+Z undoes building. The notebook (B) keeps saved blueprints and a few starter designs.'], ]; diff --git a/src/ui/previews.js b/src/ui/previews.js index 2371ecd..c37bb80 100644 --- a/src/ui/previews.js +++ b/src/ui/previews.js @@ -221,6 +221,20 @@ 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)) + 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(); return; } code = slopeShape(code); diff --git a/src/ui/selection-pickers.js b/src/ui/selection-pickers.js index 305cc5b..963bdfb 100644 --- a/src/ui/selection-pickers.js +++ b/src/ui/selection-pickers.js @@ -14,19 +14,19 @@ function filterSentence(f) { const p = filterParts(f); return `drop ${p.drops} a // Change the whole run of matching filters at once function applyFilterRun(run, f) { undoRecord('filter settings', () => { - for (const b of run) { undoNote({ t: 'filter', bx: b % BW, by: (b / BW) | 0, v: blockV[b], extra: filterInfo(b) }); blockV[b] = f.dir ? 128 : 0; setFilter(b, f); } + for (const b of run) { undoNote({ t: 'filter', bx: b % BW, by: (b / BW) | 0, v: blockV[b], extra: filterInfo(b) }); blockV[b] = filterVariant(blockP[b], f.dir ? 128 : 0); setFilter(b, f); } }); filtersDirty = true; } function openFilterPicker(cur, onDone, title) { const sel = new Set(cur.m); - let inv = cur.inv ? 1 : 0, dir = cur.dir ? 1 : 0; + let inv = cur.inv ? 1 : 0, dir = beltGrade(cur.code) ? (beltGrade(cur.code)>0 ? 1 : 0) : cur.dir ? 1 : 0; const list = filterChoices(cur.m); const body = `
-
Rollers turn
` +
Screen travel${beltGrade(cur.code) ? `${esc(beltDirection(cur.code, dir << 7))}` : ''}
` + (list.length ? `
${list.map(m => ``).join('')}
` : '

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;i0; + })()`)); + } +} +flatWorld(run,160,120,112); +run('placeBlock(BP_FILTER_DOWN,128,12,12,true);tick=0;prepareSlopeBelts(0,0,W,H);var idleJoins=slopeBeltJoins[12*BW+12];tick=2;prepareSlopeBelts(0,0,W,H);'); +check('empty screen has a stable frame',run('slopeBeltJoins[12*BW+12]===idleJoins')); +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')); done(); diff --git a/test/t_slope_belts.js b/test/t_slope_belts.js index 60519e4..c82d7cd 100644 --- a/test/t_slope_belts.js +++ b/test/t_slope_belts.js @@ -10,18 +10,19 @@ fresh(); check('slope belts use new codes, without renumbering old slopes', run("typeof BP_BELT_UP !== 'undefined' && BP_BELT_UP > BP_SLOPE_TR")); function line(grade, filter = false, dir = 1) { fresh(); + if (filter) dir = grade > 0 ? 1 : 0; const by = grade < 0 ? 32 : 16, inlet = by + (grade < 0 ? 1 : 0), outlet = by + 9 * grade + (grade > 0 ? 1 : 0); run(`for(let k=0;k<10;k++) placeBlock(${filter ? 'BP_FILTER_UP' : 'BP_BELT_UP'}+${grade>0?1:0},${filter?dir*128:dir},16+k,${by}+k*${grade},true,{m:[SAND],inv:0}); for(let x=10;x<16;x++)placeBlock(BP_BELT,${dir},x,${inlet},true); for(let x=26;x<32;x++)placeBlock(BP_BELT,${dir},x,${outlet},true);`); - return { by, inlet, outlet, grade }; + return { by, inlet: filter && grade < 0 ? outlet : inlet, outlet, grade, left: filter && grade < 0, feedX: filter && grade < 0 ? 120 : 44 }; } function collect(s, steps, feed = false, sink = 128) { return run(`(()=>{let out=0,added=0;for(let k=0;k<${steps};k++){ - ${feed ? `for(let l=1;l<=4;l++){const i=(${s.inlet}*4-l)*W+44;if(mat[i]===EMPTY){setCell(i,SAND,20,0);added++;}}` : ''} + ${feed ? `for(let l=1;l<=4;l++){const i=(${s.inlet}*4-l)*W+${s.feedX || 44};if(mat[i]===EMPTY){setCell(i,SAND,20,0);added++;}}` : ''} simTick(); - for(let i=0;i=${sink}){out++;setCell(i,EMPTY,20,0);} - for(let j=flyers.length-1;j>=0;j--)if(flyers[j].m===SAND && flyers[j].x>=${sink}){out++;flyers.splice(j,1);} + for(let i=0;i="+sink}){out++;setCell(i,EMPTY,20,0);} + for(let j=flyers.length-1;j>=0;j--)if(flyers[j].m===SAND && ${s.left ? "flyers[j].x<40" : "flyers[j].x>="+sink}){out++;flyers.splice(j,1);} }return {out,added,left:mat.reduce((n,m)=>n+(m===SAND),0)+flyers.filter(f=>f.m===SAND).length};})()`); } const rates=[]; @@ -31,7 +32,7 @@ for(const grade of [0,-1,1]) { else {fresh();run('for(let bx=10;bx<32;bx++)placeBlock(BP_BELT,1,bx,24,true);');s={inlet:24};} collect(s,220,true); const r=collect(s,600,true);rates.push(r.out/10); - check(`grade ${grade} moves a four-grain load at flat belt speed`,r.out>0 && (grade===0 || Math.abs(r.out/10-rates[0])<=rates[0]*0.05),JSON.stringify(r)); + check(`grade ${grade} carries a generous feed at least 60 grains/s`,r.out>0 && r.out/10>=60,JSON.stringify(r)); const end=collect(s,600); check(`grade ${grade} leaves no load stuck`,end.left===0,JSON.stringify(end)); } @@ -43,7 +44,7 @@ for(const grade of [-1,1]) { const r=collect(s,1000); check(`grade ${grade} conserves and delivers every grain across both flat joins`,r.out===128 && r.left===0,JSON.stringify(r)); line(grade); - run(`spawnBar(BARCU,44,${s.inlet*4-2},0,0);`); + run(`spawnBar(BARCU,${s.feedX || 44},${s.inlet*4-2},0,0);`); ticks(run,500); check(`grade ${grade} carries a bar through both flat joins`,run('ents.length===1 && ents[0].ix>=128'),run('ents.map(e=>[e.ix,e.iy])')); } @@ -59,14 +60,14 @@ for(const grade of [-1,1]) { rect(run,104,0,104,s.outlet*4-1,run('STONE')); ticks(run,300); const held=run('mat.reduce((n,m)=>n+(m===SAND),0)+flyers.filter(f=>f.m===SAND).length'); - check(`blocked grade ${grade} holds every grain without slipping below its face`,held===160 && count(run,run('SAND'),0,0,63,207)===0 && count(run,run('SAND'),0,160,255,207)===0,held); + check(`blocked grade ${grade} conserves its load and lets overflow roll back`,held===160 && (grade>0 || count(run,run('SAND'),0,0,63,207)>0),held); rect(run,104,0,104,s.outlet*4-1,run('EMPTY')); const released=collect(s,800); check(`grade ${grade} resumes after the head clears`,released.out===160 && released.left===0,JSON.stringify(released)); line(grade); run(`for(let bx=26;bx<32;bx++)removeBlock(bx,${s.outlet});setCell((${s.by+9*grade}*4+${grade<0?0:3}-1)*W+103,SAND,20,0);tick=2;tickStamp++;machinesTick();`); check(`grade ${grade} launches off an open end along its face`,run(`flyers.length===1 && flyers[0].vx>0 && Math.sign(flyers[0].vy)===${grade}`)); - run(`spawnBar(BARCU,44,${s.inlet*4-2},0,0);`); + run(`spawnBar(BARCU,${s.feedX || 44},${s.inlet*4-2},0,0);`); const barLaunch=run('(()=>{for(let k=0;k<400;k++){simTick();if(ents[0].ix>=104)return ents[0].vx>0;}return false;})()'); check(`grade ${grade} keeps a bar moving forward off an open end`,barLaunch); for(const diagonal of [false,true]) { @@ -76,7 +77,7 @@ for(const grade of [-1,1]) { rect(run,39,20,39,s.inlet*4-1,run('STONE')); const r=collect(s,1000,false,116); check(`grade ${grade} unloads grains into a ${diagonal?'diagonal':'straight'} screw`,r.out===128 && r.left===0,JSON.stringify(r)); - run(`spawnBar(BARCU,44,${s.inlet*4-2},0,0);`); + run(`spawnBar(BARCU,${s.feedX || 44},${s.inlet*4-2},0,0);`); ticks(run,600); check(`grade ${grade} unloads a bar into a ${diagonal?'diagonal':'straight'} screw`,run('ents.length===1 && ents[0].ix>=116 && !mat.some((m,i)=>occ[i] && PH[m]===PH_SOLID)'),run('ents.map(e=>[e.ix,e.iy])')); @@ -88,18 +89,18 @@ for(const grade of [-1,1]) { collect(s,220,true); const r=collect(s,600,true); console.log(`Filter throughput grade ${grade}, grains/s: ${r.out/10}`); - check(`carrying filter grade ${grade} matches the belt feed`,r.out/10>=rates[0]*0.95,JSON.stringify(r)); + check(`downhill screen grade ${grade} discharges its generous feed`,r.out/10>=45 && r.out/10<=90,JSON.stringify(r)); } for(const grade of [-1,1]) for(const inv of [0,1]) { const s=line(grade,true); run(`for(const b of filterCfg.keys())setFilter(b,{m:[SAND],inv:${inv}});`); - rect(run,39,20,39,s.inlet*4-1,run('STONE')); - run(`for(let y=${s.inlet*4-8};y<${s.inlet*4};y++)for(let x=40;x<56;x++)setCell(y*W+x,(x+y)&1?SAND:GRAVEL,20,0);`); + rect(run,s.left?128:39,20,s.left?128:39,s.inlet*4-1,run('STONE')); + run(`for(let y=${s.inlet*4-8};y<${s.inlet*4};y++)for(let x=${s.left?112:40};x<${s.left?128:56};x++)setCell(y*W+x,(x+y)&1?SAND:GRAVEL,20,0);`); const result=run(`(()=>{const out=[0,0],below=[0,0];for(let k=0;k<1200;k++){ simTick(); for(let i=0;i=128){out[m]++;setCell(i,EMPTY,20,0);}else if(y>=180){below[m]++;setCell(i,EMPTY,20,0);}} - for(let f=flyers.length-1;f>=0;f--){const p=flyers[f];if((p.m===SAND || p.m===GRAVEL) && p.x>=128){out[p.m===SAND?0:1]++;flyers.splice(f,1);}} + if(${s.left?"x<40":"x>=128"}){out[m]++;setCell(i,EMPTY,20,0);}else if(y>=180){below[m]++;setCell(i,EMPTY,20,0);}} + for(let f=flyers.length-1;f>=0;f--){const p=flyers[f];if((p.m===SAND || p.m===GRAVEL) && ${s.left?"p.x<40":"p.x>=128"}){out[p.m===SAND?0:1]++;flyers.splice(f,1);}} }return {out,below,left:mat.reduce((n,m)=>n+(m===SAND || m===GRAVEL),0)+flyers.filter(f=>f.m===SAND || f.m===GRAVEL).length};})()`); console.log(`Sorting grade ${grade} inverse ${inv}: `+JSON.stringify(result)); check(`grade ${grade} ${inv?'carries selected':'drops selected'} and conserves the full mix`,result.out[inv?0:1]===64 && result.below[inv?1:0]===64 && result.left===0 && result.out[inv?1:0]===0 && result.below[inv?0:1]===0,JSON.stringify(result)); @@ -107,21 +108,25 @@ for(const grade of [-1,1]) for(const inv of [0,1]) { for(const c of ['BP_BELT_UP','BP_BELT_DOWN','BP_FILTER_UP','BP_FILTER_DOWN'])for(const d of [0,1]) { fresh(); run(`placeBlock(${c},${c.includes('FILTER')?d*128:d},20,20,true,{m:[SAND,BARCU],inv:1});globalThis.saved=serializeWorld();deserializeWorld(saved.meta,saved.buffers);`); - check(`${c} ${d} saves its shape, direction and settings`,run(`blockP[20*BW+20]===${c} && blockV[20*BW+20]===${c.includes('FILTER')?d*128:d} && blockIntegrity(20*BW+20)===10 ${c.includes('FILTER')?'&& sameFilter(filterInfo(20*BW+20),{m:[SAND,BARCU].sort((a,b)=>a-b),inv:1})':''}`)); + check(`${c} ${d} saves its shape, direction and settings`,run(`blockP[20*BW+20]===${c} && blockV[20*BW+20]===${c.includes('FILTER')?'filterVariant('+c+','+d+'*128)':d} && blockIntegrity(20*BW+20)===10 ${c.includes('FILTER')?'&& sameFilter(filterInfo(20*BW+20),{m:[SAND,BARCU].sort((a,b)=>a-b),inv:1})':''}`)); + if(c.includes('BELT')) { + run(`slopePhase[20*BW+20]=${d?1:7};rebuildPower();slopeCleatsTick(false);globalThis.savedPhase=serializeWorld();deserializeWorld(savedPhase.meta,savedPhase.buffers);`); + check(`${c} saves the moving cleat phase and immediately restores its barriers`,run(`slopePhase[20*BW+20]===${d?1:7} && cleatCells.length>0`)); + } run(`globalThis.clip=copyRegion(80,80,83,83);bot.x=96;bot.y=80;undoRecord('paste',()=>pasteGhost(clip,24,20));ghostTick();`); check(`${c} ${d} copy/paste keeps all settings`,run('blockP[20*BW+24]===blockP[20*BW+20] && blockV[20*BW+24]===blockV[20*BW+20] && sameFilter(filterInfo(20*BW+20),filterInfo(20*BW+24))')); run('undoLast();'); check(`${c} ${d} undo clears the paste including its seal`,run(`blockP[20*BW+24]===0 && blockCellList(24,20).every(i=>mat[i]===EMPTY && !part[i]) && mat[slopeSeal(${c},24,20)]===EMPTY && !part[slopeSeal(${c},24,20)]`)); run("undoRecord('cut',()=>cutRegion(80,80,83,83));undoLast();"); check(`${c} ${d} undo restores a cut`,run(`blockP[20*BW+20]===${c} && blockIntegrity(20*BW+20)===10`)); - check(`${c} ${d} mirrors its shape and reverses horizontally`,run(`(()=>{const b=flipBlueprint(clip).blocks[0];return b[2]===${c}+${c.endsWith('UP')?1:-1} && b[3]===${c.includes('FILTER')?(1-d)*128:1-d};})()`)); + check(`${c} ${d} mirrors its shape and reverses horizontally`,run(`(()=>{const b=flipBlueprint(clip).blocks[0];return b[2]===${c}+${c.endsWith('UP')?1:-1} && b[3]===${c.includes('FILTER')?'filterVariant('+c+'+'+(c.endsWith('UP')?1:-1)+',0)':1-d};})()`)); } for(const grade of [-1,1]) { const s=line(grade,true); - run(`spawnBar(BARCU,44,${s.inlet*4-2},0,0);`);ticks(run,500); - check(`sloped filter grade ${grade} carries a bar it does not pass`,run('ents.length===1 && ents[0].ix>=128')); + run(`spawnBar(BARCU,${s.feedX || 44},${s.inlet*4-2},0,0);`);ticks(run,500); + check(`sloped filter grade ${grade} slides a bar it does not pass downhill`,run(`ents.length===1 && ${s.left?'ents[0].ix<40':'ents[0].ix>=128'}`)); line(grade,true); - run(`for(const b of filterCfg.keys())setFilter(b,{m:[BARCU],inv:0});spawnBar(BARCU,44,${s.inlet*4-2},0,0);`);ticks(run,500); + run(`for(const b of filterCfg.keys())setFilter(b,{m:[BARCU],inv:0});spawnBar(BARCU,${s.feedX || 44},${s.inlet*4-2},0,0);`);ticks(run,500); check(`sloped filter grade ${grade} drops a selected bar through its open frame`,run('ents.length===1 && ents[0].ix<104 && ents[0].iy===206 && !mat.some((m,i)=>occ[i] && PH[m]===PH_SOLID)')); } // Reverse the motor without flipping the upward-facing triangle. @@ -135,8 +140,30 @@ for(const grade of [-1,1]) { check(`reversed grade ${grade} carries bars left across both joins`,run('ents.length===1 && ents[0].ix<40'),run('ents.map(e=>[e.ix,e.iy])')); } fresh(); -run('placeBlock(BP_BELT_UP,1,20,32,true);for(let l=1;l<=CARRY_DEPTH;l++)setCell((131-l)*W+80,SAND,20,0);tickStamp++;carryColumn(130*W+80,1,null);'); -check('a slope carries the same full 96-grain column as a flat belt',run('Array.from({length:CARRY_DEPTH},(_,k)=>mat[(129-k)*W+81]===SAND).every(Boolean)')); +run('placeBlock(BP_BELT_UP,1,20,32,true);'); +check('pocket capacity is bounded by the real cleat height',run('Array.from({length:CLEAT_SPACING},(_,k)=>pocketDepth(80+k,32*BW+20)).every(d=>d>=0 && d<=CLEAT_REACH*2)')); +// A stationary cleat blocks downhill crossings; the bare face still lets grains slide. +for (const grade of [-1,1]) { + const s=line(grade); + run('rebuildPower();slopeCleatsTick(false);'); + check(`grade ${grade} cleats block every downhill crossing`,run(`cleatCells.length>0 && cleatCells.every(i=>!enter(SAND,PH_POWDER,i,${grade},1) && !enter(SAND,PH_POWDER,i+W+${grade},${grade},1))`)); + check(`grade ${grade} bare face slides between steps without stamping the load`,run(`(()=>{ + for(let x=72;x<96;x++){ + const by=${s.by}+((x>>2)-16)*${grade}, y=beltFaceY(blockP[by*BW+(x>>2)],x>>2,by,x)-1,i=y*W+x; + if(cleatMask[i] || cleatMask[i+W+${grade}])continue; + setCell(i,SAND,20,0);tick=3;tickStamp++;machinesTick(); + const unstamped=stamp[i]!==tickStamp; + for(let k=0;k<200 && mat[i]===SAND;k++)powder(i,x,y,SAND); + return unstamped && mat[i]===EMPTY; + } return false; + })()`)); +} +fresh(); +run('placeBlock(BP_FILTER_DOWN,128,20,20,true,{m:[],inv:0});setCell(79*W+80,GRAVEL,20,0);tickStamp++;tick=SCREEN_NUDGE_TICKS-1;screenNudge(80*W+80,1,null);'); +check('screen waits between its configured nudges',run('mat[79*W+80]===GRAVEL && stamp[79*W+80]!==tickStamp')); +run('tick++;screenNudge(80*W+80,1,null);'); +check('screen nudges just its mesh contact downhill at the configured rate',run('mat[80*W+81]===GRAVEL && mat[79*W+80]===EMPTY')); +fresh();run('placeBlock(BP_BELT_UP,1,20,32,true);'); run(fs.readFileSync('src/render/slope-belts.js','utf8')); // The renderer prepares joins and power first; stripe pitch is measured along the rotated band. run('prepareSlopeBelts(0,0,W,H);'); @@ -147,18 +174,18 @@ run("globalThis.GAME={pieceDir:{belt:1,filter:1},filterCfg:{m:[SAND],inv:0},wall for(const id of ['belt','filter'])for(const [dx,dy] of [[5,-4],[4,5],[-5,4],[-4,-5]]) { run('GAME.drag={bx:20,by:20};'); const c=(id==='belt'?19:21)+(dx*dy>0?1:0); - check(`${id} drag ${dx},${dy} picks an upward face and runs along the drag`,run(`pieceCode(PIECE_BY_ID.${id},{bx:${20+dx},by:${20+dy}})===${c} && dragDirFor(PIECE_BY_ID.${id},GAME.drag,{bx:${20+dx},by:${20+dy}})===${dx>0?1:0}`)); + check(`${id} drag ${dx},${dy} picks the slope shape and permitted travel direction`,run(`pieceCode(PIECE_BY_ID.${id},{bx:${20+dx},by:${20+dy}})===${c} && dragDirFor(PIECE_BY_ID.${id},GAME.drag,{bx:${20+dx},by:${20+dy}})===${id==='filter'?(dx*dy>0?1:0):(dx>0?1:0)}`)); } run("globalThis.setStatus=()=>{};globalThis.catOf=()=>0;globalThis.selectTool=()=>{};globalThis.aimBlock=()=>blockOf(GAME.aim.x,GAME.aim.y);"); for(const id of ['belt','filter'])for(const [dx,dy] of [[3,-3],[3,3],[-3,3],[-3,-3]]) { fresh(); run(`globalThis.piece=()=>PIECE_BY_ID.${id};GAME.drag={bx:20,by:20};GAME.aim={x:${(20+dx)*4},y:${(20+dy)*4}};bot.x=80;bot.y=80;primaryRelease(false);`); - const c=(id==='belt'?19:21)+(dx*dy>0?1:0), v=dx>0?(id==='filter'?128:1):0; + const c=(id==='belt'?19:21)+(dx*dy>0?1:0), v=id==='filter'?(dx*dy>0?128:0):(dx>0?1:0); check(`${id} release really builds the diagonal in direction ${dx},${dy}`,run(`Array.from({length:4},(_,k)=>{const b=(20+k*${Math.sign(dy)})*BW+20+k*${Math.sign(dx)};return blockP[b]===${c} && blockV[b]===${v};}).every(Boolean)`)); run('pickUnderCursor();rotate();'); - check(`${id} pick and R reverse the motor while preserving a single slope shape`,run(`pieceCode(PIECE_BY_ID.${id})===${c} && pieceV(PIECE_BY_ID.${id})===${id==='filter'?128-v:1-v}`)); + check(`${id} pick and R preserve the slope and use its permitted direction`,run(`pieceCode(PIECE_BY_ID.${id})===${c} && pieceV(PIECE_BY_ID.${id})===${id==='filter'?v:1-v}`)); run('GAME.drag=aimBlock();GAME.replace=true;primaryRelease(false);GAME.replace=false;'); - check(`${id} a single replacement reverses the picked slope`,run(`blockP[${20+dy}*BW+${20+dx}]===${c} && blockV[${20+dy}*BW+${20+dx}]===${id==='filter'?128-v:1-v}`)); + check(`${id} a single replacement keeps the selected direction`,run(`blockP[${20+dy}*BW+${20+dx}]===${c} && blockV[${20+dy}*BW+${20+dx}]===${id==='filter'?v:1-v}`)); } run(fs.readFileSync('src/ui/selection-pickers.js','utf8')); @@ -166,7 +193,7 @@ const filterLine=line(-1,true); run("applyFilterRun(filterRun(32*BW+16),{m:[GRAVEL],inv:1,dir:0});"); check('the settings UI updates the matching diagonal filter run',run('filterCfg.size===10 && [...filterCfg.keys()].every(b=>sameFilter(filterInfo(b),{m:[GRAVEL],inv:1}) && blockV[b]===0)')); run('undoLast();'); -check('undo restores diagonal filter settings and direction',run('filterCfg.size===10 && [...filterCfg.keys()].every(b=>sameFilter(filterInfo(b),{m:[SAND],inv:0}) && blockV[b]===128)')); +check('undo restores diagonal filter settings and direction',run('filterCfg.size===10 && [...filterCfg.keys()].every(b=>sameFilter(filterInfo(b),{m:[SAND],inv:0}) && blockV[b]===0)')); // An actual v2 fixture, before either diagonal belt or filter existed. const legacy=JSON.parse(require('zlib').gunzipSync(fs.readFileSync(__dirname+'/fixtures/slopes-v2.json.gz'))); globalThis.legacyBeltSave=legacy; diff --git a/tools/experiments/slope-cost.js b/tools/experiments/slope-cost.js new file mode 100644 index 0000000..9ec0ed1 --- /dev/null +++ b/tools/experiments/slope-cost.js @@ -0,0 +1,34 @@ +// Many loaded slopes, with a fresh deterministic world for each timing sample. +const { performance } = require('perf_hooks'); +const { load, flatWorld } = require(process.env.DG_HARNESS || '../../test/harness'); +const { run } = load(); +const assert = require('node:assert/strict'); +const samples=[]; +for(let sample=0;sample<6;sample++) { + flatWorld(run,1024,512,496); + run(`RS=7;money=1e9;AMB.fill(20);temp.fill(20);hot.fill(0); + for(let row=0;row<6;row++)for(let col=0;col<8;col++)for(let k=0;k<12;k++){ + const bx=col*30+4+k,by=row*18+35-k; + placeBlock(BP_BELT_UP,1,bx,by,true); + for(let x=bx*4;xNumber.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. +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);'); + 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`); diff --git a/tools/experiments/slope-flow.js b/tools/experiments/slope-flow.js new file mode 100644 index 0000000..d37e98b --- /dev/null +++ b/tools/experiments/slope-flow.js @@ -0,0 +1,29 @@ +// Reproducible generous-feed throughput and settled pile angles for t42. +const { load, flatWorld } = require(process.env.DG_HARNESS || '../../test/harness'); +const { run } = load(); +const assert = require('node:assert/strict'); +for (const material of ['SAND','GRAVEL','CUORE','COAL']) { + const rates=[]; + for (const grade of [0,-1]) { + flatWorld(run,256,224,208); + run(`RS=7;money=1e6;AMB.fill(20);temp.fill(20);hot.fill(0); + for(let k=0;k<10;k++)placeBlock(${grade?'BP_BELT_UP':'BP_BELT'},1,16+k,${grade?32:24}+k*${grade},true); + for(let x=10;x<16;x++)placeBlock(BP_BELT,1,x,${grade?33:24},true); + for(let x=26;x<32;x++)placeBlock(BP_BELT,1,x,${grade?23:24},true);`); + const result=run(`(()=>{let out=0;for(let k=0;k<1420;k++){ + for(let l=1;l<=4;l++){const i=(${grade?132:96}-l)*W+44;if(mat[i]===EMPTY)setCell(i,${material},20,0);} + simTick(); + for(let i=0;i=128){if(k>=220)out++;setCell(i,EMPTY,20,0);} + for(let j=flyers.length-1;j>=0;j--)if(flyers[j].m===${material} && flyers[j].x>=128){if(k>=220)out++;flyers.splice(j,1);} + }return out/20;})()`); + rates.push(result); + } + flatWorld(run,256,224,208); + run(`RS=7;AMB.fill(20);temp.fill(20);hot.fill(0); + for(let y=120;y<160;y++)for(let x=118;x<138;x++)setCell(y*W+x,${material},20,0); + for(let k=0;k<1200;k++)simTick();`); + // Apparent settled angle: heap height divided by half its footprint width. + const angle=run(`(()=>{let top=H,left=W,right=0;for(let i=0;i=60 && rates[1]<=rates[0]*1.1, `${material} slope rate outside the target`); + console.log(`${material}: flat ${rates[0].toFixed(1)}, uphill ${rates[1].toFixed(1)} grains/s; pile angle ${angle.toFixed(1)} degrees`); +} diff --git a/tools/scenes/slope-conveyors.js b/tools/scenes/slope-conveyors.js new file mode 100644 index 0000000..6ed7a52 --- /dev/null +++ b/tools/scenes/slope-conveyors.js @@ -0,0 +1,21 @@ +// Loaded sidewall conveyor and screen. Use --js 'slopeScene(false)' (belt) or 'slopeScene(true)' (screen). +globalThis.slopeScene = function(screen = false) { + const bx = Math.floor((SX - 100) / BS), by = Math.floor((SY + 12) / BS); + const left = (bx - 12) * BS, right = (bx + 24) * BS; + for(let y=by*BS-64;y=by*BS+56?STONE:EMPTY,20,0); + } + for(let x=left;x