feat: extend abilities with tryHeal and tryRally

This commit is contained in:
dualfroz committed 2026-09-01 13:52:41 +02:00
1 parent 7af63ad1d7
commit aa657fdc88
3 files changed
+183 -5

No files matched your search

+110
View File
@@ -151,3 +151,113 @@ local function tryShriek(game, monster)
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
+8 -3
View File
@@ -1,14 +1,18 @@
-- Gear affixes. A weapon or armour piece may roll a prefix that bumps its power
-- and, for some weapons, attaches an on-hit rider (e.g. flaming sets the target
-- alight). Rolls draw from the shared rng so drops stay deterministic per seed.
-- An elemental rider also retags the weapon's own base swing (damageType,
-- read by combat.lua) to match - a flaming sword fights with fire, not steel,
-- so it hits harder against a cold-weak target and can do nothing at all
-- against something fire-immune.
local Affixes = {}
Affixes.weapon = {
{ key = "sharp", name = "sharp", power = 1 },
{ key = "vicious", name = "vicious", power = 2 },
{ key = "flaming", name = "flaming", power = 1, onHit = { status = "burning", duration = 3, potency = 2 } },
{ key = "venomed", name = "venomed", power = 0, onHit = { status = "poison", duration = 4, potency = 1 } },
{ key = "flaming", name = "flaming", power = 1, onHit = { status = "burning", duration = 3, potency = 2 }, damageType = "fire" },
{ key = "venomed", name = "venomed", power = 0, onHit = { status = "poison", duration = 4, potency = 1 }, damageType = "poison" },
}
Affixes.armor = {
@@ -31,9 +35,10 @@ end
function Affixes.decorate(item, affix)
if not affix then return item end
item.affix = { key = affix.key, name = affix.name, power = affix.power, onHit = affix.onHit }
item.affix = { key = affix.key, name = affix.name, power = affix.power, onHit = affix.onHit, damageType = affix.damageType }
item.power = item.power + affix.power
item.name = affix.name .. " " .. item.name
item.damageType = affix.damageType
return item
end
+65 -2
View File
@@ -2,15 +2,38 @@
-- resolves that monster's turn through game helpers, so all state mutation and
-- rng use stays centralised and deterministic.
local Status = require("status")
local Infighting = require("infighting")
local Ai = {}
local AGGRO = { chase = 10, pack = 13, ranged = 12, flee = 8, erratic = 8 }
local AGGRO = { chase = 10, pack = 13, ranged = 12, flee = 8, erratic = 8, tunnel = 10 }
local DIRS = { { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } }
-- A player-aligned ally standing adjacent to `monster` occupies its cell
-- exactly the way a wall would for Game:stepMonsterToward's own pathfinding
-- (blockedForMonster treats any occupied non-goal cell as impassable) - so a
-- one-wide corridor an ally is plugging leaves every pursuit behaviour below
-- with no route at all, and without this check they simply idle beside it
-- forever rather than fighting the living combatant in their way. Resolves
-- through Infighting.strike, the same generic monster-vs-any-creature combat
-- infighting.lua already uses for rivalries and discord. Returns true once
-- it has spent the monster's turn this way, so each caller can bail out at
-- the same priority Game:playerAdjacent already has over closing distance.
local function attackBlockingAlly(game, monster)
local ally = game:adjacentAlly(monster)
if not ally then return false end
Infighting.strike(game, monster, ally)
return true
end
local function chase(game, monster)
if game:playerAdjacent(monster) then
game:monsterMelee(monster)
elseif game:canSeePlayer(monster, AGGRO.chase) then
return
end
if attackBlockingAlly(game, monster) then return end
if game:canSeePlayer(monster, AGGRO.chase) then
game:stepMonsterToward(monster, game.player.x, game.player.y)
end
end
@@ -20,6 +43,7 @@ local function pack(game, monster)
game:monsterMelee(monster)
return
end
if attackBlockingAlly(game, monster) then return end
if game:canSeePlayer(monster, AGGRO.pack) then
game:stepMonsterToward(monster, game.player.x, game.player.y)
return
@@ -39,6 +63,7 @@ local function ranged(game, monster)
end
return
end
if attackBlockingAlly(game, monster) then return end
if game:canSeePlayer(monster, AGGRO.ranged) then
game:stepMonsterToward(monster, game.player.x, game.player.y)
end
@@ -50,6 +75,14 @@ local function flee(game, monster)
end
end
-- Shopkeepers idle unless game.lua has already flagged them hostile (a theft
-- from their stock), at which point they behave exactly like a chaser.
local function vend(game, monster)
if monster.hostile then
chase(game, monster)
end
end
local function erratic(game, monster)
if game:playerAdjacent(monster) then
game:monsterMelee(monster)
@@ -63,15 +96,45 @@ local function erratic(game, monster)
end
end
-- A burrower: melee once adjacent, chase like anything else once it can see
-- the player through open floor, otherwise claw straight through whatever
-- rock stands between them (see Game:monsterDig, digging.lua) rather than
-- idling the way a plain chaser out of sight would.
local function tunnel(game, monster)
if game:playerAdjacent(monster) then
game:monsterMelee(monster)
return
end
if attackBlockingAlly(game, monster) then return end
if game:canSeePlayer(monster, AGGRO.tunnel) then
game:stepMonsterToward(monster, game.player.x, game.player.y)
return
end
game:monsterDig(monster, game.player.x, game.player.y)
end
Ai.behaviours = {
chase = chase,
pack = pack,
ranged = ranged,
flee = flee,
erratic = erratic,
vend = vend,
tunnel = tunnel,
}
-- A confused monster gets the exact same chance each turn to have its own
-- chosen direction overridden that Game:movePlayer rolls for the player - see
-- status.lua's Status.CONFUSE_NUMERATOR/DENOMINATOR, shared by both sides so
-- neither one confuses more reliably than the other. On a lucky roll (or once
-- confusion wears off) `behaviour` below runs completely untouched.
function Ai.act(game, monster)
if Status.has(monster, Status.CONFUSE)
and game.rng:chance(Status.CONFUSE_NUMERATOR, Status.CONFUSE_DENOMINATOR) then
local dir = DIRS[game.rng:range(1, #DIRS)]
game:moveMonster(monster, dir[1], dir[2])
return
end
local behaviour = Ai.behaviours[monster.ai] or chase
behaviour(game, monster)
end