diff --git a/docs/DESIGN.md b/docs/DESIGN.md index b7d2028..04f6956 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -388,3 +388,9 @@ above, including: - **Upgrade ideas for the drill**: an amplifier, a tuning fork, harmonics. Also a Grav pick upgrade tool and mining charges. See `docs/systems/dig-tools.md`. - The owner's ideas for later are tasks tagged `idea` in the Backlog of the task board. + +- **Sloped belts and filters (t40, 2026-10-25).** The owner asked for belts, then filters, to work + diagonally like tubes and walls. A Belt or Filter drag at about 45° lays slope-shaped blocks + whose face is a running belt, carrying grains and bars up or down in the drag's direction at + flat-belt speed; the face grips its load, so nothing slides back. A sloped filter drops what it + passes out underneath and carries the rest. New block codes (19-22), so saves still load. diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index 4223306..4fc3457 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -205,10 +205,10 @@ 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_TU, P_TD, P_TL, P_TR, P_GRATE, P_GOPEN, P_WHEEL, P_INTAKE, P_FILTER, P_PIPE, P_TUR, P_TDR, P_TDL, P_TUL, TUBE_PARTS, TUBE_DIRS, IS_TUBE, TDX, TDY, BIT_TIERS, TANK_TIERS, REACH -### src/sim/pieces.js (156 lines) +### src/sim/pieces.js (176 lines) Everything the player can build (PIECES): blocks, machines and molds, with costs and descriptions; filter settings; wall materials and their cost. -- functions: isSlope, isWall, slopeCell, slopeCode, isPipeCode, filterable, cleanFilterCfg, filterRec, filterPassAt, filterInfo, sameFilter, wallCost -- variables: BS, BP_WALL, BP_BELT, BP_TUBE, 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, filterCfg, WALL_OF, WALL_LIST, BAR_OF, GRAINS_PER_BAR, POWER_FREE, CASH, PIECES, PIECE_BY_ID, PIECE_BY_CODE, BELT_POWER, TUBE_POWER, FAN_POWER, WHEEL_K, ENGINE_K, MOLDS +- functions: isSlopeBelt, isFilter, isBelt, beltGrade, slopeShape, beltFaceY, beltDirection, isSlope, isWall, slopeCell, slopeCode, isPipeCode, filterable, cleanFilterCfg, filterRec, filterPassAt, filterInfo, sameFilter, wallCost +- variables: BS, BP_WALL, BP_BELT, BP_TUBE, 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, TUBE_POWER, FAN_POWER, WHEEL_K, ENGINE_K, MOLDS ### src/sim/economy.js (152 lines) Money, prices, market saturation, the trade dock, buyer and builder orders. @@ -260,27 +260,27 @@ 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 (168 lines) +### src/sim/machine-tick.js (154 lines) Per-tick machine work: steam engines, fans, belts, filters, screw tubes (machinesTick). - functions: engineIntake, fanBlow, machinesTick -### src/sim/conveyors.js (147 lines) +### src/sim/conveyors.js (201 lines) How belts, filters and screw tubes 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: carryColumn, beltDrain, launchGrain, rebuildFilters, filterCarry, setFilter, filterRun, roomFor, tubeSpread, tubeIntake, diagonalTubeNext +- functions: conveyorStep, carryColumn, holdSlopeColumn, beltDrain, launchGrain, rebuildFilters, filterCarry, carryBeltsAndFilters, setFilter, filterRun, roomFor, tubeSpread, tubeIntake, diagonalTubeNext - variables: CARRY_DEPTH, filterR, filterL, filtersDirty -### src/sim/blocks.js (208 lines) +### src/sim/blocks.js (219 lines) Building blocks: placing, integrity, removing, lines and rectangles of blocks. -- functions: blockOf, blockCellList, pieceCost, slopeSeal, slopeSealOwner, wallCells, canPlaceBlock, placeBlock, blockIntegrity, removeWallCell, removeBlock, diagonalDrag, blockLine, blockRect, blockInReach, blockIs, placeBlocks +- functions: blockOf, blockCellList, pieceCost, setSlopeSeal, slopeSeal, slopeSealOwner, wallCells, canPlaceBlock, placeBlock, blockIntegrity, removeWallCell, removeBlock, diagonalDrag, blockLine, blockRect, blockInReach, blockIs, placeBlocks ### src/sim/molds.js (317 lines) Molds: cavities for castings; ingot troughs empty themselves below. Removal returns set metal. - functions: moldCellsAt, canPlaceMold, newMoldRecord, addMold, castMix, cavityState, troughState, clearCavity, moldPlugCells, rakeAbove, furnaceJunkHint, unplugMold, ejectIngot, checkMolds, moldAt, tally, collectMold, removeMold - variables: DROSS, grains, barWord -### src/sim/items.js (256 lines) +### src/sim/items.js (288 lines) Bars and other loose objects (ents), and the bag: adding, taking, costs and refunds. -- functions: entSolidCell, entSupportCount, entFreeAt, entMark, spawnBar, removeEnt, entTubeAt, entTubeFree, entTubeMove, entTubeLane, entTubeEdge, entTubeEnter, entTubeTick, entDiagonalTubeTick, entTick, entsInCircle, bagAdd, bagTake, giveOrDrop, refundAt, canAfford, payCost, fmtMoney, costText, needText +- functions: entSolidCell, entSupportCount, entFreeAt, entMark, spawnBar, removeEnt, entTubeAt, entTubeFree, entTubeMove, entTubeLane, entTubeEdge, entTubeEnter, entTubeTick, entDiagonalTubeTick, entSlopeBeltTick, entTick, entsInCircle, bagAdd, bagTake, giveOrDrop, refundAt, canAfford, payCost, fmtMoney, costText, needText - variables: BAR_W, BAR_H, entCurM, droppedLast ### src/sim/tools.js (52 lines) @@ -356,7 +356,11 @@ 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/world.js (410 lines) +### src/render/slope-belts.js (28 lines) +A slope's band uses world coordinates, so its stripes never restart at block joints. +- functions: slopeBeltAt, slopeBeltColor + +### src/render/world.js (422 lines) Scale colour without changing the smooth-look class. - functions: fogColor, diagonalTubeJoins, diagonalTubeShape, diagonalTubeBlock, drawWorld - variables: TUBE_CLASS, diagonalJoins, diagonalAcross, diagonalAlong, diagonalCap @@ -374,7 +378,7 @@ Smooth look: WebGL setup, texture uploads, drawing and adaptive resolution. - functions: smCompile, smoothAvailable, smUpload, smoothDraw, smoothPace - variables: SM, smBytes -### src/render/smooth-shaders.js (366 lines) +### src/render/smooth-shaders.js (393 lines) Smooth shaders: vertex and fragment programs for the rounded world look. - variables: SM_VS, SM_FS @@ -430,16 +434,16 @@ Notebook blueprints: starter designs, saved library and code sharing. The tool hotbar and what is selected. - functions: renderHotbar, toolId, steering, modShift, grainAt, selectTool, bagMaterials, blowList, blowAllList, blowingAll, piece -### src/ui/build-menu.js (109 lines) +### src/ui/build-menu.js (111 lines) 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 (112 lines) +### src/ui/selection.js (119 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 -### src/ui/selection-pickers.js (59 lines) +### src/ui/selection-pickers.js (61 lines) Selection pickers: filter rules, filter settings and wall materials. What a filter can hold: loose things and bars you have found, plus anything it already holds - functions: filterChoices, matList, filterParts, filterSentence, applyFilterRun, openFilterPicker, openWallPicker @@ -457,7 +461,7 @@ Keyboard and mouse listeners. - functions: isTyping - variables: canvas -### src/ui/actions.js (240 lines) +### src/ui/actions.js (242 lines) What input does: overlays, F, pick, copy / cut / paste, undo, building and tool use each frame. Which overlays are on, shown as a coloured frame round the screen and a named badge - functions: updateOverlayUI, toggleLens, doInteract, pickUnderCursor, aimBlock, stampOrigin, selModeHeld, startPaste, stopSelectAndPaste, plural, piecesIn, doUndo, endRmbRecord, dragDirFor, blockLabel, primaryPress, primaryRelease, secondaryPress, toolTick - variables: ovShown @@ -467,7 +471,7 @@ Gamepad input. - functions: padPressed, pollPad - variables: PAD -### src/ui/previews.js (216 lines) +### src/ui/previews.js (227 lines) Cursor previews: shared grid helpers, air streaks and tool preview dispatch. - functions: blockRectPx, drawGrid, previewColor, drawAirStreaks, drawReachCircle, drawPreviews, previewBlock @@ -518,6 +522,7 @@ The frame loop and boot. - `test/t_pipes.js`: Pipes, pumps and outlets move liquids; the Grinder grinds waste rock for trace ore. - `test/t_racks.js`: Rack intake, FIFO storage, gate, removal and saved-state validation. - `test/t_sim_optimizations.js`: Optimized paths must match the full roof scan and the original heat loop exactly. +- `test/t_slope_belts.js`: Sloped belts: transport, joins, sorting, persistence and the actual drag controls. - `test/t_slopes.js`: Sloped walls: flow, sealed joints, building, refunds and persistence. - `test/t_smooth.py`: The smooth look: the WebGL pass compiles, draws, falls back cleanly, and the thermal lens still works. - `test/t_stone.js`: Uses for waste rock and sand: glass bricks from a fan furnace, the Saw, the silo and builders' orders. @@ -549,3 +554,4 @@ The frame loop and boot. - `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. - `tools/scenes/t35-joins.js`: Run after clear-sky.js. Paused, empty pipes make casing, flights and mouths easy to inspect. - `tools/scenes/t36-funnel.js`: A twelve-block funnel, one block open at the bottom, draining 400 grains of sand. +- `tools/scenes/t40-sloped-belt.js`: Self-contained: a flat feed climbs, crosses the top, and runs downhill; a filter sorts below. diff --git a/docs/agent-log.md b/docs/agent-log.md index d70829f..b8c72a9 100644 --- a/docs/agent-log.md +++ b/docs/agent-log.md @@ -4,6 +4,16 @@ What agents have done, newest first. Add an entry for every piece of work: the d note or request it was for, what changed, the files touched, the tests run, and anything left open or not verified. Keep each entry short; the git history has the details. +## 2026-10-25: t40: sloped belts and filters (Claude Code, Codex) + +- Block codes 19-22 (`BP_BELT_UP/DOWN`, `BP_FILTER_UP/DOWN`, `src/sim/pieces.js`): slope-shaped + belts and filters whose 45° face runs, laid by a diagonal Belt or Filter drag. `conveyorStep`, + `holdSlopeColumn` and a shared front-first order for belts and filters in `src/sim/conveyors.js`; + bars in `items.js`; saves, blueprints, undo, hover, previews; `src/render/slope-belts.js`. +- 90 grains/s flat, up and down for belts and filters; a sloped filter sorted 64/64 with no loss. + Seeds 777, 77 and 4242 generate identically. +- `test/t_slope_belts.js` (32 checks); `run_all.py --browser` ALL PASSED. Art pass follows. + ## 2026-10-25: t35 follow-up: diagonal tube elbows (Claude Code, Codex) - Diagonal tubes are as thick as straight ones, and an end block joining a straight tube, belt or diff --git a/src/manifest.json b/src/manifest.json index 532c951..4a33a0e 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -41,6 +41,7 @@ "client": [ "render/setup.js", "render/light.js", + "render/slope-belts.js", "render/world.js", "render/machines.js", "render/overlays.js", diff --git a/src/render/setup.js b/src/render/setup.js index bdeb7de..e60da43 100644 --- a/src/render/setup.js +++ b/src/render/setup.js @@ -117,7 +117,7 @@ function thinPipeColor(x, y, anim) { PC[0] = r; PC[1] = g; PC[2] = bl; } // a block that is a belt or a filter: they join up into one belt -function beltLike(b) { if (b < 0 || b >= NB) return false; const c = blockP[b]; return c === BP_BELT || c === BP_FILTER; } +function beltLike(b) { if (b < 0 || b >= NB) return false; const c = blockP[b]; return isBelt(c) || isFilter(c); } // standing trees: the trunk shows its dark bark, so it reads apart from the pale cut wood it drops const TREE_R = new Uint8Array(NMAT), TREE_G = new Uint8Array(NMAT), TREE_B = new Uint8Array(NMAT); for (let m = 0; m < NMAT; m++) { TREE_R[m] = COLR[m]; TREE_G[m] = COLG[m]; TREE_B[m] = COLB[m]; } diff --git a/src/render/slope-belts.js b/src/render/slope-belts.js new file mode 100644 index 0000000..7e3f9db --- /dev/null +++ b/src/render/slope-belts.js @@ -0,0 +1,28 @@ +// A slope's band uses world coordinates, so its stripes never restart at block joints. +function slopeBeltAt(i, x, y) { + const b = (y >> 2) * BW + (x >> 2); + if (isSlopeBelt(blockP[b])) return b; + if ((y & 3) || ((x & 3) !== 0 && (x & 3) !== 3)) return -1; + const owner = slopeSealOwner(i); + return owner >= 0 && isSlopeBelt(blockP[owner]) ? owner : -1; +} +function slopeBeltColor(b, x, y, anim, br, bg, bb) { + const c = blockP[b], grade = beltGrade(c), d = y - beltFaceY(c, b % BW, (b / BW) | 0, x); + let r = br, g = bg, blue = bb; + if (d >= 0) { + const dir = isFilter(c) ? (blockV[b] & 128 ? 1 : -1) : (blockV[b] ? 1 : -1); + const axis = (x + grade * y) * 0.5, stripe = (Math.floor((axis - dir * (anim >> 1)) / 2) & 1); + if (d === 0) { + r = stripe ? 92 : 56; g = stripe ? 96 : 58; blue = stripe ? 104 : 66; + if (isFilter(c) && !stripe) { + const f = filterCfg.get(b), m = f && f.m[0]; + if (m) { r = COLR[m] * 0.7; g = COLG[m] * 0.7; blue = COLB[m] * 0.7; } + } + } else if (isFilter(c)) { + if (d === 1 && ((x + (grade < 0 ? 0 : 1)) & 1)) { r = 64; g = 68; blue = 76; } + } else if (d === 1) { + r = stripe ? 132 : 104; g = stripe ? 120 : 94; blue = stripe ? 108 : 84; + } else { r = 46; g = 48; blue = 54; } + } + return (r | 0) | ((g | 0) << 8) | ((blue | 0) << 16); +} diff --git a/src/render/smooth-shaders.js b/src/render/smooth-shaders.js index ec56109..bf5ae7b 100644 --- a/src/render/smooth-shaders.js +++ b/src/render/smooth-shaders.js @@ -51,8 +51,8 @@ float beltSD(vec2 lb, bool endL, bool endR) { // the line only rounds off where the whole run ends. float beltLineSD(ivec2 c, vec2 lb) { int nr = cls(cellAt(tCol, c + ivec2(4, 0))), nl = cls(cellAt(tCol, c - ivec2(4, 0))); - return beltSD(lb, isBelt(nl) + isF(nl) < 0.5 && !(nl >= 19 && nl <= 22), - isBelt(nr) + isF(nr) < 0.5 && !(nr >= 19 && nr <= 22)); + return beltSD(lb, isBelt(nl) + isF(nl) < 0.5 && !(nl >= 19 && nl <= 30) && cls(cellAt(tCol, c + ivec2(-4, -4))) < 23, + isBelt(nr) + isF(nr) < 0.5 && !(nr >= 19 && nr <= 30) && cls(cellAt(tCol, c + ivec2(4, -4))) < 23); } // The rubber band of a belt or filter: 1 on a ridge. Ridges travel along the top run one way and // back along the bottom. @@ -352,6 +352,33 @@ void main() { } } + // Sloped belts and filters: a continuous running band over a triangular frame. + if (nc >= 23 && nc <= 30) { + int shape = (nc - 23) / 2; + bool mesh = shape >= 2; + float grade = (shape == 0 || shape == 2) ? -1.0 : 1.0; + float dir = ((nc - 23) % 2) == 1 ? 1.0 : -1.0; + vec4 tag = cellAt(tTree, c); + vec2 lb = mod(w, 4.0); + if (tag.a > 0.4) lb.y += 4.0; + float face = grade < 0.0 ? 4.0 - lb.x : lb.x; + float depth = (lb.y - face) * 0.70710678; + float inside = smoothstep(-px, px, depth); + float axis = (w.x + grade * w.y) * 0.5; + float rg = step(0.72, fract((axis - dir * uTime * 30.0) * 0.5)); + vec3 band = bandColor(vec2(axis, 0.5), rg); + vec3 frame = vec3(0.18, 0.19, 0.21); + float roller = 1.0 - smoothstep(0.32, 0.5, length(vec2(mod(axis, 2.0) - 1.0, depth - 0.95))); + frame = mix(frame, vec3(0.50, 0.40, 0.31), roller); + if (mesh) { + float hole = step(0.48, fract(axis)) * step(0.35, fract(depth * 1.8)); + band = mix(band, dot(tag.rgb, vec3(1)) > 0.01 ? tag.rgb * 0.7 : vec3(0.28, 0.32, 0.33), hole); + frame = mix(C, vec3(0.25, 0.27, 0.30), roller * 0.7); + } + vec3 belt = mix(band, frame, smoothstep(0.55, 0.75, depth)); + C = mix(C, belt, inside); + } + // ---- light, then the glow of hot things on top if (uLightMap.w > 0.5) C *= textureLod(tLight, (q - uLightMap.xy) / uLightMap.z / uLightSize, 0.0).rgb; if (uGlowK > 0.0) { diff --git a/src/render/world.js b/src/render/world.js index 66e6024..cc28eb3 100644 --- a/src/render/world.js +++ b/src/render/world.js @@ -281,6 +281,11 @@ function drawWorld() { if (pipeBodyCell(x, y)) { thinPipeColor(x, y, anim); r = PC[0]; g = PC[1]; b = PC[2]; } } } + const sb = slopeBeltAt(i, x, y), slopeArt = sb >= 0 && (m === EMPTY || PH[m] === PH_GAS || m === BELTL || m === BELTR); + if (slopeArt) { + const color = slopeBeltColor(sb, x, y, anim, br, bgc, bb); + r = color & 255; g = color >> 8 & 255; b = color >> 16 & 255; + } // 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]; @@ -296,9 +301,9 @@ function drawWorld() { // what kind of thing this grain is drawn as: 0 open, 1 terrain, 2 loose grain, 3 liquid, // 4 built piece (drawn crisp), 5 smoke and flame, 6 built wall block (drawn as a tile) let cl = 0; - const pt = p === P_PIPE && !pipeBodyCell(x, y) ? 0 : p; // the open parts of a pipe block show what is in them + const pt = (p === P_PIPE && !pipeBodyCell(x, y)) || (p === P_FILTER && sb >= 0) ? 0 : p; // the open parts of a pipe block show what is in them if (IS_TUBE[pt]) cl = TUBE_CLASS[pt]; - else if (p === P_FILTER) { + else if (p === P_FILTER && sb < 0) { // filter: 13 carries left, 14 right; the tree buffer carries its colour tag (first material) and mode const fb = (y >> 2) * BW + (x >> 2), fc = filterCfg.get(fb), fm = fc && fc.m.length ? fc.m[0] : 0; cl = blockV[fb] & 128 ? 14 : 13; @@ -319,6 +324,13 @@ function drawWorld() { const wm = WALL_OF[blockV[(y >> 2) * BW + (x >> 2)]]; r = COLR[wm]; g = COLG[wm]; b = COLB[wm]; } + if (slopeArt) { + const code = blockP[sb], dir = isFilter(code) ? (blockV[sb] & 128 ? 1 : 0) : blockV[sb]; + cl = 23 + (code - BP_BELT_UP) * 2 + dir; + const f = isFilter(code) && filterCfg.get(sb), fm = f && f.m[0]; + tpx[row + bx] = ((sb !== (y >> 2) * BW + (x >> 2) ? 0x80 : 0x40) << 24) | + (fm ? (COLB[fm] << 16) | (COLG[fm] << 8) | COLR[fm] : 0); + } 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 5c3d661..0b2f81f 100644 --- a/src/sim/blocks.js +++ b/src/sim/blocks.js @@ -3,33 +3,42 @@ function blockOf(x, y) { return { bx: Math.floor(x / BS), by: Math.floor(y / BS) }; } function blockCellList(bx, by) { const out = []; for (let y = by * BS; y < by * BS + BS; y++) for (let x = bx * BS; x < bx * BS + BS; x++) out.push(y * W + x); return out; } function pieceCost(code, v) { return isWall(code) ? wallCost(v, isSlope(code) ? 8 : BS * BS) : PIECE_BY_CODE[code].cost; } +function setSlopeSeal(i, code, v) { + if (isFilter(code)) { part[i] = P_FILTER; wakeIdx(i); } + else { + setCell(i, isBelt(code) ? (v ? BELTR : BELTL) : WALL_OF[v], AMB[(i / W) | 0], 0); + if (isBelt(code)) markPow(i, true); + } +} // One extra cell on the solid side seals diagonal crossings between successive triangles. function slopeSeal(code, bx, by) { + code = slopeShape(code); const x = bx * BS + (code === BP_SLOPE_BR || code === BP_SLOPE_TR ? 0 : 3); const y = by * BS + (code >= BP_SLOPE_TL ? -1 : 4); return y < 0 || y >= H ? -1 : y * W + x; } function slopeSealOwner(i) { const x = i % W, y = (i / W) | 0, bx = x >> 2; - for (const by of [(y >> 2) - 1, (y >> 2) + 1]) { + if (((x & 3) !== 0 && (x & 3) !== 3) || ((y & 3) !== 0 && (y & 3) !== 3)) return -1; + for (let by = (y >> 2) - 1; by <= (y >> 2) + 1; by += 2) { if (by < 0 || by >= BH) continue; const b = by * BW + bx, c = blockP[b]; - if (isSlope(c) && slopeSeal(c, bx, by) === i) return b; + if ((isSlope(c) || isSlopeBelt(c)) && slopeSeal(c, bx, by) === i) return b; } return -1; } function wallCells(code, bx, by) { - return blockCellList(bx, by).filter(i => !isSlope(code) || slopeCell(code, (i % W) & 3, ((i / W) | 0) & 3)); + return blockCellList(bx, by).filter(i => !(isSlope(code) || isSlopeBelt(code)) || slopeCell(code, (i % W) & 3, ((i / W) | 0) & 3)); } function canPlaceBlock(bx, by, code = BP_WALL) { if (bx < 0 || by < 0 || bx >= BW || by >= BH) return 'Out of bounds'; if (blockP[by * BW + bx]) return 'Something is already built there'; for (const i of blockCellList(bx, by)) { - if (mach[i] || part[i]) return 'Something is already built there'; + if (mach[i] || (part[i] && slopeSealOwner(i) < 0)) return 'Something is already built there'; if (PH[mat[i]] === PH_SOLID && ((flags[i] & F_NAT) || slopeSealOwner(i) < 0)) return 'Needs open space; clear the rock first.'; if (occ[i]) return 'A bar is in the way'; } - if (isSlope(code)) { + if (isSlope(code) || isSlopeBelt(code)) { const i = slopeSeal(code, bx, by); if (i >= 0 && PH[mat[i]] !== PH_SOLID && (mach[i] || part[i] || occ[i])) return 'The slope corner is blocked.'; } @@ -41,25 +50,25 @@ function placeBlock(code, v, bx, by, free, extra) { if (isWall(code) && !WALL_OF[v]) return 'Pick a wall material with R'; const cost = pieceCost(code, v); if (!free) { if (!canAfford(cost, 1)) return needText(cost, 1); payCost(cost, 1); } - const cells = isSlope(code) ? wallCells(code, bx, by) : blockCellList(bx, by); + const cells = isSlope(code) || isSlopeBelt(code) ? wallCells(code, bx, by) : blockCellList(bx, by); if (code !== BP_PIPE) evictCells(cells, by * BS); // a pipe is thin: what is there stays and flows past it for (const i of cells) { const y = (i / W) | 0, x = i % W; if (isWall(code)) setCell(i, WALL_OF[v], AMB[y], 0); - else if (code === BP_BELT) { setCell(i, v === 0 ? BELTL : BELTR, AMB[y], 0); markPow(i, true); } + else if (isBelt(code)) { setCell(i, v === 0 ? BELTL : BELTR, AMB[y], 0); markPow(i, true); } else if (code === BP_SHAFT) { setCell(i, SHAFT, AMB[y], 0); markPow(i, true); } else if (code === BP_RACK) setCell(i, RACKB, AMB[y], 0); else if (isProcCode(code)) setCell(i, code === BP_GRIND ? GRINDER : code === BP_PRESS ? PRESSB : SAWB, AMB[y], 0); else if (code === BP_GATE) setCell(i, GATE, AMB[y], 0); else if (code === BP_GRATE) { part[i] = P_GRATE; wakeXY(x, y); } else if (code === BP_TUBE) { part[i] = TUBE_PARTS[v & 7]; markPow(i, true); wakeXY(x, y); } - else if (code === BP_FILTER) { part[i] = P_FILTER; wakeXY(x, y); } + else if (isFilter(code)) { part[i] = P_FILTER; wakeXY(x, y); } else if (code === BP_PIPE) { part[i] = P_PIPE; wakeXY(x, y); } else if (isPipeCode(code)) setCell(i, PIPE, AMB[y], 0); } if (isPipeCode(code)) pipesDirty = true; blockP[by * BW + bx] = code; blockV[by * BW + bx] = v; - if (code === BP_FILTER) { blockV[by * BW + bx] = v & 128; filterCfg.set(by * BW + bx, filterRec(extra)); filtersDirty = true; } + if (isFilter(code)) { blockV[by * BW + bx] = v & 128; 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(); @@ -67,9 +76,9 @@ function placeBlock(code, v, bx, by, free, extra) { rackState.set(b, s); for (let q = rackTop(b); q <= rackBottom(b); q += BW) rackState.get(q).open = s.open; } - if (isSlope(code)) { + if (isSlope(code) || isSlopeBelt(code)) { const i = slopeSeal(code, bx, by); - if (i >= 0 && PH[mat[i]] !== PH_SOLID) { evictCells([i], by * BS); setCell(i, WALL_OF[v], AMB[(i / W) | 0], 0); } + if (i >= 0 && PH[mat[i]] !== PH_SOLID && !part[i]) { evictCells([i], by * BS); setSlopeSeal(i, code, v); } } stats.placed++; undoNote({ t: 'block+', bx, by, code }); @@ -84,14 +93,14 @@ function blockIntegrity(b) { for (const i of blockCellList(bx, by)) { const m = mat[i], p = part[i]; if (isWall(code) && (!isSlope(code) || slopeCell(code, (i % W) & 3, ((i / W) | 0) & 3))) { if (PH[m] === PH_SOLID && !(flags[i] & F_NAT) && !PARTMAT[m]) n++; } - else if (code === BP_BELT) { if (m === BELTL || m === BELTR) n++; } + else if (isBelt(code)) { if (m === BELTL || m === BELTR) n++; } else if (code === BP_SHAFT) { if (m === SHAFT) n++; } else if (code === BP_RACK) { if (m === RACKB) n++; } else if (isProcCode(code)) { if (m === (code === BP_GRIND ? GRINDER : code === BP_PRESS ? PRESSB : SAWB)) n++; } else if (code === BP_GATE) { if (m === GATE || p === P_GOPEN) n++; } else if (code === BP_GRATE) { if (p === P_GRATE) n++; } else if (code === BP_TUBE) { if (IS_TUBE[p]) n++; } - else if (code === BP_FILTER) { if (p === P_FILTER) n++; } + else if (isFilter(code)) { if (p === P_FILTER) n++; } else if (isPipeCode(code)) { if (m === PIPE || p === P_PIPE) n++; } } void v; @@ -109,7 +118,7 @@ function removeWallCell(i, wall) { function removeBlock(bx, by) { const b = by * BW + bx, code = blockP[b], v = blockV[b]; if (!code) return 0; - undoNote({ t: 'block-', bx, by, code, v, extra: code === BP_FILTER ? filterInfo(b) : undefined }); + undoNote({ t: 'block-', bx, by, code, v, extra: isFilter(code) ? filterInfo(b) : undefined }); // a pipe taken out spills its share of what the line holds const spill = isPipeCode(code) ? pipeTakeShare(b) : null; let n = 0, metal = 0; @@ -125,7 +134,7 @@ function removeBlock(bx, by) { } else if (isPipeCode(code)) { if (p === P_PIPE) { part[i] = 0; wakeIdx(i); n++; } else if (m === PIPE) { setCell(i, EMPTY, temp[i], 0); n++; } - } else if (code === BP_BELT || code === BP_SHAFT || code === BP_GATE || code === BP_RACK || isProcCode(code)) { + } else if (isBelt(code) || code === BP_SHAFT || code === BP_GATE || code === BP_RACK || isProcCode(code)) { if (m === BELTL || m === BELTR || m === SHAFT || m === GATE || m === GRINDER || m === SAWB || m === PRESSB || m === RACKB) { setCell(i, EMPTY, temp[i], 0); markPow(i, false); n++; } if (p === P_GOPEN) { part[i] = 0; n++; wakeIdx(i); } } else if (p) { part[i] = 0; markPow(i, false); wakeIdx(i); n++; } @@ -137,7 +146,7 @@ function removeBlock(bx, by) { } if (code === BP_WALL && metal) giveOrDrop(BAR_OF[WALL_OF[v]] || BARCU, Math.floor(metal / GRAINS_PER_BAR), bx * BS + 2, by * BS + 2); else if (!isWall(code) && n >= 8) refundAt(PIECE_BY_CODE[code].cost, 1, bx * BS + 2, by * BS + 2); - if (code === BP_FILTER) { filtersDirty = true; filterCfg.delete(b); } + if (isFilter(code)) { filtersDirty = true; filterCfg.delete(b); } if (isProcCode(code)) { procDirty = true; procState.delete(b); } if (code === BP_RACK) { const s = rackState.get(b); @@ -146,17 +155,19 @@ function removeBlock(bx, by) { } blockP[b] = 0; blockV[b] = 0; const cleared = blockCellList(bx, by); - if (isSlope(code)) { + if (isSlope(code) || isSlopeBelt(code)) { const i = slopeSeal(code, bx, by); if (i >= 0) { const nb = (((i / W) | 0) >> 2) * BW + ((i % W) >> 2), nc = blockP[nb]; - if ((!nc || (isSlope(nc) && !slopeCell(nc, (i % W) & 3, ((i / W) | 0) & 3))) && mat[i] === WALL_OF[v] && !(flags[i] & F_NAT)) setCell(i, EMPTY, temp[i], 0); + if ((!nc || (isSlope(nc) && !slopeCell(nc, (i % W) & 3, ((i / W) | 0) & 3))) && (mat[i] === (isBelt(code) ? (v ? BELTR : BELTL) : WALL_OF[v]) || (isFilter(code) && part[i] === P_FILTER)) && !(flags[i] & F_NAT)) { if (isFilter(code)) { part[i] = 0; wakeIdx(i); } else { setCell(i, EMPTY, temp[i], 0); if (isBelt(code)) markPow(i, false); } } cleared.push(i); } } for (const i of cleared) { const owner = slopeSealOwner(i); - if (owner >= 0 && mat[i] === EMPTY) setCell(i, WALL_OF[blockV[owner]], AMB[(i / W) | 0], 0); + if (owner >= 0 && mat[i] === EMPTY && !part[i]) { + setSlopeSeal(i, blockP[owner], blockV[owner]); + } } if (isPipeCode(code)) { pipesDirty = true; @@ -186,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 (code === BP_FILTER) return (blockV[b] & 128) === (v & 128) && sameFilter(filterInfo(b), cleanFilterCfg(extra)); + if (isFilter(code)) return (blockV[b] & 128) === (v & 128) && 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 @@ -197,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] === BP_FILTER && code === BP_FILTER) { 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] = v & 128; 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/blueprints.js b/src/sim/blueprints.js index c3fce38..ef1f061 100644 --- a/src/sim/blueprints.js +++ b/src/sim/blueprints.js @@ -7,7 +7,7 @@ function copyRegion(x0, y0, x1, y1) { const blocks = [], ms = [], ols = []; for (let by = by0; by <= by1; by++) for (let bx = bx0; bx <= bx1; bx++) { const b = by * BW + bx; - if (blockP[b] && blockIntegrity(b) >= 8) blocks.push(blockP[b] === BP_FILTER ? [bx - bx0, by - by0, blockP[b], blockV[b], filterInfo(b)] : [bx - bx0, by - by0, blockP[b], blockV[b]]); + if (blockP[b] && blockIntegrity(b) >= 8) blocks.push(isFilter(blockP[b]) ? [bx - bx0, by - by0, blockP[b], blockV[b], filterInfo(b)] : [bx - bx0, by - by0, blockP[b], blockV[b]]); } for (const mm of machines) { if (!mm || PIECE_BY_ID[mm.type].fixed) continue; @@ -23,11 +23,11 @@ function copyRegion(x0, y0, x1, y1) { } function flipBlueprint(bp) { const bw = bp.bw; - const flipV = (code, v) => code === BP_BELT ? 1 - v : code === BP_FILTER ? v ^ 128 : code === BP_TUBE ? [0, 3, 2, 1, 7, 6, 5, 4][v & 7] + const flipV = (code, v) => isBelt(code) ? 1 - v : isFilter(code) ? v ^ 128 : code === BP_TUBE ? [0, 3, 2, 1, 7, 6, 5, 4][v & 7] : code === BP_PUMP || code === BP_OUTLET ? (((v & 3) === 1 ? 3 : (v & 3) === 3 ? 1 : v & 3) | (v & 4)) : v; return { v: 2, bw, bh: bp.bh, name: bp.name, - blocks: bp.blocks.map(([x, y, c, v, e]) => e ? [bw - 1 - x, y, isSlope(c) ? BP_SLOPE_BL + ((c - BP_SLOPE_BL) ^ 1) : c, flipV(c, v), e] : [bw - 1 - x, y, isSlope(c) ? BP_SLOPE_BL + ((c - BP_SLOPE_BL) ^ 1) : c, flipV(c, v)]), + blocks: bp.blocks.map(([x, y, c, v, e]) => e ? [bw - 1 - x, y, isSlopeBelt(c) ? c + (c & 1 ? 1 : -1) : isSlope(c) ? BP_SLOPE_BL + ((c - BP_SLOPE_BL) ^ 1) : c, flipV(c, v), e] : [bw - 1 - x, y, isSlopeBelt(c) ? c + (c & 1 ? 1 : -1) : isSlope(c) ? BP_SLOPE_BL + ((c - BP_SLOPE_BL) ^ 1) : c, flipV(c, v)]), machines: bp.machines.map(([t, x, y, d]) => { const P = PIECE_BY_ID[t]; let nd = d; diff --git a/src/sim/conveyors.js b/src/sim/conveyors.js index 4f05671..637e4b9 100644 --- a/src/sim/conveyors.js +++ b/src/sim/conveyors.js @@ -4,7 +4,24 @@ // 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. const CARRY_DEPTH = 96; +// Find the next carrying face at the same height or one grain above/below it. +function conveyorStep(x, y, d) { + const nx = x + d; + if (nx < 0 || nx >= W) return 0; + if ((x >> 2) === (nx >> 2)) return beltGrade(blockP[(y >> 2) * BW + (x >> 2)]) * d; + for (let dy = -1; dy <= 1; dy++) { + const yy = y + dy; + if (yy < 0 || yy >= H) continue; + const b = (yy >> 2) * BW + (nx >> 2), c = blockP[b], i = yy * W + nx; + if ((isBelt(c) || isFilter(c)) && beltFaceY(c, nx >> 2, yy >> 2, nx) === yy && + (mat[i] === BELTL || mat[i] === BELTR || part[i] === P_FILTER)) return dy; + } + const b = (y >> 2) * BW + (x >> 2); + return beltGrade(blockP[b]) * d; +} function carryColumn(a, d, pass) { + 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) { if (a < 0) return; @@ -20,20 +37,30 @@ function carryColumn(a, d, pass) { if (blocked) continue; // filters still sift above a stopped load const x = a % W, nx = x + d; if (nx < 0 || nx >= W) return; - const j = a + d, tj = mat[j]; + const j = a + d + step * W; + if (j < 0 || j >= N) return; + const tj = mat[j]; // grains on a wet belt push through the water rather than stopping at it const wet = PH[tj] === PH_LIQUID && !occ[j] && stamp[j] !== tickStamp && (!part[j] || part[j] === P_FILTER || part[j] === P_GOPEN); - if (!wet && !enter(m, PH_POWDER, j, d, 0)) { + if (!wet && !enter(m, PH_POWDER, j, d, step)) { if (!pass) return; blocked = true; continue; } const y = (a / W) | 0, bj = j + W; // carried off the end with nothing under it: it leaves at belt speed and falls - if (mat[j] === EMPTY && !part[j] && bj < N && (mat[bj] === EMPTY || PH[mat[bj]] === PH_GAS) && !occ[bj] && !part[bj]) launchGrain(a, nx, y, d * 0.5, 0); - else move(a, j, x, y, nx, y); + if (mat[j] === EMPTY && !part[j] && bj < N && (mat[bj] === EMPTY || PH[mat[bj]] === PH_GAS) && !occ[bj] && !part[bj]) launchGrain(a, nx, y + step, d * 0.5, step * 0.5); + else move(a, j, x, y, nx, y + step); 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; @@ -53,23 +80,48 @@ function launchGrain(i, x, y, vx, vy) { let filterR = [], filterL = [], filtersDirty = true; function rebuildFilters() { filtersDirty = false; filterR = []; filterL = []; - for (let b = 0; b < NB; b++) if (blockP[b] === BP_FILTER) (blockV[b] & 128 ? filterR : filterL).push(b); + for (let b = 0; b < NB; b++) if (isFilter(blockP[b])) (blockV[b] & 128 ? filterR : filterL).push(b); // front of the line first, so a full run moves as one filterR.sort((a, c) => (c % BW) - (a % BW)); filterL.sort((a, c) => (a % BW) - (c % BW)); } -function filterCarry(b, d) { - if (blockP[b] !== BP_FILTER) return; - const bx = b % BW, y = ((b / BW) | 0) * BS; +function filterCarry(b, d, carrying = true) { + if (!isFilter(blockP[b])) return; + const bx = b % BW, by = (b / BW) | 0, code = blockP[b], y = by * BS; if (y < 1) return; + if (!carrying && !isSlopeBelt(code)) return; const c = filterCfg.get(b), pass = c ? c.pass : null; for (let k = 0; k < BS; k++) { - const x = d > 0 ? bx * BS + BS - 1 - k : bx * BS + k, top = y * W + x; - if (part[top] === P_FILTER) carryColumn(top - W, d, pass); + 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); + } + } +} +// Belts and filters share one front-first order, including their flat/slope joins. +function carryBeltsAndFilters(belts, filters, d, bm, carrying) { + let k = 0, f = 0; + while (k < belts.length || f < filters.length) { + const fx = f < filters.length ? (filters[f] % BW) * BS + (d > 0 ? BS - 1 : 0) : 0; + if (f < filters.length && (k >= belts.length || (fx - belts[k] % W) * d > 0)) { filterCarry(filters[f++], d, carrying); continue; } + const b = belts[k++]; + if (mat[b] !== bm || b < W) continue; + const x = b % W, y = (b / W) | 0, code = blockP[(y >> 2) * BW + (x >> 2)]; + if (!carrying && !isSlopeBelt(code)) continue; + if (isSlopeBelt(code) && beltFaceY(code, x >> 2, y >> 2, x) !== y) continue; + if (slopeSealOwner(b) >= 0) continue; + const above = mat[b - W]; + 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)) holdSlopeColumn(b - W, null); } } // Change what a filter does; grains inside it that no longer pass are pushed out on top. function setFilter(b, cfg) { - if (blockP[b] !== BP_FILTER) return false; + if (!isFilter(blockP[b])) return false; const rec = filterRec(cfg); filterCfg.set(b, rec); const bx = b % BW, by = (b / BW) | 0; @@ -79,11 +131,13 @@ function setFilter(b, cfg) { } // The run of filters in a row that touch block b and do the same thing it does function filterRun(b) { - if (blockP[b] !== BP_FILTER) return []; + if (!isFilter(blockP[b])) return []; const f = filterInfo(b), dir = blockV[b] & 128, by = (b / BW) | 0, out = [b]; for (const s of [-1, 1]) for (let bx = (b % BW) + s; bx >= 0 && bx < BW; bx += s) { - const c = by * BW + bx; - if (blockP[c] !== BP_FILTER || (blockV[c] & 128) !== dir || !sameFilter(filterInfo(c), f)) break; + const yy = by + beltGrade(blockP[b]) * (bx - (b % BW)); + if (yy < 0 || yy >= BH) break; + const c = yy * BW + bx; + if (blockP[c] !== blockP[b] || (blockV[c] & 128) !== dir || !sameFilter(filterInfo(c), f)) break; out.push(c); } return out.sort((a, c) => a - c); diff --git a/src/sim/items.js b/src/sim/items.js index c3ea375..116f20e 100644 --- a/src/sim/items.js +++ b/src/sim/items.js @@ -59,9 +59,10 @@ function entTubeFree(x, y, tubeOnly = false) { function entTubeMove(e, x, y, tubeOnly = false, mouth = false) { if (!entTubeFree(x, y, tubeOnly)) return false; if (mouth) { - // Lift clear of the receiving belt before sliding over it; check both legs before moving. - for (let yy = Math.min(e.iy, y); yy <= Math.max(e.iy, y); yy++) if (!entTubeFree(e.ix, yy)) return false; - for (let xx = Math.min(e.ix, x); xx <= Math.max(e.ix, x); xx++) if (!entTubeFree(xx, y)) return false; + // Go around the receiving corner, checking both legs before moving. + const acrossY = y > e.iy ? e.iy : y, downX = y > e.iy ? x : e.ix; + for (let yy = Math.min(e.iy, y); yy <= Math.max(e.iy, y); yy++) if (!entTubeFree(downX, yy)) return false; + for (let xx = Math.min(e.ix, x); xx <= Math.max(e.ix, x); xx++) if (!entTubeFree(xx, acrossY)) return false; } else for (let yy = Math.min(e.iy, y); yy <= Math.max(e.iy, y); yy++) for (let xx = Math.min(e.ix, x); xx <= Math.max(e.ix, x); xx++) if (!entTubeFree(xx, yy)) return false; e.x = e.ix = x; e.y = e.iy = y; e.vx = e.vy = 0; return true; @@ -71,7 +72,7 @@ function entTubeEdge(e, dx, dy, k) { const x = e.ix + (dx > 0 ? BAR_W : dx < 0 ? -1 : k), y = e.iy + (dy > 0 ? BAR_H : dy < 0 ? -1 : k); return x < 0 || y < 0 || x >= W || y >= H ? -1 : y * W + x; } -function entTubeEnter(e, dx, dy) { +function entTubeEnter(e, dx, dy, mouth = false) { for (let k = 0; k < (dx ? BAR_H : BAR_W); k++) { const i = entTubeEdge(e, dx, dy, k); if (i < 0) continue; @@ -79,7 +80,7 @@ function entTubeEnter(e, dx, dy) { const p = part[i]; if (!IS_TUBE[p] || (!dx && TDX[p]) || dx * TDX[p] + dy * TDY[p] < 0) continue; // Vertical screws require the full width; horizontal screws use the nearest two-row lane. const x = (xx & ~3), y = entTubeLane(yy, e.iy); - if (entTubeMove(e, x, TDX[p] || dy ? y : e.iy, true)) return true; + if (entTubeMove(e, x, TDX[p] || dy ? y : e.iy, true, mouth)) return true; } return false; } @@ -129,6 +130,37 @@ function entDiagonalTubeTick(e, p) { for (let yy = y; yy < y + BAR_H; yy++) for (let xx = x; xx < x + BAR_W; xx++) if (part[yy * W + xx]) return; if (entTubeMove(e, x, y, false, dy < 0)) { e.vx = dx * 0.5; e.vy = dy * 0.6; stats.moved++; } } +// Bars stay level; their highest contact follows the face one grain per belt beat. +function entSlopeBeltTick(e) { + const bottom = e.iy + BAR_H; + if (bottom >= H) return false; + let d = 0, slope = 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 (!d) return false; + let face = H; + for (let x = e.ix + d; x < e.ix + d + BAR_W; x++) for (let dy = -1; dy <= 1; dy++) { + const y = bottom + dy; + if (x < 0 || x >= W || y < 0 || y >= H) continue; + 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; + 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; + 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 (!entTubeEnter(e, d, 0)) entTubeEnter(e, d, Math.sign(y - e.iy), true); + return true; +} function entTick() { for (let k = ents.length - 1; k >= 0; k--) { const e = ents[k]; @@ -137,7 +169,7 @@ function entTick() { let ix = e.ix; const iy = e.iy; const tube = entTubeAt(ix, iy); if (tube) entTubeTick(e, tube); - else { + else if (!entSlopeBeltTick(e)) { // what is underneath? let belt = 0, bsat = 0, transport = false, support = iy + BAR_H >= H; if (!support) { diff --git a/src/sim/machine-tick.js b/src/sim/machine-tick.js index aeb03fb..b600b61 100644 --- a/src/sim/machine-tick.js +++ b/src/sim/machine-tick.js @@ -87,24 +87,10 @@ function machinesTick() { mm.hot = Math.round(hotT); } } - // belts and filters move what rests on them every other tick: half a grain per tick - if ((tick & 1) === 0) { - for (let pass = 0; pass < 2; pass++) { - const list = pass ? beltR : beltL, d = pass ? 1 : -1, bm = pass ? BELTR : BELTL; - for (let k = 0; k < list.length; k++) { - const b = list[k]; - if (mat[b] !== bm || b < W) continue; - const above = mat[b - W]; - if (above === BELTL || above === BELTR) continue; // only the top surface carries - if (netSat(b) <= 0) continue; - if (PH[above] === PH_LIQUID) beltDrain(b); - carryColumn(b - W, d, null); - } - } - if (filtersDirty) rebuildFilters(); - for (const b of filterR) filterCarry(b, 1); - for (const b of filterL) filterCarry(b, -1); - } + // Half a grain per tick; sloped faces grip their load on the intervening ticks too. + if (filtersDirty) rebuildFilters(); + carryBeltsAndFilters(beltL, filterL, -1, BELTL, (tick & 1) === 0); + carryBeltsAndFilters(beltR, filterR, 1, BELTR, (tick & 1) === 0); // tubes: 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_TUBE.length; p++) { diff --git a/src/sim/machines.js b/src/sim/machines.js index c9f2885..5fd1594 100644 --- a/src/sim/machines.js +++ b/src/sim/machines.js @@ -44,7 +44,7 @@ function rebuildPower() { if (!net) continue; if (mm.type === 'fan') net.fans.push(mm); else if (mm.type === 'wheel' || mm.type === 'engine') net.prods.push(mm); } - beltL.sort((a, b) => a - b); beltR.sort((a, b) => b - a); + beltL.sort((a, b) => (a % W) - (b % W) || a - b); beltR.sort((a, b) => (b % W) - (a % W) || b - a); for (const p of TUBE_PARTS) { tubeLists[p].sort((a, b) => { if (TDX[p] && TDY[p]) return ((b % W) - (a % W)) * TDX[p]; diff --git a/src/sim/pieces.js b/src/sim/pieces.js index 14b04f7..f4d98d3 100644 --- a/src/sim/pieces.js +++ b/src/sim/pieces.js @@ -6,9 +6,22 @@ const BS = 4; const BP_WALL = 1, BP_BELT = 2, BP_TUBE = 3, BP_SHAFT = 4, BP_GRATE = 5, BP_GATE = 6, BP_FILTER = 7, BP_PIPE = 8, BP_PUMP = 9, BP_OUTLET = 10, BP_GRIND = 11, BP_SAW = 12, BP_PRESS = 13, BP_RACK = 14; const BP_SLOPE_BL = 15, BP_SLOPE_BR = 16, BP_SLOPE_TL = 17, BP_SLOPE_TR = 18; +// The shape is independent of the direction: reversing a belt never flips its face. +const BP_BELT_UP = 19, BP_BELT_DOWN = 20, BP_FILTER_UP = 21, BP_FILTER_DOWN = 22; +function isSlopeBelt(c) { return c >= BP_BELT_UP && c <= BP_FILTER_DOWN; } +function isFilter(c) { return c === BP_FILTER || c === BP_FILTER_UP || c === BP_FILTER_DOWN; } +function isBelt(c) { return c === BP_BELT || c === BP_BELT_UP || c === BP_BELT_DOWN; } +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 beltDirection(c, v) { + const d = isFilter(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; } function isWall(c) { return c === BP_WALL || isSlope(c); } function slopeCell(c, x, y) { + c = slopeShape(c); if (c === BP_SLOPE_BR || c === BP_SLOPE_TR) x = 3 - x; if (c === BP_SLOPE_TL || c === BP_SLOPE_TR) y = 3 - y; return y >= x; @@ -30,7 +43,12 @@ function filterRec(c) { for (let m = 1; m < NMAT; m++) if (filterable(m)) pass[m] = (k.m.includes(m) ? 1 : 0) ^ k.inv; return { m: k.m, inv: k.inv, pass }; } -function filterPassAt(m, j) { const c = filterCfg.get((((j / W) | 0) >> 2) * BW + ((j % W) >> 2)); return c ? c.pass[m] : 0; } +function filterPassAt(m, j) { + let b = (((j / W) | 0) >> 2) * BW + ((j % W) >> 2); + if (!isFilter(blockP[b])) { const owner = slopeSealOwner(j); if (owner >= 0) b = owner; } + const c = filterCfg.get(b); + return c ? c.pass[m] : 0; +} function filterInfo(b) { const c = filterCfg.get(b); return c ? { m: c.m.slice(), inv: c.inv } : { m: [], inv: 0 }; } function sameFilter(a, b) { return a.inv === b.inv && a.m.length === b.m.length && a.m.every((m, k) => m === b.m[k]); } // loose material -> the solid it becomes in a wall block @@ -52,11 +70,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.' }, + 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.' }, { id: 'tube', code: BP_TUBE, name: 'Screw tube', kind: 'block', cost: [[CASH, 25]], dirs: ['up', 'right', 'down', 'left', 'up-right', 'down-right', 'down-left', 'up-left'], desc: 'A turning screw 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 tube. Join either end to tubes 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.' }, + 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.' }, { 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]], @@ -97,6 +115,8 @@ const PIECES = [ const PIECE_BY_ID = {}, PIECE_BY_CODE = []; PIECES.forEach((p, i) => { p.index = i; PIECE_BY_ID[p.id] = p; if (p.code) PIECE_BY_CODE[p.code] = p; if (p.kind === 'machine') { p.w = p.bw * BS; p.h = p.bh * BS; } }); for (let c = BP_SLOPE_BL; c <= BP_SLOPE_TR; c++) PIECE_BY_CODE[c] = PIECE_BY_ID.wall; +for (const c of [BP_BELT_UP, BP_BELT_DOWN]) PIECE_BY_CODE[c] = PIECE_BY_ID.belt; +for (const c of [BP_FILTER_UP, BP_FILTER_DOWN]) PIECE_BY_CODE[c] = PIECE_BY_ID.filter; // power per grain of belt / tube (a block is 16 grains) const BELT_POWER = 0.03, TUBE_POWER = 0.125, FAN_POWER = 6; const WHEEL_K = 0.32, ENGINE_K = 40; diff --git a/src/sim/save.js b/src/sim/save.js index b1567d7..b0427a8 100644 --- a/src/sim/save.js +++ b/src/sim/save.js @@ -55,8 +55,8 @@ function deserializeWorld(meta, buffers) { } // pipes used to be solid copper blocks; now they are thin and let things past for (let b = 0; b < NB; b++) if (blockP[b] === BP_PIPE) for (const i of blockCellList(b % BW, (b / BW) | 0)) if (mat[i] === PIPE) { mat[i] = EMPTY; part[i] = P_PIPE; } - for (const [b, m, inv] of meta.filters || []) if (blockP[b] === BP_FILTER) filterCfg.set(b, filterRec({ m, inv })); - for (let b = 0; b < NB; b++) if (blockP[b] === BP_FILTER) { + for (const [b, m, inv] of meta.filters || []) if (isFilter(blockP[b])) filterCfg.set(b, filterRec({ m, inv })); + for (let b = 0; b < NB; b++) if (isFilter(blockP[b])) { // the first filters kept one material in their block variant if (!filterCfg.has(b)) filterCfg.set(b, filterRec({ m: [blockV[b] & 127], inv: 0 })); blockV[b] &= 128; diff --git a/src/sim/undo.js b/src/sim/undo.js index cdc12df..b62f818 100644 --- a/src/sim/undo.js +++ b/src/sim/undo.js @@ -22,8 +22,8 @@ function undoLast() { for (let k = rec.ops.length - 1; k >= 0; k--) { const o = rec.ops[k]; if (o.t === 'block+') { if (blockP[o.by * BW + o.bx] === o.code) removeBlock(o.bx, o.by); } - else if (o.t === 'block-') { const r = placeBlock(o.code, o.code === BP_FILTER ? o.v & 128 : o.v, o.bx, o.by, false, o.extra); if (r) fail(r); } - else if (o.t === 'filter') { const b = o.by * BW + o.bx; if (blockP[b] === BP_FILTER) { blockV[b] = o.v & 128; setFilter(b, o.extra); filtersDirty = true; } } + else if (o.t === 'block-') { const r = placeBlock(o.code, isFilter(o.code) ? o.v & 128 : o.v, o.bx, o.by, false, o.extra); if (r) fail(r); } + else if (o.t === 'filter') { const b = o.by * BW + o.bx; if (isFilter(blockP[b])) { blockV[b] = o.v & 128; setFilter(b, o.extra); filtersDirty = true; } } else if (o.t === 'mach+') { const mm = machines.find(m => m && m.type === o.type && m.x === o.bx * BS && m.y === o.by * BS); if (mm && siloFull(mm)) fail('empty the silo first'); else if (mm) removeMachine(mm.id, true, false); } else if (o.t === 'mach-') { const r = placeMachineAt(o.type, o.dir, o.bx, o.by); if (r) fail(r); else { const mm = machines.find(m => m && m.type === o.type && m.x === o.bx * BS && m.y === o.by * BS); if (mm) mm.on = o.on !== false; } } else if (o.t === 'mold+') { const mo = molds.find(m => m.kind === o.kind && m.x === o.bx * BS && m.y === o.by * BS); if (mo) removeMold(mo); } @@ -56,8 +56,8 @@ function ghostTick0() { if (nx.done) { ghostIter = ghostBlocks.entries(); nx = ghostIter.next(); if (nx.done) break; } const [b, [code, v, e]] = nx.value; const bx = b % BW, by = (b / BW) | 0; - if (blockP[b] === code && blockV[b] === (code === BP_FILTER ? v & 128 : v)) { - if (code === BP_FILTER && e && !sameFilter(filterInfo(b), cleanFilterCfg(e))) setFilter(b, e); // same piece, new settings + if (blockP[b] === code && blockV[b] === (isFilter(code) ? v & 128 : v)) { + if (isFilter(code) && e && !sameFilter(filterInfo(b), cleanFilterCfg(e))) setFilter(b, e); // same piece, new settings ghostBlocks.delete(b); ghostBlocked.delete(b); continue; } if (!blockInReach(bx, by)) continue; diff --git a/src/ui/actions.js b/src/ui/actions.js index 762478c..4f3535e 100644 --- a/src/ui/actions.js +++ b/src/ui/actions.js @@ -27,16 +27,16 @@ function doInteract() { if (fb >= 0) { if (!inReach(ax, ay)) { setStatus('Out of reach.'); playSfx('error'); return; } const run = filterRun(fb); - openFilterPicker(Object.assign(filterInfo(fb), { dir: blockV[fb] & 128 ? 1 : 0 }), f => { + openFilterPicker(Object.assign(filterInfo(fb), { dir: blockV[fb] & 128 ? 1 : 0, code: blockP[fb] }), f => { applyFilterRun(run, f); GAME.filterCfg = { m: f.m.slice(), inv: f.inv }; GAME.pieceDir.filter = f.dir; - setStatus(`${run.length} ${run.length === 1 ? 'filter' : 'filters'} updated, carrying ${f.dir ? 'right' : 'left'}.`, 'info'); + setStatus(`${run.length} ${run.length === 1 ? 'filter' : 'filters'} updated, carrying ${beltDirection(blockP[fb], f.dir ? 128 : 0)}.`, 'info'); }, run.length > 1 ? `Filter (${run.length} in this row)` : 'Filter'); GAME.editRun = run; return; } if (toolId() === 'build' && pieceUsesWall(piece())) { openWallPicker(); return; } - if (toolId() === 'build' && piece().filter) { openFilterPicker(Object.assign({}, GAME.filterCfg, { dir: GAME.pieceDir.filter || 0 }), f => { GAME.filterCfg = { m: f.m, inv: f.inv }; GAME.pieceDir.filter = f.dir; }, 'New filters'); return; } + if (toolId() === 'build' && piece().filter) { openFilterPicker(Object.assign({}, GAME.filterCfg, { dir: GAME.pieceDir.filter || 0, code: pieceCode(piece()) }), f => { GAME.filterCfg = { m: f.m, inv: f.inv }; GAME.pieceDir.filter = f.dir; }, 'New filters'); return; } let msg = ''; outer: for (let r = 0; r <= 2; r++) for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) { if (!inReach(ax + dx, ay + dy)) continue; @@ -59,6 +59,7 @@ function pickUnderCursor() { else if (blockP[bi] || ghost) { const code = blockP[bi] || ghost[0], v = blockP[bi] ? blockV[bi] : ghost[1]; const P = PIECE_BY_CODE[code]; pid = P.id; + if (isBelt(code) || isFilter(code)) { GAME.pieceShape = GAME.pieceShape || {}; GAME.pieceShape[P.id] = code; } if (isWall(code)) { GAME.wallMat = v; GAME.pieceDir.wall = isSlope(code) ? code - BP_SLOPE_BL + 1 : 0; } else if (P.filter) { GAME.filterCfg = blockP[bi] ? filterInfo(bi) : cleanFilterCfg(ghost[2]); GAME.pieceDir[P.id] = v >> 7; } else if (P.dirs) GAME.pieceDir[P.id] = v % P.dirs.length; @@ -99,6 +100,7 @@ function dragDirFor(P, a, b) { if (!a || !P.dirs || !(P.id === 'belt' || P.id === 'tube' || 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 === 'tube' && diagonalDrag(dx, dy)) return dx > 0 ? (dy < 0 ? 4 : 5) : (dy > 0 ? 6 : 7); if (P.id === 'tube') 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; @@ -163,7 +165,7 @@ function primaryRelease(shift) { } if (t === 'build' && GAME.drag) { const P = piece(), b = aimBlock(), code = pieceCode(P, b); - const list = shift ? blockRect(GAME.drag.bx, GAME.drag.by, b.bx, b.by) : blockLine(GAME.drag.bx, GAME.drag.by, b.bx, b.by, isWall(code) || code === BP_TUBE); + const list = shift ? blockRect(GAME.drag.bx, GAME.drag.by, b.bx, b.by) : blockLine(GAME.drag.bx, GAME.drag.by, b.bx, b.by, isWall(code) || isSlopeBelt(code) || code === BP_TUBE); const dd = dragDirFor(P, GAME.drag, b); if (dd >= 0) GAME.pieceDir[P.id] = dd; const v = pieceV(P), ex = pieceExtra(P); diff --git a/src/ui/build-menu.js b/src/ui/build-menu.js index e836be5..380104e 100644 --- a/src/ui/build-menu.js +++ b/src/ui/build-menu.js @@ -12,8 +12,8 @@ const PIECE_ICONS = { wall: '', grate: '', gate: '', - belt: '', - filter: '', + belt: '', + filter: '', tube: '', pipe: '', pump: '', @@ -52,7 +52,9 @@ function buildVariantChips(P) { // while dragging a line of belts, filters or tubes, the direction is the drag's const dragging = GAME.drag && GAME.dragDir >= 0 && P.dirs; const glyph = d => ({ left: '←', right: '→', up: '↑', down: '↓', 'up-right': '↗', 'down-right': '↘', 'down-left': '↙', 'up-left': '↖' })[d] || ''; - const dirChip = !P.dirs ? '' : dragging ? `${glyph(P.dirs[GAME.dragDir])} ${esc(P.dirs[GAME.dragDir])} · from your drag` : `${glyph(dir)} ${esc(dir)} · R`; + const code = pieceCode(P), direction = dragging ? GAME.dragDir : GAME.pieceDir[P.id] || 0; + const slopeDir = isSlopeBelt(code) ? beltDirection(code, P.filter ? direction << 7 : direction) : ''; + const dirChip = !P.dirs ? '' : dragging ? `${glyph(slopeDir || P.dirs[GAME.dragDir])} ${esc(slopeDir || P.dirs[GAME.dragDir])} · from your drag` : `${glyph(slopeDir || dir)} ${esc(slopeDir || dir)} · R`; const fc = GAME.filterCfg; const sws = ms => ms.slice(0, 6).map(m => ``).join('') + (ms.length > 6 ? `+${ms.length - 6}` : ''); return pieceUsesWall(P) ? `${P.code === BP_WALL ? `${['square', '◣ lower left', '◢ lower right', '◤ upper left', '◥ upper right'][isSlope(pieceCode(P)) ? pieceCode(P) - BP_SLOPE_BL + 1 : 0]} · T · turn / flip` : ''}` diff --git a/src/ui/info.js b/src/ui/info.js index 749250f..dfbf8eb 100644 --- a/src/ui/info.js +++ b/src/ui/info.js @@ -109,7 +109,7 @@ function hoverInfo(ax, ay) { t1 = press ? 'Brick press' : saw ? 'Saw' : 'Grinder'; t2.push(s && s.blocked ? saw || press ? 'stopped: no room underneath; a belt there carries them away' : 'stopped: clear the space under it' : s && tick - s.busyT < 30 ? press ? 'pressing' : saw ? 'cutting' : 'grinding' : press ? 'idle: drop clay lumps on top' : saw ? 'idle: drop stone rubble or wood chunks on top' : 'idle: drop rubble, gravel, sand or dirt on top', press ? `${stats.pressed || 0} green bricks pressed` : saw ? `${(stats.bricks || 0).toLocaleString('en-US')} stone blocks, ${(stats.planks || 0).toLocaleString('en-US')} planks cut` : `${(stats.refined || 0).toLocaleString('en-US')} ground, ${(stats.refinedOre || 0).toLocaleString('en-US')} ore found`); } else if (p === P_FILTER) { - const fb = ((ay / BS) | 0) * BW + ((ax / BS) | 0), pr = filterParts(filterInfo(fb)), ar = blockV[fb] & 128 ? '→' : '←'; + const owner = slopeSealOwner(i), fb = owner >= 0 ? owner : ((ay / BS) | 0) * BW + ((ax / BS) | 0), pr = filterParts(filterInfo(fb)), ar = beltDirection(blockP[fb], blockV[fb]); t1 = 'Filter ' + ar; t2.push(`drops ${pr.drops}`, `carries ${pr.rides} ${ar}`, 'F to change · I shows all'); } else if (p === P_PIPE) { const pb = ((ay / BS) | 0) * BW + ((ax / BS) | 0), pn = pipeNetOf(pb); @@ -117,7 +117,7 @@ function hoverInfo(ax, ay) { t2.push(pipeContentsText(pn)); } else if (IS_TUBE[p]) t1 = 'Screw tube ' + PIECE_BY_ID.tube.dirs[TUBE_PARTS.indexOf(p)] + (m !== EMPTY ? ` · ${MD[m].name}` : ''); else if (p) t1 = ({ 5: 'Grate', 6: 'Gate (open)', 7: 'Water wheel', 8: 'Engine intake' })[p] + (m !== EMPTY ? ` · ${MD[m].name}` : ''); - else if (m === BELTL || m === BELTR) t1 = 'Belt ' + (m === BELTL ? '←' : '→'); + else if (m === BELTL || m === BELTR) { const owner = slopeSealOwner(i), fb = owner >= 0 ? owner : (ay >> 2) * BW + (ax >> 2); t1 = 'Belt ' + beltDirection(blockP[fb], blockV[fb]); } else if (m === PIPE && isPipeCode(b)) { const pb = ((ay / BS) | 0) * BW + ((ax / BS) | 0), pn = pipeNetOf(pb), v = blockV[pb], arrows = ['↑', '→', '↓', '←']; t1 = b === BP_PUMP ? `Pump ${arrows[v & 3]}` : b === BP_OUTLET ? `Outlet ${['↓', '←', '↑', '→'][v & 3]}` : 'Pipe'; diff --git a/src/ui/notebook.js b/src/ui/notebook.js index fce71cf..a40e47c 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, tubes, 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. 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, tubes, 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.'], ['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 c7c8c88..f8768b2 100644 --- a/src/ui/previews.js +++ b/src/ui/previews.js @@ -15,7 +15,8 @@ function drawGrid(cbx, cby, radius) { } function previewColor(code, v) { if (isWall(code)) { const m = WALL_OF[v]; return `${COLR[m]},${COLG[m]},${COLB[m]}`; } - if (code === BP_FILTER) return '96,100,110'; + if (isFilter(code)) return '96,100,110'; + if (isBelt(code)) return '110,114,124'; return { [BP_BELT]: '110,114,124', [BP_TUBE]: '120,132,150', [BP_SHAFT]: '160,118,70', [BP_GRATE]: '170,90,66', [BP_GATE]: '160,84,64', [BP_PIPE]: '176,112,72', [BP_PUMP]: '104,122,142', [BP_OUTLET]: '170,106,66' }[code] || '120,200,190'; } // Air swirls into the pickup ring or spreads from the nozzle toward the cursor. @@ -107,7 +108,7 @@ function drawPreviews() { if (P.kind === 'block') { const v = pieceV(P); col = previewColor(code, v); - cells = GAME.drag ? (modShift() ? blockRect(GAME.drag.bx, GAME.drag.by, b.bx, b.by) : blockLine(GAME.drag.bx, GAME.drag.by, b.bx, b.by, isWall(code) || code === BP_TUBE)) : [[b.bx, b.by]]; + cells = GAME.drag ? (modShift() ? blockRect(GAME.drag.bx, GAME.drag.by, b.bx, b.by) : blockLine(GAME.drag.bx, GAME.drag.by, b.bx, b.by, isWall(code) || isSlopeBelt(code) || code === BP_TUBE)) : [[b.bx, b.by]]; const dd = GAME.drag ? dragDirFor(P, GAME.drag, b) : -1; if (dd !== GAME.dragDir) { GAME.dragDir = dd; hudDirty = true; } const dIdx = dd >= 0 ? dd : (GAME.pieceDir[P.id] || 0); @@ -126,7 +127,7 @@ function drawPreviews() { previewBlock(code, x, y, w, h, true, P.id === 'tube' ? dIdx : -1, bx, by, planned); ctx.setLineDash([]); if (P.dirs && (P.id === 'belt' || P.id === 'tube' || P.filter || P.id === 'pump' || P.id === 'outlet')) { // a chevron on every block, pointing the way it will carry (or pump, or pour) - const [dx, dy] = P.id === 'tube' ? TUBE_DIRS[dIdx] : P.id === 'pump' ? PUMP_V[dIdx] : P.id === 'outlet' ? OUTLET_V[dIdx] : [[-1, 0], [1, 0]][dIdx]; + const [dx, dy] = isSlopeBelt(code) ? [dIdx ? 1 : -1, beltGrade(code) * (dIdx ? 1 : -1)] : P.id === 'tube' ? TUBE_DIRS[dIdx] : P.id === 'pump' ? PUMP_V[dIdx] : P.id === 'outlet' ? OUTLET_V[dIdx] : [[-1, 0], [1, 0]][dIdx]; const cx = x + w / 2, cy = y + h / 2, k = w * 0.22, px_ = -dy, py_ = dx; ctx.strokeStyle = 'rgba(20,16,12,0.55)'; ctx.lineWidth = Math.max(2, zp * 0.9); ctx.lineCap = 'round'; ctx.lineJoin = 'round'; const chev = () => { ctx.beginPath(); ctx.moveTo(cx - dx * k * 0.6 + px_ * k, cy - dy * k * 0.6 + py_ * k); ctx.lineTo(cx + dx * k * 0.7, cy + dy * k * 0.7); ctx.lineTo(cx - dx * k * 0.6 - px_ * k, cy - dy * k * 0.6 - py_ * k); ctx.stroke(); }; @@ -206,6 +207,16 @@ function previewBlock(code, x, y, w, h, stroke = false, direction = -1, bx = -1, if (stroke) ctx.stroke(); else ctx.fill(); ctx.restore(); return; } + if (isSlopeBelt(code) && isFilter(code)) { + ctx.beginPath(); + if (beltGrade(code) < 0) { + ctx.moveTo(x, y + h); ctx.lineTo(x + w, y); ctx.lineTo(x + w, y + h * 0.25); ctx.lineTo(x + w * 0.25, y + h); + } else { + ctx.moveTo(x, y); ctx.lineTo(x + w, y + h); ctx.lineTo(x + w * 0.75, y + h); ctx.lineTo(x, y + h * 0.25); + } + ctx.closePath(); if (stroke) ctx.stroke(); else ctx.fill(); return; + } + code = slopeShape(code); if (!isSlope(code)) { if (stroke) ctx.strokeRect(x, y, w, h); else ctx.fillRect(x, y, w, h); return; } const right = code === BP_SLOPE_BR || code === BP_SLOPE_TR, above = code >= BP_SLOPE_TL; ctx.beginPath(); diff --git a/src/ui/selection-pickers.js b/src/ui/selection-pickers.js index e2cb051..305cc5b 100644 --- a/src/ui/selection-pickers.js +++ b/src/ui/selection-pickers.js @@ -13,7 +13,9 @@ function filterParts(f) { function filterSentence(f) { const p = filterParts(f); return `drop ${p.drops} and carry ${p.rides}`; } // Change the whole run of matching filters at once function applyFilterRun(run, f) { - for (const b of run) { blockV[b] = f.dir ? 128 : 0; setFilter(b, 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); } + }); filtersDirty = true; } function openFilterPicker(cur, onDone, title) { @@ -24,7 +26,7 @@ function openFilterPicker(cur, onDone, title) { -
Rollers turn
` +
Rollers turn
` + (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; } }]); diff --git a/src/ui/selection.js b/src/ui/selection.js index 17d8eb8..b8703ba 100644 --- a/src/ui/selection.js +++ b/src/ui/selection.js @@ -87,7 +87,7 @@ function pieceV(P) { return GAME.pieceDir[P.id] || 0; } function pieceExtra(P) { return P.filter ? { m: GAME.filterCfg.m.slice(), inv: GAME.filterCfg.inv } : undefined; } -function filterBlockAt(x, y) { if (x < 0 || y < 0 || x >= W || y >= H) return -1; const b = ((y / BS) | 0) * BW + ((x / BS) | 0); return blockP[b] === BP_FILTER ? b : -1; } +function filterBlockAt(x, y) { if (x < 0 || y < 0 || x >= W || y >= H) return -1; const b = ((y / BS) | 0) * BW + ((x / BS) | 0); return isFilter(blockP[b]) ? b : -1; } function chipCost(cost) { const ok = canAfford(cost, 1); return `${esc(costText(cost, 1))}`; } function pieceCostNow(P, m = GAME.wallMat) { if (P.kind === 'mold') return wallCost(m, MOLDS[P.mold].walls); @@ -96,6 +96,13 @@ function pieceCostNow(P, m = GAME.wallMat) { } function pieceCode(P, end = aimBlock()) { + if (P.id === 'belt' || P.filter) { + const a = GAME.drag || end; + if (GAME.drag && diagonalDrag(end.bx - a.bx, end.by - a.by) && !(typeof modShift === 'function' && modShift())) + return (P.filter ? BP_FILTER_UP : BP_BELT_UP) + ((end.bx - a.bx) * (end.by - a.by) > 0 ? 1 : 0); + if (end.bx === a.bx && end.by === a.by) return GAME.pieceShape && GAME.pieceShape[P.id] || P.code; + return P.code; + } if (P.code !== BP_WALL) return P.code; const a = GAME.drag || end, dx = end.bx - a.bx, dy = end.by - a.by; if (GAME.drag && (dx || dy)) { diff --git a/test/t_slope_belts.js b/test/t_slope_belts.js new file mode 100644 index 0000000..3e12340 --- /dev/null +++ b/test/t_slope_belts.js @@ -0,0 +1,173 @@ +// Sloped belts: transport, joins, sorting, persistence and the actual drag controls. +const { load, suite, flatWorld, rect, ticks, count } = require('./harness'); +const fs = require('fs'); +const { run } = load(), { check, done } = suite(); +function fresh() { + flatWorld(run, 256, 224, 208); + run('RS=7;nextPowerBuild=0;money=10000;AMB.fill(20);temp.fill(20);hot.fill(0);'); +} +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(); + 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 }; +} +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++;}}` : ''} + 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);} + }return {out,added,left:mat.reduce((n,m)=>n+(m===SAND),0)+flyers.filter(f=>f.m===SAND).length};})()`); +} +const rates=[]; +for(const grade of [0,-1,1]) { + let s; + if(grade) s=line(grade); + 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)); + const end=collect(s,600); + check(`grade ${grade} leaves no load stuck`,end.left===0,JSON.stringify(end)); +} +console.log('Throughput grains/s flat/up/down: '+rates.join('/')); +for(const grade of [-1,1]) { + const s=line(grade); + rect(run,40,s.inlet*4-8,55,s.inlet*4-1,run('SAND')); + rect(run,39,20,39,s.inlet*4-1,run('STONE')); + 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);`); + 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])')); +} +for(const grade of [-1,1]) { + const s=line(grade); + rect(run,68,s.by*4-12,71,s.by*4-5,run('WATER')); + const before=count(run,run('WATER')); + ticks(run,600); + check(`water drains through grade ${grade} and is conserved`,count(run,run('WATER'))===before && count(run,run('WATER'),0,200,255,207)===before); + // A completely full face held at its head must not unload backwards or through corners. + line(grade); + run(`for(let x=64;x<104;x++){const by=${s.by}+((x>>2)-16)*${grade},c=blockP[by*BW+(x>>2)],y=beltFaceY(c,x>>2,by,x);for(let l=1;l<=4;l++)setCell((y-l)*W+x,SAND,20,0);}`); + 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); + 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);`); + 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]) { + line(grade); + run(`for(let bx=26;bx<32;bx++)removeBlock(bx,${s.outlet});for(let k=0;k<3;k++)placeBlock(BP_TUBE,${diagonal?(grade<0?4:5):1},26+k,${s.by+9*grade+(grade<0?-1:0)}+k*${diagonal?grade:0},true);`); + rect(run,40,s.inlet*4-8,55,s.inlet*4-1,run('SAND')); + 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'} tube`,r.out===128 && r.left===0,JSON.stringify(r)); + run(`spawnBar(BARCU,44,${s.inlet*4-2},0,0);`); + ticks(run,600); + check(`grade ${grade} unloads a bar into a ${diagonal?'diagonal':'straight'} tube`,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])')); + + } +} +for(const grade of [-1,1]) { + const s=line(grade,true); + run('for(const b of filterCfg.keys())setFilter(b,{m:[SAND],inv: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)); +} +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);`); + 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);}} + }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)); +} +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})':''}`)); + 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};})()`)); +} +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')); + 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); + 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. +for(const grade of [-1,1]) { + const s=line(grade,false,0); + rect(run,112,s.outlet*4-8,127,s.outlet*4-1,run('SAND')); + rect(run,128,20,128,s.outlet*4-1,run('STONE')); + const r=run(`(()=>{let out=0;for(let k=0;k<1000;k++){simTick();for(let i=0;i=0;f--)if(flyers[f].m===SAND && flyers[f].x<40){out++;flyers.splice(f,1);}}return {out,left:mat.reduce((n,m)=>n+(m===SAND),0)};})()`); + check(`reversed grade ${grade} delivers every grain to the left`,r.out===128 && r.left===0,JSON.stringify(r)); + line(grade,false,0);run(`spawnBar(BARCU,120,${s.outlet*4-2},0,0);`);ticks(run,500); + 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(fs.readFileSync('src/render/slope-belts.js','utf8')); +check('sloped stripes vary along the face and travel with the motor',run('(()=>{const b=32*BW+20,c=(x,y,t)=>slopeBeltColor(b,x,y,t,0,0,0);return c(80,131,0)!==c(82,129,0) && c(80,131,0)===c(82,129,4);})()')); +run(fs.readFileSync('src/ui/selection.js','utf8')); +run(fs.readFileSync('src/ui/actions.js','utf8')); +run("globalThis.GAME={pieceDir:{belt:1,filter:1},filterCfg:{m:[SAND],inv:0},wallMat:RSTONE};globalThis.hudDirty=false;globalThis.aimBlock=()=>({bx:25,by:15});globalThis.toolId=()=>'build';globalThis.playSfx=()=>{};"); +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}`)); +} +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; + 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}`)); + 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}`)); + +} +run(fs.readFileSync('src/ui/selection-pickers.js','utf8')); +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)')); +// 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; +run('deserializeWorld(legacyBeltSave.meta,legacyBeltSave.buffers)'); +check('old v2 saves keep all wall slope ids and materials',run('Array.from({length:4},(_,k)=>{const b=6*BW+2+k*3;return blockP[b]===15+k && blockV[b]===RSTONE && blockIntegrity(b)===10;}).every(Boolean)')); +done(); diff --git a/tools/scenes/t40-sloped-belt.js b/tools/scenes/t40-sloped-belt.js new file mode 100644 index 0000000..6e40e1b --- /dev/null +++ b/tools/scenes/t40-sloped-belt.js @@ -0,0 +1,31 @@ +// Self-contained: a flat feed climbs, crosses the top, and runs downhill; a filter sorts below. +const left = Math.max(8, ((SX - 176) >> 2) * 4), base = ((SY - 20) >> 2), bx0 = left >> 2; +const ground = (base + 22) * 4; +for (let y = (base - 16) * 4; y < ground + 8; y++) for (let x = left; x < left + 188; x++) { + const i = y * W + x; + mat[i] = y >= ground ? STONE : EMPTY; part[i] = 0; mach[i] = 0; bgm[i] = 0; flags[i] = y >= ground ? F_NAT : 0; temp[i] = 20; wakeXY(x, y); +} +for (let x = left; x < left + 188; x++) { surfY[x] = ground; dirtY[x] = ground + 8; } +AMB.fill(20); money = 50000; weather.next = 1e15; +for (let k = 4; k < 10; k++) placeBlock(BP_BELT, 1, bx0 + k, base + 1, true); +for (let k = 0; k < 10; k++) placeBlock(BP_BELT_UP, 1, bx0 + 10 + k, base - k, true); +for (let k = 20; k < 26; k++) placeBlock(BP_BELT, 1, bx0 + k, base - 9, true); +for (let k = 0; k < 10; k++) placeBlock(BP_BELT_DOWN, 1, bx0 + 26 + k, base - 9 + k, true); +for (let k = 36; k < 43; k++) placeBlock(BP_BELT, 1, bx0 + k, base + 1, true); +const low = base + 16; +for (let k = 4; k < 10; k++) placeBlock(BP_BELT, 1, bx0 + k, low + 1, true); +for (let k = 0; k < 10; k++) placeBlock(BP_FILTER_UP, 128, bx0 + 10 + k, low - k, true, {m:[SAND],inv:0}); +for (let k = 20; k < 30; k++) placeBlock(BP_BELT, 1, bx0 + k, low - 9, true); +for (let k = 0; k < 380; k++) { + for (let l = 1; l <= 4; l++) { + let i = ((base + 1) * 4 - l) * W + left + 20; + if (mat[i] === EMPTY) setCell(i, SAND, 20, 0); + i = ((low + 1) * 4 - l) * W + left + 20; + if (mat[i] === EMPTY) setCell(i, (k + l) & 1 ? SAND : GRAVEL, 20, 0); + } + simTick(); +} +spawnBar(BARCU, left + 92, (base - 9) * 4 - BAR_H, 0, 0, 20); +window.CAM = [left + 92, (base + 6) * 4]; +window.SLOPE_CAM = [left + 64, (base - 4) * 4]; +updateRoof();