From 928f1e6a9edc50afa166853e4a5c309e2604b521 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 6 Sep 2026 14:31:40 +0200 Subject: [PATCH] feat: extend game with Game:checkTrapFor and Game:fallIntoChasm --- src/game.lua | 176 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 173 insertions(+), 3 deletions(-) diff --git a/src/game.lua b/src/game.lua index a78b980..39c371c 100644 --- a/src/game.lua +++ b/src/game.lua @@ -467,6 +467,81 @@ local function blockedForMonster(game, self, goalX, goalY) 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) local path = Astar.find(self.level.map, monster.x, monster.y, tx, ty, blockedForMonster(self, monster, tx, ty)) @@ -474,7 +549,10 @@ function Game:stepMonsterToward(monster, tx, ty) local step = path[2] 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 + local dx, dy = step.x - monster.x, step.y - monster.y monster.x, monster.y = step.x, step.y + self:checkTrapFor(monster) + self:checkTerrainFor(monster, dx, dy) end end end @@ -494,15 +572,89 @@ function Game:stepMonsterAway(monster, tx, ty) 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 +-- 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) local nx, ny = monster.x + dx, monster.y + dy if nx == self.player.x and ny == self.player.y then 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 + 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 @@ -519,10 +671,28 @@ function Game:nearestAlly(monster) return ally 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) self.log:addAll(Status.tick(monster)) if not monster:isAlive() then - self:cleanupDead() + self:onActorDeath(monster) return end if not Status.skipsTurn(monster) then