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)
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
extends RefCounted
|
||||
class_name _RollbackHistoryRecorder
|
||||
|
||||
# Provided externally by RBS
|
||||
var _state_history: _PropertyHistoryBuffer
|
||||
var _input_history: _PropertyHistoryBuffer
|
||||
|
||||
var _state_property_config: _PropertyConfig
|
||||
var _input_property_config: _PropertyConfig
|
||||
|
||||
var _property_cache: PropertyCache
|
||||
|
||||
var _latest_state_tick: int
|
||||
var _skipset: _Set
|
||||
|
||||
func configure(
|
||||
p_state_history: _PropertyHistoryBuffer, p_input_history: _PropertyHistoryBuffer,
|
||||
p_state_property_config: _PropertyConfig, p_input_property_config: _PropertyConfig,
|
||||
p_property_cache: PropertyCache,
|
||||
p_skipset: _Set
|
||||
) -> void:
|
||||
_state_history = p_state_history
|
||||
_input_history = p_input_history
|
||||
_state_property_config = p_state_property_config
|
||||
_input_property_config = p_input_property_config
|
||||
_property_cache = p_property_cache
|
||||
_skipset = p_skipset
|
||||
|
||||
func set_latest_state_tick(p_latest_state_tick: int) -> void:
|
||||
_latest_state_tick = p_latest_state_tick
|
||||
|
||||
func apply_state(tick: int) -> void:
|
||||
# Apply state for tick
|
||||
var state = _state_history.get_history(tick)
|
||||
state.apply(_property_cache)
|
||||
|
||||
func apply_display_state() -> void:
|
||||
apply_state(NetworkRollback.display_tick)
|
||||
|
||||
func apply_tick(tick: int) -> void:
|
||||
var state := _state_history.get_history(tick)
|
||||
var input := _input_history.get_history(tick)
|
||||
|
||||
state.apply(_property_cache)
|
||||
input.apply(_property_cache)
|
||||
|
||||
func trim_history() -> void:
|
||||
# Trim history
|
||||
_state_history.trim()
|
||||
_input_history.trim()
|
||||
|
||||
func record_input(tick: int) -> void:
|
||||
# Record input
|
||||
if not _get_recorded_input_props().is_empty():
|
||||
var input = _PropertySnapshot.extract(_get_recorded_input_props())
|
||||
var input_tick: int = tick + NetworkRollback.input_delay
|
||||
_input_history.set_snapshot(input_tick, input)
|
||||
|
||||
func record_state(tick: int) -> void:
|
||||
# Record state for specified tick ( current + 1 )
|
||||
# Check if any of the managed nodes were mutated
|
||||
var is_mutated := _get_recorded_state_props().any(func(pe):
|
||||
return NetworkRollback.is_mutated(pe.node, tick - 1))
|
||||
|
||||
var record_state := _PropertySnapshot.extract(_get_state_props_to_record(tick))
|
||||
if record_state.size():
|
||||
var merge_state := _state_history.get_history(tick - 1)
|
||||
_state_history.set_snapshot(tick, merge_state.merge(record_state))
|
||||
|
||||
func _should_record_tick(tick: int) -> bool:
|
||||
if _get_recorded_state_props().is_empty():
|
||||
# Don't record tick if there's no props to record
|
||||
return false
|
||||
|
||||
if _get_recorded_state_props().any(func(pe):
|
||||
return NetworkRollback.is_mutated(pe.node, tick - 1)):
|
||||
# If there's any node that was mutated, there's something to record
|
||||
return true
|
||||
|
||||
# Otherwise, record only if we don't have authoritative state for the tick
|
||||
return tick > _latest_state_tick
|
||||
|
||||
func _get_state_props_to_record(tick: int) -> Array[PropertyEntry]:
|
||||
if not _should_record_tick(tick):
|
||||
return []
|
||||
if _skipset.is_empty():
|
||||
return _get_recorded_state_props()
|
||||
|
||||
return _get_recorded_state_props().filter(func(pe): return _should_record_property(pe, tick))
|
||||
|
||||
func _should_record_property(property_entry: PropertyEntry, tick: int) -> bool:
|
||||
if NetworkRollback.is_mutated(property_entry.node, tick - 1):
|
||||
return true
|
||||
if _skipset.has(property_entry.node):
|
||||
return false
|
||||
return true
|
||||
|
||||
# =============================================================================
|
||||
# Shared utils, extract later
|
||||
|
||||
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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://bn6fsqxbfhihk
|
||||
@@ -0,0 +1,282 @@
|
||||
extends Node
|
||||
class_name _RollbackHistoryTransmitter
|
||||
|
||||
var root: Node
|
||||
var enable_input_broadcast: bool = true
|
||||
var full_state_interval: int
|
||||
var diff_ack_interval: int
|
||||
|
||||
# Provided externally by RBS
|
||||
var _state_history: _PropertyHistoryBuffer
|
||||
var _input_history: _PropertyHistoryBuffer
|
||||
var _visibility_filter: PeerVisibilityFilter
|
||||
|
||||
var _state_property_config: _PropertyConfig
|
||||
var _input_property_config: _PropertyConfig
|
||||
|
||||
var _property_cache: PropertyCache
|
||||
var _skipset: _Set
|
||||
|
||||
# Collaborators
|
||||
var _input_encoder: _RedundantHistoryEncoder
|
||||
var _full_state_encoder: _SnapshotHistoryEncoder
|
||||
var _diff_state_encoder: _DiffHistoryEncoder
|
||||
|
||||
# State
|
||||
var _ackd_state: Dictionary = {}
|
||||
var _next_full_state_tick: int
|
||||
var _next_diff_ack_tick: int
|
||||
|
||||
var _earliest_input_tick: int
|
||||
var _latest_state_tick: int
|
||||
|
||||
var _is_predicted_tick: bool
|
||||
var _is_initialized: bool
|
||||
|
||||
# Signals
|
||||
signal _on_transmit_state(state: Dictionary, tick: int)
|
||||
|
||||
static var _logger: NetfoxLogger = NetfoxLogger._for_netfox("RollbackHistoryTransmitter")
|
||||
|
||||
func get_earliest_input_tick() -> int:
|
||||
return _earliest_input_tick
|
||||
|
||||
func get_latest_state_tick() -> int:
|
||||
return _latest_state_tick
|
||||
|
||||
func set_predicted_tick(p_is_predicted_tick) -> void:
|
||||
_is_predicted_tick = p_is_predicted_tick
|
||||
|
||||
func sync_settings(p_root: Node, p_enable_input_broadcast: bool, p_full_state_interval: int, p_diff_ack_interval: int) -> void:
|
||||
root = p_root
|
||||
enable_input_broadcast = p_enable_input_broadcast
|
||||
full_state_interval = p_full_state_interval
|
||||
diff_ack_interval = p_diff_ack_interval
|
||||
|
||||
func configure(
|
||||
p_state_history: _PropertyHistoryBuffer, p_input_history: _PropertyHistoryBuffer,
|
||||
p_state_property_config: _PropertyConfig, p_input_property_config: _PropertyConfig,
|
||||
p_visibility_filter: PeerVisibilityFilter,
|
||||
p_property_cache: PropertyCache,
|
||||
p_skipset: _Set
|
||||
) -> void:
|
||||
_state_history = p_state_history
|
||||
_input_history = p_input_history
|
||||
_state_property_config = p_state_property_config
|
||||
_input_property_config = p_input_property_config
|
||||
_visibility_filter = p_visibility_filter
|
||||
_property_cache = p_property_cache
|
||||
_skipset = p_skipset
|
||||
|
||||
_input_encoder = _RedundantHistoryEncoder.new(_input_history, _property_cache)
|
||||
_full_state_encoder = _SnapshotHistoryEncoder.new(_state_history, _property_cache)
|
||||
_diff_state_encoder = _DiffHistoryEncoder.new(_state_history, _property_cache)
|
||||
|
||||
_is_initialized = true
|
||||
|
||||
reset()
|
||||
|
||||
func reset() -> void:
|
||||
_ackd_state.clear()
|
||||
_latest_state_tick = NetworkTime.tick - 1
|
||||
_earliest_input_tick = NetworkTime.tick
|
||||
_next_full_state_tick = NetworkTime.tick
|
||||
_next_diff_ack_tick = NetworkTime.tick
|
||||
|
||||
# Scatter full state sends, so not all nodes send at the same tick
|
||||
if is_inside_tree():
|
||||
_next_full_state_tick += hash(root.get_path()) % maxi(1, full_state_interval)
|
||||
_next_diff_ack_tick += hash(root.get_path()) % maxi(1, diff_ack_interval)
|
||||
else:
|
||||
_next_full_state_tick += hash(root.name) % maxi(1, full_state_interval)
|
||||
_next_diff_ack_tick += hash(root.name) % maxi(1, diff_ack_interval)
|
||||
|
||||
_diff_state_encoder.add_properties(_state_property_config.get_properties())
|
||||
_full_state_encoder.set_properties(_get_owned_state_props())
|
||||
_input_encoder.set_properties(_get_owned_input_props())
|
||||
|
||||
func conclude_tick_loop() -> void:
|
||||
_earliest_input_tick = NetworkTime.tick
|
||||
|
||||
func transmit_input(tick: int) -> void:
|
||||
if not _get_owned_input_props().is_empty():
|
||||
var input_tick: int = tick + NetworkRollback.input_delay
|
||||
var input_data := _input_encoder.encode(input_tick, _get_owned_input_props())
|
||||
var state_owning_peer := root.get_multiplayer_authority()
|
||||
NetworkRollback.register_input_submission(root, tick)
|
||||
|
||||
if enable_input_broadcast:
|
||||
for peer in _visibility_filter.get_rpc_target_peers():
|
||||
_submit_input.rpc_id(peer, input_tick, input_data)
|
||||
elif state_owning_peer != multiplayer.get_unique_id():
|
||||
_submit_input.rpc_id(state_owning_peer, input_tick, input_data)
|
||||
|
||||
func transmit_state(tick: int) -> void:
|
||||
if _get_owned_state_props().is_empty():
|
||||
# We don't own state, don't transmit anything
|
||||
return
|
||||
|
||||
if _is_predicted_tick and not _input_property_config.get_properties().is_empty():
|
||||
# Don't transmit anything if we're predicting
|
||||
# EXCEPT when we're running inputless
|
||||
return
|
||||
|
||||
# Include properties we own
|
||||
var full_state := _PropertySnapshot.new()
|
||||
|
||||
for property in _get_owned_state_props():
|
||||
if _should_broadcast(property, tick):
|
||||
full_state.set_value(property.to_string(), property.get_value())
|
||||
|
||||
_on_transmit_state.emit(full_state, tick)
|
||||
|
||||
# No properties to send?
|
||||
if full_state.is_empty():
|
||||
return
|
||||
|
||||
_latest_state_tick = max(_latest_state_tick, tick)
|
||||
|
||||
var is_sending_diffs := NetworkRollback.enable_diff_states
|
||||
var is_full_state_tick := not is_sending_diffs or (full_state_interval > 0 and tick > _next_full_state_tick)
|
||||
|
||||
if is_full_state_tick:
|
||||
# Broadcast new full state
|
||||
for peer in _visibility_filter.get_rpc_target_peers():
|
||||
_send_full_state(tick, peer)
|
||||
|
||||
# Adjust next full state if sending diffs
|
||||
if is_sending_diffs:
|
||||
_next_full_state_tick = tick + full_state_interval
|
||||
else:
|
||||
# Send diffs to each peer
|
||||
for peer in _visibility_filter.get_visible_peers():
|
||||
var reference_tick := _ackd_state.get(peer, -1) as int
|
||||
if reference_tick < 0 or not _state_history.has(reference_tick):
|
||||
# Peer hasn't ack'd any tick, or we don't have the ack'd tick
|
||||
# Send full state
|
||||
_send_full_state(tick, peer)
|
||||
continue
|
||||
|
||||
# Prepare diff
|
||||
var diff_state_data := _diff_state_encoder.encode(tick, reference_tick, _get_owned_state_props())
|
||||
|
||||
if _diff_state_encoder.get_full_snapshot().size() == _diff_state_encoder.get_encoded_snapshot().size():
|
||||
# State is completely different, send full state
|
||||
_send_full_state(tick, peer)
|
||||
else:
|
||||
# Send only diff
|
||||
_submit_diff_state.rpc_id(peer, diff_state_data, tick, reference_tick)
|
||||
|
||||
# Push metrics
|
||||
NetworkPerformance.push_full_state(_diff_state_encoder.get_full_snapshot())
|
||||
NetworkPerformance.push_sent_state(_diff_state_encoder.get_encoded_snapshot())
|
||||
|
||||
func _should_broadcast(property: PropertyEntry, tick: int) -> bool:
|
||||
# Only broadcast if we've simulated the node
|
||||
# NOTE: _can_simulate checks mutations, but to override _skipset
|
||||
# we check first
|
||||
if NetworkRollback.is_mutated(property.node, tick - 1):
|
||||
return true
|
||||
if _skipset.has(property.node):
|
||||
return false
|
||||
if NetworkRollback.is_rollback_aware(property.node):
|
||||
return NetworkRollback.is_simulated(property.node)
|
||||
|
||||
# Node is not rollback-aware, broadcast updates only if we own it
|
||||
return property.node.is_multiplayer_authority()
|
||||
|
||||
func _send_full_state(tick: int, peer: int = 0) -> void:
|
||||
var full_state_snapshot := _state_history.get_snapshot(tick).as_dictionary()
|
||||
var full_state_data := _full_state_encoder.encode(tick, _get_owned_state_props())
|
||||
|
||||
_submit_full_state.rpc_id(peer, full_state_data, tick)
|
||||
|
||||
if peer <= 0:
|
||||
NetworkPerformance.push_full_state_broadcast(full_state_snapshot)
|
||||
NetworkPerformance.push_sent_state_broadcast(full_state_snapshot)
|
||||
else:
|
||||
NetworkPerformance.push_full_state(full_state_snapshot)
|
||||
NetworkPerformance.push_sent_state(full_state_snapshot)
|
||||
|
||||
func _notification(what):
|
||||
if what == NOTIFICATION_PREDELETE:
|
||||
NetworkRollback.free_input_submission_data_for(root)
|
||||
|
||||
@rpc("any_peer", "unreliable", "call_remote")
|
||||
func _submit_input(tick: int, data: Array) -> void:
|
||||
if not _is_initialized:
|
||||
# Settings not processed yet
|
||||
return
|
||||
|
||||
var sender := multiplayer.get_remote_sender_id()
|
||||
var snapshots := _input_encoder.decode(data, _input_property_config.get_properties_owned_by(sender))
|
||||
var earliest_received_input = _input_encoder.apply(tick, snapshots, sender)
|
||||
if earliest_received_input >= 0:
|
||||
_earliest_input_tick = mini(_earliest_input_tick, earliest_received_input)
|
||||
NetworkRollback.register_input_submission(root, tick)
|
||||
|
||||
# `serialized_state` is a serialized _PropertySnapshot
|
||||
@rpc("any_peer", "unreliable_ordered", "call_remote")
|
||||
func _submit_full_state(data: Array, tick: int) -> void:
|
||||
if not _is_initialized:
|
||||
# Settings not processed yet
|
||||
return
|
||||
|
||||
var sender := multiplayer.get_remote_sender_id()
|
||||
var snapshot := _full_state_encoder.decode(data, _state_property_config.get_properties_owned_by(sender))
|
||||
if not _full_state_encoder.apply(tick, snapshot, sender):
|
||||
# Invalid data
|
||||
return
|
||||
|
||||
_latest_state_tick = tick
|
||||
if NetworkRollback.enable_diff_states:
|
||||
_ack_full_state.rpc_id(sender, tick)
|
||||
|
||||
# State is a serialized _PropertySnapshot (Dictionary[String, Variant])
|
||||
@rpc("any_peer", "unreliable_ordered", "call_remote")
|
||||
func _submit_diff_state(data: PackedByteArray, tick: int, reference_tick: int) -> void:
|
||||
if not _is_initialized:
|
||||
# Settings not processed yet
|
||||
return
|
||||
|
||||
var sender = multiplayer.get_remote_sender_id()
|
||||
var diff_snapshot := _diff_state_encoder.decode(data, _state_property_config.get_properties_owned_by(sender))
|
||||
if not _diff_state_encoder.apply(tick, diff_snapshot, reference_tick, sender):
|
||||
# Invalid data
|
||||
return
|
||||
|
||||
_latest_state_tick = tick
|
||||
|
||||
if NetworkRollback.enable_diff_states:
|
||||
if diff_ack_interval > 0 and tick > _next_diff_ack_tick:
|
||||
_ack_diff_state.rpc_id(sender, tick)
|
||||
_next_diff_ack_tick = tick + diff_ack_interval
|
||||
|
||||
@rpc("any_peer", "reliable", "call_remote")
|
||||
func _ack_full_state(tick: int) -> void:
|
||||
var sender_id := multiplayer.get_remote_sender_id()
|
||||
_ackd_state[sender_id] = tick
|
||||
|
||||
_logger.trace("Peer %d ack'd full state for tick %d", [sender_id, tick])
|
||||
|
||||
@rpc("any_peer", "unreliable_ordered", "call_remote")
|
||||
func _ack_diff_state(tick: int) -> void:
|
||||
var sender_id := multiplayer.get_remote_sender_id()
|
||||
_ackd_state[sender_id] = tick
|
||||
|
||||
_logger.trace("Peer %d ack'd diff state for tick %d", [sender_id, tick])
|
||||
|
||||
# =============================================================================
|
||||
# Shared utils, extract later
|
||||
|
||||
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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://ofadsxmvd3e3
|
||||
@@ -0,0 +1,382 @@
|
||||
extends Node
|
||||
class_name _NetworkRollback
|
||||
|
||||
## Orchestrates the rollback loop.
|
||||
##
|
||||
## @tutorial(NetworkRollback Guide): https://foxssake.github.io/netfox/latest/netfox/guides/network-rollback/
|
||||
## @tutorial(Modifying objects during rollback): https://foxssake.github.io/netfox/latest/netfox/tutorials/modifying-objects-during-rollback/
|
||||
|
||||
## Whether rollback is enabled.
|
||||
var enabled: bool = ProjectSettings.get_setting(&"netfox/rollback/enabled", true)
|
||||
|
||||
## Whether diff states are enabled.
|
||||
## [br][br]
|
||||
## Diff states send only the state properties that have changed.
|
||||
var enable_diff_states: bool = ProjectSettings.get_setting(&"netfox/rollback/enable_diff_states", true)
|
||||
|
||||
## How many ticks to store as history.
|
||||
## [br][br]
|
||||
## The larger the history limit, the further we can roll back into the past,
|
||||
## thus the more latency we can manage. The drawback is, with higher history
|
||||
## limit comes more history data stored, thus higher memory usage.
|
||||
## [br][br]
|
||||
## Rollback won't go further than this limit, regardless of inputs received.
|
||||
## [br][br]
|
||||
## [i]read-only[/i], you can change this in the project settings
|
||||
var history_limit: int:
|
||||
get:
|
||||
return _history_limit
|
||||
set(v):
|
||||
push_error("Trying to set read-only variable history_limit")
|
||||
|
||||
## The earliest tick that history is retained for.
|
||||
## [br][br]
|
||||
## Determined by [member history_limit].
|
||||
## [br][br]
|
||||
## [i]read-only[/i]
|
||||
var history_start: int:
|
||||
get:
|
||||
return maxi(0, NetworkTime.tick - history_limit)
|
||||
set(v):
|
||||
push_error("Trying to set read-only variable history_start")
|
||||
|
||||
## Offset into the past for display, in ticks.
|
||||
## [br][br]
|
||||
## After the rollback, we have the option to not display the absolute latest
|
||||
## state of the game, but let's say the state two frames ago ( offset = 2 ).
|
||||
## This can help with hiding latency, by giving more time for an up-to-date
|
||||
## state to arrive before we try to display it.
|
||||
## [br][br]
|
||||
## [i]read-only[/i], you can change this in the project settings
|
||||
var display_offset: int:
|
||||
get:
|
||||
return _display_offset
|
||||
set(v):
|
||||
push_error("Trying to set read-only variable display_offset")
|
||||
|
||||
## The currently displayed tick.
|
||||
## [br][br]
|
||||
## This is the current tick as returned by [member _NetworkTime.tick], minus
|
||||
## the [member display_offset]. By configuring the [member display_offset], a
|
||||
## past tick may be displayed to the player, so that updates from the server
|
||||
## have slightly more time to arrive, masking latency.
|
||||
## [br][br]
|
||||
## [i]read-only[/i]
|
||||
var display_tick: int:
|
||||
get:
|
||||
if enabled:
|
||||
return maxi(0, NetworkTime.tick - NetworkRollback.display_offset)
|
||||
else:
|
||||
return NetworkTime.tick
|
||||
set(v):
|
||||
push_error("Trying to set read-only variable display_tick")
|
||||
|
||||
## Offset into the future to submit inputs, in ticks.
|
||||
##
|
||||
## By submitting inputs into the future, they don't happen instantly, but with
|
||||
## some delay. This can help hiding latency - even if input takes some time to
|
||||
## arrive, it will still be up to date, as it was timestamped into the future.
|
||||
## This only works if the input delay is greater than the network latency.
|
||||
## [br][br]
|
||||
## In cases where the latency is greater than the input delay, this can still
|
||||
## reduce the amount of resimulated frames, resulting in less compute.
|
||||
## [br][br]
|
||||
## [b]Note:[/b] the [code]is_fresh[/code] parameter may not work as expected
|
||||
## with input latency higher than network latency.
|
||||
## [br][br]
|
||||
## [i]read-only[/i], you can change this in the project settings
|
||||
|
||||
var input_delay: int:
|
||||
get:
|
||||
return _input_delay
|
||||
set(v):
|
||||
push_error("Trying to set read-only variable input_delay")
|
||||
|
||||
## How many previous input frames to send along with the current one.
|
||||
## [br][br]
|
||||
## As inputs are sent over an unreliable channel, packets may get lost or appear
|
||||
## out of order. To mitigate packet loss, we send the current and previous n
|
||||
## ticks of input data. This way, even if the input for a given tick gets lost
|
||||
## in transmission, the next (n-1) packets will contain the data for it.
|
||||
## [br][br]
|
||||
## [i]read-only[/i], you can change this in the project settings
|
||||
|
||||
var input_redundancy: int:
|
||||
get:
|
||||
return max(1, _input_redundancy)
|
||||
set(v):
|
||||
push_error("Trying to set read-only variable input_redundancy")
|
||||
|
||||
## The current [i]rollback[/i] tick.
|
||||
## [br][br]
|
||||
## Note that this is different from [member _NetworkTime.tick], and only makes
|
||||
## sense in the context of a rollback loop.
|
||||
var tick: int:
|
||||
get:
|
||||
return _tick
|
||||
set(v):
|
||||
push_error("Trying to set read-only variable tick")
|
||||
|
||||
## Event emitted before running the network rollback loop.
|
||||
signal before_loop()
|
||||
|
||||
## Event emitted in preparation of each rollback tick.
|
||||
## [br][br]
|
||||
## Handlers should apply the state and input corresponding to the given tick.
|
||||
signal on_prepare_tick(tick: int)
|
||||
|
||||
## Event emitted after preparing each rollback tick.
|
||||
## [br][br]
|
||||
## Handlers may process the prepared tick, e.g. modulating the input by its age
|
||||
## to implement input prediction.
|
||||
signal after_prepare_tick(tick: int)
|
||||
|
||||
## Event emitted to process the given rollback tick.
|
||||
## [br][br]
|
||||
## Handlers should check if they *need* to resimulate the given tick, and if so,
|
||||
## generate the next state based on the current data ( applied in the prepare
|
||||
## tick phase ).
|
||||
signal on_process_tick(tick: int)
|
||||
|
||||
## Event emitted after the given rollback tick was processed.
|
||||
signal after_process_tick(tick: int)
|
||||
|
||||
## Event emitted to record the given rollback tick.
|
||||
## [br][br]
|
||||
## By this time, the tick is advanced from the simulation, handlers should save
|
||||
## their resulting states for the given tick.
|
||||
signal on_record_tick(tick: int)
|
||||
|
||||
## Event emitted after running the network rollback loop.
|
||||
signal after_loop()
|
||||
|
||||
# Settings
|
||||
var _history_limit: int = ProjectSettings.get_setting(&"netfox/rollback/history_limit", 64)
|
||||
var _display_offset: int = ProjectSettings.get_setting(&"netfox/rollback/display_offset", 0)
|
||||
var _input_delay: int = ProjectSettings.get_setting(&"netfox/rollback/input_delay", 0)
|
||||
var _input_redundancy: int = ProjectSettings.get_setting(&"netfox/rollback/input_redundancy", 3)
|
||||
|
||||
# Timing
|
||||
var _tick: int = 0
|
||||
var _resim_from: int
|
||||
|
||||
var _rollback_from: int = -1
|
||||
var _rollback_to: int = -1
|
||||
var _rollback_stage: String = ""
|
||||
|
||||
# Resim + mutations
|
||||
var _is_rollback: bool = false
|
||||
var _simulated_nodes: _Set = _Set.new()
|
||||
var _mutated_nodes: Dictionary = {}
|
||||
var _input_submissions: Dictionary = {}
|
||||
|
||||
const _STAGE_BEFORE := "B"
|
||||
const _STAGE_PREPARE := "P"
|
||||
const _STAGE_SIMULATE := "S"
|
||||
const _STAGE_RECORD := "R"
|
||||
const _STAGE_AFTER := "A"
|
||||
|
||||
static var _logger: NetfoxLogger = NetfoxLogger._for_netfox("NetworkRollback")
|
||||
|
||||
## Submit the resimulation start tick for the current loop.
|
||||
##
|
||||
## This is used to determine the resimulation range during each loop.
|
||||
func notify_resimulation_start(tick: int) -> void:
|
||||
_resim_from = min(_resim_from, tick)
|
||||
|
||||
## Submit node for simulation.
|
||||
##
|
||||
## This is used mostly internally by [RollbackSynchronizer]. The idea is to
|
||||
## submit each affected node while preparing the tick, and then run only the
|
||||
## nodes that need to be resimulated.
|
||||
func notify_simulated(node: Node) -> void:
|
||||
_simulated_nodes.add(node)
|
||||
|
||||
|
||||
## Check if node was submitted for simulation.
|
||||
##
|
||||
## This is used mostly internally by [RollbackSynchronizer]. The idea is to
|
||||
## submit each affected node while preparing the tick, and then use
|
||||
## [member is_simulated] to run only the nodes that need to be resimulated.
|
||||
func is_simulated(node: Node) -> bool:
|
||||
return _simulated_nodes.has(node)
|
||||
|
||||
## Check if a network rollback is currently active.
|
||||
func is_rollback() -> bool:
|
||||
return _is_rollback
|
||||
|
||||
## Checks if a given object is rollback-aware, i.e. has the
|
||||
## [code]_rollback_tick[/code] method implemented.
|
||||
##
|
||||
## This is used by [RollbackSynchronizer] to see if it should simulate the
|
||||
## given object during rollback.
|
||||
func is_rollback_aware(what: Object) -> bool:
|
||||
return what.has_method(&"_rollback_tick")
|
||||
|
||||
## Calls the [code]_rollback_tick[/code] method on the target, running its
|
||||
## simulation for the given rollback tick.
|
||||
## [br][br]
|
||||
## This is used by [RollbackSynchronizer] to resimulate ticks during rollback.
|
||||
## While the [code]_rollback_tick[/code] method could be called directly as
|
||||
## well, this method exists to future-proof the code a bit, so the method name
|
||||
## is not repeated all over the place.
|
||||
## [br][br]
|
||||
## [i]Note:[/i] Make sure to check if the target is rollback-aware, because if
|
||||
## it's not, this method will run into an error.
|
||||
func process_rollback(target: Object, delta: float, p_tick: int, is_fresh: bool) -> void:
|
||||
target._rollback_tick(delta, p_tick, is_fresh)
|
||||
|
||||
## Marks the target object as mutated.
|
||||
## [br][br]
|
||||
## Mutated objects will be re-recorded for the specified tick, and resimulated
|
||||
## from the given tick onwards.
|
||||
## [br][br]
|
||||
## For special cases, you can specify the tick when the mutation happened. Since
|
||||
## it defaults to the current rollback [member tick], this parameter rarely
|
||||
## needs to be specified.
|
||||
## [br][br]
|
||||
## Note that registering a mutation into the past will yield a warning.
|
||||
## [br][br]
|
||||
## @experimental: The Mutations API is experimental!
|
||||
func mutate(target: Object, p_tick: int = tick) -> void:
|
||||
_mutated_nodes[target] = mini(p_tick, _mutated_nodes.get(target, p_tick))
|
||||
|
||||
if is_rollback() and p_tick < tick:
|
||||
_logger.warning(
|
||||
"Trying to mutate object %s in the past, for tick %d!",
|
||||
[target, p_tick]
|
||||
)
|
||||
|
||||
## Check whether the target object was mutated in or after the given tick via
|
||||
## [method mutate].
|
||||
## [br][br]
|
||||
## @experimental: The Mutations API is experimental!
|
||||
func is_mutated(target: Object, p_tick: int = tick) -> bool:
|
||||
if _mutated_nodes.has(target):
|
||||
return p_tick >= _mutated_nodes.get(target)
|
||||
else:
|
||||
return false
|
||||
|
||||
## Check whether the target object was mutated specifically in the given tick
|
||||
## via [method mutate].
|
||||
## [br][br]
|
||||
## @experimental: The Mutations API is experimental!
|
||||
func is_just_mutated(target: Object, p_tick: int = tick) -> bool:
|
||||
if _mutated_nodes.has(target):
|
||||
return _mutated_nodes.get(target) == p_tick
|
||||
else:
|
||||
return false
|
||||
|
||||
## Register that a node has submitted its input for a specific tick
|
||||
func register_input_submission(root_node: Node, tick: int) -> void:
|
||||
if not _input_submissions.has(root_node):
|
||||
_input_submissions[root_node] = tick
|
||||
else:
|
||||
_input_submissions[root_node] = maxi(_input_submissions[root_node], tick)
|
||||
|
||||
## Get the latest input tick submitted by a specific root node
|
||||
## [br][br]
|
||||
## Returns [code]-1[/code] if no input was submitted for the node, ever.
|
||||
func get_latest_input_tick(root_node: Node) -> int:
|
||||
if _input_submissions.has(root_node):
|
||||
return _input_submissions[root_node]
|
||||
return -1
|
||||
|
||||
## Check if a node has submitted input for a specific tick (or later)
|
||||
func has_input_for_tick(root_node: Node, tick: int) -> bool:
|
||||
return _input_submissions.has(root_node) and _input_submissions[root_node] >= tick
|
||||
|
||||
## Free all input submission data for a node
|
||||
## [br][br]
|
||||
## Use this once the node is freed.
|
||||
func free_input_submission_data_for(root_node: Node) -> void:
|
||||
_input_submissions.erase(root_node)
|
||||
|
||||
func _ready():
|
||||
NetfoxLogger.register_tag(_get_rollback_tag)
|
||||
NetworkTime.after_tick_loop.connect(_rollback)
|
||||
|
||||
func _exit_tree():
|
||||
NetfoxLogger.free_tag(_get_rollback_tag)
|
||||
|
||||
func _get_rollback_tag() -> String:
|
||||
if _is_rollback:
|
||||
return "%s@%d|%d>%d" % [_rollback_stage, _tick, _rollback_from, _rollback_to]
|
||||
else:
|
||||
return "_"
|
||||
|
||||
func _rollback() -> void:
|
||||
if not enabled:
|
||||
return
|
||||
|
||||
# Ask all rewindables to submit their earliest inputs
|
||||
_resim_from = NetworkTime.tick
|
||||
before_loop.emit()
|
||||
|
||||
# Only set _is_rollback *after* emitting before_loop
|
||||
_is_rollback = true
|
||||
_rollback_stage = _STAGE_BEFORE
|
||||
|
||||
# from = Earliest input amongst all rewindables
|
||||
var from := _resim_from
|
||||
|
||||
# to = Current tick
|
||||
var to := NetworkTime.tick
|
||||
|
||||
# Limit number of rollback ticks
|
||||
if to - from > history_limit:
|
||||
_logger.warning(
|
||||
"Trying to run rollback for ticks %d to %d, past the history limit of %d",
|
||||
[from, to, history_limit]
|
||||
)
|
||||
from = NetworkTime.tick - history_limit
|
||||
|
||||
# for tick in from .. to:
|
||||
_rollback_from = from
|
||||
_rollback_to = to
|
||||
for tick in range(from, to):
|
||||
_tick = tick
|
||||
_simulated_nodes.clear()
|
||||
|
||||
# Prepare state
|
||||
# Done individually by Rewindables ( usually Rollback Synchronizers )
|
||||
# Restore input and state for tick
|
||||
_rollback_stage = _STAGE_PREPARE
|
||||
on_prepare_tick.emit(tick)
|
||||
after_prepare_tick.emit(tick)
|
||||
|
||||
# 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
|
||||
_rollback_stage = _STAGE_SIMULATE
|
||||
on_process_tick.emit(tick)
|
||||
after_process_tick.emit(tick)
|
||||
|
||||
# Record state for tick + 1
|
||||
_rollback_stage = _STAGE_RECORD
|
||||
on_record_tick.emit(tick + 1)
|
||||
|
||||
# Restore display state
|
||||
_rollback_stage = _STAGE_AFTER
|
||||
after_loop.emit()
|
||||
|
||||
# Cleanup
|
||||
_mutated_nodes.clear()
|
||||
_is_rollback = false
|
||||
|
||||
# Insight 1:
|
||||
# state(x) = simulate(state(x - 1), input(x - 1))
|
||||
# state(x + 1) = simulate(state(x), input(x))
|
||||
# Insight 2:
|
||||
# Server is authorative over all state, client over its own input, i.e.
|
||||
# Server broadcasts state
|
||||
# Client sends input to server
|
||||
# Flow:
|
||||
# Clients send in their inputs
|
||||
# Server simulates frames from earliest input to current
|
||||
# Server broadcasts simulated frames
|
||||
# Clients receive authorative states
|
||||
# Clients simulate local frames
|
||||
@@ -0,0 +1 @@
|
||||
uid://gas743comroj
|
||||
@@ -0,0 +1,34 @@
|
||||
extends RefCounted
|
||||
class_name RollbackFreshnessStore
|
||||
|
||||
## This class tracks nodes and whether they have processed any given tick during
|
||||
## a rollback.
|
||||
|
||||
# TODO: _Set
|
||||
var _data: Dictionary = {}
|
||||
|
||||
func is_fresh(node: Node, tick: int) -> bool:
|
||||
if not _data.has(tick):
|
||||
return true
|
||||
|
||||
if not _data[tick].has(node):
|
||||
return true
|
||||
|
||||
return false
|
||||
|
||||
func notify_processed(node: Node, tick: int) -> void:
|
||||
if not _data.has(tick):
|
||||
_data[tick] = {}
|
||||
|
||||
_data[tick][node] = true
|
||||
|
||||
func trim() -> void:
|
||||
while not _data.is_empty():
|
||||
var earliest_tick := _data.keys().min()
|
||||
if earliest_tick < NetworkRollback.history_start:
|
||||
_data.erase(earliest_tick)
|
||||
else:
|
||||
break
|
||||
|
||||
func clear():
|
||||
_data.clear()
|
||||
@@ -0,0 +1 @@
|
||||
uid://bjpcq3jhfbqeh
|
||||
@@ -0,0 +1,451 @@
|
||||
@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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://d350u8evihs1u
|
||||
Reference in New Issue
Block a user