diff --git a/src/reanimation.lua b/src/reanimation.lua new file mode 100644 index 0000000..f2f7965 --- /dev/null +++ b/src/reanimation.lua @@ -0,0 +1,158 @@ +-- 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