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_SPEC.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