feat: add chebyshev and Horde.spawnAnchors

This commit is contained in:
dualfroz committed 2026-09-06 20:13:45 +02:00
1 parent e92b6d844f
commit 58994a6132
1 file changed
+159
+159
View File
@@ -0,0 +1,159 @@
-- Horde Arena: an optional survival trial layered onto an existing run,
-- entirely separate from the depth-descent dungeon (dungeon.lua/builder.lua
-- never run for it - Game:startHorde builds one closed Level directly, see
-- Horde.buildArena) yet it still runs through every existing turn system
-- untouched: scheduler.lua, ai.lua and combat.lua all just see a Level like
-- any other, since Level.new already defaults every collection an arena has
-- no use for (shrines, nodes, traps, quest boards...) to an empty table.
-- What makes it a *horde* rather than one more static floor is Horde.tick,
-- wired into Game:endPlayerTurn behind a game.horde check: the instant
-- every monster the current wave placed is dead, it banks a reward, heals a
-- slice of damage back, and throws the next wave in immediately - there is
-- never a turn where the arena stands empty. A wave's difficulty is driven
-- by the same Monsters.forDepth/Elites.maybePromote a real floor already
-- uses, just fed the wave number as if it were depth, so it never plateaus:
-- long past the point every species has unlocked, waves just keep growing
-- larger and rolling elites more often, exactly as if still descending.
--
-- Scope: a fresh Game.new never has a game.horde field at all, exactly like
-- game.nemesis before a nemesis has ever spawned. Because the arena level
-- lives outside self.dungeon entirely, Game:toData/fromData snapshot both
-- game.horde and the arena's own Level separately (hordeLevel) so a save
-- taken mid-arena resumes the exact wave in progress rather than silently
-- dropping the player back onto the ordinary floor the arena replaced.
local Map = require("map")
local Tiles = require("tiles")
local Connect = require("gen.connect")
local Monsters = require("monsters")
local Elites = require("elites")
local Stealth = require("stealth")
local Horde = {}
Horde.WIDTH = 19
Horde.HEIGHT = 13
Horde.PILLAR_NUMERATOR = 1
Horde.PILLAR_DENOMINATOR = 9
-- Chebyshev radius around each corner anchor left pillar-free by
-- Horde.buildArena, and the radius Horde.spawnPoints itself searches within
-- of the very same anchors - the two have to agree, or a spawn queue built
-- wider than the guaranteed-clear zone could try to place a monster on a
-- cell that turned out to be a pillar.
Horde.SPAWN_CLEARANCE = 2
Horde.BASE_COUNT = 3
Horde.COUNT_PER_WAVE = 1
Horde.MAX_COUNT = 12
Horde.REST_HEAL = 4
Horde.ESSENCE_PER_WAVE = 5
local function chebyshev(ax, ay, bx, by)
return math.max(math.abs(ax - bx), math.abs(ay - by))
end
-- Fixed corner anchors, inset one cell from the border wall so they are
-- never the border itself and always inside the interior floor Horde.buildArena
-- carves - plain open floor by construction, regardless of anything rolled
-- on the rng that built the rest of the arena.
function Horde.spawnAnchors(width, height)
return {
{ x = 2, y = 2 }, { x = width - 1, y = 2 },
{ x = 2, y = height - 1 }, { x = width - 1, y = height - 1 },
}
end
-- The deterministic placement queue Horde.spawnWave fills wave by wave:
-- every walkable cell within Horde.SPAWN_CLEARANCE of any anchor, round-
-- robin across the four anchors (first cell of each, then second cell of
-- each...) rather than anchor-by-anchor, so even a small wave arrives from
-- more than one direction instead of clumping in a single corner. Each
-- underlying map cell can only ever claim one slot in the queue, even if
-- two anchors' clearance zones happened to overlap.
function Horde.spawnPoints(map)
local anchors = Horde.spawnAnchors(map.width, map.height)
local perAnchor, claimed = {}, {}
for _, anchor in ipairs(anchors) do
local cells = {}
for y = anchor.y - Horde.SPAWN_CLEARANCE, anchor.y + Horde.SPAWN_CLEARANCE do
for x = anchor.x - Horde.SPAWN_CLEARANCE, anchor.x + Horde.SPAWN_CLEARANCE do
local idx = map:inBounds(x, y) and map:index(x, y)
if idx and not claimed[idx] and map:isWalkable(x, y) then
claimed[idx] = true
cells[#cells + 1] = { x = x, y = y }
end
end
end
perAnchor[#perAnchor + 1] = cells
end
local points = {}
local i, more = 1, true
while more do
more = false
for _, cells in ipairs(perAnchor) do
if cells[i] then
points[#points + 1] = cells[i]
more = true
end
end
i = i + 1
end
return points
end
-- One fully-enclosed arena: a rectangle of floor ringed by wall, its
-- interior sparsely sown with single-tile pillars for cover - except within
-- Horde.SPAWN_CLEARANCE of any corner anchor, and the centre cell (the
-- player's own start, see Game:startHorde), both deliberately left clear
-- regardless of what the pillar pass would otherwise roll there.
-- Connect.ensure runs last, exactly like every generator gen.init already
-- dispatches to, so a pillar can never wall off a pocket of floor even
-- though placement itself never checks for that. Returns the map plus the
-- centre cell, since Game:startHorde needs it for the player's own start.
function Horde.buildArena(rng, width, height)
width = width or Horde.WIDTH
height = height or Horde.HEIGHT
local map = Map.new(width, height)
for y = 2, height - 1 do
for x = 2, width - 1 do
map:set(x, y, Tiles.FLOOR)
end
end
local anchors = Horde.spawnAnchors(width, height)
local cx, cy = width // 2 + 1, height // 2 + 1
for y = 2, height - 1 do
for x = 2, width - 1 do
local clear = x == cx and y == cy
if not clear then
for _, anchor in ipairs(anchors) do
if chebyshev(x, y, anchor.x, anchor.y) <= Horde.SPAWN_CLEARANCE then
clear = true
break
end
end
end
if not clear and rng:chance(Horde.PILLAR_NUMERATOR, Horde.PILLAR_DENOMINATOR) then
map:set(x, y, Tiles.WALL)
end
end
end
Connect.ensure(map, rng)
return map, cx, cy
end
function Horde.newState()
return { wave = 0 }
end
function Horde.depthFor(wave)
return wave
end
function Horde.countFor(wave)
local count = Horde.BASE_COUNT + (wave - 1) * Horde.COUNT_PER_WAVE
if count > Horde.MAX_COUNT then count = Horde.MAX_COUNT end
return count
end