feat: add Nemesis.isEligible and chebyshev

This commit is contained in:
dualfroz committed 2026-09-07 15:12:32 +02:00
1 parent 9eec4709bf
commit dfa4ef9bbc
1 file changed
+155
+155
View File
@@ -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