feat: extend game with Game:nemesisFollow and Game:nemesisTryEmerge

This commit is contained in:
dualfroz committed 2026-09-06 18:54:54 +02:00
1 parent 3427a1a8f0
commit 7e20884d3d
1 file changed
+202 -2
+202 -2
View File
@@ -1729,30 +1729,229 @@ function Game:useItem(index)
return true
end
-- Removes the currently-live nemesis (nemesis.lua) from the floor about to
-- be left and escalates its grudge, so Game:nemesisArrive rebuilds it
-- stronger on the other side of the stairwell. Unlike every other hostile
-- (level.lua's own header: a revisited floor keeps its exact state, dead
-- monsters and all), a nemesis is never left behind - it always follows.
function Game:nemesisFollow()
for i, entity in ipairs(self.level.entities) do
if entity.nemesis then
table.remove(self.level.entities, i)
Nemesis.escalate(self.nemesis)
return
end
end
end
-- Rolls a brand new nemesis into being from whichever hostile was already
-- hunting the player on the floor just cleared. Only tried on the way down
-- (Game:descend) and only while no nemesis is following yet - ordinary
-- backtracking upstairs never manufactures one.
function Game:nemesisTryEmerge()
if not self.rng:chance(Nemesis.CHANCE_NUMERATOR, Nemesis.CHANCE_DENOMINATOR) then return end
local hunter = Nemesis.pickHunter(self.level, self.player)
if not hunter then return end
for i, entity in ipairs(self.level.entities) do
if entity == hunter then
table.remove(self.level.entities, i)
break
end
end
self.nemesis = Nemesis.beginFrom(hunter)
end
-- Drops the pursuing nemesis onto whichever floor Game:descend/ascend has
-- just switched self.level/self.depth to. A no-op without one following.
function Game:nemesisArrive()
if not self.nemesis then return end
local x, y = self:randomFloorCell()
if not x then return end
local entity = Nemesis.spawn(self.nemesis, self:nextMonsterId(), x, y, self.depth)
entity.awareness = Stealth.HUNTING
self.level.entities[#self.level.entities + 1] = entity
self.log:add(entity.name .. " has followed you")
end
local ALLY_CARRY_KINDS = { companion = true, hireling = true, thrall = true }
local ALLY_CARRY_DIRS = { { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 }, { 1, 1 }, { 1, -1 }, { -1, 1 }, { -1, -1 } }
-- Pulls every living player-aligned ally (companion/hireling/thrall) off
-- `fromLevel`'s own entities list, ready to be handed to
-- Game:plantCarriedAllies once self.level/self.player.x/y already point at
-- the destination. companions.lua/mercenaries.lua/reanimation.lua's own
-- module comments describe a bonded ally as persisting "across the whole
-- run" (or, for a hireling, demanding its wage "for as long as it lives") -
-- without this pair, every one of them was actually abandoned in place the
-- instant the player took a single flight of stairs or crossed a rift,
-- silently contradicting that promise. Mirrors Game:nemesisFollow's own
-- table.remove-while-scanning shape, just for up to three kinds at once
-- instead of one flagged entity.
function Game:detachCarriedAllies(fromLevel)
local carried = {}
for i = #fromLevel.entities, 1, -1 do
local entity = fromLevel.entities[i]
if ALLY_CARRY_KINDS[entity.kind] and entity:isAlive() then
table.remove(fromLevel.entities, i)
carried[#carried + 1] = entity
end
end
return carried
end
-- Re-plants entities Game:detachCarriedAllies pulled off the old floor onto
-- self.level, each on the first free cell around the player's new position -
-- the same 8-directional, first-free-cell convention Companions.summon/
-- Mercenaries.hire/Reanimation.raise already use to place a brand new one -
-- falling back to Game:randomFloorCell (the same primitive Game:nemesisArrive
-- uses) only if every adjacent cell is blocked, so an ally is never simply
-- lost for want of room next to the stairs.
function Game:plantCarriedAllies(carried)
for _, ally in ipairs(carried) do
local placed = false
for _, dir in ipairs(ALLY_CARRY_DIRS) do
local x, y = self.player.x + dir[1], self.player.y + dir[2]
if self.level.map:isWalkable(x, y) and not self.level:entityAt(x, y) then
ally.x, ally.y = x, y
placed = true
break
end
end
if not placed then
local x, y = self:randomFloorCell()
if x then ally.x, ally.y = x, y; placed = true end
end
if placed then
self.level.entities[#self.level.entities + 1] = ally
self.log:add("your " .. ally.name .. " follows you")
end
end
end
function Game:descend()
if self.rift then return false end
if self.player.x ~= self.level.downX or self.player.y ~= self.level.downY then return false end
if self.depth >= self.maxDepth then
self.log:add("the stairwell ends in solid rock here")
return false
end
if self.nemesis then self:nemesisFollow() else self:nemesisTryEmerge() end
local carriedAllies = self:detachCarriedAllies(self.level)
self.depth = self.depth + 1
self.level = self.dungeon:get(self.depth)
self.player.x, self.player.y = self.level.upX, self.level.upY
if self.depth > self.deepestDepth then self.deepestDepth = self.depth end
self.log:add("you descend to depth " .. self.depth)
self:nemesisArrive()
self:plantCarriedAllies(carriedAllies)
self:computeVisible()
return true
end
function Game:ascend()
if self.rift then return false end
if self.player.x ~= self.level.upX or self.player.y ~= self.level.upY then return false end
if self.depth <= 1 then
self.log:add("you climb toward the surface")
if self:hasAmulet() then
self.won = true
self.log:add("you escape the dungeon with the Amulet of Warren - you win!")
else
self.log:add("you climb toward the surface")
end
return true
end
if self.nemesis then self:nemesisFollow() end
local carriedAllies = self:detachCarriedAllies(self.level)
self.depth = self.depth - 1
self.level = self.dungeon:get(self.depth)
self.player.x, self.player.y = self.level.downX, self.level.downY
self.log:add("you climb to depth " .. self.depth)
self:nemesisArrive()
self:plantCarriedAllies(carriedAllies)
self:computeVisible()
return true
end
-- Crosses the rift the player is currently standing on (see branches.lua),
-- swapping the run's active level for that rift's own pocket floor. The
-- pocket floor is generated once per origin depth and cached on
-- self.riftLevels exactly like self.dungeon caches every main-track floor,
-- so leaving and coming back always finds it exactly as it was left, dead
-- monsters and all - and only the very first crossing of a given rift spends
-- the one carried shard the stabilization costs; every crossing after that,
-- in either direction, is free. Returns false without side effects when the
-- player is not standing on a rift, is already across one, or (on a first
-- crossing only) is not carrying a shard to spend.
function Game:enterRift()
if self.rift then return false end
local rift = self.level:riftAt(self.player.x, self.player.y)
if not rift then return false end
self.riftLevels = self.riftLevels or {}
local firstCrossing = self.riftLevels[self.depth] == nil
if firstCrossing then
local shardIndex = self:findInInventory(function(item) return item.kind == Items.RIFTSTONE end)
if not shardIndex then return false end
table.remove(self.player.inventory, shardIndex)
self.riftLevels[self.depth] = Branches.build(self.seed, self.depth, self.dungeon.options)
end
local carriedAllies = self:detachCarriedAllies(self.level)
self.riftReturn = { level = self.level, depth = self.depth, x = self.player.x, y = self.player.y }
self.level = self.riftLevels[self.depth]
self.rift = true
self.player.x, self.player.y = self.level.upX, self.level.upY
self.log:add(firstCrossing and "the shard crumbles to dust as you step through the rift"
or "you step through the stabilized rift once more")
self:plantCarriedAllies(carriedAllies)
self:computeVisible()
return true
end
-- Steps back out of a rift crossing at its own pocket floor's downX/downY
-- (Branches.build reuses an ordinary Builder.build floor's ordinary "stairs
-- down" pair as the one way back rather than inventing a second tile kind),
-- restoring the exact main-track level, depth and position Game:enterRift
-- saved before the crossing. Returns false without effect when the player is
-- not currently across a rift, or has not yet reached its own exit point.
function Game:exitRift()
if not self.rift then return false end
if self.player.x ~= self.level.downX or self.player.y ~= self.level.downY then return false end
local carriedAllies = self:detachCarriedAllies(self.level)
self.level = self.riftReturn.level
self.depth = self.riftReturn.depth
self.player.x, self.player.y = self.riftReturn.x, self.riftReturn.y
self.rift = false
self.riftReturn = nil
self.log:add("you step back through the rift, blinking in familiar torchlight")
self:plantCarriedAllies(carriedAllies)
self:computeVisible()
return true
end
-- Starts a Horde Arena trial for the run in progress: swaps the player's
-- current level for a fresh closed arena (see horde.lua's own header for
-- why this can bypass dungeon.lua/builder.lua entirely and still work with
-- every existing turn system unmodified) and spawns its first wave through
-- the very same Horde.tick that clears each wave after it. The dungeon
-- floor left behind is simply abandoned - descending is not something an
-- arena run does, only Game:endPlayerTurn's escalating waves. A bonded
-- companion/hireling/thrall is not abandoned along with it though: the same
-- detachCarriedAllies/plantCarriedAllies pair Game:descend/ascend/enterRift/
-- exitRift already use follows the player in here too, planted around the
-- arena's centre anchor (cx, cy) rather than left to die alone on a floor
-- the run can never return to - an ally bonded specifically to fight beside
-- the player has the most reason to stand with them in a survival trial.
function Game:startHorde()
local carriedAllies = self:detachCarriedAllies(self.level)
local map, cx, cy = Horde.buildArena(self.rng, Horde.WIDTH, Horde.HEIGHT)
self.level = Level.new({ depth = self.depth, generator = "horde", map = map })
self.player.x, self.player.y = cx, cy
self.horde = Horde.newState()
self:plantCarriedAllies(carriedAllies)
local line = Horde.tick(self)
if line then self.log:add(line) end
self:computeVisible()
end
-- Autopilot.
function Game:findInInventory(predicate)
@@ -1765,7 +1964,7 @@ end
function Game:adjacentMonster()
for _, dir in ipairs(MONSTER_DIRS) do
local monster = self.level:entityAt(self.player.x + dir[1], self.player.y + dir[2])
if monster then return monster end
if monster and not isPlayerAlly(monster) then return monster end
end
return nil
end
@@ -1775,6 +1974,7 @@ function Game:playerPathTo(tx, ty)
return Astar.find(self.level.map, self.player.x, self.player.y, tx, ty, function(x, y)
if x == tx and y == ty then return false end
if not self.level.map:isPassable(x, y, hasKey) then return true end
if self.level:blockAt(x, y) then return true end
return self.level:entityAt(x, y) ~= nil
end)
end