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.
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
extends GutTest
|
||||
## Progression through a whole dungeon instance: forming, two trash waves, the
|
||||
## boss, and the cleared state that sends the party home.
|
||||
## A dungeon run, end to end: a generated map populated at creation, explored
|
||||
## rather than survived, and cleared by killing the boss.
|
||||
|
||||
var inst: Instance
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
inst = Instance.make_dungeon(2, 12345)
|
||||
inst = Instance.make_dungeon(2, 12345, 1)
|
||||
inst.add_peer(1, "tester")
|
||||
|
||||
|
||||
@@ -15,9 +15,26 @@ func _step(n: int) -> void:
|
||||
inst.step()
|
||||
|
||||
|
||||
func _kill_all_enemies() -> void:
|
||||
func test_a_dungeon_is_populated_when_it_is_created() -> void:
|
||||
# Not spawned in waves: the map has contents before anyone walks in, which
|
||||
# is what makes exploring a decision instead of a countdown.
|
||||
assert_gt(inst.world.enemies.size(), 0, "the rooms should have occupants")
|
||||
assert_not_null(inst.world.boss)
|
||||
assert_true(inst.world.boss.alive)
|
||||
|
||||
|
||||
func test_the_boss_starts_inside_its_own_room() -> void:
|
||||
assert_true(inst.boss_room.size != Vector2i.ZERO, "a boss room must exist")
|
||||
assert_true(inst.world.boss.room.has_point(inst.world.boss.pos),
|
||||
"the boss has to start inside the arena it is confined to")
|
||||
|
||||
|
||||
func test_nothing_spawns_inside_geometry() -> void:
|
||||
for e in inst.world.enemies.values():
|
||||
e.alive = false
|
||||
assert_false(inst.world.map.circle_blocked(e.pos, e.def.radius),
|
||||
"%s spawned inside a wall" % e.def.id)
|
||||
assert_false(inst.world.map.circle_blocked(inst.world.spawn_point,
|
||||
SimConfig.PLAYER_RADIUS), "the party would arrive inside a wall")
|
||||
|
||||
|
||||
func test_a_forming_dungeon_locks_after_the_window() -> void:
|
||||
@@ -34,53 +51,45 @@ func test_a_full_party_locks_the_dungeon_immediately() -> void:
|
||||
assert_false(inst.accepts_new_party_member())
|
||||
|
||||
|
||||
func test_waves_gate_on_clearing_the_previous_one() -> void:
|
||||
_step(SimConfig.DUNGEON_FORMING_TICKS + 200)
|
||||
assert_eq(inst.stage, 0)
|
||||
var wave_one := inst.world.enemies.size()
|
||||
assert_gt(wave_one, 0)
|
||||
|
||||
_step(200)
|
||||
assert_eq(inst.stage, 0, "a slow party must never be overrun by the next wave")
|
||||
|
||||
_kill_all_enemies()
|
||||
_step(120)
|
||||
assert_eq(inst.stage, 1)
|
||||
assert_gt(inst.world.enemies.size(), wave_one)
|
||||
|
||||
|
||||
func test_the_boss_arrives_after_the_last_wave() -> void:
|
||||
_step(SimConfig.DUNGEON_FORMING_TICKS + 200)
|
||||
for _wave in 2:
|
||||
_kill_all_enemies()
|
||||
_step(120)
|
||||
assert_eq(inst.stage, 2)
|
||||
assert_not_null(inst.world.boss)
|
||||
assert_eq(inst.world.boss.def.id, Content.BOSS_WARDEN)
|
||||
|
||||
|
||||
func test_killing_the_boss_clears_the_instance() -> void:
|
||||
_step(SimConfig.DUNGEON_FORMING_TICKS + 200)
|
||||
for _wave in 2:
|
||||
_kill_all_enemies()
|
||||
_step(120)
|
||||
_step(SimConfig.DUNGEON_FORMING_TICKS + 5)
|
||||
inst.world.boss.alive = false
|
||||
_step(5)
|
||||
assert_eq(inst.state, Instance.State.CLEARED)
|
||||
assert_almost_eq(inst.exit_countdown_seconds(),
|
||||
SimConfig.DUNGEON_CLEARED_EXIT_TICKS / SimConfig.TICK_RATE, 1,
|
||||
"the party gets a visible countdown, not an instant boot")
|
||||
# The exit timer has to run down, or the party would never be released.
|
||||
SimConfig.DUNGEON_CLEARED_EXIT_TICKS / SimConfig.TICK_RATE, 1)
|
||||
_step(SimConfig.DUNGEON_CLEARED_EXIT_TICKS + 10)
|
||||
assert_eq(inst.stage_delay, 0)
|
||||
assert_eq(inst.exit_countdown_seconds(), 0)
|
||||
|
||||
|
||||
## Trash left alive must not keep the run open -- you clear a dungeon by
|
||||
## beating the boss, not by sweeping every corner of the map.
|
||||
func test_leftover_enemies_do_not_block_clearing() -> void:
|
||||
_step(SimConfig.DUNGEON_FORMING_TICKS + 5)
|
||||
assert_gt(inst.world.enemies.size(), 0, "setup: enemies should remain")
|
||||
inst.world.boss.alive = false
|
||||
_step(5)
|
||||
assert_eq(inst.state, Instance.State.CLEARED)
|
||||
|
||||
|
||||
func test_deeper_dungeons_are_larger() -> void:
|
||||
var deep := Instance.make_dungeon(3, 12345, 5)
|
||||
assert_gt(deep.world.map.width, inst.world.map.width)
|
||||
|
||||
|
||||
# --- The hub ----------------------------------------------------------------
|
||||
|
||||
func _lobby() -> Instance:
|
||||
var l := Instance.make_lobby(SimConfig.LOBBY_INSTANCE_ID)
|
||||
l.add_peer(1, "tester")
|
||||
return l
|
||||
|
||||
|
||||
func test_the_lobby_has_a_portal_and_no_hostiles() -> void:
|
||||
var lobby := Instance.make_lobby(SimConfig.LOBBY_INSTANCE_ID)
|
||||
lobby.add_peer(1, "tester")
|
||||
var lobby := _lobby()
|
||||
assert_true(lobby.world.portal_enabled)
|
||||
_step(1)
|
||||
assert_ne(lobby.world.portal_pos, Vector2.ZERO, "the portal comes from the map")
|
||||
for _i in 600:
|
||||
lobby.step()
|
||||
assert_eq(lobby.world.pool.live_count, 0, "nothing in the hub may shoot at you")
|
||||
@@ -88,9 +97,8 @@ func test_the_lobby_has_a_portal_and_no_hostiles() -> void:
|
||||
|
||||
|
||||
func test_interacting_on_the_portal_asks_for_a_dungeon() -> void:
|
||||
var lobby := Instance.make_lobby(SimConfig.LOBBY_INSTANCE_ID)
|
||||
lobby.add_peer(1, "tester")
|
||||
lobby.world.players[1].pos = SimConfig.PORTAL_POS
|
||||
var lobby := _lobby()
|
||||
lobby.world.players[1].pos = lobby.world.portal_pos
|
||||
var frames: Array[InputFrame] = [
|
||||
InputFrame.make(lobby.world.tick + 1, Vector2.ZERO, 0.0, InputFrame.BTN_INTERACT)]
|
||||
lobby.world.queue_input(1, frames)
|
||||
@@ -101,9 +109,9 @@ func test_interacting_on_the_portal_asks_for_a_dungeon() -> void:
|
||||
|
||||
|
||||
func test_interacting_away_from_the_portal_does_nothing() -> void:
|
||||
var lobby := Instance.make_lobby(SimConfig.LOBBY_INSTANCE_ID)
|
||||
lobby.add_peer(1, "tester")
|
||||
lobby.world.players[1].pos = SimConfig.PORTAL_POS + Vector2(0.0, SimConfig.PORTAL_RADIUS + 50.0)
|
||||
var lobby := _lobby()
|
||||
lobby.world.players[1].pos = lobby.world.portal_pos \
|
||||
+ Vector2(0.0, SimConfig.PORTAL_RADIUS + 80.0)
|
||||
var frames: Array[InputFrame] = [
|
||||
InputFrame.make(lobby.world.tick + 1, Vector2.ZERO, 0.0, InputFrame.BTN_INTERACT)]
|
||||
lobby.world.queue_input(1, frames)
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
extends GutTest
|
||||
## Aggro is what turns a populated map into an explorable one: a dungeon has to
|
||||
## be quiet until you engage it, and cover has to actually protect you.
|
||||
|
||||
var world: SimWorld
|
||||
var map: MapGrid
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
map = MapGrid.new(40, 20, MapGrid.Kind.WALL)
|
||||
map.fill_rect(Rect2i(1, 1, 38, 18), MapGrid.Kind.FLOOR)
|
||||
map.centre_on_origin()
|
||||
world = SimWorld.new(1)
|
||||
world.set_map(map)
|
||||
|
||||
|
||||
func _turret_at(tx: int, ty: int) -> SimEnemy:
|
||||
return world.spawn_enemy(Content.turret(), map.tile_centre(tx, ty))
|
||||
|
||||
|
||||
func _player_at(tx: int, ty: int) -> SimPlayer:
|
||||
var p := world.add_player(1, "tester")
|
||||
p.pos = map.tile_centre(tx, ty)
|
||||
p.spawn_grace = 0
|
||||
return p
|
||||
|
||||
|
||||
func test_an_enemy_out_of_range_never_fires() -> void:
|
||||
var e := _turret_at(2, 10)
|
||||
_player_at(37, 10)
|
||||
assert_gt(e.pos.distance_to(world.players[1].pos), Content.turret().aggro_range,
|
||||
"setup: the player must actually be out of range")
|
||||
for _i in 400:
|
||||
world.step()
|
||||
assert_eq(world.pool.live_count, 0,
|
||||
"a dungeon has to stay quiet until you walk into it")
|
||||
assert_null(e.target)
|
||||
|
||||
|
||||
func test_an_enemy_in_range_and_in_sight_fires() -> void:
|
||||
var e := _turret_at(10, 10)
|
||||
_player_at(16, 10)
|
||||
for _i in 400:
|
||||
world.step()
|
||||
assert_gt(world.pool.live_count, 0)
|
||||
assert_not_null(e.target)
|
||||
|
||||
|
||||
## Cover is the whole point of walls. An enemy that shoots through them makes
|
||||
## every wall decorative.
|
||||
func test_a_wall_between_them_breaks_aggro() -> void:
|
||||
var e := _turret_at(10, 10)
|
||||
_player_at(16, 10)
|
||||
for ty in range(1, 19):
|
||||
map.set_tile(13, ty, MapGrid.Kind.WALL)
|
||||
for _i in 400:
|
||||
world.step()
|
||||
assert_eq(world.pool.live_count, 0, "no line of sight, no shooting")
|
||||
assert_null(e.target)
|
||||
|
||||
|
||||
func test_a_barricade_does_not_break_aggro() -> void:
|
||||
# You can see over a barricade, so it does not hide you -- it only stops
|
||||
# what you and it are shooting.
|
||||
var e := _turret_at(10, 10)
|
||||
_player_at(16, 10)
|
||||
for ty in range(1, 19):
|
||||
map.set_tile(13, ty, MapGrid.Kind.BARRICADE)
|
||||
for _i in 200:
|
||||
world.step()
|
||||
assert_not_null(e.target, "a chest-high barricade is not concealment")
|
||||
|
||||
|
||||
func test_a_chasing_enemy_stops_when_it_loses_you() -> void:
|
||||
var e := world.spawn_enemy(Content.stalker(), map.tile_centre(10, 10))
|
||||
var p := _player_at(14, 10)
|
||||
for _i in 30:
|
||||
world.step()
|
||||
var closed: float = e.pos.distance_to(p.pos)
|
||||
assert_lt(closed, map.tile_centre(10, 10).distance_to(p.pos),
|
||||
"setup: the stalker should have closed some distance")
|
||||
|
||||
# Walk out of range; it must give up rather than follow across the map.
|
||||
p.pos = map.tile_centre(37, 18)
|
||||
var before: Vector2 = e.pos
|
||||
for _i in 120:
|
||||
world.step()
|
||||
assert_almost_eq(e.pos.distance_to(before), 0.0, 0.001,
|
||||
"an enemy that never disengages makes retreating impossible")
|
||||
@@ -0,0 +1 @@
|
||||
uid://demetqlhmetq6
|
||||
@@ -3,10 +3,17 @@ extends GutTest
|
||||
## would end the run, so its slot bookkeeping is tested directly.
|
||||
|
||||
var pool: BulletPool
|
||||
var map: MapGrid
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
pool = BulletPool.new()
|
||||
# Bullets now die against real geometry rather than a global rectangle, so
|
||||
# the pool needs a map to cull against at all.
|
||||
map = MapGrid.new(42, 24, MapGrid.Kind.WALL)
|
||||
map.fill_rect(Rect2i(1, 1, 40, 22), MapGrid.Kind.FLOOR)
|
||||
map.centre_on_origin()
|
||||
pool.map = map
|
||||
|
||||
|
||||
func _spawn(p := Vector2.ZERO, v := Vector2(100, 0), life := 60) -> int:
|
||||
@@ -57,11 +64,45 @@ func test_bullets_expire_at_end_of_life() -> void:
|
||||
assert_eq(pool.live_count, 0)
|
||||
|
||||
|
||||
func test_bullets_leaving_the_arena_are_culled() -> void:
|
||||
var start := Vector2(SimConfig.ARENA_HALF.x, 0.0)
|
||||
func test_bullets_leaving_the_map_are_culled() -> void:
|
||||
var start := Vector2(map.world_rect().end.x, 0.0)
|
||||
var a := _spawn(start, Vector2(100000, 0), 600)
|
||||
pool.step()
|
||||
assert_eq(pool.alive[a], 0, "a bullet past the cull margin must not linger")
|
||||
assert_eq(pool.wall_kill_log.size(), 0,
|
||||
"leaving the map is derivable from the map size, so it is not announced")
|
||||
|
||||
|
||||
func test_bullets_stopped_by_a_wall_are_logged_for_announcement() -> void:
|
||||
# A client is only streamed the map near itself, so it cannot work out that
|
||||
# a bullet hit a wall it has never been sent. The server has to say so.
|
||||
var a := _spawn(map.tile_centre(2, 12), Vector2(-600.0, 0.0), 600)
|
||||
var id: int = pool.uid[a]
|
||||
for _i in 10:
|
||||
pool.step()
|
||||
assert_eq(pool.alive[a], 0)
|
||||
assert_true(pool.wall_kill_log.has(id), "wall deaths must be announceable")
|
||||
|
||||
|
||||
## Wall collision samples the bullet's position once per tick, so a bullet that
|
||||
## travels more than one tile per tick can step straight over a wall. Nothing in
|
||||
## the game comes close today, but a future "fast projectile" upgrade could, and
|
||||
## it would look like walls randomly failing.
|
||||
func test_bullet_speeds_stay_below_the_tunnelling_threshold() -> void:
|
||||
var limit := MapGrid.TILE / SimConfig.TICK_DELTA
|
||||
assert_lt(SimConfig.PLAYER_BULLET_SPEED * 2.0, limit,
|
||||
"even at the doubled speed an upgrade could grant, a bullet must not " +
|
||||
"cross a whole tile in one tick or it will tunnel through walls")
|
||||
|
||||
|
||||
func test_a_pool_with_no_map_does_not_cull() -> void:
|
||||
# Standalone pools (used by emitter tests) have no geometry; culling
|
||||
# against nothing would silently delete their bullets.
|
||||
var bare := BulletPool.new()
|
||||
var a := bare.spawn(Vector2(1.0e6, 0.0), Vector2.ZERO, 5.0, 60, 10,
|
||||
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
|
||||
bare.step()
|
||||
assert_eq(bare.alive[a], 1)
|
||||
|
||||
|
||||
func test_pool_saturation_returns_minus_one_rather_than_growing() -> void:
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
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])
|
||||
@@ -0,0 +1 @@
|
||||
uid://b8h7yr5onlhsb
|
||||
@@ -0,0 +1,104 @@
|
||||
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))
|
||||
@@ -0,0 +1 @@
|
||||
uid://bxisk7s3jdti7
|
||||
+36
-13
@@ -2,30 +2,41 @@ 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, SimConfig.ARENA_HALF)
|
||||
var diagonal := Movement.step_player(Vector2.ZERO, Vector2(1, 1), SimConfig.PLAYER_SPEED, SimConfig.ARENA_HALF)
|
||||
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, SimConfig.ARENA_HALF)
|
||||
var honest := Movement.step_player(Vector2.ZERO, Vector2(1, 0), SimConfig.PLAYER_SPEED, SimConfig.ARENA_HALF)
|
||||
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, SimConfig.ARENA_HALF)
|
||||
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_player_is_clamped_to_the_arena() -> void:
|
||||
var far := Vector2(SimConfig.ARENA_HALF.x - 1.0, 0.0)
|
||||
var out := Movement.step_player(far, Vector2.RIGHT, 10000.0, SimConfig.ARENA_HALF)
|
||||
assert_almost_eq(out.x, SimConfig.ARENA_HALF.x, 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:
|
||||
@@ -33,7 +44,19 @@ func test_circles_overlap_at_the_boundary() -> void:
|
||||
assert_false(Movement.circles_overlap(Vector2.ZERO, 5.0, Vector2(10.1, 0.0), 5.0))
|
||||
|
||||
|
||||
func test_outside_arena_respects_the_cull_margin() -> void:
|
||||
var edge := SimConfig.ARENA_HALF.x + SimConfig.BULLET_CULL_MARGIN
|
||||
assert_false(Movement.outside_arena(Vector2(edge - 1.0, 0.0)))
|
||||
assert_true(Movement.outside_arena(Vector2(edge + 1.0, 0.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))
|
||||
|
||||
Reference in New Issue
Block a user