050b8251a7
ci / verify (push) Successful in 48s
Four always-on-screen slots, items as data, and loot tables on enemies and bosses. Health potions drop rarely from trash and always from the Warden; the Warden also drops a Warden's Ration, one per living player, which does nothing at all. The ration is not filler. Player-instanced loot is a separate code path from shared loot -- a distinct entity per owner, filtered per peer in the snapshot encoder -- and the cheapest way to keep that path honest is to have something in the game that exercises it on every boss kill. Item actions ride the input frame rather than becoming new client messages. InputFrame gained BTN_USE, BTN_DROP and a slot byte, which buys the packet-loss redundancy, the replay guard on last_input_tick, ordering against movement on the same tick, and a rate limit of one action per tick -- all of which a separate RPC would have needed bolted back on. The cost is that anything in the frame which must not repeat has to be edge-triggered, since frames are resent and a starved server coasts on the last one it holds. Instanced loot is enforced in NetCodec.encode_snapshot, beside the actor interest radius: a peer is never told another player's copy exists. Hiding it client-side would have been the same mistake as relying on fog to hide enemies. Inventories live on the character and are written to the store on every transaction, so a crash between "picked it up" and "wrote it down" cannot lose or duplicate an item. Anything dropped becomes world-shared whatever it was before, and a potion used at full health is refused rather than spent. tools/diag_loot.tscn covers drop -> snapshot -> pick up -> persist -> use -> drop plus both visibilities on the wire, for the same reason diag_progression exists: bots are poor shots and almost never produce a drop. It asserts each input frame was actually consumed, after an early version silently dropped its first press and every later check passed for the wrong reason. check.sh clean, 266 tests, SMOKE PASS, all three diagnostics green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
507 lines
16 KiB
GDScript
507 lines
16 KiB
GDScript
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.
|
|
## [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,
|
|
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))
|
|
|
|
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)
|
|
b.put_u16(wrapi(roundi(p.aim / TAU * 65536.0), 0, 65536))
|
|
b.put_u16(clampi(p.hp, 0, 65535))
|
|
# Sent rather than assumed: max health follows the character's level, so
|
|
# a HUD bar computed from a constant would be wrong for anyone past
|
|
# level 1, and wrong for every other player in the party.
|
|
b.put_u16(clampi(p.max_hp, 1, 65535))
|
|
var flags := 0
|
|
if p.alive:
|
|
flags |= Protocol.F_ALIVE
|
|
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))
|
|
# Whole ticks are more than the HUD needs; a byte of tenths of a second
|
|
# covers the lockout with room to spare.
|
|
b.put_u8(clampi(roundi(float(p.respawn_lockout) / 6.0), 0, 255))
|
|
# Echoed so the owning client knows how far to rewind when reconciling.
|
|
b.put_u32(p.last_input_tick)
|
|
# Static per character, so sending it every snapshot is slightly
|
|
# wasteful -- but it is four bytes, and the alternative is a separate
|
|
# message plus the join-ordering bug where someone arrives before it.
|
|
b.put_u32(p.colour.to_rgba32())
|
|
# Experience rides the snapshot rather than waiting for a roster
|
|
# message: the bar has to move on every kill, and the roster is only
|
|
# re-sent when the set of characters actually changes.
|
|
b.put_u32(maxi(p.total_xp, 0))
|
|
|
|
var live_enemies: Array[SimEnemy] = []
|
|
for e in world.enemies.values():
|
|
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)
|
|
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
|
|
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)
|
|
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))
|
|
|
|
# Only the observer's own bag. Nobody needs to see what a party member is
|
|
# carrying, and not sending it means there is nothing to leak.
|
|
for i in SimConfig.INVENTORY_SLOTS:
|
|
var carried := Items.NONE
|
|
if observer != null and i < observer.inventory.size():
|
|
carried = observer.inventory[i]
|
|
b.put_u8(Items.index_of(carried))
|
|
|
|
# Ground loot. Player-instanced items are filtered here rather than hidden
|
|
# in the client: a peer is never told that another player's copy exists, so
|
|
# a modified client has nothing to reveal.
|
|
var visible_loot: Array[SimLoot] = []
|
|
for l in world.loot.values():
|
|
if observer != null:
|
|
if not l.visible_to(for_peer):
|
|
continue
|
|
if eye.distance_squared_to(l.pos) > cull_sq:
|
|
continue
|
|
visible_loot.append(l)
|
|
b.put_u16(mini(visible_loot.size(), 65535))
|
|
for l in visible_loot:
|
|
b.put_u32(l.id)
|
|
b.put_float(l.pos.x)
|
|
b.put_float(l.pos.y)
|
|
b.put_u8(Items.index_of(l.item))
|
|
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,
|
|
"loot": [], "inventory": [],
|
|
}
|
|
|
|
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(),
|
|
"max_hp": b.get_u16(),
|
|
"flags": b.get_u8(),
|
|
"escape": float(b.get_u8()) / 255.0,
|
|
"respawn_wait": float(b.get_u8()) / 10.0,
|
|
"last_input_tick": b.get_u32(),
|
|
"colour": Color.hex(b.get_u32()),
|
|
"total_xp": 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(),
|
|
}
|
|
|
|
var inventory: Array[int] = []
|
|
for _i in SimConfig.INVENTORY_SLOTS:
|
|
inventory.append(b.get_u8())
|
|
snap["inventory"] = inventory
|
|
|
|
var lcount := b.get_u16()
|
|
for _i in lcount:
|
|
snap["loot"].append({
|
|
"id": b.get_u32(),
|
|
"pos": Vector2(b.get_float(), b.get_float()),
|
|
"item": 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, SimEvent.Type.PLAYER_FIRED:
|
|
body.put_u32(ev["peer"])
|
|
SimEvent.Type.ITEM_PICKED_UP, SimEvent.Type.ITEM_USED, \
|
|
SimEvent.Type.ITEM_DROPPED:
|
|
body.put_u32(ev["peer"])
|
|
# By index, like everywhere else on the wire. See Items.ORDER.
|
|
body.put_u8(Items.index_of(ev["item"]))
|
|
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, SimEvent.Type.PLAYER_FIRED:
|
|
ev["peer"] = b.get_u32()
|
|
SimEvent.Type.ITEM_PICKED_UP, SimEvent.Type.ITEM_USED, \
|
|
SimEvent.Type.ITEM_DROPPED:
|
|
ev["peer"] = b.get_u32()
|
|
ev["item"] = Items.by_index(b.get_u8())
|
|
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:
|
|
if b.get_available_bytes() < 4:
|
|
break
|
|
var peer := b.get_u32()
|
|
var display := _safe_utf8(b)
|
|
# kind, instance, alive.
|
|
if b.get_available_bytes() < 1 + 4 + 1:
|
|
break
|
|
out.append({
|
|
"peer": peer,
|
|
"name": display,
|
|
"kind": b.get_u8(),
|
|
"instance": b.get_u32(),
|
|
"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
|
|
|
|
|
|
# --- Character roster -------------------------------------------------------
|
|
# Sent once at login and after any change. Low frequency and carries strings,
|
|
# like the online roster, so it is the same fixed-header-then-utf8 shape.
|
|
|
|
static func encode_characters(chars: Array[Character], selected: String) -> PackedByteArray:
|
|
var b := StreamPeerBuffer.new()
|
|
b.big_endian = false
|
|
b.put_utf8_string(selected)
|
|
b.put_u8(mini(chars.size(), 255))
|
|
for c in chars:
|
|
b.put_utf8_string(c.id)
|
|
b.put_utf8_string(c.display_name)
|
|
b.put_u8(clampi(c.level, 1, 255))
|
|
b.put_u32(maxi(c.total_xp, 0))
|
|
b.put_u8(clampi(roundi(c.xp_progress() * 255.0), 0, 255))
|
|
b.put_u16(clampi(c.max_hp(), 1, 65535))
|
|
b.put_u8(1 if c.active else 0)
|
|
b.put_u32(c.colour.to_rgba32())
|
|
for i in SimConfig.INVENTORY_SLOTS:
|
|
b.put_u8(Items.index_of(c.inventory[i] if i < c.inventory.size() else Items.NONE))
|
|
return b.data_array
|
|
|
|
|
|
## Length-prefixed string read that refuses to run off the end.
|
|
##
|
|
## StreamPeerBuffer.get_utf8_string() reads a length and then that many bytes,
|
|
## and pushes an engine error if the buffer is short -- so a truncated or
|
|
## hostile packet turns into error spam plus a garbage value. Returns an empty
|
|
## string and leaves the cursor at the end instead, which callers detect via
|
|
## get_available_bytes().
|
|
static func _safe_utf8(b: StreamPeerBuffer) -> String:
|
|
if b.get_available_bytes() < 4:
|
|
b.seek(b.get_size())
|
|
return ""
|
|
var length := b.get_u32()
|
|
if length > b.get_available_bytes():
|
|
b.seek(b.get_size())
|
|
return ""
|
|
return b.get_data(length)[1].get_string_from_utf8() if length > 0 else ""
|
|
|
|
|
|
## Returns { "selected": String, "characters": Array[Dictionary] }.
|
|
static func decode_characters(data: PackedByteArray) -> Dictionary:
|
|
var out: Array[Dictionary] = []
|
|
if data.size() < 1:
|
|
return {"selected": "", "characters": out}
|
|
var b := StreamPeerBuffer.new()
|
|
b.big_endian = false
|
|
b.data_array = data
|
|
var selected := _safe_utf8(b)
|
|
if b.get_available_bytes() < 1:
|
|
return {"selected": selected, "characters": out}
|
|
var count := b.get_u8()
|
|
# Bytes each entry needs after its two strings: level, xp, progress,
|
|
# max_hp, active, colour, then one byte per inventory slot.
|
|
var fixed := 1 + 4 + 1 + 2 + 1 + 4 + SimConfig.INVENTORY_SLOTS
|
|
for _i in count:
|
|
var id := _safe_utf8(b)
|
|
var display := _safe_utf8(b)
|
|
if id.is_empty() or b.get_available_bytes() < fixed:
|
|
break
|
|
out.append({
|
|
"id": id,
|
|
"name": display,
|
|
"level": b.get_u8(),
|
|
"xp": b.get_u32(),
|
|
"progress": float(b.get_u8()) / 255.0,
|
|
"max_hp": b.get_u16(),
|
|
"active": b.get_u8() == 1,
|
|
"colour": Color.hex(b.get_u32()),
|
|
"inventory": _read_inventory(b),
|
|
})
|
|
return {"selected": selected, "characters": out}
|
|
|
|
|
|
static func _read_inventory(b: StreamPeerBuffer) -> Array[int]:
|
|
var out: Array[int] = []
|
|
for _i in SimConfig.INVENTORY_SLOTS:
|
|
out.append(b.get_u8())
|
|
return out
|