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.
136 lines
4.7 KiB
GDScript
136 lines
4.7 KiB
GDScript
extends GutTest
|
|
## The pool is the hottest data structure in the game and the one place a leak
|
|
## 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:
|
|
return pool.spawn(p, v, 5.0, life, 10, SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
|
|
|
|
|
|
func test_spawn_assigns_unique_ids() -> void:
|
|
var a := _spawn()
|
|
var b := _spawn()
|
|
assert_ne(pool.uid[a], pool.uid[b])
|
|
assert_eq(pool.live_count, 2)
|
|
|
|
|
|
func test_despawned_slots_are_reused() -> void:
|
|
var a := _spawn()
|
|
pool.despawn(a)
|
|
assert_eq(pool.live_count, 0)
|
|
var b := _spawn()
|
|
assert_eq(b, a, "the free list should hand back the slot just released")
|
|
assert_eq(pool.high_water, 1, "reuse must not grow the scanned range")
|
|
|
|
|
|
func test_double_despawn_does_not_corrupt_the_count() -> void:
|
|
var a := _spawn()
|
|
pool.despawn(a)
|
|
pool.despawn(a)
|
|
assert_eq(pool.live_count, 0)
|
|
|
|
|
|
func test_step_integrates_velocity() -> void:
|
|
var a := _spawn(Vector2.ZERO, Vector2(600, 0))
|
|
pool.step()
|
|
assert_almost_eq(pool.pos[a].x, 600.0 * SimConfig.TICK_DELTA, 0.001)
|
|
|
|
|
|
func test_turn_curves_the_bullet() -> void:
|
|
var a := pool.spawn(Vector2.ZERO, Vector2(100, 0), 5.0, 60, 10,
|
|
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB, 0.0, deg_to_rad(90.0))
|
|
pool.step()
|
|
assert_almost_eq(pool.vel[a].angle(), deg_to_rad(90.0), 0.001)
|
|
|
|
|
|
func test_bullets_expire_at_end_of_life() -> void:
|
|
var a := _spawn(Vector2.ZERO, Vector2.ZERO, 3)
|
|
for _i in 3:
|
|
pool.step()
|
|
assert_eq(pool.alive[a], 0)
|
|
assert_eq(pool.live_count, 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:
|
|
for _i in SimConfig.MAX_BULLETS:
|
|
_spawn(Vector2.ZERO, Vector2.ZERO, 10000)
|
|
assert_eq(pool.live_count, SimConfig.MAX_BULLETS)
|
|
assert_eq(_spawn(), -1)
|
|
|
|
|
|
func test_advance_slot_matches_repeated_steps() -> void:
|
|
var a := pool.spawn(Vector2.ZERO, Vector2(150, 40), 5.0, 300, 10,
|
|
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB, 30.0, 0.02)
|
|
var other := BulletPool.new()
|
|
var b := other.spawn(Vector2.ZERO, Vector2(150, 40), 5.0, 300, 10,
|
|
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB, 30.0, 0.02)
|
|
pool.advance_slot(a, 12)
|
|
for _i in 12:
|
|
other.step()
|
|
# Client catch-up must land on exactly the server's integration, or bullets
|
|
# would drift a little further out of place with every packet.
|
|
assert_almost_eq(pool.pos[a].x, other.pos[b].x, 0.0001)
|
|
assert_almost_eq(pool.pos[a].y, other.pos[b].y, 0.0001)
|
|
|
|
|
|
func test_find_by_uid_only_matches_live_bullets() -> void:
|
|
var a := _spawn()
|
|
var id: int = pool.uid[a]
|
|
assert_eq(pool.find_by_uid(id), a)
|
|
pool.despawn(a)
|
|
assert_eq(pool.find_by_uid(id), -1)
|