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
+21 -4
View File
@@ -54,6 +54,8 @@ var roster: Array[Dictionary] = []
## Whole seconds until a cleared dungeon returns the party, or
## Protocol.COUNTDOWN_NONE outside that state.
var cleared_countdown: int = Protocol.COUNTDOWN_NONE
## Where this world's dungeon portal is. Per-map now, so it has to be told.
var portal_pos := Vector2.ZERO
## Backstop for input-numbering drift: if the server stops acknowledging new
## inputs, our tick numbering has fallen outside its acceptance window and no
@@ -93,7 +95,7 @@ func _physics_process(delta: float) -> void:
if my_alive:
predicted_pos = Movement.step_player(predicted_pos, frame.move,
SimConfig.PLAYER_SPEED, SimConfig.ARENA_HALF)
SimConfig.PLAYER_SPEED, world.map)
# Send the last few frames every tick. Inputs are unreliable-ordered, so the
# redundancy is what covers a dropped packet without a retransmit stall.
@@ -160,7 +162,7 @@ func _bot_input() -> InputFrame:
if instance_kind == Protocol.InstanceKind.LOBBY and _bot_tick % 120 < 30:
buttons |= InputFrame.BTN_INTERACT
# Walk onto the portal instead of orbiting, or interact never lands.
move = (SimConfig.PORTAL_POS - predicted_pos).normalized()
move = (portal_pos - predicted_pos).normalized()
if instance_kind == Protocol.InstanceKind.DUNGEON and _bot_tick > 900:
buttons |= InputFrame.BTN_ESCAPE
return InputFrame.make(input_tick, move, aim, buttons)
@@ -185,16 +187,31 @@ func _resync_input_tick(server_tick: int, why: String) -> void:
GameLog.warn("client", "input re-sync: %s" % why)
func on_map_chunks(from_instance: int, data: PackedByteArray) -> void:
# A chunk still in flight when we changed instances describes the wrong map.
if from_instance != instance_id or world.map == null:
return
NetCodec.decode_map_chunks_into(world.map, data)
func on_roster(data: PackedByteArray) -> void:
roster = NetCodec.decode_roster(data)
hud_dirty.emit()
func on_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
spawn: Vector2) -> void:
spawn: Vector2, map_w: int, map_h: int, portal: Vector2) -> void:
instance_id = id
instance_kind = kind as Protocol.InstanceKind
portal_pos = portal
boss_def = Content.boss(StringName(boss_id)) if not boss_id.is_empty() else null
# We are told how big the map is and nothing else. Every tile starts UNKNOWN
# and is filled in by streaming as the player moves, so the client never
# holds terrain it has not been near -- there is no seed here to regenerate
# from. Origin matches the server's centre_on_origin().
var blank := MapGrid.new(maxi(map_w, 1), maxi(map_h, 1), MapGrid.Kind.UNKNOWN)
blank.centre_on_origin()
world.set_map(blank)
world.pool.clear()
snap_prev = {}
snap_curr = {}
@@ -281,7 +298,7 @@ func _reconcile(rec: Dictionary) -> void:
var p: Vector2 = rec["pos"]
if my_alive:
for f in pending:
p = Movement.step_player(p, f.move, SimConfig.PLAYER_SPEED, SimConfig.ARENA_HALF)
p = Movement.step_player(p, f.move, SimConfig.PLAYER_SPEED, world.map)
var error := predicted_pos.distance_to(p)
if error > 24.0:
predicted_pos = p # real divergence: take the server's word
+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
+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