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
+36
View File
@@ -290,3 +290,39 @@ static func decode_roster(data: PackedByteArray) -> Array[Dictionary]:
"alive": b.get_u8() == 1,
})
return out
# --- Map chunks -------------------------------------------------------------
static func encode_map_chunks(map: MapGrid, ids: Array) -> PackedByteArray:
var b := StreamPeerBuffer.new()
b.big_endian = false
b.put_u8(mini(ids.size(), 255))
for id in ids:
b.put_u16(int(id))
b.put_data(map.encode_chunk(int(id)))
return b.data_array
## Applies straight into [param map]. Chunk sizes come from the map's own
## dimensions, which the client learned at enter_instance, so a truncated or
## hostile packet cannot make it read past the end.
static func decode_map_chunks_into(map: MapGrid, data: PackedByteArray) -> int:
if data.size() < 1:
return 0
var b := StreamPeerBuffer.new()
b.big_endian = false
b.data_array = data
var count := b.get_u8()
var applied := 0
for _i in count:
if b.get_available_bytes() < 2:
break
var id := b.get_u16()
var r := map.chunk_rect(id)
var n := r.size.x * r.size.y
if n <= 0 or b.get_available_bytes() < n:
break
map.apply_chunk(id, b.get_data(n)[1])
applied += 1
return applied