From 7e20884d3d5561856c32bb9747faccab4e051a36 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 6 Sep 2026 18:54:54 +0200 Subject: [PATCH] feat: extend game with Game:nemesisFollow and Game:nemesisTryEmerge --- src/game.lua | 204 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 202 insertions(+), 2 deletions(-) diff --git a/src/game.lua b/src/game.lua index 7cbca5b..72c9868 100644 --- a/src/game.lua +++ b/src/game.lua @@ -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