From dfc632e83afb3cfebedb4712b663b03c2e282db7 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 28 Aug 2026 12:25:00 +0200 Subject: [PATCH] feat(game): add autopilot, descent and save/load --- src/game.lua | 297 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 297 insertions(+) diff --git a/src/game.lua b/src/game.lua index 5d38774..3df0b76 100644 --- a/src/game.lua +++ b/src/game.lua @@ -273,4 +273,301 @@ function Game:triggerTrap(trap) end end +function Game:pickupHere() + for i = #self.level.floorItems, 1, -1 do + local ground = self.level.floorItems[i] + if ground.x == self.player.x and ground.y == self.player.y then + self.player.inventory[#self.player.inventory + 1] = ground.item + self.log:add("you pick up " .. Identify.label(self.identify, ground.item)) + table.remove(self.level.floorItems, i) + end + end +end + +function Game:movePlayer(dx, dy) + local nx, ny = self.player.x + dx, self.player.y + dy + local map = self.level.map + if Tiles.isClosedDoor(map:get(nx, ny)) then + return self:openDoor(nx, ny) + end + local target = self.level:entityAt(nx, ny) + if target then + self:playerAttack(target) + return true + end + if map:isWalkable(nx, ny) then + self.player.x, self.player.y = nx, ny + local trap = self.level:trapAt(nx, ny) + if trap and not trap.revealed then self:triggerTrap(trap) end + self:pickupHere() + return true + end + return false +end + +function Game:useItem(index) + local item = self.player.inventory[index] + if not item then return false end + if item.kind == Items.POTION or item.kind == Items.SCROLL then + self.log:addAll(Effects.apply(item.effect, self, self.player, item)) + Identify.reveal(self.identify, item) + table.remove(self.player.inventory, index) + elseif item.kind == Items.FOOD then + self.player.satiation = self.player.satiation + item.nutrition + self.log:add("you eat the " .. item.name) + table.remove(self.player.inventory, index) + elseif item.kind == Items.WEAPON or item.kind == Items.ARMOR or item.kind == Items.RING then + Items.useFromInventory(self.player, index) + self.log:add("you equip " .. Identify.label(self.identify, item)) + else + return false + end + return true +end + +function Game:descend() + if self.player.x ~= self.level.downX or self.player.y ~= self.level.downY then return false end + self.depth = self.depth + 1 + self.level = self.dungeon:get(self.depth) + self.player.x, self.player.y = self.level.upX, self.level.upY + self.log:add("you descend to depth " .. self.depth) + self:computeVisible() + return true +end + +function Game:ascend() + 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") + return true + end + 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:computeVisible() + return true +end + +-- Autopilot. + +function Game:findInInventory(predicate) + for i, item in ipairs(self.player.inventory) do + if predicate(item) then return i end + end + return nil +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 + end + return nil +end + +function Game:playerPathTo(tx, ty) + local hasKey = self:hasKey() + 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 + return self.level:entityAt(x, y) ~= nil + end) +end + +-- First step of the shortest route to the nearest monster the player can +-- actually reach, so blockers standing between the player and the stairs get +-- hunted down. Ties break on ascending monster id for determinism. +function Game:reachableMonsterStep() + local bestLen, bestId, step = math.huge, math.huge, nil + for _, monster in ipairs(self.level.entities) do + local path = self:playerPathTo(monster.x, monster.y) + if path and #path >= 2 then + if #path < bestLen or (#path == bestLen and monster.id < bestId) then + bestLen, bestId, step = #path, monster.id, path[2] + end + end + end + return step +end + +-- First step toward the nearest not-yet-explored walkable cell via a BFS that +-- routes around monsters. Lets the autopilot discover a way onward when the +-- direct path to the stairs is temporarily blocked. +function Game:exploreStep() + local map = self.level.map + local hasKey = self:hasKey() + local start = map:index(self.player.x, self.player.y) + local came = { [start] = start } + local queue = { { self.player.x, self.player.y, start } } + local head = 1 + while head <= #queue do + local cell = queue[head] + head = head + 1 + local cx, cy, idx = cell[1], cell[2], cell[3] + if idx ~= start and not self.level.explored[idx] and map:isWalkable(cx, cy) then + local cursor = idx + while came[cursor] ~= start do + cursor = came[cursor] + end + local stepX = (cursor - 1) % map.width + 1 + local stepY = (cursor - 1) // map.width + 1 + return sign(stepX - self.player.x), sign(stepY - self.player.y) + end + for _, dir in ipairs(MONSTER_DIRS) do + local nx, ny = cx + dir[1], cy + dir[2] + if map:inBounds(nx, ny) and map:isPassable(nx, ny, hasKey) + and not self.level:entityAt(nx, ny) then + local nIdx = map:index(nx, ny) + if not came[nIdx] then + came[nIdx] = idx + queue[#queue + 1] = { nx, ny, nIdx } + end + end + end + end + return nil +end + +function Game:anyMonsterVisible() + for _, monster in ipairs(self.level.entities) do + if self.visible[self.level.map:index(monster.x, monster.y)] then return true end + end + return false +end + +-- Inventory index of the single best equipment upgrade to put on this turn, or +-- nil. Weapons and armour compare on power, rings on attack plus defence. +function Game:bestUpgrade() + local player = self.player + local weaponPower = player.weapon and player.weapon.power or 0 + local armorPower = player.armor and player.armor.power or 0 + local ringValue = player.ring and (player.ring.attack + player.ring.defense) or 0 + for i, item in ipairs(player.inventory) do + if item.kind == Items.WEAPON and item.power > weaponPower then return i end + end + for i, item in ipairs(player.inventory) do + if item.kind == Items.ARMOR and item.power > armorPower then return i end + end + for i, item in ipairs(player.inventory) do + if item.kind == Items.RING and (item.attack + item.defense) > ringValue then return i end + end + return nil +end + +function Game:choosePlayerAction() + local player = self.player + if not self:anyMonsterVisible() then + local upgrade = self:bestUpgrade() + if upgrade then self:useItem(upgrade); return end + end + if player.hp < player.maxHp // 3 then + local potion = self:findInInventory(function(item) + return item.kind == Items.POTION and item.effect == "heal" + end) + if potion then self:useItem(potion); return end + end + if player.satiation < 150 then + local food = self:findInInventory(function(item) return item.kind == Items.FOOD end) + if food then self:useItem(food); return end + end + local target = self:adjacentMonster() + if target then + self:movePlayer(sign(target.x - player.x), sign(target.y - player.y)) + return + end + if player.x == self.level.downX and player.y == self.level.downY then + self:descend() + return + end + local path = self:playerPathTo(self.level.downX, self.level.downY) + if path and #path >= 2 then + self:movePlayer(path[2].x - player.x, path[2].y - player.y) + return + end + local hunt = self:reachableMonsterStep() + if hunt then + self:movePlayer(hunt.x - player.x, hunt.y - player.y) + return + end + local ex, ey = self:exploreStep() + if ex then self:movePlayer(ex, ey) end +end + +function Game:hungerTorchTick() + self.player.satiation = self.player.satiation - 1 + if self.player.satiation <= 0 then + self.player.satiation = 0 + self.player.hp = self.player.hp - 1 + self.log:add("you are starving") + end + if self.player.light.fuel > 0 then + self.player.light.fuel = self.player.light.fuel - 1 + end +end + +function Game:endPlayerTurn() + Scheduler.spend(self.player) + self:hungerTorchTick() + self.turn = self.turn + 1 + self:computeVisible() +end + +function Game:autoStep() + self:runToPlayer() + if not self.player:isAlive() then return end + self.log:addAll(Status.tick(self.player)) + if not self.player:isAlive() then return end + if Status.skipsTurn(self.player) then + self.log:add("you are stunned") + else + self:choosePlayerAction() + end + self:endPlayerTurn() +end + +function Game:render() + return Render.frame(self) +end + +function Game:toData() + return { + seed = self.seed, + depth = self.depth, + turn = self.turn, + rng = self.rng:serialize(), + identify = self.identify, + dungeon = self.dungeon:toData(), + player = self.player:toData(), + playerExtra = { satiation = self.player.satiation, light = self.player.light }, + log = self.log:toData(), + } +end + +function Game.fromData(snapshot) + local game = setmetatable({}, Game) + game.seed = snapshot.seed + game.depth = snapshot.depth + game.turn = snapshot.turn + game.rng = Rng.deserialize(snapshot.rng) + game.identify = snapshot.identify + game.dungeon = Dungeon.fromData(snapshot.dungeon) + game.level = game.dungeon:get(game.depth) + game.player = Entity.fromData(snapshot.player) + game.player.satiation = snapshot.playerExtra.satiation + game.player.light = snapshot.playerExtra.light + game.log = MessageLog.fromData(snapshot.log) + game.visible = {} + game:computeVisible() + return game +end + +function Game:save(path) + Serialize.saveFile(path, self:toData()) +end + +function Game.load(path) + return Game.fromData(Serialize.loadFile(path)) +end + return Game