class_name NetCodec extends RefCounted ## Binary encoders for the two server -> client streams. ## ## Snapshots are lossy and unreliable: only what is needed to draw and predict. ## Events are exact and reliable: things a client can never re-derive, above all ## bullet spawns and the despawns caused by a hit. # --- Snapshot --------------------------------------------------------------- ## [param cleared_countdown] is whole seconds until a cleared dungeon returns ## its party, or Protocol.COUNTDOWN_NONE when that does not apply. static func encode_snapshot(world: SimWorld, cleared_countdown: int = Protocol.COUNTDOWN_NONE) -> 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)) for p in world.players.values(): b.put_u32(p.peer_id) b.put_float(p.pos.x) b.put_float(p.pos.y) b.put_u16(wrapi(roundi(p.aim / TAU * 65536.0), 0, 65536)) b.put_u16(clampi(p.hp, 0, 65535)) var flags := 0 if p.alive: flags |= Protocol.F_ALIVE if p.iframes > 0: flags |= Protocol.F_INVULN if p.escape_ticks > 0: flags |= Protocol.F_ESCAPING if p.spawn_grace > 0: flags |= Protocol.F_SPAWN_GRACE if p.linkdead: flags |= Protocol.F_LINKDEAD b.put_u8(flags) b.put_u8(clampi(roundi(p.escape_progress() * 255.0), 0, 255)) # Echoed so the owning client knows how far to rewind when reconciling. b.put_u32(p.last_input_tick) var live_enemies: Array[SimEnemy] = [] for e in world.enemies.values(): if e.alive: live_enemies.append(e) b.put_u16(mini(live_enemies.size(), 65535)) for e in live_enemies: b.put_u32(e.id) b.put_float(e.pos.x) b.put_float(e.pos.y) b.put_u16(clampi(e.hp, 0, 65535)) # Radius and visual travel with the snapshot so a client that joins # mid-fight can draw an enemy without any extra handshake. b.put_u8(clampi(roundi(e.def.radius * 2.0), 0, 255)) b.put_u8(clampi(e.def.visual, 0, 255)) var has_boss := world.boss != null and world.boss.alive b.put_u8(1 if has_boss else 0) if has_boss: b.put_u32(world.boss.id) b.put_float(world.boss.pos.x) b.put_float(world.boss.pos.y) b.put_u32(maxi(world.boss.hp, 0)) b.put_u8(clampi(world.boss.phase_index, 0, 255)) return b.data_array static func decode_snapshot(data: PackedByteArray) -> Dictionary: var b := StreamPeerBuffer.new() b.big_endian = false b.data_array = data var snap := { "tick": b.get_u32(), "cleared_countdown": b.get_u8(), "players": [], "enemies": [], "boss": null, } var pcount := b.get_u8() for _i in pcount: snap["players"].append({ "peer": b.get_u32(), "pos": Vector2(b.get_float(), b.get_float()), "aim": float(b.get_u16()) / 65536.0 * TAU, "hp": b.get_u16(), "flags": b.get_u8(), "escape": float(b.get_u8()) / 255.0, "last_input_tick": b.get_u32(), }) var ecount := b.get_u16() for _i in ecount: snap["enemies"].append({ "id": b.get_u32(), "pos": Vector2(b.get_float(), b.get_float()), "hp": b.get_u16(), "radius": float(b.get_u8()) * 0.5, "visual": b.get_u8(), }) if b.get_u8() == 1: snap["boss"] = { "id": b.get_u32(), "pos": Vector2(b.get_float(), b.get_float()), "hp": b.get_u32(), "phase": b.get_u8(), } return snap # --- Events ----------------------------------------------------------------- ## Events the client never sees; the instance layer consumes them server-side. ## These describe a decision the server is about to act on, not an outcome -- ## leaking them would tell a client about a message it might try to forge. const SERVER_ONLY := [ SimEvent.Type.PORTAL_USED, SimEvent.Type.ESCAPE_COMPLETED, SimEvent.Type.RESPAWN_REQUESTED, ] ## [param server_tick] rides along so the client can fast-forward a bullet by ## however many ticks the packet spent in flight, instead of popping it in at ## the muzzle a round-trip late. static func encode_events(server_tick: int, events: Array[Dictionary]) -> PackedByteArray: var b := StreamPeerBuffer.new() b.big_endian = false b.put_u32(server_tick) var count := 0 var body := StreamPeerBuffer.new() body.big_endian = false for ev in events: var t := int(ev["t"]) if SERVER_ONLY.has(t): continue body.put_u8(t) match t: SimEvent.Type.BULLET_SPAWN: body.put_u32(ev["uid"]) body.put_float(ev["pos"].x) body.put_float(ev["pos"].y) body.put_float(ev["vel"].x) body.put_float(ev["vel"].y) body.put_float(ev["r"]) body.put_u16(clampi(int(ev["life"]), 0, 65535)) body.put_u8(int(ev["kind"])) body.put_u8(int(ev["team"])) body.put_float(ev["accel"]) body.put_float(ev["turn"]) SimEvent.Type.BULLET_DESPAWN: body.put_u32(ev["uid"]) SimEvent.Type.PLAYER_HIT: body.put_u32(ev["peer"]) body.put_u16(clampi(int(ev["dmg"]), 0, 65535)) body.put_u16(clampi(int(ev["hp"]), 0, 65535)) SimEvent.Type.PLAYER_DIED, SimEvent.Type.ESCAPE_STARTED, \ SimEvent.Type.ESCAPE_CANCELLED: body.put_u32(ev["peer"]) SimEvent.Type.PLAYER_RESPAWNED: body.put_u32(ev["peer"]) body.put_float(ev["pos"].x) body.put_float(ev["pos"].y) SimEvent.Type.ENEMY_HIT: body.put_u32(ev["id"]) body.put_u16(clampi(int(ev["dmg"]), 0, 65535)) body.put_u32(maxi(int(ev["hp"]), 0)) SimEvent.Type.ENEMY_DIED: body.put_u32(ev["id"]) SimEvent.Type.BOSS_PHASE: body.put_u8(clampi(int(ev["phase"]), 0, 255)) SimEvent.Type.BOSS_DIED: pass count += 1 b.put_u16(count) b.put_data(body.data_array) return b.data_array ## Returns { "tick": int, "events": Array[Dictionary] }. static func decode_events(data: PackedByteArray) -> Dictionary: var out: Array[Dictionary] = [] var b := StreamPeerBuffer.new() b.big_endian = false b.data_array = data var server_tick := b.get_u32() var count := b.get_u16() for _i in count: var t := b.get_u8() var ev := {"t": t} match t: SimEvent.Type.BULLET_SPAWN: ev["uid"] = b.get_u32() ev["pos"] = Vector2(b.get_float(), b.get_float()) ev["vel"] = Vector2(b.get_float(), b.get_float()) ev["r"] = b.get_float() ev["life"] = b.get_u16() ev["kind"] = b.get_u8() ev["team"] = b.get_u8() ev["accel"] = b.get_float() ev["turn"] = b.get_float() SimEvent.Type.BULLET_DESPAWN: ev["uid"] = b.get_u32() SimEvent.Type.PLAYER_HIT: ev["peer"] = b.get_u32() ev["dmg"] = b.get_u16() ev["hp"] = b.get_u16() SimEvent.Type.PLAYER_DIED, SimEvent.Type.ESCAPE_STARTED, \ SimEvent.Type.ESCAPE_CANCELLED: ev["peer"] = b.get_u32() SimEvent.Type.PLAYER_RESPAWNED: ev["peer"] = b.get_u32() ev["pos"] = Vector2(b.get_float(), b.get_float()) SimEvent.Type.ENEMY_HIT: ev["id"] = b.get_u32() ev["dmg"] = b.get_u16() ev["hp"] = b.get_u32() SimEvent.Type.ENEMY_DIED: ev["id"] = b.get_u32() SimEvent.Type.BOSS_PHASE: ev["phase"] = b.get_u8() SimEvent.Type.BOSS_DIED: pass out.append(ev) return {"tick": server_tick, "events": out} # --- Input ------------------------------------------------------------------ static func encode_inputs(frames: Array[InputFrame]) -> PackedByteArray: var b := StreamPeerBuffer.new() b.big_endian = false b.put_u8(mini(frames.size(), 255)) for f in frames: f.write(b) return b.data_array static func decode_inputs(data: PackedByteArray) -> Array[InputFrame]: var out: Array[InputFrame] = [] if data.size() < 1: return out var b := StreamPeerBuffer.new() b.big_endian = false b.data_array = data var count := b.get_u8() # A malformed or hostile packet must not make the server read past the end. if data.size() < 1 + count * InputFrame.SIZE: return out for _i in count: out.append(InputFrame.read(b)) return out # --- Roster ----------------------------------------------------------------- # Who is online and where. Low frequency (membership changes only), so it is # the one message that carries strings; everything hot stays fixed-width. static func encode_roster(entries: Array[Dictionary]) -> PackedByteArray: var b := StreamPeerBuffer.new() b.big_endian = false b.put_u8(mini(entries.size(), 255)) for e in entries: b.put_u32(int(e["peer"])) b.put_utf8_string(String(e["name"])) b.put_u8(int(e["kind"])) b.put_u32(int(e["instance"])) b.put_u8(1 if e["alive"] else 0) return b.data_array static func decode_roster(data: PackedByteArray) -> Array[Dictionary]: var out: Array[Dictionary] = [] if data.size() < 1: return out var b := StreamPeerBuffer.new() b.big_endian = false b.data_array = data var count := b.get_u8() for _i in count: # get_utf8_string reads its own length prefix, so a truncated packet # yields empty strings rather than reading off the end. out.append({ "peer": b.get_u32(), "name": b.get_utf8_string(), "kind": b.get_u8(), "instance": b.get_u32(), "alive": b.get_u8() == 1, }) return out