diff --git a/src/phases.lua b/src/phases.lua new file mode 100644 index 0000000..9508800 --- /dev/null +++ b/src/phases.lua @@ -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 diff --git a/src/polymorph.lua b/src/polymorph.lua new file mode 100644 index 0000000..3691f14 --- /dev/null +++ b/src/polymorph.lua @@ -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