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
+73 -47
View File
@@ -20,11 +20,17 @@ var peers: Array[int] = []
var state: State = State.ACTIVE
var age: int = 0
var seed_value: int = 0
## Drives dungeon size and difficulty. The hub is always depth 0.
var depth: int = 0
## Dungeon progression. -1 is the pre-fight breather.
var stage: int = -1
var stage_delay: int = 0
var boss_id: StringName = &""
## Generated layout, used to place enemies room by room.
var rooms: Array[Rect2i] = []
var boss_spawn := Vector2.ZERO
var boss_room := Rect2i()
static func make_lobby(instance_id: int) -> Instance:
@@ -33,33 +39,88 @@ static func make_lobby(instance_id: int) -> Instance:
inst.kind = Protocol.InstanceKind.LOBBY
inst.seed_value = 1
inst.world = SimWorld.new(inst.seed_value)
var built := MapGen.build(Protocol.InstanceKind.LOBBY, inst.seed_value, 0)
inst.world.set_map(built["grid"])
inst.world.portal_enabled = true
inst.world.spawn_point = Vector2(0.0, 120.0)
inst.world.portal_pos = built["portal"]
inst.world.spawn_point = built["spawn"]
inst.state = State.ACTIVE
# A single inert dummy so players can feel out the gun before committing to
# a run. It has no emitters and no contact damage.
inst.world.spawn_enemy(Content.dummy(), Vector2(-220.0, -40.0))
# A single inert practice target so players can feel out the gun before
# committing to a run.
inst.world.spawn_enemy(Content.dummy(), built["dummy"])
return inst
static func make_dungeon(instance_id: int, dungeon_seed: int) -> Instance:
static func make_dungeon(instance_id: int, dungeon_seed: int, dungeon_depth: int = 1) -> Instance:
var inst := Instance.new()
inst.id = instance_id
inst.kind = Protocol.InstanceKind.DUNGEON
inst.seed_value = dungeon_seed
inst.depth = maxi(dungeon_depth, 1)
inst.world = SimWorld.new(dungeon_seed)
inst.world.spawn_point = Vector2(0.0, 260.0)
var built := MapGen.build(Protocol.InstanceKind.DUNGEON, dungeon_seed, inst.depth)
inst.world.set_map(built["grid"])
inst.rooms = built["rooms"]
inst.boss_spawn = built["boss_pos"]
inst.boss_room = built["boss_room"]
inst.world.spawn_point = built["spawn"]
# Arriving into a fight already in progress needs a moment of protection;
# arriving in the hub does not.
inst.world.spawn_grace_ticks = SimConfig.SPAWN_GRACE_TICKS
inst.boss_id = Content.BOSS_WARDEN
inst.state = State.FORMING
if GameOpts.boss_rush:
# Dev switch: the next stage advance lands on the boss.
inst.stage = 1
inst._populate()
return inst
## Enemies are placed once, per room, at generation -- not spawned in waves.
## A dungeon you explore has to be populated before you arrive in it: waves
## would make the map's contents depend on when you walk in rather than where,
## and aggro range would have nothing left to gate.
func _populate() -> void:
var rng := RandomNumberGenerator.new()
rng.seed = seed_value ^ 0x5eed
var boss := world.spawn_boss(Content.boss(boss_id))
boss.pos = boss_spawn
boss.room = _room_rect_world(boss_room)
GameLog.info("instance", "BOSS_SPAWNED %s in instance %d" % [boss_id, id])
if GameOpts.boss_rush:
return # dev switch: an empty dungeon with just the fight in it
for i in rooms.size():
# Room 0 holds the entrance; arriving into a fight you cannot see yet
# is not a decision, it is an ambush.
if i == 0:
continue
var r: Rect2i = rooms[i]
var count := rng.randi_range(1, 3 + depth / 2)
for _n in count:
var def := _pick_enemy(rng)
var tx := rng.randi_range(r.position.x + 1, r.end.x - 2)
var ty := rng.randi_range(r.position.y + 1, r.end.y - 2)
var at := world.map.tile_centre(tx, ty)
if world.map.circle_blocked(at, def.radius):
continue
world.spawn_enemy(def, at, rng.randi_range(0, 120))
func _pick_enemy(rng: RandomNumberGenerator) -> EnemyDef:
var roll := rng.randf()
if roll < 0.4:
return Content.drifter()
if roll < 0.75:
return Content.turret()
return Content.stalker()
## Tile-space room rect to world space, for confining a boss to its arena.
func _room_rect_world(r: Rect2i) -> Rect2:
if r.size == Vector2i.ZERO:
return Rect2()
var lo := world.map.tile_centre(r.position.x + 1, r.position.y + 1)
var hi := world.map.tile_centre(r.end.x - 2, r.end.y - 2)
return Rect2(lo, hi - lo)
func add_peer(peer_id: int, display_name: String) -> void:
if not peers.has(peer_id):
peers.append(peer_id)
@@ -118,11 +179,9 @@ func step() -> void:
_step_dungeon()
## Straight-line progression: two trash waves, then the boss. Waves gate on
## "everything dead" rather than a timer so a slow party is never overrun.
## The dungeon is fully populated at generation, so all that is left to track
## is the party forming and the boss dying.
func _step_dungeon() -> void:
# The delay has to tick down before the CLEARED check, or a cleared dungeon
# would sit on its exit timer forever and never release its party.
if stage_delay > 0:
stage_delay -= 1
return
@@ -132,31 +191,13 @@ func _step_dungeon() -> void:
if state == State.FORMING:
if age >= SimConfig.DUNGEON_FORMING_TICKS or peers.size() >= SimConfig.DUNGEON_PARTY_MAX:
state = State.ACTIVE
# stage is left as make_dungeon set it -- resetting it here would
# silently undo the --boss-rush dev switch.
stage_delay = 90
return
if stage >= 0 and _live_enemy_count() > 0:
return
if stage == 2:
if world.boss != null and world.boss.alive:
return
if world.boss != null and not world.boss.alive:
state = State.CLEARED
stage_delay = SimConfig.DUNGEON_CLEARED_EXIT_TICKS
GameLog.info("instance", "instance %d CLEARED, returning party in %ds" % [
id, SimConfig.DUNGEON_CLEARED_EXIT_TICKS / SimConfig.TICK_RATE])
return
stage += 1
stage_delay = 60
match stage:
0: _spawn_wave_one()
1: _spawn_wave_two()
2:
world.spawn_boss(Content.boss(boss_id))
GameLog.info("instance", "BOSS_SPAWNED %s in instance %d" % [boss_id, id])
GameLog.debug("instance", "instance %d entered stage %d" % [id, stage])
func _live_enemy_count() -> int:
@@ -165,18 +206,3 @@ func _live_enemy_count() -> int:
if e.alive:
n += 1
return n
func _spawn_wave_one() -> void:
for i in 3:
world.spawn_enemy(Content.drifter(), Vector2(-320.0 + 320.0 * float(i), -120.0), i * 40)
world.spawn_enemy(Content.turret(), Vector2(-420.0, -220.0), 0)
world.spawn_enemy(Content.turret(), Vector2(420.0, -220.0), 75)
func _spawn_wave_two() -> void:
for i in 4:
world.spawn_enemy(Content.stalker(), Vector2(-300.0 + 200.0 * float(i), -260.0), i * 15)
for i in 3:
world.spawn_enemy(Content.turret(), Vector2(-380.0 + 380.0 * float(i), -60.0), i * 50)
world.spawn_enemy(Content.drifter(), Vector2(0.0, -300.0), 20)