e7299b17e9
Stack installed: - netfox v1.35.3 (core + extras + noray + internals) - godot-jolt v0.16.0-stable Architecture: - Server: ENet transport (works headless, no netfox deps) - Client/Editor: netfox rollback (RollbackSynchronizer, TickInterpolator) New/modified: - docs/migration-netfox-plan.md — migration architecture - scripts/network/network_manager.gd — netfox-aware ENet fallback - scripts/network/player.gd — clean base player - client/characters/player_netfox.gd — rollback player w/ WeaponManager - client/characters/input/player_net_input.gd — BaseNetInput subclass - client/characters/character/fps_character_controller.gd — netfox input feed - client/weapons/ — weapon data, registry, TacticalWeaponHitscan, WeaponManager - client/scripts/round_replicator.gd — client-side round state bridge - server/scripts/round_manager.gd — improved state machine - server/scripts/plugin_api/plugin_manager.gd — refined plugin system - config: enemy_tag, ally_tag for meatball targeting Removed: old C++ SimulationServer GDExtension (replaced by netfox rollback)
277 lines
8.5 KiB
GDScript
277 lines
8.5 KiB
GDScript
extends Node
|
|
class_name _NetworkTimeSynchronizer
|
|
|
|
## Continuously synchronizes time to the host's remote clock.
|
|
##
|
|
## Make sure to read the [i]NetworkTimeSynchronizer Guide[/i] to understand the
|
|
## different clocks that the class docs refer to.
|
|
##
|
|
## @tutorial(NetworkTimeSynchronizer Guide): https://foxssake.github.io/netfox/netfox/guides/network-time-synchronizer/
|
|
|
|
## The minimum time in seconds between two sync samples.
|
|
##
|
|
## See [member sync_interval]
|
|
const MIN_SYNC_INTERVAL := 0.1
|
|
|
|
## Time between sync samples, in seconds.
|
|
## Cannot be less than [member MIN_SYNC_INTERVAL]
|
|
## [br][br]
|
|
## [i]read-only[/i], you can change this in the Netfox project settings
|
|
var sync_interval: float:
|
|
get:
|
|
return maxf(
|
|
_sync_interval,
|
|
MIN_SYNC_INTERVAL
|
|
)
|
|
set(v):
|
|
push_error("Trying to set read-only variable sync_interval")
|
|
|
|
## Number of measurements ( samples ) to use for time synchronization.
|
|
## [br][br]
|
|
## [i]read-only[/i], you can change this in the Netfox project settings
|
|
var sync_samples: int:
|
|
get:
|
|
return _sync_samples
|
|
set(v):
|
|
push_error("Trying to set read-only variable sync_samples")
|
|
|
|
## Number of iterations to nudge towards the host's remote clock.
|
|
##
|
|
## Lower values result in more aggressive changes in clock and may be more
|
|
## sensitive to jitter. Larger values may end up approaching the remote clock
|
|
## too slowly.
|
|
## [br][br]
|
|
## [i]read-only[/i], you can change this in the Netfox project settings
|
|
var adjust_steps: int:
|
|
get:
|
|
return _adjust_steps
|
|
set(v):
|
|
push_error("Trying to set read-only variable adjust_steps")
|
|
|
|
## Largest tolerated offset from the host's remote clock before panicking.
|
|
##
|
|
## Once this threshold is reached, the clock will be reset to the remote clock's
|
|
## value, and the nudge process will start from scratch.
|
|
## [br][br]
|
|
## [i]read-only[/i], you can change this in the Netfox project settings
|
|
var panic_threshold: float:
|
|
get:
|
|
return _panic_threshold
|
|
set(v):
|
|
push_error("Trying to set read-only variable panic_threshold")
|
|
|
|
## Measured roundtrip time measured to the host.
|
|
##
|
|
## This value is calculated from multiple samples. The actual roundtrip times
|
|
## can be anywhere in the [member rtt] +/- [member rtt_jitter] range.
|
|
## [br][br]
|
|
## [i]read-only[/i]
|
|
var rtt: float:
|
|
get:
|
|
return _rtt
|
|
set(v):
|
|
push_error("Trying to set read-only variable rtt")
|
|
|
|
## Measured jitter in the roundtrip time to the host remote.
|
|
##
|
|
## This value is calculated from multiple samples. The actual roundtrip times
|
|
## can be anywhere in the [member rtt] +/- [member rtt_jitter] range.
|
|
## [br][br]
|
|
## [i]read-only[/i]
|
|
var rtt_jitter: float:
|
|
get:
|
|
return _rtt_jitter
|
|
set(v):
|
|
push_error("Trying to set read-only variable rtt_jitter")
|
|
|
|
## Estimated offset from the host's remote clock.
|
|
##
|
|
## Positive values mean that the host's remote clock is ahead of ours, while
|
|
## negative values mean that our clock is behind the host's remote.
|
|
## [br][br]
|
|
## [i]read-only[/i]
|
|
var remote_offset: float:
|
|
get:
|
|
return _offset
|
|
set(v):
|
|
push_error("Trying to set read-only variable remote_offset")
|
|
|
|
# Settings
|
|
var _sync_interval: float = ProjectSettings.get_setting(&"netfox/time/sync_interval", 0.25)
|
|
var _sync_samples: int = ProjectSettings.get_setting(&"netfox/time/sync_samples", 8)
|
|
var _adjust_steps: int =ProjectSettings.get_setting(&"netfox/time/sync_adjust_steps", 8)
|
|
var _panic_threshold: float = ProjectSettings.get_setting(&"netfox/time/recalibrate_threshold", 2.)
|
|
|
|
var _active: bool = false
|
|
static var _logger: NetfoxLogger = NetfoxLogger._for_netfox("NetworkTimeSynchronizer")
|
|
|
|
# Samples are stored in a ring buffer
|
|
var _sample_buffer: _RingBuffer
|
|
var _sample_idx: int = 0
|
|
var _awaiting_samples: Dictionary = {}
|
|
|
|
var _clock: NetworkClocks.SystemClock = NetworkClocks.SystemClock.new()
|
|
var _offset: float = 0.
|
|
var _rtt: float = 0.
|
|
var _rtt_jitter: float = 0.
|
|
|
|
## Emitted after the initial time sync.
|
|
##
|
|
## At the start of the game, clients request an initial timestamp to kickstart
|
|
## their time sync loop. This event is emitted once that initial timestamp is
|
|
## received.
|
|
signal on_initial_sync()
|
|
|
|
## Emitted when clocks get overly out of sync and a time sync panic occurs.
|
|
##
|
|
## Panic means that the difference between clocks is too large. The time sync
|
|
## will reset the clock to the remote clock's time and restart the time sync loop
|
|
## from there.
|
|
## [br][br]
|
|
## Use this event in case you need to react to clock changes in your game.
|
|
signal on_panic(offset: float)
|
|
|
|
## Start the time synchronization loop.
|
|
##
|
|
## Starting multiple times has no effect.
|
|
func start() -> void:
|
|
if _active:
|
|
return
|
|
|
|
_clock.set_time(0.)
|
|
|
|
if not multiplayer.is_server():
|
|
_active = true
|
|
_sample_idx = 0
|
|
_sample_buffer = _RingBuffer.new(sync_samples)
|
|
|
|
_request_timestamp.rpc_id(1)
|
|
|
|
## Stop the time synchronization loop.
|
|
func stop() -> void:
|
|
_active = false
|
|
|
|
## Get the current time from the reference clock.
|
|
##
|
|
## Returns a timestamp in seconds, with a fractional part for extra precision.
|
|
func get_time() -> float:
|
|
return _clock.get_time()
|
|
|
|
func _loop() -> void:
|
|
_logger.info("Time sync loop started! Initial timestamp: %ss", [_clock.get_time()])
|
|
on_initial_sync.emit()
|
|
|
|
while _active:
|
|
if multiplayer.is_server():
|
|
return stop()
|
|
|
|
var sample := NetworkClockSample.new()
|
|
_awaiting_samples[_sample_idx] = sample
|
|
|
|
sample.ping_sent = _clock.get_time()
|
|
_send_ping.rpc_id(1, _sample_idx)
|
|
|
|
_sample_idx += 1
|
|
|
|
await get_tree().create_timer(sync_interval).timeout
|
|
|
|
func _discipline_clock() -> void:
|
|
var sorted_samples := _sample_buffer.get_data()
|
|
if sorted_samples.is_empty():
|
|
# Should never happen
|
|
_logger.warning("Trying to discipline the clock with no samples available!")
|
|
return
|
|
|
|
# Sort samples by latency
|
|
sorted_samples.sort_custom(
|
|
func(a: NetworkClockSample, b: NetworkClockSample):
|
|
return a.get_rtt() < b.get_rtt()
|
|
)
|
|
|
|
_logger.trace("Using sorted samples: \n%s", [
|
|
"\n".join(sorted_samples.map(func(it: NetworkClockSample): return "\t" + it.to_string() + " (%.4fs)" % [get_time() - it.ping_sent]))
|
|
])
|
|
|
|
# Calculate rtt bounds
|
|
var rtt_min := sorted_samples.front().get_rtt() as float
|
|
var rtt_max := sorted_samples.back().get_rtt() as float
|
|
_rtt = (rtt_max + rtt_min) / 2.
|
|
_rtt_jitter = (rtt_max - rtt_min) / 2.
|
|
|
|
# Calculate offset
|
|
var offset := 0.
|
|
var offsets := sorted_samples.map(func(it): return it.get_offset())
|
|
var offset_weight := 0.
|
|
for i in range(offsets.size()):
|
|
var w = log(1 + sorted_samples[i].get_rtt())
|
|
offset += offsets[i] * w
|
|
offset_weight += w
|
|
|
|
if not is_zero_approx(offset_weight):
|
|
offset /= offset_weight
|
|
else:
|
|
# RTT is so good it's basically zero, which means offset_weight is zero
|
|
# Use a simple average instead
|
|
offset /= sorted_samples.size()
|
|
|
|
# Panic / Adjust
|
|
if abs(offset) > panic_threshold:
|
|
# Reset clock, throw away all samples
|
|
_clock.adjust(offset)
|
|
_sample_buffer.clear()
|
|
|
|
# Also drop in-flight samples
|
|
_awaiting_samples.clear()
|
|
|
|
_offset = 0.
|
|
|
|
_logger.warning("Offset %ss is above panic threshold %ss! Resetting clock", [offset, panic_threshold])
|
|
on_panic.emit(offset)
|
|
else:
|
|
# Nudge clock towards estimated time
|
|
var nudge := offset / adjust_steps
|
|
_clock.adjust(nudge)
|
|
_logger.trace("Adjusted clock by %.2fms, offset: %.2fms, new time: %.4fss", [nudge * 1000., offset * 1000., _clock.get_time()])
|
|
|
|
_offset = offset - nudge
|
|
|
|
@rpc("any_peer", "call_remote", "unreliable")
|
|
func _send_ping(idx: int) -> void:
|
|
var ping_received := _clock.get_time()
|
|
var sender := multiplayer.get_remote_sender_id()
|
|
|
|
_send_pong.rpc_id(sender, idx, ping_received, _clock.get_time())
|
|
|
|
@rpc("any_peer", "call_remote", "unreliable")
|
|
func _send_pong(idx: int, ping_received: float, pong_sent: float) -> void:
|
|
var pong_received := _clock.get_time()
|
|
|
|
if not _awaiting_samples.has(idx):
|
|
# Sample was dropped mid-flight during a panic episode
|
|
return
|
|
|
|
var sample := _awaiting_samples[idx] as NetworkClockSample
|
|
sample.ping_received = ping_received
|
|
sample.pong_sent = pong_sent
|
|
sample.pong_received = pong_received
|
|
|
|
_logger.trace("Received sample: %s", [sample])
|
|
|
|
# Once a sample is done, remove from in-flight samples and move to sample buffer
|
|
_awaiting_samples.erase(idx)
|
|
_sample_buffer.push(sample)
|
|
|
|
# Discipline clock based on new sample
|
|
_discipline_clock()
|
|
|
|
@rpc("any_peer", "call_remote", "reliable")
|
|
func _request_timestamp() -> void:
|
|
_logger.debug("Requested initial timestamp @ %.4fs raw time", [_clock.get_raw_time()])
|
|
_set_timestamp.rpc_id(multiplayer.get_remote_sender_id(), _clock.get_time())
|
|
|
|
@rpc("any_peer", "call_remote", "reliable")
|
|
func _set_timestamp(timestamp: float) -> void:
|
|
_logger.debug("Received initial timestamp @ %.4fs raw time", [_clock.get_raw_time()])
|
|
_clock.set_time(timestamp)
|
|
_loop()
|