From 381019b7aa5f6bdcd7b0af7b648bb09b94f1ae1f Mon Sep 17 00:00:00 2001 From: 44r0n7 <44r0n7+gitea@pm.me> Date: Sun, 25 Oct 2026 17:17:51 -0400 Subject: [PATCH] =?UTF-8?q?Sloped=20walls=20with=20a=20flat=2045=C2=B0=20f?= =?UTF-8?q?ace,=20and=20diagonal=20wall=20lines=20(t36)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New Sloped wall piece: a half-block triangle in any wall material, at half a wall's cost. Dragging walls or slopes at about 45° lays a diagonal line; the drag picks the slope's orientation and T flips or turns it. - Each slope fills one cell past its corner, so a diagonal run is sealed against grains and water. - New test/t_slopes.js: a slope funnel passes 400/400 sand (square steps 379/400). Co-Authored-By: Claude Opus 5.5 --- docs/DESIGN.md | 9 ++++ docs/PROJECT_MAP.md | 26 ++++++----- docs/agent-log.md | 11 +++++ src/render/smooth-shaders.js | 11 ++++- src/render/world.js | 17 +++++-- src/sim/blocks.js | 78 +++++++++++++++++++++++++------ src/sim/blueprints.js | 2 +- src/sim/pieces.js | 15 ++++++ src/sim/tools.js | 2 +- src/sim/undo.js | 2 +- src/ui/actions.js | 12 ++--- src/ui/build-menu.js | 5 +- src/ui/info.js | 2 +- src/ui/input.js | 2 +- src/ui/notebook.js | 6 +-- src/ui/preview-selection.js | 2 +- src/ui/previews.js | 27 +++++++---- src/ui/selection.js | 15 +++++- test/play.py | 19 ++++++++ test/t_slopes.js | 90 ++++++++++++++++++++++++++++++++++++ tools/scenes/t36-funnel.js | 16 +++++++ 21 files changed, 311 insertions(+), 58 deletions(-) create mode 100644 test/t_slopes.js create mode 100644 tools/scenes/t36-funnel.js diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 8dec1b6..548a2ac 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -364,6 +364,15 @@ above, including: tick, nearest first (a Huge world is fully started after 17 s), and flying toward areas still waiting starts them. Nothing is skipped: the same settling happens, spread out. +- **Sloped walls and diagonal wall lines (t36, 2026-10-25).** The owner wanted funnel walls that + material slides down instead of catching on block steps. A Sloped wall is a half-block triangle + with a flat 45° face, in four orientations, built of any wall material at half a wall block's + cost. Dragging walls or slopes at about 45° lays a diagonal line; for slopes the drag direction + picks the orientation (solid side below) and T flips it, or turns a single piece. Each slope + also fills one cell past its corner so a diagonal run is sealed (grains move diagonally and + would otherwise slip through where two pieces meet). Square walls placed diagonally still + touch only at corners and leak there, like any square blocks. + ## 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 3e67937..5f32336 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, TDX, TDY, BIT_TIERS, TANK_TIERS, REACH -### src/sim/pieces.js (143 lines) +### src/sim/pieces.js (158 lines) Everything the player can build (PIECES): blocks, machines and molds, with costs and descriptions; filter settings; wall materials and their cost. -- functions: 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, 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: 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 ### src/sim/economy.js (152 lines) Money, prices, market saturation, the trade dock, buyer and builder orders. @@ -269,9 +269,9 @@ How belts, filters and screw tubes move what is on or in them. What rests on a b - functions: carryColumn, beltDrain, launchGrain, rebuildFilters, filterCarry, setFilter, filterRun, roomFor, tubeSpread, tubeIntake - variables: CARRY_DEPTH, filterR, filterL, filtersDirty -### src/sim/blocks.js (156 lines) +### src/sim/blocks.js (208 lines) Building blocks: placing, integrity, removing, lines and rectangles of blocks. -- functions: blockOf, blockCellList, pieceCost, canPlaceBlock, placeBlock, blockIntegrity, removeWallCell, removeBlock, blockLine, blockRect, blockInReach, blockIs, placeBlocks +- functions: blockOf, blockCellList, pieceCost, 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. @@ -356,7 +356,7 @@ Drawing (src/render/): browser only. This file: the renderer state R, colour tab Propagated coloured light from glowing and burning cells. - functions: builtForLight, computeLight -### src/render/world.js (321 lines) +### src/render/world.js (330 lines) Scale colour without changing the smooth-look class. - functions: fogColor, drawWorld - variables: TUBE_CLASS @@ -374,7 +374,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 (330 lines) +### src/render/smooth-shaders.js (337 lines) Smooth shaders: vertex and fragment programs for the rounded world look. - variables: SM_VS, SM_FS @@ -430,14 +430,14 @@ 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 (104 lines) +### src/ui/build-menu.js (105 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 (96 lines) +### src/ui/selection.js (107 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 +- 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) 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 @@ -467,9 +467,9 @@ Gamepad input. - functions: padPressed, pollPad - variables: PAD -### src/ui/previews.js (192 lines) +### src/ui/previews.js (203 lines) Cursor previews: shared grid helpers, air streaks and tool preview dispatch. -- functions: blockRectPx, drawGrid, previewColor, drawAirStreaks, drawReachCircle, drawPreviews +- functions: blockRectPx, drawGrid, previewColor, drawAirStreaks, drawReachCircle, drawPreviews, previewBlock ### src/ui/preview-drill.js (104 lines) Drill previews: shaken rock cracks, pulse wavefronts and aiming fan. Rock the drill has shaken but not yet broken shows cracks, more and finer as it nears breaking. Each crack is a contour line of a noise field (marching squares, cell by cell), so the lines run on unbroken from one cell to the next and look like real fractures, not a pattern. @@ -517,6 +517,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_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. - `test/t_water.js`: Water conservation, settling, ledge pouring, boundaries, pipes and phase changes. Tall worlds keep geothermal heat out of the ponds. @@ -544,3 +545,4 @@ The frame loop and boot. - `tools/scenes/silo.js`: Run after clear-sky.js; leave paused so the open gate keeps its demonstration charge. - `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/t36-funnel.js`: A twelve-block funnel, one block open at the bottom, draining 400 grains of sand. diff --git a/docs/agent-log.md b/docs/agent-log.md index 5f0b0e7..2d8d608 100644 --- a/docs/agent-log.md +++ b/docs/agent-log.md @@ -4,6 +4,17 @@ 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: t36: sloped walls and diagonal wall lines (Claude Code, Codex) + +- `BP_SLOPE_BL`..`BP_SLOPE_TR` (15-18) in `src/sim/pieces.js` (`isSlope`, `isWall`, `slopeCell`, + `slopeCode`); `src/sim/blocks.js`: half cost and refund, `slopeSeal` corner cell and + `slopeSealOwner`, `diagonalDrag` and `blockLine(.., diagonal)`. Build menu, T key + (`turnSlope`), previews, copy/paste, undo, blueprints, hover, both looks. +- `test/t_slopes.js`: funnel 400/400 sand (square steps 379/400), water 400/400, no leaks in all + four orientations from both sides, bars slide, every wall material; play.py menu and diagonal + drag. Seeds unchanged. `run_all.py --browser` ALL PASSED. Screenshots + `shots/t36-funnel-*.png`, scene `tools/scenes/t36-funnel.js`. + ## 2026-10-25: t33: loose grains and solid ground look different (Claude Code, Codex) - Smooth look (`src/render/smooth-shaders.js`): natural ground gets a soft seam where it meets diff --git a/src/render/smooth-shaders.js b/src/render/smooth-shaders.js index 7fca332..ff5e9a3 100644 --- a/src/render/smooth-shaders.js +++ b/src/render/smooth-shaders.js @@ -271,7 +271,7 @@ void main() { } C = tc; } - else if (nc == 6) { + else if (nc == 6 || (nc >= 15 && nc <= 18)) { vec2 lb = fract(w * 0.25) * 4.0, rb = 4.0 - lb; float e = min(min(lb.x, lb.y), min(rb.x, rb.y)); vec3 tc = own.rgb * (0.92 + 0.14 * fbm(w * 0.55)); @@ -279,7 +279,14 @@ void main() { float shd = max(1.0 - smoothstep(0.0, 0.8, rb.x), 1.0 - smoothstep(0.0, 0.8, rb.y)); tc *= 1.0 + 0.2 * hl - 0.26 * shd; tc *= mix(0.5, 1.0, smoothstep(px * 0.5, px * 0.5 + max(px * 1.2, 0.14), e)); - C = tc; + if (nc >= 15) { + vec2 sl = lb; + if (nc == 16 || nc == 18) sl.x = 4.0 - sl.x; + if (nc >= 17) sl.y = 4.0 - sl.y; + float d = (sl.y - sl.x) * 0.70710678; + tc *= 1.0 + 0.2 * (1.0 - smoothstep(0.0, 0.6, d)); + C = mix(C, tc, smoothstep(-px * 0.5, px * 0.5, d)); + } else C = tc; } else if (nc == 13 || nc == 14) { diff --git a/src/render/world.js b/src/render/world.js index bd4c611..87c582f 100644 --- a/src/render/world.js +++ b/src/render/world.js @@ -145,9 +145,11 @@ function drawWorld() { r += lift; g += lift; b += lift * 1.08; } // built wall blocks read as tiles: light top-left edge, dark bottom-right edge - if (!sm && PH[m] === PH_SOLID && !(flags[i] & F_NAT) && blockP[(y >> 2) * BW + (x >> 2)] === BP_WALL) { + if (!sm && PH[m] === PH_SOLID && !(flags[i] & F_NAT) && isWall(blockP[(y >> 2) * BW + (x >> 2)])) { const lx = x & 3, ly = y & 3; - if (lx === 0 || ly === 0) { r += 16; g += 16; b += 16; } + const wc = blockP[(y >> 2) * BW + (x >> 2)]; + const edge = isSlope(wc) && (wc === BP_SLOPE_BL || wc === BP_SLOPE_TR ? lx === ly : lx + ly === 3); + if (edge || lx === 0 || ly === 0) { r += 16; g += 16; b += 16; } else if (lx === 3 || ly === 3) { r -= 18; g -= 18; b -= 18; } } } @@ -226,11 +228,17 @@ function drawWorld() { else if (pt || PARTMAT[m] || mmask[row + bx]) cl = 4; else if (m !== EMPTY) { const ph = PH[m]; - if (ph === PH_SOLID) cl = (!(flags[i] & F_NAT) && blockP[(y >> 2) * BW + (x >> 2)] === BP_WALL) ? 6 : 1; + if (ph === PH_SOLID) cl = (!(flags[i] & F_NAT) && isWall(blockP[(y >> 2) * BW + (x >> 2)])) ? 6 : 1; else if (ph === PH_POWDER) cl = 2; else if (ph === PH_LIQUID) cl = 3; else cl = 5; } + const wallCode = blockP[(y >> 2) * BW + (x >> 2)]; + if (isSlope(wallCode) && ((m === EMPTY && !slopeCell(wallCode, x & 3, y & 3)) || (PH[m] === PH_SOLID && !(flags[i] & F_NAT)))) { + cl = wallCode; + const wm = WALL_OF[blockV[(y >> 2) * BW + (x >> 2)]]; + r = COLR[wm]; g = COLG[wm]; b = COLB[wm]; + } 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); } @@ -279,8 +287,9 @@ function drawWorld() { const blocked = ghostBlocked.has(b), code = gv[0], v = gv[1]; let tr = 90, tg = 190, tb = 230; if (blocked) { tr = 230; tg = 70; tb = 60; } - else if (code === BP_WALL && WALL_OF[v]) { const wm = WALL_OF[v]; tr = (COLR[wm] + 90) / 2; tg = (COLG[wm] + 190) / 2; tb = (COLB[wm] + 230) / 2; } + else if (isWall(code) && WALL_OF[v]) { const wm = WALL_OF[v]; tr = (COLR[wm] + 90) / 2; tg = (COLG[wm] + 190) / 2; tb = (COLB[wm] + 230) / 2; } for (let dy = 0; dy < BS; dy++) for (let dx = 0; dx < BS; dx++) { + if (isSlope(code) && !slopeCell(code, dx, dy)) continue; const sx = gbx * BS + dx - vx0, sy = gby * BS + dy - vy0; if (sx < 0 || sy < 0 || sx >= bw || sy >= bh) continue; const k = sy * bw + sx, c = px[k]; diff --git a/src/sim/blocks.js b/src/sim/blocks.js index 74db3d8..8ba5293 100644 --- a/src/sim/blocks.js +++ b/src/sim/blocks.js @@ -2,28 +2,50 @@ // ---------- Blocks ---------- 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 code === BP_WALL ? wallCost(v, BS * BS) : PIECE_BY_CODE[code].cost; } -function canPlaceBlock(bx, by) { +function pieceCost(code, v) { return isWall(code) ? wallCost(v, isSlope(code) ? 8 : BS * BS) : PIECE_BY_CODE[code].cost; } +// One extra cell on the solid side seals diagonal crossings between successive triangles. +function slopeSeal(code, bx, by) { + 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 (by < 0 || by >= BH) continue; + const b = by * BW + bx, c = blockP[b]; + if (isSlope(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)); +} +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 (PH[mat[i]] === PH_SOLID) return 'Needs open space; clear the rock first.'; + 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)) { + 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.'; + } return ''; } // v: wall material (loose id) for walls, direction for belts and tubes function placeBlock(code, v, bx, by, free, extra) { - const err = canPlaceBlock(bx, by); if (err) return err; - if (code === BP_WALL && !WALL_OF[v]) return 'Pick a wall material with R'; + const err = canPlaceBlock(bx, by, code); if (err) return err; + 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 = blockCellList(bx, by); + const cells = isSlope(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 (code === BP_WALL) setCell(i, WALL_OF[v], AMB[y], 0); + 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 (code === BP_SHAFT) { setCell(i, SHAFT, AMB[y], 0); markPow(i, true); } else if (code === BP_RACK) setCell(i, RACKB, AMB[y], 0); @@ -45,6 +67,10 @@ 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)) { + 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); } + } stats.placed++; undoNote({ t: 'block+', bx, by, code }); return ''; @@ -57,7 +83,7 @@ function blockIntegrity(b) { let n = 0; for (const i of blockCellList(bx, by)) { const m = mat[i], p = part[i]; - if (code === BP_WALL) { if (PH[m] === PH_SOLID && !(flags[i] & F_NAT) && !PARTMAT[m]) n++; } + 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 (code === BP_SHAFT) { if (m === SHAFT) n++; } else if (code === BP_RACK) { if (m === RACKB) n++; } @@ -89,9 +115,11 @@ function removeBlock(bx, by) { let n = 0, metal = 0; for (const i of blockCellList(bx, by)) { const m = mat[i], p = part[i]; - if (code === BP_WALL) { + if (isWall(code)) { + if (isSlope(code) && !slopeCell(code, (i % W) & 3, ((i / W) | 0) & 3)) continue; if (PH[m] === PH_SOLID && !(flags[i] & F_NAT) && !PARTMAT[m] && m !== SPRING) { - metal += removeWallCell(i, v); + if (isSlope(code)) setCell(i, EMPTY, temp[i], 0); + else metal += removeWallCell(i, v); n++; } } else if (isPipeCode(code)) { @@ -102,8 +130,13 @@ function removeBlock(bx, by) { if (p === P_GOPEN) { part[i] = 0; n++; wakeIdx(i); } } else if (p) { part[i] = 0; markPow(i, false); wakeIdx(i); n++; } } + if (isSlope(code)) { + const cells = Math.floor(n * 8 / 10), refund = PH[v] === PH_ITEM ? Math.floor(cells / GRAINS_PER_BAR) : cells; + giveOrDrop(v, refund, bx * BS + 2, by * BS + 2); + + } 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 (code !== BP_WALL && n >= 8) refundAt(PIECE_BY_CODE[code].cost, 1, 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 (isProcCode(code)) { procDirty = true; procState.delete(b); } if (code === BP_RACK) { @@ -112,15 +145,34 @@ function removeBlock(bx, by) { if (s) for (const [m, count] of s.q) giveOrDrop(m, count, bot.x, bot.y); } blockP[b] = 0; blockV[b] = 0; + const cleared = blockCellList(bx, by); + if (isSlope(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); + 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 (isPipeCode(code)) { pipesDirty = true; if (spill) { let k = spill.n; for (let y = by * BS + BS - 1; y >= by * BS && k > 0; y--) for (let x = bx * BS; x < bx * BS + BS && k > 0; x++) { const i = y * W + x; if (mat[i] === EMPTY) { setCell(i, spill.m, spill.T, 0); k--; } } } } return n; } -// Straight line of blocks along the dominant axis -function blockLine(bx0, by0, bx1, by1) { +// Wall drags near 45° snap to a diagonal; other lines follow the dominant axis. +function diagonalDrag(dx, dy) { return dx !== 0 && dy !== 0 && Math.min(Math.abs(dx), Math.abs(dy)) >= Math.max(Math.abs(dx), Math.abs(dy)) * 0.7; } +function blockLine(bx0, by0, bx1, by1, diagonal = false) { const out = []; + const dx = bx1 - bx0, dy = by1 - by0; + if (diagonal && diagonalDrag(dx, dy)) { + for (let k = 0; k <= Math.max(Math.abs(dx), Math.abs(dy)); k++) out.push([bx0 + k * Math.sign(dx), by0 + k * Math.sign(dy)]); + return out; + } if (Math.abs(bx1 - bx0) >= Math.abs(by1 - by0)) { const s = bx1 >= bx0 ? 1 : -1; for (let x = bx0; ; x += s) { out.push([x, by0]); if (x === bx1) break; } } else { const s = by1 >= by0 ? 1 : -1; for (let y = by0; ; y += s) { out.push([bx0, y]); if (y === by1) break; } } return out; diff --git a/src/sim/blueprints.js b/src/sim/blueprints.js index c13d68f..a6ee998 100644 --- a/src/sim/blueprints.js +++ b/src/sim/blueprints.js @@ -27,7 +27,7 @@ function flipBlueprint(bp) { : 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, c, flipV(c, v), e] : [bw - 1 - x, y, c, flipV(c, v)]), + 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)]), machines: bp.machines.map(([t, x, y, d]) => { const P = PIECE_BY_ID[t]; let nd = d; diff --git a/src/sim/pieces.js b/src/sim/pieces.js index 8d9523a..9a80808 100644 --- a/src/sim/pieces.js +++ b/src/sim/pieces.js @@ -5,6 +5,18 @@ // stays grain-level; only built pieces snap. 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; +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) { + 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; +} +function slopeCode(dx, dy, orientation) { + if (!diagonalDrag(dx, dy)) return BP_SLOPE_BL + orientation; + return dx * dy > 0 ? (orientation >= 2 ? BP_SLOPE_TR : BP_SLOPE_BL) : (orientation >= 2 ? BP_SLOPE_TL : BP_SLOPE_BR); +} function isPipeCode(c) { return c === BP_PIPE || c === BP_PUMP || c === BP_OUTLET; } // Filters: each filter block has a set of materials and a mode. In drop mode (inv 0) // the chosen materials fall through and everything else rides along on top; in carry @@ -39,6 +51,8 @@ const CASH = 255; // stands for money in cost lists const PIECES = [ { id: 'wall', code: BP_WALL, name: 'Wall block', kind: 'block', variants: 'wall', desc: 'A solid block. R picks what it is made of: 16 grains of rubble, lumps or chunks, or 2 bars of metal.' }, + { id: 'slope', code: BP_SLOPE_BL, name: 'Sloped wall', kind: 'block', variants: 'wall', + desc: 'A half-block with a flat 45° face. Drag diagonally to make a funnel. R picks the material. T flips a dragged slope; without a drag, T cycles its four corners. Costs and returns 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.' }, { id: 'tube', code: BP_TUBE, name: 'Screw tube', kind: 'block', cost: [[CASH, 25]], dirs: ['up', 'right', 'down', 'left'], @@ -84,6 +98,7 @@ 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_BR; c <= BP_SLOPE_TR; c++) PIECE_BY_CODE[c] = PIECE_BY_ID.slope; // 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/tools.js b/src/sim/tools.js index 4512187..f3c51ff 100644 --- a/src/sim/tools.js +++ b/src/sim/tools.js @@ -34,7 +34,7 @@ function toolDig(cx, cy, r) { const b = blockP[((y / BS) | 0) * BW + ((x / BS) | 0)]; setCell(i, to, temp[i], 0); discover(to, x, y, m, 'dig'); - if (b === BP_WALL && !blockIntegrity(((y / BS) | 0) * BW + ((x / BS) | 0))) blockP[((y / BS) | 0) * BW + ((x / BS) | 0)] = 0; + if (isWall(b) && !blockIntegrity(((y / BS) | 0) * BW + ((x / BS) | 0))) removeBlock((x / BS) | 0, (y / BS) | 0); dug++; if ((dug & 3) === 0) emit({ t: 'debris', x, y, m }); } else digp[i] = p; diff --git a/src/sim/undo.js b/src/sim/undo.js index ed738d4..cdc12df 100644 --- a/src/sim/undo.js +++ b/src/sim/undo.js @@ -61,7 +61,7 @@ function ghostTick0() { ghostBlocks.delete(b); ghostBlocked.delete(b); continue; } if (!blockInReach(bx, by)) continue; - const err = canPlaceBlock(bx, by); + const err = canPlaceBlock(bx, by, code); if (err) { if (err.startsWith('Needs')) ghostBlocked.add(b); continue; } if (!canAfford(pieceCost(code, v), 1)) { ghostMissing++; continue; } placeBlock(code, v, bx, by, false, e); diff --git a/src/ui/actions.js b/src/ui/actions.js index 8f1fc1a..9c691b2 100644 --- a/src/ui/actions.js +++ b/src/ui/actions.js @@ -59,7 +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 (P.code === BP_WALL) { GAME.wallMat = v; } + if (isWall(code)) { GAME.wallMat = v; if (isSlope(code)) GAME.pieceDir.slope = code - BP_SLOPE_BL; } 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 & 3; } @@ -161,8 +161,8 @@ function primaryRelease(shift) { return; } if (t === 'build' && GAME.drag) { - const P = piece(), b = aimBlock(); - 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); + 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)); const dd = dragDirFor(P, GAME.drag, b); if (dd >= 0) GAME.pieceDir[P.id] = dd; const v = pieceV(P), ex = pieceExtra(P); @@ -170,12 +170,12 @@ function primaryRelease(shift) { const near = list.filter(([bx, by]) => blockInReach(bx, by)), far = list.filter(([bx, by]) => !blockInReach(bx, by)); let ghosts = 0; const r = undoRecord(rec => blockLabel(P, rec) + (piecesIn(rec, 'paste') ? ' (and its ghosts)' : ''), () => { - const res = placeBlocks(P.code, v, near, ex, GAME.replace); + const res = placeBlocks(code, v, near, ex, GAME.replace); const g = { t: 'paste', blocks: [], machines: [], molds: [] }; for (const [bx, by] of far) { const b = by * BW + bx; - if (bx < 0 || by < 0 || bx >= BW || by >= BH || blockIs(b, P.code, v, ex) || ghostBlocks.has(b)) continue; - ghostBlocks.set(b, ex ? [P.code, v, ex] : [P.code, v]); g.blocks.push([bx, by, P.code]); ghosts++; + if (bx < 0 || by < 0 || bx >= BW || by >= BH || blockIs(b, code, v, ex) || ghostBlocks.has(b)) continue; + ghostBlocks.set(b, ex ? [code, v, ex] : [code, v]); g.blocks.push([bx, by, code]); ghosts++; } if (ghosts) undoNote(g); return res; diff --git a/src/ui/build-menu.js b/src/ui/build-menu.js index edf1a18..8041ef9 100644 --- a/src/ui/build-menu.js +++ b/src/ui/build-menu.js @@ -1,7 +1,7 @@ // The build menu along the bottom: categories, piece tiles and their icons. // ---------- Build menu ---------- const BUILD_CATS = [ - ['Structure', ['wall', 'grate', 'gate']], + ['Structure', ['wall', 'slope', 'grate', 'gate']], ['Moving', ['belt', 'filter', 'tube']], ['Liquids', ['pipe', 'pump', 'outlet']], ['Machines', ['fan', 'grinder', 'saw', 'press', 'rack', 'silo']], @@ -10,6 +10,7 @@ const BUILD_CATS = [ const BUILD_ORDER = BUILD_CATS.flatMap(c => c[1]); const PIECE_ICONS = { wall: '', + slope: '', grate: '', gate: '', belt: '', @@ -50,7 +51,7 @@ function buildVariantChips(P) { const dirChip = !P.dirs ? '' : dragging ? `${glyph(P.dirs[GAME.dragDir])} ${esc(P.dirs[GAME.dragDir])} · from your drag` : `${glyph(dir)} ${esc(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) ? `` + return pieceUsesWall(P) ? `${isSlope(P.code) ? 'T · turn / flip slope' : ''}` : P.filter ? `${fc.inv ? 'carries' : 'drops'} ${fc.m.length ? sws(fc.m) : 'nothing'} · F${dirChip}` : dir ? dirChip : ''; } diff --git a/src/ui/info.js b/src/ui/info.js index 87edfbf..b421f31 100644 --- a/src/ui/info.js +++ b/src/ui/info.js @@ -124,7 +124,7 @@ function hoverInfo(ax, ay) { if (b === BP_PUMP) t2.push(v & 4 ? 'off · press F' : 'on · F turns it off'); if (b === BP_OUTLET) t2.push(v & 4 ? 'closed · press F' : 'open · F closes it'); } - else if (b === BP_WALL && PH[m] === PH_SOLID && !(flags[i] & F_NAT)) t1 = `${MD[m].name} block`; + else if (isWall(b) && PH[m] === PH_SOLID && !(flags[i] & F_NAT)) t1 = `${MD[m].name} ${isSlope(b) ? 'sloped wall' : 'block'}`; const n = nets[netId[i]]; if (n && isPow[i] && !POWER_FREE) t2.push(`network ${n.supply.toFixed(1)} supply / ${n.demand.toFixed(1)} demand`); if (player.lens && !bar) t2.push(fmtT(temp[i])); diff --git a/src/ui/input.js b/src/ui/input.js index 53220ab..2d93b36 100644 --- a/src/ui/input.js +++ b/src/ui/input.js @@ -30,7 +30,7 @@ window.addEventListener('keydown', e => { case 'q': if (e.shiftKey && toolId() === 'build') cycleBuildCat(-1); else cycleItem(-1); break; case 'e': if (e.shiftKey && toolId() === 'build') cycleBuildCat(1); else cycleItem(1); break; case 'r': rotate(); break; - case 't': if (toolId() === 'build' && piece().filter) pickFilterUnderCursor(); break; + case 't': if (toolId() === 'build' && isSlope(piece().code)) turnSlope(); else if (toolId() === 'build' && piece().filter) pickFilterUnderCursor(); break; case 'f': doInteract(); break; case 'g': GAME.replace = true; hudDirty = true; break; case 'h': toggleLens(); break; diff --git a/src/ui/notebook.js b/src/ui/notebook.js index a9a6cb6..31dace0 100644 --- a/src/ui/notebook.js +++ b/src/ui/notebook.js @@ -43,8 +43,8 @@ function renderNotebook() { let h = '

