Files
warren/src/abilities.lua
T

264 lines
12 KiB
Lua

-- Species-level passive and death-triggered abilities layered on top of the
-- generic AI behaviours in ai.lua. Declared per species in Monsters.species
-- so a bestiary entry fully describes what a creature does, not just how
-- hard it hits or how it moves.
local Status = require("status")
local Items = require("items")
local Los = require("los")
local Combat = require("combat")
local Durability = require("durability")
local Momentum = require("momentum")
local Abilities = {}
-- Regeneration: heals `monster.regen` hp at the start of its turn, unless it
-- is actively burning - fire cauterizes the wound faster than it closes.
-- Runs after Status.tick so a fresh burn this same turn still suppresses it.
function Abilities.regenTick(monster)
local amount = monster.regen
if not amount or not monster:isAlive() then return nil end
if Status.has(monster, Status.BURNING) then return nil end
local before = monster.hp
monster.hp = math.min(monster.maxHp, monster.hp + amount)
if monster.hp == before then return nil end
return string.format("%s knits its wounds for %d", monster.name, monster.hp - before)
end
-- On-death bursts: some species release a parting effect on whatever is
-- adjacent to their corpse - including the player - the instant they die.
-- Keyed by the `onDeath` tag on the species, run from Game:awardKill so the
-- burst fires no matter what finished the monster off (melee, spell, thrown
-- weapon, or a status effect ticking it down).
local bursts = {
spores = function(game, monster)
local hit = {}
local targets = { game.player }
for _, other in ipairs(game:currentEntities()) do targets[#targets + 1] = other end
for _, victim in ipairs(targets) do
if victim ~= monster and victim:isAlive()
and math.abs(victim.x - monster.x) <= 1 and math.abs(victim.y - monster.y) <= 1 then
Status.apply(victim, Status.POISON, 4, 2)
hit[#hit + 1] = victim.name
end
end
if #hit == 0 then return nil end
return string.format("a burst of spores poisons %s", table.concat(hit, ", "))
end,
}
function Abilities.onDeath(game, monster)
local burst = monster.onDeath and bursts[monster.onDeath]
if not burst then return nil end
return burst(game, monster)
end
-- Turn-start special abilities, keyed by the `ability` tag on the species
-- (Monsters.species) and stamped onto the entity at creation. Abilities.tryAct
-- runs from Game:takeMonsterTurn BEFORE the plain ai.lua behaviour: when it
-- fires it consumes the whole turn and the caller must skip Ai.act; when it
-- returns false the monster falls through to its ordinary ai tag unchanged.
-- Range/cooldown constants live here, not on the species, mirroring how
-- `bursts` above centralises onDeath behaviour by tag rather than by species.
local RANGE = { summon = 6, curse = 5, heal = 5, shriek = 5 }
local COOLDOWN = { summon = 6, curse = 4, heal = 5, rally = 8, shriek = 6 }
local MAX_SUMMONED = 2
local EIGHT_DIRS = {
{ 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 },
{ 1, 1 }, { 1, -1 }, { -1, 1 }, { -1, -1 },
}
-- The first free, unoccupied, walkable cell next to (x, y), scanning a fixed
-- direction order so results are deterministic for a given map state.
local function freeAdjacent(game, x, y)
for _, dir in ipairs(EIGHT_DIRS) do
local nx, ny = x + dir[1], y + dir[2]
if game.level.map:isWalkable(nx, ny) and not game.level:entityAt(nx, ny)
and not (nx == game.player.x and ny == game.player.y) then
return nx, ny
end
end
return nil
end
function Abilities.tickCooldown(monster)
if monster.cooldown and monster.cooldown > 0 then
monster.cooldown = monster.cooldown - 1
end
end
-- Necromancer-style ability: raises a fresh minion of `summonSpecies` beside
-- itself, tagged `summonedBy` so the population cap only counts its own brood.
local function trySummon(game, monster)
if not game:canSeePlayer(monster, RANGE.summon) then return false end
local count = 0
for _, other in ipairs(game.level.entities) do
if other:isAlive() and other.summonedBy == monster.id then count = count + 1 end
end
if count >= MAX_SUMMONED then return false end
local x, y = freeAdjacent(game, monster.x, monster.y)
if not x then return false end
local minion = game:spawnMinion(monster.summonSpecies, x, y)
minion.summonedBy = monster.id
monster.cooldown = COOLDOWN.summon
return true, string.format("%s calls forth a %s", monster.name, minion.name)
end
-- Shaman-style ability: a ranged bolt that both hurts and weakens the player,
-- withheld at point-blank range so the caster's ai (flee) kites instead. The
-- hurt half runs through the exact same Combat.damage the melee/shoot paths
-- use - mitigated by the player's defense and resistances, breaking Momentum
-- (see momentum.lua's own "claw, breath or ambush" wording - a curse bolt is
-- none of those by name but is that in shape) and wearing armor exactly like
-- monsterShoot's breath already does - rather than a flat, unmitigated
-- subtraction none of that pipeline ever saw.
local function tryCurse(game, monster)
if game:playerAdjacent(monster) then return false end
if not game:canSeePlayer(monster, RANGE.curse) or not game:hasClearShot(monster) then
return false
end
local dealt = Combat.damage(monster, game.player, 1)
game.player.hp = game.player.hp - dealt
Momentum.onDamaged(game.player)
local broke = Durability.wearArmor(game.player)
if broke then game.log:add(broke) end
Status.apply(game.player, Status.WEAKEN, 6, 3)
monster.cooldown = COOLDOWN.curse
return true, string.format("%s curses you for %d", monster.name, dealt)
end
-- Banshee-style ability: a ranged wail that blinds instead of hurting,
-- withheld point-blank exactly like tryCurse so the caster's own "flee" ai
-- keeps it at kiting distance rather than trading blows. Status.BLIND
-- (game.lua's Game:effectiveRadius) was previously only ever inflicted BY
-- the player - a thrown potion, the blind spell, or a shared trap effect -
-- never the other way around; this is the first hostile source of it, the
-- same gap tryShriek's sibling "curse" would leave for WEAKEN if a monster
-- could apply it but nothing here ever did. Duration outlasts the cooldown
-- by one turn on purpose (5 vs 6) so a banshee left free to keep casting
-- keeps you blind almost the whole encounter but not with zero window - the
-- one turn of clear sight between wails is a player's chance to reposition
-- or close the distance before the next howl lands.
local function tryShriek(game, monster)
if game:playerAdjacent(monster) then return false end
if not game:canSeePlayer(monster, RANGE.shriek) or not game:hasClearShot(monster) then
return false
end
Status.apply(game.player, Status.BLIND, 5, 1)
monster.cooldown = COOLDOWN.shriek
return true, string.format("%s's wail blinds you", monster.name)
end
-- Cleric-style ability: mends the neediest hurt ally within range and line of
-- sight instead of attacking. Ties break on the lowest hp fraction. Filtered
-- to other.kind == "monster" - the same "hostile" tag every other module
-- (companions.lua/mercenaries.lua/reanimation.lua's own nearestHostile,
-- commands.lua's isVisibleHostile) already keys off of - so a dark cleric
-- mends a fellow monster, never the player's own wounded companion, hireling
-- or thrall sharing the same entities list.
local function tryHeal(game, monster)
local best, bestRatio = nil, 1
for _, other in ipairs(game.level.entities) do
if other ~= monster and other.kind == "monster" and other:isAlive() and other.hp < other.maxHp then
local dist = math.max(math.abs(other.x - monster.x), math.abs(other.y - monster.y))
if dist <= RANGE.heal and Los.clear(game.level.map, monster.x, monster.y, other.x, other.y) then
local ratio = other.hp / other.maxHp
if ratio < bestRatio then bestRatio, best = ratio, other end
end
end
end
if not best then return false end
local amount = 6
local before = best.hp
best.hp = math.min(best.maxHp, best.hp + amount)
monster.cooldown = COOLDOWN.heal
return true, string.format("%s mends %s for %d", monster.name, best.name, best.hp - before)
end
-- Chieftain-style ability: grants every adjacent, not-yet-buffed ally (and
-- itself) a temporary attack boost. No-op once the whole knot is already lit.
-- Filtered to other.kind == "monster" for the same reason tryHeal above is:
-- without it a chieftain standing next to the player's companion, hireling
-- or thrall would rally that ally of the PLAYER's instead of one of its own.
local function tryRally(game, monster)
local buffed = {}
for _, other in ipairs(game.level.entities) do
if other ~= monster and other.kind == "monster" and other:isAlive() and not Status.has(other, Status.STRENGTH)
and math.abs(other.x - monster.x) <= 1 and math.abs(other.y - monster.y) <= 1 then
Status.apply(other, Status.STRENGTH, 10, 2)
buffed[#buffed + 1] = other.name
end
end
if #buffed == 0 then return false end
Status.apply(monster, Status.STRENGTH, 10, 2)
monster.cooldown = COOLDOWN.rally
return true, string.format("%s rallies %s", monster.name, table.concat(buffed, ", "))
end
-- Footpad-style ability: not cooldown-gated. Once adjacent it lifts a single
-- non-essential item (never an equipped key or the amulet, since those never
-- sit unequipped in inventory) and flees for the rest of the encounter; a
-- fleeing footpad just keeps running rather than trying again.
local function trySteal(game, monster)
if monster.fleeing then
game:stepMonsterAway(monster, game.player.x, game.player.y)
return true
end
if not game:playerAdjacent(monster) then return false end
local idx = game:findInInventory(function(item)
return item.kind ~= Items.KEY and item.kind ~= Items.AMULET
end)
if not idx then
monster.fleeing = true
return true, string.format("%s finds nothing worth stealing and flees", monster.name)
end
local item = table.remove(game.player.inventory, idx)
monster.stolen = item
monster.fleeing = true
return true, string.format("%s steals your %s and flees", monster.name, item.name)
end
local dispatch = { summon = trySummon, curse = tryCurse, heal = tryHeal, rally = tryRally, shriek = tryShriek }
-- Called once per monster turn, after status ticks and regen but before
-- ai.lua. Returns true when the ability consumed the turn.
function Abilities.tryAct(game, monster)
if not monster.ability then return false end
if monster.ability == "steal" then
local acted, line = trySteal(game, monster)
if acted and line then game.log:add(line) end
return acted == true
end
local handler = dispatch[monster.ability]
if not handler or (monster.cooldown and monster.cooldown > 0) then return false end
local acted, line = handler(game, monster)
if not acted then return false end
if line then game.log:add(line) end
return true
end
-- Slime-style reaction: fired from Game:playerAttack when a hit lands but
-- does not kill. Halves the victim's max hp and spawns a clone at the other
-- half, capped at two splits total (`splitGen`) so a slime cannot be farmed
-- into an unbounded swarm.
function Abilities.onHit(game, target)
if target.ability ~= "split" then return nil end
if not target:isAlive() or target.maxHp < 4 then return nil end
if (target.splitGen or 0) >= 2 then return nil end
local x, y = freeAdjacent(game, target.x, target.y)
if not x then return nil end
local newMax = target.maxHp // 2
target.maxHp = newMax
target.hp = math.min(target.hp, newMax)
target.splitGen = (target.splitGen or 0) + 1
local clone = game:spawnMinion(target.species, x, y)
clone.maxHp = newMax
clone.hp = newMax
clone.splitGen = target.splitGen
return string.format("the %s splits in two", target.name)
end
return Abilities