Files
warren/src/reanimation.lua
T

159 lines
6.6 KiB
Lua

-- Necromancy: consumes a still-fresh corpse underfoot to raise it as a
-- temporary skeletal thrall that fights beside the player until its
-- borrowed animation runs out. Unlike companions.lua's charm-bonded ally,
-- which persists across the whole run once bonded, a thrall is spent the
-- instant it collapses - reduced to inert bones, nothing left to butcher a
-- second time - and raising one costs the player a bite of sanity.lua's own
-- meter, the same currency dread already drains, since bending a corpse
-- back into motion is exactly the kind of wrongness that meter tracks.
-- Only one thrall may walk at a time; raising a second dismisses the first
-- outright, the same one-bond-at-a-time rule companions.lua keeps for its
-- own charm. Thralls are plain entities on level.entities, exactly like
-- companions, so they serialize with no special save-format handling
-- beyond the one extra field (decay) entity.lua's toData whitelist adds.
local Entity = require("entity")
local Combat = require("combat")
local Los = require("los")
local Monsters = require("monsters")
local Corpses = require("corpses")
local Status = require("status")
local Noise = require("noise")
local Reanimation = {}
Reanimation.LIFESPAN = 40
Reanimation.SANITY_COST = 15
Reanimation.HP_FRACTION = 0.6
Reanimation.ATTACK_PENALTY = 1
Reanimation.SIGHT = 6
local ADJACENT = { { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 }, { 1, 1 }, { 1, -1 }, { -1, 1 }, { -1, -1 } }
-- Combat numbers for the thrall raised from a corpse of `speciesKey`: a
-- fraction of the living species' own hp, attack blunted by one point for
-- fighting with brittle bone instead of flesh, and defense and speed left
-- exactly as they were - an animated skeleton is exactly as easy or hard to
-- land a blow on, and exactly as quick underfoot, as the creature it once
-- was. Falls back to a flat, unremarkable skeleton for any corpse whose
-- species the bestiary no longer recognises (none today, but a save could
-- in principle outlive a species removed from a later build).
local function statsFor(speciesKey)
local spec = Monsters.byKey[speciesKey]
if not spec then return { hp = 8, attack = 2, defense = 0, speed = 100 } end
return {
hp = math.max(4, math.floor(spec.hp * Reanimation.HP_FRACTION)),
attack = math.max(1, spec.attack - Reanimation.ATTACK_PENALTY),
defense = spec.defense,
speed = spec.speed,
}
end
-- Raises `corpse` beside the player, retiring any earlier thrall first and
-- spending the corpse and a bite of sanity outright. Returns the new
-- thrall, or (nil, line) with nothing changed when the corpse has already
-- rotted past use, the player's own mind cannot bear the cost, or every
-- cell around the player is blocked.
function Reanimation.raise(game, corpse)
if Corpses.isRotten(corpse) then
return nil, "this corpse has rotted too far to answer the call"
end
if game.player.sanity < Reanimation.SANITY_COST then
return nil, "your grip on your own mind is too frail for this"
end
local player = game.player
local x, y
for _, dir in ipairs(ADJACENT) do
local cx, cy = player.x + dir[1], player.y + dir[2]
if game.level.map:isWalkable(cx, cy) and not game.level:entityAt(cx, cy) then
x, y = cx, cy
break
end
end
if not x then
return nil, "there is nowhere for a thrall to rise"
end
for _, other in ipairs(game.level.entities) do
if other.kind == "thrall" and other:isAlive() then other.hp = 0 end
end
game:cleanupDead()
for i, candidate in ipairs(game.level.corpses) do
if candidate == corpse then
table.remove(game.level.corpses, i)
break
end
end
player.sanity = math.max(0, player.sanity - Reanimation.SANITY_COST)
local stats = statsFor(corpse.species)
local thrall = Entity.new({
id = game:nextMonsterId(), x = x, y = y,
glyph = "Z", name = corpse.name .. " thrall", kind = "thrall",
species = corpse.species, hp = stats.hp, attack = stats.attack, defense = stats.defense, speed = stats.speed,
})
thrall.decay = Reanimation.LIFESPAN
game.level.entities[#game.level.entities + 1] = thrall
return thrall
end
local function adjacent(a, b)
return math.max(math.abs(a.x - b.x), math.abs(a.y - b.y)) == 1
end
-- Nearest living monster the thrall can see within Reanimation.SIGHT, ties
-- broken by ascending id for determinism - the same shape companions.lua's
-- own nearestHostile follows, including its blind clamp (Game:canSeePlayer's
-- own melee-range fallback for a blinded monster).
local function nearestHostile(game, thrall)
local sight = Reanimation.SIGHT
if Status.has(thrall, Status.BLIND) then sight = math.min(sight, 1) end
local best, bestDist = nil, math.huge
for _, entity in ipairs(game.level.entities) do
if entity.kind == "monster" and entity:isAlive() then
local dist = math.max(math.abs(entity.x - thrall.x), math.abs(entity.y - thrall.y))
if dist <= sight and Los.clear(game.level.map, thrall.x, thrall.y, entity.x, entity.y) then
if dist < bestDist or (dist == bestDist and entity.id < best.id) then
best, bestDist = entity, dist
end
end
end
end
return best
end
-- The thrall's full turn: its borrowed animation runs out by one turn
-- first, and once it hits zero the thrall collapses back into inert bones
-- through the very same Game:onActorDeath path a slain one already falls
-- through - called directly here, exactly like Game:checkTrapFor calls it
-- directly for a trap kill mid-move, since nothing later in the caller's
-- own takeMonsterTurn pipeline re-checks aliveness once dispatch returns.
-- Otherwise it fights whatever hostile it can already reach, closes on one
-- it can see but not yet reach, or shadows the player - the same
-- three-branch shape as Companions.act, minus any tending since a thrall
-- has nothing of its own to spare.
function Reanimation.act(game, thrall)
thrall.decay = thrall.decay - 1
if thrall.decay <= 0 then
thrall.hp = 0
game:onActorDeath(thrall)
return
end
if game:allyActsConfused(thrall) then return end
local target = nearestHostile(game, thrall)
if target and adjacent(thrall, target) then
local outcome = Combat.attack(thrall, target)
game.log:add(string.format("your %s claws %s for %d", thrall.name, target.name, outcome.damage))
game:emitNoise(thrall.x, thrall.y, Noise.MELEE)
if outcome.killed then game:awardKill(target) end
return
end
if target then
game:stepMonsterToward(thrall, target.x, target.y)
return
end
if not adjacent(thrall, game.player) and not (thrall.x == game.player.x and thrall.y == game.player.y) then
game:stepMonsterToward(thrall, game.player.x, game.player.y)
end
end
return Reanimation