Files
tactical-shooter/addons/netfox/state-synchronizer.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

265 lines
9.2 KiB
GDScript

@tool
extends Node
class_name StateSynchronizer
## Synchronizes state from authority.
##
## Similar to Godot's [MultiplayerSynchronizer], but is tied to the network tick loop. Works well
## with [TickInterpolator].
## [br][br]
## @tutorial(StateSynchronizer Guide): https://foxssake.github.io/netfox/latest/netfox/nodes/state-synchronizer/
## The root node for resolving node paths in properties.
@export var root: Node
## Properties to record and broadcast.
@export var 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 # TODO: Don't tie to a network rollback setting?
## 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 # TODO: Don't tie to a network rollback setting?
## Decides which peers will receive updates
var visibility_filter := PeerVisibilityFilter.new()
var _property_cache: PropertyCache
var _property_config: _PropertyConfig = _PropertyConfig.new()
var _properties_dirty: bool = false
var _state_history := _PropertyHistoryBuffer.new()
# Collaborators
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 _is_initialized: bool = false
static var _logger := NetfoxLogger._for_netfox("StateSynchronizer")
## Process settings.
## [br][br]
## Call this after any change to configuration.
func process_settings() -> void:
_property_cache = PropertyCache.new(root)
_property_config.set_properties_from_paths(properties, _property_cache)
_full_state_encoder = _SnapshotHistoryEncoder.new(_state_history, _property_cache)
_diff_state_encoder = _DiffHistoryEncoder.new(_state_history, _property_cache)
_diff_state_encoder.add_properties(_property_config.get_properties())
_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)
_is_initialized = true
## 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) -> void:
var property_path := PropertyEntry.make_path(root, node, property)
if not property_path or properties.has(property_path):
return
properties.push_back(property_path)
_properties_dirty = true
_reprocess_settings.call_deferred()
func _notification(what) -> void:
if what == NOTIFICATION_EDITOR_PRE_SAVE:
update_configuration_warnings()
func _get_configuration_warnings() -> PackedStringArray:
if not root:
root = get_parent()
# Explore state properties
if not root:
return ["No valid root node found!"]
return _NetfoxEditorUtils.gather_properties(root, "_get_synchronized_state_properties",
func(node, prop):
add_state(node, prop)
)
func _connect_signals() -> void:
NetworkTime.after_tick.connect(_after_tick)
NetworkTime.after_tick_loop.connect(_after_loop)
func _disconnect_signals() -> void:
NetworkTime.after_tick.disconnect(_after_tick)
NetworkTime.after_tick_loop.disconnect(_after_loop)
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)
_connect_signals.call_deferred()
process_settings.call_deferred()
func _exit_tree() -> void:
if Engine.is_editor_hint():
return
_disconnect_signals()
func _after_tick(_dt: float, tick: int) -> void:
if is_multiplayer_authority():
# Submit snapshot
var state := _PropertySnapshot.extract(_property_config.get_properties())
_state_history.set_snapshot(tick, state)
_broadcast_state(tick, state)
elif not _state_history.is_empty():
var state := _state_history.get_history(tick)
state.apply(_property_cache)
func _after_loop() -> void:
_state_history.trim(NetworkTime.tick - NetworkRollback.history_limit) # TODO: Don't tie to rollback?
func _reprocess_settings() -> void:
if not _properties_dirty:
return
_properties_dirty = false
process_settings()
func _broadcast_state(tick: int, state: _PropertySnapshot) -> void:
var is_sending_diffs := NetworkRollback.enable_diff_states # TODO: Don't tie to a rollback setting?
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
_logger.trace("Reference tick @%d not found for peer #%s, sending full tick", [reference_tick, peer])
_send_full_state(tick, peer)
continue
# Prepare diff
var diff_state_data := _diff_state_encoder.encode(tick, reference_tick, _property_config.get_properties())
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 _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, _property_config.get_properties())
_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)
# `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: return
var sender := multiplayer.get_remote_sender_id()
var snapshot := _full_state_encoder.decode(data, _property_config.get_properties_owned_by(sender))
if not _full_state_encoder.apply(tick, snapshot, sender):
# Invalid data
return
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: return
var sender = multiplayer.get_remote_sender_id()
var diff_snapshot := _diff_state_encoder.decode(data, _property_config.get_properties_owned_by(sender))
if not _diff_state_encoder.apply(tick, diff_snapshot, reference_tick, sender):
# Invalid data
return
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:
if not _is_initialized: return
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])