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)
|
||||
|
||||
Reference in New Issue
Block a user