mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
39a41c016c
Implements tasks 2.1-2.7: NetworkedMatch spawns a deterministic slot layout from the lobby roster, the server drives each connected peer's ship via RLShipController fed by decoded client input and broadcasts 60Hz snapshots, and the client renders everything (including its own ship) from a per-body NetInterpolator with no local prediction yet. Dual-time remote entities split collider updates (present-time, for correct contacts) from $Visual updates (interp-delayed, for smoothness). Camera/HUD wiring and remote engine-flame VFX fell out of the existing Ship API for free once snapshots were flowing. Three real bugs found and fixed while getting a two-process test green: an RPC method named _input collided with Node's built-in _input virtual and broke the whole MatchSim autoload from loading; networked_match.gd never called NetworkManager.poll(), so nothing sent via RPC in this scene reached the wire despite Phase 1's manual polling being wired up everywhere else; and a match_config request/response fallback (added to close a startup race) could double-deliver once polling was fixed, requiring an idempotency guard. Verified with tests/networked_match_smoke: a real headless two-process host+client run shows the client rendering 31m of server-authoritative movement from a held forward-thrust input, with thrust_z=1.0 confirmed on the interpolated snapshot mid-drive and camera/HUD both wired. Full Phase 1 regression suite re-run clean alongside it. Task 2.8 (net_sim.gd latency/jitter/loss decorator) is not yet done; Phase 2's own gate needs it before it's fully met.
96 lines
4.0 KiB
GDScript
96 lines
4.0 KiB
GDScript
extends Node
|
|
|
|
# Autoload (project.godot [autoload] MatchSim). Phase 2 simulation RPCs:
|
|
# match_config (server assigns arena + deterministic slot order from
|
|
# MatchNet.roster), input (client -> server, per-tick action), snapshot
|
|
# (server -> client, NetCodec-packed body state), and a small score_update
|
|
# for the HUD. Lives on an autoload per §1.3's derived decision ("All
|
|
# hot-path RPCs live on autoloads") even though these are scoped to
|
|
# whichever match happens to be running — a scene-node RPC target would
|
|
# need matching NodePaths across peers, which an autoload sidesteps
|
|
# entirely, and it's what lets NetworkedMatch itself stay a plain scene
|
|
# node with no networking-identity concerns of its own.
|
|
#
|
|
# Channel intent per §2.1: 0 reliable (match_config, score_update), 1
|
|
# unreliable-ordered (input), 2 unreliable-ordered (snapshot) — not yet
|
|
# verified against ENet's own reserved system channel offset (§2.1's own
|
|
# "verify empirically" hedge); if that turns out to matter these indices
|
|
# will need adjusting, not the RPC design itself.
|
|
|
|
const NetCodec = preload("res://scripts/net_codec.gd")
|
|
|
|
signal match_config_received(arena_path: String, peer_ids: PackedInt32Array, teams: PackedInt32Array, spawn_indices: PackedInt32Array)
|
|
signal input_received(peer_id: int, decoded: Dictionary) # decoded: see NetCodec.unpack_input
|
|
signal snapshot_received(decoded: Dictionary) # decoded: see NetCodec.unpack_snapshot
|
|
signal score_update_received(score: Dictionary)
|
|
|
|
# Server only: the last match_config actually sent, so a client whose own
|
|
# scene load (and therefore its match_config_received listener) finishes
|
|
# AFTER the server already broadcast can still get it — a one-shot
|
|
# broadcast alone is racy against however long the client takes to reach
|
|
# the point where it's listening, and Godot signals never buffer for a
|
|
# late connection. request_match_config() closes that race by turning
|
|
# delivery into "ask until you get it" instead of "hope you were already
|
|
# listening." Also covers a late joiner mid-match (Phase 5 will still need
|
|
# to add live match *state*, not just this static config, for that case).
|
|
var _last_match_config: Dictionary = {}
|
|
|
|
|
|
func send_match_config(arena_path: String, peer_ids: PackedInt32Array, teams: PackedInt32Array, spawn_indices: PackedInt32Array) -> void:
|
|
_last_match_config = {
|
|
"arena_path": arena_path, "peer_ids": peer_ids, "teams": teams, "spawn_indices": spawn_indices,
|
|
}
|
|
_match_config.rpc(arena_path, peer_ids, teams, spawn_indices)
|
|
|
|
|
|
func request_match_config() -> void:
|
|
_request_match_config.rpc_id(1)
|
|
|
|
|
|
func send_input(bytes: PackedByteArray) -> void:
|
|
_recv_input.rpc_id(1, bytes)
|
|
|
|
|
|
func send_snapshot(peer_id: int, bytes: PackedByteArray) -> void:
|
|
_snapshot.rpc_id(peer_id, bytes)
|
|
|
|
|
|
func send_score_update(score: Dictionary) -> void:
|
|
_score_update.rpc(score)
|
|
|
|
|
|
@rpc("authority", "call_remote", "reliable", 0)
|
|
func _match_config(arena_path: String, peer_ids: PackedInt32Array, teams: PackedInt32Array, spawn_indices: PackedInt32Array) -> void:
|
|
match_config_received.emit(arena_path, peer_ids, teams, spawn_indices)
|
|
|
|
|
|
@rpc("any_peer", "call_remote", "reliable", 0)
|
|
func _request_match_config() -> void:
|
|
if not multiplayer.is_server() or _last_match_config.is_empty():
|
|
return
|
|
var peer_id := multiplayer.get_remote_sender_id()
|
|
_match_config.rpc_id(
|
|
peer_id, _last_match_config["arena_path"], _last_match_config["peer_ids"],
|
|
_last_match_config["teams"], _last_match_config["spawn_indices"]
|
|
)
|
|
|
|
|
|
@rpc("any_peer", "call_remote", "unreliable_ordered", 1)
|
|
func _recv_input(bytes: PackedByteArray) -> void:
|
|
if not multiplayer.is_server():
|
|
return
|
|
var peer_id := multiplayer.get_remote_sender_id()
|
|
var decoded := NetCodec.unpack_input(bytes)
|
|
input_received.emit(peer_id, decoded)
|
|
|
|
|
|
@rpc("authority", "call_remote", "unreliable_ordered", 2)
|
|
func _snapshot(bytes: PackedByteArray) -> void:
|
|
var decoded := NetCodec.unpack_snapshot(bytes)
|
|
snapshot_received.emit(decoded)
|
|
|
|
|
|
@rpc("authority", "call_remote", "reliable", 0)
|
|
func _score_update(score: Dictionary) -> void:
|
|
score_update_received.emit(score)
|