feat: add Digging.canDig, Digging.swing and Disarm.sweep

This commit is contained in:
dualfroz committed 2026-09-04 18:42:26 +02:00
1 parent bdf3624b02
commit c214f5c127
2 files changed
+195

No files matched your search

+113
View File
@@ -0,0 +1,113 @@
-- Tunneling through plain rock, player-driven with a limited-use pick and
-- monster-driven for a burrower born to it (see monsters.lua's "burrower"
-- species and ai.lua's "tunnel" behaviour). A dug wall never clears in one
-- swing: it crumbles to Tiles.RUBBLE first - already walkable and already
-- open to sight, unlike the wall it replaced - and only a second swing
-- against that same cell clears it the rest of the way to open floor, so a
-- tunnel always costs two hits, never one, whoever is swinging. A vault's
-- own boundary (Tiles.VAULT_DOOR) never yields no matter how many swings it
-- takes - only the lever it guards opens it (see Game:pullLever). Every
-- swing risks a cave-in that buries the digger for damage instead of
-- yielding progress, and a lucky one strikes a raw vein outright - clearing
-- the wall in a single stroke and paying out essence exactly like a spent
-- nodes.lua vein, but only for a digger that tracks essence at all (the
-- player; a no-op reward for anything else, i.e. a monster).
-- Digging mutates the map tile grid directly, exactly like terrain.lua's
-- hazards bake straight into it, so a dug tunnel needs no special
-- save-format handling of its own - the map already serializes.
local Tiles = require("tiles")
local Digging = {}
Digging.CAVE_IN_CHANCE_DEN = 8
Digging.CAVE_IN_DAMAGE_MIN = 2
Digging.CAVE_IN_DAMAGE_MAX = 5
Digging.ORE_STRIKE_CHANCE_DEN = 10
Digging.ORE_ESSENCE_MIN = 4
Digging.ORE_ESSENCE_MAX = 9
Digging.DEFAULT_USES = 20
-- A burrower senses the player's direction straight through solid rock out
-- to this many Chebyshev cells - beyond it, it idles exactly like "flee"
-- idles once it loses sight, rather than clawing forever from across the map.
Digging.SENSE_RANGE = 6
-- Only a plain wall or the rubble left by a prior swing can be dug further;
-- open floor has nothing left to carve and a vault door never yields.
function Digging.canDig(map, x, y)
if not map:inBounds(x, y) then return false end
local tile = map:get(x, y)
return tile == Tiles.WALL or tile == Tiles.RUBBLE
end
-- Resolves one swing of `digger`'s pick (or a burrower's own claws) against
-- (x, y), already known diggable by the caller. Mutates the map tile in
-- place on progress, deals cave-in damage to the digger on bad luck, and
-- always returns a log line describing what happened.
function Digging.swing(game, digger, x, y)
local map = game.level.map
local tile = map:get(x, y)
local isPlayer = digger == game.player
if game.rng:chance(1, Digging.CAVE_IN_CHANCE_DEN) then
local dealt = game.rng:range(Digging.CAVE_IN_DAMAGE_MIN, Digging.CAVE_IN_DAMAGE_MAX)
digger.hp = digger.hp - dealt
if isPlayer then return string.format("the rock groans and buries you for %d", dealt) end
return string.format("the rock groans and buries %s for %d", digger.name, dealt)
end
if tile == Tiles.WALL then
if game.rng:chance(1, Digging.ORE_STRIKE_CHANCE_DEN) then
map:set(x, y, Tiles.FLOOR)
if digger.essence ~= nil then
local amount = game.rng:range(Digging.ORE_ESSENCE_MIN, Digging.ORE_ESSENCE_MAX)
digger.essence = digger.essence + amount
return string.format("you strike a vein and pocket %d essence", amount)
end
return string.format("%s claws straight through a vein of ore", digger.name)
end
map:set(x, y, Tiles.RUBBLE)
if isPlayer then return "you carve through the rock" end
return string.format("%s carves through the rock", digger.name)
end
map:set(x, y, Tiles.FLOOR)
if isPlayer then return "you clear the rubble away" end
return string.format("%s clears the rubble away", digger.name)
end
-- Consumes one use from `entity.pick`'s durability, discarding the tool
-- once its edge dulls past use. A no-op (returns nil) for anything without
-- a pick equipped - a burrower digs with its own claws instead, which never
-- wear out. Returns a log line only on the swing that breaks the tool.
function Digging.wearPick(entity)
local pick = entity.pick
if not pick then return nil end
pick.uses = pick.uses - 1
if pick.uses <= 0 then
entity.pick = nil
return "your " .. pick.name .. " crumbles apart from the strain"
end
return nil
end
-- Orthogonal step directions from (mx, my) toward (tx, ty), longer axis
-- first, in the same 4-connected grid every monster already moves and
-- paths on (see game.lua's MONSTER_DIRS, astar.lua's DIRS) - never a
-- diagonal, so a dug tunnel is always walkable by the same step logic that
-- carved it. Returns an empty list when already on the target cell.
function Digging.directionsToward(mx, my, tx, ty)
local ddx = tx - mx
local ddy = ty - my
local dx = ddx > 0 and 1 or (ddx < 0 and -1 or 0)
local dy = ddy > 0 and 1 or (ddy < 0 and -1 or 0)
local dirs = {}
if math.abs(ddx) >= math.abs(ddy) then
if dx ~= 0 then dirs[#dirs + 1] = { dx, 0 } end
if dy ~= 0 then dirs[#dirs + 1] = { 0, dy } end
else
if dy ~= 0 then dirs[#dirs + 1] = { 0, dy } end
if dx ~= 0 then dirs[#dirs + 1] = { dx, 0 } end
end
return dirs
end
return Digging
+82
View File
@@ -0,0 +1,82 @@
-- Passive trap sense and the deliberate act of disarming what it finds.
-- Every trap already carries a hidden/revealed pair (level.traps entries -
-- see level.lua and game.lua's Game:springTrap, which flips a trap's
-- `revealed` the instant it is triggered, whether by a footstep or a failed
-- disarm below). This module adds a third state in between the two,
-- `detected`: noticed but not yet sprung. Detection is a passive roll
-- against every still-hidden trap within a step of the player, rolled once
-- per player turn from Game:endPlayerTurn - once per round rather than once
-- per actor, exactly like hazards.step and events.step already advance the
-- world. Disarming a detected trap is the deliberate, risky counterpart:
-- succeed and it is gone for good, plus a handful of essence for the
-- salvaged parts; fail and it springs on the disarmer exactly as if they
-- had blundered onto it outright, through the very same Game:springTrap
-- every other trigger already shares.
local Disarm = {}
Disarm.DETECT_NUMERATOR = 1
Disarm.DETECT_DENOMINATOR = 4
Disarm.DISARM_BASE_NUMERATOR = 3
Disarm.DISARM_BASE_DENOMINATOR = 5
Disarm.LEVEL_BONUS_DIVISOR = 4
Disarm.SALVAGE_MIN = 3
Disarm.SALVAGE_MAX = 6
-- Sweeps every trap within Chebyshev distance 1 of (px, py) - the same
-- adjacency a monster's melee reach and a shrine's own footprint are judged
-- by elsewhere - that is neither already detected nor already sprung, and
-- rolls each independently. Returns the traps newly detected this call (in
-- level.traps order), so a caller can report exactly what was noticed.
function Disarm.sweep(level, rng, px, py)
local found = {}
for _, trap in ipairs(level.traps) do
if not trap.detected and not trap.revealed
and math.max(math.abs(trap.x - px), math.abs(trap.y - py)) <= 1
and rng:chance(Disarm.DETECT_NUMERATOR, Disarm.DETECT_DENOMINATOR) then
trap.detected = true
found[#found + 1] = trap
end
end
return found
end
-- Odds of successfully disarming a detected trap: a flat base that climbs
-- slightly with player level, capped a point shy of the denominator so
-- disarming never becomes a sure thing no matter how experienced the
-- player gets.
function Disarm.oddsFor(player)
local numerator = Disarm.DISARM_BASE_NUMERATOR + player.level // Disarm.LEVEL_BONUS_DIVISOR
if numerator > Disarm.DISARM_BASE_DENOMINATOR - 1 then
numerator = Disarm.DISARM_BASE_DENOMINATOR - 1
end
return numerator, Disarm.DISARM_BASE_DENOMINATOR
end
-- Resolves one disarm attempt against `trap`, already known to `game`'s
-- player (see Game:disarmTrap, which enforces that it was actually
-- detected first). On success the trap is spliced out of level.traps for
-- good and its salvage is credited as essence; on failure it springs on the
-- player through Game:springTrap, the identical consequence a careless
-- footstep would have caused. Returns (true, line) or (false, nil) - a
-- failure logs nothing of its own since springTrap already does.
function Disarm.attempt(game, trap)
local numerator, denominator = Disarm.oddsFor(game.player)
if not game.rng:chance(numerator, denominator) then
game:springTrap(trap, game.player)
return false, nil
end
for i, candidate in ipairs(game.level.traps) do
if candidate == trap then
table.remove(game.level.traps, i)
break
end
end
local salvage = game.rng:range(Disarm.SALVAGE_MIN, Disarm.SALVAGE_MAX)
game.player.essence = game.player.essence + salvage
return true, string.format("you carefully disarm the trap, salvaging %d essence", salvage)
end
return Disarm