Files
tactical-shooter/addons/netfox/network-time.gd
T
shawn e7299b17e9 Phase 7: netfox + godot-jolt stack upgrade
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)
2026-07-02 17:39:22 -04:00

602 lines
20 KiB
GDScript

extends Node
class_name _NetworkTime
## This class handles timing.
##
## @tutorial(NetworkTime Guide): https://foxssake.github.io/netfox/latest/netfox/guides/network-time/
## Number of ticks per second.
##
## [i]read-only[/i], you can change this in the project settings
var tickrate: int:
get:
if sync_to_physics:
return Engine.physics_ticks_per_second
else:
return _tickrate
set(v):
push_error("Trying to set read-only variable tickrate")
## Whether to sync the network ticks to physics updates.
##
## When set to true, tickrate will be the same as the physics ticks per second,
## and the network tick loop will be run inside the physics update process.
##
## [i]read-only[/i], you can change this in the project settings
var sync_to_physics: bool:
get:
return _sync_to_physics
set(v):
push_error("Trying to set read-only variable sync_to_physics")
## Maximum number of ticks to simulate per frame.
##
## If the game itself runs slower than the configured tickrate, multiple ticks
## will be run in a single go. However, to avoid an endless feedback loop of
## running too many ticks in a frame, which makes the game even slower, which
## results in even more ticks and so on, this setting is an upper limit on how
## many ticks can be simulated in a single go.
##
## [i]read-only[/i], you can change this in the project settings
var max_ticks_per_frame: int:
get:
return _max_ticks_per_frame
set(v):
push_error("Trying to set read-only variable max_ticks_per_frame")
## Current network time in seconds.
##
## Time is measured from the start of NetworkTime, in practice this is often the
## time from the server's start.
##
## Use this value in cases where timestamps need to be shared with the server.
##
## [i]Note:[/i] Time is continuously synced with the server. If the difference
## between local and server time is above a certain threshold, this value will
## be adjusted.
##
## See [NetworkTimeSynchronizer].
## See the setting [code]"netfox/time/recalibrate_threshold"[/code].
##
## [i]read-only[/i]
var time: float:
get:
return float(_tick) / tickrate
set(v):
push_error("Trying to set read-only variable time")
## Current network time in ticks.
##
## Time is measured from the start of NetworkTime, in practice this is often the
## time from the server's start.
##
## Use this value in cases where timestamps need to be shared with the server.
##
## [i]Note:[/i] Time is continuously synced with the server. If the difference
## between local and server time is above a certain threshold, this value will
## be adjusted.
##
## See [NetworkTimeSynchronizer].
## See the setting [code]"netfox/time/recalibrate_threshold"[/code].
##
## [i]read-only[/i]
var tick: int:
get:
return _tick
set(v):
push_error("Trying to set read-only variable tick")
## Threshold before recalibrating [member tick] and [member time].
##
## Time is continuously synced to the server. In case the time difference is
## excessive between local and the server, both [code]tick[/code] and
## [code]time[/code] will be reset to the estimated server values.
## [br][br]
## This property determines the difference threshold in seconds for
## recalibration.
## [br][br]
## [i]read-only[/i], you can change this in the project settings
## [br][br]
## @deprecated: Use [member _NetworkTimeSynchronizer.panic_threshold] instead.
var recalibrate_threshold: float:
get:
return _recalibrate_threshold
set(v):
push_error("Trying to set read-only variable recalibrate_threshold")
## Seconds required to pass before considering the game stalled.
##
## If the game becomes unresponsive for some time - e.g. it becomes minimized,
## unfocused, or freezes -, the game time needs to be readjusted. These stalls
## are detected by checking how much time passes between frames. If it's more
## than this threshold, it's considered a stall, and will be compensated
## against.
var stall_threshold: float:
get:
return _stall_threshold
set(v):
push_error("Trying to set read-only variable stall_threshold")
## Current network time in ticks on the server.
##
## This is value is only an estimate, and is regularly updated. This means that
## this value can and probably will change depending on network conditions.
## [br][br]
## [i]read-only[/i]
## [br][br]
## @deprecated: Will return the same as [member tick].
var remote_tick: int:
get:
return tick
set(v):
push_error("Trying to set read-only variable remote_tick")
## Current network time in seconds on the server.
##
## This is value is only an estimate, and is regularly updated. This means that
## this value can and probably will change depending on network conditions.
## [br][br]
## [i]read-only[/i]
## [br][br]
## @deprecated: Will return the same as [member time].
var remote_time: float:
get:
return time
set(v):
push_error("Trying to set read-only variable remote_time")
## Estimated roundtrip time to server.
##
## This value is updated regularly, during server time sync. Latency can be
## estimated as half of the roundtrip time. Returns the same as [member
## _NetworkTimeSynchronizer.rtt].
## [br][br]
## Will always be 0 on servers.
## [br][br]
## [i]read-only[/i]
var remote_rtt: float:
get:
return NetworkTimeSynchronizer.rtt
set(v):
push_error("Trying to set read-only variable remote_rtt")
## Current network time in ticks.
##
## On clients, this value is synced to the server [i]only once[/i] when joining
## the game. After that, it will increase monotonically, incrementing every
## single tick.
## [br][br]
## When hosting, this value is simply the number of ticks since game start.
## [br][br]
## This property can be used for things that require a timer that is guaranteed
## to be linear, i.e. no jumps in time.
## [br][br]
## [i]read-only[/i]
## [br][br]
## @deprecated: Will return the same as [member tick].
var local_tick: int:
get:
return tick
set(v):
push_error("Trying to set read-only variable local_tick")
## Current network time in seconds.
##
## On clients, this value is synced to the server [i]only once[/i] when joining
## the game. After that, it will increase monotonically, incrementing every
## single tick.
## [br][br]
## When hosting, this value is simply the seconds elapsed since game start.
## [br][br]
## This property can be used for things that require a timer that is guaranteed
## to be linear, i.e. no jumps in time.
## [br][br]
## [i]read-only[/i]
## [br][br]
## @deprecated: Will return the same as [member time].
var local_time: float:
get:
return time
set(v):
push_error("Trying to set read-only variable local_time")
## Amount of time a single tick takes, in seconds.
##
## This is the reverse of tickrate
##
## [i]read-only[/i]
var ticktime: float:
get:
return 1.0 / tickrate
set(v):
push_error("Trying to set read-only variable ticktime")
## Percentage of where we are in time for the current tick.
##
## 0.0 - the current tick just happened[br]
## 0.5 - we're halfway to the next tick[br]
## 1.0 - the next tick is right about to be simulated[br]
##
## [i]read-only[/i]
var tick_factor: float:
get:
if not sync_to_physics:
return 1.0 - clampf((_next_tick_time - _last_process_time) * tickrate, 0, 1)
else:
return Engine.get_physics_interpolation_fraction()
set(v):
push_error("Trying to set read-only variable tick_factor")
## Multiplier to get from physics process speeds to tick speeds.
##
## Some methods, like CharacterBody's move_and_slide take velocity in units/sec
## and figure out the time delta on their own. However, they are not aware of
## netfox's time, so motion is all wrong in a network tick. For example, the
## network ticks run at 30 fps, while the game is running at 60fps, thus
## move_and_slide will also assume that it's running on 60fps, resulting in
## slower than expected movement.
##
## To circument this, you can multiply any velocities with this variable, and
## get the desired speed. Don't forget to then divide by this value if it's a
## persistent variable ( e.g. CharacterBody's velocity ).
##
## NOTE: This works correctly both in regular and in physics frames, but may
## yield different values.
##
## [i]read-only[/i]
var physics_factor: float:
get:
if Engine.is_in_physics_frame():
return Engine.physics_ticks_per_second / tickrate
else:
return ticktime / _process_delta
set(v):
push_error("Trying to set read-only variable physics_factor")
## The maximum clock stretch factor allowed.
##
## For more context on clock stretch, see [member clock_stretch_factor]. The
## minimum allowed clock stretch factor is derived as 1.0 / clock_stretch_max.
## Setting this to larger values will allow for quicker clock adjustment at the
## cost of bigger deviations in game speed.
## [br][br]
## Make sure to adjust this value based on the game's needs.
## [br][br]
## [i]read-only[/i], you can change this in the project settings
var clock_stretch_max: float:
get:
return _clock_stretch_max
set(v):
push_error("Trying to set read-only variable stretch_max")
## Suppress warning when calling [member start] with an [OfflineMultiplayerPeer]
## active.
var suppress_offline_peer_warning: bool:
get:
return _suppress_offline_peer_warning
set(v):
push_error("Trying to set read-only variable suppress_offline_peer_warning")
## The currently used clock stretch factor.
##
## As the game progresses, the simulation clock may be ahead of, or behind the
## host's remote clock. To compensate, whenever the simulation clock is ahead of
## the remote clock, the game will slightly slow down, to allow the remote clock
## to catch up. When the remote clock is ahead of the simulation clock, the game
## will run slightly faster to catch up with the remote clock.
## [br][br]
## This value indicates the current clock speed multiplier. Values over 1.0
## indicate speeding up, under 1.0 indicate slowing down.
## [br][br]
## See [member clock_stretch_max] for clock stretch bounds.[br]
## See [_NetworkTimeSynchronizer] for more on the reference- and simulation
## clock.
## [br][br]
## [i]read-only[/i]
var clock_stretch_factor: float:
get:
return _clock_stretch_factor
## The current estimated offset between the reference clock and the simulation
## clock.
##
## Positive values mean the simulation clock is behind, and needs to run
## slightly faster to catch up. Negative values mean the simulation clock is
## ahead, and needs to slow down slightly.
## [br][br]
## See [member clock_stretch] for more clock speed adjustment.
## [br][br]
## [i]read-only[/i]
var clock_offset: float:
get:
# Offset is synced time - local time
return NetworkTimeSynchronizer.get_time() - _clock.get_time()
## The current estimated offset between the reference clock and the remote
## clock.
##
## Positive values mean the reference clock is behind the remote clock.
## Negative values mean the reference clock is ahead of the remote clock.
## [br][br]
## Returns the same as [member _NetworkTimeSynchronizer.remote_offset].
## [br][br]
## [i]read-only[/i]
var remote_clock_offset: float:
get:
return NetworkTimeSynchronizer.remote_offset
## Emitted before a tick loop is run.
signal before_tick_loop()
## Emitted before a tick is run.
signal before_tick(delta: float, tick: int)
## Emitted for every network tick.
signal on_tick(delta: float, tick: int)
## Emitted after every network tick.
signal after_tick(delta: float, tick: int)
## Emitted after the tick loop is run.
signal after_tick_loop()
## Emitted after time is synchronized.
##
## This happens once the NetworkTime is started, and the first time sync process
## concludes. When running as server, this is emitted instantly after started.
signal after_sync()
## Emitted after a client synchronizes their time.
##
## This is only emitted on the server, and is emitted when the client concludes
## their time sync process. This is useful as this event means that the client
## is ticking and gameplay has started on their end.
signal after_client_sync(peer_id: int)
## Emitted when a tickrate mismatch is encountered, and
## [member NetworkTickrateHandshake.mismatch_action] is set to
## [constant NetworkTickrateHandshake.SIGNAL].
signal on_tickrate_mismatch(peer: int, tickrate: int)
# NetworkTime states
const _STATE_INACTIVE := 0
const _STATE_SYNCING := 1
const _STATE_ACTIVE := 2
# Settings
var _tickrate: int = ProjectSettings.get_setting(&"netfox/time/tickrate", 30)
var _sync_to_physics: bool = ProjectSettings.get_setting(&"netfox/time/sync_to_physics", false)
var _max_ticks_per_frame: int = ProjectSettings.get_setting(&"netfox/time/max_ticks_per_frame", 8)
var _recalibrate_threshold: float = ProjectSettings.get_setting(&"netfox/time/recalibrate_threshold", 8.0)
var _stall_threshold: float = ProjectSettings.get_setting(&"netfox/time/stall_threshold", 1.0)
var _clock_stretch_max: float = ProjectSettings.get_setting(&"netfox/time/max_time_stretch", 1.25)
var _suppress_offline_peer_warning: bool = ProjectSettings.get_setting(&"netfox/time/suppress_offline_peer_warning", false)
var _state: int = _STATE_INACTIVE
# Timing
var _tick: int = 0
var _was_paused: bool = false
var _initial_sync_done = false
var _process_delta: float = 0
var _next_tick_time: float = 0
var _last_process_time: float = 0.
var _clock: NetworkClocks.SteppingClock = NetworkClocks.SteppingClock.new()
var _clock_stretch_factor: float = 1.
var _tickrate_handshake: NetworkTickrateHandshake
var _synced_peers: Dictionary = {}
static var _logger: NetfoxLogger = NetfoxLogger._for_netfox("NetworkTime")
## Start NetworkTime.
##
## Once this is called, time will be synchronized and ticks will be consistently
## emitted.
## [br][br]
## On clients, the initial time sync must complete before any ticks are emitted.
## [br][br]
## To check if this initial sync is done, see [method is_initial_sync_done]. If
## you need a signal, see [signal after_sync].
## [br][br]
## Returns [constant OK] on success.[br]
## Returns [constant ERR_ALREADY_IN_USE] if the tick loop is currently active.[br]
## Returns [constant ERR_UNAVAILABLE] if there's no available [MultiplayerPeer].
func start() -> int:
# Check if time loop can be started
if _state != _STATE_INACTIVE:
_logger.warning(
"Multiple calls to NetworkTime.start()! " +
"Are you manually calling *and* have NetworkEvents enabled?")
return ERR_ALREADY_IN_USE
if not multiplayer.has_multiplayer_peer():
_logger.error("Starting time loop without a multiplayer peer!")
return ERR_UNAVAILABLE
if multiplayer.multiplayer_peer is OfflineMultiplayerPeer and not suppress_offline_peer_warning:
_logger.warning("Starting time loop with an offline peer! " +
"If this is intended, suppress this warning in the project settings, " +
"under netfox/Time/Suppress Offline Peer Warning.")
# Reset state
_tick = 0
_initial_sync_done = false
# Host is always synced, as their time is considered ground truth
_synced_peers[1] = true
# Start sync
NetworkTimeSynchronizer.start()
_state = _STATE_SYNCING
if not multiplayer.is_server():
await NetworkTimeSynchronizer.on_initial_sync
_tick = seconds_to_ticks(NetworkTimeSynchronizer.get_time())
_initial_sync_done = true
_state = _STATE_ACTIVE
_submit_sync_success.rpc()
else:
_state = _STATE_ACTIVE
_initial_sync_done = true
# Remove clients from the synced cache when disconnected
multiplayer.peer_disconnected.connect(_handle_peer_disconnect)
# Set initial clock state
_clock.set_time(NetworkTimeSynchronizer.get_time())
_last_process_time = _clock.get_time()
_next_tick_time = _clock.get_time()
after_sync.emit()
# Handle tickrate handshake
_tickrate_handshake.run()
return OK
## Stop NetworkTime.
##
## This will stop the time sync in the background, and no more ticks will be
## emitted until the next start.
func stop() -> void:
NetworkTimeSynchronizer.stop()
_tickrate_handshake.stop()
_state = _STATE_INACTIVE
_synced_peers.clear()
if multiplayer.peer_disconnected.is_connected(_handle_peer_disconnect):
multiplayer.peer_disconnected.disconnect(_handle_peer_disconnect)
## Check if the initial time sync is done.
func is_initial_sync_done() -> bool:
return _initial_sync_done
## Check if client's time sync is complete.
##
## Using this from a client is considered an error.
func is_client_synced(peer_id: int) -> bool:
return _synced_peers.has(peer_id)
## Convert a duration of ticks to seconds.
func ticks_to_seconds(ticks: int) -> float:
return ticks * ticktime
## Convert a duration of seconds to ticks.
func seconds_to_ticks(seconds: float) -> int:
return int(seconds * tickrate)
## Calculate the duration between two ticks in seconds
##
## [i]Note:[/i] Returns negative values if tick_to is smaller than tick_from
func seconds_between(tick_from: int, tick_to: int) -> float:
return ticks_to_seconds(tick_to - tick_from)
## Calculate the duration between two points in time as ticks
##
## [i]Note:[/i] Returns negative values if seconds_to is smaller than seconds_from
func ticks_between(seconds_from: float, seconds_to: float) -> int:
return seconds_to_ticks(seconds_to - seconds_from)
func _ready() -> void:
NetfoxLogger.register_tag(_get_tick_tag, -100)
_tickrate_handshake = NetworkTickrateHandshake.new()
add_child(_tickrate_handshake)
# Proxy tickrate mismatch event
_tickrate_handshake.on_tickrate_mismatch.connect(func(peer, tickrate):
on_tickrate_mismatch.emit(peer, tickrate)
)
func _exit_tree() -> void:
NetfoxLogger.free_tag(_get_tick_tag)
func _get_tick_tag() -> String:
return "@%d" % tick
func _loop() -> void:
# Adjust local clock
_clock.step(_clock_stretch_factor)
var clock_diff := NetworkTimeSynchronizer.get_time() - _clock.get_time()
# Ignore diffs under 1ms
clock_diff = sign(clock_diff) * max(abs(clock_diff) - 0.001, 0.)
var clock_stretch_min := 1. / clock_stretch_max
# var clock_stretch_f = (1. + clock_diff / (1. * ticktime)) / 2.
var clock_stretch_f := inverse_lerp(-ticktime, +ticktime, clock_diff)
clock_stretch_f = clampf(clock_stretch_f, 0., 1.)
var previous_stretch_factor := _clock_stretch_factor
_clock_stretch_factor = lerpf(clock_stretch_min, clock_stretch_max, clock_stretch_f)
# Detect editor pause
var clock_step := _clock.get_time() - _last_process_time
var clock_step_raw := clock_step / previous_stretch_factor
if clock_step_raw > stall_threshold:
# Game stalled for a while, probably paused, don't run extra ticks
# to catch up
_was_paused = true
_logger.debug("Game stalled for %.4fs, assuming it was a pause", [clock_step_raw])
# Handle pause
if _was_paused:
_was_paused = false
_next_tick_time += clock_step
_tick = seconds_to_ticks(NetworkTimeSynchronizer.get_time())
# Run tick loop if needed
var ticks_in_loop := 0
_last_process_time = _clock.get_time()
while _next_tick_time < _last_process_time and ticks_in_loop < max_ticks_per_frame:
if ticks_in_loop == 0:
before_tick_loop.emit()
before_tick.emit(ticktime, tick)
on_tick.emit(ticktime, tick)
after_tick.emit(ticktime, tick)
_tick += 1
ticks_in_loop += 1
_next_tick_time += ticktime
if ticks_in_loop > 0:
after_tick_loop.emit()
func _process(delta: float) -> void:
_process_delta = delta
if _is_active() and not sync_to_physics:
_loop()
func _physics_process(delta: float) -> void:
if _is_active() and sync_to_physics:
_loop()
func _notification(what) -> void:
if what == NOTIFICATION_UNPAUSED:
_was_paused = true
func _is_active() -> bool:
return _state == _STATE_ACTIVE
func _handle_peer_disconnect(peer: int) -> void:
_synced_peers.erase(peer)
@rpc("any_peer", "reliable", "call_local")
func _submit_sync_success() -> void:
var peer_id := multiplayer.get_remote_sender_id()
_logger.trace("Received time sync success from #%s, synced peers: %s", [peer_id, _synced_peers.keys()])
if not _synced_peers.has(peer_id):
_synced_peers[peer_id] = true
after_client_sync.emit(peer_id)
_logger.debug("Peer #%s is now on time!", [peer_id])