Files
transcience/tests/unit/test_map_grid.gd
T
claude 7af439341d
ci / verify (push) Successful in 46s
Stage 1: tile maps, walls, fog of war, aggro, scrolling camera
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.
2026-09-03 20:49:27 +02:00

105 lines
4.2 KiB
GDScript

extends GutTest
## The grid is the foundation for collision, bullets, fog and interest
## management, so its edge cases are worth pinning down directly rather than
## discovering them as strange behaviour three systems away.
var grid: MapGrid
func before_each() -> void:
# 10x10 room: solid border, open interior.
grid = MapGrid.new(10, 10, MapGrid.Kind.WALL)
grid.fill_rect(Rect2i(1, 1, 8, 8), MapGrid.Kind.FLOOR)
func test_out_of_bounds_reads_as_wall() -> void:
assert_eq(grid.at(-1, 5), MapGrid.Kind.WALL,
"the world has to be sealed without every caller bounds-checking")
assert_eq(grid.at(999, 999), MapGrid.Kind.WALL)
func test_tile_flags_are_independent() -> void:
# The whole reason for three flags rather than one "solid" bit.
assert_true(grid.BLOCKS_MOVE[MapGrid.Kind.PIT], "you cannot walk over a pit")
assert_false(grid.BLOCKS_BULLET[MapGrid.Kind.PIT], "but you can shoot over it")
assert_false(grid.BLOCKS_SIGHT[MapGrid.Kind.PIT], "and see over it")
assert_true(grid.BLOCKS_MOVE[MapGrid.Kind.BARRICADE])
assert_true(grid.BLOCKS_BULLET[MapGrid.Kind.BARRICADE])
assert_false(grid.BLOCKS_SIGHT[MapGrid.Kind.BARRICADE],
"chest height: you see over it but cannot shoot or walk through")
func test_world_and_tile_coordinates_round_trip() -> void:
assert_eq(grid.to_tile(Vector2(0.0, 0.0)), Vector2i(0, 0))
assert_eq(grid.to_tile(Vector2(MapGrid.TILE * 3.5, MapGrid.TILE * 2.5)), Vector2i(3, 2))
assert_eq(grid.tile_centre(3, 2), Vector2(MapGrid.TILE * 3.5, MapGrid.TILE * 2.5))
assert_eq(grid.world_size(), Vector2(10.0, 10.0) * MapGrid.TILE)
func test_negative_world_positions_map_outside_the_grid() -> void:
# floor(), not truncation -- int() would fold -0.5 onto tile 0 and let an
# actor stand half a tile outside the map.
assert_eq(grid.to_tile(Vector2(-1.0, -1.0)), Vector2i(-1, -1))
func test_circle_collision_against_a_wall() -> void:
var open := grid.tile_centre(4, 4)
assert_false(grid.circle_blocked(open, 6.0))
# Hard against the left wall: tile 0 is solid, so a circle at tile 1's
# centre minus most of a tile overlaps it.
assert_true(grid.circle_blocked(Vector2(MapGrid.TILE + 2.0, grid.tile_centre(1, 4).y), 6.0))
func test_sliding_along_a_wall_preserves_the_free_axis() -> void:
# Moving diagonally into the top wall should keep the horizontal motion.
var start := grid.tile_centre(4, 1)
var moved := grid.slide_circle(start, Vector2(8.0, -40.0), 6.0)
assert_almost_eq(moved.x, start.x + 8.0, 0.001, "x is unobstructed and must not be lost")
assert_lt(absf(moved.y - start.y), 40.0, "y is blocked by the wall")
func test_a_circle_cannot_be_pushed_through_a_wall() -> void:
var start := grid.tile_centre(4, 1)
for _i in 60:
start = grid.slide_circle(start, Vector2(0.0, -100.0), 6.0)
assert_gt(start.y, MapGrid.TILE, "no amount of shoving may cross a solid tile")
func test_bullets_stop_at_walls_but_cross_pits() -> void:
grid.set_tile(4, 4, MapGrid.Kind.PIT)
assert_false(grid.bullet_blocked(grid.tile_centre(4, 4)), "bullets fly over pits")
assert_true(grid.bullet_blocked(grid.tile_centre(0, 0)), "and stop at walls")
func test_line_of_sight_is_blocked_by_walls_and_not_by_pits() -> void:
var a := grid.tile_centre(1, 4)
var b := grid.tile_centre(8, 4)
assert_true(grid.has_line_of_sight(a, b), "clear floor between them")
grid.set_tile(4, 4, MapGrid.Kind.PIT)
assert_true(grid.has_line_of_sight(a, b), "a pit is a hole, not a screen")
grid.set_tile(4, 4, MapGrid.Kind.PILLAR)
assert_false(grid.has_line_of_sight(a, b), "a pillar blocks it")
grid.set_tile(4, 4, MapGrid.Kind.BARRICADE)
assert_true(grid.has_line_of_sight(a, b), "you can see over a barricade")
func test_line_of_sight_is_symmetric() -> void:
grid.set_tile(5, 4, MapGrid.Kind.WALL)
var a := grid.tile_centre(1, 4)
var b := grid.tile_centre(8, 4)
assert_eq(grid.has_line_of_sight(a, b), grid.has_line_of_sight(b, a),
"asymmetric sight would mean an enemy can shoot you from cover you " +
"cannot shoot back into")
func test_standing_inside_a_wall_can_still_see_out() -> void:
# Endpoints must not block, or an actor clipped into geometry goes blind
# and its aggro check silently fails.
var inside := grid.tile_centre(0, 0)
var outside := grid.tile_centre(1, 1)
assert_true(grid.has_line_of_sight(inside, outside))