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)
452 lines
16 KiB
GDScript
452 lines
16 KiB
GDScript
@tool
|
|
extends Node
|
|
class_name RollbackSynchronizer
|
|
|
|
## Similar to [MultiplayerSynchronizer], this class is responsible for
|
|
## synchronizing data between players, but with support for rollback.
|
|
## [br][br]
|
|
## @tutorial(RollbackSynchronizer Guide): https://foxssake.github.io/netfox/latest/netfox/nodes/rollback-synchronizer/
|
|
|
|
## The root node for resolving node paths in properties. Defaults to the parent
|
|
## node.
|
|
@export var root: Node = get_parent()
|
|
|
|
## Toggle prediction.
|
|
## [br][br]
|
|
## Enabling this will run [code]_rollback_tick[/code] on nodes under
|
|
## [member root] even if there's no current input available for the tick.
|
|
@export var enable_prediction: bool = false
|
|
|
|
@export_group("State")
|
|
## Properties that define the game state.
|
|
## [br][br]
|
|
## State properties are recorded for each tick and restored during rollback.
|
|
## State is restored before every rollback tick, and recorded after simulating
|
|
## the tick.
|
|
@export var state_properties: Array[String]
|
|
|
|
## Ticks to wait between sending full states.
|
|
## [br][br]
|
|
## If set to 0, full states will never be sent. If set to 1, only full states
|
|
## will be sent. If set higher, full states will be sent regularly, but not
|
|
## for every tick.
|
|
## [br][br]
|
|
## Only considered if [member _NetworkRollback.enable_diff_states] is true.
|
|
@export_range(0, 128, 1, "or_greater")
|
|
var full_state_interval: int = 24
|
|
|
|
## Ticks to wait between unreliably acknowledging diff states.
|
|
## [br][br]
|
|
## This can reduce the amount of properties sent in diff states, due to clients
|
|
## more often acknowledging received states. To avoid introducing hickups, these
|
|
## are sent unreliably.
|
|
## [br][br]
|
|
## If set to 0, diff states will never be acknowledged. If set to 1, all diff
|
|
## states will be acknowledged. If set higher, ack's will be sent regularly, but
|
|
## not for every diff state.
|
|
## [br][br]
|
|
## If enabled, it's worth to tune this setting until network traffic is actually
|
|
## reduced.
|
|
## [br][br]
|
|
## Only considered if [member _NetworkRollback.enable_diff_states] is true.
|
|
@export_range(0, 128, 1, "or_greater")
|
|
var diff_ack_interval: int = 0
|
|
|
|
@export_group("Inputs")
|
|
## Properties that define the input for the game simulation.
|
|
## [br][br]
|
|
## Input properties drive the simulation, which in turn results in updated state
|
|
## properties. Input is recorded after every network tick.
|
|
@export var input_properties: Array[String]
|
|
|
|
## This will broadcast input to all peers, turning this off will limit to
|
|
## sending it to the server only. Turning this off is recommended to save
|
|
## bandwidth and reduce cheating risks.
|
|
@export var enable_input_broadcast: bool = true
|
|
|
|
# Make sure this exists from the get-go, just not in the scene tree
|
|
## Decides which peers will receive updates
|
|
var visibility_filter := PeerVisibilityFilter.new()
|
|
|
|
var _state_property_config: _PropertyConfig = _PropertyConfig.new()
|
|
var _input_property_config: _PropertyConfig = _PropertyConfig.new()
|
|
var _nodes: Array[Node] = []
|
|
|
|
var _simset: _Set = _Set.new()
|
|
var _skipset: _Set = _Set.new()
|
|
|
|
var _properties_dirty: bool = false
|
|
|
|
var _property_cache := PropertyCache.new(root)
|
|
var _freshness_store := RollbackFreshnessStore.new()
|
|
|
|
var _states := _PropertyHistoryBuffer.new()
|
|
var _inputs := _PropertyHistoryBuffer.new()
|
|
var _last_simulated_tick: int
|
|
|
|
var _has_input: bool
|
|
var _input_tick: int
|
|
var _is_predicted_tick: bool
|
|
|
|
static var _logger: NetfoxLogger = NetfoxLogger._for_netfox("RollbackSynchronizer")
|
|
|
|
# Composition
|
|
var _history_transmitter: _RollbackHistoryTransmitter
|
|
var _history_recorder: _RollbackHistoryRecorder
|
|
|
|
## Process settings.
|
|
##
|
|
## Call this after any change to configuration. Updates based on authority too
|
|
## ( calls process_authority ).
|
|
func process_settings() -> void:
|
|
_property_cache.root = root
|
|
_property_cache.clear()
|
|
_freshness_store.clear()
|
|
|
|
_nodes.clear()
|
|
|
|
_states.clear()
|
|
_inputs.clear()
|
|
process_authority()
|
|
|
|
# Gather all rollback-aware nodes to simulate during rollbacks
|
|
_nodes = root.find_children("*")
|
|
_nodes.push_front(root)
|
|
_nodes = _nodes.filter(func(it): return NetworkRollback.is_rollback_aware(it))
|
|
_nodes.erase(self)
|
|
|
|
_history_transmitter.sync_settings(root, enable_input_broadcast, full_state_interval, diff_ack_interval)
|
|
_history_transmitter.configure(_states, _inputs, _state_property_config, _input_property_config, visibility_filter, _property_cache, _skipset)
|
|
_history_recorder.configure(_states, _inputs, _state_property_config, _input_property_config, _property_cache, _skipset)
|
|
|
|
## Process settings based on authority.
|
|
##
|
|
## Call this whenever the authority of any of the nodes managed by
|
|
## RollbackSynchronizer changes. Make sure to do this at the same time on all
|
|
## peers.
|
|
func process_authority():
|
|
_state_property_config.local_peer_id = multiplayer.get_unique_id()
|
|
_input_property_config.local_peer_id = multiplayer.get_unique_id()
|
|
|
|
_state_property_config.set_properties_from_paths(state_properties, _property_cache)
|
|
_input_property_config.set_properties_from_paths(input_properties, _property_cache)
|
|
|
|
## Add a state property.
|
|
## [br][br]
|
|
## Settings will be automatically updated. The [param node] may be a string or
|
|
## [NodePath] pointing to a node, or an actual [Node] instance. If the given
|
|
## property is already tracked, this method does nothing.
|
|
func add_state(node: Variant, property: String):
|
|
var property_path := PropertyEntry.make_path(root, node, property)
|
|
if not property_path or state_properties.has(property_path):
|
|
return
|
|
|
|
state_properties.push_back(property_path)
|
|
_properties_dirty = true
|
|
_reprocess_settings.call_deferred()
|
|
|
|
## Add an input property.
|
|
## [br][br]
|
|
## Settings will be automatically updated. The [param node] may be a string or
|
|
## [NodePath] pointing to a node, or an actual [Node] instance. If the given
|
|
## property is already tracked, this method does nothing.
|
|
func add_input(node: Variant, property: String) -> void:
|
|
var property_path := PropertyEntry.make_path(root, node, property)
|
|
if not property_path or input_properties.has(property_path):
|
|
return
|
|
|
|
input_properties.push_back(property_path)
|
|
_properties_dirty = true
|
|
_reprocess_settings.call_deferred()
|
|
|
|
## Check if input is available for the current tick.
|
|
##
|
|
## This input is not always current, it may be from multiple ticks ago.
|
|
## [br][br]
|
|
## Returns true if input is available.
|
|
func has_input() -> bool:
|
|
return _has_input
|
|
|
|
## Get the age of currently available input in ticks.
|
|
##
|
|
## The available input may be from the current tick, or from multiple ticks ago.
|
|
## This number of tick is the input's age.
|
|
## [br][br]
|
|
## Calling this when [member has_input] is false will yield an error.
|
|
func get_input_age() -> int:
|
|
if has_input():
|
|
return NetworkRollback.tick - _input_tick
|
|
else:
|
|
_logger.error("Trying to check input age without having input!")
|
|
return -1
|
|
|
|
## Check if the current tick is predicted.
|
|
##
|
|
## A tick becomes predicted if there's no up-to-date input available. It will be
|
|
## simulated and recorded, but will not be broadcast, nor considered
|
|
## authoritative.
|
|
func is_predicting() -> bool:
|
|
return _is_predicted_tick
|
|
|
|
## Ignore a node's prediction for the current rollback tick.
|
|
##
|
|
## Call this when the input is too old to base predictions on. This call is
|
|
## ignored if [member enable_prediction] is false.
|
|
func ignore_prediction(node: Node) -> void:
|
|
if enable_prediction:
|
|
_skipset.add(node)
|
|
|
|
## Get the tick of the last known input.
|
|
## [br][br]
|
|
## This is the latest tick where input information is available. If there's
|
|
## locally owned input for this instance ( e.g. running as client ), this value
|
|
## will be the current tick. Otherwise, this will be the latest tick received
|
|
## from the input owner.
|
|
## [br][br]
|
|
## If [member enable_input_broadcast] is false, there may be no input available
|
|
## for peers who own neither state nor input.
|
|
## [br][br]
|
|
## Returns -1 if there's no known input.
|
|
func get_last_known_input() -> int:
|
|
# If we own input, it is updated regularly, this will be the current tick
|
|
# If we don't own input, _inputs is only updated when input data is received
|
|
if not _inputs.is_empty():
|
|
return _inputs.keys().max()
|
|
return -1
|
|
|
|
## Get the tick of the last known state.
|
|
## [br][br]
|
|
## This is the latest tick where information is available for state. For state
|
|
## owners ( usually the host ), this is the current tick. Note that even this
|
|
## data may change as new input arrives. For peers that don't own state, this
|
|
## will be the tick of the latest state received from the state owner.
|
|
func get_last_known_state() -> int:
|
|
# If we own state, this will be updated when recording and broadcasting
|
|
# state, this will be the current tick
|
|
# If we don't own state, this will be updated when state data is received
|
|
return _history_transmitter.get_latest_state_tick()
|
|
|
|
func _ready() -> void:
|
|
if Engine.is_editor_hint():
|
|
return
|
|
|
|
if not NetworkTime.is_initial_sync_done():
|
|
# Wait for time sync to complete
|
|
await NetworkTime.after_sync
|
|
|
|
process_settings.call_deferred()
|
|
|
|
func _connect_signals() -> void:
|
|
NetworkTime.before_tick.connect(_before_tick)
|
|
NetworkTime.after_tick.connect(_after_tick)
|
|
|
|
NetworkRollback.on_prepare_tick.connect(_on_prepare_tick)
|
|
NetworkRollback.on_process_tick.connect(_process_tick)
|
|
NetworkRollback.on_record_tick.connect(_on_record_tick)
|
|
|
|
NetworkRollback.before_loop.connect(_before_rollback_loop)
|
|
NetworkRollback.after_loop.connect(_after_rollback_loop)
|
|
|
|
func _disconnect_signals() -> void:
|
|
NetworkTime.before_tick.disconnect(_before_tick)
|
|
NetworkTime.after_tick.disconnect(_after_tick)
|
|
|
|
NetworkRollback.on_prepare_tick.disconnect(_on_prepare_tick)
|
|
NetworkRollback.on_process_tick.disconnect(_process_tick)
|
|
NetworkRollback.on_record_tick.disconnect(_on_record_tick)
|
|
|
|
NetworkRollback.before_loop.disconnect(_before_rollback_loop)
|
|
NetworkRollback.after_loop.disconnect(_after_rollback_loop)
|
|
|
|
func _before_tick(_dt: float, tick: int) -> void:
|
|
_history_recorder.apply_state(tick)
|
|
|
|
func _after_tick(_dt: float, tick: int) -> void:
|
|
_history_recorder.record_input(tick)
|
|
_history_transmitter.transmit_input(tick)
|
|
_history_recorder.trim_history()
|
|
_freshness_store.trim()
|
|
|
|
func _before_rollback_loop() -> void:
|
|
_notify_resim()
|
|
|
|
func _on_prepare_tick(tick: int) -> void:
|
|
_history_recorder.apply_tick(tick)
|
|
_prepare_tick_process(tick)
|
|
|
|
func _process_tick(tick: int) -> void:
|
|
_run_rollback_tick(tick)
|
|
_push_simset_metrics()
|
|
|
|
func _on_record_tick(tick: int) -> void:
|
|
_history_recorder.record_state(tick)
|
|
_history_transmitter.transmit_state(tick)
|
|
|
|
func _after_rollback_loop() -> void:
|
|
_history_recorder.apply_display_state()
|
|
_history_transmitter.conclude_tick_loop()
|
|
|
|
func _notification(what: int) -> void:
|
|
if what == NOTIFICATION_EDITOR_PRE_SAVE:
|
|
update_configuration_warnings()
|
|
|
|
func _get_configuration_warnings() -> PackedStringArray:
|
|
if not root:
|
|
root = get_parent()
|
|
|
|
# Explore state and input properties
|
|
if not root:
|
|
return ["No valid root node found!"]
|
|
|
|
var result := PackedStringArray()
|
|
result.append_array(_NetfoxEditorUtils.gather_properties(root, "_get_rollback_state_properties",
|
|
func(node, prop):
|
|
add_state(node, prop)
|
|
))
|
|
|
|
result.append_array(_NetfoxEditorUtils.gather_properties(root, "_get_rollback_input_properties",
|
|
func(node, prop):
|
|
add_input(node, prop)
|
|
))
|
|
|
|
return result
|
|
|
|
func _enter_tree() -> void:
|
|
if Engine.is_editor_hint():
|
|
return
|
|
|
|
if not visibility_filter:
|
|
visibility_filter = PeerVisibilityFilter.new()
|
|
|
|
if not visibility_filter.get_parent():
|
|
add_child(visibility_filter)
|
|
|
|
if _history_transmitter == null:
|
|
_history_transmitter = _RollbackHistoryTransmitter.new()
|
|
add_child(_history_transmitter, true)
|
|
_history_transmitter.set_multiplayer_authority(get_multiplayer_authority())
|
|
|
|
if _history_recorder == null:
|
|
_history_recorder = _RollbackHistoryRecorder.new()
|
|
|
|
if not NetworkTime.is_initial_sync_done():
|
|
# Wait for time sync to complete
|
|
await NetworkTime.after_sync
|
|
_connect_signals.call_deferred()
|
|
process_settings.call_deferred()
|
|
|
|
func _exit_tree() -> void:
|
|
if Engine.is_editor_hint():
|
|
return
|
|
|
|
_disconnect_signals()
|
|
|
|
func _notify_resim() -> void:
|
|
if _get_owned_input_props().is_empty():
|
|
# We don't have any inputs we own, simulate from earliest we've received
|
|
NetworkRollback.notify_resimulation_start(_history_transmitter.get_earliest_input_tick())
|
|
else:
|
|
# We own inputs, simulate from latest authorative state
|
|
NetworkRollback.notify_resimulation_start(_history_transmitter.get_latest_state_tick())
|
|
|
|
func _prepare_tick_process(tick: int) -> void:
|
|
_history_recorder.set_latest_state_tick(_history_transmitter._latest_state_tick)
|
|
|
|
# Save data for input prediction
|
|
var retrieved_tick := _inputs.get_closest_tick(tick)
|
|
|
|
# These are used as input for input age ( i.e. do we even have input, and if so, how old? )
|
|
_has_input = retrieved_tick != -1
|
|
_input_tick = retrieved_tick
|
|
|
|
# Used to explicitly determine if this is a predicted tick
|
|
# ( even if we could grab *some* input )
|
|
_is_predicted_tick = _is_predicted_tick_for(null, tick)
|
|
_history_transmitter.set_predicted_tick(_is_predicted_tick)
|
|
|
|
# Reset the set of simulated and ignored nodes
|
|
_simset.clear()
|
|
_skipset.clear()
|
|
|
|
# Gather nodes that can be simulated
|
|
for node in _nodes:
|
|
if _can_simulate(node, tick):
|
|
NetworkRollback.notify_simulated(node)
|
|
|
|
func _can_simulate(node: Node, tick: int) -> bool:
|
|
if not enable_prediction and _is_predicted_tick_for(node, tick):
|
|
# Don't simulate if prediction is not allowed and tick is predicted
|
|
return false
|
|
if NetworkRollback.is_mutated(node, tick):
|
|
# Mutated nodes are always resimulated
|
|
return true
|
|
if input_properties.is_empty():
|
|
# If we're running inputless and own the node, simulate it if we haven't
|
|
if node.is_multiplayer_authority():
|
|
return tick > _last_simulated_tick
|
|
# If we're running inputless and don't own the node, only run as prediction
|
|
return enable_prediction
|
|
if node.is_multiplayer_authority():
|
|
# Simulate from earliest input
|
|
# Don't simulate frames we don't have input for
|
|
return tick >= _history_transmitter.get_earliest_input_tick()
|
|
else:
|
|
# Simulate ONLY if we have state from server
|
|
# Simulate from latest authorative state - anything the server confirmed we don't rerun
|
|
# Don't simulate frames we don't have input for
|
|
return tick >= _history_transmitter.get_latest_state_tick()
|
|
|
|
# `node` can be set to null, in case we're not simulating a specific node
|
|
func _is_predicted_tick_for(node: Node, tick: int) -> bool:
|
|
if input_properties.is_empty() and node != null:
|
|
# We're running without inputs
|
|
# It's only predicted if we don't own the node
|
|
return not node.is_multiplayer_authority()
|
|
else:
|
|
# We have input properties, it's only predicted if we don't have the input for the tick
|
|
return not _inputs.has(tick)
|
|
|
|
func _run_rollback_tick(tick: int) -> void:
|
|
# Simulate rollback tick
|
|
# Method call on rewindables
|
|
# Rollback synchronizers go through each node they manage
|
|
# If current tick is in node's range, tick
|
|
# If authority: Latest input >= tick >= Latest state
|
|
# If not: Latest input >= tick >= Earliest input
|
|
for node in _nodes:
|
|
if not NetworkRollback.is_simulated(node):
|
|
continue
|
|
|
|
var is_fresh := _freshness_store.is_fresh(node, tick)
|
|
_is_predicted_tick = _is_predicted_tick_for(node, tick)
|
|
NetworkRollback.process_rollback(node, NetworkTime.ticktime, tick, is_fresh)
|
|
|
|
if _skipset.has(node):
|
|
continue
|
|
|
|
_freshness_store.notify_processed(node, tick)
|
|
_simset.add(node)
|
|
|
|
func _push_simset_metrics():
|
|
# Push metrics
|
|
NetworkPerformance.push_rollback_nodes_simulated(_simset.size())
|
|
|
|
func _reprocess_settings() -> void:
|
|
if not _properties_dirty or Engine.is_editor_hint():
|
|
return
|
|
|
|
_properties_dirty = false
|
|
process_settings()
|
|
|
|
func _get_recorded_state_props() -> Array[PropertyEntry]:
|
|
return _state_property_config.get_properties()
|
|
|
|
func _get_owned_state_props() -> Array[PropertyEntry]:
|
|
return _state_property_config.get_owned_properties()
|
|
|
|
func _get_recorded_input_props() -> Array[PropertyEntry]:
|
|
return _input_property_config.get_owned_properties()
|
|
|
|
func _get_owned_input_props() -> Array[PropertyEntry]:
|
|
return _input_property_config.get_owned_properties()
|