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, which is what makes a failing run ## reproducible from its log line. It does NOT mean the client can rebuild the ## map -- see the note on build(). ## Tunnel widths, in tiles, rolled per leg of every corridor. ## ## The floor is 2 because a one-tile tunnel in a bullet hell is a shooting ## gallery: there is no side to step to, so anything fired down it lands. Each ## leg of an L rolls its own width, so a tunnel can open up or pinch at its ## bend rather than every connection in the map being the same pipe. const CORRIDOR_MIN_WIDTH := 2 const CORRIDOR_MAX_WIDTH := 4 ## The single entry point. SERVER-SIDE ONLY. ## ## This used to be described as "both sides call it with the same three ## integers" -- that is no longer true and has not been since map streaming ## landed. Handing a client the seed would let it regenerate the entire floor ## plan, which is a map hack with no work required, so the client is given the ## map's SIZE and nothing else and fills tiles in from streamed chunks as it ## walks. See ServerRuntime._stream_map. ## ## Generation is still deterministic from (kind, seed, depth), which is what ## makes a bad run reproducible from its log line; tests/unit/test_map_gen.gd ## pins that. static func build(kind: Protocol.InstanceKind, seed_value: int, depth: int, arena: StringName = &"") -> Dictionary: if kind == Protocol.InstanceKind.LOBBY: return _build_lobby() return generate(seed_value, depth, arena) 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) # One portal per marker, in reading order, matched against Dungeons.ORDER. # A stamp with fewer markers than dungeons simply makes the extra ones # unreachable rather than crashing -- an unreachable dungeon is a content # bug, not a runtime one. var portals: Array[SimPortal] = [] for i in (markers["P"] as Array).size(): if i >= Dungeons.ORDER.size(): break var m: Vector2i = markers["P"][i] portals.append(SimPortal.make(grid.tile_centre(m.x, m.y), Dungeons.ORDER[i])) if portals.is_empty(): portals.append(SimPortal.make( grid.tile_centre(size.x / 2, 3), Dungeons.default_id())) 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) var npc := grid.tile_centre(size.x * 3 / 4, size.y / 2) if not markers["U"].is_empty(): var m: Vector2i = markers["U"][0] npc = grid.tile_centre(m.x, m.y) return { "grid": grid, "rooms": [] as Array[Rect2i], "spawn": spawn, "portals": portals, "dummy": target, "npc": npc, "boss_pos": Vector2.ZERO, "boss_room": Rect2i(), "boss_id": &"", } ## Result keys: grid, rooms (Array[Rect2i]), spawn (Vector2), boss_pos ## (Vector2), boss_room (Rect2i). ## [param arena] names the boss arena to stamp, and therefore which boss the run ## ends with. Empty leaves it to the seed, which is what the Proving Grounds ## wants and nothing else does -- for a real dungeon the boss is the thing a ## player chose at the portal. static func generate(seed_value: int, depth: int, arena: StringName = &"") -> 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. # Normally the dungeon says which arena it is; only the test harness leaves # it open, and then the seed decides. var chosen := arena if chosen.is_empty(): chosen = &"warden_hall" if posmod(seed_value, 2) == 0 else &"choir_vault" var stamp := Rooms.warden_hall() if chosen == &"warden_hall" 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 arena_id := chosen 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. var widths := PackedInt32Array() var legs: Array[Rect2i] = [] for i in range(1, rooms.size()): var from := _centre_of(rooms[i - 1]) widths.append_array(_carve_corridor(grid, rng, from, _centre_of(rooms[i]), (from.x + from.y) % 2 == 0, boss_room, legs)) if not rooms.is_empty(): # Aimed at the tile OUTSIDE the door, and always arriving along the # door's own row. Aiming at the door itself, an L that bent the other # way ran its second leg straight down the arena's west wall -- 45% of # generated arenas had a slit carved in an authored room. The arena is # also kept out of every carve, so a wide tunnel stops at its wall # rather than eating into it. var outside := door + Vector2i(-1, 0) widths.append_array(_carve_corridor(grid, rng, _centre_of(rooms[rooms.size() - 1]), outside, false, boss_room, legs)) # 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(outside.x, outside.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, "portals": [] as Array[SimPortal], "dummy": Vector2.ZERO, "npc": Vector2.ZERO, "boss_pos": boss_pos, "boss_room": boss_room, # What every corridor leg was carved at. Not used by anything that # builds a world -- it is here so a test can check the widths actually # vary, which the tiles alone cannot say once tunnels overlap. "corridor_widths": widths, # The rectangle each tunnel leg was carved over. Overlaps the rooms it # joins at its ends, so whoever uses it decides what counts as tunnel -- # Instance._populate_tunnels keeps only floor clear of every room. "tunnels": legs, # Which boss lives here follows from WHICH arena was stamped. Choosing # it anywhere else would let a dungeon put the Cantor in the Warden's # hall, where its patterns assume geometry that is not there. "boss_id": Rooms.boss_for_arena(arena_id), } 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. ## An L-shaped tunnel from [param from] to [param to], each leg its own width. ## Returns the two widths it rolled. ## ## [param horizontal_first] picks which way the L bends. [param keep_out] is ## never carved: it is the boss arena, which is authored, and keeps exactly the ## walls it was drawn with. static func _carve_corridor(grid: MapGrid, rng: RandomNumberGenerator, from: Vector2i, to: Vector2i, horizontal_first: bool, keep_out: Rect2i, legs: Array[Rect2i]) -> PackedInt32Array: var first := rng.randi_range(CORRIDOR_MIN_WIDTH, CORRIDOR_MAX_WIDTH) var second := rng.randi_range(CORRIDOR_MIN_WIDTH, CORRIDOR_MAX_WIDTH) var bend := Vector2i(to.x, from.y) if horizontal_first else Vector2i(from.x, to.y) # Each leg overshoots the bend by half the other's width, so the corner is # carved square. Cosmetic, not structural: the two legs always overlap on # the INNER side of the bend, so leaving this out never narrows a tunnel # below its width -- it just bevels the outer corner, which reads as a # notch rather than a turn. legs.append(_carve_leg(grid, from, bend, first, second / 2, keep_out)) legs.append(_carve_leg(grid, bend, to, second, first / 2, keep_out)) return PackedInt32Array([first, second]) ## One straight leg of [param width] tiles between two points sharing a row or ## a column, extended [param overshoot] tiles past both ends. ## ## Never touches the map's outermost ring: out-of-bounds reads as wall, and the ## world is sealed by construction, so a wide tunnel that hugs the edge must ## stop one tile short of it rather than open the map to nothing. static func _carve_leg(grid: MapGrid, a: Vector2i, b: Vector2i, width: int, overshoot: int, keep_out: Rect2i) -> Rect2i: # Width is laid across the leg, centred on its line: two tiles is the line # and the one after it, three is one either side, four leans one way. var lo := -(width - 1) / 2 var hi := width / 2 var rect: Rect2i if a.y == b.y: var x0 := mini(a.x, b.x) - overshoot var x1 := maxi(a.x, b.x) + overshoot rect = Rect2i(x0, a.y + lo, x1 - x0 + 1, hi - lo + 1) else: var y0 := mini(a.y, b.y) - overshoot var y1 := maxi(a.y, b.y) + overshoot rect = Rect2i(a.x + lo, y0, hi - lo + 1, y1 - y0 + 1) for y in range(maxi(rect.position.y, 1), mini(rect.end.y, grid.height - 1)): for x in range(maxi(rect.position.x, 1), mini(rect.end.x, grid.width - 1)): if keep_out.has_point(Vector2i(x, y)): continue grid.set_tile(x, y, MapGrid.Kind.FLOOR) return rect ## 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)