From 9bcc8fe6e2a3b50845dc30325e8828fbc04ba486 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 1 Sep 2026 21:03:17 +0200 Subject: [PATCH] feat: add Biomes.forDepth, Biomes.describe and Blocks.treasure --- src/biomes.lua | 62 +++++++++++++++++++++++++++ src/blocks.lua | 113 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 src/biomes.lua create mode 100644 src/blocks.lua diff --git a/src/biomes.lua b/src/biomes.lua new file mode 100644 index 0000000..2a845c1 --- /dev/null +++ b/src/biomes.lua @@ -0,0 +1,62 @@ +-- Depth-band environmental theme layered on top of the systems that already +-- read raw depth alone: unlike atmosphere.lua's weather (a coin flip drawn +-- fresh on every floor from depth two on, see Atmosphere.order), a biome is +-- a fixed function of depth by itself, so floor three always belongs to the +-- same people and the same climate no matter which seed generated it - +-- weather is what a given run of that floor happens to be like today, a +-- biome is what the floor always is. Each entry names the faction that +-- actually lives there (see factions.lua) and the hazard kind its climate +-- tends toward (see hazards.lua), which the builder's biome pass - appended +-- dead last of all in Builder.build, after even the grave, so it can never +-- perturb any earlier system's rng sequence - may draw on to reinforce a +-- floor with one more native monster or bloom a small pocket of that +-- hazard, strictly on top of everything already placed there. + +local Factions = require("factions") +local Hazards = require("hazards") + +local Biomes = {} + +Biomes.CATALOG = { + { key = "warrens", name = "the mossy warrens", favoredFaction = Factions.VERMIN, hazardKind = Hazards.GAS, ambient = "damp stone and skittering claws" }, + { key = "burrows", name = "the goblin burrows", favoredFaction = Factions.GOBLINOID, hazardKind = Hazards.GAS, ambient = "crude tunnels reeking of tallow smoke" }, + { key = "crypts", name = "the sunken crypts", favoredFaction = Factions.UNDEAD, hazardKind = Hazards.GAS, ambient = "cold air thick with grave rot" }, + { key = "warcamp", name = "the orcish warcamp", favoredFaction = Factions.ORCISH, hazardKind = Hazards.FIRE, ambient = "smoke-stained banners and cookfires" }, + { key = "sanctum", name = "the cult sanctum", favoredFaction = Factions.CULT, hazardKind = Hazards.FIRE, ambient = "chanting echoes off blood-lit stone" }, + { key = "wyrmnest", name = "the wyrm's nest", favoredFaction = Factions.DRAKE, hazardKind = Hazards.FIRE, ambient = "scorched walls and the reek of sulfur" }, +} + +Biomes.byKey = {} +for _, biome in ipairs(Biomes.CATALOG) do + Biomes.byKey[biome.key] = biome +end + +-- Cyclic by depth, exactly the way chosenGenerator above already cycles +-- through Gen.order: a dungeon of any Builder.maxDepth wraps back through +-- the catalog rather than running off its end once depth outgrows it. +function Biomes.forDepth(depth) + return Biomes.CATALOG[((depth - 1) % #Biomes.CATALOG) + 1] +end + +function Biomes.describe(biome) + return string.format("%s: %s", biome.name, biome.ambient) +end + +-- The subset of an already depth-filtered species list (see +-- Monsters.forDepth) that actually belongs to biome's own faction - the +-- pool the builder's reinforcement pass draws a native from, empty whenever +-- nothing eligible this shallow yet belongs to the biome's own people. +-- Ranged specs are excluded outright: a second archer stacks free damage no +-- melee approach can answer, so reinforcement only ever sends backup a +-- player can actually close the distance on. +function Biomes.nativesAmong(biome, eligible) + local natives = {} + for _, spec in ipairs(eligible) do + if spec.faction == biome.favoredFaction and spec.ai ~= "ranged" then + natives[#natives + 1] = spec + end + end + return natives +end + +return Biomes diff --git a/src/blocks.lua b/src/blocks.lua new file mode 100644 index 0000000..4c0a459 --- /dev/null +++ b/src/blocks.lua @@ -0,0 +1,113 @@ +-- Pushable blocks: a block occupies a floor cell and blocks movement and +-- pathing exactly like a wall, but shoving it (see Game:movePlayer) relocates +-- it one cell in the push direction instead of just refusing the move, the +-- same "walk into it to interact" shape a closed door or a vault lever +-- already has. A block shoved into lava solidifies into rubble and one +-- shoved into a chasm fills the gap into plain floor - both permanently +-- consume the block, since it has nothing left to rest on once the hazard +-- it corrected is gone. Shoved onto a pressure plate instead, the block +-- survives and holds its linked gate open for as long as it stays put (see +-- Blocks.updateGates) - unlike vaults.lua's lever, a gate reseals the +-- instant the weight holding it open moves off the plate again. + +local Tiles = require("tiles") +local Items = require("items") +local Affixes = require("affixes") + +local Blocks = {} + +local treasureWeapons = { + { name = "gate-forged blade", power = 7 }, + { name = "gate-forged maul", power = 8 }, +} +local treasureArmors = { + { name = "gate-forged plate", power = 6 }, +} + +-- A guaranteed affix on a base already well above anything the floor's own +-- loot table can roll at this depth, mirroring vaults.lua's own Vaults. +-- treasure but with names that never claim to come from a vault. +function Blocks.treasure(rng, depth) + if rng:chance(1, 2) then + local base = treasureWeapons[rng:range(1, #treasureWeapons)] + local item = Items.weapon(base.name, base.power + depth // 3) + return Affixes.decorate(item, Affixes.weapon[rng:range(1, #Affixes.weapon)]) + end + local base = treasureArmors[rng:range(1, #treasureArmors)] + local item = Items.armor(base.name, base.power + depth // 3) + return Affixes.decorate(item, Affixes.armor[rng:range(1, #Affixes.armor)]) +end + +-- Whether a cell a block might be shoved into can actually receive it: in +-- bounds, not a wall, a door of any kind, or a gate, and not already host to +-- another block or a living entity. Lava and chasm cells count as +-- receiving even though Blocks.push special-cases what happens to them +-- next, rather than refusing the shove outright. +function Blocks.canReceive(level, x, y) + if not level.map:inBounds(x, y) then return false end + local tile = level.map:get(x, y) + if tile == Tiles.WALL or Tiles.isDoor(tile) or Tiles.isVaultDoor(tile) or Tiles.isGate(tile) then + return false + end + if level:blockAt(x, y) or level:entityAt(x, y) then return false end + return true +end + +local function remove(level, block) + for i, b in ipairs(level.blocks) do + if b == block then + table.remove(level.blocks, i) + return + end + end +end + +-- Shoves `block` by (dx, dy) if the far cell can receive it. Returns +-- (true, message) on a successful shove, resolving whatever lava or chasm +-- it lands in along the way (Blocks.remove), or (false) when the far cell +-- refuses it, exactly like bumping into a wall. +function Blocks.push(level, block, dx, dy) + local nx, ny = block.x + dx, block.y + dy + if not Blocks.canReceive(level, nx, ny) then return false end + local tile = level.map:get(nx, ny) + if tile == Tiles.LAVA then + level.map:set(nx, ny, Tiles.RUBBLE) + remove(level, block) + return true, "the block hisses into the lava and cools into rubble" + end + if tile == Tiles.CHASM then + level.map:set(nx, ny, Tiles.FLOOR) + remove(level, block) + return true, "the block tumbles into the chasm and fills it" + end + block.x, block.y = nx, ny + return true, "you shove the block" +end + +-- Whether any plate linked to `gate` currently carries a block's weight. +local function gateHeld(level, gate) + for _, plate in ipairs(level.plates) do + if plate.gateX == gate.x and plate.gateY == gate.y and level:blockAt(plate.x, plate.y) then + return true + end + end + return false +end + +-- Re-evaluates every gate on the level against its own plates' current +-- weight, flipping both its tile glyph and its `open` field the instant a +-- plate gains or loses a block. Called after any block shove (Game: +-- movePlayer) so a gate reacts immediately rather than waiting for a full +-- turn tick, the same way Vaults.pull's door flip is immediate on the pull +-- that completes it. +function Blocks.updateGates(level) + for _, gate in ipairs(level.gates) do + local held = gateHeld(level, gate) + if held ~= gate.open then + gate.open = held + level.map:set(gate.x, gate.y, held and Tiles.GATE_OPEN or Tiles.GATE_CLOSED) + end + end +end + +return Blocks