feat: extend game with Game:nemesisFollow and Game:nemesisTryEmerge
This commit is contained in:
1 parent
3427a1a8f0
commit
7e20884d3d
1 file changed
+201
-1
+201
-1
@@ -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
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user