From a10eb10d22bde0d8df32b1a35849107ea664cb33 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Mon, 7 Sep 2026 17:22:25 +0200 Subject: [PATCH] feat: add Nodes.new, Nodes.isFallow and Noise.propagate --- src/nodes.lua | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/noise.lua | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 src/nodes.lua create mode 100644 src/noise.lua diff --git a/src/nodes.lua b/src/nodes.lua new file mode 100644 index 0000000..1a43db0 --- /dev/null +++ b/src/nodes.lua @@ -0,0 +1,75 @@ +-- Renewable resource nodes scattered on the map: an ore vein pays out +-- crafting essence (crafting.lua's forging currency) and a gem seam yields a +-- raw tier-1 gem outright, both without spending a single turn in combat - +-- a genuine second income alongside killing monsters (Crafting.essenceFor), +-- not a strictly better replacement for it. Each node holds a fixed number +-- of charges before it plays out; a played-out node does not vanish, it goes +-- fallow and slowly reopens on its own, so a floor already cleared of +-- monsters still rewards a return visit. Nodes are plain data on +-- level.nodes, exactly like level.shrines and level.cauldrons, so they +-- serialize with no special save-format handling. + +local Crafting = require("crafting") + +local Nodes = {} + +Nodes.ORE = "ore" +Nodes.GEM = "gem" + +Nodes.glyphs = { [Nodes.ORE] = "O", [Nodes.GEM] = "M" } + +Nodes.MAX_CHARGES = 3 +Nodes.REGEN_TURNS = 40 +Nodes.ORE_YIELD_MIN = 3 +Nodes.ORE_YIELD_MAX = 7 + +-- A freshly placed node at full charges, never on cooldown. Builder stamps +-- x/y directly onto the returned table, matching how it stamps every other +-- floor feature (shrines, cauldrons, vault levers). +function Nodes.new(kind, element) + return { kind = kind, element = element, charges = Nodes.MAX_CHARGES, cooldown = 0 } +end + +function Nodes.isFallow(node) + return node.charges <= 0 +end + +function Nodes.glyph(node) + return Nodes.glyphs[node.kind] +end + +-- Extracts one charge's worth of resource from `node` into `game.player`. +-- Returns (false, line) without touching the node when it has nothing left +-- to give, or (true, line) once a charge is spent - dropping the node into +-- cooldown the instant that spends its last one. +function Nodes.harvest(game, node) + if Nodes.isFallow(node) then + return false, "the vein is played out; give it time to reopen" + end + node.charges = node.charges - 1 + if node.charges <= 0 then node.cooldown = Nodes.REGEN_TURNS end + local player = game.player + if node.kind == Nodes.ORE then + local amount = math.floor(game.rng:range(Nodes.ORE_YIELD_MIN, Nodes.ORE_YIELD_MAX) * game:essenceMultiplier()) + player.essence = player.essence + amount + return true, string.format("you chip %d essence from the ore vein", amount) + end + local gem = Crafting.gem(node.element, 1) + player.inventory[#player.inventory + 1] = gem + return true, "you pry a " .. gem.name .. " loose from the seam" +end + +-- Advances every fallow node's cooldown one world round, exactly like +-- hazards.step advances fire and gas - called once per round, not per actor, +-- so a seeded run always reopens a vein on the identical turn regardless of +-- how many actors acted that round. +function Nodes.step(level) + for _, node in ipairs(level.nodes) do + if node.cooldown > 0 then + node.cooldown = node.cooldown - 1 + if node.cooldown <= 0 then node.charges = Nodes.MAX_CHARGES end + end + end +end + +return Nodes diff --git a/src/noise.lua b/src/noise.lua new file mode 100644 index 0000000..7440ba2 --- /dev/null +++ b/src/noise.lua @@ -0,0 +1,74 @@ +-- Sound propagation. Unlike los.lua's straight sightline, a noisy action's +-- racket bends around corners: it floods outward from its origin through +-- open, walkable cells only, using the same four-directional adjacency +-- astar.lua's own pathing already assumes (see its DIRS and game.lua's +-- MONSTER_DIRS), and fades by exactly one point of loudness for every step +-- the flood has to take to get there. A closed door or a solid wall stops +-- it dead - nothing leaks through a wall diagonally the way it might with +-- an eight-directional flood, so a noise never reaches anywhere a monster +-- could not actually have walked to investigate it. +-- +-- The flood itself has no idea what a monster or a player is; it is a pure +-- function of the map, an origin and a starting loudness. game.lua's +-- Game:emitNoise is what turns a resulting field into a dormant monster +-- actually stirring (see stealth.lua's Stealth.hear). + +local Noise = {} + +local DIRS = { { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } } + +-- Loudness constants for the actions game.lua wires up to emitNoise, tuned +-- so a whisper-quiet action (nothing currently that quiet) would decay +-- within a couple of cells while a pick striking stone carries the length +-- of a small floor. +Noise.MELEE = 6 +Noise.RANGED = 6 +Noise.THROW = 5 +Noise.SPELL = 8 +Noise.DIG = 9 + +-- Floods outward from (x, y) through walkable cells, returning a table +-- keyed by map:index(nx, ny) -> the loudness still remaining once the sound +-- has traveled there (loudness itself at the origin, decaying by one per +-- step, never revisiting a cell at an intensity no better than one already +-- recorded there). A cell the flood never reaches simply has no entry. The +-- origin itself need not be walkable - a pick striking solid rock (see +-- game.lua's Game:dig) is exactly as loud as one striking rubble - only the +-- cells the flood actually spreads into have to be open for anything to +-- ever stand in them and hear it. +function Noise.propagate(map, x, y, loudness) + local field = {} + if loudness <= 0 or not map:inBounds(x, y) then return field end + local startIdx = map:index(x, y) + field[startIdx] = loudness + local queue = { { x = x, y = y, intensity = loudness } } + local head = 1 + while head <= #queue do + local cur = queue[head] + head = head + 1 + if cur.intensity > 0 then + local nextIntensity = cur.intensity - 1 + for _, dir in ipairs(DIRS) do + local nx, ny = cur.x + dir[1], cur.y + dir[2] + if map:isWalkable(nx, ny) then + local idx = map:index(nx, ny) + if (field[idx] or -1) < nextIntensity then + field[idx] = nextIntensity + queue[#queue + 1] = { x = nx, y = ny, intensity = nextIntensity } + end + end + end + end + end + return field +end + +-- Remaining loudness a previously computed field still carries at (x, y), +-- or 0 for anywhere the flood never reached (including out of bounds, which +-- map:index would otherwise happily compute an index for). +function Noise.intensityAt(field, map, x, y) + if not map:inBounds(x, y) then return 0 end + return field[map:index(x, y)] or 0 +end + +return Noise