feat: extend abilities with tryHeal and tryRally
This commit is contained in:
1 parent
7af63ad1d7
commit
aa657fdc88
3 files changed
+183
-5
No files matched your search
@@ -151,3 +151,113 @@ local function tryShriek(game, monster)
|
|||||||
return true, string.format("%s's wail blinds you", monster.name)
|
return true, string.format("%s's wail blinds you", monster.name)
|
||||||
end
|
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
@@ -1,14 +1,18 @@
|
|||||||
-- Gear affixes. A weapon or armour piece may roll a prefix that bumps its power
|
-- 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
|
-- 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.
|
-- 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 = {}
|
local Affixes = {}
|
||||||
|
|
||||||
Affixes.weapon = {
|
Affixes.weapon = {
|
||||||
{ key = "sharp", name = "sharp", power = 1 },
|
{ key = "sharp", name = "sharp", power = 1 },
|
||||||
{ key = "vicious", name = "vicious", power = 2 },
|
{ key = "vicious", name = "vicious", power = 2 },
|
||||||
{ key = "flaming", name = "flaming", power = 1, onHit = { status = "burning", duration = 3, potency = 2 } },
|
{ 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 } },
|
{ key = "venomed", name = "venomed", power = 0, onHit = { status = "poison", duration = 4, potency = 1 }, damageType = "poison" },
|
||||||
}
|
}
|
||||||
|
|
||||||
Affixes.armor = {
|
Affixes.armor = {
|
||||||
@@ -31,9 +35,10 @@ end
|
|||||||
|
|
||||||
function Affixes.decorate(item, affix)
|
function Affixes.decorate(item, affix)
|
||||||
if not affix then return item end
|
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.power = item.power + affix.power
|
||||||
item.name = affix.name .. " " .. item.name
|
item.name = affix.name .. " " .. item.name
|
||||||
|
item.damageType = affix.damageType
|
||||||
return item
|
return item
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+65
-2
@@ -2,15 +2,38 @@
|
|||||||
-- resolves that monster's turn through game helpers, so all state mutation and
|
-- resolves that monster's turn through game helpers, so all state mutation and
|
||||||
-- rng use stays centralised and deterministic.
|
-- rng use stays centralised and deterministic.
|
||||||
|
|
||||||
|
local Status = require("status")
|
||||||
|
local Infighting = require("infighting")
|
||||||
|
|
||||||
local Ai = {}
|
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 } }
|
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)
|
local function chase(game, monster)
|
||||||
if game:playerAdjacent(monster) then
|
if game:playerAdjacent(monster) then
|
||||||
game:monsterMelee(monster)
|
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)
|
game:stepMonsterToward(monster, game.player.x, game.player.y)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -20,6 +43,7 @@ local function pack(game, monster)
|
|||||||
game:monsterMelee(monster)
|
game:monsterMelee(monster)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
if attackBlockingAlly(game, monster) then return end
|
||||||
if game:canSeePlayer(monster, AGGRO.pack) then
|
if game:canSeePlayer(monster, AGGRO.pack) then
|
||||||
game:stepMonsterToward(monster, game.player.x, game.player.y)
|
game:stepMonsterToward(monster, game.player.x, game.player.y)
|
||||||
return
|
return
|
||||||
@@ -39,6 +63,7 @@ local function ranged(game, monster)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
if attackBlockingAlly(game, monster) then return end
|
||||||
if game:canSeePlayer(monster, AGGRO.ranged) then
|
if game:canSeePlayer(monster, AGGRO.ranged) then
|
||||||
game:stepMonsterToward(monster, game.player.x, game.player.y)
|
game:stepMonsterToward(monster, game.player.x, game.player.y)
|
||||||
end
|
end
|
||||||
@@ -50,6 +75,14 @@ local function flee(game, monster)
|
|||||||
end
|
end
|
||||||
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)
|
local function erratic(game, monster)
|
||||||
if game:playerAdjacent(monster) then
|
if game:playerAdjacent(monster) then
|
||||||
game:monsterMelee(monster)
|
game:monsterMelee(monster)
|
||||||
@@ -63,15 +96,45 @@ local function erratic(game, monster)
|
|||||||
end
|
end
|
||||||
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 = {
|
Ai.behaviours = {
|
||||||
chase = chase,
|
chase = chase,
|
||||||
pack = pack,
|
pack = pack,
|
||||||
ranged = ranged,
|
ranged = ranged,
|
||||||
flee = flee,
|
flee = flee,
|
||||||
erratic = erratic,
|
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)
|
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
|
local behaviour = Ai.behaviours[monster.ai] or chase
|
||||||
behaviour(game, monster)
|
behaviour(game, monster)
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in new issue
Block a user