7af439341d
ci / verify (push) Successful in 46s
Replaces the fixed centred arena with per-world tile geometry, which is the foundation the remaining features sit on. - MapGrid: tile grid with three independent flags -- blocks movement, bullets, sight -- so a pit stops feet but not bullets or eyes, and a barricade stops feet and bullets but not eyes. Circle collision with per-axis sliding, and Bresenham line of sight shared by fog and aggro. - MapGen: rooms and corridors generated from (seed, depth), with hand-authored boss arenas from Rooms stamped in first so a corridor can never carve through a designed fight. Reachability from spawn to boss is asserted over 40 seeds -- "usually connected" is the failure mode that ruins one run in twenty. - Dungeons are populated at creation, per room, instead of gating on waves. You explore and choose your fights; the run ends when the boss dies, not when the map is swept. Boss rooms have no lock, so walking out is always available. - Aggro: enemies need range AND line of sight, so a dungeon stays quiet until engaged and cover actually protects. - Hard fog, scrolling camera, and terrain rendering. Maps are streamed per peer in chunks around that peer's player, and the seed is deliberately NOT sent -- a client holding it could regenerate the whole dungeon, which is a map hack for free. Stream radius (900u) is wider than view radius (460u) because the client predicts movement against walls and simulates bullets that die on them; the accepted cost is a cheater seeing a little further than the fog, never the floor plan. Partial map knowledge means wall deaths must be announced rather than derived. A test caught the subtle half of that: out-of-bounds tiles read as WALL by design, so checking geometry before bounds reported every bullet leaving the map as a wall kill. 128 tests (was 103); check.sh, test.sh and smoke.sh pass. 0.26 ms/tick with 4 players and a boss, ~65x headroom.
86 lines
3.1 KiB
GDScript
86 lines
3.1 KiB
GDScript
extends GutTest
|
|
## Generator properties. The important one is reachability: a map where the
|
|
## boss cannot be walked to is a run that cannot be finished, and it would show
|
|
## up as a player wandering confused rather than as an error.
|
|
|
|
|
|
func _flood_reachable(grid: MapGrid, from: Vector2i) -> Dictionary:
|
|
var seen := {}
|
|
var queue: Array[Vector2i] = [from]
|
|
seen[from] = true
|
|
while not queue.is_empty():
|
|
var t: Vector2i = queue.pop_back()
|
|
for d in [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]:
|
|
var n: Vector2i = t + d
|
|
if seen.has(n) or not grid.in_bounds(n.x, n.y):
|
|
continue
|
|
if grid.blocks_move(n.x, n.y):
|
|
continue
|
|
seen[n] = true
|
|
queue.append(n)
|
|
return seen
|
|
|
|
|
|
func test_the_boss_is_always_reachable_from_the_spawn() -> void:
|
|
# Many seeds, because "usually connected" is the failure mode that survives
|
|
# a single-seed test and ruins one run in twenty.
|
|
for s in range(1, 40):
|
|
var m := MapGen.generate(s * 7919, 1 + (s % 5))
|
|
var grid: MapGrid = m["grid"]
|
|
var spawn_tile := grid.to_tile(m["spawn"])
|
|
var boss_tile := grid.to_tile(m["boss_pos"])
|
|
var reachable := _flood_reachable(grid, spawn_tile)
|
|
assert_true(reachable.has(boss_tile),
|
|
"seed %d: the boss room is walled off from the spawn" % s)
|
|
|
|
|
|
func test_generation_is_deterministic() -> void:
|
|
var a := MapGen.generate(12345, 3)
|
|
var b := MapGen.generate(12345, 3)
|
|
assert_eq((a["grid"] as MapGrid).tiles, (b["grid"] as MapGrid).tiles,
|
|
"same seed and depth must give the same map, or the server cannot " +
|
|
"just send two numbers")
|
|
assert_eq(a["spawn"], b["spawn"])
|
|
assert_eq(a["boss_pos"], b["boss_pos"])
|
|
|
|
|
|
func test_different_seeds_give_different_maps() -> void:
|
|
var a := MapGen.generate(1, 2)
|
|
var b := MapGen.generate(2, 2)
|
|
assert_ne((a["grid"] as MapGrid).tiles, (b["grid"] as MapGrid).tiles)
|
|
|
|
|
|
func test_maps_grow_with_depth() -> void:
|
|
var shallow: MapGrid = MapGen.generate(99, 1)["grid"]
|
|
var deep: MapGrid = MapGen.generate(99, 6)["grid"]
|
|
assert_gt(deep.width, shallow.width)
|
|
assert_gt(deep.height, shallow.height)
|
|
|
|
|
|
func test_the_map_is_sealed_at_its_border() -> void:
|
|
var grid: MapGrid = MapGen.generate(4242, 2)["grid"]
|
|
for x in grid.width:
|
|
assert_true(grid.blocks_move(x, 0), "top border leaks at x=%d" % x)
|
|
assert_true(grid.blocks_move(x, grid.height - 1), "bottom border leaks at x=%d" % x)
|
|
for y in grid.height:
|
|
assert_true(grid.blocks_move(0, y), "left border leaks at y=%d" % y)
|
|
assert_true(grid.blocks_move(grid.width - 1, y), "right border leaks at y=%d" % y)
|
|
|
|
|
|
func test_spawn_and_boss_stand_on_open_ground() -> void:
|
|
for s in range(1, 20):
|
|
var m := MapGen.generate(s * 104729, 1 + (s % 4))
|
|
var grid: MapGrid = m["grid"]
|
|
assert_false(grid.circle_blocked(m["spawn"], SimConfig.PLAYER_RADIUS),
|
|
"seed %d: player spawns inside geometry" % s)
|
|
assert_false(grid.circle_blocked(m["boss_pos"], 40.0),
|
|
"seed %d: boss spawns inside geometry" % s)
|
|
|
|
|
|
func test_rooms_do_not_overlap() -> void:
|
|
var rooms: Array = MapGen.generate(777, 4)["rooms"]
|
|
for i in rooms.size():
|
|
for j in range(i + 1, rooms.size()):
|
|
assert_false((rooms[i] as Rect2i).intersects(rooms[j]),
|
|
"rooms %d and %d overlap" % [i, j])
|