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)