feat: extend game with Game:checkTrapFor and Game:fallIntoChasm

This commit is contained in:
dualfroz committed 2026-09-06 14:31:40 +02:00
1 parent d04add4a7b
commit 928f1e6a9e
1 file changed
+173 -3
+173 -3
View File
@@ -467,6 +467,81 @@ local function blockedForMonster(game, self, goalX, goalY)
end end
end end
-- Springs whatever untouched trap sits under `entity` after it has just
-- moved there - a player-planted trap kit harms a monster or companion
-- exactly as a builder-placed one harms the player, since both live in the
-- same level.traps list and share springTrap's resolution. A levitating
-- entity (Status.LEVITATE) drifts clear of the pressure plate entirely -
-- deliberately silent and without marking the trap revealed, unlike the
-- lava/chasm skip in checkTerrainFor: a hidden trap is information the
-- player never earned just by floating over it, so levitation buys safety
-- without also buying detection.
function Game:checkTrapFor(entity)
if Status.has(entity, Status.LEVITATE) then return end
local trap = self.level:trapAt(entity.x, entity.y)
if trap and not trap.revealed then
self:springTrap(trap, entity)
if not entity:isAlive() then self:onActorDeath(entity) end
end
end
-- A pit with no floor below it: the player falls to the next depth and
-- takes fall damage, a monster that blunders in is simply gone. Builder
-- never seeds a chasm on the final floor, so there is always a level below
-- to land on.
function Game:fallIntoChasm(entity)
if entity == self.player then
local dealt = 6 + self.rng:range(0, 6)
self.player.hp = self.player.hp - dealt
self.log:add(string.format("the floor gives way - you plunge into the dark for %d", dealt))
if not self.player:isAlive() then return end
self.depth = self.depth + 1
self.level = self.dungeon:get(self.depth)
local x, y = self:randomFloorCell()
if not x then x, y = self.level.upX, self.level.upY end
self.player.x, self.player.y = x, y
if self.depth > self.deepestDepth then self.deepestDepth = self.depth end
self:computeVisible()
else
entity.hp = 0
self.log:add(string.format("%s plunges into the chasm and is gone", entity.name))
self:onActorDeath(entity)
end
end
-- Runs after entity has just come to rest on (dx, dy) from its previous
-- cell: ice keeps sliding it onward, lava burns it in place, and a chasm
-- ends the move altogether. Order matters - a slide can carry a traveller
-- straight off the ice and into whatever waits beyond it. A levitating
-- entity (Status.LEVITATE) never touches the ground at all, so it short
-- circuits every branch below - no slide, no burn, no fall - though it
-- still gets a log line when that skip actually mattered (standing over
-- lava or a chasm), since both are visible on the map and worth calling out.
function Game:checkTerrainFor(entity, dx, dy)
if not entity:isAlive() then return end
if Status.has(entity, Status.LEVITATE) then
if self.level.map:get(entity.x, entity.y) == Tiles.LAVA or Terrain.isChasm(self.level, entity) then
self.log:add(entity == self.player and "you drift safely over the hazard below"
or string.format("%s drifts safely over the hazard below", entity.name))
end
return
end
if self.level.map:get(entity.x, entity.y) == Tiles.ICE then
Terrain.slide(self.level, entity, dx, dy)
end
local line = Terrain.afflictLava(self.level, entity)
if line then
self.log:add(line)
if not entity:isAlive() then
if entity ~= self.player then self:onActorDeath(entity) end
return
end
end
if Terrain.isChasm(self.level, entity) then
self:fallIntoChasm(entity)
end
end
function Game:stepMonsterToward(monster, tx, ty) function Game:stepMonsterToward(monster, tx, ty)
local path = Astar.find(self.level.map, monster.x, monster.y, tx, ty, local path = Astar.find(self.level.map, monster.x, monster.y, tx, ty,
blockedForMonster(self, monster, tx, ty)) blockedForMonster(self, monster, tx, ty))
@@ -474,7 +549,10 @@ function Game:stepMonsterToward(monster, tx, ty)
local step = path[2] local step = path[2]
if self.level.map:isWalkable(step.x, step.y) and not self.level:entityAt(step.x, step.y) if self.level.map:isWalkable(step.x, step.y) and not self.level:entityAt(step.x, step.y)
and not (step.x == self.player.x and step.y == self.player.y) then and not (step.x == self.player.x and step.y == self.player.y) then
local dx, dy = step.x - monster.x, step.y - monster.y
monster.x, monster.y = step.x, step.y monster.x, monster.y = step.x, step.y
self:checkTrapFor(monster)
self:checkTerrainFor(monster, dx, dy)
end end
end end
end end
@@ -494,15 +572,89 @@ function Game:stepMonsterAway(monster, tx, ty)
end end
end end
end end
if bx then monster.x, monster.y = bx, by end if bx then
local dx, dy = bx - monster.x, by - monster.y
monster.x, monster.y = bx, by
self:checkTrapFor(monster)
self:checkTerrainFor(monster, dx, dy)
end
end end
-- An erratic wanderer's random step or a confused chaser's overridden one
-- (Ai.act, both routed here) can land on an ally's cell exactly as easily as
-- the player's - Infighting.strike resolves that the same generic way
-- Game:adjacentAlly's own callers do, rather than the bump silently doing
-- nothing the way it would against an impassable wall.
function Game:moveMonster(monster, dx, dy) function Game:moveMonster(monster, dx, dy)
local nx, ny = monster.x + dx, monster.y + dy local nx, ny = monster.x + dx, monster.y + dy
if nx == self.player.x and ny == self.player.y then if nx == self.player.x and ny == self.player.y then
self:monsterMelee(monster) self:monsterMelee(monster)
elseif self.level.map:isWalkable(nx, ny) and not self.level:entityAt(nx, ny) then return
end
local occupant = self.level:entityAt(nx, ny)
if occupant and (occupant.kind == "companion" or occupant.kind == "hireling" or occupant.kind == "thrall") then
Infighting.strike(self, monster, occupant)
return
end
if self.level.map:isWalkable(nx, ny) and not occupant then
monster.x, monster.y = nx, ny monster.x, monster.y = nx, ny
self:checkTrapFor(monster)
self:checkTerrainFor(monster, dx, dy)
end
end
-- Mirrors ai.lua's own Ai.act confusion stumble for the three player-aligned
-- actor kinds (companion/hireling/thrall). None of those ever dispatch
-- through Ai.act, so without this they would leave Status.CONFUSE completely
-- inert on them despite Game:springTrap and friends already applying it to
-- "any victim" regardless of kind. Returns true whenever the roll actually
-- fires (whether or not the stumble itself finds anywhere to go), so each
-- caller can `if game:allyActsConfused(ally) then return end` first thing,
-- exactly the precedence Ai.act gives the same roll over chase/pack/etc.
-- Unlike a confused monster's stumble (moveMonster above, which treats a
-- step onto the player's cell as an attack), a confused ally's stumble onto
-- its own master's cell is harmless - an ally never attacks the player it is
-- bonded or hired to protect, so that one direction is simply blocked like a
-- wall rather than routed into any melee helper.
function Game:allyActsConfused(ally)
if not Status.has(ally, Status.CONFUSE) then return false end
if not self.rng:chance(Status.CONFUSE_NUMERATOR, Status.CONFUSE_DENOMINATOR) then return false end
local dir = MONSTER_DIRS[self.rng:range(1, #MONSTER_DIRS)]
local nx, ny = ally.x + dir[1], ally.y + dir[2]
if not (nx == self.player.x and ny == self.player.y)
and self.level.map:isWalkable(nx, ny) and not self.level:entityAt(nx, ny) then
ally.x, ally.y = nx, ny
self:checkTrapFor(ally)
self:checkTerrainFor(ally, dir[1], dir[2])
end
return true
end
-- A burrower's turn when it can neither reach nor see the player through
-- open floor: senses the player's general direction through solid rock out
-- to Digging.SENSE_RANGE away and, of the two candidate cells toward them
-- (longer axis first - see Digging.directionsToward), either claws one
-- swing into the first one that is still diggable, exactly like Game:dig
-- but toolless and self-directed, or - if open floor already leads that
-- way - simply steps there so it closes the distance before it ever needs
-- to swing at all. Idles once out of range or once neither candidate cell
-- is diggable nor walkable, the same way "flee" idles once it loses sight.
function Game:monsterDig(monster, tx, ty)
local cheb = math.max(math.abs(monster.x - tx), math.abs(monster.y - ty))
if cheb == 0 or cheb > Digging.SENSE_RANGE then return end
for _, dir in ipairs(Digging.directionsToward(monster.x, monster.y, tx, ty)) do
local nx, ny = monster.x + dir[1], monster.y + dir[2]
if Digging.canDig(self.level.map, nx, ny) then
self.log:add(Digging.swing(self, monster, nx, ny))
if not monster:isAlive() then self:onActorDeath(monster) end
return
elseif self.level.map:isWalkable(nx, ny) and not self.level:entityAt(nx, ny)
and not (nx == self.player.x and ny == self.player.y) then
monster.x, monster.y = nx, ny
self:checkTrapFor(monster)
self:checkTerrainFor(monster, dir[1], dir[2])
return
end
end end
end end
@@ -519,10 +671,28 @@ function Game:nearestAlly(monster)
return ally return ally
end end
-- A monster's death sweeps xp and essence to the player; a companion fights
-- for the player rather than yielding a reward for dying, so its death is
-- only logged and swept.
function Game:onActorDeath(actor)
if actor.kind == "companion" then
self.log:add("your " .. actor.name .. " falls")
self:cleanupDead()
elseif actor.kind == "hireling" then
self.log:add("your " .. actor.name .. " falls")
self:cleanupDead()
elseif actor.kind == "thrall" then
self.log:add("your " .. actor.name .. " crumbles back to bones")
self:cleanupDead()
else
self:awardKill(actor)
end
end
function Game:takeMonsterTurn(monster) function Game:takeMonsterTurn(monster)
self.log:addAll(Status.tick(monster)) self.log:addAll(Status.tick(monster))
if not monster:isAlive() then if not monster:isAlive() then
self:cleanupDead() self:onActorDeath(monster)
return return
end end
if not Status.skipsTurn(monster) then if not Status.skipsTurn(monster) then