mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
4fb7ddfecf
multiplayer-todo.md and multiplayer-next.md tracked overlapping information in two places. Fold everything into multiplayer-next.md (architecture decisions, wire format, task breakdown with checkboxes, gotchas list, testing notes) and delete multiplayer-todo.md. Section numbers are unchanged, so existing code comments citing them by section/task number still resolve; update every such reference to point at the new filename.
127 lines
5.1 KiB
GDScript
127 lines
5.1 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-next.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:
|
|
# Never let a stale unreliable snapshot rewrite the epoch. The previous
|
|
# ordering cleared samples on its reset byte before checking tick order,
|
|
# so a delayed pre-reset packet could alternately flip generations and
|
|
# repeatedly cancel an active local ball handoff.
|
|
if not _samples.is_empty() and server_tick <= _samples.back()["tick"]:
|
|
return false
|
|
var is_reset := reset_gen != -1 and sample_reset_gen != reset_gen
|
|
if is_reset:
|
|
_samples.clear()
|
|
reset_gen = sample_reset_gen
|
|
_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 accepts_tick(server_tick: int) -> bool:
|
|
return _samples.is_empty() or server_tick > int(_samples.back()["tick"])
|
|
|
|
|
|
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)
|
|
var angular_speed := state.angular_velocity.length()
|
|
if angular_speed > 0.00001:
|
|
out.rotation = (Quaternion(state.angular_velocity / angular_speed, angular_speed * (clamped_ms / 1000.0)) * state.rotation).normalized()
|
|
else:
|
|
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
|