feat: add statsFor and Reanimation.raise
This commit is contained in:
1 parent
10488b48e8
commit
bd949a25a5
1 file changed
+158
@@ -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
|
||||
Reference in new issue
Block a user