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
+26 -2
View File
@@ -20,6 +20,10 @@ var team := PackedByteArray()
var kind := PackedByteArray()
var alive := PackedByteArray()
## The geometry bullets die against. Set by the owning SimWorld; identical on
## server and client, which is what keeps the replica in step.
var map: MapGrid = null
var live_count := 0
## Exclusive upper bound over slots that have ever been used, so the hot loops
## do not walk the whole 4096-slot table while the arena is nearly empty.
@@ -33,6 +37,13 @@ var _next_uid := 1
## arrive from the wire already.
var spawn_log := PackedInt32Array()
## uids of bullets killed by hitting solid geometry this tick. Lifetime and
## out-of-bounds deaths are NOT recorded: both sides can derive those from the
## spawn event alone. A wall death cannot be derived by a client that has not
## been streamed that wall yet, so the server announces those explicitly --
## otherwise partial map knowledge shows bullets sailing through walls.
var wall_kill_log := PackedInt32Array()
func _init() -> void:
var n := SimConfig.MAX_BULLETS
@@ -55,6 +66,7 @@ func clear() -> void:
uid[i] = 0
_free.clear()
spawn_log.clear()
wall_kill_log.clear()
live_count = 0
high_water = 0
@@ -97,6 +109,7 @@ func spawn(p: Vector2, v: Vector2, r: float, lifetime: int, dmg: int,
func clear_spawn_log() -> void:
spawn_log.clear()
wall_kill_log.clear()
func despawn(slot: int) -> void:
@@ -134,7 +147,17 @@ func step() -> void:
var p: Vector2 = pos[i] + v * dt
pos[i] = p
life[i] -= 1
if life[i] <= 0 or Movement.outside_arena(p):
if life[i] <= 0:
despawn(i)
continue
# Bounds first: out-of-bounds tiles read as WALL by design (that is what
# seals the world), so testing geometry first would report every bullet
# that simply left the map as a wall kill and announce it needlessly.
if Movement.outside_map(p, map):
despawn(i)
continue
if map != null and map.bullet_blocked(p):
wall_kill_log.append(uid[i])
despawn(i)
@@ -156,6 +179,7 @@ func advance_slot(slot: int, ticks: int) -> void:
vel[slot] = v
pos[slot] = pos[slot] + v * dt
life[slot] -= 1
if life[slot] <= 0 or Movement.outside_arena(pos[slot]):
if life[slot] <= 0 or Movement.bullet_stopped(pos[slot], map):
despawn(slot)
return