feat: add chebyshev, novaTargets and Polymorph.pool
This commit is contained in:
1 parent
a10eb10d22
commit
b31072d587
2 files changed
+154
No files matched your search
@@ -0,0 +1,91 @@
|
|||||||
|
-- A boss's second, harder-won escalation beyond bosses.lua's enrage. Enrage
|
||||||
|
-- is a single instantaneous stat bump that never costs the boss a turn; a
|
||||||
|
-- desperation phase is a two-turn state machine that consumes both turns
|
||||||
|
-- outright once the boss is battered all the way down to its catalog
|
||||||
|
-- desperateAt threshold:
|
||||||
|
-- 1. brace - the boss telegraphs for one full turn, a visible defense
|
||||||
|
-- spike (desperateDefenseBonus) warning the player something is coming
|
||||||
|
-- without yet hurting anyone.
|
||||||
|
-- 2. unleash - on its very next turn the boss reverts that defense spike
|
||||||
|
-- and detonates a single cataclysm, dealing novaDamage to the player
|
||||||
|
-- and to every companion, hireling or thrall still within novaRadius.
|
||||||
|
-- The cataclysm fires at most once per boss: novaSpent latches permanently
|
||||||
|
-- the moment it lands, so a fight survived past it settles back into the
|
||||||
|
-- boss's ordinary ability/ai behaviour (abilities.lua) for good. A boss with
|
||||||
|
-- no desperateAt in its catalog entry (none outside bosses.lua today) never
|
||||||
|
-- enters this state machine at all.
|
||||||
|
--
|
||||||
|
-- Bracing and unleashing both consume the boss's entire turn - the caller
|
||||||
|
-- (Game:takeMonsterTurn) must skip Stealth.tick, Abilities.tryAct and
|
||||||
|
-- Ai.act whenever Phases.tick reports the turn consumed, exactly as it
|
||||||
|
-- already skips them all under Status.skipsTurn.
|
||||||
|
|
||||||
|
local Bosses = require("bosses")
|
||||||
|
|
||||||
|
local Phases = {}
|
||||||
|
|
||||||
|
local function chebyshev(ax, ay, bx, by)
|
||||||
|
return math.max(math.abs(ax - bx), math.abs(ay - by))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Everyone the cataclysm can reach: the player when in range, plus every
|
||||||
|
-- living companion, hireling or thrall within the same radius - the same
|
||||||
|
-- three-way ally kind the rest of the engine treats as a single "player's
|
||||||
|
-- own side" (see game.lua's isPlayerAlly, infighting.lua, and the noise/
|
||||||
|
-- confuse/blind symmetry work across companions.lua/mercenaries.lua/
|
||||||
|
-- reanimation.lua). A wild monster caught nearby is never a target - a
|
||||||
|
-- boss's desperate gambit is aimed at the party that cornered it, not at
|
||||||
|
-- whatever else happens to be on the floor (mirrors Bosses itself only ever
|
||||||
|
-- threatening the player's side).
|
||||||
|
local function novaTargets(game, monster, radius)
|
||||||
|
local targets = {}
|
||||||
|
if chebyshev(game.player.x, game.player.y, monster.x, monster.y) <= radius then
|
||||||
|
targets[#targets + 1] = game.player
|
||||||
|
end
|
||||||
|
for _, other in ipairs(game.level.entities) do
|
||||||
|
if other:isAlive() and (other.kind == "companion" or other.kind == "hireling" or other.kind == "thrall")
|
||||||
|
and chebyshev(other.x, other.y, monster.x, monster.y) <= radius then
|
||||||
|
targets[#targets + 1] = other
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return targets
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Called once per boss turn, at the same point Bosses.tryEnrage already
|
||||||
|
-- runs in Game:takeMonsterTurn (after status/hazard/regen, before every
|
||||||
|
-- other action). Returns (line, consumedTurn); consumedTurn true means the
|
||||||
|
-- caller must skip everything else this turn, bracing and unleashing both
|
||||||
|
-- being the entire point of the turn they happen on.
|
||||||
|
function Phases.tick(game, monster)
|
||||||
|
if not monster.isBoss or not monster:isAlive() then return nil, false end
|
||||||
|
local spec = Bosses.byKey[monster.bossKey]
|
||||||
|
if not spec or not spec.desperateAt then return nil, false end
|
||||||
|
|
||||||
|
if monster.channeling then
|
||||||
|
monster.channeling = false
|
||||||
|
monster.novaSpent = true
|
||||||
|
monster.defense = monster.defense - spec.desperateDefenseBonus
|
||||||
|
monster.baseDefense = monster.baseDefense - spec.desperateDefenseBonus
|
||||||
|
local hit = {}
|
||||||
|
for _, victim in ipairs(novaTargets(game, monster, spec.novaRadius)) do
|
||||||
|
victim.hp = victim.hp - spec.novaDamage
|
||||||
|
hit[#hit + 1] = victim == game.player and "you" or victim.name
|
||||||
|
end
|
||||||
|
if #hit == 0 then
|
||||||
|
return string.format("%s unleashes a cataclysm that finds no one in range", monster.name), true
|
||||||
|
end
|
||||||
|
return string.format("%s unleashes a cataclysm on %s for %d", monster.name,
|
||||||
|
table.concat(hit, ", "), spec.novaDamage), true
|
||||||
|
end
|
||||||
|
|
||||||
|
if monster.novaSpent or monster.desperate then return nil, false end
|
||||||
|
if monster.hp > monster.maxHp * spec.desperateAt then return nil, false end
|
||||||
|
|
||||||
|
monster.desperate = true
|
||||||
|
monster.channeling = true
|
||||||
|
monster.defense = monster.defense + spec.desperateDefenseBonus
|
||||||
|
monster.baseDefense = monster.baseDefense + spec.desperateDefenseBonus
|
||||||
|
return string.format("%s reels and begins channeling a desperate cataclysm", monster.name), true
|
||||||
|
end
|
||||||
|
|
||||||
|
return Phases
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
-- Rewrites an entity's very body into a different bestiary species mid-run.
|
||||||
|
-- Unlike affixes.lua or runewords.lua, which layer bonuses on top of a fixed
|
||||||
|
-- body, a polymorph replaces the body outright: name, glyph, ai behaviour,
|
||||||
|
-- attack, defense, speed, resistances, faction and any ability tag all come
|
||||||
|
-- from the new species wholesale, the same fields Monsters.create stamps
|
||||||
|
-- onto a fresh spawn. Only the current hp fraction survives the swap, so a
|
||||||
|
-- polymorph is never a cheap full heal and never an outright kill either.
|
||||||
|
-- Mutation happens in place on the entity table already held everywhere
|
||||||
|
-- else (the turn scheduler, level.entities, a live nemesis reference), so
|
||||||
|
-- nothing needs to re-look-up or re-insert anything after a transform.
|
||||||
|
|
||||||
|
local Monsters = require("monsters")
|
||||||
|
|
||||||
|
local Polymorph = {}
|
||||||
|
|
||||||
|
-- Species a polymorph may land on at `depth`, in Monsters.species order so a
|
||||||
|
-- given rng draw is deterministic. Excludes the shopkeeper outright: its
|
||||||
|
-- "vend" behaviour idles forever once out of the shop context a polymorphed
|
||||||
|
-- body would never occupy, leaving it neither hostile nor useful to anyone.
|
||||||
|
function Polymorph.pool(depth)
|
||||||
|
local eligible = {}
|
||||||
|
for _, spec in ipairs(Monsters.species) do
|
||||||
|
if spec.key ~= "shopkeeper" and spec.minDepth <= depth then
|
||||||
|
eligible[#eligible + 1] = spec
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return eligible
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Transforms `entity` in place into a random species drawn from `pool`,
|
||||||
|
-- excluding whatever species it already is so a polymorph always changes
|
||||||
|
-- something. Returns the species table it became, or nil (entity untouched)
|
||||||
|
-- if `pool` has nothing left to offer once the current species is excluded.
|
||||||
|
function Polymorph.transform(entity, rng, pool)
|
||||||
|
local choices = {}
|
||||||
|
for _, spec in ipairs(pool) do
|
||||||
|
if spec.key ~= entity.species then choices[#choices + 1] = spec end
|
||||||
|
end
|
||||||
|
if #choices == 0 then return nil end
|
||||||
|
local spec = choices[rng:range(1, #choices)]
|
||||||
|
local fraction = entity.maxHp > 0 and (entity.hp / entity.maxHp) or 1
|
||||||
|
entity.glyph = spec.glyph
|
||||||
|
entity.name = spec.name
|
||||||
|
entity.species = spec.key
|
||||||
|
entity.ai = spec.ai
|
||||||
|
entity.maxHp = spec.hp
|
||||||
|
entity.hp = math.max(1, math.floor(spec.hp * fraction + 0.5))
|
||||||
|
entity.attack = spec.attack
|
||||||
|
entity.defense = spec.defense
|
||||||
|
entity.speed = spec.speed
|
||||||
|
entity.range = spec.range or 0
|
||||||
|
entity.regen = spec.regen
|
||||||
|
entity.onDeath = spec.onDeath
|
||||||
|
entity.breath = spec.breath
|
||||||
|
entity.ability = spec.ability
|
||||||
|
entity.summonSpecies = spec.summonSpecies
|
||||||
|
entity.cooldown = 0
|
||||||
|
entity.resist = spec.resist
|
||||||
|
entity.faction = spec.faction
|
||||||
|
return spec
|
||||||
|
end
|
||||||
|
|
||||||
|
return Polymorph
|
||||||
Reference in new issue
Block a user