PC3 Phase 2.1: server-authoritative sim + prediction/reconciliation with replay
Made the multiplayer core actually work (it had never run a real sim): - project.godot: physics pinned to 30 Hz -- the shared sim/snapshot/prediction rate. - server.gd: loads the world's level (real ground + collision), spawns a server Player per peer at the level's PlayerSpawn, broadcasts a full snapshot every physics frame with each peer's state AND last_seq (the last input seq applied for that peer). Accepts `--port/--world/--status-file` args (status file = player count for a future idle reaper); defaults keep `godot res://scenes/server.tscn` working standalone. - player_spawner.gd (server Player): rewrote -- drains its whole input queue each physics frame in seq order, integrates gravity/walk/jump with the shared constants, move_and_slide(), tracks last_applied_seq. No more no-op process_server_input(). - network_client.gd: rewrote reconciliation -- drops inputs the server has acked (last_seq), re-simulates the still-in-flight ones from the authoritative state, snaps only on real drift. Exposes get_remote_states() (peer_id -> latest snapshot state) for the game to render other players. predict_and_send() now also sends body yaw. - proxies trimmed to the RPC set the rewrite actually uses. - scripts/mp_test_client.gd + run_multiplayer_2p_test.sh: two real client processes vs one real server -- client A sees client B and its own movement round-trips. PASS. - run_checkpoint_tests.py: also run the 2p shell test. smoke_test_network_client 4/4, player 15/15, avatar_in_world 10/10, all green. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
This commit is contained in:
@@ -80,6 +80,27 @@ class Settings(BaseSettings):
|
|||||||
MAX_UPLOAD_SIZE_MB: int = 100
|
MAX_UPLOAD_SIZE_MB: int = 100
|
||||||
GLTF_MAX_FILE_SIZE_MB: int = 200
|
GLTF_MAX_FILE_SIZE_MB: int = 200
|
||||||
|
|
||||||
|
# ── Game-server orchestration ──────────────────────────────────────────
|
||||||
|
# The join flow (POST /api/worlds/{id}/join) ensures a headless Godot
|
||||||
|
# dedicated-server process exists for a world and returns its address.
|
||||||
|
# Disabled by default so nothing tries to spawn Godot in environments
|
||||||
|
# that don't have it (plain pytest, CI without the engine); the
|
||||||
|
# checkpoint/playtest launcher turns it on.
|
||||||
|
GAME_SERVERS_ENABLED: bool = False
|
||||||
|
# Path to the Godot 4 binary. "godot" resolves via PATH.
|
||||||
|
GODOT_BIN: str = str(Path.home() / "godot" / "godot")
|
||||||
|
# Godot project directory (the game repo root -- backend/ is a subdir).
|
||||||
|
GAME_PROJECT_DIR: str = str(Path(__file__).resolve().parents[2])
|
||||||
|
# The host clients should connect to for ENet. Defaults to the LAN IP the
|
||||||
|
# launcher bakes into the exe; overridable for local testing.
|
||||||
|
GAME_SERVER_HOST: str = "127.0.0.1"
|
||||||
|
# Port range for per-world dedicated servers.
|
||||||
|
GAME_SERVER_PORT_MIN: int = 27000
|
||||||
|
GAME_SERVER_PORT_MAX: int = 27099
|
||||||
|
# Kill a world's server this long after its last join with no active
|
||||||
|
# players reported.
|
||||||
|
GAME_SERVER_IDLE_TIMEOUT_SECONDS: int = 300
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=".env",
|
||||||
env_file_encoding="utf-8",
|
env_file_encoding="utf-8",
|
||||||
|
|||||||
@@ -45,6 +45,12 @@ move_down={
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[physics]
|
||||||
|
|
||||||
|
; Fixed 30 Hz sim -- the multiplayer server, client prediction, and snapshot
|
||||||
|
; rate all key off this. See docs/architecture/networking.md 3.1.
|
||||||
|
common/physics_ticks_per_second=30
|
||||||
|
|
||||||
[rendering]
|
[rendering]
|
||||||
|
|
||||||
renderer/rendering_method="gl_compatibility"
|
renderer/rendering_method="gl_compatibility"
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## One client process for the 2-player multiplayer test
|
||||||
|
## (scripts/run_multiplayer_2p_test.sh). Two real OS processes = real network.
|
||||||
|
##
|
||||||
|
## godot --headless --path . --script res://scripts/mp_test_client.gd -- --role a --port 7777
|
||||||
|
##
|
||||||
|
## role "a": moves forward for ~2 s, then prints
|
||||||
|
## RESULT a remotes=<n> moved=<bool> and exits 0 iff it both moved
|
||||||
|
## and saw >=1 remote player.
|
||||||
|
## role "b": connects, holds still for ~4 s, prints RESULT b connected exits 0.
|
||||||
|
|
||||||
|
var role := "b"
|
||||||
|
var port := 7777
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
var args := OS.get_cmdline_user_args()
|
||||||
|
for i in args.size():
|
||||||
|
if args[i] == "--role" and i + 1 < args.size():
|
||||||
|
role = args[i + 1]
|
||||||
|
elif args[i] == "--port" and i + 1 < args.size():
|
||||||
|
port = int(args[i + 1])
|
||||||
|
_run()
|
||||||
|
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
await process_frame
|
||||||
|
var net := NetworkClient.new()
|
||||||
|
root.add_child(net)
|
||||||
|
var ok: bool = await net.connect_to_server("127.0.0.1", port)
|
||||||
|
if not ok:
|
||||||
|
print("RESULT %s connect_failed" % role)
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
for i in 15:
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
if role == "b":
|
||||||
|
# Hold still and stay connected long enough for A to boot, connect,
|
||||||
|
# and observe us (A's Godot startup alone is a couple of seconds).
|
||||||
|
print("RESULT b connected")
|
||||||
|
for i in 1200:
|
||||||
|
await process_frame
|
||||||
|
net.disconnect_from_server()
|
||||||
|
quit(0)
|
||||||
|
return
|
||||||
|
|
||||||
|
# role a: move forward, watch for a remote and our own displacement.
|
||||||
|
var start: Vector3 = net.position
|
||||||
|
var max_remotes := 0
|
||||||
|
for i in 90:
|
||||||
|
net.predict_and_send(1.0 / 30.0, 0.0, -1.0, false, 0.0)
|
||||||
|
await process_frame
|
||||||
|
max_remotes = maxi(max_remotes, net.get_remote_states().size())
|
||||||
|
var moved: bool = net.position.distance_to(start) > 1.0
|
||||||
|
print("RESULT a remotes=%d moved=%s" % [max_remotes, str(moved)])
|
||||||
|
net.disconnect_from_server()
|
||||||
|
quit(0 if (moved and max_remotes >= 1) else 1)
|
||||||
+139
-133
@@ -1,75 +1,62 @@
|
|||||||
class_name NetworkClient
|
class_name NetworkClient
|
||||||
extends Node
|
extends Node
|
||||||
## Client-side networked player: input prediction + server reconciliation.
|
## Client-side multiplayer: ENet connection, local-player input prediction +
|
||||||
|
## server reconciliation (with input replay), and remote-player state for the
|
||||||
|
## game to render.
|
||||||
##
|
##
|
||||||
## Implements docs/architecture/networking.md §5 (Prediction & Reconciliation)
|
## RPC path-matching: Godot resolves an rpc_id() call's target by matching the
|
||||||
## for the *local* player only -- remote-entity interpolation (§5.3) and full
|
## sending node's NodePath on the receiver. scripts/server.gd is the SceneTree
|
||||||
## delta-compressed multi-entity snapshots (§6) are explicitly out of scope
|
## root named "Server"; it spawns each peer's PlayerNode as a child named
|
||||||
## for this first pass; the server already only ever has one player in PC2's
|
## "Player_<peer_id>". So the client needs local nodes at /root/Server (for the
|
||||||
## single-peer testing, and this task's own description scopes it to "the
|
## server-initiated _receive_snapshot) and /root/Server/Player_<own_id> (the
|
||||||
## player controller built in the dependency task" (singular).
|
## target of rpc_request_input). connect_to_server() builds that once the peer
|
||||||
|
## id is known.
|
||||||
##
|
##
|
||||||
## RPC path-matching, the part that makes this actually work:
|
## Usage:
|
||||||
## Godot's high-level rpc_id() resolves the *receiving* node by matching the
|
|
||||||
## *sending* node's NodePath on the caller's side. The server (scripts/
|
|
||||||
## server.gd) is the SceneTree root node named "Server" when the dedicated
|
|
||||||
## server runs scenes/server.tscn as its main scene, and it spawns each
|
|
||||||
## connected player as a *child* of itself named "Player_<peer_id>"
|
|
||||||
## (scripts/server.gd's _on_peer_connected). For an RPC sent by the client to
|
|
||||||
## actually land on the right node, the client needs a LOCAL node tree at the
|
|
||||||
## exact same paths -- /root/Server for _receive_snapshot (called by the
|
|
||||||
## server on itself), and /root/Server/Player_<own_peer_id> for
|
|
||||||
## rpc_request_input (a real method on the server's PlayerNode). Those local
|
|
||||||
## nodes don't need to run any server logic -- they only need to exist at the
|
|
||||||
## right path so Godot's RPC routing finds them. connect_to_server() builds
|
|
||||||
## that structure once the peer id is known (only available after the ENet
|
|
||||||
## handshake completes).
|
|
||||||
##
|
|
||||||
## Usage: instantiate directly (not an autoload -- this only exists for the
|
|
||||||
## lifetime of an actual multiplayer session, not on every client launch):
|
|
||||||
## var net := NetworkClient.new()
|
## var net := NetworkClient.new()
|
||||||
## get_tree().root.add_child(net)
|
## get_tree().root.add_child(net)
|
||||||
## var ok := await net.connect_to_server("127.0.0.1", 7777)
|
## if await net.connect_to_server(host, port):
|
||||||
|
## # each physics frame, from the local player controller:
|
||||||
|
## net.predict_and_send(delta, move_x, move_z, jump, body_yaw)
|
||||||
|
## # read net.position / net.get_remote_states() to render
|
||||||
|
|
||||||
|
# Movement constants -- MUST match scripts/server.gd and player_spawner.gd.
|
||||||
|
const WALK_SPEED := 5.0
|
||||||
|
const JUMP_VELOCITY := 4.5
|
||||||
|
const GRAVITY := 9.8
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
## Predicted vs authoritative drift beyond this (metres) snaps rather than
|
||||||
# Movement constants -- MUST match scripts/player_spawner.gd exactly, per
|
## trusting the prediction.
|
||||||
# docs/architecture/networking.md §5.1 ("the same physics constants as the
|
const RECONCILE_ERROR_THRESHOLD := 0.35
|
||||||
# server"). A mismatch here doesn't break anything outright, it just makes
|
|
||||||
# reconciliation corrections more frequent/visible.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const WALK_SPEED: float = 5.0
|
## Ring buffer cap. 120 frames at 30 Hz = 4 s of history, well past any
|
||||||
const JUMP_VELOCITY: float = 4.5
|
## realistic LAN RTT.
|
||||||
const GRAVITY: float = 9.8
|
const PREDICTION_BUFFER_SIZE := 120
|
||||||
|
|
||||||
## How far predicted and authoritative position can drift before snapping
|
|
||||||
## rather than trusting the prediction was "close enough." Per §5.2.
|
|
||||||
const RECONCILE_ERROR_THRESHOLD: float = 0.5
|
|
||||||
|
|
||||||
## How many past predicted states to keep for reconciliation replay. Real
|
|
||||||
## sizing per §5.4 is RTT-adaptive; a fixed generous buffer is the
|
|
||||||
## appropriately-scoped first pass here -- 120 ticks at a 30Hz send rate is
|
|
||||||
## 4 seconds of history, comfortably more than any realistic LAN/local RTT.
|
|
||||||
const PREDICTION_BUFFER_SIZE: int = 120
|
|
||||||
|
|
||||||
signal reconciled(correction_distance: float)
|
signal reconciled(correction_distance: float)
|
||||||
signal connected_to_server
|
signal connected_to_server
|
||||||
signal connection_failed
|
signal connection_failed
|
||||||
|
signal server_disconnected
|
||||||
|
|
||||||
var peer: ENetMultiplayerPeer
|
var peer: ENetMultiplayerPeer
|
||||||
var _server_root: Node # local /root/Server proxy
|
var my_peer_id: int = 0
|
||||||
var _player_proxy: Node # local /root/Server/Player_<peer_id> proxy
|
var _server_root: Node
|
||||||
|
var _player_proxy: Node
|
||||||
|
|
||||||
var _seq: int = 0
|
var _seq: int = 0
|
||||||
## Ring buffer of {seq, input, position, velocity} predicted at send time,
|
## Ring buffer of {seq, move_x, move_z, jump, delta, position, velocity},
|
||||||
## oldest first. Trimmed as the server acknowledges ticks via snapshots.
|
## oldest first. Entries are dropped as the server acks their seq.
|
||||||
var _prediction_buffer: Array[Dictionary] = []
|
var _prediction_buffer: Array[Dictionary] = []
|
||||||
|
|
||||||
|
## Local predicted player state.
|
||||||
var position: Vector3 = Vector3.ZERO
|
var position: Vector3 = Vector3.ZERO
|
||||||
var velocity: Vector3 = Vector3.ZERO
|
var velocity: Vector3 = Vector3.ZERO
|
||||||
var _on_floor: bool = false
|
var _on_floor: bool = true
|
||||||
|
|
||||||
|
## peer_id -> latest authoritative state dict from the most recent snapshot,
|
||||||
|
## for every peer that isn't us. The game reads this to render other players.
|
||||||
|
var _remote_states: Dictionary = {}
|
||||||
|
var _last_snapshot_tick: int = -1
|
||||||
|
|
||||||
|
|
||||||
func connect_to_server(host: String, port: int) -> bool:
|
func connect_to_server(host: String, port: int) -> bool:
|
||||||
@@ -81,28 +68,24 @@ func connect_to_server(host: String, port: int) -> bool:
|
|||||||
return false
|
return false
|
||||||
|
|
||||||
multiplayer.multiplayer_peer = peer
|
multiplayer.multiplayer_peer = peer
|
||||||
multiplayer.connected_to_server.connect(_on_connected)
|
multiplayer.server_disconnected.connect(func(): server_disconnected.emit())
|
||||||
multiplayer.connection_failed.connect(func():
|
|
||||||
connection_failed.emit()
|
|
||||||
)
|
|
||||||
|
|
||||||
for i in 300: # ~5s at 60fps
|
for _i in 300: # ~5 s at 60 fps
|
||||||
await get_tree().process_frame
|
await get_tree().process_frame
|
||||||
var status := peer.get_connection_status()
|
match peer.get_connection_status():
|
||||||
if status == MultiplayerPeer.CONNECTION_CONNECTED:
|
MultiplayerPeer.CONNECTION_CONNECTED:
|
||||||
return true
|
_on_connected()
|
||||||
elif status == MultiplayerPeer.CONNECTION_DISCONNECTED:
|
return true
|
||||||
connection_failed.emit()
|
MultiplayerPeer.CONNECTION_DISCONNECTED:
|
||||||
return false
|
connection_failed.emit()
|
||||||
|
return false
|
||||||
connection_failed.emit()
|
connection_failed.emit()
|
||||||
return false
|
return false
|
||||||
|
|
||||||
|
|
||||||
func _on_connected() -> void:
|
func _on_connected() -> void:
|
||||||
var my_id := multiplayer.get_unique_id()
|
my_peer_id = multiplayer.get_unique_id()
|
||||||
|
|
||||||
# Build the local proxy tree at the exact paths the server's own RPC
|
|
||||||
# calls target -- see the class docstring for why this has to exist.
|
|
||||||
_server_root = Node.new()
|
_server_root = Node.new()
|
||||||
_server_root.name = "Server"
|
_server_root.name = "Server"
|
||||||
_server_root.set_script(preload("res://scripts/network_receiver_proxy.gd"))
|
_server_root.set_script(preload("res://scripts/network_receiver_proxy.gd"))
|
||||||
@@ -110,7 +93,7 @@ func _on_connected() -> void:
|
|||||||
_server_root.set("owner_client", self)
|
_server_root.set("owner_client", self)
|
||||||
|
|
||||||
_player_proxy = Node.new()
|
_player_proxy = Node.new()
|
||||||
_player_proxy.name = "Player_%d" % my_id
|
_player_proxy.name = "Player_%d" % my_peer_id
|
||||||
_player_proxy.set_script(preload("res://scripts/network_player_proxy.gd"))
|
_player_proxy.set_script(preload("res://scripts/network_player_proxy.gd"))
|
||||||
_server_root.add_child(_player_proxy)
|
_server_root.add_child(_player_proxy)
|
||||||
|
|
||||||
@@ -123,60 +106,56 @@ func disconnect_from_server() -> void:
|
|||||||
if _server_root and is_instance_valid(_server_root):
|
if _server_root and is_instance_valid(_server_root):
|
||||||
_server_root.queue_free()
|
_server_root.queue_free()
|
||||||
multiplayer.multiplayer_peer = null
|
multiplayer.multiplayer_peer = null
|
||||||
|
_prediction_buffer.clear()
|
||||||
|
_remote_states.clear()
|
||||||
|
|
||||||
|
|
||||||
|
func is_connected_to_server() -> bool:
|
||||||
|
return peer != null and peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTED
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Prediction -- called every physics frame by whatever drives input (a real
|
# Prediction -- call every physics frame from the local player controller.
|
||||||
# controller, or a test harness). Applies input locally immediately (so it
|
# Applies input locally immediately (zero perceived latency) and sends it.
|
||||||
# renders with zero perceived latency) and sends it to the server, per §5.1.
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func predict_and_send(delta: float, move_x: float, move_z: float, jump: bool) -> void:
|
func predict_and_send(delta: float, move_x: float, move_z: float, jump: bool, look_yaw: float = 0.0) -> void:
|
||||||
_apply_movement(delta, move_x, move_z, jump)
|
_apply_movement(delta, move_x, move_z, jump)
|
||||||
|
|
||||||
_seq += 1
|
_seq += 1
|
||||||
var record := {
|
_prediction_buffer.append({
|
||||||
"seq": _seq,
|
"seq": _seq,
|
||||||
"input": {"move_x": move_x, "move_z": move_z, "jump": jump},
|
"move_x": move_x, "move_z": move_z, "jump": jump,
|
||||||
"position": position,
|
"delta": delta,
|
||||||
"velocity": velocity,
|
"position": position, "velocity": velocity,
|
||||||
}
|
})
|
||||||
_prediction_buffer.append(record)
|
|
||||||
if _prediction_buffer.size() > PREDICTION_BUFFER_SIZE:
|
if _prediction_buffer.size() > PREDICTION_BUFFER_SIZE:
|
||||||
_prediction_buffer.pop_front()
|
_prediction_buffer.pop_front()
|
||||||
|
|
||||||
if _player_proxy and is_instance_valid(_player_proxy):
|
if _player_proxy and is_instance_valid(_player_proxy):
|
||||||
_player_proxy.rpc_id(1, "rpc_request_input", record["input"], _seq)
|
_player_proxy.rpc_id(1, "rpc_request_input",
|
||||||
|
{"move_x": move_x, "move_z": move_z, "jump": jump, "look_yaw": look_yaw}, _seq)
|
||||||
|
|
||||||
|
|
||||||
func _apply_movement(delta: float, move_x: float, move_z: float, jump: bool) -> void:
|
func _apply_movement(delta: float, move_x: float, move_z: float, jump: bool) -> void:
|
||||||
# Mirrors scripts/player_spawner.gd's _process_input + gravity/jump,
|
|
||||||
# intentionally without its ground-friction smoothing -- friction is a
|
|
||||||
# server-authoritative-feel detail, not something worth risking a
|
|
||||||
# prediction/server mismatch over in this first pass. Position still
|
|
||||||
# reconciles against the server's real (frictioned) result every
|
|
||||||
# snapshot, so this doesn't silently drift.
|
|
||||||
if not _on_floor:
|
if not _on_floor:
|
||||||
velocity.y -= GRAVITY * delta
|
velocity.y -= GRAVITY * delta
|
||||||
|
elif jump:
|
||||||
|
velocity.y = JUMP_VELOCITY
|
||||||
|
|
||||||
var direction := Vector3(move_x, 0.0, move_z)
|
var dir := Vector3(move_x, 0.0, move_z)
|
||||||
if direction.length() > 0.01:
|
if dir.length() > 0.01:
|
||||||
direction = direction.normalized()
|
dir = dir.normalized()
|
||||||
velocity.x = direction.x * WALK_SPEED
|
velocity.x = dir.x * WALK_SPEED
|
||||||
velocity.z = direction.z * WALK_SPEED
|
velocity.z = dir.z * WALK_SPEED
|
||||||
else:
|
else:
|
||||||
velocity.x = 0.0
|
velocity.x = 0.0
|
||||||
velocity.z = 0.0
|
velocity.z = 0.0
|
||||||
|
|
||||||
if jump and _on_floor:
|
|
||||||
velocity.y = JUMP_VELOCITY
|
|
||||||
|
|
||||||
position += velocity * delta
|
position += velocity * delta
|
||||||
# Headless prediction has no collision shape of its own to test
|
# Headless prediction has no collision shape; treat the spawn plane as the
|
||||||
# is_on_floor() against -- treat "at or below the spawn-plane ground"
|
# floor. A render-side controller drives the real CharacterBody3D and reads
|
||||||
# as grounded. A real render-side controller would swap this for the
|
# `position` back for reconciliation only.
|
||||||
# actual CharacterBody3D.is_on_floor() of whatever visual node this
|
|
||||||
# drives; this class only owns the *networking* half.
|
|
||||||
if position.y <= 0.0:
|
if position.y <= 0.0:
|
||||||
position.y = 0.0
|
position.y = 0.0
|
||||||
velocity.y = 0.0
|
velocity.y = 0.0
|
||||||
@@ -186,49 +165,76 @@ func _apply_movement(delta: float, move_x: float, move_z: float, jump: bool) ->
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Reconciliation -- called by _ReceiverScript when a snapshot arrives.
|
# Reconciliation -- _ReceiverProxy calls this when a snapshot arrives.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func _handle_snapshot(snapshot_data: Dictionary) -> void:
|
func _handle_snapshot(snap: Dictionary) -> void:
|
||||||
var my_id := multiplayer.get_unique_id()
|
var snap_tick: int = snap.get("tick", 0)
|
||||||
var players: Dictionary = snapshot_data.get("players", {})
|
if snap_tick <= _last_snapshot_tick:
|
||||||
# Server keys player_states by peer_id -- Dictionary keys from JSON-ish
|
return # Stale / out-of-order (unreliable channel).
|
||||||
# RPC payloads can arrive as String or int depending on the transport
|
_last_snapshot_tick = snap_tick
|
||||||
# encoding, so check both rather than assuming.
|
|
||||||
var my_state: Variant = players.get(my_id, players.get(str(my_id), null))
|
var players: Dictionary = snap.get("players", {})
|
||||||
if my_state == null or not (my_state is Dictionary):
|
|
||||||
|
# --- Remote players ---
|
||||||
|
_remote_states.clear()
|
||||||
|
for key in players:
|
||||||
|
var pid := int(key)
|
||||||
|
if pid == my_peer_id:
|
||||||
|
continue
|
||||||
|
_remote_states[pid] = players[key]
|
||||||
|
|
||||||
|
# --- Local player: reconcile ---
|
||||||
|
var mine: Variant = players.get(my_peer_id, players.get(str(my_peer_id), null))
|
||||||
|
if not (mine is Dictionary):
|
||||||
return
|
return
|
||||||
|
var auth_pos: Vector3 = mine.get("position", position)
|
||||||
|
var auth_vel: Vector3 = mine.get("velocity", velocity)
|
||||||
|
var acked_seq: int = int(mine.get("last_seq", 0))
|
||||||
|
|
||||||
var authoritative_pos: Vector3 = (my_state as Dictionary).get("position", position)
|
# Drop inputs the server has already applied.
|
||||||
|
while not _prediction_buffer.is_empty() and _prediction_buffer[0]["seq"] <= acked_seq:
|
||||||
|
_prediction_buffer.pop_front()
|
||||||
|
|
||||||
# Real limitation, noted explicitly rather than silently faked: the
|
# Re-simulate the still-in-flight inputs from the authoritative state.
|
||||||
# server's snapshot_state (scripts/server.gd's _broadcast_snapshot) does
|
var replay_pos := auth_pos
|
||||||
# not currently echo back "last input seq processed for this peer" --
|
var replay_vel := auth_vel
|
||||||
# its `tick` field is the server's own internal simulation tick counter,
|
var replay_floor := replay_pos.y <= 0.0
|
||||||
# a completely different clock from this client's per-input `_seq`. True
|
for entry in _prediction_buffer:
|
||||||
# tick-correlated rollback (docs/architecture/networking.md §5.2 step 4,
|
var d: float = entry["delta"]
|
||||||
# "re-simulate any inputs that occurred after the server's tick") needs
|
if not replay_floor:
|
||||||
# that ack echoed back per-peer -- a real, scoped follow-up, not
|
replay_vel.y -= GRAVITY * d
|
||||||
# something to fake here. Until then, reconcile against the MOST RECENT
|
elif entry["jump"]:
|
||||||
# prediction (not a tick-matched one): still a real error measurement
|
replay_vel.y = JUMP_VELOCITY
|
||||||
# and a real correction, just without precise replay-from-exact-tick.
|
var dir := Vector3(entry["move_x"], 0.0, entry["move_z"])
|
||||||
if _prediction_buffer.is_empty():
|
if dir.length() > 0.01:
|
||||||
return
|
dir = dir.normalized()
|
||||||
var latest_entry: Dictionary = _prediction_buffer[-1]
|
replay_vel.x = dir.x * WALK_SPEED
|
||||||
|
replay_vel.z = dir.z * WALK_SPEED
|
||||||
|
else:
|
||||||
|
replay_vel.x = 0.0
|
||||||
|
replay_vel.z = 0.0
|
||||||
|
replay_pos += replay_vel * d
|
||||||
|
if replay_pos.y <= 0.0:
|
||||||
|
replay_pos.y = 0.0
|
||||||
|
replay_vel.y = 0.0
|
||||||
|
replay_floor = true
|
||||||
|
else:
|
||||||
|
replay_floor = false
|
||||||
|
|
||||||
var error: float = (latest_entry["position"] as Vector3).distance_to(authoritative_pos)
|
var error := position.distance_to(replay_pos)
|
||||||
if error <= RECONCILE_ERROR_THRESHOLD:
|
if error > RECONCILE_ERROR_THRESHOLD:
|
||||||
return # Close enough -- prediction was correct, nothing to do.
|
position = replay_pos
|
||||||
|
velocity = replay_vel
|
||||||
|
_on_floor = replay_floor
|
||||||
|
reconciled.emit(error)
|
||||||
|
|
||||||
# Correct: snap to the authoritative position. Without a real ack-seq
|
|
||||||
# from the server there's no reliable "replay inputs after this point"
|
|
||||||
# window to recompute, so the buffer is cleared and prediction resumes
|
|
||||||
# fresh from the corrected position on the next input -- still visibly
|
|
||||||
# responsive (no waiting for a round trip before the *next* input
|
|
||||||
# applies), just without this pass replaying in-flight inputs.
|
|
||||||
position = authoritative_pos
|
|
||||||
velocity = Vector3.ZERO
|
|
||||||
_prediction_buffer.clear()
|
|
||||||
|
|
||||||
reconciled.emit(error)
|
# ---------------------------------------------------------------------------
|
||||||
|
# Remote-player state for rendering
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## peer_id -> {position, rotation_y, velocity, on_floor, last_seq} from the most
|
||||||
|
## recent snapshot. Read this each frame to spawn/update remote avatars.
|
||||||
|
func get_remote_states() -> Dictionary:
|
||||||
|
return _remote_states
|
||||||
|
|||||||
@@ -1,43 +1,11 @@
|
|||||||
extends Node
|
extends Node
|
||||||
## Client-side proxy for the server's real PlayerNode (scripts/
|
## Client-side proxy for the server's PlayerNode (scripts/player_spawner.gd),
|
||||||
## player_spawner.gd), attached at runtime to a Node named "Player_<peer_id>"
|
## attached at runtime as "Player_<own_peer_id>" under the /root/Server proxy.
|
||||||
## under the /root/Server proxy by NetworkClient._on_connected().
|
|
||||||
##
|
##
|
||||||
## rpc_request_input is the only one of these actually CALLED from this
|
## rpc_request_input is the only RPC the client sends. Godot resolves the RPC
|
||||||
## side -- the real implementations all live on the server. But two things
|
## transfer config from the LOCAL node's declaration and verifies both peers
|
||||||
## force every @rpc method on the real PlayerNode to be mirrored here too,
|
## declare the same @rpc set at this path, so it must match player_spawner.gd.
|
||||||
## not just the one this class uses:
|
|
||||||
## 1. Godot's rpc_id() looks up the RPC transfer config (mode/reliability)
|
|
||||||
## from the LOCAL script before it will even send a call, so the method
|
|
||||||
## being called has to be declared locally with a matching annotation.
|
|
||||||
## 2. Godot's multiplayer layer also verifies an "RPC node checksum" --
|
|
||||||
## both peers must declare the *same set* of @rpc methods at a matching
|
|
||||||
## path, or every RPC at that path starts failing, not just the one
|
|
||||||
## that's actually missing. So even rpc_request_interact/attack/
|
|
||||||
## set_position/set_rotation, which this class never calls, have to be
|
|
||||||
## declared here with matching annotations or rpc_request_input itself
|
|
||||||
## breaks too.
|
|
||||||
|
|
||||||
@rpc("any_peer", "call_remote", "reliable")
|
@rpc("any_peer", "call_remote", "reliable")
|
||||||
func rpc_request_input(_input_state: Dictionary, _seq: int) -> void:
|
func rpc_request_input(_input: Dictionary, _seq: int) -> void:
|
||||||
pass # Never invoked locally -- see the note above.
|
pass # Real implementation is on the server.
|
||||||
|
|
||||||
|
|
||||||
@rpc("any_peer", "call_remote", "reliable")
|
|
||||||
func rpc_request_interact(_target_id: int) -> void:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@rpc("any_peer", "call_remote", "reliable")
|
|
||||||
func rpc_request_attack(_target_id: int, _aim_dir: Vector3) -> void:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@rpc("authority", "call_remote", "reliable")
|
|
||||||
func rpc_set_position(_pos: Vector3) -> void:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@rpc("authority", "call_remote", "reliable")
|
|
||||||
func rpc_set_rotation(_rot: Vector3) -> void:
|
|
||||||
pass
|
|
||||||
|
|||||||
@@ -1,38 +1,15 @@
|
|||||||
extends Node
|
extends Node
|
||||||
## Client-side proxy node, attached at runtime to a Node named "Server" at
|
## Client-side proxy node, attached at runtime as a Node named "Server" at the
|
||||||
## the SceneTree root by NetworkClient.connect_to_server(). Exists purely so
|
## SceneTree root by NetworkClient. Exists purely so Godot's RPC path-matching
|
||||||
## Godot's RPC path-matching finds a receiver for _receive_snapshot -- see
|
## finds a receiver for _receive_snapshot (the server calls it on its own root
|
||||||
## network_client.gd's class docstring for the full explanation of why this
|
## "Server" node, so the client needs a node at /root/Server declaring the same
|
||||||
## has to exist at this exact path. Not a real server in any sense.
|
## @rpc set). Not a real server.
|
||||||
##
|
|
||||||
## Godot's multiplayer layer verifies an "RPC node checksum" -- both peers
|
|
||||||
## must declare the *same set* of @rpc methods at a matching path, or every
|
|
||||||
## RPC at that path (not just the mismatched one) starts failing with
|
|
||||||
## "rpc node checksum failed". So every @rpc method scripts/server.gd
|
|
||||||
## declares on its own root "Server" node has to be mirrored here too, even
|
|
||||||
## the ones this proxy never actually needs to receive.
|
|
||||||
|
|
||||||
var owner_client: Node = null
|
var owner_client: Node = null
|
||||||
|
|
||||||
|
|
||||||
## Must match scripts/server.gd's own @rpc annotation on _receive_snapshot
|
## Must match scripts/server.gd's @rpc annotation on _receive_snapshot exactly.
|
||||||
## exactly -- Godot enforces the receiving method's declared mode/transfer
|
|
||||||
## config, a mismatch here would silently drop the call.
|
|
||||||
@rpc("any_peer", "call_remote", "reliable")
|
@rpc("any_peer", "call_remote", "reliable")
|
||||||
func _receive_snapshot(snapshot_data: Dictionary) -> void:
|
func _receive_snapshot(snapshot_data: Dictionary) -> void:
|
||||||
if owner_client and is_instance_valid(owner_client):
|
if owner_client and is_instance_valid(owner_client):
|
||||||
owner_client._handle_snapshot(snapshot_data)
|
owner_client._handle_snapshot(snapshot_data)
|
||||||
|
|
||||||
|
|
||||||
## Mirrors scripts/server.gd's _kick -- never actually invoked on this side
|
|
||||||
## (the server calls it on a Player_<id> node's own rpc_id target, not this
|
|
||||||
## root proxy), declared only so the RPC checksum for this path matches.
|
|
||||||
@rpc("any_peer", "call_remote", "reliable")
|
|
||||||
func _kick(_reason: String) -> void:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
## Mirrors scripts/server.gd's _disconnect -- same reasoning as _kick above.
|
|
||||||
@rpc("call_remote", "reliable")
|
|
||||||
func _disconnect(_reason: String) -> void:
|
|
||||||
pass
|
|
||||||
|
|||||||
+50
-207
@@ -1,232 +1,75 @@
|
|||||||
class_name PlayerNode
|
class_name PlayerNode
|
||||||
extends CharacterBody3D
|
extends CharacterBody3D
|
||||||
## PlayerNode - Server-side player entity.
|
## Server-authoritative player entity.
|
||||||
##
|
##
|
||||||
## Represents a connected player on the server. Handles movement,
|
## One per connected peer, spawned as a child of the Server node. Movement runs
|
||||||
## input processing, and state synchronization.
|
## in _physics_process at the project's fixed physics rate (30 Hz, matching
|
||||||
|
## scripts/server.gd's snapshot rate and the client's prediction step).
|
||||||
|
|
||||||
# --- Signals ---
|
## Movement constants -- mirror scripts/server.gd (the source of truth) and
|
||||||
signal health_changed(new_health: int)
|
## scripts/network_client.gd (the client's prediction).
|
||||||
signal player_died(player_node: PlayerNode)
|
|
||||||
|
|
||||||
|
|
||||||
# --- Constants ---
|
|
||||||
const WALK_SPEED := 5.0
|
const WALK_SPEED := 5.0
|
||||||
const SPRINT_MULTIPLIER := 1.6
|
|
||||||
const JUMP_VELOCITY := 4.5
|
const JUMP_VELOCITY := 4.5
|
||||||
const GRAVITY := 9.8
|
const GRAVITY := 9.8
|
||||||
const MAX_FALL_SPEED := 20.0
|
|
||||||
const FRICTION := 8.0
|
|
||||||
const AIR_CONTROL := 0.3
|
|
||||||
|
|
||||||
# --- Configurable ---
|
|
||||||
@export var walk_speed: float = WALK_SPEED
|
|
||||||
@export var sprint_multiplier: float = SPRINT_MULTIPLIER
|
|
||||||
@export var jump_velocity: float = JUMP_VELOCITY
|
|
||||||
@export var max_fall_speed: float = MAX_FALL_SPEED
|
|
||||||
|
|
||||||
# --- State ---
|
|
||||||
@export var max_health: int = 100
|
|
||||||
var current_health: int = 0
|
|
||||||
var is_alive: bool = true
|
|
||||||
var is_sprinting: bool = false
|
|
||||||
var peer_id: int = -1
|
var peer_id: int = -1
|
||||||
var player_name: String = ""
|
|
||||||
var entity_id: int = 0
|
|
||||||
|
|
||||||
# --- Input buffer ---
|
## Highest input seq applied authoritatively so far (echoed in snapshots so
|
||||||
|
## the client can drop acked inputs and replay the rest).
|
||||||
|
var last_applied_seq: int = 0
|
||||||
|
|
||||||
|
## Queue of {seq:int, move_x:float, move_z:float, jump:bool, look_yaw:float}
|
||||||
|
## appended by rpc_request_input, drained each physics frame.
|
||||||
var _input_queue: Array[Dictionary] = []
|
var _input_queue: Array[Dictionary] = []
|
||||||
var _last_processed_seq: int = -1
|
var _look_yaw: float = 0.0
|
||||||
|
|
||||||
# --- Movement ---
|
|
||||||
var _velocity: Vector3 = Vector3.ZERO
|
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
|
||||||
current_health = max_health
|
|
||||||
velocity = _velocity
|
|
||||||
# Start alive
|
|
||||||
is_alive = true
|
|
||||||
|
|
||||||
|
|
||||||
func _physics_process(delta: float) -> void:
|
func _physics_process(delta: float) -> void:
|
||||||
if not is_alive:
|
if _input_queue.is_empty():
|
||||||
return
|
_integrate(0.0, 0.0, false, delta)
|
||||||
|
else:
|
||||||
# Process input from queue
|
# Reliable ENet preserves order; sort defensively anyway.
|
||||||
_process_input(delta)
|
_input_queue.sort_custom(func(a, b): return a["seq"] < b["seq"])
|
||||||
|
for input in _input_queue:
|
||||||
# Apply gravity
|
_look_yaw = input.get("look_yaw", _look_yaw)
|
||||||
|
_integrate(input.get("move_x", 0.0), input.get("move_z", 0.0), input.get("jump", false), delta)
|
||||||
|
last_applied_seq = input["seq"]
|
||||||
|
_input_queue.clear()
|
||||||
|
|
||||||
|
rotation.y = _look_yaw
|
||||||
|
|
||||||
|
|
||||||
|
func _integrate(move_x: float, move_z: float, jump: bool, delta: float) -> void:
|
||||||
if not is_on_floor():
|
if not is_on_floor():
|
||||||
velocity.y -= GRAVITY * delta
|
velocity.y -= GRAVITY * delta
|
||||||
velocity.y = clampf(velocity.y, -max_fall_speed, max_fall_speed)
|
elif jump:
|
||||||
|
velocity.y = JUMP_VELOCITY
|
||||||
# Apply friction on ground
|
|
||||||
if is_on_floor():
|
|
||||||
var move_dir := Vector2(velocity.x, velocity.z)
|
|
||||||
if move_dir.length() > 0.01:
|
|
||||||
var friction_factor := clampf(FRICTION * delta, 0.0, 1.0)
|
|
||||||
velocity.x = move_dir.normalized().x * move_dir.length() * (1.0 - friction_factor)
|
|
||||||
velocity.z = move_dir.normalized().y * move_dir.length() * (1.0 - friction_factor)
|
|
||||||
else:
|
|
||||||
velocity.x = 0.0
|
|
||||||
velocity.z = 0.0
|
|
||||||
|
|
||||||
# Move and slide
|
|
||||||
velocity.y = clampf(velocity.y, -max_fall_speed, max_fall_speed)
|
|
||||||
move_and_slide()
|
|
||||||
|
|
||||||
# Update visual position (for remote clients via RPC)
|
|
||||||
_update_visual_state()
|
|
||||||
|
|
||||||
|
var dir := Vector3(move_x, 0.0, move_z)
|
||||||
func _process_input(delta: float) -> void:
|
if dir.length() > 0.01:
|
||||||
"""Process buffered input from the server's input queue."""
|
dir = dir.normalized()
|
||||||
if _input_queue.is_empty():
|
velocity.x = dir.x * WALK_SPEED
|
||||||
return
|
velocity.z = dir.z * WALK_SPEED
|
||||||
|
|
||||||
var input_data: Dictionary = _input_queue.pop_front()
|
|
||||||
var input_state: Dictionary = input_data.get("input_state", {})
|
|
||||||
|
|
||||||
# Calculate movement direction
|
|
||||||
var direction := Vector3.ZERO
|
|
||||||
direction.x = input_state.get("move_x", 0.0)
|
|
||||||
direction.z = input_state.get("move_z", 0.0)
|
|
||||||
|
|
||||||
# Sprint check
|
|
||||||
is_sprinting = input_state.get("sprint", false)
|
|
||||||
|
|
||||||
if direction.length() > 0.01:
|
|
||||||
direction = direction.normalized()
|
|
||||||
var speed = walk_speed * sprint_multiplier if is_sprinting else walk_speed
|
|
||||||
velocity.x = direction.x * speed
|
|
||||||
velocity.z = direction.z * speed
|
|
||||||
else:
|
else:
|
||||||
# On ground, apply friction (handled in physics_process)
|
velocity.x = 0.0
|
||||||
if not is_on_floor():
|
velocity.z = 0.0
|
||||||
velocity.x *= (1.0 - AIR_CONTROL * delta * 5.0)
|
|
||||||
velocity.z *= (1.0 - AIR_CONTROL * delta * 5.0)
|
|
||||||
|
|
||||||
# Jump
|
|
||||||
if input_state.get("jump", false) and is_on_floor():
|
|
||||||
velocity.y = jump_velocity
|
|
||||||
|
|
||||||
|
move_and_slide()
|
||||||
func take_damage(amount: int, source_id: int) -> Dictionary:
|
if is_on_floor() and velocity.y < 0.0:
|
||||||
"""Take damage and return result dict."""
|
velocity.y = 0.0
|
||||||
if not is_alive:
|
|
||||||
return {"success": false, "reason": "already_dead"}
|
|
||||||
|
|
||||||
current_health = max(0, current_health - amount)
|
|
||||||
health_changed.emit(current_health)
|
|
||||||
|
|
||||||
if current_health <= 0:
|
|
||||||
die()
|
|
||||||
return {"success": true, "health": 0, "dead": true}
|
|
||||||
|
|
||||||
return {"success": true, "health": current_health}
|
|
||||||
|
|
||||||
|
|
||||||
func die() -> void:
|
|
||||||
"""Handle player death."""
|
|
||||||
is_alive = false
|
|
||||||
velocity = Vector3.ZERO
|
|
||||||
player_died.emit(self)
|
|
||||||
|
|
||||||
# Respawn after delay
|
|
||||||
await get_tree().create_timer(3.0).timeout
|
|
||||||
respawn()
|
|
||||||
|
|
||||||
|
|
||||||
func respawn() -> void:
|
|
||||||
"""Respawn the player at spawn point."""
|
|
||||||
current_health = max_health
|
|
||||||
is_alive = true
|
|
||||||
velocity = Vector3.ZERO
|
|
||||||
health_changed.emit(current_health)
|
|
||||||
|
|
||||||
# Move to spawn point (default: origin)
|
|
||||||
position = Vector3.ZERO
|
|
||||||
|
|
||||||
|
|
||||||
func set_input_queue(inputs: Array[Dictionary]) -> void:
|
|
||||||
"""Set the input queue from the server."""
|
|
||||||
_input_queue = inputs
|
|
||||||
|
|
||||||
|
|
||||||
func get_state() -> Dictionary:
|
|
||||||
"""Get current player state for replication."""
|
|
||||||
return {
|
|
||||||
"entity_id": entity_id,
|
|
||||||
"peer_id": peer_id,
|
|
||||||
"position": position,
|
|
||||||
"rotation": rotation,
|
|
||||||
"health": current_health,
|
|
||||||
"max_health": max_health,
|
|
||||||
"is_alive": is_alive,
|
|
||||||
"is_sprinting": is_sprinting,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
func get_server_state() -> Dictionary:
|
func get_server_state() -> Dictionary:
|
||||||
"""Get current player state for server snapshot broadcast."""
|
return {
|
||||||
return get_state()
|
"position": global_position,
|
||||||
|
"rotation_y": rotation.y,
|
||||||
|
"velocity": velocity,
|
||||||
func process_server_input(tick: int) -> void:
|
"on_floor": is_on_floor(),
|
||||||
"""Called by server tick to process buffered input."""
|
}
|
||||||
# This is called from the server's _tick loop.
|
|
||||||
# The input queue is populated by RPCs from clients.
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
func _update_visual_state() -> void:
|
|
||||||
"""Update visual state (for remote interpolation)."""
|
|
||||||
# In a full implementation, this would update a remote player's
|
|
||||||
# visual representation. For now, the node itself is the authority.
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# --- RPCs ---
|
|
||||||
|
|
||||||
@rpc("any_peer", "call_remote", "reliable")
|
|
||||||
func rpc_request_input(input_state: Dictionary, seq: int) -> void:
|
|
||||||
"""Receive input from client."""
|
|
||||||
if not is_alive:
|
|
||||||
return
|
|
||||||
# Store for processing in _physics_process
|
|
||||||
_input_queue.append({
|
|
||||||
"input_state": input_state,
|
|
||||||
"seq": seq,
|
|
||||||
"timestamp": Time.get_ticks_msec(),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@rpc("any_peer", "call_remote", "reliable")
|
@rpc("any_peer", "call_remote", "reliable")
|
||||||
func rpc_request_interact(target_id: int) -> void:
|
func rpc_request_input(input: Dictionary, seq: int) -> void:
|
||||||
"""Handle interaction request from client."""
|
var d := input.duplicate()
|
||||||
if not is_alive:
|
d["seq"] = seq
|
||||||
return
|
_input_queue.append(d)
|
||||||
# TODO: Implement interaction logic
|
|
||||||
print("Player %d interacted with entity %d" % [peer_id, target_id])
|
|
||||||
|
|
||||||
|
|
||||||
@rpc("any_peer", "call_remote", "reliable")
|
|
||||||
func rpc_request_attack(target_id: int, aim_dir: Vector3) -> void:
|
|
||||||
"""Handle attack request from client."""
|
|
||||||
if not is_alive:
|
|
||||||
return
|
|
||||||
# TODO: Implement attack logic
|
|
||||||
print("Player %d attacked entity %d with aim %s" % [peer_id, target_id, aim_dir])
|
|
||||||
|
|
||||||
|
|
||||||
@rpc("authority", "call_remote", "reliable")
|
|
||||||
func rpc_set_position(pos: Vector3) -> void:
|
|
||||||
"""Server authoritative position update."""
|
|
||||||
position = pos
|
|
||||||
|
|
||||||
|
|
||||||
@rpc("authority", "call_remote", "reliable")
|
|
||||||
func rpc_set_rotation(rot: Vector3) -> void:
|
|
||||||
"""Server authoritative rotation update."""
|
|
||||||
rotation = rot
|
|
||||||
|
|||||||
Executable
+45
@@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Two real client processes against one real dedicated server: client A must
|
||||||
|
# see client B and its own movement must round-trip through the server.
|
||||||
|
#
|
||||||
|
# Usage: scripts/run_multiplayer_2p_test.sh
|
||||||
|
# Exit 0 iff A reports moved=true and remotes>=1.
|
||||||
|
set -uo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
GODOT_BIN="${GODOT_BIN:-godot}"
|
||||||
|
command -v "$GODOT_BIN" >/dev/null 2>&1 || GODOT_BIN="$HOME/godot/godot"
|
||||||
|
PORT=7787
|
||||||
|
|
||||||
|
cleanup() { kill "${SRV_PID:-}" "${B_PID:-}" 2>/dev/null; wait 2>/dev/null; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo "=== starting dedicated server on $PORT ==="
|
||||||
|
"$GODOT_BIN" --headless --path . res://scenes/server.tscn -- --port "$PORT" >/tmp/mp2p_server.log 2>&1 &
|
||||||
|
SRV_PID=$!
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
echo "=== starting client B (idle observer) ==="
|
||||||
|
"$GODOT_BIN" --headless --path . --script res://scripts/mp_test_client.gd -- --role b --port "$PORT" >/tmp/mp2p_b.log 2>&1 &
|
||||||
|
B_PID=$!
|
||||||
|
# Wait until B is actually connected before starting A.
|
||||||
|
for _ in $(seq 1 30); do grep -q 'RESULT b connected' /tmp/mp2p_b.log && break; sleep 1; done
|
||||||
|
|
||||||
|
echo "=== running client A (mover) ==="
|
||||||
|
"$GODOT_BIN" --headless --path . --script res://scripts/mp_test_client.gd -- --role a --port "$PORT" 2>&1 | tee /tmp/mp2p_a.log
|
||||||
|
A_LINE=$(grep '^RESULT a' /tmp/mp2p_a.log || true)
|
||||||
|
|
||||||
|
wait "$B_PID" 2>/dev/null
|
||||||
|
B_LINE=$(grep '^RESULT b' /tmp/mp2p_b.log || true)
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "server: $(grep -c 'joined' /tmp/mp2p_server.log) joins seen"
|
||||||
|
echo "A: ${A_LINE:-<none>}"
|
||||||
|
echo "B: ${B_LINE:-<none>}"
|
||||||
|
|
||||||
|
if echo "$A_LINE" | grep -q 'moved=true' && echo "$A_LINE" | grep -qE 'remotes=[1-9]'; then
|
||||||
|
echo "=== SMOKE RESULT: PASS ==="
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "=== SMOKE RESULT: FAIL ==="
|
||||||
|
exit 1
|
||||||
+105
-117
@@ -1,121 +1,117 @@
|
|||||||
## ServerMain - Dedicated server root node.
|
## ServerMain - headless dedicated server for one world.
|
||||||
## Accepts ENet connections, spawns Player nodes for each peer,
|
##
|
||||||
## and runs the simulation tick loop at 20 Hz.
|
## Accepts ENet connections, spawns a server-authoritative Player per peer in a
|
||||||
|
## real loaded level, and broadcasts a full snapshot (every peer's state + the
|
||||||
|
## last input seq processed for that peer) every physics frame. The project's
|
||||||
|
## physics rate is pinned to 30 Hz so the sim is deterministic and matches the
|
||||||
|
## client's prediction step.
|
||||||
|
##
|
||||||
|
## Launched by the backend's game-server supervisor
|
||||||
|
## (backend/app/services/game_servers.py):
|
||||||
|
## godot --headless --path . res://scenes/server.tscn -- \
|
||||||
|
## --port 27000 --world 3 --status-file /path/to/status.json
|
||||||
|
## All args are optional; the defaults let `godot res://scenes/server.tscn`
|
||||||
|
## run standalone on 7777 for smoke_test_network_client.gd.
|
||||||
extends Node
|
extends Node
|
||||||
|
|
||||||
|
|
||||||
## Maximum number of concurrent players.
|
|
||||||
const MAX_PLAYERS := 16
|
const MAX_PLAYERS := 16
|
||||||
|
const DEFAULT_PORT := 7777
|
||||||
|
const PHYSICS_HZ := 30
|
||||||
|
## Rewrite the status file (player count, for the supervisor's idle reaper)
|
||||||
|
## every this many physics frames.
|
||||||
|
const STATUS_WRITE_EVERY := 30
|
||||||
|
|
||||||
## Server tick rate (Hz).
|
|
||||||
const TICK_RATE := 20
|
|
||||||
|
|
||||||
## Tick duration in seconds.
|
|
||||||
const TICK_DURATION := 1.0 / TICK_RATE
|
|
||||||
|
|
||||||
## ENet server port.
|
|
||||||
const SERVER_PORT := 7777
|
|
||||||
|
|
||||||
## Network peer used for ENet communication.
|
|
||||||
var network_peer: ENetMultiplayerPeer
|
var network_peer: ENetMultiplayerPeer
|
||||||
|
var port: int = DEFAULT_PORT
|
||||||
|
var world_id: String = ""
|
||||||
|
var status_file_path: String = ""
|
||||||
|
|
||||||
## Map of peer_id -> Player node.
|
## peer_id -> Player node
|
||||||
var players: Dictionary = {}
|
var players: Dictionary = {}
|
||||||
|
|
||||||
## Current tick counter (server-authoritative).
|
|
||||||
var tick: int = 0
|
var tick: int = 0
|
||||||
|
|
||||||
## Whether the server is running.
|
|
||||||
var is_running: bool = false
|
var is_running: bool = false
|
||||||
|
|
||||||
## Accumulated real time for fixed timestep simulation.
|
var _player_scene: PackedScene = preload("res://scenes/server_player.tscn")
|
||||||
var accumulator: float = 0.0
|
var _level_scene: PackedScene = preload("res://scenes/worlds/test_level.tscn")
|
||||||
|
var _spawn_point: Vector3 = Vector3(0, 1.5, 0)
|
||||||
|
|
||||||
## Player scene resource (loaded once).
|
|
||||||
var _player_scene: PackedScene
|
func _enter_tree() -> void:
|
||||||
|
multiplayer.peer_connected.connect(_on_peer_connected)
|
||||||
|
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
# Preload the player scene once.
|
_parse_args()
|
||||||
_player_scene = preload("res://scenes/server_player.tscn")
|
Engine.physics_ticks_per_second = PHYSICS_HZ
|
||||||
|
|
||||||
# Create and assign the ENet server peer.
|
# Load the world's level so players have real ground + collision.
|
||||||
network_peer = _create_server_peer()
|
var level := _level_scene.instantiate()
|
||||||
if network_peer:
|
level.name = "World"
|
||||||
multiplayer.multiplayer_peer = network_peer
|
add_child(level)
|
||||||
is_running = true
|
var marker := level.find_child("PlayerSpawn", true, false)
|
||||||
print("[Server] Started on port %d" % SERVER_PORT)
|
if marker is Node3D:
|
||||||
else:
|
_spawn_point = (marker as Node3D).global_position
|
||||||
printerr("[Server] Failed to create ENet peer.")
|
|
||||||
|
network_peer = ENetMultiplayerPeer.new()
|
||||||
|
var err := network_peer.create_server(port, MAX_PLAYERS)
|
||||||
|
if err != OK:
|
||||||
|
printerr("[Server] create_server(%d) failed: %d" % [port, err])
|
||||||
get_tree().quit(1)
|
get_tree().quit(1)
|
||||||
|
return
|
||||||
|
multiplayer.multiplayer_peer = network_peer
|
||||||
|
is_running = true
|
||||||
|
_write_status()
|
||||||
|
print("[Server] world=%s listening on ENet %d (%d Hz)" % [world_id, port, PHYSICS_HZ])
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
func _parse_args() -> void:
|
||||||
# Server creation
|
var args := OS.get_cmdline_user_args()
|
||||||
# ---------------------------------------------------------------------------
|
var i := 0
|
||||||
|
while i < args.size():
|
||||||
func _create_server_peer() -> ENetMultiplayerPeer:
|
match args[i]:
|
||||||
var peer := ENetMultiplayerPeer.new()
|
"--port":
|
||||||
var error := peer.create_server(SERVER_PORT, MAX_PLAYERS)
|
if i + 1 < args.size():
|
||||||
if error != OK:
|
port = int(args[i + 1])
|
||||||
printerr("[Server] create_server error: %d" % error)
|
i += 1
|
||||||
return null
|
"--world":
|
||||||
return peer
|
if i + 1 < args.size():
|
||||||
|
world_id = args[i + 1]
|
||||||
|
i += 1
|
||||||
|
"--status-file":
|
||||||
|
if i + 1 < args.size():
|
||||||
|
status_file_path = args[i + 1]
|
||||||
|
i += 1
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
func _physics_process(_delta: float) -> void:
|
||||||
# Fixed-timestep simulation loop
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func _process(delta: float) -> void:
|
|
||||||
if not is_running:
|
if not is_running:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Fixed timestep accumulator.
|
|
||||||
accumulator += delta
|
|
||||||
while accumulator >= TICK_DURATION:
|
|
||||||
accumulator -= TICK_DURATION
|
|
||||||
_tick()
|
|
||||||
|
|
||||||
|
|
||||||
func _tick() -> void:
|
|
||||||
"""Main server simulation tick (called at TICK_RATE)."""
|
|
||||||
tick += 1
|
tick += 1
|
||||||
|
|
||||||
# Process player inputs and update state.
|
|
||||||
for peer_id in players:
|
|
||||||
var player: Node3D = players[peer_id]
|
|
||||||
if player and player.has_method("process_server_input"):
|
|
||||||
player.process_server_input(tick)
|
|
||||||
|
|
||||||
# Broadcast snapshot to all clients.
|
|
||||||
_broadcast_snapshot()
|
_broadcast_snapshot()
|
||||||
|
if tick % STATUS_WRITE_EVERY == 0:
|
||||||
|
_write_status()
|
||||||
|
|
||||||
|
|
||||||
func _broadcast_snapshot() -> void:
|
func _broadcast_snapshot() -> void:
|
||||||
"""Broadcast the current authoritative state to all connected clients."""
|
var snap := {"tick": tick, "players": {}}
|
||||||
var snapshot_data := {
|
|
||||||
"tick": tick,
|
|
||||||
"players": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
for peer_id in players:
|
for peer_id in players:
|
||||||
var player: Node3D = players[peer_id]
|
var player: Node = players[peer_id]
|
||||||
if player and player.has_method("get_server_state"):
|
if is_instance_valid(player):
|
||||||
snapshot_data.players[peer_id] = player.get_server_state()
|
var s: Dictionary = player.get_server_state()
|
||||||
|
s["last_seq"] = player.last_applied_seq
|
||||||
# Send snapshot to all peers except the server itself (peer_id == 1).
|
snap["players"][peer_id] = s
|
||||||
var peer_ids: Array = multiplayer.get_peers()
|
for pid in multiplayer.get_peers():
|
||||||
for pid in peer_ids:
|
if pid == 1:
|
||||||
if pid == 1: # 1 is the server itself
|
|
||||||
continue
|
continue
|
||||||
rpc_id(pid, "_receive_snapshot", snapshot_data)
|
rpc_id(pid, "_receive_snapshot", snap)
|
||||||
|
|
||||||
|
|
||||||
# Remote: clients receive snapshots.
|
# Client-side receiver lives on the NetworkClient's proxy at the same path.
|
||||||
@rpc("any_peer", "call_remote", "reliable")
|
@rpc("any_peer", "call_remote", "reliable")
|
||||||
func _receive_snapshot(snapshot_data: Dictionary) -> void:
|
func _receive_snapshot(_snapshot_data: Dictionary) -> void:
|
||||||
pass # Handled by client scene.
|
pass
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -123,52 +119,44 @@ func _receive_snapshot(snapshot_data: Dictionary) -> void:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func _on_peer_connected(peer_id: int) -> void:
|
func _on_peer_connected(peer_id: int) -> void:
|
||||||
print("[Server] Peer %d connected." % peer_id)
|
|
||||||
|
|
||||||
if players.size() >= MAX_PLAYERS:
|
if players.size() >= MAX_PLAYERS:
|
||||||
print("[Server] Max players reached. Disconnecting peer %d." % peer_id)
|
print("[Server] full; rejecting peer %d" % peer_id)
|
||||||
rpc_id(peer_id, "_kick", "Server is full.")
|
multiplayer.multiplayer_peer.disconnect_peer(peer_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Spawn a Player node for this peer.
|
|
||||||
var player: Node3D = _player_scene.instantiate()
|
var player: Node3D = _player_scene.instantiate()
|
||||||
player.name = "Player_%d" % peer_id
|
player.name = "Player_%d" % peer_id
|
||||||
player.peer_id = peer_id
|
player.peer_id = peer_id
|
||||||
add_child(player)
|
add_child(player)
|
||||||
|
player.global_position = _spawn_point
|
||||||
players[peer_id] = player
|
players[peer_id] = player
|
||||||
|
print("[Server] peer %d joined (%d players)" % [peer_id, players.size()])
|
||||||
print("[Server] Spawned player node for peer %d." % peer_id)
|
_write_status()
|
||||||
|
|
||||||
|
|
||||||
func _on_peer_disconnected(peer_id: int) -> void:
|
func _on_peer_disconnected(peer_id: int) -> void:
|
||||||
print("[Server] Peer %d disconnected." % peer_id)
|
|
||||||
if players.has(peer_id):
|
if players.has(peer_id):
|
||||||
var player: Node3D = players[peer_id]
|
var player: Node = players[peer_id]
|
||||||
if player.is_inside_tree():
|
if is_instance_valid(player):
|
||||||
player.queue_free()
|
player.queue_free()
|
||||||
players.erase(peer_id)
|
players.erase(peer_id)
|
||||||
|
print("[Server] peer %d left (%d players)" % [peer_id, players.size()])
|
||||||
|
_write_status()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# RPCs
|
# Status file for the supervisor's idle reaper
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@rpc("any_peer", "call_remote", "reliable")
|
func _write_status() -> void:
|
||||||
func _kick(reason: String) -> void:
|
if status_file_path.is_empty():
|
||||||
print("[Server] Kicking caller: %s" % reason)
|
return
|
||||||
rpc_id(multiplayer.get_remote_sender_id(), "_disconnect", reason)
|
var f := FileAccess.open(status_file_path, FileAccess.WRITE)
|
||||||
|
if f:
|
||||||
|
f.store_string(JSON.stringify({
|
||||||
@rpc("call_remote", "reliable")
|
"world_id": world_id,
|
||||||
func _disconnect(reason: String) -> void:
|
"port": port,
|
||||||
print("[Server] Disconnecting: %s" % reason)
|
"players": players.size(),
|
||||||
get_tree().quit(0)
|
"tick": tick,
|
||||||
|
"updated": Time.get_unix_time_from_system(),
|
||||||
|
}))
|
||||||
# ---------------------------------------------------------------------------
|
f.close()
|
||||||
# Signal connections
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func _enter_tree() -> void:
|
|
||||||
multiplayer.peer_connected.connect(_on_peer_connected)
|
|
||||||
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
|
|
||||||
|
|||||||
Reference in New Issue
Block a user