feat(game): add autopilot, descent and save/load

This commit is contained in:
dualfroz committed 2026-08-28 12:25:00 +02:00
1 parent 539d8bf4f5
commit dfc632e83a
1 file changed
+297
+297
View File
@@ -273,4 +273,301 @@ function Game:triggerTrap(trap)
end end
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 return Game