diff --git a/src/nemesis.lua b/src/nemesis.lua new file mode 100644 index 0000000..77950b3 --- /dev/null +++ b/src/nemesis.lua @@ -0,0 +1,155 @@ +-- A cross-floor hunter. Every other hostile in the bestiary is confined to +-- the one floor builder.lua drops it on (level.lua's own header: "revisiting +-- one restores its exact state, dead monsters and all") - a nemesis is the +-- single exception. Any ordinary monster actually hunting the player the +-- moment they take a stairwell has a chance to follow them through it +-- instead of being left behind; from then on it keeps following, growing +-- stronger each trip, until the player either kills it or dies first. +-- +-- Unlike elites.lua's affixed variant (rolled stronger the instant builder.lua +-- places it) or bosses.lua's fixed per-floor set piece, a nemesis is born +-- mid-run from a plain wild monster and its escalation is entirely +-- player-earned: it does not exist at all until a hunt actually happens, and +-- it only ever grows because the player failed to finish it off before +-- reaching the stairs. Bosses, mimics, graves, elites and the shopkeeper are +-- all excluded from ever becoming one - each already has its own dedicated +-- escalation path, and letting them double up would just be two systems +-- fighting over the same monster. + +local Monsters = require("monsters") +local Stealth = require("stealth") + +local Nemesis = {} + +-- Odds a hunting monster actually gives chase down the stairs, rolled once +-- per descent (Game:nemesisTryEmerge) only while no nemesis is following yet. +Nemesis.CHANCE_NUMERATOR = 1 +Nemesis.CHANCE_DENOMINATOR = 5 + +Nemesis.MAX_GRUDGE = 5 +Nemesis.HP_BONUS_PER_GRUDGE = 4 +Nemesis.ATTACK_BONUS_PER_GRUDGE = 1 + +-- How much of its lost hp a nemesis recovers between the floor it last fought +-- on and the one it just followed the player onto - capped at full, so a +-- nemesis engaged over and over never shows up weaker than this floor alone +-- would explain, but also never becomes unkillable simply by being ignored. +Nemesis.HEAL_FRACTION_PER_FOLLOW = 0.2 + +-- One epithet per grudge tier, read by index and clamped to the last entry +-- once grudge outgrows the list - the name keeps escalating in flavour even +-- after Nemesis.MAX_GRUDGE stops raising its stats any further. +Nemesis.EPITHETS = { + "the Wounded", "the Relentless", "the Scarred", "the Undying", "the Dreaded", +} + +-- ai tags eligible to actually give chase: a permanent "flee" species (the +-- cave wretch, the goblin shaman, the necromancer) breaks off the instant it +-- is adjacent so it would never follow anyone down a stairwell, and the +-- shopkeeper's "vend" idles unless provoked into a plain chase of its own - +-- by then it is already just a chaser under a different tag, still excluded +-- here because Nemesis.isEligible also checks awareness below. +Nemesis.ELIGIBLE_AI = { chase = true, pack = true, ranged = true, erratic = true, tunnel = true } + +-- Whether `monster` could give chase this descent: alive, a plain hostile +-- under its own self-directed AI, already actively hunting the player (not +-- dormant, not searching, not merely wandering into the stairwell by +-- coincidence), and not already spoken for by another escalation system. +function Nemesis.isEligible(monster) + return monster:isAlive() + and monster.kind == "monster" + and Nemesis.ELIGIBLE_AI[monster.ai] == true + and monster.awareness == Stealth.HUNTING + and not monster.isBoss + and not monster.mimic + and not monster.grave + and not monster.elite + and not monster.nemesis +end + +local function chebyshev(ax, ay, bx, by) + return math.max(math.abs(ax - bx), math.abs(ay - by)) +end + +-- The eligible hunter standing closest to the player, ties broken by +-- level.entities order - deterministic without spending any rng draw of its +-- own, since only whether a nemesis emerges at all needs to be a coin flip. +function Nemesis.pickHunter(level, player) + local best, bestDist + for _, monster in ipairs(level.entities) do + if Nemesis.isEligible(monster) then + local dist = chebyshev(monster.x, monster.y, player.x, player.y) + if not best or dist < bestDist then + best, bestDist = monster, dist + end + end + end + return best +end + +-- The persistent descriptor Game keeps between floors (see game.nemesis) - +-- not a live entity, just enough to rebuild one, exactly like graves.lua +-- stores a fallen player's own remnants instead of keeping a live shade +-- around between runs. +function Nemesis.beginFrom(monster) + return { + species = monster.species, + grudge = 1, + hpFraction = monster.hp / monster.maxHp, + } +end + +-- Called every time the nemesis follows the player through a stairwell +-- without having been killed first: its grudge climbs (capped) and it stews +-- back toward full health (also capped), so each escape makes the next +-- encounter both tougher and a little less finished business than before. +function Nemesis.escalate(pursuit) + pursuit.grudge = math.min(Nemesis.MAX_GRUDGE, pursuit.grudge + 1) + pursuit.hpFraction = math.min(1, pursuit.hpFraction + Nemesis.HEAL_FRACTION_PER_FOLLOW) + return pursuit +end + +function Nemesis.bonusHp(grudge) + return grudge * Nemesis.HP_BONUS_PER_GRUDGE +end + +function Nemesis.bonusAttack(grudge) + return grudge * Nemesis.ATTACK_BONUS_PER_GRUDGE +end + +-- Flat xp on top of the species' own plain reward (Monsters.xpFor already +-- applies, since Nemesis.spawn stamps a real species key onto the entity) - +-- mirrors elites.lua's xpBonus riding on top of the same base reward. +function Nemesis.xpBonus(grudge) + return 10 + grudge * 5 +end + +-- Gold the nemesis's hoard finally pays out once it is actually put down, +-- credited alongside (not instead of) the ordinary awardKill flow. +function Nemesis.bountyGold(grudge) + return 10 * grudge +end + +function Nemesis.nameFor(baseName, grudge) + local index = math.min(grudge, #Nemesis.EPITHETS) + return baseName .. ", " .. Nemesis.EPITHETS[index] +end + +-- Rebuilds a live entity from `pursuit` at (x, y) on the floor just reached, +-- depth-scaled exactly like any other freshly created monster of the same +-- species plus its own escalated bonuses. hpFraction survives the trip so a +-- nemesis last seen half-dead still shows up half-dead here, healed only by +-- however much Nemesis.escalate already restored between floors. +function Nemesis.spawn(pursuit, id, x, y, depth) + local spec = Monsters.byKey[pursuit.species] + local entity = Monsters.create(spec, id, x, y, depth) + entity.maxHp = entity.maxHp + Nemesis.bonusHp(pursuit.grudge) + entity.hp = math.max(1, math.floor(entity.maxHp * pursuit.hpFraction)) + entity.attack = entity.attack + Nemesis.bonusAttack(pursuit.grudge) + entity.nemesis = true + entity.nemesisGrudge = pursuit.grudge + entity.name = Nemesis.nameFor(spec.name, pursuit.grudge) + return entity +end + +return Nemesis