feat(game): add autopilot, descent and save/load
This commit is contained in:
1 parent
539d8bf4f5
commit
dfc632e83a
1 file changed
+297
+297
@@ -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
|
||||||
Reference in new issue
Block a user