From 539d8bf4f522c0644ce8d2a730bd5168387a615b Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 28 Aug 2026 09:00:00 +0200 Subject: [PATCH] feat(game): add world state, visibility and combat turns --- src/game.lua | 276 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 src/game.lua diff --git a/src/game.lua b/src/game.lua new file mode 100644 index 0000000..5d38774 --- /dev/null +++ b/src/game.lua @@ -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