Stage 1: tile maps, walls, fog of war, aggro, scrolling camera
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.
This commit is contained in:
2026-09-03 20:49:27 +02:00
parent e1f9fa8096
commit 7af439341d
34 changed files with 1522 additions and 196 deletions
+54 -46
View File
@@ -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)