159 lines
6.6 KiB
Lua
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
|