mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
b43ad207c1
multiplayer-next.md was a 1662-line mix of standing architecture spec and task-completion tracking, most of which was dense per-task DONE evidence for finished Phases 0-6. Split it: - MULTIPLAYER_SPEC.md (new): the locked architecture decisions, wire format, server-side input handling, prediction/reconciliation, latency/frame-rate budget, and match lifecycle state machine - standing design reference, not task-tracked. - multiplayer-next.md (trimmed 1662 -> ~370 lines): only outstanding work remains - §0 status, §7 Phase 7/8 task tables condensed to "what's left" per task, §8-11 reference material (refactoring notes, gotchas, testing, flagged items). Phases 0-6 collapsed to a pointer at git history instead of ~500 lines of DONE evidence. Also: - Repointed every `multiplayer-next.md §N` code comment (N 1-6) across Game/scripts, Game/tools and Game/tests to MULTIPLAYER_SPEC.md, since those sections moved. Task-number references (`task N.N`, §7-11) correctly still point at multiplayer-next.md. - Updated CLAUDE.md's doc index and docs/TECH_STACK.md's spec-section citations to match. - TODO.md: added a "what's left to actually finish multiplayer (human-actionable)" checklist pulled from multiplayer-next.md §0 and docs/MATCHMAKING.md - things that need a person (hardware, a design decision, a Steam App ID, hands on a controller), not more agent code.
296 lines
11 KiB
GDScript
296 lines
11 KiB
GDScript
class_name NetCodec
|
|
|
|
# Wire-format constants, quantisers, and pack/unpack for the two hot-path
|
|
# packets (§2 of MULTIPLAYER_SPEC.md). Pure functions only — no networking,
|
|
# no autoload state — so they're testable head-on by tests/test_runner.tscn
|
|
# without a live connection.
|
|
#
|
|
# Referenced from elsewhere via preload(), not the bare class_name, per the
|
|
# same global-script-class-cache caveat documented in tests/test_case.gd and
|
|
# sim_constants.gd.
|
|
|
|
const SimConstants = preload("res://scripts/sim_constants.gd")
|
|
const ShipAction = preload("res://scripts/ship_action.gd")
|
|
const NetBodyState = preload("res://scripts/net_body_state.gd")
|
|
|
|
# --- Protocol ---
|
|
const PROTOCOL_VERSION := 1
|
|
const TICK_HZ: int = SimConstants.TICK_HZ
|
|
|
|
# --- Channels (logical intent; NetworkManager may need to offset these on
|
|
# top of ENet's own reserved channels — verify empirically, see §2.1) ---
|
|
const CHANNEL_CONTROL := 0
|
|
const CHANNEL_INPUT := 1
|
|
const CHANNEL_SNAPSHOT := 2
|
|
|
|
# --- Packet type/version byte: high nibble = type, low nibble = protocol version ---
|
|
enum PacketType { INPUT = 0, SNAPSHOT = 1 }
|
|
|
|
# --- Input packet (§2.3) ---
|
|
const MAX_REDUNDANCY := 4
|
|
# type_version u8 + seq u32 + count u8 + ack_snapshot_tick u32 + client_send_ms u16
|
|
const INPUT_HEADER_SIZE := 12
|
|
const INPUT_ENTRY_SIZE := 7 # thrust i8x3 + rotation i8x3 + flags u8
|
|
const INPUT_FLAG_TURBO := 1 << 0
|
|
|
|
# --- Snapshot packet (§2.4) ---
|
|
# last_input_seq u32 + input_buffer_depth i8 + echo_client_send_ms u16
|
|
const SNAPSHOT_CLIENT_HEADER_SIZE := 7
|
|
# type_version u8 + server_tick u32 + match_state u8 + reset_gen u8 + body_count u8
|
|
const SNAPSHOT_BODY_HEADER_SIZE := 8
|
|
const SNAPSHOT_BODY_SIZE := 22
|
|
|
|
const BODY_FLAG_FROZEN := 1 << 0
|
|
const BODY_FLAG_TURBO := 1 << 1
|
|
const BODY_FLAG_THRUST_Z_SHIFT := 2
|
|
const BODY_FLAG_THRUST_Z_MASK := 0x1C # bits 2-4
|
|
const BODY_FLAG_STALLED := 1 << 5
|
|
const BODY_FLAG_QUAT_W_SIGN := 1 << 6
|
|
|
|
# --- Quantisation ranges (§2.4 — derived from arena/gameplay constants, not
|
|
# restated prose; see MULTIPLAYER_SPEC.md for the ArenaBoundary/Ship/Ball
|
|
# constants these are sized against) ---
|
|
const POS_RANGE := 64.0 # metres, ±
|
|
const VEL_RANGE := 64.0 # m/s, ±
|
|
const QUAT_COMPONENT_RANGE := 1.0
|
|
const SHIP_AVEL_RANGE := 4.0 # rad/s, ±
|
|
const BALL_AVEL_RANGE := 32.0 # rad/s, ±
|
|
|
|
const I16_MAX := 32767
|
|
const I8_MAX := 127
|
|
const THRUST_Z_BIN_MAX := 7 # 3 bits
|
|
|
|
|
|
# ============================================================
|
|
# Quantisers — pure, reusable, independently testable.
|
|
# ============================================================
|
|
|
|
static func quantize_i16(value: float, range_max: float) -> int:
|
|
var scaled := clampf(value / range_max, -1.0, 1.0) * I16_MAX
|
|
return clampi(roundi(scaled), -I16_MAX, I16_MAX)
|
|
|
|
static func dequantize_i16(raw: int, range_max: float) -> float:
|
|
return (float(raw) / I16_MAX) * range_max
|
|
|
|
static func quantize_i8(value: float, range_max: float) -> int:
|
|
var scaled := clampf(value / range_max, -1.0, 1.0) * I8_MAX
|
|
return clampi(roundi(scaled), -I8_MAX, I8_MAX)
|
|
|
|
static func dequantize_i8(raw: int, range_max: float) -> float:
|
|
return (float(raw) / I8_MAX) * range_max
|
|
|
|
static func quantize_thrust_z_bin(thrust_z: float) -> int:
|
|
var t := clampf((thrust_z + 1.0) * 0.5, 0.0, 1.0)
|
|
return clampi(roundi(t * THRUST_Z_BIN_MAX), 0, THRUST_Z_BIN_MAX)
|
|
|
|
static func dequantize_thrust_z_bin(bin_value: int) -> float:
|
|
return (float(bin_value) / THRUST_Z_BIN_MAX) * 2.0 - 1.0
|
|
|
|
|
|
static func type_version_byte(type: PacketType) -> int:
|
|
return ((int(type) & 0x0F) << 4) | (PROTOCOL_VERSION & 0x0F)
|
|
|
|
static func packet_type_of(type_version: int) -> int:
|
|
return (type_version >> 4) & 0x0F
|
|
|
|
static func protocol_version_of(type_version: int) -> int:
|
|
return type_version & 0x0F
|
|
|
|
|
|
# ============================================================
|
|
# Input packet — client -> server, channel 1 (§2.3)
|
|
# ============================================================
|
|
|
|
# actions: newest-first, 1..MAX_REDUNDANCY ShipAction instances.
|
|
static func pack_input(seq: int, ack_snapshot_tick: int, client_send_ms: int, actions: Array) -> PackedByteArray:
|
|
var count: int = clampi(actions.size(), 1, MAX_REDUNDANCY)
|
|
var buf := StreamPeerBuffer.new()
|
|
buf.put_u8(type_version_byte(PacketType.INPUT))
|
|
buf.put_u32(seq)
|
|
buf.put_u8(count)
|
|
buf.put_u32(ack_snapshot_tick)
|
|
buf.put_u16(client_send_ms & 0xFFFF)
|
|
for i in count:
|
|
var action: ShipAction = actions[i]
|
|
buf.put_8(quantize_i8(action.thrust.x, 1.0))
|
|
buf.put_8(quantize_i8(action.thrust.y, 1.0))
|
|
buf.put_8(quantize_i8(action.thrust.z, 1.0))
|
|
buf.put_8(quantize_i8(action.rotation.x, 1.0))
|
|
buf.put_8(quantize_i8(action.rotation.y, 1.0))
|
|
buf.put_8(quantize_i8(action.rotation.z, 1.0))
|
|
var flags := 0
|
|
if action.turbo:
|
|
flags |= INPUT_FLAG_TURBO
|
|
buf.put_u8(flags)
|
|
return buf.data_array
|
|
|
|
|
|
# Returns a Dictionary: type_version, seq, count, ack_snapshot_tick,
|
|
# client_send_ms, actions (Array[ShipAction], newest first).
|
|
static func unpack_input(bytes: PackedByteArray) -> Dictionary:
|
|
var buf := StreamPeerBuffer.new()
|
|
buf.data_array = bytes
|
|
var type_version := buf.get_u8()
|
|
var seq := buf.get_u32()
|
|
var count := buf.get_u8()
|
|
var ack_snapshot_tick := buf.get_u32()
|
|
var client_send_ms := buf.get_u16()
|
|
var actions: Array[ShipAction] = []
|
|
for i in count:
|
|
var a := ShipAction.new()
|
|
a.thrust = Vector3(
|
|
dequantize_i8(buf.get_8(), 1.0),
|
|
dequantize_i8(buf.get_8(), 1.0),
|
|
dequantize_i8(buf.get_8(), 1.0)
|
|
)
|
|
a.rotation = Vector3(
|
|
dequantize_i8(buf.get_8(), 1.0),
|
|
dequantize_i8(buf.get_8(), 1.0),
|
|
dequantize_i8(buf.get_8(), 1.0)
|
|
)
|
|
var flags := buf.get_u8()
|
|
a.turbo = (flags & INPUT_FLAG_TURBO) != 0
|
|
actions.append(a)
|
|
return {
|
|
"type_version": type_version,
|
|
"seq": seq,
|
|
"count": count,
|
|
"ack_snapshot_tick": ack_snapshot_tick,
|
|
"client_send_ms": client_send_ms,
|
|
"actions": actions,
|
|
}
|
|
|
|
|
|
# ============================================================
|
|
# Snapshot packet — server -> client, channel 2 (§2.4)
|
|
# ============================================================
|
|
|
|
# Shared across every peer this tick — build once, reuse (§2.4's stated
|
|
# intent). Returns type_version + server_tick + match_state + reset_gen +
|
|
# body_count + body_count * SNAPSHOT_BODY_SIZE bytes.
|
|
static func pack_snapshot_body_segment(server_tick: int, match_state: int, reset_gen: int, bodies: Array) -> PackedByteArray:
|
|
var buf := StreamPeerBuffer.new()
|
|
buf.put_u8(type_version_byte(PacketType.SNAPSHOT))
|
|
buf.put_u32(server_tick)
|
|
buf.put_u8(match_state & 0xFF)
|
|
buf.put_u8(reset_gen & 0xFF)
|
|
buf.put_u8(bodies.size())
|
|
for body in bodies:
|
|
var b: NetBodyState = body
|
|
buf.put_16(quantize_i16(b.position.x, POS_RANGE))
|
|
buf.put_16(quantize_i16(b.position.y, POS_RANGE))
|
|
buf.put_16(quantize_i16(b.position.z, POS_RANGE))
|
|
buf.put_16(quantize_i16(b.rotation.x, QUAT_COMPONENT_RANGE))
|
|
buf.put_16(quantize_i16(b.rotation.y, QUAT_COMPONENT_RANGE))
|
|
buf.put_16(quantize_i16(b.rotation.z, QUAT_COMPONENT_RANGE))
|
|
buf.put_16(quantize_i16(b.linear_velocity.x, VEL_RANGE))
|
|
buf.put_16(quantize_i16(b.linear_velocity.y, VEL_RANGE))
|
|
buf.put_16(quantize_i16(b.linear_velocity.z, VEL_RANGE))
|
|
buf.put_8(quantize_i8(b.angular_velocity.x, b.avel_range))
|
|
buf.put_8(quantize_i8(b.angular_velocity.y, b.avel_range))
|
|
buf.put_8(quantize_i8(b.angular_velocity.z, b.avel_range))
|
|
var flags := 0
|
|
if b.frozen:
|
|
flags |= BODY_FLAG_FROZEN
|
|
if b.turbo:
|
|
flags |= BODY_FLAG_TURBO
|
|
flags |= (quantize_thrust_z_bin(b.thrust_z) << BODY_FLAG_THRUST_Z_SHIFT) & BODY_FLAG_THRUST_Z_MASK
|
|
if b.stalled:
|
|
flags |= BODY_FLAG_STALLED
|
|
if b.rotation.w < 0.0:
|
|
flags |= BODY_FLAG_QUAT_W_SIGN
|
|
buf.put_u8(flags)
|
|
return buf.data_array
|
|
|
|
|
|
static func pack_snapshot_client_header(last_input_seq: int, input_buffer_depth: int, echo_client_send_ms: int) -> PackedByteArray:
|
|
var buf := StreamPeerBuffer.new()
|
|
buf.put_u32(last_input_seq)
|
|
buf.put_8(clampi(input_buffer_depth, -128, 127))
|
|
buf.put_u16(echo_client_send_ms & 0xFFFF)
|
|
return buf.data_array
|
|
|
|
|
|
# Convenience: one full per-client packet = per-client header + shared body segment.
|
|
static func pack_snapshot(last_input_seq: int, input_buffer_depth: int, echo_client_send_ms: int, body_segment: PackedByteArray) -> PackedByteArray:
|
|
var header := pack_snapshot_client_header(last_input_seq, input_buffer_depth, echo_client_send_ms)
|
|
var out := PackedByteArray()
|
|
out.append_array(header)
|
|
out.append_array(body_segment)
|
|
return out
|
|
|
|
|
|
# Returns a Dictionary: last_input_seq, input_buffer_depth, echo_client_send_ms,
|
|
# type_version, server_tick, match_state, reset_gen, bodies (Array[NetBodyState]).
|
|
static func unpack_snapshot(bytes: PackedByteArray) -> Dictionary:
|
|
var buf := StreamPeerBuffer.new()
|
|
buf.data_array = bytes
|
|
var last_input_seq := buf.get_u32()
|
|
var input_buffer_depth := buf.get_8()
|
|
var echo_client_send_ms := buf.get_u16()
|
|
var type_version := buf.get_u8()
|
|
var server_tick := buf.get_u32()
|
|
var match_state := buf.get_u8()
|
|
var reset_gen := buf.get_u8()
|
|
var body_count := buf.get_u8()
|
|
var bodies: Array[NetBodyState] = []
|
|
for i in body_count:
|
|
var b := NetBodyState.new()
|
|
b.position = Vector3(
|
|
dequantize_i16(buf.get_16(), POS_RANGE),
|
|
dequantize_i16(buf.get_16(), POS_RANGE),
|
|
dequantize_i16(buf.get_16(), POS_RANGE)
|
|
)
|
|
var qx := dequantize_i16(buf.get_16(), QUAT_COMPONENT_RANGE)
|
|
var qy := dequantize_i16(buf.get_16(), QUAT_COMPONENT_RANGE)
|
|
var qz := dequantize_i16(buf.get_16(), QUAT_COMPONENT_RANGE)
|
|
b.linear_velocity = Vector3(
|
|
dequantize_i16(buf.get_16(), VEL_RANGE),
|
|
dequantize_i16(buf.get_16(), VEL_RANGE),
|
|
dequantize_i16(buf.get_16(), VEL_RANGE)
|
|
)
|
|
# avel_range is unknown to the codec at this point (it isn't on the
|
|
# wire — see net_body_state.gd) — decode at SHIP_AVEL_RANGE and let
|
|
# the caller, which knows this slot's body kind, rescale if it's the
|
|
# ball's slot. Storing the raw i8 would avoid this, but every other
|
|
# field in this struct is already physical units; consistency wins.
|
|
b.angular_velocity = Vector3(
|
|
dequantize_i8(buf.get_8(), SHIP_AVEL_RANGE),
|
|
dequantize_i8(buf.get_8(), SHIP_AVEL_RANGE),
|
|
dequantize_i8(buf.get_8(), SHIP_AVEL_RANGE)
|
|
)
|
|
var flags := buf.get_u8()
|
|
b.frozen = (flags & BODY_FLAG_FROZEN) != 0
|
|
b.turbo = (flags & BODY_FLAG_TURBO) != 0
|
|
var bin_value := (flags & BODY_FLAG_THRUST_Z_MASK) >> BODY_FLAG_THRUST_Z_SHIFT
|
|
b.thrust_z = dequantize_thrust_z_bin(bin_value)
|
|
b.stalled = (flags & BODY_FLAG_STALLED) != 0
|
|
var w_sq := 1.0 - qx * qx - qy * qy - qz * qz
|
|
var w := sqrt(maxf(w_sq, 0.0))
|
|
if (flags & BODY_FLAG_QUAT_W_SIGN) != 0:
|
|
w = -w
|
|
b.rotation = Quaternion(qx, qy, qz, w)
|
|
bodies.append(b)
|
|
return {
|
|
"last_input_seq": last_input_seq,
|
|
"input_buffer_depth": input_buffer_depth,
|
|
"echo_client_send_ms": echo_client_send_ms,
|
|
"type_version": type_version,
|
|
"server_tick": server_tick,
|
|
"match_state": match_state,
|
|
"reset_gen": reset_gen,
|
|
"bodies": bodies,
|
|
}
|
|
|
|
|
|
# Rescales an already-decoded body's angular_velocity from the SHIP_AVEL_RANGE
|
|
# assumption unpack_snapshot() decoded it with to the range it was actually
|
|
# quantised at (BALL_AVEL_RANGE for the ball). Call once per non-ship body
|
|
# immediately after unpack_snapshot(), using slot order to know which.
|
|
static func rescale_avel(body: NetBodyState, actual_range: float) -> void:
|
|
if is_equal_approx(actual_range, SHIP_AVEL_RANGE):
|
|
body.avel_range = actual_range
|
|
return
|
|
body.angular_velocity = (body.angular_velocity / SHIP_AVEL_RANGE) * actual_range
|
|
body.avel_range = actual_range
|