feat(game): add world state, visibility and combat turns
This commit is contained in:
1 parent
6080324ec2
commit
539d8bf4f5
1 file changed
+276
+276
@@ -0,0 +1,276 @@
|
|||||||
|
-- Turn orchestration built on an energy scheduler: the world advances actor by
|
||||||
|
-- actor until the player is due, the player acts, and the cycle repeats. Owns
|
||||||
|
-- the dungeon, the player, identification and the message log, and drives the
|
||||||
|
-- headless autopilot that descends the dungeon deterministically.
|
||||||
|
|
||||||
|
local Rng = require("rng")
|
||||||
|
local Tiles = require("tiles")
|
||||||
|
local Astar = require("astar")
|
||||||
|
local Los = require("los")
|
||||||
|
local Light = require("light")
|
||||||
|
local Combat = require("combat")
|
||||||
|
local Items = require("items")
|
||||||
|
local Effects = require("effects")
|
||||||
|
local Status = require("status")
|
||||||
|
local Identify = require("identify")
|
||||||
|
local Dungeon = require("dungeon")
|
||||||
|
local Entity = require("entity")
|
||||||
|
local MessageLog = require("messagelog")
|
||||||
|
local Scheduler = require("scheduler")
|
||||||
|
local Ai = require("ai")
|
||||||
|
local Serialize = require("serialize")
|
||||||
|
local Render = require("render")
|
||||||
|
|
||||||
|
local Game = {}
|
||||||
|
Game.__index = Game
|
||||||
|
|
||||||
|
local FOV_RADIUS = 8
|
||||||
|
|
||||||
|
local function sign(n)
|
||||||
|
if n > 0 then return 1 elseif n < 0 then return -1 else return 0 end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Game.new(seed)
|
||||||
|
local game = setmetatable({}, Game)
|
||||||
|
game.seed = seed
|
||||||
|
game.rng = Rng.new(seed)
|
||||||
|
game.depth = 1
|
||||||
|
game.turn = 0
|
||||||
|
game.log = MessageLog.new(32)
|
||||||
|
game.identify = Identify.new(game.rng)
|
||||||
|
game.dungeon = Dungeon.new(seed)
|
||||||
|
game.level = game.dungeon:get(1)
|
||||||
|
game.player = Entity.new({
|
||||||
|
id = 0, x = game.level.upX, y = game.level.upY,
|
||||||
|
glyph = "@", name = "you", kind = "player",
|
||||||
|
hp = 30, maxHp = 30, attack = 5, defense = 1, speed = 100,
|
||||||
|
})
|
||||||
|
game.player.satiation = 1000
|
||||||
|
game.player.light = { fuel = 400, radius = 8 }
|
||||||
|
game.player.inventory = {
|
||||||
|
Items.potion(10), Items.potion(10), Items.food("ration", 400), Items.scroll("mapping"),
|
||||||
|
}
|
||||||
|
game.visible = {}
|
||||||
|
game:computeVisible()
|
||||||
|
return game
|
||||||
|
end
|
||||||
|
|
||||||
|
function Game:currentEntities()
|
||||||
|
return self.level.entities
|
||||||
|
end
|
||||||
|
|
||||||
|
function Game:effectiveRadius()
|
||||||
|
if not self.level.dark then return FOV_RADIUS end
|
||||||
|
if self.player.light.fuel > 0 then return self.player.light.radius end
|
||||||
|
return 2
|
||||||
|
end
|
||||||
|
|
||||||
|
function Game:computeVisible()
|
||||||
|
self.visible = Light.compute(self.level.map, self.player.x, self.player.y,
|
||||||
|
self:effectiveRadius(), self.level.lights)
|
||||||
|
for idx in pairs(self.visible) do
|
||||||
|
self.level:markExplored(idx)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Game:cleanupDead()
|
||||||
|
self.level:removeDead()
|
||||||
|
end
|
||||||
|
|
||||||
|
function Game:hasKey()
|
||||||
|
for _, item in ipairs(self.player.inventory) do
|
||||||
|
if item.kind == Items.KEY then return true end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
function Game:randomFloorCell()
|
||||||
|
local map = self.level.map
|
||||||
|
local cells = {}
|
||||||
|
for y = 1, map.height do
|
||||||
|
for x = 1, map.width do
|
||||||
|
if map:isWalkable(x, y) and not self.level:entityAt(x, y)
|
||||||
|
and not (x == self.player.x and y == self.player.y) then
|
||||||
|
cells[#cells + 1] = { x = x, y = y }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #cells == 0 then return nil end
|
||||||
|
local pick = cells[self.rng:range(1, #cells)]
|
||||||
|
return pick.x, pick.y
|
||||||
|
end
|
||||||
|
|
||||||
|
function Game:revealWholeLevel()
|
||||||
|
local map = self.level.map
|
||||||
|
for idx = 1, map.width * map.height do
|
||||||
|
self.level:markExplored(idx)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Monster-turn helpers used by the ai module.
|
||||||
|
|
||||||
|
function Game:playerAdjacent(monster)
|
||||||
|
return math.abs(monster.x - self.player.x) + math.abs(monster.y - self.player.y) == 1
|
||||||
|
end
|
||||||
|
|
||||||
|
function Game:canSeePlayer(monster, range)
|
||||||
|
local cheb = math.max(math.abs(monster.x - self.player.x), math.abs(monster.y - self.player.y))
|
||||||
|
if cheb > range then return false end
|
||||||
|
return Los.clear(self.level.map, monster.x, monster.y, self.player.x, self.player.y)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Game:hasClearShot(monster)
|
||||||
|
return Los.clear(self.level.map, monster.x, monster.y, self.player.x, self.player.y)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Game:monsterMelee(monster)
|
||||||
|
local outcome = Combat.attack(monster, self.player)
|
||||||
|
self.log:add(string.format("%s hits you for %d", monster.name, outcome.damage))
|
||||||
|
end
|
||||||
|
|
||||||
|
function Game:monsterShoot(monster)
|
||||||
|
local dealt = Combat.damage(monster, self.player)
|
||||||
|
self.player.hp = self.player.hp - dealt
|
||||||
|
self.log:add(string.format("%s shoots you for %d", monster.name, dealt))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function blockedForMonster(game, self, goalX, goalY)
|
||||||
|
return function(x, y)
|
||||||
|
if x == goalX and y == goalY then return false end
|
||||||
|
if not game.level.map:isWalkable(x, y) then return true end
|
||||||
|
if x == game.player.x and y == game.player.y then return true end
|
||||||
|
local occupant = game.level:entityAt(x, y)
|
||||||
|
return occupant ~= nil and occupant ~= self
|
||||||
|
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))
|
||||||
|
if path and #path >= 2 then
|
||||||
|
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
|
||||||
|
monster.x, monster.y = step.x, step.y
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local MONSTER_DIRS = { { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } }
|
||||||
|
|
||||||
|
function Game:stepMonsterAway(monster, tx, ty)
|
||||||
|
local current = math.abs(monster.x - tx) + math.abs(monster.y - ty)
|
||||||
|
local best, bx, by = current, nil, nil
|
||||||
|
for _, dir in ipairs(MONSTER_DIRS) do
|
||||||
|
local nx, ny = monster.x + dir[1], monster.y + dir[2]
|
||||||
|
if 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
|
||||||
|
local dist = math.abs(nx - tx) + math.abs(ny - ty)
|
||||||
|
if dist > best then
|
||||||
|
best, bx, by = dist, nx, ny
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if bx then monster.x, monster.y = bx, by end
|
||||||
|
end
|
||||||
|
|
||||||
|
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
|
||||||
|
monster.x, monster.y = nx, ny
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Game:nearestAlly(monster)
|
||||||
|
local best, ally = math.huge, nil
|
||||||
|
for _, other in ipairs(self.level.entities) do
|
||||||
|
if other ~= monster and other:isAlive() then
|
||||||
|
local dist = math.abs(other.x - monster.x) + math.abs(other.y - monster.y)
|
||||||
|
if dist < best or (dist == best and ally and other.id < ally.id) then
|
||||||
|
best, ally = dist, other
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return ally
|
||||||
|
end
|
||||||
|
|
||||||
|
function Game:takeMonsterTurn(monster)
|
||||||
|
self.log:addAll(Status.tick(monster))
|
||||||
|
if not monster:isAlive() then
|
||||||
|
self:cleanupDead()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not Status.skipsTurn(monster) then
|
||||||
|
Ai.act(self, monster)
|
||||||
|
end
|
||||||
|
Scheduler.spend(monster)
|
||||||
|
self:cleanupDead()
|
||||||
|
end
|
||||||
|
|
||||||
|
function Game:runToPlayer()
|
||||||
|
while true do
|
||||||
|
local actor = Scheduler.next(self)
|
||||||
|
if actor == nil or actor == self.player then return end
|
||||||
|
self:takeMonsterTurn(actor)
|
||||||
|
if not self.player:isAlive() then return end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Player actions.
|
||||||
|
|
||||||
|
function Game:playerAttack(target)
|
||||||
|
local outcome = Combat.attack(self.player, target)
|
||||||
|
self.log:add(string.format("you hit %s for %d", target.name, outcome.damage))
|
||||||
|
local weapon = self.player.weapon
|
||||||
|
if weapon and weapon.affix and weapon.affix.onHit and target:isAlive() then
|
||||||
|
local rider = weapon.affix.onHit
|
||||||
|
Status.apply(target, rider.status, rider.duration, rider.potency)
|
||||||
|
self.log:add(string.format("%s is %s", target.name, rider.status))
|
||||||
|
end
|
||||||
|
if outcome.killed then
|
||||||
|
self.log:add(target.name .. " dies")
|
||||||
|
self:cleanupDead()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Game:openDoor(x, y)
|
||||||
|
local map = self.level.map
|
||||||
|
if map:get(x, y) == Tiles.DOOR_LOCKED then
|
||||||
|
for i, item in ipairs(self.player.inventory) do
|
||||||
|
if item.kind == Items.KEY then
|
||||||
|
table.remove(self.player.inventory, i)
|
||||||
|
map:set(x, y, Tiles.DOOR_OPEN)
|
||||||
|
self.log:add("you unlock the door")
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
self.log:add("the door is locked")
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
map:set(x, y, Tiles.DOOR_OPEN)
|
||||||
|
self.log:add("you open the door")
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
function Game:triggerTrap(trap)
|
||||||
|
trap.revealed = true
|
||||||
|
self.level.map:set(trap.x, trap.y, Tiles.TRAP)
|
||||||
|
if trap.effect == "dart" then
|
||||||
|
local dmg = 3 + self.depth // 2
|
||||||
|
self.player.hp = self.player.hp - dmg
|
||||||
|
self.log:add(string.format("a dart trap hits you for %d", dmg))
|
||||||
|
elseif trap.effect == "poison" then
|
||||||
|
Status.apply(self.player, Status.POISON, 5, 2)
|
||||||
|
self.log:add("you stumble into a poison trap")
|
||||||
|
else
|
||||||
|
local x, y = self:randomFloorCell()
|
||||||
|
if x then
|
||||||
|
self.player.x, self.player.y = x, y
|
||||||
|
self.log:add("a trap flings you across the level")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return Game
|
||||||
Reference in new issue
Block a user