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
+21 -4
View File
@@ -54,6 +54,8 @@ var roster: Array[Dictionary] = []
## Whole seconds until a cleared dungeon returns the party, or
## Protocol.COUNTDOWN_NONE outside that state.
var cleared_countdown: int = Protocol.COUNTDOWN_NONE
## Where this world's dungeon portal is. Per-map now, so it has to be told.
var portal_pos := Vector2.ZERO
## Backstop for input-numbering drift: if the server stops acknowledging new
## inputs, our tick numbering has fallen outside its acceptance window and no
@@ -93,7 +95,7 @@ func _physics_process(delta: float) -> void:
if my_alive:
predicted_pos = Movement.step_player(predicted_pos, frame.move,
SimConfig.PLAYER_SPEED, SimConfig.ARENA_HALF)
SimConfig.PLAYER_SPEED, world.map)
# Send the last few frames every tick. Inputs are unreliable-ordered, so the
# redundancy is what covers a dropped packet without a retransmit stall.
@@ -160,7 +162,7 @@ func _bot_input() -> InputFrame:
if instance_kind == Protocol.InstanceKind.LOBBY and _bot_tick % 120 < 30:
buttons |= InputFrame.BTN_INTERACT
# Walk onto the portal instead of orbiting, or interact never lands.
move = (SimConfig.PORTAL_POS - predicted_pos).normalized()
move = (portal_pos - predicted_pos).normalized()
if instance_kind == Protocol.InstanceKind.DUNGEON and _bot_tick > 900:
buttons |= InputFrame.BTN_ESCAPE
return InputFrame.make(input_tick, move, aim, buttons)
@@ -185,16 +187,31 @@ func _resync_input_tick(server_tick: int, why: String) -> void:
GameLog.warn("client", "input re-sync: %s" % why)
func on_map_chunks(from_instance: int, data: PackedByteArray) -> void:
# A chunk still in flight when we changed instances describes the wrong map.
if from_instance != instance_id or world.map == null:
return
NetCodec.decode_map_chunks_into(world.map, data)
func on_roster(data: PackedByteArray) -> void:
roster = NetCodec.decode_roster(data)
hud_dirty.emit()
func on_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
spawn: Vector2) -> void:
spawn: Vector2, map_w: int, map_h: int, portal: Vector2) -> void:
instance_id = id
instance_kind = kind as Protocol.InstanceKind
portal_pos = portal
boss_def = Content.boss(StringName(boss_id)) if not boss_id.is_empty() else null
# We are told how big the map is and nothing else. Every tile starts UNKNOWN
# and is filled in by streaming as the player moves, so the client never
# holds terrain it has not been near -- there is no seed here to regenerate
# from. Origin matches the server's centre_on_origin().
var blank := MapGrid.new(maxi(map_w, 1), maxi(map_h, 1), MapGrid.Kind.UNKNOWN)
blank.centre_on_origin()
world.set_map(blank)
world.pool.clear()
snap_prev = {}
snap_curr = {}
@@ -281,7 +298,7 @@ func _reconcile(rec: Dictionary) -> void:
var p: Vector2 = rec["pos"]
if my_alive:
for f in pending:
p = Movement.step_player(p, f.move, SimConfig.PLAYER_SPEED, SimConfig.ARENA_HALF)
p = Movement.step_player(p, f.move, SimConfig.PLAYER_SPEED, world.map)
var error := predicted_pos.distance_to(p)
if error > 24.0:
predicted_pos = p # real divergence: take the server's word