Tunnels are wide enough to fight in now, so occasionally one is occupied: each tunnel leg rolls a 10% chance of a group of two or three of one kind, standing within two tiles of each other. Most legs are too short or too near the entrance to qualify, so in practice it is about one depth-1 run in three (60 of 200 measured), rising with depth as tunnels get longer. Rare on purpose -- tunnels are where you go between fights, and a tunnel that is usually empty is what makes the one that is not a surprise. Never within aggro range of the entrance, checked per member rather than per group: members stand up to two tiles off the group's centre, so a centre that clears the entrance does not mean everyone does. The first version checked the centre, and the nearest member landed 15 units outside aggro range by luck. Never a lone straggler: a leg that cannot fit two gets nobody. Placed after every room, so each room's population is exactly what its seed always produced, and every leg rolls the same dice whether or not it is used, so one tunnel's luck never depends on another's shape. Scaled by the dungeon like every other spawn, each member from its own fresh definition. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
283 lines
12 KiB
GDScript
283 lines
12 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, 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)
|