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
+15 -2
View File
@@ -11,15 +11,27 @@ var _bound: ClientRuntime = null
func _ready() -> void:
world_view.position = get_viewport_rect().size * 0.5
_recentre()
get_viewport().size_changed.connect(_recentre)
menu.return_to_hub_requested.connect(_on_return_to_hub)
menu.disconnect_requested.connect(_on_disconnect)
hud.respawn_pressed.connect(_on_respawn_pressed)
var _screen_centre := Vector2.ZERO
func _recentre() -> void:
world_view.position = get_viewport_rect().size * 0.5
_screen_centre = get_viewport_rect().size * 0.5
## Scroll the world so the local player stays centred. Dungeons are larger than
## the viewport now, so a fixed camera would simply lose the player off-screen.
func _follow_camera() -> void:
var focus := Vector2.ZERO
if _bound != null:
focus = _bound.predicted_pos
world_view.position = _screen_centre - focus
func _process(_delta: float) -> void:
@@ -27,6 +39,7 @@ func _process(_delta: float) -> void:
_bound = Net.client
if _bound != null and not _bound.local_hit.is_connected(_on_local_hit):
_bound.local_hit.connect(_on_local_hit)
_follow_camera()
menu.set_in_dungeon(_bound != null
and _bound.instance_kind == Protocol.InstanceKind.DUNGEON)
+72 -20
View File
@@ -7,6 +7,11 @@ extends Node2D
## decides anything. Every number it draws came from the server.
const COL_ARENA := Color(0.22, 0.24, 0.34)
const COL_FLOOR := Color(0.11, 0.12, 0.17)
const COL_WALL := Color(0.28, 0.30, 0.40)
const COL_PILLAR := Color(0.36, 0.34, 0.44)
const COL_PIT := Color(0.03, 0.03, 0.05)
const COL_BARRICADE := Color(0.30, 0.26, 0.22)
const COL_LOCAL := Color(0.5, 1.0, 0.8)
const COL_REMOTE := Color(0.55, 0.75, 1.0)
const COL_DEAD := Color(0.4, 0.4, 0.45, 0.5)
@@ -33,40 +38,87 @@ func _process(_delta: float) -> void:
func _draw() -> void:
_draw_arena()
if client == null:
return
_draw_terrain()
if client.instance_kind == Protocol.InstanceKind.LOBBY:
_draw_portal()
for e in client.enemies():
_draw_enemy(e)
if _visible(e["pos"]):
_draw_enemy(e)
_draw_boss()
for p in client.remote_players():
_draw_remote_player(p)
if _visible(p["pos"]):
_draw_remote_player(p)
_draw_local_player()
func _draw_arena() -> void:
var h := SimConfig.ARENA_HALF
draw_rect(Rect2(-h, h * 2.0), COL_ARENA, false, 2.0)
# Faint grid, purely so movement reads against a background.
var step := 80.0
var faint := Color(COL_ARENA, 0.25)
var x := -h.x + step
while x < h.x:
draw_line(Vector2(x, -h.y), Vector2(x, h.y), faint, 1.0)
x += step
var y := -h.y + step
while y < h.y:
draw_line(Vector2(-h.x, y), Vector2(h.x, y), faint, 1.0)
y += step
## Terrain, drawn only where the player can currently see it.
##
## Hard fog: nothing outside line of sight is drawn, including ground already
## walked over. Note what this is and is not -- it is a rendering rule, not a
## secrecy mechanism. The client holds real tile data because it simulates
## bullets, so a modified client could draw what is hidden here. What it cannot
## do is see terrain the server never streamed (only a radius around the
## player) or actors the server filtered out, which is where the line actually
## sits.
func _draw_terrain() -> void:
var map: MapGrid = client.world.map
if map == null:
return
var eye := client.predicted_pos
var view := SimConfig.FOG_VIEW_RADIUS
var lo := map.to_tile(eye - Vector2(view, view))
var hi := map.to_tile(eye + Vector2(view, view))
var view_sq := view * view
for ty in range(lo.y, hi.y + 1):
for tx in range(lo.x, hi.x + 1):
var kind := map.at(tx, ty)
if kind == MapGrid.Kind.UNKNOWN:
continue
var centre := map.tile_centre(tx, ty)
if eye.distance_squared_to(centre) > view_sq:
continue
if not map.has_line_of_sight(eye, centre):
continue
_draw_tile(kind, centre)
func _draw_tile(kind: MapGrid.Kind, centre: Vector2) -> void:
var half := MapGrid.TILE * 0.5
var r := Rect2(centre - Vector2(half, half), Vector2(MapGrid.TILE, MapGrid.TILE))
match kind:
MapGrid.Kind.FLOOR:
draw_rect(r.grow(-1.0), COL_FLOOR)
MapGrid.Kind.WALL:
draw_rect(r, COL_WALL)
MapGrid.Kind.PILLAR:
draw_rect(r.grow(-2.0), COL_PILLAR)
MapGrid.Kind.PIT:
draw_rect(r.grow(-1.0), COL_PIT)
MapGrid.Kind.BARRICADE:
# Drawn short so "you can see over this" reads at a glance.
draw_rect(Rect2(r.position + Vector2(0.0, half * 0.5),
Vector2(MapGrid.TILE, half)), COL_BARRICADE)
## Actors are only drawn where the player can actually see them. The server
## already withholds distant ones; this hides the ones behind walls.
func _visible(at: Vector2) -> bool:
var map: MapGrid = client.world.map
if map == null:
return true
var eye := client.predicted_pos
if eye.distance_squared_to(at) > SimConfig.FOG_VIEW_RADIUS * SimConfig.FOG_VIEW_RADIUS:
return false
return map.has_line_of_sight(eye, at)
func _draw_portal() -> void:
var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.003)
draw_arc(SimConfig.PORTAL_POS, SimConfig.PORTAL_RADIUS, 0.0, TAU, 48,
draw_arc(client.portal_pos, SimConfig.PORTAL_RADIUS, 0.0, TAU, 48,
Color(COL_PORTAL, 0.4 + 0.4 * pulse), 3.0)
draw_circle(SimConfig.PORTAL_POS, SimConfig.PORTAL_RADIUS * 0.25,
draw_circle(client.portal_pos, SimConfig.PORTAL_RADIUS * 0.25,
Color(COL_PORTAL, 0.25 + 0.25 * pulse))
@@ -94,7 +146,7 @@ func _draw_target_dummy(pos: Vector2, r: float, col: Color) -> void:
func _draw_boss() -> void:
var b := client.boss_state()
if b.is_empty():
if b.is_empty() or not _visible(b["pos"]):
return
var r: float = client.boss_def.radius if client.boss_def != null else 42.0
var pos: Vector2 = b["pos"]