From f4c8fcd5fce20688e9f63fbd699b710a50b091d4 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 4 Sep 2026 20:33:35 +0200 Subject: [PATCH] feat: add applyAffix and Elites.eligible --- src/elites.lua | 162 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 src/elites.lua diff --git a/src/elites.lua b/src/elites.lua new file mode 100644 index 0000000..7e67ab3 --- /dev/null +++ b/src/elites.lua @@ -0,0 +1,162 @@ +-- Elite monsters: rare, affixed variants of the ordinary bestiary. Builder +-- rolls promotion once a floor's entities are already fully placed (appended +-- dead last in Builder.build, after every other placement - artifacts, the +-- boss, the quest board - so it can never perturb an earlier system's rng +-- sequence, exactly like those do for their own rolls). An elite still rides +-- the same ai.lua/abilities.lua dispatch and scheduler as any other creature; +-- only its stats, its name, and the on-hit/on-death/lifesteal hooks Game +-- wires in separately set it apart from a plain spawn of the same species. +-- A floor deep enough for a second affix ("champion") draws it from the +-- remaining pool, so a champion never doubles up on the same one. + +local Resistances = require("resistances") + +local Elites = {} + +Elites.MIN_DEPTH = 3 +Elites.CHAMPION_DEPTH = 7 + +Elites.affixes = { + { key = "brutal", name = "brutal", hpMult = 1.2, attack = 3 }, + { key = "armored", name = "armored", hpMult = 1.3, defense = 3 }, + { key = "swift", name = "swift", speed = 30 }, + { key = "regenerating", name = "regenerating", regen = 3 }, + { key = "warded", name = "warded", resist = Resistances.RESIST }, + { key = "vampiric", name = "vampiric", lifesteal = 0.5 }, + { key = "chilling", name = "chilling", onHit = { status = "slow", duration = 3, potency = 1 } }, + { key = "volatile", name = "volatile", onDeathDamage = 8, onDeathType = Resistances.FIRE }, +} + +Elites.byKey = {} +for _, affix in ipairs(Elites.affixes) do + Elites.byKey[affix.key] = affix +end + +-- Elemental types a "warded" roll can pick from - physical is excluded since +-- every hit already respects the plain attack/defense floor in combat.lua. +local RESISTABLE = { + Resistances.FIRE, Resistances.COLD, Resistances.POISON, + Resistances.LIGHTNING, Resistances.ARCANE, +} + +local function applyAffix(entity, affix, rng) + entity.eliteAffixes[#entity.eliteAffixes + 1] = affix.key + if affix.hpMult then + local bonus = math.floor(entity.maxHp * (affix.hpMult - 1)) + entity.maxHp = entity.maxHp + bonus + entity.hp = entity.hp + bonus + end + if affix.attack then + entity.attack = entity.attack + affix.attack + entity.baseAttack = entity.baseAttack + affix.attack + end + if affix.defense then + entity.defense = entity.defense + affix.defense + entity.baseDefense = entity.baseDefense + affix.defense + end + if affix.speed then entity.speed = entity.speed + affix.speed end + if affix.regen then entity.regen = (entity.regen or 0) + affix.regen end + if affix.resist then + local element = RESISTABLE[rng:range(1, #RESISTABLE)] + entity.resist = entity.resist or {} + if entity.resist[element] ~= Resistances.IMMUNE then + entity.resist[element] = affix.resist + end + end + entity.name = affix.name .. " " .. entity.name +end + +-- Eligible for promotion: a plain bestiary spawn, never a boss (already the +-- rarer, sturdier variant) or the passive shopkeeper. +function Elites.eligible(entity) + return entity.kind == "monster" and not entity.isBoss and entity.ai ~= "vend" +end + +-- Rolled once per eligible monster after a floor's entities are already +-- placed. Mutates `entity` in place; a no-op below Elites.MIN_DEPTH, on an +-- ineligible entity, or when the roll simply misses. +function Elites.maybePromote(rng, entity, depth) + if depth < Elites.MIN_DEPTH or not Elites.eligible(entity) then return end + if not rng:chance(1, 6) then return end + entity.elite = true + entity.eliteAffixes = {} + local pool = {} + for i, affix in ipairs(Elites.affixes) do pool[i] = affix end + local rolls = 1 + if depth >= Elites.CHAMPION_DEPTH and rng:chance(1, 3) then rolls = 2 end + for _ = 1, rolls do + if #pool == 0 then break end + local idx = rng:range(1, #pool) + applyAffix(entity, pool[idx], rng) + table.remove(pool, idx) + end + entity.eliteTier = #entity.eliteAffixes >= 2 and "champion" or "elite" +end + +-- The first volatile affix on `entity`, if any - the death burst +-- Game:awardKill triggers right after Abilities.onDeath. +function Elites.explosionFor(entity) + if not entity.eliteAffixes then return nil end + for _, key in ipairs(entity.eliteAffixes) do + local affix = Elites.byKey[key] + if affix and affix.onDeathDamage then return affix end + end + return nil +end + +-- The status rider (if any) a hit landed by `entity` should apply to its +-- victim - same shape as a weapon affix's onHit (see affixes.lua), so Game +-- can hand it straight to Status.apply. +function Elites.onHitRider(entity) + if not entity.eliteAffixes then return nil end + for _, key in ipairs(entity.eliteAffixes) do + local affix = Elites.byKey[key] + if affix and affix.onHit then return affix.onHit end + end + return nil +end + +-- Fraction of dealt damage `entity` heals back on a hit it lands, summed +-- across its affixes (never more than one vampiric roll in practice). +function Elites.lifestealFraction(entity) + if not entity.eliteAffixes then return 0 end + local total = 0 + for _, key in ipairs(entity.eliteAffixes) do + local affix = Elites.byKey[key] + if affix and affix.lifesteal then total = total + affix.lifesteal end + end + return total +end + +-- Bonus xp a felled elite is worth on top of its plain species reward: half +-- again for a single-affix elite, a full second helping for a champion. +function Elites.xpBonus(entity, baseXp) + if not entity.elite then return 0 end + local mult = entity.eliteTier == "champion" and 1.0 or 0.5 + return math.floor(baseXp * mult) +end + +-- Fired from Game:awardKill right after Abilities.onDeath: a volatile +-- elite's corpse bursts, scorching the player and any companion or monster +-- adjacent to it (itself excluded) for its cataloged damage and type. +function Elites.onDeath(game, monster) + local affix = Elites.explosionFor(monster) + if not affix then return nil end + 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 + local dealt = Resistances.apply(victim, affix.onDeathDamage, affix.onDeathType) + if dealt > 0 then + victim.hp = victim.hp - dealt + hit[#hit + 1] = string.format("%s for %d", victim.name, dealt) + end + end + end + if #hit == 0 then return nil end + return string.format("%s bursts, scorching %s", monster.name, table.concat(hit, ", ")) +end + +return Elites