The gap Stage 1 left open: snapshots were encoded once and broadcast to every peer in an instance, so clients were handed enemies the fog then hid. Fog is a rendering rule -- a modified client draws whatever it holds -- so that was no defence at all. Snapshots are now encoded per peer, and actors beyond ACTOR_INTEREST_RADIUS (800u) are never sent. Measured cost of four filtered encodes against one shared: 95.6us vs 24.9us, or 0.032 ms/tick amortised over the snapshot interval. Two asymmetries worth keeping: - The observer's own player record is never filtered, however far out the arithmetic puts it. The client reconciles its prediction against that record, so dropping it breaks the player's own movement rather than hiding someone. - Only bullet SPAWNS are filtered, never despawns. A client told about a bullet must always be told it died, or it keeps a phantom. Bullet spawns use a much wider radius (2200u) than actors, deliberately. An enemy appearing at the edge of sight is cosmetic; a bullet withheld at spawn that later flies into view is invisible damage. The floor is longest bullet travel + fog radius -- 1500 for the Warden's Collapse snipe -- and test_interest.gd recomputes that from live content, so adding a faster or longer-lived bullet fails a test instead of producing bullets that wink into existence. Also clamped bosses to their room. A no-op today since every boss is stationary, which is exactly when the invariant is cheap to establish: boss rooms have no door that locks, so the only thing keeping a fight in the boss room is the boss, and Stage 5's movement work would otherwise break it quietly. 146 tests (was 137). check.sh, test.sh and smoke.sh pass. Stage 1 has no partials left; docs/ROADMAP.md now records the three interest radii and the floor each must respect.
This commit is contained in:
@@ -117,6 +117,26 @@ const DUNGEON_CLEARED_EXIT_TICKS := 1800 # 30 seconds
|
||||
## always holds the terrain it is about to reveal.
|
||||
const FOG_VIEW_RADIUS := 460.0
|
||||
|
||||
# --- Interest management ----------------------------------------------------
|
||||
## Actors further than this from a player are not sent to that player at all.
|
||||
## Comfortably beyond FOG_VIEW_RADIUS so nothing visibly pops in at the edge of
|
||||
## sight, but far short of a dungeon, so a modified client cannot read enemy
|
||||
## positions across the map.
|
||||
const ACTOR_INTEREST_RADIUS := 800.0
|
||||
|
||||
## Bullet spawns are filtered far more generously than actors, and deliberately
|
||||
## so. A bullet is announced once, at spawn, and then simulated locally -- if we
|
||||
## withhold it and it later flies into view, it is invisible damage, which is a
|
||||
## gameplay bug rather than a cosmetic one. An enemy appearing at the edge of
|
||||
## sight is neither.
|
||||
##
|
||||
## The floor is (longest bullet travel + FOG_VIEW_RADIUS): a bullet spawned
|
||||
## further away than that cannot reach visible ground before it expires.
|
||||
## test_interest.gd computes that from the real content and asserts this covers
|
||||
## it, so adding a faster or longer-lived bullet fails a test instead of
|
||||
## producing bullets that wink into existence.
|
||||
const BULLET_INTEREST_RADIUS := 2200.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
|
||||
|
||||
+31
-4
@@ -10,15 +10,36 @@ extends RefCounted
|
||||
|
||||
## [param cleared_countdown] is whole seconds until a cleared dungeon returns
|
||||
## its party, or Protocol.COUNTDOWN_NONE when that does not apply.
|
||||
## [param for_peer] scopes the snapshot to what that player may know: actors
|
||||
## beyond SimConfig.ACTOR_INTEREST_RADIUS are omitted entirely, so fog is not
|
||||
## the only thing hiding them. Pass 0 to encode the whole world (tests, and the
|
||||
## safe fallback if the observer cannot be found).
|
||||
static func encode_snapshot(world: SimWorld,
|
||||
cleared_countdown: int = Protocol.COUNTDOWN_NONE) -> PackedByteArray:
|
||||
cleared_countdown: int = Protocol.COUNTDOWN_NONE,
|
||||
for_peer: int = 0) -> PackedByteArray:
|
||||
var b := StreamPeerBuffer.new()
|
||||
b.big_endian = false
|
||||
b.put_u32(world.tick)
|
||||
b.put_u8(clampi(cleared_countdown, 0, Protocol.COUNTDOWN_NONE))
|
||||
|
||||
b.put_u8(mini(world.players.size(), 255))
|
||||
var observer: SimPlayer = world.players.get(for_peer) if for_peer != 0 else null
|
||||
var eye := Vector2.ZERO
|
||||
var cull_sq := 0.0
|
||||
if observer != null:
|
||||
eye = observer.pos
|
||||
cull_sq = SimConfig.ACTOR_INTEREST_RADIUS * SimConfig.ACTOR_INTEREST_RADIUS
|
||||
|
||||
var visible_players: Array[SimPlayer] = []
|
||||
for p in world.players.values():
|
||||
# Always include the observer's own player, whatever the distance
|
||||
# arithmetic says: the client reconciles its prediction against this
|
||||
# record, and dropping it would break its own movement.
|
||||
if observer == null or p.peer_id == for_peer \
|
||||
or eye.distance_squared_to(p.pos) <= cull_sq:
|
||||
visible_players.append(p)
|
||||
|
||||
b.put_u8(mini(visible_players.size(), 255))
|
||||
for p in visible_players:
|
||||
b.put_u32(p.peer_id)
|
||||
b.put_float(p.pos.x)
|
||||
b.put_float(p.pos.y)
|
||||
@@ -43,8 +64,11 @@ static func encode_snapshot(world: SimWorld,
|
||||
|
||||
var live_enemies: Array[SimEnemy] = []
|
||||
for e in world.enemies.values():
|
||||
if e.alive:
|
||||
live_enemies.append(e)
|
||||
if not e.alive:
|
||||
continue
|
||||
if observer != null and eye.distance_squared_to(e.pos) > cull_sq:
|
||||
continue
|
||||
live_enemies.append(e)
|
||||
b.put_u16(mini(live_enemies.size(), 65535))
|
||||
for e in live_enemies:
|
||||
b.put_u32(e.id)
|
||||
@@ -57,6 +81,9 @@ static func encode_snapshot(world: SimWorld,
|
||||
b.put_u8(clampi(e.def.visual, 0, 255))
|
||||
|
||||
var has_boss := world.boss != null and world.boss.alive
|
||||
if has_boss and observer != null \
|
||||
and eye.distance_squared_to(world.boss.pos) > cull_sq:
|
||||
has_boss = false
|
||||
b.put_u8(1 if has_boss else 0)
|
||||
if has_boss:
|
||||
b.put_u32(world.boss.id)
|
||||
|
||||
@@ -41,9 +41,13 @@ func _physics_process(_delta: float) -> void:
|
||||
inst.step()
|
||||
_dispatch_events(inst)
|
||||
if send_snapshot and not inst.peers.is_empty():
|
||||
var snap := NetCodec.encode_snapshot(inst.world, inst.exit_countdown_seconds())
|
||||
# Encoded per peer, not once and broadcast: each player is told only
|
||||
# about actors near them. Fog alone was hiding enemies the client
|
||||
# had already been handed, which is no defence against a modified
|
||||
# client at all.
|
||||
for peer in inst.peers:
|
||||
Net.send_snapshot(peer, snap)
|
||||
Net.send_snapshot(peer, NetCodec.encode_snapshot(
|
||||
inst.world, inst.exit_countdown_seconds(), peer))
|
||||
_stream_map(peer, inst)
|
||||
if inst.kind != Protocol.InstanceKind.DUNGEON:
|
||||
continue
|
||||
@@ -83,9 +87,25 @@ func _dispatch_events(inst: Instance) -> void:
|
||||
_:
|
||||
pass
|
||||
|
||||
var payload := NetCodec.encode_events(inst.world.tick, events)
|
||||
# Bullet spawns are scoped per peer too; everything else (hits, deaths,
|
||||
# phase changes) is low volume and mostly concerns the recipient, so it goes
|
||||
# to everyone. Despawns are deliberately NOT filtered -- a client that was
|
||||
# told about a bullet must always be told it died, or it keeps a phantom.
|
||||
var shared: Array[Dictionary] = []
|
||||
var spawns: Array[Dictionary] = []
|
||||
for ev in events:
|
||||
if int(ev["t"]) == SimEvent.Type.BULLET_SPAWN:
|
||||
spawns.append(ev)
|
||||
else:
|
||||
shared.append(ev)
|
||||
|
||||
for peer in inst.peers:
|
||||
Net.send_events(peer, payload)
|
||||
var for_peer := shared
|
||||
if not spawns.is_empty():
|
||||
for_peer = shared + _spawns_near(inst, peer, spawns)
|
||||
if for_peer.is_empty():
|
||||
continue
|
||||
Net.send_events(peer, NetCodec.encode_events(inst.world.tick, for_peer))
|
||||
|
||||
for peer in to_lobby:
|
||||
_send_to_lobby(peer)
|
||||
@@ -184,11 +204,11 @@ func _place(peer_id: int, inst: Instance) -> void:
|
||||
_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)
|
||||
var backlog := _live_bullet_events(inst.world, peer_id)
|
||||
if not backlog.is_empty():
|
||||
Net.send_events(peer_id, NetCodec.encode_events(inst.world.tick, backlog))
|
||||
Net.send_snapshot(peer_id, NetCodec.encode_snapshot(inst.world,
|
||||
inst.exit_countdown_seconds()))
|
||||
inst.exit_countdown_seconds(), peer_id))
|
||||
|
||||
|
||||
func _transfer(peer_id: int, to: Instance) -> void:
|
||||
@@ -290,11 +310,31 @@ func _broadcast_roster() -> void:
|
||||
Net.send_roster(peer, payload)
|
||||
|
||||
|
||||
func _live_bullet_events(world: SimWorld) -> Array[Dictionary]:
|
||||
## Bullet spawns within this peer's interest radius. See
|
||||
## SimConfig.BULLET_INTEREST_RADIUS for why this radius is much wider than the
|
||||
## one used for actors.
|
||||
func _spawns_near(inst: Instance, peer_id: int, spawns: Array[Dictionary]) -> Array[Dictionary]:
|
||||
var p: SimPlayer = inst.world.players.get(peer_id)
|
||||
if p == null:
|
||||
return spawns
|
||||
var cull_sq := SimConfig.BULLET_INTEREST_RADIUS * SimConfig.BULLET_INTEREST_RADIUS
|
||||
var out: Array[Dictionary] = []
|
||||
for ev in spawns:
|
||||
if p.pos.distance_squared_to(ev["pos"]) <= cull_sq:
|
||||
out.append(ev)
|
||||
return out
|
||||
|
||||
|
||||
func _live_bullet_events(world: SimWorld, for_peer: int = 0) -> Array[Dictionary]:
|
||||
var out: Array[Dictionary] = []
|
||||
var observer: SimPlayer = world.players.get(for_peer) if for_peer != 0 else null
|
||||
var cull_sq := SimConfig.BULLET_INTEREST_RADIUS * SimConfig.BULLET_INTEREST_RADIUS
|
||||
for i in world.pool.high_water:
|
||||
if world.pool.alive[i] == 0:
|
||||
continue
|
||||
if observer != null \
|
||||
and observer.pos.distance_squared_to(world.pool.pos[i]) > cull_sq:
|
||||
continue
|
||||
out.append({
|
||||
"t": SimEvent.Type.BULLET_SPAWN,
|
||||
"uid": world.pool.uid[i],
|
||||
|
||||
@@ -334,6 +334,16 @@ func _step_boss() -> void:
|
||||
var phase := boss.current_phase()
|
||||
if phase == null:
|
||||
return
|
||||
# A boss never leaves its arena. Enforced here rather than left to each
|
||||
# boss's movement code, because boss rooms deliberately do not lock: the
|
||||
# player can always walk out, and the fight only stays a fight if the boss
|
||||
# cannot follow. Currently a no-op (every boss is stationary), which is
|
||||
# exactly when an invariant is cheapest to establish.
|
||||
if boss.room.size != Vector2.ZERO:
|
||||
boss.pos = boss.room.position + Vector2(
|
||||
clampf(boss.pos.x - boss.room.position.x, 0.0, boss.room.size.x),
|
||||
clampf(boss.pos.y - boss.room.position.y, 0.0, boss.room.size.y))
|
||||
|
||||
if boss.phase_tick >= phase.telegraph_ticks:
|
||||
var local := posmod(boss.phase_tick - phase.telegraph_ticks, maxi(phase.loop_ticks, 1))
|
||||
_run_emitters(phase.emitters, boss.pos, local, boss.room)
|
||||
|
||||
Reference in New Issue
Block a user