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.
90 lines
2.6 KiB
GDScript
90 lines
2.6 KiB
GDScript
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")
|