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:
+50
-207
@@ -1,232 +1,75 @@
|
||||
class_name PlayerNode
|
||||
extends CharacterBody3D
|
||||
## PlayerNode - Server-side player entity.
|
||||
## Server-authoritative player entity.
|
||||
##
|
||||
## Represents a connected player on the server. Handles movement,
|
||||
## input processing, and state synchronization.
|
||||
## One per connected peer, spawned as a child of the Server node. Movement runs
|
||||
## 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 ---
|
||||
signal health_changed(new_health: int)
|
||||
signal player_died(player_node: PlayerNode)
|
||||
|
||||
|
||||
# --- Constants ---
|
||||
## Movement constants -- mirror scripts/server.gd (the source of truth) and
|
||||
## scripts/network_client.gd (the client's prediction).
|
||||
const WALK_SPEED := 5.0
|
||||
const SPRINT_MULTIPLIER := 1.6
|
||||
const JUMP_VELOCITY := 4.5
|
||||
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 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 _last_processed_seq: int = -1
|
||||
|
||||
# --- Movement ---
|
||||
var _velocity: Vector3 = Vector3.ZERO
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
current_health = max_health
|
||||
velocity = _velocity
|
||||
# Start alive
|
||||
is_alive = true
|
||||
var _look_yaw: float = 0.0
|
||||
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
if not is_alive:
|
||||
return
|
||||
|
||||
# Process input from queue
|
||||
_process_input(delta)
|
||||
|
||||
# Apply gravity
|
||||
if _input_queue.is_empty():
|
||||
_integrate(0.0, 0.0, false, delta)
|
||||
else:
|
||||
# Reliable ENet preserves order; sort defensively anyway.
|
||||
_input_queue.sort_custom(func(a, b): return a["seq"] < b["seq"])
|
||||
for input in _input_queue:
|
||||
_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():
|
||||
velocity.y -= GRAVITY * delta
|
||||
velocity.y = clampf(velocity.y, -max_fall_speed, max_fall_speed)
|
||||
|
||||
# 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()
|
||||
elif jump:
|
||||
velocity.y = JUMP_VELOCITY
|
||||
|
||||
|
||||
func _process_input(delta: float) -> void:
|
||||
"""Process buffered input from the server's input queue."""
|
||||
if _input_queue.is_empty():
|
||||
return
|
||||
|
||||
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
|
||||
var dir := Vector3(move_x, 0.0, move_z)
|
||||
if dir.length() > 0.01:
|
||||
dir = dir.normalized()
|
||||
velocity.x = dir.x * WALK_SPEED
|
||||
velocity.z = dir.z * WALK_SPEED
|
||||
else:
|
||||
# On ground, apply friction (handled in physics_process)
|
||||
if not is_on_floor():
|
||||
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
|
||||
velocity.x = 0.0
|
||||
velocity.z = 0.0
|
||||
|
||||
|
||||
func take_damage(amount: int, source_id: int) -> Dictionary:
|
||||
"""Take damage and return result dict."""
|
||||
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,
|
||||
}
|
||||
move_and_slide()
|
||||
if is_on_floor() and velocity.y < 0.0:
|
||||
velocity.y = 0.0
|
||||
|
||||
|
||||
func get_server_state() -> Dictionary:
|
||||
"""Get current player state for server snapshot broadcast."""
|
||||
return get_state()
|
||||
|
||||
|
||||
func process_server_input(tick: int) -> void:
|
||||
"""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(),
|
||||
})
|
||||
return {
|
||||
"position": global_position,
|
||||
"rotation_y": rotation.y,
|
||||
"velocity": velocity,
|
||||
"on_floor": is_on_floor(),
|
||||
}
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable")
|
||||
func rpc_request_interact(target_id: int) -> void:
|
||||
"""Handle interaction request from client."""
|
||||
if not is_alive:
|
||||
return
|
||||
# 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
|
||||
func rpc_request_input(input: Dictionary, seq: int) -> void:
|
||||
var d := input.duplicate()
|
||||
d["seq"] = seq
|
||||
_input_queue.append(d)
|
||||
|
||||
Reference in New Issue
Block a user