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