diff --git a/src/sim/map_gen.gd b/src/sim/map_gen.gd index 526c3ea..25cb807 100644 --- a/src/sim/map_gen.gd +++ b/src/sim/map_gen.gd @@ -7,6 +7,16 @@ extends RefCounted ## 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 @@ -140,13 +150,24 @@ static func generate(seed_value: int, depth: int, arena: StringName = &"") -> Di # 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() for i in range(1, rooms.size()): - _carve_corridor(grid, _centre_of(rooms[i - 1]), _centre_of(rooms[i])) + 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)) if not rooms.is_empty(): - _carve_corridor(grid, _centre_of(rooms[rooms.size() - 1]), door) + # 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)) # 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) + grid.set_tile(outside.x, outside.y, MapGrid.Kind.FLOOR) _scatter_cover(grid, rooms, rng) @@ -168,6 +189,10 @@ static func generate(seed_value: int, depth: int, arena: StringName = &"") -> Di "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, # 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. @@ -181,23 +206,54 @@ static func _centre_of(r: Rect2i) -> Vector2i: ## 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) +## 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) -> 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. + _carve_leg(grid, from, bend, first, second / 2, keep_out) + _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) -> void: + # 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: - _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) + 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) ## A few pillars and pits inside generated rooms, so open rooms still have diff --git a/tests/unit/test_map_gen.gd b/tests/unit/test_map_gen.gd index 0f7ffef..bb48be0 100644 --- a/tests/unit/test_map_gen.gd +++ b/tests/unit/test_map_gen.gd @@ -83,3 +83,85 @@ func test_rooms_do_not_overlap() -> void: for j in range(i + 1, rooms.size()): assert_false((rooms[i] as Rect2i).intersects(rooms[j]), "rooms %d and %d overlap" % [i, j]) + + +# --- Tunnels ---------------------------------------------------------------- + +func _inside_any(rooms: Array, x: int, y: int) -> bool: + for r: Rect2i in rooms: + if r.has_point(Vector2i(x, y)): + return true + return false + + +## Whether (x, y) is a corner of at least one 2x2 square of open floor. A +## passage one tile wide has no such square anywhere along it. +func _in_open_square(g: MapGrid, x: int, y: int) -> bool: + for dy in [-1, 0]: + for dx in [-1, 0]: + var ok := true + for oy in 2: + for ox in 2: + if g.at(x + dx + ox, y + dy + oy) != MapGrid.Kind.FLOOR: + ok = false + if ok: + return true + return false + + +func test_no_tunnel_is_a_single_file_squeeze() -> void: + # A one-tile corridor in a bullet hell is a shooting gallery: there is no + # side to step to. Every corridor tile has to sit in open ground at least + # two wide in some direction. Rooms and the authored arena are left out -- + # the arena's door is drawn one tile wide on purpose. + for s in range(1, 40): + var m := MapGen.generate(s * 7919, 1 + (s % 5)) + var g: MapGrid = m["grid"] + var rooms: Array = m["rooms"] + var arena: Rect2i = m["boss_room"] + var squeezes := 0 + for y in g.height: + for x in g.width: + if g.at(x, y) != MapGrid.Kind.FLOOR: + continue + if _inside_any(rooms, x, y) or arena.has_point(Vector2i(x, y)): + continue + if not _in_open_square(g, x, y): + squeezes += 1 + assert_eq(squeezes, 0, + "seed %d: %d corridor tile(s) in a passage one tile wide" % [s, squeezes]) + + +func test_tunnel_widths_vary_and_never_go_below_two() -> void: + var seen := {} + for s in range(1, 40): + var widths: PackedInt32Array = MapGen.generate(s * 7919, 1 + (s % 5))["corridor_widths"] + assert_false(widths.is_empty(), "seed %d carved no tunnels at all" % s) + for w in widths: + assert_between(w, MapGen.CORRIDOR_MIN_WIDTH, MapGen.CORRIDOR_MAX_WIDTH) + seen[w] = true + assert_eq(seen.size(), MapGen.CORRIDOR_MAX_WIDTH - MapGen.CORRIDOR_MIN_WIDTH + 1, + "every width in the range turns up somewhere; got %s" % [seen.keys()]) + + +func test_the_arena_keeps_every_wall_it_was_drawn_with() -> void: + # The corridor to the door used to aim at the door tile itself, which sits + # ON the arena's west wall. An L that bent the other way ran its second leg + # down that wall column, and 45% of generated arenas had a slit carved in + # an authored room. A wide tunnel makes the same mistake wider. + for arena in [&"warden_hall", &"choir_vault"]: + var stamp := Rooms.warden_hall() if arena == &"warden_hall" else Rooms.choir_vault() + for s in range(1, 60): + var m := MapGen.generate(s * 7919, 1 + (s % 5), arena) + var g: MapGrid = m["grid"] + var room: Rect2i = m["boss_room"] + var carved := 0 + for y in stamp.size(): + for x in stamp[y].length(): + if stamp[y][x] != "#": + continue + if g.at(room.position.x + x, room.position.y + y) != MapGrid.Kind.WALL: + carved += 1 + assert_eq(carved, 0, + "seed %d %s: %d of the arena's own wall tiles were carved open" + % [s * 7919, arena, carved])