feat: extend game with Game:nemesisFollow and Game:nemesisTryEmerge
This commit is contained in:
1 parent
3427a1a8f0
commit
7e20884d3d
1 file changed
+202
-2
+202
-2
@@ -1729,30 +1729,229 @@ function Game:useItem(index)
|
|||||||
return true
|
return true
|
||||||
end
|
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()
|
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.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.depth = self.depth + 1
|
||||||
self.level = self.dungeon:get(self.depth)
|
self.level = self.dungeon:get(self.depth)
|
||||||
self.player.x, self.player.y = self.level.upX, self.level.upY
|
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.log:add("you descend to depth " .. self.depth)
|
||||||
|
self:nemesisArrive()
|
||||||
|
self:plantCarriedAllies(carriedAllies)
|
||||||
self:computeVisible()
|
self:computeVisible()
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
function Game:ascend()
|
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.player.x ~= self.level.upX or self.player.y ~= self.level.upY then return false end
|
||||||
if self.depth <= 1 then
|
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
|
return true
|
||||||
end
|
end
|
||||||
|
if self.nemesis then self:nemesisFollow() end
|
||||||
|
local carriedAllies = self:detachCarriedAllies(self.level)
|
||||||
self.depth = self.depth - 1
|
self.depth = self.depth - 1
|
||||||
self.level = self.dungeon:get(self.depth)
|
self.level = self.dungeon:get(self.depth)
|
||||||
self.player.x, self.player.y = self.level.downX, self.level.downY
|
self.player.x, self.player.y = self.level.downX, self.level.downY
|
||||||
self.log:add("you climb to depth " .. self.depth)
|
self.log:add("you climb to depth " .. self.depth)
|
||||||
|
self:nemesisArrive()
|
||||||
|
self:plantCarriedAllies(carriedAllies)
|
||||||
self:computeVisible()
|
self:computeVisible()
|
||||||
return true
|
return true
|
||||||
end
|
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.
|
-- Autopilot.
|
||||||
|
|
||||||
function Game:findInInventory(predicate)
|
function Game:findInInventory(predicate)
|
||||||
@@ -1765,7 +1964,7 @@ end
|
|||||||
function Game:adjacentMonster()
|
function Game:adjacentMonster()
|
||||||
for _, dir in ipairs(MONSTER_DIRS) do
|
for _, dir in ipairs(MONSTER_DIRS) do
|
||||||
local monster = self.level:entityAt(self.player.x + dir[1], self.player.y + dir[2])
|
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
|
end
|
||||||
return nil
|
return nil
|
||||||
end
|
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)
|
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 x == tx and y == ty then return false end
|
||||||
if not self.level.map:isPassable(x, y, hasKey) then return true 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
|
return self.level:entityAt(x, y) ~= nil
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in new issue
Block a user