Everything you build snaps to a grid of 4×4-grain blocks. Pieces only move, hold, blow or sense material; every change to the material itself comes from heat. Select pieces with the Build tool (3). Hover a tile for its cost, full description and placement help. Your bot flies straight through anything you build.

'; for (const P of PIECES) { if (P.hidden) continue; - const cost = P.kind === 'mold' ? `${MOLDS[P.mold].walls} grains of wall material (R cycles, F opens the menu)` : P.code === BP_WALL ? '16 grains of rubble, lumps or chunks, or 2 bars' : costText(P.cost, 1); - const afford = P.kind === 'mold' || P.code === BP_WALL ? true : canAfford(P.cost, 1); + const cost = P.kind === 'mold' ? `${MOLDS[P.mold].walls} grains of wall material (R cycles, F opens the menu)` : isWall(P.code) ? (isSlope(P.code) ? '8 grains of rubble, lumps or chunks, or 1 bar' : '16 grains of rubble, lumps or chunks, or 2 bars') : costText(P.cost, 1); + const afford = P.kind === 'mold' || isWall(P.code) ? true : canAfford(P.cost, 1); const size = P.kind === 'machine' ? `${P.bw}×${P.bh}` : P.kind === 'mold' ? `${MOLDS[P.mold].bw}×${MOLDS[P.mold].bh}` : '1×1'; h += `
${esc(P.name)}${P.power ? esc(P.power + ' power') : P.id === 'wheel' || P.id === 'engine' ? 'makes power' : size + (size === '1×1' ? ' block' : ' blocks')}
${esc(P.desc)}
Cost: ${esc(cost)}
`; } @@ -58,7 +58,7 @@ function renderNotebook() { function controlsHtml() { const kb = [ ['Fly', 'W A S D'], ['Boost while flying', 'Shift'], ['Tool modifier (while not flying)', 'Shift'], ['Use tool', 'Left mouse'], ['Second action', 'Right mouse'], - ['Tools', '1 – 4'], ['Select one to blow · piece', 'Q / E'], ['Switch build tab', 'Shift + Q / E'], ['Variant: material · direction · flip', 'R'], ['Wall or mold material menu (Build)', 'F'], + ['Tools', '1 – 4'], ['Select one to blow · piece', 'Q / E'], ['Switch build tab', 'Shift + Q / E'], ['Variant: material · direction · flip', 'R'], ['Sloped wall: turn · flip while dragging', 'T'], ['Wall or mold material menu (Build)', 'F'], ['Pick up selection or what you click (Pneumatic nozzle)', 'Left drag · Shift: everything'], ['Select (Pneumatic nozzle)', 'Click a bag name · Q/E'], ['Add to selection (Pneumatic nozzle)', 'Ctrl+click a bag name'], ['Show everything found (Pneumatic nozzle)', 'Hold Ctrl in the bag'], ['Clear selection (Pneumatic nozzle)', 'Clear in the bag'], ['Blow everything loose in the bag', 'Shift + right click'], ['Nozzle or brush size', '[ ] or Shift + wheel'], ['Replace what is built (Build)', 'Hold G'], ['Copy a box of what you built', 'Hold C, drag'], ['Cut a box (refunded, paste it back)', 'Hold X, drag'], ['Paste again', 'V'], ['Undo building', 'Ctrl + Z'], ['Blueprint library', 'B'], ['Zoom', 'Mouse wheel'], ['Interact (gates, molds, fans, filters)', 'F'], ['Thermal lens', 'H'], ['Info overlay: filters, belt and tube directions', 'I'], ['Bot lamp', 'L'], ['Pick the piece under the cursor (with its direction and settings)', 'Middle mouse'], ['Notebook', 'N'], ['Clear all blueprint ghosts', 'Delete'], ['Save', 'Ctrl + S'], ['Pause · menu', 'Esc'], ['Mute', 'Shift + M'], ['Full map', 'M'], ['Minimap zoom', '+ / − on minimap'], diff --git a/src/ui/preview-selection.js b/src/ui/preview-selection.js index 6517f60..9a1d269 100644 --- a/src/ui/preview-selection.js +++ b/src/ui/preview-selection.js @@ -72,7 +72,7 @@ function drawPastePreview() { const [x, y, w, h] = blockRectPx(ox, oy, bp.bw, bp.bh); ctx.strokeStyle = 'rgba(92,186,166,0.85)'; ctx.setLineDash([zp, zp]); ctx.lineWidth = Math.max(1, zp * 0.3); ctx.strokeRect(x, y, w, h); ctx.setLineDash([]); - for (const [dx, dy, code, v] of bp.blocks) { const [bx2, by2, bw2, bh2] = blockRectPx(ox + dx, oy + dy, 1, 1); ctx.fillStyle = `rgba(${previewColor(code, v)},0.5)`; ctx.fillRect(bx2, by2, bw2, bh2); } + for (const [dx, dy, code, v] of bp.blocks) { const [bx2, by2, bw2, bh2] = blockRectPx(ox + dx, oy + dy, 1, 1); ctx.fillStyle = `rgba(${previewColor(code, v)},0.5)`; previewBlock(code, bx2, by2, bw2, bh2); } for (const [type, dx, dy] of bp.machines) { const P = PIECE_BY_ID[type]; const [mx, my, mw, mh] = blockRectPx(ox + dx, oy + dy, P.bw, P.bh); ctx.strokeStyle = 'rgba(255,210,122,0.8)'; ctx.strokeRect(mx, my, mw, mh); } for (const [k, dx, dy] of bp.molds) { const md = MOLDS[k]; const [mx, my, mw, mh] = blockRectPx(ox + dx, oy + dy, md.bw, md.bh); ctx.strokeStyle = 'rgba(255,190,90,0.8)'; ctx.strokeRect(mx, my, mw, mh); } ctx.restore(); diff --git a/src/ui/previews.js b/src/ui/previews.js index a3e42a0..bd8b8a2 100644 --- a/src/ui/previews.js +++ b/src/ui/previews.js @@ -14,7 +14,7 @@ function drawGrid(cbx, cby, radius) { ctx.restore(); } function previewColor(code, v) { - if (code === BP_WALL) { const m = WALL_OF[v]; return `${COLR[m]},${COLG[m]},${COLB[m]}`; } + if (isWall(code)) { const m = WALL_OF[v]; return `${COLR[m]},${COLG[m]},${COLB[m]}`; } if (code === BP_FILTER) return '96,100,110'; 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'; } @@ -100,28 +100,28 @@ function drawPreviews() { if (reachOk && (blowing || GAME.lmb)) drawAirStreaks(ax, ay, p, r, blowing); } } else if (t === 'build') { - const P = piece(), b = aimBlock(); + const P = piece(), b = aimBlock(), code = pieceCode(P, b); drawGrid(b.bx, b.by, 9); let cells = [], col = '120,200,190'; if (P.kind === 'block') { const v = pieceV(P); - col = previewColor(P.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)) : [[b.bx, b.by]]; + 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))) : [[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); const afford = canAfford(pieceCostNow(P), cells.length); for (const [bx, by] of cells) { - const err = canPlaceBlock(bx, by), rb = by * BW + bx; - const swap = GAME.replace && !!blockP[rb] && /already built/.test(err) && !blockIs(rb, P.code, v, pieceExtra(P)); + const err = canPlaceBlock(bx, by, code), rb = by * BW + bx; + const swap = GAME.replace && !!blockP[rb] && /already built/.test(err) && !blockIs(rb, code, v, pieceExtra(P)); const ghost = !blockInReach(bx, by) && (!err || /bar/.test(err)); // out of reach: it will be a ghost const ok = (!err || swap) && !ghost && afford; const [x, y, w, h] = blockRectPx(bx, by, 1, 1); ctx.fillStyle = ghost ? 'rgba(90,190,230,0.22)' : ok ? (swap ? 'rgba(255,190,90,0.5)' : `rgba(${col},0.55)`) : 'rgba(239,106,85,0.35)'; - ctx.fillRect(x, y, w, h); + previewBlock(code, x, y, w, h); ctx.strokeStyle = ghost ? 'rgba(90,190,230,0.8)' : ok ? 'rgba(236,226,210,0.8)' : 'rgba(239,106,85,0.9)'; ctx.lineWidth = Math.max(1, zp * 0.25); if (ghost) ctx.setLineDash([zp, zp * 0.7]); - ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1); ctx.setLineDash([]); + previewBlock(code, x, y, w, h, true); 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' ? [[0, -1], [1, 0], [0, 1], [-1, 0]][dIdx] : P.id === 'pump' ? PUMP_V[dIdx] : P.id === 'outlet' ? OUTLET_V[dIdx] : [[-1, 0], [1, 0]][dIdx]; @@ -190,3 +190,14 @@ function drawPreviews() { if (t === 'build' && GAME.replace && !sel && !pasting) drawTag('Replace', '255,190,90', p.x + BS * zp * 1.2, p.y + BS * zp * 1.4); drawCursorLabel(p.x, p.y, t === 'vacuum' ? GAME.brush[GAME.tool] : t === 'ignite' || t === 'drill' ? 1 : 2); } + +function previewBlock(code, x, y, w, h, stroke = false) { + const ctx = R.ctx; + 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(); + ctx.moveTo(x + (right ? w : 0), y + (above ? 0 : h)); + ctx.lineTo(x + (right ? w : 0), y + (above ? h : 0)); + ctx.lineTo(x + (right ? 0 : w), y + (above ? 0 : h)); ctx.closePath(); + if (stroke) ctx.stroke(); else ctx.fill(); +} diff --git a/src/ui/selection.js b/src/ui/selection.js index c477651..7a0f511 100644 --- a/src/ui/selection.js +++ b/src/ui/selection.js @@ -82,7 +82,7 @@ function setBrush(d) { // The block variant a piece is placed with: wall material, or direction (a filter's // materials and mode travel separately, as GAME.filterCfg) function pieceV(P) { - if (P.code === BP_WALL) return GAME.wallMat; + if (isWall(P.code)) return GAME.wallMat; if (P.filter) return (GAME.pieceDir[P.id] || 0) << 7; return GAME.pieceDir[P.id] || 0; } @@ -91,6 +91,17 @@ function filterBlockAt(x, y) { if (x < 0 || y < 0 || x >= W || y >= H) return -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); - if (P.kind === 'block' && P.code === BP_WALL) return wallCost(m, BS * BS); + if (P.kind === 'block' && isWall(P.code)) return pieceCost(P.code, m); return P.cost; } + +function pieceCode(P, end = aimBlock()) { + if (!isSlope(P.code)) return P.code; + const a = GAME.drag || end; + return slopeCode(end.bx - a.bx, end.by - a.by, GAME.pieceDir.slope || 0); +} +function turnSlope() { + const n = GAME.pieceDir.slope || 0; + GAME.pieceDir.slope = GAME.drag ? n ^ 2 : (n + 1) % 4; + hudDirty = true; +} diff --git a/test/play.py b/test/play.py index 9e5b9be..e92a9fd 100644 --- a/test/play.py +++ b/test/play.py @@ -676,6 +676,25 @@ with sync_playwright() as p: check('bot can sit inside a built wall', not ev(f'botOverlapSolid({wx_ - 2}, {wy_ - 2})')) check('bot cannot sit inside natural rock', ev(f'botOverlapSolid({bx}, {gy + 12})')) + # Slopes use the same mouse drag path as walls, with T for their corner. + pg.click('[data-piece="slope"]'); settle(60) + check('Sloped wall is next to Wall in the build menu', ev("piece().id === 'slope' && BUILD_CATS[0][1].slice(0,2).join(',') === 'wall,slope'")) + ev('GAME.wallMat = RSTONE; GAME.pieceDir.slope = 0') + drag(lambda: blk(bbx - 5, bby - 12), lambda: blk(bbx - 2, bby - 9)) + check('diagonal slope drag places four oriented blocks', 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) + pg.keyboard.press('t'); settle(60) + check('T cycles the single slope corner', ev('GAME.pieceDir.slope === 1')) + pg.mouse.move(*blk(bbx - 5, bby - 12)); settle(60); pg.mouse.down() + pg.mouse.move(*blk(bbx - 2, bby - 9)); settle(60) + pg.keyboard.press('t'); settle(60); pg.mouse.up(); settle(60) + check('T flips a diagonal slope above its face', ev(f'Array.from({{length:4}}, (_,k)=>blockP[({bby}-12+k)*BW+{bbx}-5+k]).every(c=>c===BP_SLOPE_TR)')) + pg.keyboard.press('Control+z'); settle(60) + pg.click('[data-piece="wall"]'); settle(60) + drag(lambda: blk(bbx - 5, bby - 12), lambda: blk(bbx - 2, bby - 9)) + check('square wall drag can also place a diagonal', ev(f'Array.from({{length:4}}, (_,k)=>blockP[({bby}-12+k)*BW+{bbx}-5+k]).every(c=>c===BP_WALL)')) + pg.keyboard.press('Control+z'); settle(60) + # 6. ingot mold: click to place, pour, bars drop below, vacuum them ok = select('3', "piece().id === 'ingotmold'") check('E cycles to the ingot mold', ok) diff --git a/test/t_slopes.js b/test/t_slopes.js new file mode 100644 index 0000000..75d150c --- /dev/null +++ b/test/t_slopes.js @@ -0,0 +1,90 @@ +// Sloped walls: flow, sealed joints, building, refunds and persistence. +const { load, suite, flatWorld, rect, count, ticks } = require('./harness'); +const { run } = load(); +const { check, done } = suite(); +function fresh() { flatWorld(run, 240, 192, 176); run('AMB.fill(20);temp.fill(20);'); } +function funnel(sloped, material) { + fresh(); + run(`for (let k=0;k<12;k++) { + placeBlock(${sloped ? 'BP_SLOPE_BL' : 'BP_WALL'}, RSTONE, 12+k, 12+k, true); + placeBlock(${sloped ? 'BP_SLOPE_BR' : 'BP_WALL'}, RSTONE, 36-k, 12+k, true); + }`); + rect(run, 80, 20, 119, 29, material); + ticks(run, 1800); + const out = count(run, material, 0, 96, 239, 175); + check(`${sloped ? 'slope' : 'square'} funnel conserves 400 grains`, count(run, material) === 400); + return out; +} +const sand = run('SAND'), water = run('WATER'); +const slopeOut = funnel(true, sand), squareOut = funnel(false, sand); +check('all sand leaves the sloped funnel', slopeOut === 400, slopeOut); +check('square steps retain sand', squareOut < 400, squareOut); +check('all water leaves the sloped funnel', funnel(true, water) === 400); +for (const material of ['GRAVEL', 'CUORE', 'LDIRT']) check(`${material} slides down`, funnel(true, run(material)) === 400); +fresh(); +run('for (let k=0;k<12;k++) placeBlock(BP_SLOPE_BL,RSTONE,12+k,12+k,true); spawnBar(BARCU,52,35);'); +ticks(run, 600); +check('bars slide off the slope', run('ents.length === 1 && ents[0].iy > 96'), run('ents.map(e=>[e.ix,e.iy])')); +for (let c = 15; c <= 18; c++) { + fresh(); + run(`bagAdd(RSTONE,100,true); bagAdd(BARCU,10,true);`); + const before = run('bag[RSTONE]'); + run(`placeBlock(${c},RSTONE,20,20);`); + check(`slope ${c} costs half a wall`, before-run('bag[RSTONE]') === 8); + check(`slope ${c} has its triangle`, run('blockIntegrity(20*BW+20)') === 10); + run(`undoRecord('cut',()=>cutRegion(80,80,83,83)); undoLast();`); + check(`undo restores orientation ${c}`, run(`blockP[20*BW+20] === ${c}`)); + check(`copy and flip preserve material and mirror ${c}`, run(`(()=>{ const bp=flipBlueprint(copyRegion(80,80,83,83)); return bp.blocks[0][2] === ${15+((c-15)^1)} && bp.blocks[0][3]===RSTONE; })()`)); + run(`const snap${c}=serializeWorld(); deserializeWorld(snap${c}.meta,snap${c}.buffers);`); + check(`save restores orientation ${c}`, run(`blockP[20*BW+20] === ${c} && blockIntegrity(20*BW+20)===10`)); + run(`pasteGhost(copyRegion(80,80,83,83),24,20); bot.x=96;bot.y=80;ghostTick();`); + check(`paste builds orientation ${c}`, run(`blockP[20*BW+24]===${c} && blockV[20*BW+24]===RSTONE`)); + run('removeBlock(24,20);removeBlock(20,20)'); + check(`slope ${c} refunds half a wall`, run('bag[RSTONE]') === before); + run(`placeBlock(${c},BARCU,20,20);removeBlock(20,20);`); + check(`metal slope ${c} refunds one bar`, run('bag[BARCU]') === 10); +} +check('wall diagonal snaps within 30 percent', run('JSON.stringify(blockLine(2,3,7,7,true))') === JSON.stringify([[2,3],[3,4],[4,5],[5,6],[6,7],[7,8]])); +check('belts stay straight', run('blockLine(2,3,7,8).every(p=>p[1]===3)')); +check('drag chooses below or above and ignores drag reversal', run('slopeCode(5,5,0)===15 && slopeCode(-5,-5,0)===15 && slopeCode(5,-5,0)===16 && slopeCode(5,5,2)===18 && slopeCode(5,-5,2)===17')); +// Probe every open cell along both sides, including all eleven joints. A grain may +// leave an end, so measure crossing only within the interior of the diagonal run. +for (let c = 15; c <= 18; c++) for (const m of ['SAND', 'WATER']) for (const side of [-1, 1]) { + fresh(); + const descending = c === 15 || c === 18; + run(`for(let k=0;k<12;k++) placeBlock(${c},RSTONE,12+k,${descending ? '12+k' : '23-k'},true);`); + const leaked = run(`(()=>{ + const sideAt=(x,y)=>${descending ? 'y-x' : 'x+y-143'}; + let leaks=0, probes=0; + for(let y=52;y<92;y++) for(let x=52;x<92;x++) { + const i=y*W+x, d=sideAt(x,y); + if(mat[i]!==EMPTY || Math.sign(d)!==${side} || Math.abs(d)>2) continue; + setCell(i,${m},20,0); probes++; + // Exercise the real movement functions repeatedly without letting grains escape an end. + for(let k=0;k<20 && mat[i]===${m};k++) { tickStamp++; ${m === 'WATER' ? 'liquid(i,x,y,WATER)' : 'powder(i,x,y,SAND)'}; } + for(let yy=y;yy<=y+1;yy++) for(let xx=x-32;xx<=x+32;xx++) { + if(xx<0 || xx>=W)continue; + const j=yy*W+xx; + if(mat[j]===${m}) { if(Math.sign(sideAt(xx,yy))===${-side}) leaks++; setCell(j,EMPTY,20,0); } + } + } + return [leaks,probes]; + })()`); + check(`slope ${c} seals ${m} from side ${side}`, leaked[0] === 0 && leaked[1] > 0, leaked); +} +for (const c of [15, 16]) { + fresh(); + run(`for(let k=0;k<12;k++) placeBlock(${c},RSTONE,12+k,${c === 15 ? '12+k' : '23-k'},true);spawnBar(BARCU,${c === 15 ? 52 : 136},35);`); + ticks(run,600); + check(`bars reach the bottom of slope ${c}`,run('ents.length===1 && ents[0].iy>96')); +} +for(const c of [15,16,17,18]) { + fresh(); + check(`slope ${c} supports every wall material`,run(`WALL_LIST.every((v,k)=>{ const bx=4+k*3;bagAdd(v,16,true);const before=bag[v];if(placeBlock(${c},v,bx,20))return false;removeBlock(bx,20);return bag[v]===before; })`)); +} +fresh(); +run('placeBlock(BP_SLOPE_BL,RSTONE,20,20,true);placeBlock(BP_WALL,RSTONE,20,21,true);removeBlock(20,21);'); +check('removing an adjoining square wall restores the slope seal',run('mat[slopeSeal(BP_SLOPE_BL,20,20)]===STONE')); +run('removeBlock(20,20)'); +check('removal leaves no free seal grains behind',count(run,run('STONE'),80,80,83,87)===0); +done(); diff --git a/tools/scenes/t36-funnel.js b/tools/scenes/t36-funnel.js new file mode 100644 index 0000000..2d6c21a --- /dev/null +++ b/tools/scenes/t36-funnel.js @@ -0,0 +1,16 @@ +// A twelve-block funnel, one block open at the bottom, draining 400 grains of sand. +const fx = Math.floor((SX - 150) / BS), fy = Math.floor((SY - 36) / BS); +const cx = (fx + 12) * BS + 2, floor = (fy + 25) * BS; +for (let y = fy * BS - 45; y < floor + 8; y++) for (let x = fx * BS - 20; x < (fx + 25) * BS + 20; x++) { + const i = y * W + x; + bgm[i] = 0; + setCell(i, y >= floor ? STONE : EMPTY, 20, y >= floor ? F_NAT : 0); +} +for (let x = fx * BS - 20; x < (fx + 25) * BS + 20; x++) surfY[x] = floor; +for (let k = 0; k < 12; k++) { + placeBlock(BP_SLOPE_BL, RBRICK, fx + k, fy + k, true); + placeBlock(BP_SLOPE_BR, RBRICK, fx + 24 - k, fy + k, true); +} +for (let y = fy * BS - 28; y < fy * BS - 18; y++) for (let x = cx - 20; x < cx + 20; x++) setCell(y * W + x, SAND, 20, 0); +updateRoof(); +window.CAM = [cx, fy * BS + 25];