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
+7
View File
@@ -27,6 +27,9 @@ static var listen: bool = false
## Server-side dev switch: new dungeons open straight onto the boss, skipping
## the trash waves. For iterating on a pattern without clearing two waves first.
static var boss_rush: bool = false
## Dev switch: depth of newly opened dungeons, which drives map size. Depth
## progression is a later-stage concern; this makes big maps testable now.
static var dungeon_depth: int = 1
static var parsed: bool = false
@@ -51,6 +54,10 @@ static func parse(argv: PackedStringArray = PackedStringArray()) -> void:
listen = true
"--boss-rush":
boss_rush = true
"--depth":
i += 1
if i < argv.size():
dungeon_depth = int(argv[i])
"--port":
i += 1
if i < argv.size():
+22 -9
View File
@@ -12,11 +12,15 @@ static func sanitize_move(raw: Vector2) -> Vector2:
## One tick of player locomotion. Returns the new position.
static func step_player(pos: Vector2, move: Vector2, speed: float, bounds: Vector2) -> Vector2:
var next := pos + sanitize_move(move) * speed * SimConfig.TICK_DELTA
next.x = clampf(next.x, -bounds.x, bounds.x)
next.y = clampf(next.y, -bounds.y, bounds.y)
return next
##
## Collision resolution lives in [MapGrid.slide_circle], and the client runs
## this exact call during prediction and reconciliation -- so the map the client
## holds has to match the server's, which is why maps are replicated in full
## even where fog hides them.
static func step_player(pos: Vector2, move: Vector2, speed: float, map: MapGrid,
radius: float = SimConfig.PLAYER_RADIUS) -> Vector2:
var delta := sanitize_move(move) * speed * SimConfig.TICK_DELTA
return map.slide_circle(pos, delta, radius)
## Circle-vs-circle overlap test used for every hit in the game.
@@ -25,7 +29,16 @@ static func circles_overlap(a: Vector2, ar: float, b: Vector2, br: float) -> boo
return a.distance_squared_to(b) <= r * r
## True when a point has drifted outside the arena plus the cull margin.
static func outside_arena(p: Vector2, margin: float = SimConfig.BULLET_CULL_MARGIN) -> bool:
return absf(p.x) > SimConfig.ARENA_HALF.x + margin \
or absf(p.y) > SimConfig.ARENA_HALF.y + margin
## Outside the map's extent. Derivable on both sides from the map's dimensions
## alone, so it needs no announcement even when tiles have not been streamed.
static func outside_map(p: Vector2, map: MapGrid) -> bool:
if map == null:
return false
return not map.world_rect().grow(SimConfig.BULLET_CULL_MARGIN).has_point(p)
## Outside the map, or stopped by geometry.
static func bullet_stopped(p: Vector2, map: MapGrid) -> bool:
if map == null:
return false
return outside_map(p, map) or map.bullet_blocked(p)
+17 -3
View File
@@ -13,8 +13,7 @@ const SNAPSHOT_INTERVAL := 3
const INTERPOLATION_DELAY_TICKS := 6
# --- Arena ------------------------------------------------------------------
const ARENA_HALF := Vector2(620.0, 340.0)
## Bullets are culled once they leave the arena by this margin.
## Bullets are culled once they leave the map by this margin.
const BULLET_CULL_MARGIN := 64.0
# --- Player -----------------------------------------------------------------
@@ -113,6 +112,21 @@ const DUNGEON_FORMING_TICKS := 300
## player through the portal opens a fresh instance.
const DUNGEON_CLEARED_EXIT_TICKS := 1800 # 30 seconds
# --- Fog of war -------------------------------------------------------------
## How far a player can see. Comfortably inside MAP_STREAM_RADIUS so the client
## always holds the terrain it is about to reveal.
const FOG_VIEW_RADIUS := 460.0
# --- Map streaming ----------------------------------------------------------
## How far around a player the server streams map tiles. Comfortably wider than
## the viewport so movement prediction and bullet simulation always run against
## known geometry, and comfortably narrower than a dungeon so a modified client
## still cannot draw the whole thing.
const MAP_STREAM_RADIUS := 900.0
## Chunks sent to one peer per snapshot tick. Caps the burst when a player
## arrives or sprints into virgin territory.
const MAP_CHUNKS_PER_TICK := 6
# --- Portal -----------------------------------------------------------------
const PORTAL_POS := Vector2(0.0, -220.0)
## Position is per-world now (SimWorld.portal_pos), taken from the hub's map.
const PORTAL_RADIUS := 60.0