7af439341d
ci / verify (push) Successful in 46s
Replaces the fixed centred arena with per-world tile geometry, which is the foundation the remaining features sit on. - MapGrid: tile grid with three independent flags -- blocks movement, bullets, sight -- so a pit stops feet but not bullets or eyes, and a barricade stops feet and bullets but not eyes. Circle collision with per-axis sliding, and Bresenham line of sight shared by fog and aggro. - MapGen: rooms and corridors generated from (seed, depth), with hand-authored boss arenas from Rooms stamped in first so a corridor can never carve through a designed fight. Reachability from spawn to boss is asserted over 40 seeds -- "usually connected" is the failure mode that ruins one run in twenty. - Dungeons are populated at creation, per room, instead of gating on waves. You explore and choose your fights; the run ends when the boss dies, not when the map is swept. Boss rooms have no lock, so walking out is always available. - Aggro: enemies need range AND line of sight, so a dungeon stays quiet until engaged and cover actually protects. - Hard fog, scrolling camera, and terrain rendering. Maps are streamed per peer in chunks around that peer's player, and the seed is deliberately NOT sent -- a client holding it could regenerate the whole dungeon, which is a map hack for free. Stream radius (900u) is wider than view radius (460u) because the client predicts movement against walls and simulates bullets that die on them; the accepted cost is a cheater seeing a little further than the fog, never the floor plan. Partial map knowledge means wall deaths must be announced rather than derived. A test caught the subtle half of that: out-of-bounds tiles read as WALL by design, so checking geometry before bounds reported every bullet leaving the map as a wall kill. 128 tests (was 103); check.sh, test.sh and smoke.sh pass. 0.26 ms/tick with 4 players and a boss, ~65x headroom.
184 lines
6.4 KiB
GDScript
184 lines
6.4 KiB
GDScript
class_name MapGen
|
|
extends RefCounted
|
|
## Builds a dungeon: generated rooms and corridors around a hand-authored boss
|
|
## arena (see [Rooms]).
|
|
##
|
|
## Deterministic from (seed, depth) alone, so the server can hand a client the
|
|
## same two numbers instead of a map, and a failing run can be reproduced from
|
|
## its log line.
|
|
|
|
## The single entry point both server and client use.
|
|
##
|
|
## Maps are never sent as tile data: they are a pure function of
|
|
## (kind, seed, depth), so the server ships three integers and the client
|
|
## rebuilds the identical grid. tests/unit/test_map_gen.gd pins the determinism
|
|
## that makes that safe, and it keeps a big dungeon free on the wire.
|
|
static func build(kind: Protocol.InstanceKind, seed_value: int, depth: int) -> Dictionary:
|
|
if kind == Protocol.InstanceKind.LOBBY:
|
|
return _build_lobby()
|
|
return generate(seed_value, depth)
|
|
|
|
|
|
static func _build_lobby() -> Dictionary:
|
|
var stamp := Rooms.lobby()
|
|
var size := Rooms.size_of(stamp)
|
|
var grid := MapGrid.new(size.x, size.y, MapGrid.Kind.WALL)
|
|
grid.centre_on_origin()
|
|
var markers := Rooms.stamp(grid, stamp, Vector2i.ZERO)
|
|
var spawn := grid.tile_centre(size.x / 2, size.y - 4)
|
|
if not markers["S"].is_empty():
|
|
var m: Vector2i = markers["S"][0]
|
|
spawn = grid.tile_centre(m.x, m.y)
|
|
var portal := grid.tile_centre(size.x / 2, 3)
|
|
if not markers["P"].is_empty():
|
|
var m: Vector2i = markers["P"][0]
|
|
portal = grid.tile_centre(m.x, m.y)
|
|
var target := grid.tile_centre(size.x / 4, size.y / 2)
|
|
if not markers["T"].is_empty():
|
|
var m: Vector2i = markers["T"][0]
|
|
target = grid.tile_centre(m.x, m.y)
|
|
return {
|
|
"grid": grid,
|
|
"rooms": [] as Array[Rect2i],
|
|
"spawn": spawn,
|
|
"portal": portal,
|
|
"dummy": target,
|
|
"boss_pos": Vector2.ZERO,
|
|
"boss_room": Rect2i(),
|
|
}
|
|
|
|
|
|
## Result keys: grid, rooms (Array[Rect2i]), spawn (Vector2), boss_pos
|
|
## (Vector2), boss_room (Rect2i).
|
|
static func generate(seed_value: int, depth: int) -> Dictionary:
|
|
var rng := RandomNumberGenerator.new()
|
|
rng.seed = seed_value
|
|
var d := maxi(depth, 1)
|
|
|
|
# Size grows with depth. Depth 1 is a little under two screens across --
|
|
# enough that fog and exploration mean something without a first run
|
|
# becoming a hike.
|
|
var w := 56 + d * 10
|
|
var h := 36 + d * 7
|
|
var grid := MapGrid.new(w, h, MapGrid.Kind.WALL)
|
|
grid.centre_on_origin()
|
|
|
|
# The boss arena is placed first and everything else works around it, so a
|
|
# generated corridor can never carve through the authored fight.
|
|
var stamp := Rooms.warden_hall() if d % 2 == 1 else Rooms.choir_vault()
|
|
var bs := Rooms.size_of(stamp)
|
|
var boss_origin := Vector2i(w - bs.x - 2, (h - bs.y) / 2)
|
|
var markers := Rooms.stamp(grid, stamp, boss_origin)
|
|
var boss_room := Rect2i(boss_origin, bs)
|
|
|
|
var boss_pos := grid.tile_centre(
|
|
boss_origin.x + bs.x / 2, boss_origin.y + bs.y / 2)
|
|
if not markers["B"].is_empty():
|
|
var m: Vector2i = markers["B"][0]
|
|
boss_pos = grid.tile_centre(m.x, m.y)
|
|
|
|
var door := Vector2i(boss_origin.x, boss_origin.y + bs.y / 2)
|
|
if not markers["D"].is_empty():
|
|
door = markers["D"][0]
|
|
|
|
# Generated rooms, confined to the space left of the arena.
|
|
var rooms: Array[Rect2i] = []
|
|
var usable_w := boss_origin.x - 2
|
|
var attempts := 0
|
|
var wanted := 5 + d
|
|
while rooms.size() < wanted and attempts < wanted * 40:
|
|
attempts += 1
|
|
var rw := rng.randi_range(6, 12)
|
|
var rh := rng.randi_range(5, 10)
|
|
var rx := rng.randi_range(1, maxi(usable_w - rw - 1, 2))
|
|
var ry := rng.randi_range(1, maxi(h - rh - 2, 2))
|
|
var candidate := Rect2i(rx, ry, rw, rh)
|
|
# One tile of padding so two rooms never share a wall and merge into an
|
|
# ambiguous blob.
|
|
var padded := candidate.grow(1)
|
|
var clash := false
|
|
for existing in rooms:
|
|
if padded.intersects(existing.grow(1)):
|
|
clash = true
|
|
break
|
|
if clash:
|
|
continue
|
|
rooms.append(candidate)
|
|
grid.fill_rect(candidate, MapGrid.Kind.FLOOR)
|
|
|
|
# Connect each room to the previous one, then the last to the arena door,
|
|
# so every room is reachable by construction rather than by luck.
|
|
for i in range(1, rooms.size()):
|
|
_carve_corridor(grid, _centre_of(rooms[i - 1]), _centre_of(rooms[i]))
|
|
if not rooms.is_empty():
|
|
_carve_corridor(grid, _centre_of(rooms[rooms.size() - 1]), door)
|
|
# The door tile itself, and the tile outside it, must be floor.
|
|
grid.set_tile(door.x, door.y, MapGrid.Kind.FLOOR)
|
|
grid.set_tile(door.x - 1, door.y, MapGrid.Kind.FLOOR)
|
|
|
|
_scatter_cover(grid, rooms, rng)
|
|
|
|
var spawn := boss_pos
|
|
if not rooms.is_empty():
|
|
var c := _centre_of(rooms[0])
|
|
# Cover is scattered before the spawn is chosen, so the spawn tile can
|
|
# have had a pillar dropped on it. Clear it rather than hunting for a
|
|
# free tile: this is the one tile in the map that must be standable.
|
|
grid.set_tile(c.x, c.y, MapGrid.Kind.FLOOR)
|
|
spawn = grid.tile_centre(c.x, c.y)
|
|
|
|
return {
|
|
"grid": grid,
|
|
"rooms": rooms,
|
|
"spawn": spawn,
|
|
"portal": Vector2.ZERO,
|
|
"dummy": Vector2.ZERO,
|
|
"boss_pos": boss_pos,
|
|
"boss_room": boss_room,
|
|
}
|
|
|
|
|
|
static func _centre_of(r: Rect2i) -> Vector2i:
|
|
return Vector2i(r.position.x + r.size.x / 2, r.position.y + r.size.y / 2)
|
|
|
|
|
|
## L-shaped, with the corner order chosen by parity so corridors do not all
|
|
## bend the same way.
|
|
static func _carve_corridor(grid: MapGrid, from: Vector2i, to: Vector2i) -> void:
|
|
if (from.x + from.y) % 2 == 0:
|
|
_carve_h(grid, from.x, to.x, from.y)
|
|
_carve_v(grid, from.y, to.y, to.x)
|
|
else:
|
|
_carve_v(grid, from.y, to.y, from.x)
|
|
_carve_h(grid, from.x, to.x, to.y)
|
|
|
|
|
|
static func _carve_h(grid: MapGrid, x0: int, x1: int, y: int) -> void:
|
|
for x in range(mini(x0, x1), maxi(x0, x1) + 1):
|
|
grid.set_tile(x, y, MapGrid.Kind.FLOOR)
|
|
|
|
|
|
static func _carve_v(grid: MapGrid, y0: int, y1: int, x: int) -> void:
|
|
for y in range(mini(y0, y1), maxi(y0, y1) + 1):
|
|
grid.set_tile(x, y, MapGrid.Kind.FLOOR)
|
|
|
|
|
|
## A few pillars and pits inside generated rooms, so open rooms still have
|
|
## something to fight around. Never placed on a room's edge, where they could
|
|
## seal a corridor mouth.
|
|
static func _scatter_cover(grid: MapGrid, rooms: Array[Rect2i], rng: RandomNumberGenerator) -> void:
|
|
for r in rooms:
|
|
if r.size.x < 7 or r.size.y < 6:
|
|
continue
|
|
var centre := _centre_of(r)
|
|
var count := rng.randi_range(1, 3)
|
|
for _i in count:
|
|
var tx := rng.randi_range(r.position.x + 2, r.end.x - 3)
|
|
var ty := rng.randi_range(r.position.y + 2, r.end.y - 3)
|
|
# Room centres are where corridors meet and where the spawn goes;
|
|
# blocking one can pinch a junction shut.
|
|
if tx == centre.x and ty == centre.y:
|
|
continue
|
|
var kind := MapGrid.Kind.PILLAR if rng.randf() < 0.6 else MapGrid.Kind.PIT
|
|
grid.set_tile(tx, ty, kind)
|