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.
115 lines
4.5 KiB
GDScript
115 lines
4.5 KiB
GDScript
class_name NetInterpolator
|
|
extends RefCounted
|
|
|
|
# Buffers recent snapshot samples for ONE remote body and produces
|
|
# interpolated states at any requested (possibly fractional) server tick —
|
|
# used twice per body (multiplayer-todo.md §4.1/§4.6, "dual-time remote
|
|
# entities"): once at the present-time estimate for the collider, once
|
|
# further back at present-minus-INTERP_DELAY for $Visual.
|
|
#
|
|
# server_tick (Engine.get_physics_frames() at send time) maps to an
|
|
# estimated server wall-clock time via TICK_HZ without any extra
|
|
# synchronization: both Engine.get_physics_frames() and Time.get_ticks_msec()
|
|
# count from the same process-start epoch, and physics has been running at
|
|
# a steady TICK_HZ the whole time, so tick_ms_of(tick) = tick * (1000/TICK_HZ)
|
|
# is a valid estimate of "what Time.get_ticks_msec() read on the server when
|
|
# it sent that tick." Callers convert a NetworkManager.get_server_time_estimate_ms()
|
|
# reading into the same tick-space with to_tick(ms) before calling sample_at().
|
|
|
|
const NetBodyState = preload("res://scripts/net_body_state.gd")
|
|
const SimConstants = preload("res://scripts/sim_constants.gd")
|
|
|
|
const MAX_SAMPLES := 16
|
|
# §4.6: "never extrapolate indefinitely — a stuck ship reads better than one
|
|
# flying through a wall."
|
|
const MAX_EXTRAPOLATION_MS := 150.0
|
|
const TICK_MS := 1000.0 / SimConstants.TICK_HZ
|
|
|
|
var _samples: Array[Dictionary] = [] # [{tick:int, state:NetBodyState}], oldest first
|
|
var reset_gen := -1 # -1: no sample yet, so the first real sample is never treated as a mid-flight reset
|
|
|
|
|
|
static func to_tick(server_time_ms: float) -> float:
|
|
return server_time_ms / TICK_MS
|
|
|
|
|
|
# Returns true if this sample's reset_gen differs from the last one seen —
|
|
# the caller's cue to hard-snap instead of interpolating across a
|
|
# server-authoritative teleport (kickoff, goal reset) rather than sliding
|
|
# across the arena. Clears buffered history on a reset so a stale
|
|
# pre-reset sample can never bracket a post-reset one.
|
|
func add_sample(server_tick: int, state: NetBodyState, sample_reset_gen: int) -> bool:
|
|
var is_reset := reset_gen != -1 and sample_reset_gen != reset_gen
|
|
if is_reset:
|
|
_samples.clear()
|
|
reset_gen = sample_reset_gen
|
|
if not _samples.is_empty() and server_tick <= _samples.back()["tick"]:
|
|
return is_reset # stale/duplicate (unreliable_ordered should already prevent this, but don't trust it blindly)
|
|
_samples.append({"tick": server_tick, "state": state})
|
|
if _samples.size() > MAX_SAMPLES:
|
|
_samples.pop_front()
|
|
return is_reset
|
|
|
|
|
|
func has_samples() -> bool:
|
|
return not _samples.is_empty()
|
|
|
|
|
|
func latest() -> NetBodyState:
|
|
return _samples.back()["state"] if not _samples.is_empty() else null
|
|
|
|
|
|
# target_tick may be fractional (a point in time between two integer ticks).
|
|
func sample_at(target_tick: float) -> NetBodyState:
|
|
if _samples.is_empty():
|
|
return null
|
|
if _samples.size() == 1:
|
|
return _samples[0]["state"]
|
|
if target_tick <= _samples[0]["tick"]:
|
|
return _samples[0]["state"]
|
|
var newest: Dictionary = _samples.back()
|
|
if target_tick >= newest["tick"]:
|
|
return _extrapolate(newest, target_tick)
|
|
for i in range(_samples.size() - 1):
|
|
var a: Dictionary = _samples[i]
|
|
var b: Dictionary = _samples[i + 1]
|
|
if a["tick"] <= target_tick and target_tick <= b["tick"]:
|
|
var a_tick: float = a["tick"]
|
|
var b_tick: float = b["tick"]
|
|
var span := b_tick - a_tick
|
|
var t: float = (target_tick - a_tick) / span if span > 0.0 else 0.0
|
|
return _lerp_state(a["state"], b["state"], t)
|
|
return newest["state"]
|
|
|
|
|
|
func _lerp_state(a: NetBodyState, b: NetBodyState, t: float) -> NetBodyState:
|
|
var out := NetBodyState.new()
|
|
out.position = a.position.lerp(b.position, t)
|
|
out.rotation = a.rotation.slerp(b.rotation, t)
|
|
out.linear_velocity = a.linear_velocity.lerp(b.linear_velocity, t)
|
|
out.angular_velocity = a.angular_velocity.lerp(b.angular_velocity, t)
|
|
out.frozen = b.frozen
|
|
out.turbo = b.turbo
|
|
out.thrust_z = b.thrust_z
|
|
out.stalled = b.stalled
|
|
out.avel_range = b.avel_range
|
|
return out
|
|
|
|
|
|
func _extrapolate(newest: Dictionary, target_tick: float) -> NetBodyState:
|
|
var state: NetBodyState = newest["state"]
|
|
var ticks_ahead: float = target_tick - float(newest["tick"])
|
|
var ms_ahead := ticks_ahead * TICK_MS
|
|
var clamped_ms := clampf(ms_ahead, 0.0, MAX_EXTRAPOLATION_MS)
|
|
var out := NetBodyState.new()
|
|
out.position = state.position + state.linear_velocity * (clamped_ms / 1000.0)
|
|
out.rotation = state.rotation
|
|
out.linear_velocity = state.linear_velocity
|
|
out.angular_velocity = state.angular_velocity
|
|
out.frozen = state.frozen
|
|
out.turbo = state.turbo
|
|
out.thrust_z = state.thrust_z
|
|
out.stalled = state.stalled
|
|
out.avel_range = state.avel_range
|
|
return out
|