Files
transcience/src/sim/map_gen.gd
T
claude 7af439341d
ci / verify (push) Successful in 46s
Stage 1: tile maps, walls, fog of war, aggro, scrolling camera
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.
2026-09-03 20:49:27 +02:00

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)