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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user