feat(game): add world state, visibility and combat turns

This commit is contained in:
dualfroz committed 2026-08-28 09:00:00 +02:00
1 parent 6080324ec2
commit 539d8bf4f5
1 file changed
+276
+276
View File
@@ -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