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
+40 -2
View File
@@ -11,6 +11,9 @@ extends Node
var instances: Dictionary[int, Instance] = {}
var peer_instance: Dictionary[int, int] = {}
var peer_names: Dictionary[int, String] = {}
## Map chunks each peer has been sent, per peer. Reset on every instance
## transfer -- knowledge of one dungeon must not carry into the next.
var peer_chunks: Dictionary[int, Dictionary] = {}
var lobby: Instance
var _next_instance_id: int = SimConfig.LOBBY_INSTANCE_ID
@@ -41,6 +44,7 @@ func _physics_process(_delta: float) -> void:
var snap := NetCodec.encode_snapshot(inst.world, inst.exit_countdown_seconds())
for peer in inst.peers:
Net.send_snapshot(peer, snap)
_stream_map(peer, inst)
if inst.kind != Protocol.InstanceKind.DUNGEON:
continue
if inst.state == Instance.State.CLEARED and inst.stage_delay <= 0:
@@ -120,6 +124,7 @@ func on_peer_disconnected(peer_id: int) -> void:
func _forget_peer(peer_id: int) -> void:
peer_instance.erase(peer_id)
peer_names.erase(peer_id)
peer_chunks.erase(peer_id)
_broadcast_roster()
@@ -169,8 +174,14 @@ func instance_of(peer_id: int) -> Instance:
func _place(peer_id: int, inst: Instance) -> void:
inst.add_peer(peer_id, peer_names.get(peer_id, "player"))
peer_instance[peer_id] = inst.id
# Size only: the seed stays server-side, or a client could rebuild the map.
peer_chunks[peer_id] = {}
Net.send_enter_instance(peer_id, inst.id, int(inst.kind), inst.world.tick,
String(inst.boss_id), inst.world.spawn_point)
String(inst.boss_id), inst.world.spawn_point,
inst.world.map.width, inst.world.map.height, inst.world.portal_pos)
# Seed the area around the spawn before anything else, so the player is not
# briefly standing in an unrendered void on arrival.
_stream_map(peer_id, inst)
# A player arriving mid-fight has no idea what is already in the air, so
# replay the live bullets as spawn events before the next snapshot lands.
var backlog := _live_bullet_events(inst.world)
@@ -209,7 +220,7 @@ func _send_to_dungeon(peer_id: int) -> void:
target = inst
break
if target == null:
target = Instance.make_dungeon(_take_instance_id(), randi())
target = Instance.make_dungeon(_take_instance_id(), randi(), GameOpts.dungeon_depth)
instances[target.id] = target
GameLog.info("server", "opened dungeon instance %d" % target.id)
_transfer(peer_id, target)
@@ -225,6 +236,33 @@ func _close_dungeon(id: int) -> void:
GameLog.info("server", "closed dungeon instance %d" % id)
## Send this peer any map chunks near its player that it has not been given yet.
##
## This is the anti-map-hack boundary: a client learns terrain by standing near
## it and never any other way. The radius is generous -- wider than the fog, so
## prediction and bullet simulation always run on known ground -- but it is
## still a small fraction of a dungeon, so the worst a modified client gets is
## a slightly wider view, not the floor plan.
func _stream_map(peer_id: int, inst: Instance) -> void:
var p: SimPlayer = inst.world.players.get(peer_id)
if p == null:
return
var known: Dictionary = peer_chunks.get(peer_id, {})
var wanted := inst.world.map.chunks_near(p.pos, SimConfig.MAP_STREAM_RADIUS)
var batch: Array[int] = []
for id in wanted:
if known.has(id):
continue
known[id] = true
batch.append(id)
if batch.size() >= SimConfig.MAP_CHUNKS_PER_TICK:
break
peer_chunks[peer_id] = known
if not batch.is_empty():
Net.send_map_chunks(peer_id, inst.id,
NetCodec.encode_map_chunks(inst.world.map, batch))
# --- Roster -----------------------------------------------------------------
## Tell everyone who is online and where they are, so the hub can show that a