From 364ea34db4cf8f7274bb6ff8e1ebeb6948514633 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 2 Sep 2026 14:26:27 +0200 Subject: [PATCH] feat: extend builder with onOpenSide --- src/builder.lua | 173 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/src/builder.lua b/src/builder.lua index 503d5d2..9618784 100644 --- a/src/builder.lua +++ b/src/builder.lua @@ -682,6 +682,179 @@ function Builder.build(depth, rng, options) -- A single lootable chest dots the dungeon from the first floor on, rarer -- than a lockpick, drawn dead last of all so it can never perturb any -- earlier system's rng sequence: a locked one may also be trapped (see + -- Chests.new), so a run either finds a key, gambles a lockpick, or leaves + -- it be. + local chests = {} + if rng:chance(1, 3) then + local x, y = pickFree(reachable, rng, freeFloor) + if x then + local chest = Chests.new(rng, depth) + chest.x, chest.y = x, y + chests[#chests + 1] = chest + mark(x, y) + end + end + + -- A secret door hides a single alcove of bonus loot behind an otherwise + -- ordinary stretch of wall, found only by lingering beside it long enough + -- (see secrets.lua's passive sweep). Drawn dead last of all before the + -- grave below - which must stay the true last word, since it is the one + -- block whose own rng draw count depends on what the caller passed in + -- (see its own comment) - so this can never perturb any earlier system's + -- rng sequence either: picks an already-reachable floor cell and a + -- direction whose neighbour and whose cell two further along are both + -- still untouched solid rock, then carves that far cell into a one-tile + -- room holding a single roll of loot. The door itself stays a plain wall + -- tile until secrets.lua's sweep discovers it - this list only remembers + -- where to look. Skipped outright whenever 40 random reachable cells in a + -- row turn up no such spot, so a cramped floor's own geometry never + -- forces the roll to place nothing. + local secretDoors = {} + if depth >= 2 and rng:chance(1, 4) then + for _ = 1, 40 do + local cell = reachable[rng:range(1, #reachable)] + local dir = NEIGHBOURS[rng:range(1, #NEIGHBOURS)] + local doorX, doorY = cell.x + dir[1], cell.y + dir[2] + local roomX, roomY = cell.x + dir[1] * 2, cell.y + dir[2] * 2 + if roomX >= 2 and roomY >= 2 and roomX <= map.width - 1 and roomY <= map.height - 1 + and map:get(doorX, doorY) == Tiles.WALL and map:get(roomX, roomY) == Tiles.WALL then + -- The room's only walkable neighbour may be the door itself: if any + -- of its other three sides already happens to border open floor + -- somewhere else in the layout, that side would let the player + -- wander straight into the alcove without ever finding the door, + -- so a candidate site with any such back way in is rejected outright. + local isolated = true + for _, side in ipairs(NEIGHBOURS) do + local ax, ay = roomX + side[1], roomY + side[2] + if not (ax == doorX and ay == doorY) and map:isWalkable(ax, ay) then + isolated = false + break + end + end + if isolated then + map:set(roomX, roomY, Tiles.FLOOR) + secretDoors[#secretDoors + 1] = { x = doorX, y = doorY, discovered = false } + floorItems[#floorItems + 1] = { x = roomX, y = roomY, item = rollItem(rng, depth) } + break + end + end + end + end + + -- A biome reinforcement and hazard bloom, drawn dead last of all before + -- the grave below - which must stay the true last word, since it is the + -- one block whose own rng draw count depends on what the caller passed in + -- (see its own comment) - so this can never perturb any earlier system's + -- rng sequence either: see biomes.lua for why a floor's biome is a fixed + -- function of depth rather than another dice roll like weather. The + -- reinforcement draws only from natives of the eligible roster already + -- computed above, so it can never plant a monster too deep for its own + -- minDepth, and starts dormant exactly like every other wild monster + -- placed above (see Stealth.DORMANT); the bloom seeds level.hazards the + -- same way a fire spell or a trap would mid-game, just already + -- smouldering the moment the floor is first entered. + local biome = Biomes.forDepth(depth) + local natives = Biomes.nativesAmong(biome, eligible) + if #natives > 0 and rng:chance(1, 3) then + local x, y = pickFree(reachable, rng, freeFloor) + if x then + local spec = natives[rng:range(1, #natives)] + local monster = Monsters.create(spec, #entities + 1, x, y, depth) + monster.awareness = Stealth.DORMANT + entities[#entities + 1] = monster + mark(x, y) + end + end + + local hazards = {} + if rng:chance(1, 4) then + local x, y = pickFree(reachable, rng, freeFloor) + if x then + hazards[#hazards + 1] = { x = x, y = y, kind = biome.hazardKind, duration = 4, potency = 2 } + end + end + + -- A pressure gate seals a second stash behind a shove puzzle instead of a + -- lever pull: a gate set on an unused doorway pinch point that only stays + -- open while a block's weight rests on its linked plate (see blocks.lua), + -- rather than a lever's one-way, permanently-spent pull. The block always + -- sits exactly two cells from its plate along a single cardinal direction, + -- with the cell behind the block itself also free, so a single shove from + -- that approach cell always lands it on the plate - the puzzle is never a + -- multi-step arrangement a floor's own cramped geometry might make + -- unsolvable. Also verifies, after sealing the gate shut, that the stairs + -- down are still reachable without ever crossing it - exactly the same + -- guarantee the locked-door pass above gives a key - so a gate can only + -- ever seal optional treasure, never the one path through the floor. + -- Falls back to an ordinary closed door whenever the floor's geometry + -- cannot host a genuine puzzle (no free doorway, no straight three-cell + -- run for the plate/block/approach, or the gate would seal the only route + -- to the stairs), so a gate never seals content behind an unsolvable or + -- pointless door. Drawn dead last of all before the grave below - which + -- must stay the true last word (see its own comment) - so this can never + -- perturb any earlier system's rng sequence either. + local blocks, plates, gates = {}, {}, {} + if depth >= 3 and rng:chance(1, 5) then + local doorCandidates = doorwayCandidates(map) + for i = #doorCandidates, 1, -1 do + if taken(doorCandidates[i].x, doorCandidates[i].y) then + table.remove(doorCandidates, i) + end + end + if #doorCandidates > 0 then + local cell = doorCandidates[rng:range(1, #doorCandidates)] + map:set(cell.x, cell.y, Tiles.GATE_CLOSED) + mark(cell.x, cell.y) + local sealsTheOnlyRoute = not reachSet(map, upX, upY, false)[map:index(downX, downY)] + local openSide = reachSet(map, upX, upY, false, false) + local function onOpenSide(cx, cy) + return freeFloor(cx, cy) and openSide[map:index(cx, cy)] == true + end + local px, py = pickFree(reachable, rng, onOpenSide) + local siteDirs = {} + if px then + for _, dir in ipairs(NEIGHBOURS) do + local bx, by = px - dir[1], py - dir[2] + local ax, ay = bx - dir[1], by - dir[2] + if onOpenSide(bx, by) and onOpenSide(ax, ay) then + siteDirs[#siteDirs + 1] = { bx = bx, by = by } + end + end + end + local tx, ty + if not sealsTheOnlyRoute and #siteDirs > 0 then + local farSide = reachSet(map, upX, upY, false, true) + tx, ty = pickFree(reachable, rng, function(cx, cy) + return freeFloor(cx, cy) and farSide[map:index(cx, cy)] == true + and openSide[map:index(cx, cy)] ~= true + end) + end + if tx then + local site = siteDirs[rng:range(1, #siteDirs)] + plates[#plates + 1] = { x = px, y = py, gateX = cell.x, gateY = cell.y } + blocks[#blocks + 1] = { x = site.bx, y = site.by } + gates[#gates + 1] = { x = cell.x, y = cell.y, open = false } + mark(px, py) + mark(site.bx, site.by) + floorItems[#floorItems + 1] = { x = tx, y = ty, item = Blocks.treasure(rng, depth) } + mark(tx, ty) + else + map:set(cell.x, cell.y, Tiles.DOOR_CLOSED) + end + end + end + + -- A hand-authored prefab vault dots the deep dungeon from depth three on, + -- sealed behind solid rock rather than gated by a key or a lever: the one + -- way in is digging.lua's pick (or a burrower's own claws), swung from + -- whichever reachable cell happens to border its outer wall. Anchored only + -- over untouched rock - every cell of its footprint still the generator's + -- own default fill - so it can never overwrite a corridor or room this + -- floor's own generator already carved, and only accepted once at least + -- one of its wall cells actually borders the reachable area, so a vault + -- can never end up sealed unreachably deep inside solid rock no digger + -- could ever find. Its monster and loot markers (see gen/prefab.lua) draw + -- from the same eligible roster and item tables every earlier pass in this return Level.new({ depth = depth, generator = generator,