Files
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

63 lines
2.8 KiB
GDScript

extends GutTest
## Movement is the one function the client is allowed to run ahead of the
## server, so client prediction is only correct while these hold.
var map: MapGrid
func before_each() -> void:
# A plain walled room, big enough that these tests are about movement
# rather than about walls.
map = MapGrid.new(42, 24, MapGrid.Kind.WALL)
map.fill_rect(Rect2i(1, 1, 40, 22), MapGrid.Kind.FLOOR)
map.centre_on_origin()
func test_diagonal_is_not_faster_than_cardinal() -> void:
var straight := Movement.step_player(Vector2.ZERO, Vector2(1, 0), SimConfig.PLAYER_SPEED, map)
var diagonal := Movement.step_player(Vector2.ZERO, Vector2(1, 1), SimConfig.PLAYER_SPEED, map)
assert_almost_eq(diagonal.length(), straight.length(), 0.001,
"a diagonal must cover the same distance as a cardinal move")
func test_oversized_input_vector_is_clamped() -> void:
# The wire format cannot express this, but a patched client could try.
var cheated := Movement.step_player(Vector2.ZERO, Vector2(1000, 0), SimConfig.PLAYER_SPEED, map)
var honest := Movement.step_player(Vector2.ZERO, Vector2(1, 0), SimConfig.PLAYER_SPEED, map)
assert_eq(cheated, honest, "an over-long move vector must buy no extra speed")
func test_speed_matches_config() -> void:
var moved := Movement.step_player(Vector2.ZERO, Vector2.RIGHT, SimConfig.PLAYER_SPEED, map)
assert_almost_eq(moved.x, SimConfig.PLAYER_SPEED * SimConfig.TICK_DELTA, 0.001)
func test_a_player_cannot_walk_through_the_map_edge() -> void:
var far := Vector2(map.world_rect().end.x - MapGrid.TILE * 1.5, 0.0)
var out := Movement.step_player(far, Vector2.RIGHT, 10000.0, map)
assert_lt(out.x, map.world_rect().end.x - MapGrid.TILE,
"the border wall has to stop even an absurd step")
func test_circles_overlap_at_the_boundary() -> void:
assert_true(Movement.circles_overlap(Vector2.ZERO, 5.0, Vector2(9.9, 0.0), 5.0))
assert_false(Movement.circles_overlap(Vector2.ZERO, 5.0, Vector2(10.1, 0.0), 5.0))
func test_outside_map_respects_the_cull_margin() -> void:
var edge := map.world_rect().end.x + SimConfig.BULLET_CULL_MARGIN
assert_false(Movement.outside_map(Vector2(edge - 1.0, 0.0), map))
assert_true(Movement.outside_map(Vector2(edge + 1.0, 0.0), map))
## Out-of-map death is derivable from the map's dimensions, which every client
## is told; wall death is not, because tiles are streamed. Only the second one
## needs announcing, and conflating them would either desync bullets or double
## the despawn traffic.
func test_map_bounds_and_wall_deaths_are_distinguishable() -> void:
var outside := Vector2(map.world_rect().end.x + 100.0, 0.0)
assert_true(Movement.outside_map(outside, map))
var in_wall := map.tile_centre(0, 12)
assert_false(Movement.outside_map(in_wall, map), "a border wall is inside the map")
assert_true(Movement.bullet_stopped(in_wall, map))