## PlayerInterpolator — Headless-safe visual interpolation for rolled-back players. ## ## Mirrors netfox's TickInterpolator pattern but with full headless/export guards: ## - Checks for NetworkTime singleton before connecting ## - Gracefully no-ops when singletons are absent (dedicated server without netfox) ## - Falls back to netfox TickInterpolator's Interpolators for type-aware lerp ## ## Architecture: ## _before_tick_loop → snapshot current → mark teleport end ## _after_tick_loop → push_state() → shift (from ← to, to ← current) ## _process(delta) → interpolate(from, to, tick_factor) ## ## Properties interpolated: ## position (Vector3), rotation (Vector3 — Euler angles) extends Node # --------------------------------------------------------------------------- # Exports # --------------------------------------------------------------------------- ## If true, interpolation is active. Set false to disable (no-ops). @export var enabled: bool = true ## If true, teleport on first state (instant snap, no fade-in). @export var snap_initial: bool = true ## Root node whose properties are interpolated. Defaults to parent. @export var root: Node3D = null ## Interpolation alpha smoothing (0.0=linear raw, >0=exponential smoothing). ## Higher values make the interpolation lag behind more smoothly. @export_range(0.0, 1.0, 0.05) var smoothing: float = 0.5 # --------------------------------------------------------------------------- # Internal state # --------------------------------------------------------------------------- ## Previous state snapshot (source of interpolation). var _from_pos: Vector3 = Vector3.ZERO var _from_rot: Vector3 = Vector3.ZERO ## Target state snapshot (destination of interpolation). var _to_pos: Vector3 = Vector3.ZERO var _to_rot: Vector3 = Vector3.ZERO ## Whether we have at least two snapshots (can interpolate). var _has_state: bool = false ## Whether the last tick was a teleport (snap, don't interpolate). var _is_teleporting: bool = false ## Whether netfox singletons were found at startup. var _netfox_available: bool = false ## Stored NetworkTime singleton reference (avoids bare identifier references). var _nt: Node = null # --------------------------------------------------------------------------- # Lifecycle # --------------------------------------------------------------------------- func _ready() -> void: if Engine.is_editor_hint(): return # Resolve root node if root == null: root = get_parent() as Node3D if root == null: push_warning("[PlayerInterpolator] No root node found — interpolation disabled") enabled = false return # Check if netfox NetworkTime is available _netfox_available = Engine.has_singleton(&"NetworkTime") if _netfox_available: _nt = Engine.get_singleton(&"NetworkTime") _nt.before_tick_loop.connect(_on_before_tick_loop) _nt.after_tick_loop.connect(_on_after_tick_loop) print("[PlayerInterpolator] Connected to netfox NetworkTime — interpolation active") else: print("[PlayerInterpolator] netfox NetworkTime not available — interpolation disabled in headless/server mode") enabled = false return # Teleport to initial state if snap_initial: await get_tree().process_frame _teleport() func _exit_tree() -> void: if Engine.is_editor_hint(): return if _nt != null: if _nt.before_tick_loop.is_connected(_on_before_tick_loop): _nt.before_tick_loop.disconnect(_on_before_tick_loop) if _nt.after_tick_loop.is_connected(_on_after_tick_loop): _nt.after_tick_loop.disconnect(_on_after_tick_loop) # --------------------------------------------------------------------------- # Process — interpolate between snapshots each frame # --------------------------------------------------------------------------- func _process(_delta: float) -> void: if not enabled or not _has_state or _is_teleporting or root == null: return # Get interpolation factor from NetworkTime (0.0 = just after tick, 1.0 = just before next tick) var factor: float = 0.0 if _nt != null: factor = _nt.tick_factor # Apply exponential smoothing to the interpolation alpha if smoothing > 0.0: factor = lerpf(0.0, factor, 1.0 - smoothing) # Interpolate position root.position = _from_pos.lerp(_to_pos, factor) # Interpolate rotation (Euler — simple lerp per axis) var rot_x := lerpf(_from_rot.x, _to_rot.x, factor) var rot_y := lerpf(_from_rot.y, _to_rot.y, factor) var rot_z := lerpf(_from_rot.z, _to_rot.z, factor) root.rotation = Vector3(rot_x, rot_y, rot_z) # --------------------------------------------------------------------------- # Netfox tick loop hooks # --------------------------------------------------------------------------- ## Called before the tick loop starts. Records the current state as the ## incoming snapshot, preparing for push_state() after the tick. func _on_before_tick_loop() -> void: _is_teleporting = false ## Called after the tick loop. Rotates the state snapshots: old target ## becomes new source, current root state becomes new target. func _on_after_tick_loop() -> void: if not enabled or root == null: return # Shift state snapshots: from ← to, to ← current _from_pos = _to_pos _from_rot = _to_rot _to_pos = root.position _to_rot = root.rotation _has_state = true # --------------------------------------------------------------------------- # Teleport — snap to current state without interpolation # --------------------------------------------------------------------------- ## Teleport: set both source and target to current state, disable ## interpolation for one cycle. func _teleport() -> void: if root == null: return _is_teleporting = true var pos := root.position var rot := root.rotation _from_pos = pos _from_rot = rot _to_pos = pos _to_rot = rot _has_state = true ## Force a teleport from external code (e.g., after spawn). func force_teleport() -> void: _teleport() ## Reset state (for pooling / re-use). func reset() -> void: _has_state = false _is_teleporting = false _from_pos = Vector3.ZERO _from_rot = Vector3.ZERO _to_pos = Vector3.ZERO _to_rot = Vector3.ZERO