Varied-width tunnels, and stop carving slits in the boss arenas

Every corridor was one tile wide, which in a bullet hell is a shooting
gallery: there is no side to step to, so anything fired down it lands.
Each leg of each L now rolls its own width between 2 and 4, so a tunnel
can open up or pinch at its bend rather than every connection being the
same pipe. Carving stops one tile short of the map's outer ring, which
keeps the world sealed however close to the edge a wide tunnel runs.

Found while doing it: 45% of generated arenas had their own walls carved
open. The last corridor aimed at the door tile, which sits ON the
arena's west wall, and an L that bent the other way ran its second leg
straight down that column -- 181 of 398 arenas, measured. It now aims at
the tile outside the door and always arrives along the door's own row,
and the arena is excluded from every carve, so a wide tunnel stops at
the wall instead of eating into it.

Three tests: no corridor tile sits in a passage one tile wide, every
width in the range turns up and none falls below two, and every arena
keeps every wall tile it was drawn with. The overshoot at a bend is
cosmetic -- the legs always overlap on the inner side, so removing it
bevels the outer corner and narrows nothing -- and the comment now says
so rather than claiming otherwise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
claudeandClaude Opus 5.5 committed 2026-10-05 09:51:19 +02:00
1 parent a6084c1a8d
commit 83c3b920fb
2 files changed
+157 -19

No files matched your search

+75 -19
View File
@@ -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