From 868bc8395e3b800d41c7d8a4173f85d323ea549f Mon Sep 17 00:00:00 2001 From: tuysuztavsan Date: Mon, 27 Apr 2026 17:54:45 +0300 Subject: [PATCH 01/57] initial commit for simulation concept --- addons/netfox/netfox.gd | 11 ++++ .../netfox/rollback/rollback-synchronizer.gd | 2 +- .../netfox/servers/network-history-server.gd | 33 +++++++++- .../servers/network-synchronization-server.gd | 63 ++++++++++++++----- addons/netfox/simulation/input_sender.gd | 52 +++++++++++++++ addons/netfox/simulation/input_sender.gd.uid | 1 + addons/netfox/simulation/simulator.gd | 31 +++++++++ addons/netfox/simulation/simulator.gd.uid | 1 + 8 files changed, 175 insertions(+), 19 deletions(-) create mode 100644 addons/netfox/simulation/input_sender.gd create mode 100644 addons/netfox/simulation/input_sender.gd.uid create mode 100644 addons/netfox/simulation/simulator.gd create mode 100644 addons/netfox/simulation/simulator.gd.uid diff --git a/addons/netfox/netfox.gd b/addons/netfox/netfox.gd index e7592fba2..4ba53c8be 100644 --- a/addons/netfox/netfox.gd +++ b/addons/netfox/netfox.gd @@ -169,6 +169,17 @@ var SETTINGS: Array[Dictionary] = [ "name": "netfox/events/enabled", "value": true, "type": TYPE_BOOL + }, + # Simulation + { + "name": "netfox/simulation/history_limit", + "value": 64, + "type" : TYPE_INT + }, + { + "name": "netfox/simulation/input_redundancy", + "value": 3, + "type" : TYPE_INT } ] diff --git a/addons/netfox/rollback/rollback-synchronizer.gd b/addons/netfox/rollback/rollback-synchronizer.gd index df2c81196..956042fc2 100644 --- a/addons/netfox/rollback/rollback-synchronizer.gd +++ b/addons/netfox/rollback/rollback-synchronizer.gd @@ -86,9 +86,9 @@ func process_settings() -> void: for node in _sim_nodes + _state_properties.get_subjects() + _input_properties.get_subjects(): RollbackSimulationServer.deregister_node(node) _sim_nodes.clear() - process_authority() + # Register nodes for simulation and liveness var managed_nodes := [root] + _collect_managed_nodes(root) _logger.debug("Filtering managed nodes: %s", [managed_nodes]) diff --git a/addons/netfox/servers/network-history-server.gd b/addons/netfox/servers/network-history-server.gd index 6d511f022..ef0213fac 100644 --- a/addons/netfox/servers/network-history-server.gd +++ b/addons/netfox/servers/network-history-server.gd @@ -5,8 +5,10 @@ class_name _NetworkHistoryServer ## Tracks the history of objects' properties ## -## Specifically, history is stored for rollback state properties, rollback input -## properties, and synchronized state properties. +## History is stored for [br] +## 1- rollback state and inputs, +## 2- syncronized states, +## 3- simulated inputs and states. ## [br][br] ## Keeping history lets rollback restore earlier game states for resimulation, ## and enables [_NetworkSynchronizationServer] to send diff states by comparing @@ -15,9 +17,12 @@ class_name _NetworkHistoryServer var _rb_input_properties := _PropertyPool.new() var _rb_state_properties := _PropertyPool.new() var _sync_state_properties := _PropertyPool.new() +var _sim_input_properties := _PropertyPool.new() +var _sim_state_properties := _PropertyPool.new() var _rb_history_size := NetworkRollback.history_limit var _sync_history_size := ProjectSettings.get_setting("netfox/state_synchronizer/history_limit", 64) as int +var _sim_history_size := ProjectSettings.get_setting("netfox/simulation/history_limit", 64) as int var _ignored_subjects := _Set.new() @@ -25,11 +30,15 @@ var _ignored_subjects := _Set.new() var _rb_input_history := _PerObjectHistory.new(_rb_history_size) var _rb_state_history := _PerObjectHistory.new(_rb_history_size) var _sync_history := _PerObjectHistory.new(_sync_history_size) +var _sim_input_history := _PerObjectHistory.new(_sim_history_size) +var _sim_state_history := _PerObjectHistory.new(_sim_history_size) # Cached snapshots for syncing var _rb_input_snapshots := _HistoryBuffer.new(_rb_history_size) var _rb_state_snapshots := _HistoryBuffer.new(_rb_history_size) var _sync_state_snapshots := _HistoryBuffer.new(_sync_history_size) +var _sim_input_snapshots := _HistoryBuffer.new(_sim_history_size) +var _sim_state_snapshots := _HistoryBuffer.new(_sim_history_size) static var _logger := NetfoxLogger._for_netfox("NetworkHistoryServer") @@ -57,6 +66,14 @@ func register_sync_state(node: Node, property: NodePath) -> void: func deregister_sync_state(node: Node, property: NodePath) -> void: _sync_state_properties.erase(node, property) +## Register a simulated input property +func register_simulated_input(node : Node, property : NodePath) -> void: + _sim_input_properties.add(node, property) + +## Register a simulated state property +func register_simulated_state(node : Node, property : NodePath) -> void: + _sim_state_properties.add(node, property) + ## Deregister a node, no longer tracking any property it had registered using ## any of the [code]register_*()[/code] methods func deregister(node: Node) -> void: @@ -64,14 +81,18 @@ func deregister(node: Node) -> void: _rb_state_properties.erase_subject(node) _rb_input_properties.erase_subject(node) _sync_state_properties.erase_subject(node) + _sim_state_properties.erase_subject(node) # Erase from per-object history _rb_state_history.erase_subject(node) _rb_input_history.erase_subject(node) _sync_history.erase_subject(node) + _sim_state_history.erase_subject(node) + _sim_input_history.erase_subject(node) # Erase from per-tick history - for history in [_rb_state_snapshots, _rb_input_snapshots, _sync_state_snapshots]: + for history in [_rb_state_snapshots, _rb_input_snapshots, _sync_state_snapshots,\ + _sim_state_snapshots, _sim_input_snapshots]: for value in history.values(): var snapshot := value as _Snapshot snapshot.erase_subject(node) @@ -179,6 +200,12 @@ func _get_rollback_state_snapshot(tick: int) -> _Snapshot: func _get_synchronizer_state_snapshot(tick: int) -> _Snapshot: return _sync_state_snapshots.get_at(tick) +func _get_simulation_input_snapshot(tick : int) -> _Snapshot: + return _sim_input_snapshots.get_at(tick) + +func _get_simulation_state_snapshot(tick : int) -> _Snapshot: + return _sim_state_snapshots.get_at(tick) + func _merge_rollback_input(snapshot: _Snapshot) -> bool: _merge_snapshot(snapshot, _rb_input_snapshots, true) return _merge_history(snapshot, _rb_input_history, true) diff --git a/addons/netfox/servers/network-synchronization-server.gd b/addons/netfox/servers/network-synchronization-server.gd index 430b2ac95..0f49cb877 100644 --- a/addons/netfox/servers/network-synchronization-server.gd +++ b/addons/netfox/servers/network-synchronization-server.gd @@ -5,10 +5,10 @@ class_name _NetworkSynchronizationServer ## Synchronizes properties over the network ## -## Handles synchronization of rollback and state properties ( -## [RollbackSynchronizer] and [StateSynchronizer] ), while respecting visibility +## Handles synchronization of states and inputs while respecting visibility ## filters and schemas for serialization. ## [br][br] +## [RollbackSynchronizer], [StateSynchronizer], [InputSender], [Simulator] uses this class internally. ## Packets are sent per tick, instead of per object. So for every simulated ## rollback tick, a packet is sent with states, and for every recorded input, ## a packet is sent with the inputs. @@ -29,6 +29,8 @@ var _rb_owned_input_properties := _PropertyPool.new() var _rb_owned_state_properties := _PropertyPool.new() var _sync_state_properties := _PropertyPool.new() var _sync_owned_state_properties := _PropertyPool.new() +var _sim_input_properties := _PropertyPool.new() +var _sim_owned_input_properties := _PropertyPool.new() var _visibility_filters := {} # Node to PeerVisibilityFilter @@ -37,7 +39,8 @@ var _rb_enable_diffs := NetworkRollback.enable_diff_states var _rb_full_interval := ProjectSettings.get_setting("netfox/rollback/full_state_interval", 24) as int var _rb_full_scheduler := _IntervalScheduler.new(_rb_full_interval) -var _input_redundancy := NetworkRollback.input_redundancy +var _rb_input_redundancy := NetworkRollback.input_redundancy +var _sim_input_redundancy := ProjectSettings.get_setting("netfox/simulation/history_limit", 3) as int var _last_sync_state_sent := _Snapshot.new(0) var _sync_enable_diffs := ProjectSettings.get_setting("netfox/state_synchronizer/enable_diff_states", true) as bool @@ -105,6 +108,19 @@ func deregister_sync_state(node: Node, property: NodePath) -> void: _sync_state_properties.erase(node, property) _sync_owned_state_properties.erase(node, property) +## Register a [param property] of [param node] to be synchronized +## as simulated input +func register_simulation_input(node: Node, property: NodePath) -> void: + _sim_input_properties.add(node, property) + if node.is_multiplayer_authority(): + _sim_owned_input_properties.add(node, property) + +## Deregister a [param property] of [param node] from being synchronized +## as simulated input +func deregister_simulation_input(node: Node, property: NodePath) -> void: + _sim_input_properties.erase(node, property) + _sim_owned_input_properties.erase(node, property) + ## Register a [param serializer] to use when transmitting ## [param property param] of [param node] over the network func register_schema(node: Node, property: NodePath, serializer: NetworkSchemaSerializer) -> void: @@ -136,6 +152,8 @@ func deregister(node: Node) -> void: _rb_owned_input_properties.erase_subject(node) _sync_state_properties.erase_subject(node) _sync_owned_state_properties.erase_subject(node) + _sim_input_properties.erase_subject(node) + _sim_owned_input_properties.erase_subject(node) _visibility_filters.erase(node) _schemas.erase_subject(node) @@ -148,12 +166,15 @@ func _is_node_visible_to(peer: int, node: Node) -> bool: func _synchronize_input(tick: int) -> void: # We don't own inputs, nothing to synchronize - if _rb_owned_input_properties.is_empty(): + if _rb_owned_input_properties.is_empty() and _sim_owned_input_properties.is_empty(): return - var snapshots := [] as Array[_Snapshot] + var rb_snapshots := [] as Array[_Snapshot] + var sim_snapshots := [] as Array[_Snapshot] var notified_peers := _Set.new() - + + ## TODO Handle notified peers for simulator changes? + if not _rb_enable_input_broadcast: # If input broadcast is off, find which peers need to know our inputs # That is all peers who own state controlled by our input @@ -175,18 +196,30 @@ func _synchronize_input(tick: int) -> void: notified_peers.erase(multiplayer.get_unique_id()) # Prepare snapshot package - for offset in _input_redundancy: - # Grab snapshot from NetworkHistoryServer - var snapshot := NetworkHistoryServer._get_rollback_input_snapshot(tick - offset) - if not snapshot: + # First rollback inputs. + for offset in _rb_input_redundancy: + # Grab rollback snapshot from NetworkHistoryServer + var rollback_snapshot := NetworkHistoryServer._get_rollback_input_snapshot(tick - offset) + if not rollback_snapshot: break - - _logger.trace("Submitting input: %s", [snapshot]) - snapshots.append(snapshot) - + + _logger.trace("Submitting rollback input: %s", [rollback_snapshot]) + rb_snapshots.append(rollback_snapshot) + + # Now prepare simulation inputs. + for offset in _sim_input_redundancy: + # Grab simulation snapshot from NetworkHistoryServer + var simulation_snapshot := NetworkHistoryServer._get_simulation_input_snapshot(tick - offset) + if not simulation_snapshot: + break + + _logger.trace("Submitting simulation input: %s", [simulation_snapshot]) + sim_snapshots.append(simulation_snapshot) + _logger.trace("Submitting input to peers: %s", [notified_peers]) for peer in notified_peers: - var data := _redundant_serializer.write_for(peer, snapshots, _rb_owned_input_properties) + var data := _redundant_serializer.write_for(peer, rb_snapshots, _rb_owned_input_properties) + data.append_array(_redundant_serializer.write_for(peer, sim_snapshots, _sim_owned_input_properties)) _cmd_input.send(data, peer) func _synchronize_state(tick: int) -> void: diff --git a/addons/netfox/simulation/input_sender.gd b/addons/netfox/simulation/input_sender.gd new file mode 100644 index 000000000..db41e0b48 --- /dev/null +++ b/addons/netfox/simulation/input_sender.gd @@ -0,0 +1,52 @@ +@tool +extends Node +class_name InputSender + +## Stores inputs and sends them to server. +## [br][br] +## [InputSender] can be used alone or with [Simulator]. + +## The root node for resolving node paths in inputs. Defaults to the parent node. +@export var root: Node = get_parent() + +@export_group("Input") +## 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] + +@onready var _logger: NetfoxLogger = NetfoxLogger._for_netfox("InputSender:" + root.name) + +var _input_properties := _PropertyPool.new() + +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. +## [br][br] +## Call this after any change to configuration. Updates based on authority too +## ( calls process_authority ). +func process_settings() -> void: + + # First, deregister what might be registered. + + + + pass + +## Process settings based on authority. +## [br][br] +## Call this whenever the authority of any of the input nodes change. +## Make sure to do this at the same time on all peers. +func process_authority(): + # Deregister all recorded inputs + for node in _input_properties.get_subjects(): + for property in _input_properties.get_properties_of(node): + NetworkHistoryServer.deregister_rollback_input(node, property) + NetworkSynchronizationServer.deregister_rollback_input(node, property) diff --git a/addons/netfox/simulation/input_sender.gd.uid b/addons/netfox/simulation/input_sender.gd.uid new file mode 100644 index 000000000..ae0701ba2 --- /dev/null +++ b/addons/netfox/simulation/input_sender.gd.uid @@ -0,0 +1 @@ +uid://dgihodqy5q27e diff --git a/addons/netfox/simulation/simulator.gd b/addons/netfox/simulation/simulator.gd new file mode 100644 index 000000000..3474cf536 --- /dev/null +++ b/addons/netfox/simulation/simulator.gd @@ -0,0 +1,31 @@ +@tool +extends Node +class_name Simulator + +## Simulates for the ticks clients dont have information yet. +## [br][br] +## [Simulator] doesnt participate in RollBack at all, and will simulate only on local clients. [br] +## Its good idea to use [Simulator] whenever you want to give control of something to local player. + +## The root node for resolving node paths in properties. Defaults to the parent node. +@export var root: Node = get_parent() + +@export_group("State") +## Properties that define the game state. +## [br][br] +## State properties are recorded for each tick. +## State is restored when server broadcasts truth, [Simulator] then will accept this +## as true state and apply it. Only then if we have inputs for future ticks it will simulate them. +@export var state_properties: Array[String] + +@onready var _logger: NetfoxLogger = NetfoxLogger._for_netfox("Simulator:" + root.name) + +var _input_properties := _PropertyPool.new() + +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 diff --git a/addons/netfox/simulation/simulator.gd.uid b/addons/netfox/simulation/simulator.gd.uid new file mode 100644 index 000000000..b1a238aca --- /dev/null +++ b/addons/netfox/simulation/simulator.gd.uid @@ -0,0 +1 @@ +uid://bi4g87012gvok From b29cc0af3842265753dbe4e91bc6c4a2731c7493 Mon Sep 17 00:00:00 2001 From: tuysuztavsan Date: Wed, 29 Apr 2026 01:13:05 +0300 Subject: [PATCH 02/57] renamed most of the things, more base code done on input sender. --- addons/netfox/icons/input-sender.svg | 54 ++++++++ addons/netfox/icons/input-sender.svg.import | 37 +++++ addons/netfox/input_sender.gd | 131 ++++++++++++++++++ addons/netfox/netfox.gd | 20 ++- .../netfox/servers/network-history-server.gd | 55 ++++---- .../servers/network-synchronization-server.gd | 82 +++++------ addons/netfox/simulation/input_sender.gd | 52 ------- 7 files changed, 309 insertions(+), 122 deletions(-) create mode 100644 addons/netfox/icons/input-sender.svg create mode 100644 addons/netfox/icons/input-sender.svg.import create mode 100644 addons/netfox/input_sender.gd delete mode 100644 addons/netfox/simulation/input_sender.gd diff --git a/addons/netfox/icons/input-sender.svg b/addons/netfox/icons/input-sender.svg new file mode 100644 index 000000000..3f3f39358 --- /dev/null +++ b/addons/netfox/icons/input-sender.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + diff --git a/addons/netfox/icons/input-sender.svg.import b/addons/netfox/icons/input-sender.svg.import new file mode 100644 index 000000000..8976192c0 --- /dev/null +++ b/addons/netfox/icons/input-sender.svg.import @@ -0,0 +1,37 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dd227x8br84rs" +path="res://.godot/imported/input-sender.svg-7b3cd669dc50ce8229dd58ca509f298a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/netfox/icons/input-sender.svg" +dest_files=["res://.godot/imported/input-sender.svg-7b3cd669dc50ce8229dd58ca509f298a.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/addons/netfox/input_sender.gd b/addons/netfox/input_sender.gd new file mode 100644 index 000000000..1c16ebbcd --- /dev/null +++ b/addons/netfox/input_sender.gd @@ -0,0 +1,131 @@ +@tool +extends Node +class_name InputSender + +## Stores inputs and sends them to server. +## [br][br] +## [InputSender] can be used alone or with [Simulator]. + +## The root node for resolving node paths in inputs. Defaults to the parent node. +@export var root: Node = get_parent() + +@export_group("Input") +## 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] + +# 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() + +@onready var _logger: NetfoxLogger = NetfoxLogger._for_netfox("InputSender:" + root.name) + +var _input_properties := _PropertyPool.new() +var _properties_dirty: bool = false + +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 + +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 not NetworkTime.is_initial_sync_done(): + # Wait for time sync to complete + await NetworkTime.after_sync + process_settings.call_deferred() + +## Process settings. +## [br][br] +## Call this after any change to configuration. Updates based on authority too +## ( calls process_authority ). +func process_settings() -> void: + process_authority() + + # Register identifiers + for node in _input_properties.get_subjects(): + NetworkIdentityServer.register_node(node) + + # Register visibility filter + for node in _input_properties.get_subjects(): + NetworkSynchronizationServer.register_visibility_filter(node, visibility_filter) + +## Process settings based on authority. +## [br][br] +## Call this whenever the authority of input node changes. +## Make sure to do this at the same time on all peers. +func process_authority(): + for node in _input_properties.get_subjects(): + for property in _input_properties.get_properties_of(node): + NetworkHistoryServer.deregister_input_sender_input(node, property) + + # Process authority + _input_properties.set_from_paths(root, input_properties) + + # Register new recorded inputs + for node in _input_properties.get_subjects(): + for property in _input_properties.get_properties_of(node): + NetworkHistoryServer.register_input_sender_input(node, property) + +## 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() + +func _notification(what: int) -> void: + if what == NOTIFICATION_EDITOR_PRE_SAVE: + update_configuration_warnings() + elif what == NOTIFICATION_PREDELETE: + for node in _input_properties.get_subjects(): + NetworkSynchronizationServer.deregister(node) + NetworkIdentityServer.deregister_node(node) + NetworkHistoryServer.deregister(node) + +func _get_configuration_warnings() -> PackedStringArray: + if not root: + root = get_parent() + + # Check if root exists. + if not root: + return ["No valid root node found!"] + + var result := PackedStringArray() + + result.append_array(_NetfoxEditorUtils.gather_properties(root, "_get_input_sender_input_properties", + func(node, prop): + add_input(node, prop) + )) + + if _input_properties.is_empty() and input_properties.is_empty(): + return ["Input properties are not configured!"] + + return result + +func _reprocess_settings() -> void: + if not _properties_dirty or Engine.is_editor_hint(): + return + + _properties_dirty = false + process_settings() diff --git a/addons/netfox/netfox.gd b/addons/netfox/netfox.gd index 4ba53c8be..85c1d05dc 100644 --- a/addons/netfox/netfox.gd +++ b/addons/netfox/netfox.gd @@ -170,17 +170,17 @@ var SETTINGS: Array[Dictionary] = [ "value": true, "type": TYPE_BOOL }, - # Simulation + # Input Sender { - "name": "netfox/simulation/history_limit", - "value": 64, + "name": "netfox/input_sender/input_redundancy", + "value": 3, "type" : TYPE_INT }, - { - "name": "netfox/simulation/input_redundancy", - "value": 3, + { + "name": "netfox/input_sender/history_limit", + "value": 64, "type" : TYPE_INT - } + }, ] const AUTOLOADS: Array[Dictionary] = [ @@ -265,6 +265,12 @@ const TYPES: Array[Dictionary] = [ "script": ROOT + "/rollback/predictive-synchronizer.gd", "icon": ROOT + "/icons/predictive-synchronizer.svg" }, + { + "name": "InputSender", + "base": "Node", + "script": ROOT + "/simulation/input_sender.gd", + "icon": ROOT + "/icons/input-sender.svg" + }, ] func _enter_tree(): diff --git a/addons/netfox/servers/network-history-server.gd b/addons/netfox/servers/network-history-server.gd index ef0213fac..d3754833e 100644 --- a/addons/netfox/servers/network-history-server.gd +++ b/addons/netfox/servers/network-history-server.gd @@ -8,7 +8,7 @@ class_name _NetworkHistoryServer ## History is stored for [br] ## 1- rollback state and inputs, ## 2- syncronized states, -## 3- simulated inputs and states. +## 3- input_sender inputs. ## [br][br] ## Keeping history lets rollback restore earlier game states for resimulation, ## and enables [_NetworkSynchronizationServer] to send diff states by comparing @@ -17,12 +17,11 @@ class_name _NetworkHistoryServer var _rb_input_properties := _PropertyPool.new() var _rb_state_properties := _PropertyPool.new() var _sync_state_properties := _PropertyPool.new() -var _sim_input_properties := _PropertyPool.new() -var _sim_state_properties := _PropertyPool.new() +var _input_sender_properties := _PropertyPool.new() var _rb_history_size := NetworkRollback.history_limit var _sync_history_size := ProjectSettings.get_setting("netfox/state_synchronizer/history_limit", 64) as int -var _sim_history_size := ProjectSettings.get_setting("netfox/simulation/history_limit", 64) as int +var _input_sender_history_size := ProjectSettings.get_setting("netfox/input_sender/history_limit", 64) as int var _ignored_subjects := _Set.new() @@ -30,15 +29,13 @@ var _ignored_subjects := _Set.new() var _rb_input_history := _PerObjectHistory.new(_rb_history_size) var _rb_state_history := _PerObjectHistory.new(_rb_history_size) var _sync_history := _PerObjectHistory.new(_sync_history_size) -var _sim_input_history := _PerObjectHistory.new(_sim_history_size) -var _sim_state_history := _PerObjectHistory.new(_sim_history_size) +var _input_sender_history := _PerObjectHistory.new(_input_sender_history_size) # Cached snapshots for syncing var _rb_input_snapshots := _HistoryBuffer.new(_rb_history_size) var _rb_state_snapshots := _HistoryBuffer.new(_rb_history_size) var _sync_state_snapshots := _HistoryBuffer.new(_sync_history_size) -var _sim_input_snapshots := _HistoryBuffer.new(_sim_history_size) -var _sim_state_snapshots := _HistoryBuffer.new(_sim_history_size) +var _input_sender_snapshots := _HistoryBuffer.new(_input_sender_history_size) static var _logger := NetfoxLogger._for_netfox("NetworkHistoryServer") @@ -66,13 +63,13 @@ func register_sync_state(node: Node, property: NodePath) -> void: func deregister_sync_state(node: Node, property: NodePath) -> void: _sync_state_properties.erase(node, property) -## Register a simulated input property -func register_simulated_input(node : Node, property : NodePath) -> void: - _sim_input_properties.add(node, property) +## Register a input_sender input property +func register_input_sender_input(node : Node, property : NodePath) -> void: + _input_sender_properties.add(node, property) -## Register a simulated state property -func register_simulated_state(node : Node, property : NodePath) -> void: - _sim_state_properties.add(node, property) +## Deregister a input_sender input propert +func deregister_input_sender_input(node : Node, property : NodePath) -> void: + _input_sender_properties.erase(node, property) ## Deregister a node, no longer tracking any property it had registered using ## any of the [code]register_*()[/code] methods @@ -81,18 +78,18 @@ func deregister(node: Node) -> void: _rb_state_properties.erase_subject(node) _rb_input_properties.erase_subject(node) _sync_state_properties.erase_subject(node) - _sim_state_properties.erase_subject(node) + _input_sender_properties.erase_subject(node) # Erase from per-object history _rb_state_history.erase_subject(node) _rb_input_history.erase_subject(node) _sync_history.erase_subject(node) - _sim_state_history.erase_subject(node) - _sim_input_history.erase_subject(node) + _input_sender_history.erase_subject(node) # Erase from per-tick history - for history in [_rb_state_snapshots, _rb_input_snapshots, _sync_state_snapshots,\ - _sim_state_snapshots, _sim_input_snapshots]: + for history in [_rb_state_snapshots, _rb_input_snapshots,\ + _sync_state_snapshots, _input_sender_snapshots]: + for value in history.values(): var snapshot := value as _Snapshot snapshot.erase_subject(node) @@ -182,6 +179,12 @@ func _record_sync_state(tick: int) -> void: return subject.is_multiplayer_authority() ) +func _record_input_sender_input(tick: int) -> void: + _record(tick, _input_sender_history, _input_sender_snapshots, _input_sender_properties,\ + true, func(subject: Node): + return subject.is_multiplayer_authority() + ) + func _restore_rollback_input(tick: int) -> bool: return _restore_latest(tick, _rb_input_history) @@ -191,6 +194,9 @@ func _restore_rollback_state(tick: int) -> bool: func _restore_synchronizer_state(tick: int) -> bool: return _restore_latest(tick, _sync_history) +func _restore_input_sender_inputs(tick : int) -> bool: + return _restore_latest(tick, _input_sender_history) + func _get_rollback_input_snapshot(tick: int) -> _Snapshot: return _rb_input_snapshots.get_at(tick) @@ -200,11 +206,8 @@ func _get_rollback_state_snapshot(tick: int) -> _Snapshot: func _get_synchronizer_state_snapshot(tick: int) -> _Snapshot: return _sync_state_snapshots.get_at(tick) -func _get_simulation_input_snapshot(tick : int) -> _Snapshot: - return _sim_input_snapshots.get_at(tick) - -func _get_simulation_state_snapshot(tick : int) -> _Snapshot: - return _sim_state_snapshots.get_at(tick) +func _get_input_sender_input_snapshot(tick : int) -> _Snapshot: + return _input_sender_snapshots.get_at(tick) func _merge_rollback_input(snapshot: _Snapshot) -> bool: _merge_snapshot(snapshot, _rb_input_snapshots, true) @@ -218,6 +221,10 @@ func _merge_synchronizer_state(snapshot: _Snapshot) -> bool: _merge_snapshot(snapshot, _sync_state_snapshots, true) return _merge_history(snapshot, _sync_history) +func _merge_input_sender_input(snapshot: _Snapshot) -> bool: + _merge_snapshot(snapshot, _input_sender_snapshots, true) + return _merge_history(snapshot, _input_sender_history) + func _record(tick: int, history: _PerObjectHistory, snapshots: _HistoryBuffer, property_pool: _PropertyPool, only_auth: bool, auth_filter: Callable) -> void: var snapshot := snapshots.get_at(tick, _Snapshot.new(tick)) as _Snapshot if not snapshots.has_at(tick): diff --git a/addons/netfox/servers/network-synchronization-server.gd b/addons/netfox/servers/network-synchronization-server.gd index 0f49cb877..6d4d66199 100644 --- a/addons/netfox/servers/network-synchronization-server.gd +++ b/addons/netfox/servers/network-synchronization-server.gd @@ -29,8 +29,8 @@ var _rb_owned_input_properties := _PropertyPool.new() var _rb_owned_state_properties := _PropertyPool.new() var _sync_state_properties := _PropertyPool.new() var _sync_owned_state_properties := _PropertyPool.new() -var _sim_input_properties := _PropertyPool.new() -var _sim_owned_input_properties := _PropertyPool.new() +var _input_sender_input_properties := _PropertyPool.new() +var _input_sender_owned_input_properties := _PropertyPool.new() var _visibility_filters := {} # Node to PeerVisibilityFilter @@ -40,7 +40,7 @@ var _rb_full_interval := ProjectSettings.get_setting("netfox/rollback/full_state var _rb_full_scheduler := _IntervalScheduler.new(_rb_full_interval) var _rb_input_redundancy := NetworkRollback.input_redundancy -var _sim_input_redundancy := ProjectSettings.get_setting("netfox/simulation/history_limit", 3) as int +var _input_sender_input_redundancy := ProjectSettings.get_setting("netfox/input_sender/input_redundancy", 3) as int var _last_sync_state_sent := _Snapshot.new(0) var _sync_enable_diffs := ProjectSettings.get_setting("netfox/state_synchronizer/enable_diff_states", true) as bool @@ -60,6 +60,7 @@ var _redundant_serializer: _RedundantSnapshotSerializer var _cmd_full_state: NetworkCommandServer.Command var _cmd_diff_state: NetworkCommandServer.Command var _cmd_input: NetworkCommandServer.Command +var _cmd_input_sender_input : NetworkCommandServer.Command var _cmd_full_sync: NetworkCommandServer.Command var _cmd_diff_sync: NetworkCommandServer.Command @@ -67,6 +68,7 @@ var _cmd_diff_sync: NetworkCommandServer.Command static var _logger := NetfoxLogger._for_netfox("NetworkSynchronizationServer") signal _on_input(snapshot: _Snapshot) +signal _on_input_sender_input(snapshot : _Snapshot) signal _on_state(snapshot: _Snapshot) ## Register a [param property] of [param node] to be synchronized @@ -109,17 +111,17 @@ func deregister_sync_state(node: Node, property: NodePath) -> void: _sync_owned_state_properties.erase(node, property) ## Register a [param property] of [param node] to be synchronized -## as simulated input -func register_simulation_input(node: Node, property: NodePath) -> void: - _sim_input_properties.add(node, property) +## as input_sender input +func register_input_sender_input(node: Node, property: NodePath) -> void: + _input_sender_input_properties.add(node, property) if node.is_multiplayer_authority(): - _sim_owned_input_properties.add(node, property) + _input_sender_owned_input_properties.add(node, property) ## Deregister a [param property] of [param node] from being synchronized -## as simulated input -func deregister_simulation_input(node: Node, property: NodePath) -> void: - _sim_input_properties.erase(node, property) - _sim_owned_input_properties.erase(node, property) +## as input_sender input +func deregister_input_sender_input(node: Node, property: NodePath) -> void: + _input_sender_input_properties.erase(node, property) + _input_sender_owned_input_properties.erase(node, property) ## Register a [param serializer] to use when transmitting ## [param property param] of [param node] over the network @@ -152,8 +154,8 @@ func deregister(node: Node) -> void: _rb_owned_input_properties.erase_subject(node) _sync_state_properties.erase_subject(node) _sync_owned_state_properties.erase_subject(node) - _sim_input_properties.erase_subject(node) - _sim_owned_input_properties.erase_subject(node) + _input_sender_input_properties.erase_subject(node) + _input_sender_owned_input_properties.erase_subject(node) _visibility_filters.erase(node) _schemas.erase_subject(node) @@ -166,15 +168,12 @@ func _is_node_visible_to(peer: int, node: Node) -> bool: func _synchronize_input(tick: int) -> void: # We don't own inputs, nothing to synchronize - if _rb_owned_input_properties.is_empty() and _sim_owned_input_properties.is_empty(): + if _rb_owned_input_properties.is_empty(): return - var rb_snapshots := [] as Array[_Snapshot] - var sim_snapshots := [] as Array[_Snapshot] + var snapshots := [] as Array[_Snapshot] var notified_peers := _Set.new() - - ## TODO Handle notified peers for simulator changes? - + if not _rb_enable_input_broadcast: # If input broadcast is off, find which peers need to know our inputs # That is all peers who own state controlled by our input @@ -196,30 +195,18 @@ func _synchronize_input(tick: int) -> void: notified_peers.erase(multiplayer.get_unique_id()) # Prepare snapshot package - # First rollback inputs. for offset in _rb_input_redundancy: - # Grab rollback snapshot from NetworkHistoryServer - var rollback_snapshot := NetworkHistoryServer._get_rollback_input_snapshot(tick - offset) - if not rollback_snapshot: - break - - _logger.trace("Submitting rollback input: %s", [rollback_snapshot]) - rb_snapshots.append(rollback_snapshot) - - # Now prepare simulation inputs. - for offset in _sim_input_redundancy: - # Grab simulation snapshot from NetworkHistoryServer - var simulation_snapshot := NetworkHistoryServer._get_simulation_input_snapshot(tick - offset) - if not simulation_snapshot: + # Grab snapshot from NetworkHistoryServer + var snapshot := NetworkHistoryServer._get_rollback_input_snapshot(tick - offset) + if not snapshot: break - - _logger.trace("Submitting simulation input: %s", [simulation_snapshot]) - sim_snapshots.append(simulation_snapshot) - + + _logger.trace("Submitting input: %s", [snapshot]) + snapshots.append(snapshot) + _logger.trace("Submitting input to peers: %s", [notified_peers]) for peer in notified_peers: - var data := _redundant_serializer.write_for(peer, rb_snapshots, _rb_owned_input_properties) - data.append_array(_redundant_serializer.write_for(peer, sim_snapshots, _sim_owned_input_properties)) + var data := _redundant_serializer.write_for(peer, snapshots, _rb_owned_input_properties) _cmd_input.send(data, peer) func _synchronize_state(tick: int) -> void: @@ -319,6 +306,8 @@ func _synchronize_sync_state(tick: int) -> void: # NOTE: This is a shared instance, theoretically shouldn't screw things up _last_sync_state_sent = snapshot +## TODO Add syncronize input_sender input here. + func _init( p_command_server: _NetworkCommandServer = null, p_history_server: _NetworkHistoryServer = null, @@ -350,10 +339,25 @@ func _ready(): _cmd_full_state = _command_server.register_command(_handle_full_state, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) _cmd_diff_state = _command_server.register_command(_handle_diff_state, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) _cmd_input = _command_server.register_command(_handle_input, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) + _cmd_input_sender_input = _command_server.register_command(_handle_input_sender_input, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) _cmd_full_sync = _command_server.register_command(_handle_full_sync, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE_ORDERED) _cmd_diff_sync = _command_server.register_command(_handle_diff_sync, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE_ORDERED) +func _handle_input_sender_input(sender : int, data : PackedByteArray) -> void: + var buffer := StreamPeerBuffer.new() + buffer.data_array = data + + var snapshots := _redundant_serializer.read_from(sender, _input_sender_input_properties, buffer, true) + + for snapshot in snapshots: + snapshot.sanitize(sender) + + _logger.trace("Ingesting input_sender inputs: %s", [snapshot]) + # TODO Handle Network History Server to merge input_sender inputs. + if NetworkHistoryServer._merge_input_sender_input(snapshot): + _on_input_sender_input.emit(snapshot) + func _handle_input(sender: int, data: PackedByteArray): var buffer := StreamPeerBuffer.new() buffer.data_array = data diff --git a/addons/netfox/simulation/input_sender.gd b/addons/netfox/simulation/input_sender.gd deleted file mode 100644 index db41e0b48..000000000 --- a/addons/netfox/simulation/input_sender.gd +++ /dev/null @@ -1,52 +0,0 @@ -@tool -extends Node -class_name InputSender - -## Stores inputs and sends them to server. -## [br][br] -## [InputSender] can be used alone or with [Simulator]. - -## The root node for resolving node paths in inputs. Defaults to the parent node. -@export var root: Node = get_parent() - -@export_group("Input") -## 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] - -@onready var _logger: NetfoxLogger = NetfoxLogger._for_netfox("InputSender:" + root.name) - -var _input_properties := _PropertyPool.new() - -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. -## [br][br] -## Call this after any change to configuration. Updates based on authority too -## ( calls process_authority ). -func process_settings() -> void: - - # First, deregister what might be registered. - - - - pass - -## Process settings based on authority. -## [br][br] -## Call this whenever the authority of any of the input nodes change. -## Make sure to do this at the same time on all peers. -func process_authority(): - # Deregister all recorded inputs - for node in _input_properties.get_subjects(): - for property in _input_properties.get_properties_of(node): - NetworkHistoryServer.deregister_rollback_input(node, property) - NetworkSynchronizationServer.deregister_rollback_input(node, property) From f3d5dac878702a79d0556534b39b796d0eed5998 Mon Sep 17 00:00:00 2001 From: tuysuztavsan Date: Wed, 29 Apr 2026 03:34:42 +0300 Subject: [PATCH 03/57] prepared an example scene where we can test our new features with style. --- addons/netfox/netfox.gd | 2 +- .../scenes/server_side_tank.tscn | 267 +++++++++++++ .../scenes/server_side_vehicle_example.tscn | 33 ++ .../scenes/tank_arena.tscn | 352 ++++++++++++++++++ .../scripts/player_spawner.gd | 69 ++++ .../scripts/server_side_tank.gd | 19 + 6 files changed, 741 insertions(+), 1 deletion(-) create mode 100644 examples/server-side-vehicle/scenes/server_side_tank.tscn create mode 100644 examples/server-side-vehicle/scenes/server_side_vehicle_example.tscn create mode 100644 examples/server-side-vehicle/scenes/tank_arena.tscn create mode 100644 examples/server-side-vehicle/scripts/player_spawner.gd create mode 100644 examples/server-side-vehicle/scripts/server_side_tank.gd diff --git a/addons/netfox/netfox.gd b/addons/netfox/netfox.gd index 85c1d05dc..509358df6 100644 --- a/addons/netfox/netfox.gd +++ b/addons/netfox/netfox.gd @@ -268,7 +268,7 @@ const TYPES: Array[Dictionary] = [ { "name": "InputSender", "base": "Node", - "script": ROOT + "/simulation/input_sender.gd", + "script": ROOT + "/input_sender.gd", "icon": ROOT + "/icons/input-sender.svg" }, ] diff --git a/examples/server-side-vehicle/scenes/server_side_tank.tscn b/examples/server-side-vehicle/scenes/server_side_tank.tscn new file mode 100644 index 000000000..0827d67b6 --- /dev/null +++ b/examples/server-side-vehicle/scenes/server_side_tank.tscn @@ -0,0 +1,267 @@ +[gd_scene load_steps=7 format=3 uid="uid://f1annxuory74"] + +[ext_resource type="Script" path="res://addons/netfox/input_sender.gd" id="1_c04if"] +[ext_resource type="Script" path="res://examples/server-side-vehicle/scripts/server_side_tank.gd" id="1_jtlcb"] + +[sub_resource type="BoxShape3D" id="BoxShape3D_tqf64"] +size = Vector3(2.9885, 1.6003, 4.71182) + +[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_sclqb"] +albedo_color = Color(0.188235, 0.188235, 0.188235, 1) +metallic = 0.7 + +[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_m7l4t"] +albedo_color = Color(0.462745, 0.462745, 0.462745, 1) +metallic = 0.79 + +[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_g1au6"] +albedo_color = Color(0.466667, 0.466667, 0.466667, 1) +metallic = 0.45 + +[node name="ServerSideTank" type="VehicleBody3D"] +mass = 750.0 +center_of_mass_mode = 1 +center_of_mass = Vector3(0, 1.2, 0) +linear_damp = 0.13 +angular_damp = 0.5 +script = ExtResource("1_jtlcb") + +[node name="InputSender" type="Node" parent="." node_paths=PackedStringArray("root")] +script = ExtResource("1_c04if") +root = NodePath("..") + +[node name="Camera3D" type="Camera3D" parent="."] +transform = Transform3D(-0.999957, -0.0017973, 0.00912711, -1.68907e-08, 0.981158, 0.193207, -0.00930239, 0.193199, -0.981116, 0, 4.67115, -4.54559) + +[node name="CollisionShape3D" type="CollisionShape3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.29733, 0.0841393) +shape = SubResource("BoxShape3D_tqf64") + +[node name="Body" type="CSGMesh3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.0007, 0.0944731) +material_override = SubResource("StandardMaterial3D_sclqb") + +[node name="CSGBox3D" type="CSGBox3D" parent="Body"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.690109, 0) +size = Vector3(3, 1.62169, 4.90454) + +[node name="CSGBox3D2" type="CSGBox3D" parent="Body"] +transform = Transform3D(1, 0, 0, 0, 0.561549, 0.827443, 0, -0.827443, 0.561549, 0, 0, 2.24295) +operation = 2 +size = Vector3(3.44184, 2.19637, 1) + +[node name="CSGBox3D3" type="CSGBox3D" parent="Body"] +transform = Transform3D(1, 0, 0, 0, 0.913183, -0.40755, 0, 0.40755, 0.913183, 0, -0.000799894, -2.73253) +operation = 2 +size = Vector3(3.35521, 1.22864, 1) + +[node name="Turret" type="CSGMesh3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.4627, 0) +material_override = SubResource("StandardMaterial3D_m7l4t") + +[node name="CSGBox3D" type="CSGBox3D" parent="Turret"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, -0.24471) +size = Vector3(2, 0.75, 2) + +[node name="Barrel" type="CSGCylinder3D" parent="Turret"] +transform = Transform3D(1, 0, 0, 0, -4.37114e-08, -1, 0, 1, -4.37114e-08, 0, 0, 2.6253) +radius = 0.15 +height = 4.0 +sides = 16 + +[node name="VehicleWheel3D" type="VehicleWheel3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.6, 0.6, 2.14) +use_as_traction = true +use_as_steering = true +wheel_roll_influence = 1.0 +wheel_radius = 0.6 +suspension_travel = 0.1 +suspension_stiffness = 50.0 +damping_compression = 0.3 +damping_relaxation = 0.5 + +[node name="Wheel" type="CSGMesh3D" parent="VehicleWheel3D"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, -0.000132322) +material_override = SubResource("StandardMaterial3D_g1au6") + +[node name="CSGCylinder3D" type="CSGCylinder3D" parent="VehicleWheel3D/Wheel"] +transform = Transform3D(-4.37114e-08, 1, 0, -1, -4.37114e-08, 0, 0, 0, 1, 0, 0, 0) +radius = 0.6 +height = 0.5 +sides = 16 + +[node name="CSGCylinder3D2" type="CSGCylinder3D" parent="VehicleWheel3D/Wheel"] +transform = Transform3D(-4.37114e-08, 1, 0, -1, -4.37114e-08, 0, 0, 0, 1, 0, 0, 0) +radius = 0.2 +height = 0.8 + +[node name="VehicleWheel3D2" type="VehicleWheel3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.6, 0.6, 0.766) +use_as_traction = true +wheel_roll_influence = 1.0 +wheel_radius = 0.6 +suspension_travel = 0.1 +suspension_stiffness = 50.0 +damping_compression = 0.3 +damping_relaxation = 0.5 + +[node name="Wheel2" type="CSGMesh3D" parent="VehicleWheel3D2"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0.000470459) +material_override = SubResource("StandardMaterial3D_g1au6") + +[node name="CSGCylinder3D" type="CSGCylinder3D" parent="VehicleWheel3D2/Wheel2"] +transform = Transform3D(-4.37114e-08, 1, 0, -1, -4.37114e-08, 0, 0, 0, 1, 0, 0, 0) +radius = 0.6 +height = 0.5 +sides = 16 + +[node name="CSGCylinder3D2" type="CSGCylinder3D" parent="VehicleWheel3D2/Wheel2"] +transform = Transform3D(-4.37114e-08, 1, 0, -1, -4.37114e-08, 0, 0, 0, 1, 0, 0, 0) +radius = 0.2 +height = 0.8 + +[node name="VehicleWheel3D3" type="VehicleWheel3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.6, 0.6, -0.618) +use_as_traction = true +wheel_roll_influence = 1.0 +wheel_radius = 0.6 +suspension_travel = 0.1 +suspension_stiffness = 50.0 +damping_compression = 0.3 +damping_relaxation = 0.5 + +[node name="Wheel3" type="CSGMesh3D" parent="VehicleWheel3D3"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, -3.03984e-06) +material_override = SubResource("StandardMaterial3D_g1au6") + +[node name="CSGCylinder3D" type="CSGCylinder3D" parent="VehicleWheel3D3/Wheel3"] +transform = Transform3D(-4.37114e-08, 1, 0, -1, -4.37114e-08, 0, 0, 0, 1, 0, 0, 0) +radius = 0.6 +height = 0.5 +sides = 16 + +[node name="CSGCylinder3D2" type="CSGCylinder3D" parent="VehicleWheel3D3/Wheel3"] +transform = Transform3D(-4.37114e-08, 1, 0, -1, -4.37114e-08, 0, 0, 0, 1, 0, 0, 0) +radius = 0.2 +height = 0.8 + +[node name="VehicleWheel3D4" type="VehicleWheel3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.6, 0.6, -1.9835) +use_as_traction = true +wheel_roll_influence = 1.0 +wheel_radius = 0.6 +suspension_travel = 0.1 +suspension_stiffness = 50.0 +damping_compression = 0.3 +damping_relaxation = 0.5 + +[node name="Wheel4" type="CSGMesh3D" parent="VehicleWheel3D4"] +material_override = SubResource("StandardMaterial3D_g1au6") + +[node name="CSGCylinder3D" type="CSGCylinder3D" parent="VehicleWheel3D4/Wheel4"] +transform = Transform3D(-4.37114e-08, 1, 0, -1, -4.37114e-08, 0, 0, 0, 1, 0, 0, 0) +radius = 0.6 +height = 0.5 +sides = 16 + +[node name="CSGCylinder3D2" type="CSGCylinder3D" parent="VehicleWheel3D4/Wheel4"] +transform = Transform3D(-4.37114e-08, 1, 0, -1, -4.37114e-08, 0, 0, 0, 1, 0, 0, 0) +radius = 0.2 +height = 0.8 + +[node name="VehicleWheel3D5" type="VehicleWheel3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.6, 0.6, 2.13987) +use_as_traction = true +use_as_steering = true +wheel_roll_influence = 1.0 +wheel_radius = 0.6 +suspension_travel = 0.1 +suspension_stiffness = 50.0 +damping_compression = 0.3 +damping_relaxation = 0.5 + +[node name="Wheel5" type="CSGMesh3D" parent="VehicleWheel3D5"] +material_override = SubResource("StandardMaterial3D_g1au6") + +[node name="CSGCylinder3D" type="CSGCylinder3D" parent="VehicleWheel3D5/Wheel5"] +transform = Transform3D(-4.37114e-08, 1, 0, -1, -4.37114e-08, 0, 0, 0, 1, 0, 0, 0) +radius = 0.6 +height = 0.5 +sides = 16 + +[node name="CSGCylinder3D2" type="CSGCylinder3D" parent="VehicleWheel3D5/Wheel5"] +transform = Transform3D(-4.37114e-08, 1, 0, -1, -4.37114e-08, 0, 0, 0, 1, 0, 0, 0) +radius = 0.2 +height = 0.8 + +[node name="VehicleWheel3D6" type="VehicleWheel3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.6, 0.6, 0.76647) +use_as_traction = true +wheel_roll_influence = 1.0 +wheel_radius = 0.6 +suspension_travel = 0.1 +suspension_stiffness = 50.0 +damping_compression = 0.3 +damping_relaxation = 0.5 + +[node name="Wheel6" type="CSGMesh3D" parent="VehicleWheel3D6"] +material_override = SubResource("StandardMaterial3D_g1au6") + +[node name="CSGCylinder3D" type="CSGCylinder3D" parent="VehicleWheel3D6/Wheel6"] +transform = Transform3D(-4.37114e-08, 1, 0, -1, -4.37114e-08, 0, 0, 0, 1, 0, 0, 0) +radius = 0.6 +height = 0.5 +sides = 16 + +[node name="CSGCylinder3D2" type="CSGCylinder3D" parent="VehicleWheel3D6/Wheel6"] +transform = Transform3D(-4.37114e-08, 1, 0, -1, -4.37114e-08, 0, 0, 0, 1, 0, 0, 0) +radius = 0.2 +height = 0.8 + +[node name="VehicleWheel3D7" type="VehicleWheel3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.6, 0.6, -0.618003) +use_as_traction = true +wheel_roll_influence = 1.0 +wheel_radius = 0.6 +suspension_travel = 0.1 +suspension_stiffness = 50.0 +damping_compression = 0.3 +damping_relaxation = 0.5 + +[node name="Wheel7" type="CSGMesh3D" parent="VehicleWheel3D7"] +material_override = SubResource("StandardMaterial3D_g1au6") + +[node name="CSGCylinder3D" type="CSGCylinder3D" parent="VehicleWheel3D7/Wheel7"] +transform = Transform3D(-4.37114e-08, 1, 0, -1, -4.37114e-08, 0, 0, 0, 1, 0, 0, 0) +radius = 0.6 +height = 0.5 +sides = 16 + +[node name="CSGCylinder3D2" type="CSGCylinder3D" parent="VehicleWheel3D7/Wheel7"] +transform = Transform3D(-4.37114e-08, 1, 0, -1, -4.37114e-08, 0, 0, 0, 1, 0, 0, 0) +radius = 0.2 +height = 0.8 + +[node name="VehicleWheel3D8" type="VehicleWheel3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.6, 0.6, -1.9835) +use_as_traction = true +wheel_roll_influence = 1.0 +wheel_radius = 0.6 +suspension_travel = 0.1 +suspension_stiffness = 50.0 +damping_compression = 0.3 +damping_relaxation = 0.5 + +[node name="Wheel8" type="CSGMesh3D" parent="VehicleWheel3D8"] +material_override = SubResource("StandardMaterial3D_g1au6") + +[node name="CSGCylinder3D" type="CSGCylinder3D" parent="VehicleWheel3D8/Wheel8"] +transform = Transform3D(-4.37114e-08, 1, 0, -1, -4.37114e-08, 0, 0, 0, 1, 0, 0, 0) +radius = 0.6 +height = 0.5 +sides = 16 + +[node name="CSGCylinder3D2" type="CSGCylinder3D" parent="VehicleWheel3D8/Wheel8"] +transform = Transform3D(-4.37114e-08, 1, 0, -1, -4.37114e-08, 0, 0, 0, 1, 0, 0, 0) +radius = 0.2 +height = 0.8 diff --git a/examples/server-side-vehicle/scenes/server_side_vehicle_example.tscn b/examples/server-side-vehicle/scenes/server_side_vehicle_example.tscn new file mode 100644 index 000000000..f3a18d45c --- /dev/null +++ b/examples/server-side-vehicle/scenes/server_side_vehicle_example.tscn @@ -0,0 +1,33 @@ +[gd_scene load_steps=5 format=3 uid="uid://g5axutycr4rn"] + +[ext_resource type="PackedScene" uid="uid://badtpsxn5lago" path="res://examples/shared/ui/network-popup.tscn" id="1_h2lt4"] +[ext_resource type="PackedScene" uid="uid://cf4xd6s672bo6" path="res://examples/server-side-vehicle/scenes/tank_arena.tscn" id="2_t7ktp"] +[ext_resource type="Script" path="res://examples/server-side-vehicle/scripts/player_spawner.gd" id="3_55dug"] +[ext_resource type="PackedScene" uid="uid://f1annxuory74" path="res://examples/server-side-vehicle/scenes/server_side_tank.tscn" id="4_rnbcl"] + +[node name="ServerSideVehicleExample" type="Node"] + +[node name="Network Popup" parent="." instance=ExtResource("1_h2lt4")] + +[node name="TankArena" parent="." instance=ExtResource("2_t7ktp")] + +[node name="Network" type="Node" parent="."] + +[node name="PlayerSpawner" type="Node" parent="Network" node_paths=PackedStringArray("spawn_points")] +script = ExtResource("3_55dug") +player_scene = ExtResource("4_rnbcl") +spawn_points = [NodePath("../../SpawnPoints/Marker3D"), NodePath("../../SpawnPoints/Marker3D2"), NodePath("../../SpawnPoints/Marker3D3"), NodePath("../../SpawnPoints/Marker3D4")] + +[node name="SpawnPoints" type="Node3D" parent="."] + +[node name="Marker3D" type="Marker3D" parent="SpawnPoints"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 20.285, 1, 16.224) + +[node name="Marker3D2" type="Marker3D" parent="SpawnPoints"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 26.6316, 1, -26.2491) + +[node name="Marker3D3" type="Marker3D" parent="SpawnPoints"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -19.381, 1, -30.0326) + +[node name="Marker3D4" type="Marker3D" parent="SpawnPoints"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -6.68788, 1, 12.6846) diff --git a/examples/server-side-vehicle/scenes/tank_arena.tscn b/examples/server-side-vehicle/scenes/tank_arena.tscn new file mode 100644 index 000000000..827400f4d --- /dev/null +++ b/examples/server-side-vehicle/scenes/tank_arena.tscn @@ -0,0 +1,352 @@ +[gd_scene load_steps=11 format=3 uid="uid://cf4xd6s672bo6"] + +[sub_resource type="PhysicalSkyMaterial" id="PhysicalSkyMaterial_e1ky4"] +rayleigh_coefficient = 4.0 +turbidity = 324.81 +ground_color = Color(0.396078, 0.356863, 0.290196, 1) + +[sub_resource type="Sky" id="Sky_7krho"] +sky_material = SubResource("PhysicalSkyMaterial_e1ky4") + +[sub_resource type="Environment" id="Environment_gm8cr"] +background_mode = 2 +sky = SubResource("Sky_7krho") +fog_light_color = Color(0.54902, 0.584314, 0.741176, 1) +fog_light_energy = 2.1 + +[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_lr3b6"] +albedo_color = Color(0.576471, 0.239216, 0.341176, 1) + +[sub_resource type="BoxShape3D" id="BoxShape3D_47cjw"] +size = Vector3(75, 1, 75) + +[sub_resource type="BoxShape3D" id="BoxShape3D_1e1cg"] +size = Vector3(1, 4, 76) + +[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_e5vwx"] +albedo_color = Color(0.305882, 0.478431, 0.835294, 1) + +[sub_resource type="BoxShape3D" id="BoxShape3D_8or65"] +size = Vector3(1, 4, 10) + +[sub_resource type="BoxShape3D" id="BoxShape3D_av0i6"] +size = Vector3(1, 4, 35) + +[sub_resource type="BoxShape3D" id="BoxShape3D_ktkvh"] +size = Vector3(1, 2, 5) + +[node name="TankArena" type="Node3D"] + +[node name="WorldEnvironment" type="WorldEnvironment" parent="."] +environment = SubResource("Environment_gm8cr") + +[node name="DirectionalLight3D" type="DirectionalLight3D" parent="."] +transform = Transform3D(0.923136, -0.326969, -0.202263, -0.0453632, -0.615034, 0.787194, -0.381787, -0.717513, -0.582593, 0, 1.32503, 0) +light_color = Color(0.996078, 0.992157, 0.988235, 1) +shadow_enabled = true + +[node name="DirectionalLight3D2" type="DirectionalLight3D" parent="."] +transform = Transform3D(0.483696, 0.725813, 0.489116, -0.00508283, -0.5565, 0.830832, 0.875221, -0.404356, -0.265487, 0, 1.32503, 0) +light_color = Color(0.94902, 0.870588, 0.854902, 1) +light_energy = 0.5 +shadow_enabled = true + +[node name="Ground" type="StaticBody3D" parent="."] + +[node name="Ground" type="CSGBox3D" parent="Ground"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.5, 0) +material_override = SubResource("StandardMaterial3D_lr3b6") +size = Vector3(75, 1, 75) + +[node name="CollisionShape3D" type="CollisionShape3D" parent="Ground"] +shape = SubResource("BoxShape3D_47cjw") + +[node name="Walls" type="Node3D" parent="."] + +[node name="RegularWall" type="StaticBody3D" parent="Walls"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 38, 2, 0) + +[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/RegularWall"] +shape = SubResource("BoxShape3D_1e1cg") + +[node name="RegularWall11" type="CSGBox3D" parent="Walls/RegularWall"] +material_override = SubResource("StandardMaterial3D_e5vwx") +size = Vector3(1, 4, 76) + +[node name="RegularWall11" type="StaticBody3D" parent="Walls"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -38, 2, 0) + +[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/RegularWall11"] +shape = SubResource("BoxShape3D_1e1cg") + +[node name="RegularWall11" type="CSGBox3D" parent="Walls/RegularWall11"] +material_override = SubResource("StandardMaterial3D_e5vwx") +size = Vector3(1, 4, 76) + +[node name="RegularWall12" type="StaticBody3D" parent="Walls"] +transform = Transform3D(-4.37114e-08, 0, -1, 0, 1, 0, 1, 0, -4.37114e-08, 0, 2, -38) + +[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/RegularWall12"] +shape = SubResource("BoxShape3D_1e1cg") + +[node name="RegularWall11" type="CSGBox3D" parent="Walls/RegularWall12"] +material_override = SubResource("StandardMaterial3D_e5vwx") +size = Vector3(1, 4, 76) + +[node name="RegularWall13" type="StaticBody3D" parent="Walls"] +transform = Transform3D(-4.37114e-08, 0, -1, 0, 1, 0, 1, 0, -4.37114e-08, 0, 2, 38) + +[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/RegularWall13"] +shape = SubResource("BoxShape3D_1e1cg") + +[node name="RegularWall11" type="CSGBox3D" parent="Walls/RegularWall13"] +material_override = SubResource("StandardMaterial3D_e5vwx") +size = Vector3(1, 4, 76) + +[node name="RegularWall18" type="StaticBody3D" parent="Walls"] +transform = Transform3D(0.752783, 0, -0.658269, 0, 1, 0, 0.658269, 0, 0.752783, -25.36, 2, 29.0161) + +[node name="RegularWall9" type="CSGBox3D" parent="Walls/RegularWall18"] +material_override = SubResource("StandardMaterial3D_e5vwx") +size = Vector3(1, 4, 10) + +[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/RegularWall18"] +shape = SubResource("BoxShape3D_8or65") + +[node name="RegularWall19" type="StaticBody3D" parent="Walls"] +transform = Transform3D(0.0668312, 0, -0.997764, 0, 1, 0, 0.997764, 0, 0.0668312, 32.9951, 2, 13.5674) + +[node name="RegularWall9" type="CSGBox3D" parent="Walls/RegularWall19"] +material_override = SubResource("StandardMaterial3D_e5vwx") +size = Vector3(1, 4, 10) + +[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/RegularWall19"] +shape = SubResource("BoxShape3D_8or65") + +[node name="LongWall" type="StaticBody3D" parent="Walls"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -20.8742, 2, 0) + +[node name="RegularWall9" type="CSGBox3D" parent="Walls/LongWall"] +material_override = SubResource("StandardMaterial3D_e5vwx") +size = Vector3(1, 4, 35) + +[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/LongWall"] +shape = SubResource("BoxShape3D_av0i6") + +[node name="LongWall2" type="StaticBody3D" parent="Walls"] +transform = Transform3D(0.861292, 0, -0.50811, 0, 1, 0, 0.50811, 0, 0.861292, 6.95699, 2, -22.6634) + +[node name="RegularWall9" type="CSGBox3D" parent="Walls/LongWall2"] +material_override = SubResource("StandardMaterial3D_e5vwx") +size = Vector3(1, 4, 35) + +[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/LongWall2"] +shape = SubResource("BoxShape3D_av0i6") + +[node name="RegularWall10" type="StaticBody3D" parent="Walls"] +transform = Transform3D(0.866025, 0, -0.5, 0, 1, 0, 0.5, 0, 0.866025, 7.36388, 2, 7.70559) + +[node name="RegularWall9" type="CSGBox3D" parent="Walls/RegularWall10"] +material_override = SubResource("StandardMaterial3D_e5vwx") +size = Vector3(1, 4, 10) + +[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/RegularWall10"] +shape = SubResource("BoxShape3D_8or65") + +[node name="ShortWall12" type="StaticBody3D" parent="Walls"] +transform = Transform3D(0.827414, 0, -0.561592, 0, 1, 0, 0.561592, 0, 0.827414, 27.1415, 2, 15.854) + +[node name="ShortWall" type="CSGBox3D" parent="Walls/ShortWall12"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0) +material_override = SubResource("StandardMaterial3D_e5vwx") +size = Vector3(1, 2, 5) + +[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/ShortWall12"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0) +shape = SubResource("BoxShape3D_ktkvh") + +[node name="RegularWall20" type="StaticBody3D" parent="Walls"] +transform = Transform3D(-0.0668311, 0, 0.997764, 0, 1, 0, -0.997764, 0, -0.0668311, -33.3546, 2, -22.8314) + +[node name="RegularWall9" type="CSGBox3D" parent="Walls/RegularWall20"] +material_override = SubResource("StandardMaterial3D_e5vwx") +size = Vector3(1, 4, 10) + +[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/RegularWall20"] +shape = SubResource("BoxShape3D_8or65") + +[node name="ShortWall13" type="StaticBody3D" parent="Walls"] +transform = Transform3D(-0.827414, 0, 0.561593, 0, 1, 0, -0.561593, 0, -0.827414, -27.5009, 2, -25.118) + +[node name="ShortWall" type="CSGBox3D" parent="Walls/ShortWall13"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0) +material_override = SubResource("StandardMaterial3D_e5vwx") +size = Vector3(1, 2, 5) + +[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/ShortWall13"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0) +shape = SubResource("BoxShape3D_ktkvh") + +[node name="ShortWall2" type="StaticBody3D" parent="Walls"] +transform = Transform3D(-5.96046e-08, 0, -1, 0, 1, 0, 1, 0, -5.96046e-08, 11.9641, 2, 3.66263) + +[node name="ShortWall" type="CSGBox3D" parent="Walls/ShortWall2"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0) +material_override = SubResource("StandardMaterial3D_e5vwx") +size = Vector3(1, 2, 5) + +[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/ShortWall2"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0) +shape = SubResource("BoxShape3D_ktkvh") + +[node name="ShortWall3" type="StaticBody3D" parent="Walls"] +transform = Transform3D(1, 0, -1.58933e-08, 0, 1, 0, 1.58933e-08, 0, 1, 4.91346, 2, 14.2493) + +[node name="ShortWall" type="CSGBox3D" parent="Walls/ShortWall3"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0) +material_override = SubResource("StandardMaterial3D_e5vwx") +size = Vector3(1, 2, 5) + +[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/ShortWall3"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0) +shape = SubResource("BoxShape3D_ktkvh") + +[node name="RegularWall14" type="StaticBody3D" parent="Walls"] +transform = Transform3D(-0.587997, 0, 0.808863, 0, 1, 0, -0.808863, 0, -0.587997, -10.7772, 2, -23.9271) + +[node name="RegularWall9" type="CSGBox3D" parent="Walls/RegularWall14"] +material_override = SubResource("StandardMaterial3D_e5vwx") +size = Vector3(1, 4, 10) + +[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/RegularWall14"] +shape = SubResource("BoxShape3D_8or65") + +[node name="ShortWall4" type="StaticBody3D" parent="Walls"] +transform = Transform3D(0.406498, 0, 0.913652, 0, 1, 0, -0.913652, 0, 0.406498, -16.6237, 2, -22.1033) + +[node name="ShortWall" type="CSGBox3D" parent="Walls/ShortWall4"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0) +material_override = SubResource("StandardMaterial3D_e5vwx") +size = Vector3(1, 2, 5) + +[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/ShortWall4"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0) +shape = SubResource("BoxShape3D_ktkvh") + +[node name="ShortWall5" type="StaticBody3D" parent="Walls"] +transform = Transform3D(-0.913652, 0, 0.406497, 0, 1, 0, -0.406497, 0, -0.913652, -5.87835, 2, -28.9097) + +[node name="ShortWall" type="CSGBox3D" parent="Walls/ShortWall5"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0) +material_override = SubResource("StandardMaterial3D_e5vwx") +size = Vector3(1, 2, 5) + +[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/ShortWall5"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0) +shape = SubResource("BoxShape3D_ktkvh") + +[node name="RegularWall16" type="StaticBody3D" parent="Walls"] +transform = Transform3D(-0.757463, 0, -0.652879, 0, 1, 0, 0.652879, 0, -0.757463, -13.2699, 2, 21.8568) + +[node name="RegularWall9" type="CSGBox3D" parent="Walls/RegularWall16"] +material_override = SubResource("StandardMaterial3D_e5vwx") +size = Vector3(1, 4, 10) + +[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/RegularWall16"] +shape = SubResource("BoxShape3D_8or65") + +[node name="ShortWall8" type="StaticBody3D" parent="Walls"] +transform = Transform3D(-0.944141, 0, 0.329542, 0, 1, 0, -0.329542, 0, -0.944141, -10.9688, 2, 27.5324) + +[node name="ShortWall" type="CSGBox3D" parent="Walls/ShortWall8"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0) +material_override = SubResource("StandardMaterial3D_e5vwx") +size = Vector3(1, 2, 5) + +[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/ShortWall8"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0) +shape = SubResource("BoxShape3D_ktkvh") + +[node name="ShortWall9" type="StaticBody3D" parent="Walls"] +transform = Transform3D(-0.329542, 0, -0.944141, 0, 1, 0, 0.944141, 0, -0.329542, -18.6406, 2, 17.3868) + +[node name="ShortWall" type="CSGBox3D" parent="Walls/ShortWall9"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0) +material_override = SubResource("StandardMaterial3D_e5vwx") +size = Vector3(1, 2, 5) + +[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/ShortWall9"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0) +shape = SubResource("BoxShape3D_ktkvh") + +[node name="RegularWall17" type="StaticBody3D" parent="Walls"] +transform = Transform3D(-0.757463, 0, -0.652879, 0, 1, 0, 0.652879, 0, -0.757463, 12.3245, 2, 21.8568) + +[node name="RegularWall9" type="CSGBox3D" parent="Walls/RegularWall17"] +material_override = SubResource("StandardMaterial3D_e5vwx") +size = Vector3(1, 4, 10) + +[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/RegularWall17"] +shape = SubResource("BoxShape3D_8or65") + +[node name="ShortWall10" type="StaticBody3D" parent="Walls"] +transform = Transform3D(-0.944141, 0, 0.329542, 0, 1, 0, -0.329542, 0, -0.944141, 14.6257, 2, 27.5324) + +[node name="ShortWall" type="CSGBox3D" parent="Walls/ShortWall10"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0) +material_override = SubResource("StandardMaterial3D_e5vwx") +size = Vector3(1, 2, 5) + +[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/ShortWall10"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0) +shape = SubResource("BoxShape3D_ktkvh") + +[node name="ShortWall11" type="StaticBody3D" parent="Walls"] +transform = Transform3D(-0.329542, 0, -0.944141, 0, 1, 0, 0.944141, 0, -0.329542, 6.95385, 2, 17.3868) + +[node name="ShortWall" type="CSGBox3D" parent="Walls/ShortWall11"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0) +material_override = SubResource("StandardMaterial3D_e5vwx") +size = Vector3(1, 2, 5) + +[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/ShortWall11"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0) +shape = SubResource("BoxShape3D_ktkvh") + +[node name="RegularWall15" type="StaticBody3D" parent="Walls"] +transform = Transform3D(-0.871008, 0, -0.491269, 0, 1, 0, 0.491269, 0, -0.871008, 25.1636, 2, -16.4828) + +[node name="RegularWall9" type="CSGBox3D" parent="Walls/RegularWall15"] +material_override = SubResource("StandardMaterial3D_e5vwx") +size = Vector3(1, 4, 10) + +[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/RegularWall15"] +shape = SubResource("BoxShape3D_8or65") + +[node name="ShortWall6" type="StaticBody3D" parent="Walls"] +transform = Transform3D(-0.860955, 0, 0.508681, 0, 1, 0, -0.508681, 0, -0.860955, 26.3044, 2, -10.4656) + +[node name="ShortWall" type="CSGBox3D" parent="Walls/ShortWall6"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0) +material_override = SubResource("StandardMaterial3D_e5vwx") +size = Vector3(1, 2, 5) + +[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/ShortWall6"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0) +shape = SubResource("BoxShape3D_ktkvh") + +[node name="ShortWall7" type="StaticBody3D" parent="Walls"] +transform = Transform3D(-0.508681, 0, -0.860955, 0, 1, 0, 0.860955, 0, -0.508681, 20.7763, 2, -21.9212) + +[node name="ShortWall" type="CSGBox3D" parent="Walls/ShortWall7"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0) +material_override = SubResource("StandardMaterial3D_e5vwx") +size = Vector3(1, 2, 5) + +[node name="CollisionShape3D" type="CollisionShape3D" parent="Walls/ShortWall7"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0) +shape = SubResource("BoxShape3D_ktkvh") + +[node name="Camera3D" type="Camera3D" parent="."] +transform = Transform3D(0.965926, 0.0449435, -0.254887, 0, 0.984808, 0.173648, 0.258819, -0.167731, 0.951251, -13.068, 8.651, 29.624) +current = true diff --git a/examples/server-side-vehicle/scripts/player_spawner.gd b/examples/server-side-vehicle/scripts/player_spawner.gd new file mode 100644 index 000000000..c215438f4 --- /dev/null +++ b/examples/server-side-vehicle/scripts/player_spawner.gd @@ -0,0 +1,69 @@ +extends Node + +## Example player spawner script for server side tank example. + +@export var player_scene: PackedScene +@export var spawn_points: Array[Marker3D] = [] + +var avatars: Dictionary = {} + +func _ready(): + NetworkEvents.on_client_start.connect(_handle_connected) + NetworkEvents.on_server_start.connect(_handle_host) + NetworkEvents.on_peer_join.connect(_handle_new_peer) + NetworkEvents.on_peer_leave.connect(_handle_leave) + NetworkEvents.on_client_stop.connect(_handle_stop) + NetworkEvents.on_server_stop.connect(_handle_stop) + +func _handle_connected(id: int): + # Spawn an avatar for us + _spawn(id) + +func _handle_host(): + # Spawn own avatar on host machine + _spawn(1) + +func _handle_new_peer(id: int): + # Spawn an avatar for new player + _spawn(id) + +func _handle_leave(id: int): + if not avatars.has(id): + return + + var avatar = avatars[id] as Node + avatar.queue_free() + avatars.erase(id) + +func _handle_stop(): + # Remove all avatars on game end + for avatar in avatars.values(): + avatar.queue_free() + avatars.clear() + +func _spawn(id: int): + var avatar = player_scene.instantiate() as Node + avatars[id] = avatar + avatar.name += " #%d" % id + add_child(avatar) + avatar.global_position = get_next_spawn_point(id) + + # Avatar is always owned by server + avatar.set_multiplayer_authority(1) + + print("Spawned avatar %s at %s" % [avatar.name, multiplayer.get_unique_id()]) + + # Avatar's input object is owned by player + var input = avatar.find_child("InputSender") + if input != null: + input.set_multiplayer_authority(id) + print("Set input(%s) ownership to %s" % [input.name, id]) + +func get_next_spawn_point(peer_id: int, spawn_idx: int = 0) -> Vector3: + # The same data is used to calculate the index on all peers + # As a result, spawn points are the same, even without sync + var idx := peer_id * 37 + spawn_idx * 19 + idx = hash(idx) + idx = idx % spawn_points.size() + + return spawn_points[idx].global_position diff --git a/examples/server-side-vehicle/scripts/server_side_tank.gd b/examples/server-side-vehicle/scripts/server_side_tank.gd new file mode 100644 index 000000000..47a35b20e --- /dev/null +++ b/examples/server-side-vehicle/scripts/server_side_tank.gd @@ -0,0 +1,19 @@ +extends VehicleBody3D + +## Script example for server side coded tank. + +@onready var input_sender = $InputSender +@onready var camera_3d = $Camera3D + +# Called when the node enters the scene tree for the first time. +func _ready(): + # Await so that player spawner sets our input authority. + await get_tree().process_frame + + if input_sender.get_multiplayer_authority() == multiplayer.get_unique_id(): + camera_3d.current = true + + +# Called every frame. 'delta' is the elapsed time since the previous frame. +func _process(delta): + pass From 7dc491cce5bba4550e3bbc399c1f7b2246b26171 Mon Sep 17 00:00:00 2001 From: tuysuztavsan Date: Wed, 29 Apr 2026 19:47:05 +0300 Subject: [PATCH 04/57] known client cant move vehicle bug? --- addons/netfox/input_sender.gd | 91 ++++++++++++++++++- addons/netfox/netfox.gd | 5 + .../netfox/servers/network-history-server.gd | 19 ++-- .../servers/network-synchronization-server.gd | 80 ++++++++++++---- .../scenes/server_side_tank.tscn | 18 +++- .../scenes/tank_arena.tscn | 1 + .../scripts/server_side_tank.gd | 44 ++++++++- .../server-side-vehicle/scripts/tank_input.gd | 14 +++ 8 files changed, 238 insertions(+), 34 deletions(-) create mode 100644 examples/server-side-vehicle/scripts/tank_input.gd diff --git a/addons/netfox/input_sender.gd b/addons/netfox/input_sender.gd index 1c16ebbcd..7d42fa0f1 100644 --- a/addons/netfox/input_sender.gd +++ b/addons/netfox/input_sender.gd @@ -6,6 +6,16 @@ class_name InputSender ## [br][br] ## [InputSender] can be used alone or with [Simulator]. +## Emitted when [InputSender] receives input from client on [signal NetworkTime.on_tick] +## [InputSender] handles applying received input internally before emitting this signal. +## Emitted only on hosts. +signal new_input_received(tick : int) + +## Emitted when [InputSender] doesnt receive anything from client on [signal NetworkTime.on_tick] +## [InputSender] handles applying latest known input internally before emitting this signal. +## Emitted only on hosts. +signal input_missing(current_tick : int, latest_known_input_tick : int) + ## The root node for resolving node paths in inputs. Defaults to the parent node. @export var root: Node = get_parent() @@ -24,6 +34,10 @@ var visibility_filter := PeerVisibilityFilter.new() var _input_properties := _PropertyPool.new() var _properties_dirty: bool = false +var _last_emitted_tick: int = -1 + +# Flag to connect signals only once. +var _signals_connected : bool = false func _ready() -> void: if Engine.is_editor_hint(): @@ -62,6 +76,10 @@ func process_settings() -> void: # Register visibility filter for node in _input_properties.get_subjects(): NetworkSynchronizationServer.register_visibility_filter(node, visibility_filter) + + if not _signals_connected: + _connect_signals() + _signals_connected = true ## Process settings based on authority. ## [br][br] @@ -70,7 +88,8 @@ func process_settings() -> void: func process_authority(): for node in _input_properties.get_subjects(): for property in _input_properties.get_properties_of(node): - NetworkHistoryServer.deregister_input_sender_input(node, property) + NetworkHistoryServer.deregister_input_sender(node, property) + NetworkSynchronizationServer.deregister_input_sender(node, property) # Process authority _input_properties.set_from_paths(root, input_properties) @@ -78,7 +97,8 @@ func process_authority(): # Register new recorded inputs for node in _input_properties.get_subjects(): for property in _input_properties.get_properties_of(node): - NetworkHistoryServer.register_input_sender_input(node, property) + NetworkHistoryServer.register_input_sender(node, property) + NetworkSynchronizationServer.register_input_sender(node, property) ## Add an input property. ## [br][br] @@ -129,3 +149,70 @@ func _reprocess_settings() -> void: _properties_dirty = false process_settings() + +func _connect_signals() -> void: + # Connect before_tick signal to static function. + # This is done to avoid having another singleton just to manage this tiny code. + if not NetworkTime.before_tick.is_connected(_on_before_tick): + NetworkTime.before_tick.connect(_on_before_tick) + +# if not NetworkTime.after_tick_loop.is_connected(_on_after_tick_loop): +# NetworkTime.after_tick_loop.connect(_on_after_tick_loop) + + NetworkTime.on_tick.connect(_on_tick) + +# Static function to connect to NetworkTime signals once. +# Before every tick, record owned inputs and send them to host. +static func _on_before_tick(_delta: float, tick: int) -> void: + NetworkHistoryServer._record_input_sender(tick) + NetworkSynchronizationServer._synchronize_input_sender(tick) + +# Check if [InputSender] received new input from client. +# Emit new_input_received with new snapshot applied if received input. +# Emit input_missing with latest snapshot if did not. +# This function only runs if +func _on_tick(delta: float, tick: int) -> void: + if not multiplayer.is_server(): + return + + # Find all ticks we haven't emitted yet up to current tick + var any_new := false + for t in range(_last_emitted_tick + 1, tick + 1): + var snapshot := NetworkHistoryServer._get_input_sender_snapshot(t) + if snapshot: + _apply_snapshot_for_self(snapshot) + new_input_received.emit(t) + _last_emitted_tick = t + any_new = true + + if not any_new: + # No new ticks at all — apply latest known and emit missing + var subjects := _input_properties.get_subjects() + var latest_tick := NetworkHistoryServer.get_latest_input_sender_tick_for(subjects, tick) + + if latest_tick >= 0: + var latest_snapshot := NetworkHistoryServer._get_input_sender_snapshot(latest_tick) + if latest_snapshot: + _apply_snapshot_for_self(latest_snapshot) + + input_missing.emit(tick, latest_tick) + +# Helper function to apply given snapshot for only this node. +# TODO Applying whole snapshot and iterating over ticks would be nicer +# if we decide to have singleton for this +func _apply_snapshot_for_self(snapshot : _Snapshot) -> void: + for node in _input_properties.get_subjects(): + for property in _input_properties.get_properties_of(node): + if snapshot.has_property(node, property): + var value := snapshot.get_property(node, property) + # TODO is this should be node.set_indexed ?? + set_indexed(property, value) + + +## Static function to connect to NetworkTime signals once. +## After every tick loop restore latest saved history. +## On hosts this will be latest received input. +## On clients this will be latest recorded input. +#static func _on_after_tick_loop() -> void: +# return # TODO delete this ? +# NetworkHistoryServer._restore_input_sender(NetworkTime.tick) diff --git a/addons/netfox/netfox.gd b/addons/netfox/netfox.gd index 509358df6..50ee55a7f 100644 --- a/addons/netfox/netfox.gd +++ b/addons/netfox/netfox.gd @@ -181,6 +181,11 @@ var SETTINGS: Array[Dictionary] = [ "value": 64, "type" : TYPE_INT }, + { + "name": "netfox/input_sender/enable_input_broadcast", + "value": false, + "type" : TYPE_BOOL + }, ] const AUTOLOADS: Array[Dictionary] = [ diff --git a/addons/netfox/servers/network-history-server.gd b/addons/netfox/servers/network-history-server.gd index d3754833e..c5c33a4f3 100644 --- a/addons/netfox/servers/network-history-server.gd +++ b/addons/netfox/servers/network-history-server.gd @@ -64,11 +64,11 @@ func deregister_sync_state(node: Node, property: NodePath) -> void: _sync_state_properties.erase(node, property) ## Register a input_sender input property -func register_input_sender_input(node : Node, property : NodePath) -> void: +func register_input_sender(node : Node, property : NodePath) -> void: _input_sender_properties.add(node, property) ## Deregister a input_sender input propert -func deregister_input_sender_input(node : Node, property : NodePath) -> void: +func deregister_input_sender(node : Node, property : NodePath) -> void: _input_sender_properties.erase(node, property) ## Deregister a node, no longer tracking any property it had registered using @@ -129,6 +129,11 @@ func get_state_age_for(subjects: Array, tick: int) -> int: func get_latest_input_for(subjects: Array, tick: int) -> int: return _get_latest_for(subjects, tick, _rb_input_history) +## Get the latest tick where any of the [param subjects] had input_sender data +## available +func get_latest_input_sender_tick_for(subjects: Array, tick: int) -> int: + return _get_latest_for(subjects, tick, _input_sender_history) + ## Return how old is the latest rollback input data for any of the ## [param subjects], in ticks func get_input_age_for(subjects: Array, tick: int) -> int: @@ -179,7 +184,7 @@ func _record_sync_state(tick: int) -> void: return subject.is_multiplayer_authority() ) -func _record_input_sender_input(tick: int) -> void: +func _record_input_sender(tick: int) -> void: _record(tick, _input_sender_history, _input_sender_snapshots, _input_sender_properties,\ true, func(subject: Node): return subject.is_multiplayer_authority() @@ -194,7 +199,7 @@ func _restore_rollback_state(tick: int) -> bool: func _restore_synchronizer_state(tick: int) -> bool: return _restore_latest(tick, _sync_history) -func _restore_input_sender_inputs(tick : int) -> bool: +func _restore_input_sender(tick : int) -> bool: return _restore_latest(tick, _input_sender_history) func _get_rollback_input_snapshot(tick: int) -> _Snapshot: @@ -206,7 +211,7 @@ func _get_rollback_state_snapshot(tick: int) -> _Snapshot: func _get_synchronizer_state_snapshot(tick: int) -> _Snapshot: return _sync_state_snapshots.get_at(tick) -func _get_input_sender_input_snapshot(tick : int) -> _Snapshot: +func _get_input_sender_snapshot(tick : int) -> _Snapshot: return _input_sender_snapshots.get_at(tick) func _merge_rollback_input(snapshot: _Snapshot) -> bool: @@ -221,9 +226,9 @@ func _merge_synchronizer_state(snapshot: _Snapshot) -> bool: _merge_snapshot(snapshot, _sync_state_snapshots, true) return _merge_history(snapshot, _sync_history) -func _merge_input_sender_input(snapshot: _Snapshot) -> bool: +func _merge_input_sender(snapshot: _Snapshot) -> bool: _merge_snapshot(snapshot, _input_sender_snapshots, true) - return _merge_history(snapshot, _input_sender_history) + return _merge_history(snapshot, _input_sender_history, true) func _record(tick: int, history: _PerObjectHistory, snapshots: _HistoryBuffer, property_pool: _PropertyPool, only_auth: bool, auth_filter: Callable) -> void: var snapshot := snapshots.get_at(tick, _Snapshot.new(tick)) as _Snapshot diff --git a/addons/netfox/servers/network-synchronization-server.gd b/addons/netfox/servers/network-synchronization-server.gd index 6d4d66199..c66ac8892 100644 --- a/addons/netfox/servers/network-synchronization-server.gd +++ b/addons/netfox/servers/network-synchronization-server.gd @@ -29,8 +29,8 @@ var _rb_owned_input_properties := _PropertyPool.new() var _rb_owned_state_properties := _PropertyPool.new() var _sync_state_properties := _PropertyPool.new() var _sync_owned_state_properties := _PropertyPool.new() -var _input_sender_input_properties := _PropertyPool.new() -var _input_sender_owned_input_properties := _PropertyPool.new() +var _input_sender_properties := _PropertyPool.new() +var _input_sender_owned_properties := _PropertyPool.new() var _visibility_filters := {} # Node to PeerVisibilityFilter @@ -38,9 +38,11 @@ var _rb_enable_input_broadcast := ProjectSettings.get_setting("netfox/rollback/e var _rb_enable_diffs := NetworkRollback.enable_diff_states var _rb_full_interval := ProjectSettings.get_setting("netfox/rollback/full_state_interval", 24) as int var _rb_full_scheduler := _IntervalScheduler.new(_rb_full_interval) +var _input_sender_enable_broadcast := ProjectSettings.get_setting("netfox/input_sender/enable_input_broadcast", false) as bool + var _rb_input_redundancy := NetworkRollback.input_redundancy -var _input_sender_input_redundancy := ProjectSettings.get_setting("netfox/input_sender/input_redundancy", 3) as int +var _input_sender_redundancy := ProjectSettings.get_setting("netfox/input_sender/input_redundancy", 3) as int var _last_sync_state_sent := _Snapshot.new(0) var _sync_enable_diffs := ProjectSettings.get_setting("netfox/state_synchronizer/enable_diff_states", true) as bool @@ -60,7 +62,7 @@ var _redundant_serializer: _RedundantSnapshotSerializer var _cmd_full_state: NetworkCommandServer.Command var _cmd_diff_state: NetworkCommandServer.Command var _cmd_input: NetworkCommandServer.Command -var _cmd_input_sender_input : NetworkCommandServer.Command +var _cmd_input_sender : NetworkCommandServer.Command var _cmd_full_sync: NetworkCommandServer.Command var _cmd_diff_sync: NetworkCommandServer.Command @@ -68,7 +70,7 @@ var _cmd_diff_sync: NetworkCommandServer.Command static var _logger := NetfoxLogger._for_netfox("NetworkSynchronizationServer") signal _on_input(snapshot: _Snapshot) -signal _on_input_sender_input(snapshot : _Snapshot) +signal _on_input_sender(snapshot : _Snapshot) signal _on_state(snapshot: _Snapshot) ## Register a [param property] of [param node] to be synchronized @@ -112,16 +114,16 @@ func deregister_sync_state(node: Node, property: NodePath) -> void: ## Register a [param property] of [param node] to be synchronized ## as input_sender input -func register_input_sender_input(node: Node, property: NodePath) -> void: - _input_sender_input_properties.add(node, property) +func register_input_sender(node: Node, property: NodePath) -> void: + _input_sender_properties.add(node, property) if node.is_multiplayer_authority(): - _input_sender_owned_input_properties.add(node, property) + _input_sender_owned_properties.add(node, property) ## Deregister a [param property] of [param node] from being synchronized ## as input_sender input -func deregister_input_sender_input(node: Node, property: NodePath) -> void: - _input_sender_input_properties.erase(node, property) - _input_sender_owned_input_properties.erase(node, property) +func deregister_input_sender(node: Node, property: NodePath) -> void: + _input_sender_properties.erase(node, property) + _input_sender_owned_properties.erase(node, property) ## Register a [param serializer] to use when transmitting ## [param property param] of [param node] over the network @@ -154,8 +156,8 @@ func deregister(node: Node) -> void: _rb_owned_input_properties.erase_subject(node) _sync_state_properties.erase_subject(node) _sync_owned_state_properties.erase_subject(node) - _input_sender_input_properties.erase_subject(node) - _input_sender_owned_input_properties.erase_subject(node) + _input_sender_properties.erase_subject(node) + _input_sender_owned_properties.erase_subject(node) _visibility_filters.erase(node) _schemas.erase_subject(node) @@ -306,7 +308,47 @@ func _synchronize_sync_state(tick: int) -> void: # NOTE: This is a shared instance, theoretically shouldn't screw things up _last_sync_state_sent = snapshot -## TODO Add syncronize input_sender input here. +func _synchronize_input_sender(tick: int) -> void: + # We don't own inputs, nothing to synchronize + if _input_sender_owned_properties.is_empty(): + return + + var snapshots := [] as Array[_Snapshot] + var notified_peers := _Set.new() + + # By default input sender only sends input to server, check if its enabled + ## TODO double check notified peers. + if not _input_sender_enable_broadcast: + # Input broadcast is off, only send inputs to host. + for node in _input_sender_owned_properties.get_subjects(): + notified_peers.add(1) + ## TODO check if this is solid or should be code below. +# notified_peers.add(node.get_multiplayer_authority()) + else: + # If input broadcast is on, send inputs to everyone + for peer in multiplayer.get_peers(): + notified_peers.add(peer) + + # Make sure to not send input to ourselves + # Maybe: Only erase ourselves if this is not host, because listen servers could benefit + # TODO does above comment this make sense? + notified_peers.erase(multiplayer.get_unique_id()) + + # Prepare snapshot package + for offset in _input_sender_redundancy: + # Grab snapshot from NetworkHistoryServer + var snapshot := NetworkHistoryServer._get_input_sender_snapshot(tick - offset) + if not snapshot: + break + + _logger.trace("Submitting input_sender inputs: %s", [snapshot]) + snapshots.append(snapshot) + + _logger.trace("Submitting input_sender inputs to peers: %s", [notified_peers]) + for peer in notified_peers: + var data := _redundant_serializer.write_for(peer, snapshots, _input_sender_owned_properties) + _cmd_input_sender.send(data, peer) + func _init( p_command_server: _NetworkCommandServer = null, @@ -339,24 +381,24 @@ func _ready(): _cmd_full_state = _command_server.register_command(_handle_full_state, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) _cmd_diff_state = _command_server.register_command(_handle_diff_state, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) _cmd_input = _command_server.register_command(_handle_input, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) - _cmd_input_sender_input = _command_server.register_command(_handle_input_sender_input, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) + _cmd_input_sender = _command_server.register_command(_handle_input_sender, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) _cmd_full_sync = _command_server.register_command(_handle_full_sync, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE_ORDERED) _cmd_diff_sync = _command_server.register_command(_handle_diff_sync, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE_ORDERED) -func _handle_input_sender_input(sender : int, data : PackedByteArray) -> void: +func _handle_input_sender(sender : int, data : PackedByteArray) -> void: var buffer := StreamPeerBuffer.new() buffer.data_array = data - var snapshots := _redundant_serializer.read_from(sender, _input_sender_input_properties, buffer, true) + var snapshots := _redundant_serializer.read_from(sender, _input_sender_properties, buffer, true) for snapshot in snapshots: snapshot.sanitize(sender) _logger.trace("Ingesting input_sender inputs: %s", [snapshot]) # TODO Handle Network History Server to merge input_sender inputs. - if NetworkHistoryServer._merge_input_sender_input(snapshot): - _on_input_sender_input.emit(snapshot) + if NetworkHistoryServer._merge_input_sender(snapshot): + _on_input_sender.emit(snapshot) func _handle_input(sender: int, data: PackedByteArray): var buffer := StreamPeerBuffer.new() diff --git a/examples/server-side-vehicle/scenes/server_side_tank.tscn b/examples/server-side-vehicle/scenes/server_side_tank.tscn index 0827d67b6..60d15c17b 100644 --- a/examples/server-side-vehicle/scenes/server_side_tank.tscn +++ b/examples/server-side-vehicle/scenes/server_side_tank.tscn @@ -1,7 +1,9 @@ -[gd_scene load_steps=7 format=3 uid="uid://f1annxuory74"] +[gd_scene load_steps=9 format=3 uid="uid://f1annxuory74"] [ext_resource type="Script" path="res://addons/netfox/input_sender.gd" id="1_c04if"] [ext_resource type="Script" path="res://examples/server-side-vehicle/scripts/server_side_tank.gd" id="1_jtlcb"] +[ext_resource type="Script" path="res://examples/server-side-vehicle/scripts/tank_input.gd" id="3_8ufv1"] +[ext_resource type="Script" path="res://addons/netfox/state-synchronizer.gd" id="4_qj6bi"] [sub_resource type="BoxShape3D" id="BoxShape3D_tqf64"] size = Vector3(2.9885, 1.6003, 4.71182) @@ -23,12 +25,21 @@ mass = 750.0 center_of_mass_mode = 1 center_of_mass = Vector3(0, 1.2, 0) linear_damp = 0.13 -angular_damp = 0.5 +angular_damp = 0.1 script = ExtResource("1_jtlcb") [node name="InputSender" type="Node" parent="." node_paths=PackedStringArray("root")] script = ExtResource("1_c04if") root = NodePath("..") +input_properties = Array[String](["TankInput:movement", "TankInput:brake"]) + +[node name="TankInput" type="Node" parent="."] +script = ExtResource("3_8ufv1") + +[node name="StateSynchronizer" type="Node" parent="." node_paths=PackedStringArray("root")] +script = ExtResource("4_qj6bi") +root = NodePath("..") +properties = Array[String]([":engine_force", ":brake", ":steering", ":global_transform"]) [node name="Camera3D" type="Camera3D" parent="."] transform = Transform3D(-0.999957, -0.0017973, 0.00912711, -1.68907e-08, 0.981158, 0.193207, -0.00930239, 0.193199, -0.981116, 0, 4.67115, -4.54559) @@ -265,3 +276,6 @@ sides = 16 transform = Transform3D(-4.37114e-08, 1, 0, -1, -4.37114e-08, 0, 0, 0, 1, 0, 0, 0) radius = 0.2 height = 0.8 + +[connection signal="input_missing" from="InputSender" to="." method="_on_input_sender_input_missing"] +[connection signal="new_input_received" from="InputSender" to="." method="_on_input_sender_new_input_received"] diff --git a/examples/server-side-vehicle/scenes/tank_arena.tscn b/examples/server-side-vehicle/scenes/tank_arena.tscn index 827400f4d..cc7db0e81 100644 --- a/examples/server-side-vehicle/scenes/tank_arena.tscn +++ b/examples/server-side-vehicle/scenes/tank_arena.tscn @@ -59,6 +59,7 @@ material_override = SubResource("StandardMaterial3D_lr3b6") size = Vector3(75, 1, 75) [node name="CollisionShape3D" type="CollisionShape3D" parent="Ground"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.5, 0) shape = SubResource("BoxShape3D_47cjw") [node name="Walls" type="Node3D" parent="."] diff --git a/examples/server-side-vehicle/scripts/server_side_tank.gd b/examples/server-side-vehicle/scripts/server_side_tank.gd index 47a35b20e..06e89d043 100644 --- a/examples/server-side-vehicle/scripts/server_side_tank.gd +++ b/examples/server-side-vehicle/scripts/server_side_tank.gd @@ -2,18 +2,54 @@ extends VehicleBody3D ## Script example for server side coded tank. -@onready var input_sender = $InputSender -@onready var camera_3d = $Camera3D +@onready var input_sender : InputSender = $InputSender as InputSender +@onready var camera_3d : Camera3D = $Camera3D as Camera3D +@onready var tank_input : Node = $TankInput as Node + +@export_category("Movement") +@export var engine_power := 600.0 +@export var brake_force := 50.0 +@export var max_steering_angle := 45.0 +@export var steering_lerp_factor := 0.02 # Called when the node enters the scene tree for the first time. func _ready(): # Await so that player spawner sets our input authority. await get_tree().process_frame + input_sender.process_authority() + if input_sender.get_multiplayer_authority() == multiplayer.get_unique_id(): camera_3d.current = true - # Called every frame. 'delta' is the elapsed time since the previous frame. -func _process(delta): +func _process(_delta): pass + + +func _on_input_sender_new_input_received(_tick : int): + print("Server received new input!") + print("movement:%s" %tank_input.movement) + print("brake:%s" %tank_input.brake) + if tank_input.movement.y != 0.0: + if tank_input.movement.y < 0: + engine_force = engine_power + else: + engine_force = -engine_power + + else: + # No move input + engine_force = 0 + + # Brake + if tank_input.brake: + brake = brake_force + else: + brake = 0.0 + + # Steering + steering = lerp(steering, deg_to_rad(max_steering_angle) * -tank_input.movement.x, steering_lerp_factor) + + +func _on_input_sender_input_missing(_current_tick : int, _latest_known_input_tick : int): + print("Input is missing") diff --git a/examples/server-side-vehicle/scripts/tank_input.gd b/examples/server-side-vehicle/scripts/tank_input.gd new file mode 100644 index 000000000..4b6b532f0 --- /dev/null +++ b/examples/server-side-vehicle/scripts/tank_input.gd @@ -0,0 +1,14 @@ +extends BaseNetInput + +## ServerSideTank input script + +var movement: Vector2 = Vector2.ZERO +var brake : bool = false + +func _gather(): + # Get the input direction and handle the movement/deceleration. + # As good practice, you should replace UI actions with custom gameplay actions. + var mx = Input.get_axis("move_west", "move_east") + var mz = Input.get_axis("move_north", "move_south") + movement = Vector2(mx, mz) + brake = Input.is_action_pressed("move_jump") From 3814b24fb3642e8c1716b36798d9e4b7e027605c Mon Sep 17 00:00:00 2001 From: tuysuztavsan Date: Fri, 1 May 2026 11:25:43 +0300 Subject: [PATCH 05/57] rebase to netfox latest rebased --- addons/netfox/input_sender.gd | 38 ++++--------------- .../scripts/player_spawner.gd | 2 +- .../scripts/server_side_tank.gd | 8 ++-- 3 files changed, 13 insertions(+), 35 deletions(-) diff --git a/addons/netfox/input_sender.gd b/addons/netfox/input_sender.gd index 7d42fa0f1..92eab1538 100644 --- a/addons/netfox/input_sender.gd +++ b/addons/netfox/input_sender.gd @@ -30,11 +30,10 @@ signal input_missing(current_tick : int, latest_known_input_tick : int) ## Decides which peers will receive updates var visibility_filter := PeerVisibilityFilter.new() -@onready var _logger: NetfoxLogger = NetfoxLogger._for_netfox("InputSender:" + root.name) - var _input_properties := _PropertyPool.new() var _properties_dirty: bool = false var _last_emitted_tick: int = -1 +var _logger := NetfoxLogger._for_netfox("InputSender") # Flag to connect signals only once. var _signals_connected : bool = false @@ -151,22 +150,8 @@ func _reprocess_settings() -> void: process_settings() func _connect_signals() -> void: - # Connect before_tick signal to static function. - # This is done to avoid having another singleton just to manage this tiny code. - if not NetworkTime.before_tick.is_connected(_on_before_tick): - NetworkTime.before_tick.connect(_on_before_tick) - -# if not NetworkTime.after_tick_loop.is_connected(_on_after_tick_loop): -# NetworkTime.after_tick_loop.connect(_on_after_tick_loop) - NetworkTime.on_tick.connect(_on_tick) -# Static function to connect to NetworkTime signals once. -# Before every tick, record owned inputs and send them to host. -static func _on_before_tick(_delta: float, tick: int) -> void: - NetworkHistoryServer._record_input_sender(tick) - NetworkSynchronizationServer._synchronize_input_sender(tick) - # Check if [InputSender] received new input from client. # Emit new_input_received with new snapshot applied if received input. # Emit input_missing with latest snapshot if did not. @@ -201,18 +186,11 @@ func _on_tick(delta: float, tick: int) -> void: # TODO Applying whole snapshot and iterating over ticks would be nicer # if we decide to have singleton for this func _apply_snapshot_for_self(snapshot : _Snapshot) -> void: - for node in _input_properties.get_subjects(): - for property in _input_properties.get_properties_of(node): - if snapshot.has_property(node, property): - var value := snapshot.get_property(node, property) + _logger.trace("Applying snapshot :%s", [snapshot]) + for subject in _input_properties.get_subjects(): + for property in _input_properties.get_properties_of(subject): + + if snapshot.has_property(subject, property): + var value := snapshot.get_property(subject, property) # TODO is this should be node.set_indexed ?? - set_indexed(property, value) - - -## Static function to connect to NetworkTime signals once. -## After every tick loop restore latest saved history. -## On hosts this will be latest received input. -## On clients this will be latest recorded input. -#static func _on_after_tick_loop() -> void: -# return # TODO delete this ? -# NetworkHistoryServer._restore_input_sender(NetworkTime.tick) + subject.set_indexed(property, value) diff --git a/examples/server-side-vehicle/scripts/player_spawner.gd b/examples/server-side-vehicle/scripts/player_spawner.gd index c215438f4..9721754b0 100644 --- a/examples/server-side-vehicle/scripts/player_spawner.gd +++ b/examples/server-side-vehicle/scripts/player_spawner.gd @@ -54,7 +54,7 @@ func _spawn(id: int): print("Spawned avatar %s at %s" % [avatar.name, multiplayer.get_unique_id()]) # Avatar's input object is owned by player - var input = avatar.find_child("InputSender") + var input = avatar.find_child("TankInput") if input != null: input.set_multiplayer_authority(id) print("Set input(%s) ownership to %s" % [input.name, id]) diff --git a/examples/server-side-vehicle/scripts/server_side_tank.gd b/examples/server-side-vehicle/scripts/server_side_tank.gd index 06e89d043..53015b681 100644 --- a/examples/server-side-vehicle/scripts/server_side_tank.gd +++ b/examples/server-side-vehicle/scripts/server_side_tank.gd @@ -12,6 +12,8 @@ extends VehicleBody3D @export var max_steering_angle := 45.0 @export var steering_lerp_factor := 0.02 +var logger := NetfoxLogger._for_netfox("ServerTank") + # Called when the node enters the scene tree for the first time. func _ready(): # Await so that player spawner sets our input authority. @@ -19,7 +21,7 @@ func _ready(): input_sender.process_authority() - if input_sender.get_multiplayer_authority() == multiplayer.get_unique_id(): + if tank_input.get_multiplayer_authority() == multiplayer.get_unique_id(): camera_3d.current = true # Called every frame. 'delta' is the elapsed time since the previous frame. @@ -28,9 +30,7 @@ func _process(_delta): func _on_input_sender_new_input_received(_tick : int): - print("Server received new input!") - print("movement:%s" %tank_input.movement) - print("brake:%s" %tank_input.brake) + logger.trace("On received input movement:%s, brake:%s", [tank_input.movement, tank_input.brake]) if tank_input.movement.y != 0.0: if tank_input.movement.y < 0: engine_force = engine_power From 73641d54c693b3065627324fb9d253e13df74c10 Mon Sep 17 00:00:00 2001 From: TuysuzTavsan Date: Fri, 1 May 2026 15:40:10 +0300 Subject: [PATCH 06/57] first working version of input-sender, need more work --- addons/netfox/input_sender.gd | 51 ++++++++++--------- .../netfox/servers/network-history-server.gd | 2 +- .../servers/network-synchronization-server.gd | 1 - .../scenes/server_side_tank.tscn | 8 +++ .../scripts/server_side_tank.gd | 15 ++++++ 5 files changed, 51 insertions(+), 26 deletions(-) diff --git a/addons/netfox/input_sender.gd b/addons/netfox/input_sender.gd index 92eab1538..239bc9646 100644 --- a/addons/netfox/input_sender.gd +++ b/addons/netfox/input_sender.gd @@ -155,38 +155,41 @@ func _connect_signals() -> void: # Check if [InputSender] received new input from client. # Emit new_input_received with new snapshot applied if received input. # Emit input_missing with latest snapshot if did not. -# This function only runs if +# This function only runs only on authority. func _on_tick(delta: float, tick: int) -> void: - if not multiplayer.is_server(): + if not is_multiplayer_authority(): return - # Find all ticks we haven't emitted yet up to current tick - var any_new := false - for t in range(_last_emitted_tick + 1, tick + 1): - var snapshot := NetworkHistoryServer._get_input_sender_snapshot(t) - if snapshot: - _apply_snapshot_for_self(snapshot) - new_input_received.emit(t) - _last_emitted_tick = t - any_new = true - - if not any_new: - # No new ticks at all — apply latest known and emit missing - var subjects := _input_properties.get_subjects() - var latest_tick := NetworkHistoryServer.get_latest_input_sender_tick_for(subjects, tick) - - if latest_tick >= 0: - var latest_snapshot := NetworkHistoryServer._get_input_sender_snapshot(latest_tick) - if latest_snapshot: - _apply_snapshot_for_self(latest_snapshot) - - input_missing.emit(tick, latest_tick) + # Get the latest input data available + # Known issue: If input sender is configured with multiple input nodes, + # Any fresh input from one node will trigger re-emitting of other node's inputs? + # TODO: look at above issue. + var latest_input_tick := NetworkHistoryServer.get_latest_input_sender_for( + _input_properties.get_subjects(), tick) + + if latest_input_tick == _last_emitted_tick: + # There is no new input data available + var latest_snapshot := NetworkHistoryServer._get_input_sender_snapshot(latest_input_tick) + if latest_snapshot: + _logger.trace("No new input is received, will emit input_missing after applying \ + snapshot: %s", [latest_snapshot]) + + _apply_snapshot_for_self(latest_snapshot) + input_missing.emit(tick, latest_input_tick) + else: + # Iterate over fresh inputs and emit a signal with fresh inputs applied. + for i in range(_last_emitted_tick + 1, latest_input_tick + 1): + var snapshot := NetworkHistoryServer._get_input_sender_snapshot(i) + if snapshot: + _apply_snapshot_for_self(snapshot) + new_input_received.emit(i) + _last_emitted_tick = i # Helper function to apply given snapshot for only this node. # TODO Applying whole snapshot and iterating over ticks would be nicer # if we decide to have singleton for this func _apply_snapshot_for_self(snapshot : _Snapshot) -> void: - _logger.trace("Applying snapshot :%s", [snapshot]) + _logger.trace("Applying snapshot for self :%s", [snapshot]) for subject in _input_properties.get_subjects(): for property in _input_properties.get_properties_of(subject): diff --git a/addons/netfox/servers/network-history-server.gd b/addons/netfox/servers/network-history-server.gd index c5c33a4f3..7c7b1cf0b 100644 --- a/addons/netfox/servers/network-history-server.gd +++ b/addons/netfox/servers/network-history-server.gd @@ -131,7 +131,7 @@ func get_latest_input_for(subjects: Array, tick: int) -> int: ## Get the latest tick where any of the [param subjects] had input_sender data ## available -func get_latest_input_sender_tick_for(subjects: Array, tick: int) -> int: +func get_latest_input_sender_for(subjects: Array, tick: int) -> int: return _get_latest_for(subjects, tick, _input_sender_history) ## Return how old is the latest rollback input data for any of the diff --git a/addons/netfox/servers/network-synchronization-server.gd b/addons/netfox/servers/network-synchronization-server.gd index c66ac8892..25815b953 100644 --- a/addons/netfox/servers/network-synchronization-server.gd +++ b/addons/netfox/servers/network-synchronization-server.gd @@ -396,7 +396,6 @@ func _handle_input_sender(sender : int, data : PackedByteArray) -> void: snapshot.sanitize(sender) _logger.trace("Ingesting input_sender inputs: %s", [snapshot]) - # TODO Handle Network History Server to merge input_sender inputs. if NetworkHistoryServer._merge_input_sender(snapshot): _on_input_sender.emit(snapshot) diff --git a/examples/server-side-vehicle/scenes/server_side_tank.tscn b/examples/server-side-vehicle/scenes/server_side_tank.tscn index 60d15c17b..4db74d902 100644 --- a/examples/server-side-vehicle/scenes/server_side_tank.tscn +++ b/examples/server-side-vehicle/scenes/server_side_tank.tscn @@ -105,6 +105,7 @@ sides = 16 transform = Transform3D(-4.37114e-08, 1, 0, -1, -4.37114e-08, 0, 0, 0, 1, 0, 0, 0) radius = 0.2 height = 0.8 +sides = 3 [node name="VehicleWheel3D2" type="VehicleWheel3D" parent="."] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.6, 0.6, 0.766) @@ -130,6 +131,7 @@ sides = 16 transform = Transform3D(-4.37114e-08, 1, 0, -1, -4.37114e-08, 0, 0, 0, 1, 0, 0, 0) radius = 0.2 height = 0.8 +sides = 3 [node name="VehicleWheel3D3" type="VehicleWheel3D" parent="."] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.6, 0.6, -0.618) @@ -155,6 +157,7 @@ sides = 16 transform = Transform3D(-4.37114e-08, 1, 0, -1, -4.37114e-08, 0, 0, 0, 1, 0, 0, 0) radius = 0.2 height = 0.8 +sides = 3 [node name="VehicleWheel3D4" type="VehicleWheel3D" parent="."] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.6, 0.6, -1.9835) @@ -179,6 +182,7 @@ sides = 16 transform = Transform3D(-4.37114e-08, 1, 0, -1, -4.37114e-08, 0, 0, 0, 1, 0, 0, 0) radius = 0.2 height = 0.8 +sides = 3 [node name="VehicleWheel3D5" type="VehicleWheel3D" parent="."] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.6, 0.6, 2.13987) @@ -204,6 +208,7 @@ sides = 16 transform = Transform3D(-4.37114e-08, 1, 0, -1, -4.37114e-08, 0, 0, 0, 1, 0, 0, 0) radius = 0.2 height = 0.8 +sides = 3 [node name="VehicleWheel3D6" type="VehicleWheel3D" parent="."] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.6, 0.6, 0.76647) @@ -228,6 +233,7 @@ sides = 16 transform = Transform3D(-4.37114e-08, 1, 0, -1, -4.37114e-08, 0, 0, 0, 1, 0, 0, 0) radius = 0.2 height = 0.8 +sides = 3 [node name="VehicleWheel3D7" type="VehicleWheel3D" parent="."] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.6, 0.6, -0.618003) @@ -252,6 +258,7 @@ sides = 16 transform = Transform3D(-4.37114e-08, 1, 0, -1, -4.37114e-08, 0, 0, 0, 1, 0, 0, 0) radius = 0.2 height = 0.8 +sides = 3 [node name="VehicleWheel3D8" type="VehicleWheel3D" parent="."] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.6, 0.6, -1.9835) @@ -276,6 +283,7 @@ sides = 16 transform = Transform3D(-4.37114e-08, 1, 0, -1, -4.37114e-08, 0, 0, 0, 1, 0, 0, 0) radius = 0.2 height = 0.8 +sides = 3 [connection signal="input_missing" from="InputSender" to="." method="_on_input_sender_input_missing"] [connection signal="new_input_received" from="InputSender" to="." method="_on_input_sender_new_input_received"] diff --git a/examples/server-side-vehicle/scripts/server_side_tank.gd b/examples/server-side-vehicle/scripts/server_side_tank.gd index 53015b681..7a149289c 100644 --- a/examples/server-side-vehicle/scripts/server_side_tank.gd +++ b/examples/server-side-vehicle/scripts/server_side_tank.gd @@ -14,6 +14,8 @@ extends VehicleBody3D var logger := NetfoxLogger._for_netfox("ServerTank") +var _total_mouse_input := Vector2.ZERO + # Called when the node enters the scene tree for the first time. func _ready(): # Await so that player spawner sets our input authority. @@ -53,3 +55,16 @@ func _on_input_sender_new_input_received(_tick : int): func _on_input_sender_input_missing(_current_tick : int, _latest_known_input_tick : int): print("Input is missing") + +func _input(event : InputEvent): + if event is InputEventMouseMotion: + _move_local_camera(event.relative) + +# Moves local camera around +# Camera movement is not networked and works entirely local. +func _move_local_camera(mouse_input : Vector2) -> void: + _total_mouse_input += mouse_input + + camera_3d.basis = Basis.IDENTITY + + From 40d2a164baf3942927a99770d47ef5fd03d27836 Mon Sep 17 00:00:00 2001 From: tuysuztavsan Date: Fri, 1 May 2026 20:49:12 +0300 Subject: [PATCH 07/57] improving example --- .../scenes/server_side_tank.tscn | 15 +++-- .../scripts/server_side_tank.gd | 59 ++++++++++++------- .../server-side-vehicle/scripts/tank_input.gd | 24 ++++++++ 3 files changed, 72 insertions(+), 26 deletions(-) diff --git a/examples/server-side-vehicle/scenes/server_side_tank.tscn b/examples/server-side-vehicle/scenes/server_side_tank.tscn index 4db74d902..52fd40535 100644 --- a/examples/server-side-vehicle/scenes/server_side_tank.tscn +++ b/examples/server-side-vehicle/scenes/server_side_tank.tscn @@ -20,18 +20,20 @@ metallic = 0.79 albedo_color = Color(0.466667, 0.466667, 0.466667, 1) metallic = 0.45 -[node name="ServerSideTank" type="VehicleBody3D"] +[node name="ServerSideTank" type="VehicleBody3D" node_paths=PackedStringArray("turret", "camera_3d")] mass = 750.0 center_of_mass_mode = 1 center_of_mass = Vector3(0, 1.2, 0) linear_damp = 0.13 angular_damp = 0.1 script = ExtResource("1_jtlcb") +turret = NodePath("Turret") +camera_3d = NodePath("Turret/Camera3D") [node name="InputSender" type="Node" parent="." node_paths=PackedStringArray("root")] script = ExtResource("1_c04if") root = NodePath("..") -input_properties = Array[String](["TankInput:movement", "TankInput:brake"]) +input_properties = Array[String](["TankInput:movement", "TankInput:brake", "TankInput:mouse_movement"]) [node name="TankInput" type="Node" parent="."] script = ExtResource("3_8ufv1") @@ -39,10 +41,7 @@ script = ExtResource("3_8ufv1") [node name="StateSynchronizer" type="Node" parent="." node_paths=PackedStringArray("root")] script = ExtResource("4_qj6bi") root = NodePath("..") -properties = Array[String]([":engine_force", ":brake", ":steering", ":global_transform"]) - -[node name="Camera3D" type="Camera3D" parent="."] -transform = Transform3D(-0.999957, -0.0017973, 0.00912711, -1.68907e-08, 0.981158, 0.193207, -0.00930239, 0.193199, -0.981116, 0, 4.67115, -4.54559) +properties = Array[String]([":engine_force", ":brake", ":steering", ":global_transform", "Turret:transform"]) [node name="CollisionShape3D" type="CollisionShape3D" parent="."] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.29733, 0.0841393) @@ -80,6 +79,10 @@ radius = 0.15 height = 4.0 sides = 16 +[node name="Camera3D" type="Camera3D" parent="Turret"] +transform = Transform3D(-0.86338, -0.150946, 0.481446, 0.00103192, 0.953671, 0.300851, -0.504553, 0.260246, -0.823224, 3.8109, 2.51503, -0.623832) +fov = 90.0 + [node name="VehicleWheel3D" type="VehicleWheel3D" parent="."] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.6, 0.6, 2.14) use_as_traction = true diff --git a/examples/server-side-vehicle/scripts/server_side_tank.gd b/examples/server-side-vehicle/scripts/server_side_tank.gd index 7a149289c..38e4de09f 100644 --- a/examples/server-side-vehicle/scripts/server_side_tank.gd +++ b/examples/server-side-vehicle/scripts/server_side_tank.gd @@ -2,19 +2,28 @@ extends VehicleBody3D ## Script example for server side coded tank. -@onready var input_sender : InputSender = $InputSender as InputSender -@onready var camera_3d : Camera3D = $Camera3D as Camera3D -@onready var tank_input : Node = $TankInput as Node - -@export_category("Movement") +@export_category("movement") @export var engine_power := 600.0 @export var brake_force := 50.0 @export var max_steering_angle := 45.0 @export var steering_lerp_factor := 0.02 +@export_category("turret_settings") +@export var turret : Node3D +@export var traverse_speed := 0.0001 +@export var tilt_speed := 0.0001 +@export var tilt_lower_limit := -30.0 +@export var tilt_upper_limit := 30.0 +@export_category("camera") +@export var camera_3d : Camera3D +@onready var input_sender : InputSender = $InputSender as InputSender +@onready var tank_input : Node = $TankInput as Node var logger := NetfoxLogger._for_netfox("ServerTank") -var _total_mouse_input := Vector2.ZERO +@onready var _turret_default_transform : Transform3D = self.turret.transform +var _turret_traverse := 0.0 # yaw +var _turret_tilt := 0.0 # pitch + # Called when the node enters the scene tree for the first time. func _ready(): @@ -24,6 +33,7 @@ func _ready(): input_sender.process_authority() if tank_input.get_multiplayer_authority() == multiplayer.get_unique_id(): + Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED) camera_3d.current = true # Called every frame. 'delta' is the elapsed time since the previous frame. @@ -33,8 +43,16 @@ func _process(_delta): func _on_input_sender_new_input_received(_tick : int): logger.trace("On received input movement:%s, brake:%s", [tank_input.movement, tank_input.brake]) - if tank_input.movement.y != 0.0: - if tank_input.movement.y < 0: + _handle_movement(tank_input.movement) + _move_turret(tank_input.mouse_movement) + +# Moves vehicle on hosts. +func _handle_movement(movement : Vector2) -> void: + if not is_multiplayer_authority(): + return + + if movement.y != 0.0: + if movement.y < 0: engine_force = engine_power else: engine_force = -engine_power @@ -50,21 +68,22 @@ func _on_input_sender_new_input_received(_tick : int): brake = 0.0 # Steering - steering = lerp(steering, deg_to_rad(max_steering_angle) * -tank_input.movement.x, steering_lerp_factor) - + steering = lerp(steering, deg_to_rad(max_steering_angle) * -movement.x, steering_lerp_factor) func _on_input_sender_input_missing(_current_tick : int, _latest_known_input_tick : int): print("Input is missing") -func _input(event : InputEvent): - if event is InputEventMouseMotion: - _move_local_camera(event.relative) - -# Moves local camera around -# Camera movement is not networked and works entirely local. -func _move_local_camera(mouse_input : Vector2) -> void: - _total_mouse_input += mouse_input - - camera_3d.basis = Basis.IDENTITY +# Moves the turret on the host. +func _move_turret(mouse_input : Vector2) -> void: + # Return if not host. + if not is_multiplayer_authority(): + return + _turret_traverse -= mouse_input.x * traverse_speed + _turret_tilt += mouse_input.y * tilt_speed + turret.basis = _turret_default_transform.basis + turret.basis = turret.basis.rotated(Vector3.UP, _turret_traverse) + + _turret_tilt = clamp(_turret_tilt, deg_to_rad(tilt_lower_limit), deg_to_rad(tilt_upper_limit)) + turret.basis = turret.basis.rotated(turret.basis.x, _turret_tilt) diff --git a/examples/server-side-vehicle/scripts/tank_input.gd b/examples/server-side-vehicle/scripts/tank_input.gd index 4b6b532f0..3c3f2f969 100644 --- a/examples/server-side-vehicle/scripts/tank_input.gd +++ b/examples/server-side-vehicle/scripts/tank_input.gd @@ -4,6 +4,9 @@ extends BaseNetInput var movement: Vector2 = Vector2.ZERO var brake : bool = false +var mouse_movement := Vector2.ZERO + +var _mouse_movement_buffer := Vector2.ZERO func _gather(): # Get the input direction and handle the movement/deceleration. @@ -12,3 +15,24 @@ func _gather(): var mz = Input.get_axis("move_north", "move_south") movement = Vector2(mx, mz) brake = Input.is_action_pressed("move_jump") + + mouse_movement = _mouse_movement_buffer if _mouse_movement_buffer else Vector2.ZERO + _mouse_movement_buffer = Vector2.ZERO + +func _notification(what): + if what == NOTIFICATION_WM_WINDOW_FOCUS_IN: + Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED) + +func _input(event: InputEvent) -> void: + if !is_multiplayer_authority(): return + + if event.is_action_pressed("escape"): + Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE) + +func _unhandled_input(event: InputEvent) -> void: + if not is_multiplayer_authority(): + return + + if Input.mouse_mode == Input.MOUSE_MODE_CAPTURED and event is InputEventMouseMotion: + _mouse_movement_buffer.x += event.relative.x + _mouse_movement_buffer.y += event.relative.y From 997f2a5da1e7e6392b7c548abc90f78ebf3ad316 Mon Sep 17 00:00:00 2001 From: TuysuzTavsan Date: Sat, 2 May 2026 01:08:54 +0300 Subject: [PATCH 08/57] working server side vehicle example with input_sender. --- .../scenes/server_side_tank.tscn | 34 ++++++-- .../server_side_vehicle_info_panel.tscn | 71 ++++++++++++++++ .../scenes/tank_shell.tscn | 22 +++++ .../scripts/player_spawner.gd | 9 +- .../scripts/server_side_tank.gd | 83 ++++++++++++++----- .../scripts/server_side_vehicle_info_panel.gd | 26 ++++++ .../server-side-vehicle/scripts/tank_input.gd | 46 ++++++++-- .../server-side-vehicle/scripts/tank_shell.gd | 25 ++++++ project.godot | 7 ++ 9 files changed, 286 insertions(+), 37 deletions(-) create mode 100644 examples/server-side-vehicle/scenes/server_side_vehicle_info_panel.tscn create mode 100644 examples/server-side-vehicle/scenes/tank_shell.tscn create mode 100644 examples/server-side-vehicle/scripts/server_side_vehicle_info_panel.gd create mode 100644 examples/server-side-vehicle/scripts/tank_shell.gd diff --git a/examples/server-side-vehicle/scenes/server_side_tank.tscn b/examples/server-side-vehicle/scenes/server_side_tank.tscn index 52fd40535..4806e54fe 100644 --- a/examples/server-side-vehicle/scenes/server_side_tank.tscn +++ b/examples/server-side-vehicle/scenes/server_side_tank.tscn @@ -1,13 +1,18 @@ -[gd_scene load_steps=9 format=3 uid="uid://f1annxuory74"] +[gd_scene load_steps=12 format=3 uid="uid://f1annxuory74"] [ext_resource type="Script" path="res://addons/netfox/input_sender.gd" id="1_c04if"] [ext_resource type="Script" path="res://examples/server-side-vehicle/scripts/server_side_tank.gd" id="1_jtlcb"] +[ext_resource type="PackedScene" uid="uid://uqytq0drkxtf" path="res://examples/server-side-vehicle/scenes/tank_shell.tscn" id="2_ea71k"] +[ext_resource type="PackedScene" uid="uid://bsavthtpx4joi" path="res://examples/server-side-vehicle/scenes/server_side_vehicle_info_panel.tscn" id="2_o1mnl"] [ext_resource type="Script" path="res://examples/server-side-vehicle/scripts/tank_input.gd" id="3_8ufv1"] [ext_resource type="Script" path="res://addons/netfox/state-synchronizer.gd" id="4_qj6bi"] [sub_resource type="BoxShape3D" id="BoxShape3D_tqf64"] size = Vector3(2.9885, 1.6003, 4.71182) +[sub_resource type="BoxShape3D" id="BoxShape3D_qe0pd"] +size = Vector3(2.01, 0.775, 2) + [sub_resource type="StandardMaterial3D" id="StandardMaterial3D_sclqb"] albedo_color = Color(0.188235, 0.188235, 0.188235, 1) metallic = 0.7 @@ -20,7 +25,7 @@ metallic = 0.79 albedo_color = Color(0.466667, 0.466667, 0.466667, 1) metallic = 0.45 -[node name="ServerSideTank" type="VehicleBody3D" node_paths=PackedStringArray("turret", "camera_3d")] +[node name="ServerSideTank" type="VehicleBody3D" node_paths=PackedStringArray("turret", "shell_spawn_point", "regular_camera", "focus_camera")] mass = 750.0 center_of_mass_mode = 1 center_of_mass = Vector3(0, 1.2, 0) @@ -28,12 +33,16 @@ linear_damp = 0.13 angular_damp = 0.1 script = ExtResource("1_jtlcb") turret = NodePath("Turret") -camera_3d = NodePath("Turret/Camera3D") +shell_spawn_point = NodePath("Turret/Marker3D") +shell_scene = ExtResource("2_ea71k") +regular_camera = NodePath("Turret/RegularCamera3D") +focus_camera = NodePath("Turret/FocusCamera3D") +info_panel_scene = ExtResource("2_o1mnl") [node name="InputSender" type="Node" parent="." node_paths=PackedStringArray("root")] script = ExtResource("1_c04if") root = NodePath("..") -input_properties = Array[String](["TankInput:movement", "TankInput:brake", "TankInput:mouse_movement"]) +input_properties = Array[String](["TankInput:movement", "TankInput:brake", "TankInput:mouse_movement", "TankInput:fire"]) [node name="TankInput" type="Node" parent="."] script = ExtResource("3_8ufv1") @@ -41,12 +50,16 @@ script = ExtResource("3_8ufv1") [node name="StateSynchronizer" type="Node" parent="." node_paths=PackedStringArray("root")] script = ExtResource("4_qj6bi") root = NodePath("..") -properties = Array[String]([":engine_force", ":brake", ":steering", ":global_transform", "Turret:transform"]) +properties = Array[String]([":engine_force", ":brake", ":steering", ":global_transform", "Turret:transform", ":_last_fire_tick", ":score"]) [node name="CollisionShape3D" type="CollisionShape3D" parent="."] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.29733, 0.0841393) shape = SubResource("BoxShape3D_tqf64") +[node name="CollisionShape3D2" type="CollisionShape3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.44697, -0.240236) +shape = SubResource("BoxShape3D_qe0pd") + [node name="Body" type="CSGMesh3D" parent="."] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.0007, 0.0944731) material_override = SubResource("StandardMaterial3D_sclqb") @@ -79,10 +92,17 @@ radius = 0.15 height = 4.0 sides = 16 -[node name="Camera3D" type="Camera3D" parent="Turret"] -transform = Transform3D(-0.86338, -0.150946, 0.481446, 0.00103192, 0.953671, 0.300851, -0.504553, 0.260246, -0.823224, 3.8109, 2.51503, -0.623832) +[node name="FocusCamera3D" type="Camera3D" parent="Turret"] +transform = Transform3D(-1, 0, -8.74228e-08, 0, 1, 0, 8.74228e-08, 0, -1, 0, 0.209147, 4.3057) +fov = 45.0 + +[node name="RegularCamera3D" type="Camera3D" parent="Turret"] +transform = Transform3D(-1, -2.26267e-08, 8.44439e-08, 0, 0.965926, 0.258819, -8.74228e-08, 0.258819, -0.965926, 0, 2.3423, -2.76887) fov = 90.0 +[node name="Marker3D" type="Marker3D" parent="Turret"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 4.83633) + [node name="VehicleWheel3D" type="VehicleWheel3D" parent="."] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.6, 0.6, 2.14) use_as_traction = true diff --git a/examples/server-side-vehicle/scenes/server_side_vehicle_info_panel.tscn b/examples/server-side-vehicle/scenes/server_side_vehicle_info_panel.tscn new file mode 100644 index 000000000..becc13b34 --- /dev/null +++ b/examples/server-side-vehicle/scenes/server_side_vehicle_info_panel.tscn @@ -0,0 +1,71 @@ +[gd_scene load_steps=2 format=3 uid="uid://bsavthtpx4joi"] + +[ext_resource type="Script" path="res://examples/server-side-vehicle/scripts/server_side_vehicle_info_panel.gd" id="1_1v7fb"] + +[node name="ServerSideVehicleInfoPanel" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +script = ExtResource("1_1v7fb") + +[node name="MarginContainer" type="MarginContainer" parent="."] +layout_mode = 1 +anchors_preset = 2 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = 32.0 +offset_top = -192.0 +offset_right = 156.0 +offset_bottom = -32.0 +grow_vertical = 0 + +[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer"] +layout_mode = 2 + +[node name="PeerLabel" type="Label" parent="MarginContainer/VBoxContainer"] +layout_mode = 2 +text = "Server Side Vehicle Example +Peer#" + +[node name="InfoLabel" type="Label" parent="MarginContainer/VBoxContainer"] +layout_mode = 2 +text = "Controls: +Mouse (use alt+tab) -> Move Turret +F / Mouse Wheel -> Zoom +WASD : Move +Space : Brake +Left Click: Shoot" + +[node name="MarginContainer2" type="MarginContainer" parent="."] +layout_mode = 1 +anchors_preset = 7 +anchor_left = 0.5 +anchor_top = 1.0 +anchor_right = 0.5 +anchor_bottom = 1.0 +offset_left = -161.0 +offset_top = -161.0 +offset_right = 161.0 +offset_bottom = -104.0 +grow_horizontal = 2 +grow_vertical = 0 + +[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer2"] +layout_mode = 2 + +[node name="ReloadLabel" type="Label" parent="MarginContainer2/VBoxContainer"] +layout_mode = 2 +text = "Reloading" +horizontal_alignment = 1 + +[node name="ReloadProgressBar" type="ProgressBar" parent="MarginContainer2/VBoxContainer"] +layout_mode = 2 + +[node name="ScoreLabel" type="Label" parent="MarginContainer2/VBoxContainer"] +layout_mode = 2 +text = "Score: 0" +horizontal_alignment = 1 diff --git a/examples/server-side-vehicle/scenes/tank_shell.tscn b/examples/server-side-vehicle/scenes/tank_shell.tscn new file mode 100644 index 000000000..750fa53d0 --- /dev/null +++ b/examples/server-side-vehicle/scenes/tank_shell.tscn @@ -0,0 +1,22 @@ +[gd_scene load_steps=4 format=3 uid="uid://uqytq0drkxtf"] + +[ext_resource type="Script" path="res://examples/server-side-vehicle/scripts/tank_shell.gd" id="1_8exh1"] + +[sub_resource type="BoxMesh" id="BoxMesh_qktr7"] +size = Vector3(0.2, 0.2, 1) + +[sub_resource type="BoxShape3D" id="BoxShape3D_rtbd2"] +size = Vector3(0.2, 0.2, 1) + +[node name="TankShell" type="Node3D"] +script = ExtResource("1_8exh1") + +[node name="MeshInstance3D" type="MeshInstance3D" parent="."] +mesh = SubResource("BoxMesh_qktr7") + +[node name="Area3D" type="Area3D" parent="."] + +[node name="CollisionShape3D" type="CollisionShape3D" parent="Area3D"] +shape = SubResource("BoxShape3D_rtbd2") + +[connection signal="body_entered" from="Area3D" to="." method="_on_area_3d_body_entered"] diff --git a/examples/server-side-vehicle/scripts/player_spawner.gd b/examples/server-side-vehicle/scripts/player_spawner.gd index 9721754b0..23d247964 100644 --- a/examples/server-side-vehicle/scripts/player_spawner.gd +++ b/examples/server-side-vehicle/scripts/player_spawner.gd @@ -45,8 +45,7 @@ func _spawn(id: int): var avatar = player_scene.instantiate() as Node avatars[id] = avatar avatar.name += " #%d" % id - add_child(avatar) - avatar.global_position = get_next_spawn_point(id) + avatar.position = get_next_spawn_point(id) # Avatar is always owned by server avatar.set_multiplayer_authority(1) @@ -58,6 +57,8 @@ func _spawn(id: int): if input != null: input.set_multiplayer_authority(id) print("Set input(%s) ownership to %s" % [input.name, id]) + + add_child(avatar) func get_next_spawn_point(peer_id: int, spawn_idx: int = 0) -> Vector3: # The same data is used to calculate the index on all peers @@ -67,3 +68,7 @@ func get_next_spawn_point(peer_id: int, spawn_idx: int = 0) -> Vector3: idx = idx % spawn_points.size() return spawn_points[idx].global_position + +# Used when tanks die and we need to reposition them. +func get_random_spawn_point() -> Vector3: + return spawn_points.pick_random().global_position diff --git a/examples/server-side-vehicle/scripts/server_side_tank.gd b/examples/server-side-vehicle/scripts/server_side_tank.gd index 38e4de09f..c1a7f795b 100644 --- a/examples/server-side-vehicle/scripts/server_side_tank.gd +++ b/examples/server-side-vehicle/scripts/server_side_tank.gd @@ -3,48 +3,70 @@ extends VehicleBody3D ## Script example for server side coded tank. @export_category("movement") -@export var engine_power := 600.0 -@export var brake_force := 50.0 -@export var max_steering_angle := 45.0 -@export var steering_lerp_factor := 0.02 +@export var engine_power := 450.0 +@export var brake_force := 45.0 +@export var max_steering_angle := 75.0 +@export var steering_lerp_factor := 0.05 @export_category("turret_settings") @export var turret : Node3D -@export var traverse_speed := 0.0001 +@export var traverse_speed := 0.0004 @export var tilt_speed := 0.0001 @export var tilt_lower_limit := -30.0 @export var tilt_upper_limit := 30.0 +@export var shell_spawn_point : Marker3D = null +@export var shell_scene : PackedScene = null +@export var fire_cooldown_tick : int = 180 @export_category("camera") -@export var camera_3d : Camera3D +@export var regular_camera : Camera3D = null +@export var focus_camera : Camera3D = null +@export_category("ui") +@export var info_panel_scene : PackedScene = null @onready var input_sender : InputSender = $InputSender as InputSender @onready var tank_input : Node = $TankInput as Node -var logger := NetfoxLogger._for_netfox("ServerTank") + +var score := 0 @onready var _turret_default_transform : Transform3D = self.turret.transform var _turret_traverse := 0.0 # yaw var _turret_tilt := 0.0 # pitch +var _last_fire_tick := 0 # Called when the node enters the scene tree for the first time. func _ready(): - # Await so that player spawner sets our input authority. - await get_tree().process_frame - - input_sender.process_authority() - + _last_fire_tick = NetworkTime.tick if tank_input.get_multiplayer_authority() == multiplayer.get_unique_id(): - Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED) - camera_3d.current = true - -# Called every frame. 'delta' is the elapsed time since the previous frame. -func _process(_delta): - pass + if info_panel_scene: + var panel := info_panel_scene.instantiate() + add_child(panel) + print("Setting camera true on %s" %[name]) + regular_camera.current = true +func _unhandled_input(event): + # Dont process local inputs on other players tanks + if not tank_input.is_multiplayer_authority(): + return + + if Input.is_action_just_pressed("weapon_fire"): + # Dont fire on host machine as it will fire already on _on_input_sender_new_input_received + if not multiplayer.is_server(): + _fire(NetworkTime.tick) + + if event.is_action_pressed("focus"): + if focus_camera.current: + focus_camera.current = false + regular_camera.current = true + else: + regular_camera.current = false + focus_camera.current = true -func _on_input_sender_new_input_received(_tick : int): - logger.trace("On received input movement:%s, brake:%s", [tank_input.movement, tank_input.brake]) +func _on_input_sender_new_input_received(tick : int): _handle_movement(tank_input.movement) _move_turret(tank_input.mouse_movement) + + if tank_input.fire: + _fire(tick) # Moves vehicle on hosts. func _handle_movement(movement : Vector2) -> void: @@ -87,3 +109,24 @@ func _move_turret(mouse_input : Vector2) -> void: _turret_tilt = clamp(_turret_tilt, deg_to_rad(tilt_lower_limit), deg_to_rad(tilt_upper_limit)) turret.basis = turret.basis.rotated(turret.basis.x, _turret_tilt) + +func _fire(tick : int) -> void: + if tick - _last_fire_tick < fire_cooldown_tick: + return + + print("Firing!") + _last_fire_tick = tick + var shell := shell_scene.instantiate() as Node3D + get_tree().root.add_child(shell) + shell.global_transform = shell_spawn_point.global_transform + shell.firing_tank = self + +# Called only on server. +func die() -> void: + if not is_multiplayer_authority(): + return + + var player_spawner = get_parent() + global_position = player_spawner.get_random_spawn_point() + _turret_tilt = 0 + _turret_traverse = 0 diff --git a/examples/server-side-vehicle/scripts/server_side_vehicle_info_panel.gd b/examples/server-side-vehicle/scripts/server_side_vehicle_info_panel.gd new file mode 100644 index 000000000..78e13e244 --- /dev/null +++ b/examples/server-side-vehicle/scripts/server_side_vehicle_info_panel.gd @@ -0,0 +1,26 @@ +extends Control + +# Server side vehicle info panel +@onready var peer_label : Label = $MarginContainer/VBoxContainer/PeerLabel as Label +@onready var reload_progress_bar = $MarginContainer2/VBoxContainer/ReloadProgressBar +@onready var reload_label = $MarginContainer2/VBoxContainer/ReloadLabel +@onready var score_label = $MarginContainer2/VBoxContainer/ScoreLabel + +# Called when the node enters the scene tree for the first time. +func _ready(): + peer_label.text = "Server Side Vehicle Example \n Peer#" + str(multiplayer.get_unique_id()) + +func _process(_delta): + var tank = get_parent() + if not tank: + return + + var percentage = (NetworkTime.tick - tank._last_fire_tick) as float / tank.fire_cooldown_tick as float + reload_progress_bar.value = percentage * 100.0 + + if reload_progress_bar.value > 99: + reload_label.text = "Loaded" + else: + reload_label.text = "Loading" + + score_label.text = str(tank.score) diff --git a/examples/server-side-vehicle/scripts/tank_input.gd b/examples/server-side-vehicle/scripts/tank_input.gd index 3c3f2f969..600705666 100644 --- a/examples/server-side-vehicle/scripts/tank_input.gd +++ b/examples/server-side-vehicle/scripts/tank_input.gd @@ -1,14 +1,37 @@ -extends BaseNetInput +extends Node ## ServerSideTank input script -var movement: Vector2 = Vector2.ZERO -var brake : bool = false +var movement := Vector2.ZERO +var brake := false var mouse_movement := Vector2.ZERO +var fire := false var _mouse_movement_buffer := Vector2.ZERO +var _fire_buffer := false + +func _ready(): + NetworkTime.before_tick_loop.connect(_gather) + NetworkTime.after_tick.connect(func(_dt, _t): _gather_always()) + +func _process(_delta) -> void: + if not is_multiplayer_authority(): + return + + if Input.is_action_just_pressed("weapon_fire"): + _fire_buffer = true + +func _notification(what): + if not is_multiplayer_authority(): + return + + if what == NOTIFICATION_WM_WINDOW_FOCUS_IN: + Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED) func _gather(): + if not is_multiplayer_authority(): + return + # Get the input direction and handle the movement/deceleration. # As good practice, you should replace UI actions with custom gameplay actions. var mx = Input.get_axis("move_west", "move_east") @@ -19,15 +42,22 @@ func _gather(): mouse_movement = _mouse_movement_buffer if _mouse_movement_buffer else Vector2.ZERO _mouse_movement_buffer = Vector2.ZERO -func _notification(what): - if what == NOTIFICATION_WM_WINDOW_FOCUS_IN: - Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED) +func _gather_always(): + if not is_multiplayer_authority(): + return + + fire = _fire_buffer + _fire_buffer = false func _input(event: InputEvent) -> void: - if !is_multiplayer_authority(): return + if not is_multiplayer_authority(): + return if event.is_action_pressed("escape"): - Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE) + if Input.mouse_mode == Input.MOUSE_MODE_CAPTURED: + Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE) + else: + Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED) func _unhandled_input(event: InputEvent) -> void: if not is_multiplayer_authority(): diff --git a/examples/server-side-vehicle/scripts/tank_shell.gd b/examples/server-side-vehicle/scripts/tank_shell.gd new file mode 100644 index 000000000..5a3f5dcc9 --- /dev/null +++ b/examples/server-side-vehicle/scripts/tank_shell.gd @@ -0,0 +1,25 @@ +extends Node3D + +# Tank Shell script + +# Only can kill tanks on server. + +@export var speed := 50.0 + +# Set this from firing tank +var firing_tank : Node = null + +# Called every frame. 'delta' is the elapsed time since the previous frame. +func _process(delta): + global_position += global_transform.basis.z * speed * delta + +func _on_area_3d_body_entered(body): + if multiplayer.is_server(): + if body.has_method("die"): + body.die() + if firing_tank: + print("%s killed another tank +1 score!" %firing_tank.name) + firing_tank.score += 1 + + + queue_free() diff --git a/project.godot b/project.godot index 38dc8ae37..c14a71052 100644 --- a/project.godot +++ b/project.godot @@ -136,6 +136,13 @@ escape={ "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194305,"key_label":0,"unicode":0,"echo":false,"script":null) ] } +focus={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":70,"key_label":0,"unicode":102,"echo":false,"script":null) +, Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":4,"canceled":false,"pressed":false,"double_click":false,"script":null) +, Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":5,"canceled":false,"pressed":false,"double_click":false,"script":null) +] +} [netfox] From dc27de37e78618167bc2d7ae382cba1b17d04cc3 Mon Sep 17 00:00:00 2001 From: TuysuzTavsan Date: Sat, 2 May 2026 01:12:07 +0300 Subject: [PATCH 09/57] fixed a label --- .../scripts/server_side_vehicle_info_panel.gd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/server-side-vehicle/scripts/server_side_vehicle_info_panel.gd b/examples/server-side-vehicle/scripts/server_side_vehicle_info_panel.gd index 78e13e244..8c9475ef5 100644 --- a/examples/server-side-vehicle/scripts/server_side_vehicle_info_panel.gd +++ b/examples/server-side-vehicle/scripts/server_side_vehicle_info_panel.gd @@ -23,4 +23,4 @@ func _process(_delta): else: reload_label.text = "Loading" - score_label.text = str(tank.score) + score_label.text = "Score: " + str(tank.score) From 1493dd653d9b57a5ba7aec1f5a65c149d502473c Mon Sep 17 00:00:00 2001 From: tuysuztavsan Date: Fri, 8 May 2026 13:53:42 +0300 Subject: [PATCH 10/57] added local_input signal to input sender and changed other signal names, more documentation --- addons/netfox/input_sender.gd | 87 ++++++++++++++++--- .../scenes/server_side_tank.tscn | 5 +- .../scripts/server_side_tank.gd | 25 +++--- 3 files changed, 91 insertions(+), 26 deletions(-) diff --git a/addons/netfox/input_sender.gd b/addons/netfox/input_sender.gd index 239bc9646..5a5eb6ca0 100644 --- a/addons/netfox/input_sender.gd +++ b/addons/netfox/input_sender.gd @@ -2,19 +2,39 @@ extends Node class_name InputSender -## Stores inputs and sends them to server. +## Stores inputs and sends them to host. ## [br][br] -## [InputSender] can be used alone or with [Simulator]. +## +## [InputSender] is a multi purpose node to use on networked games, +## It provides signals to code host and client side logic. +## [InputSender] signals are tied and emitted on [signal NetworkTime.on_tick]. +## +## @experimental: +## [InputSender] assumes input snapshots arrive as whole. (atomic), if snapshot +## arrives with multiple parts, [InputSender] signals wont be reliable to +## code game logic. -## Emitted when [InputSender] receives input from client on [signal NetworkTime.on_tick] +## Emitted when [InputSender] receives input from remote owner of input_properties. ## [InputSender] handles applying received input internally before emitting this signal. -## Emitted only on hosts. -signal new_input_received(tick : int) +## Emitted only if [InputSender] has authority. +## Use this signal to code host side logic. +signal network_input(tick : int) + +## Emitted for every tick if local peer has authority over input_property nodes. +## [InputSender] will apply latest local inputs for this tick internally before +## emitting this signal. +## Use this signal to code client side logic which doesnt interfere with actual game state. +## Examples: Playing a sound, showing a visual effect. +## Dont use this signal to code same game logic on client side as it will not likely +## be same with remote host machine, it will cause syncing issues if you are already +## using some other method to syncronize game state (Syncronizers). +signal local_input(tick : int) ## Emitted when [InputSender] doesnt receive anything from client on [signal NetworkTime.on_tick] -## [InputSender] handles applying latest known input internally before emitting this signal. -## Emitted only on hosts. -signal input_missing(current_tick : int, latest_known_input_tick : int) +## [InputSender] will apply latest known input internally before emitting this signal. +## Emitted only if [InputSender] is authority. +## Use this signal to code host side prediction logic. +signal missing_input(current_tick : int, latest_known_input_tick : int) ## The root node for resolving node paths in inputs. Defaults to the parent node. @export var root: Node = get_parent() @@ -152,11 +172,17 @@ func _reprocess_settings() -> void: func _connect_signals() -> void: NetworkTime.on_tick.connect(_on_tick) -# Check if [InputSender] received new input from client. -# Emit new_input_received with new snapshot applied if received input. -# Emit input_missing with latest snapshot if did not. -# This function only runs only on authority. +# Applies local snapshot and emits local_input if has authority over input nodes. +# Then +# applies new received network snapshots and emits network_input snapshots if +# [InputSender] is authority, +# If did not receive new network snapshots, applies latest and emits input_missing +# with latest snapshot. func _on_tick(delta: float, tick: int) -> void: + # First handle local_input signalling. + _apply_and_emit_local_inputs(tick) + + # Move on to the network_input and input_missing signalling. if not is_multiplayer_authority(): return @@ -175,14 +201,14 @@ func _on_tick(delta: float, tick: int) -> void: snapshot: %s", [latest_snapshot]) _apply_snapshot_for_self(latest_snapshot) - input_missing.emit(tick, latest_input_tick) + missing_input.emit(tick, latest_input_tick) else: # Iterate over fresh inputs and emit a signal with fresh inputs applied. for i in range(_last_emitted_tick + 1, latest_input_tick + 1): var snapshot := NetworkHistoryServer._get_input_sender_snapshot(i) if snapshot: _apply_snapshot_for_self(snapshot) - new_input_received.emit(i) + network_input.emit(i) _last_emitted_tick = i # Helper function to apply given snapshot for only this node. @@ -197,3 +223,36 @@ func _apply_snapshot_for_self(snapshot : _Snapshot) -> void: var value := snapshot.get_property(subject, property) # TODO is this should be node.set_indexed ?? subject.set_indexed(property, value) + +# If the local peer has authority over input_property node, apply latest inputs +# and emit signal local_input. +func _apply_and_emit_local_inputs(for_tick : int) -> void: + if not _has_authority_over_input_nodes(): + return + + var latest_local_snapshot := NetworkHistoryServer._get_input_sender_snapshot(for_tick) + + if latest_local_snapshot: + _logger.trace("Applying local snapshot and emitting local_inputs: %s", [latest_local_snapshot]) + _apply_snapshot_for_self(latest_local_snapshot) + local_input.emit(for_tick) + +# Helper function to determine if InputSender has authority over its input_properties +# This function iterates over input_properties subjects and checks if they have authority. +# If none of them has authority or no input_node is configured this will return false, +# If any of them has authority this will return true instantly. +# Its developers responsibility to always make sure input_nodes have same configuration. +# TODO make sure to document this responsibility to developer. +func _has_authority_over_input_nodes() -> bool: + for subject in _input_properties.get_subjects(): + + # ObjectPool does not guarentee every subject is node. + if not subject is Node: + continue + + # Found input node, check if it has authority + if subject.is_multiplayer_authority(): + return true + + # Did not find any node, or none of them has authority. + return false diff --git a/examples/server-side-vehicle/scenes/server_side_tank.tscn b/examples/server-side-vehicle/scenes/server_side_tank.tscn index 4806e54fe..aedbfd44a 100644 --- a/examples/server-side-vehicle/scenes/server_side_tank.tscn +++ b/examples/server-side-vehicle/scenes/server_side_tank.tscn @@ -308,5 +308,6 @@ radius = 0.2 height = 0.8 sides = 3 -[connection signal="input_missing" from="InputSender" to="." method="_on_input_sender_input_missing"] -[connection signal="new_input_received" from="InputSender" to="." method="_on_input_sender_new_input_received"] +[connection signal="local_input" from="InputSender" to="." method="_on_input_sender_local_input"] +[connection signal="missing_input" from="InputSender" to="." method="_on_input_sender_missing_input"] +[connection signal="network_input" from="InputSender" to="." method="_on_input_sender_network_input"] diff --git a/examples/server-side-vehicle/scripts/server_side_tank.gd b/examples/server-side-vehicle/scripts/server_side_tank.gd index c1a7f795b..dc3fad479 100644 --- a/examples/server-side-vehicle/scripts/server_side_tank.gd +++ b/examples/server-side-vehicle/scripts/server_side_tank.gd @@ -61,13 +61,6 @@ func _unhandled_input(event): regular_camera.current = false focus_camera.current = true -func _on_input_sender_new_input_received(tick : int): - _handle_movement(tank_input.movement) - _move_turret(tank_input.mouse_movement) - - if tank_input.fire: - _fire(tick) - # Moves vehicle on hosts. func _handle_movement(movement : Vector2) -> void: if not is_multiplayer_authority(): @@ -92,9 +85,6 @@ func _handle_movement(movement : Vector2) -> void: # Steering steering = lerp(steering, deg_to_rad(max_steering_angle) * -movement.x, steering_lerp_factor) -func _on_input_sender_input_missing(_current_tick : int, _latest_known_input_tick : int): - print("Input is missing") - # Moves the turret on the host. func _move_turret(mouse_input : Vector2) -> void: # Return if not host. @@ -130,3 +120,18 @@ func die() -> void: global_position = player_spawner.get_random_spawn_point() _turret_tilt = 0 _turret_traverse = 0 + +func _on_input_sender_local_input(_tick): + print("Input sender local input is emitted on peer:%s" %multiplayer.get_unique_id()) + + +func _on_input_sender_missing_input(current_tick, latest_known_input_tick): + print("Input is missing on :%s" %name) + + +func _on_input_sender_network_input(tick): + _handle_movement(tank_input.movement) + _move_turret(tank_input.mouse_movement) + + if tank_input.fire: + _fire(tick) From 2233b3bbe2dbe387f05aaf6416be4171892478b6 Mon Sep 17 00:00:00 2001 From: tuysuztavsan Date: Sun, 10 May 2026 12:34:17 +0300 Subject: [PATCH 11/57] add simulator icon --- addons/netfox/icons/simulator.svg | 54 ++++++++++++++++++++++++ addons/netfox/icons/simulator.svg.import | 37 ++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 addons/netfox/icons/simulator.svg create mode 100644 addons/netfox/icons/simulator.svg.import diff --git a/addons/netfox/icons/simulator.svg b/addons/netfox/icons/simulator.svg new file mode 100644 index 000000000..4cf290d18 --- /dev/null +++ b/addons/netfox/icons/simulator.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + diff --git a/addons/netfox/icons/simulator.svg.import b/addons/netfox/icons/simulator.svg.import new file mode 100644 index 000000000..462d8e81c --- /dev/null +++ b/addons/netfox/icons/simulator.svg.import @@ -0,0 +1,37 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dyh2wa862n5kx" +path="res://.godot/imported/simulator.svg-a031f70114a4770a3b287d54c5d39a10.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/netfox/icons/simulator.svg" +dest_files=["res://.godot/imported/simulator.svg-a031f70114a4770a3b287d54c5d39a10.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false From 51f03fcb090d398241670e984dc84b3f002cb928 Mon Sep 17 00:00:00 2001 From: TuysuzTavsan Date: Sun, 10 May 2026 15:58:03 +0300 Subject: [PATCH 12/57] added comments on how simulator should work. --- addons/netfox/netfox.gd | 6 ++ addons/netfox/simulation/input_sender.gd.uid | 1 - addons/netfox/simulation/simulator.gd | 31 ------- addons/netfox/simulation/simulator.gd.uid | 1 - addons/netfox/simulator.gd | 93 ++++++++++++++++++++ 5 files changed, 99 insertions(+), 33 deletions(-) delete mode 100644 addons/netfox/simulation/input_sender.gd.uid delete mode 100644 addons/netfox/simulation/simulator.gd delete mode 100644 addons/netfox/simulation/simulator.gd.uid create mode 100644 addons/netfox/simulator.gd diff --git a/addons/netfox/netfox.gd b/addons/netfox/netfox.gd index 50ee55a7f..e46e10cc6 100644 --- a/addons/netfox/netfox.gd +++ b/addons/netfox/netfox.gd @@ -276,6 +276,12 @@ const TYPES: Array[Dictionary] = [ "script": ROOT + "/input_sender.gd", "icon": ROOT + "/icons/input-sender.svg" }, + { + "name": "Simulator", + "base": "Node", + "script": ROOT + "/simulator.gd", + "icon": ROOT + "/icons/simulator.svg" + }, ] func _enter_tree(): diff --git a/addons/netfox/simulation/input_sender.gd.uid b/addons/netfox/simulation/input_sender.gd.uid deleted file mode 100644 index ae0701ba2..000000000 --- a/addons/netfox/simulation/input_sender.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dgihodqy5q27e diff --git a/addons/netfox/simulation/simulator.gd b/addons/netfox/simulation/simulator.gd deleted file mode 100644 index 3474cf536..000000000 --- a/addons/netfox/simulation/simulator.gd +++ /dev/null @@ -1,31 +0,0 @@ -@tool -extends Node -class_name Simulator - -## Simulates for the ticks clients dont have information yet. -## [br][br] -## [Simulator] doesnt participate in RollBack at all, and will simulate only on local clients. [br] -## Its good idea to use [Simulator] whenever you want to give control of something to local player. - -## The root node for resolving node paths in properties. Defaults to the parent node. -@export var root: Node = get_parent() - -@export_group("State") -## Properties that define the game state. -## [br][br] -## State properties are recorded for each tick. -## State is restored when server broadcasts truth, [Simulator] then will accept this -## as true state and apply it. Only then if we have inputs for future ticks it will simulate them. -@export var state_properties: Array[String] - -@onready var _logger: NetfoxLogger = NetfoxLogger._for_netfox("Simulator:" + root.name) - -var _input_properties := _PropertyPool.new() - -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 diff --git a/addons/netfox/simulation/simulator.gd.uid b/addons/netfox/simulation/simulator.gd.uid deleted file mode 100644 index b1a238aca..000000000 --- a/addons/netfox/simulation/simulator.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bi4g87012gvok diff --git a/addons/netfox/simulator.gd b/addons/netfox/simulator.gd new file mode 100644 index 000000000..360c017f9 --- /dev/null +++ b/addons/netfox/simulator.gd @@ -0,0 +1,93 @@ +@tool +extends Node +class_name Simulator + +## @experimental [Simulator] name is a wip. [br] +## Simulates network logic depending on network authority. Make sure to read +## them before using [Simulator].[br] +## +## There are 3 seperate workflows [Simulator] operate on. [br][br] +## +## 1- Host - this [Simulator] has network authority, but [InputSender]'s +## input_node (your custom player_input.gdcript code) belongs to some other peer. +## This would be your typical server (host) but doesnt have to be if you are going +## for some custom solution (example: mesh network).[br] +## +## On host [Simulator] runs _simulated_tick functions with new inputs which +## is received by [InputSender]. After running _simulated_tick with new received +## inputs, [Simulator] broadcasts ground truth (state properties) to peers. +## Use this to code game logic that must run on host. If you would like to code +## additional host side logic (example: changing team only on host) you can check +## if its host or not in _simulated_tick. [br][br] +## +## 2- Authoritative peer - this [Simulator] doesnt have network authority, but +## [InputSender]s input_node (your custom player_input.gdscript code) belongs to +## local peer. This would be your typical player. [br] +## +## On authoritative peer, [Simulator] runs _simulated_tick with [InputSender]'s +## fresh local inputs (inputs that may or may not have been sent to server at this point). +## Upon receiving ground truth from host, [Simulator] compares difference in state +## and decide whether to use snapping or interpolating depending on threshold. +## After applying true state, [Simulator] re-runs _simulated_tick to reach current +## game state. [br][br] +## +## 3- Puppet peer - both [Simulator] and [InputSender]s input_node (your custom +## player_input.gdscript code) doesnt have authority. This is how you see remote +## players when you are playing the game. For example your friend is a puppet player +## in your game. [br] +## +## On puppet peers, [Simulator] only applies truth received from host and interpolate +## it. For most games this will be enough. Even with [InputSender] broadcast toggled on from +## project settings, there is no point in re-running _simulated_ticks because server +## sends states with inputs at the same time. For puppet peers we simply dont know +## their future inputs. [br][br] +## +## +## TODO: Simulator can have option to predict if input_broadcast is on for inputsender. [br] +## +## TODO: what about physics and physic stepping? [br] +## It can be coded with _simulated_ticks if you involve some local properties to script +## that has role in godots _physics_process. If we can avoid coding physic stepping we should. + +## The root node for resolving node paths in properties. Defaults to the parent node. +@export var root: Node = get_parent() + +## [Simulator] needs [InputSender] assigned to work with at the first place. +## Any authority change to [InputSender]'s input node (example PlayerInput) requires +## calling [method Simulator.process_settings]. +## Changing or assigning [InputSender] during runtime is not recommended by design, but also +## requires call to [method Simulator.process_settings]. +@export var listened_input_sender : InputSender = null + +## If true, [Simulator] will run _simulated_tick functions with fresh received inputs. +## Set this to true, if you want to code host side logic with client inputs. +## For example: moving a vehicle on server with client inputs. +## NOTE: Dont get confused, if host is also player and owner of [InputSender] +## [Simulator] will run _simulated_tick even though this set to false (default). +@export var simulate_on_host := false + +## If enabled, takes a snapshot immediately upon instantiation, instead of +## waiting for the first network tick. Useful for objects that start moving +## instantly, like projectiles. +@export var record_first_state: bool = true + +@export_group("State") +## Properties that define the game state. +## [br][br] +## State properties are recorded for each tick. +## State is restored when host broadcasts truth, [Simulator] then will accept this +## as true state and apply it.[Simulator] will call _simulated_tick for the t. +@export var state_properties: Array[String] + +@onready var _logger: NetfoxLogger = NetfoxLogger._for_netfox("Simulator:" + root.name) + + +var _input_properties := _PropertyPool.new() + +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 From 04a1896c09922b34235b2d9ffb3d978733b3a255 Mon Sep 17 00:00:00 2001 From: TuysuzTavsan Date: Mon, 11 May 2026 01:13:46 +0300 Subject: [PATCH 13/57] more work done on simulator --- addons/netfox/simulator.gd | 152 ++++++++++++++++++++++++++++++++++++- 1 file changed, 151 insertions(+), 1 deletion(-) diff --git a/addons/netfox/simulator.gd b/addons/netfox/simulator.gd index 360c017f9..1bb1863ce 100644 --- a/addons/netfox/simulator.gd +++ b/addons/netfox/simulator.gd @@ -79,10 +79,21 @@ class_name Simulator ## as true state and apply it.[Simulator] will call _simulated_tick for the t. @export var state_properties: Array[String] +# Simulated nodes. +var _sim_nodes := [] as Array[Node] + +## Decides which peers will receive updates +var visibility_filter := PeerVisibilityFilter.new() + @onready var _logger: NetfoxLogger = NetfoxLogger._for_netfox("Simulator:" + root.name) +var _state_properties := _PropertyPool.new() + +var _properties_dirty: bool = false -var _input_properties := _PropertyPool.new() +# Dictionary (root node) -> (managing simulator) +# Used to check for foreign roots when gathering simulated nodes. +static var _managed_roots := {} func _ready() -> void: if Engine.is_editor_hint(): @@ -91,3 +102,142 @@ func _ready() -> void: if not NetworkTime.is_initial_sync_done(): # Wait for time sync to complete await NetworkTime.after_sync + +func _enter_tree() -> void: + if Engine.is_editor_hint(): + return + + _managed_roots[root] = self + + if not visibility_filter: + visibility_filter = PeerVisibilityFilter.new() + + if not visibility_filter.get_parent(): + add_child(visibility_filter) + + if not NetworkTime.is_initial_sync_done(): + # Wait for time sync to complete + await NetworkTime.after_sync + + process_settings.call_deferred() + +func _exit_tree() -> void: + _managed_roots.erase(root) + +func _notification(what: int) -> void: + if what == NOTIFICATION_EDITOR_PRE_SAVE: + update_configuration_warnings() + elif what == NOTIFICATION_PREDELETE: + for node in _sim_nodes + _state_properties.get_subjects(): + ## TODO Add deregister methods to below servers for simulator. + NetworkSynchronizationServer.deregister(node) + NetworkIdentityServer.deregister_node(node) + NetworkHistoryServer.deregister(node) + +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_simulator_state_properties", + func(node, prop): + add_state(node, prop) + )) + + return result + +## Process settings. +## [br][br] +## Call this after any change to configuration. Updates based on authority too +## ( calls process_authority ). +func process_settings() -> void: + _sim_nodes.clear() + + process_authority() + + # Gather simulated nodes. + var managed_nodes := [root] + _collect_managed_nodes(root) + _logger.debug("Filtering managed nodes: %s", [managed_nodes]) + for node in managed_nodes: + if node.has_method("_simulated_tick"): + _sim_nodes.push_back(node) + + # Register identifiers + for node in _state_properties.get_subjects(): + NetworkIdentityServer.register_node(node) + + # Register visibility filter + for node in _state_properties.get_subjects(): + NetworkSynchronizationServer.register_visibility_filter(node, visibility_filter) + +## Process settings based on authority. +## [br][br] +## Call this whenever the authority of input node changes. +## Make sure to do this at the same time on all peers. +func process_authority(): + # First de-register. + for node in _state_properties.get_subjects(): + for property in _state_properties.get_properties_of(node): + # TODO add deregister_simulator to NetworkHistoryServer. + # TODO add deregister_simulator to NetworkSyncronizationServer. + pass + + # Process authority + _state_properties.set_from_paths(root, state_properties) + + # Register state properties. + for node in _state_properties.get_subjects(): + for property in _state_properties.get_properties_of(node): + ## TODO add register_simulator to NetworkHistoryServer. + ## TODO add register_simulator to NetworkSynchronizationServer. + pass + +## 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() + +func _reprocess_settings() -> void: + if not _properties_dirty or Engine.is_editor_hint(): + return + + _properties_dirty = false + + process_settings() + +# Find managed nodes recursively from given root, ignoring branches managed by +# a different [Simulator]. +func _collect_managed_nodes(root: Node) -> Array[Node]: + var result: Array[Node] = [] + for child in root.get_children(): + if _is_foreign_simulator_root(child): + continue + result.append(child) + result.append_array(_collect_managed_nodes(child)) + return result + +# Returns true if the node is the root of a different [Simulator]. +func _is_foreign_simulator_root(node: Node) -> bool: + if not _managed_roots.has(node): + # No simulator, treat node as root + return false + + if _managed_roots[node] == self: + # Node is our own root + return false + + # Node is foreign root + return true From f724d2c0c4e790624a523fae20e8d29d0ecd8ae2 Mon Sep 17 00:00:00 2001 From: TuysuzTavsan Date: Mon, 11 May 2026 21:44:43 +0300 Subject: [PATCH 14/57] syncronization server integration for simulator node --- addons/netfox/netfox.gd | 13 ++ .../servers/network-synchronization-server.gd | 143 +++++++++++++++--- 2 files changed, 136 insertions(+), 20 deletions(-) diff --git a/addons/netfox/netfox.gd b/addons/netfox/netfox.gd index e46e10cc6..985dcf4fd 100644 --- a/addons/netfox/netfox.gd +++ b/addons/netfox/netfox.gd @@ -186,6 +186,19 @@ var SETTINGS: Array[Dictionary] = [ "value": false, "type" : TYPE_BOOL }, + # Simulator + { + "name": "netfox/simulator/full_state_interval", + "value": 24, + "type": TYPE_INT, + "hint": PROPERTY_HINT_RANGE, + "hint_string": "0,60,or_greater" + }, + { + "name": "netfox/simulator/enable_diff_states", + "value": true, + "type": TYPE_BOOL + }, ] const AUTOLOADS: Array[Dictionary] = [ diff --git a/addons/netfox/servers/network-synchronization-server.gd b/addons/netfox/servers/network-synchronization-server.gd index 25815b953..b2aaa7619 100644 --- a/addons/netfox/servers/network-synchronization-server.gd +++ b/addons/netfox/servers/network-synchronization-server.gd @@ -31,6 +31,8 @@ var _sync_state_properties := _PropertyPool.new() var _sync_owned_state_properties := _PropertyPool.new() var _input_sender_properties := _PropertyPool.new() var _input_sender_owned_properties := _PropertyPool.new() +var _simulator_properties := _PropertyPool.new() +var _simulator_owned_properties := _PropertyPool.new() var _visibility_filters := {} # Node to PeerVisibilityFilter @@ -38,11 +40,7 @@ var _rb_enable_input_broadcast := ProjectSettings.get_setting("netfox/rollback/e var _rb_enable_diffs := NetworkRollback.enable_diff_states var _rb_full_interval := ProjectSettings.get_setting("netfox/rollback/full_state_interval", 24) as int var _rb_full_scheduler := _IntervalScheduler.new(_rb_full_interval) -var _input_sender_enable_broadcast := ProjectSettings.get_setting("netfox/input_sender/enable_input_broadcast", false) as bool - - var _rb_input_redundancy := NetworkRollback.input_redundancy -var _input_sender_redundancy := ProjectSettings.get_setting("netfox/input_sender/input_redundancy", 3) as int var _last_sync_state_sent := _Snapshot.new(0) var _sync_enable_diffs := ProjectSettings.get_setting("netfox/state_synchronizer/enable_diff_states", true) as bool @@ -53,6 +51,14 @@ var _sync_full_scheduler := _IntervalScheduler.new(_sync_full_interval) # https://stackoverflow.com/a/35697810 var _max_packet_size := ProjectSettings.get_setting("netfox/general/max_sync_packet_size", 508) as int +var _input_sender_enable_broadcast := ProjectSettings.get_setting("netfox/input_sender/enable_input_broadcast", false) as bool +var _input_sender_redundancy := ProjectSettings.get_setting("netfox/input_sender/input_redundancy", 3) as int + +var _last_simulator_state_sent := _Snapshot.new(0) +var _simulator_enable_diffs := ProjectSettings.get_setting("netfox/simulator/enable_diff_states", true) as bool +var _simulator_full_interval := ProjectSettings.get_setting("netfox/simulator/full_state_interval", 24) as int +var _simulator_full_scheduler := _IntervalScheduler.new(_simulator_full_interval) + var _schemas := _NetworkSchema.new() var _dense_serializer: _DenseSnapshotSerializer @@ -62,7 +68,11 @@ var _redundant_serializer: _RedundantSnapshotSerializer var _cmd_full_state: NetworkCommandServer.Command var _cmd_diff_state: NetworkCommandServer.Command var _cmd_input: NetworkCommandServer.Command -var _cmd_input_sender : NetworkCommandServer.Command + +var _cmd_input_sender: NetworkCommandServer.Command + +var _cmd_full_simulator: NetworkCommandServer.Command +var _cmd_diff_simulator: NetworkCommandServer.Command var _cmd_full_sync: NetworkCommandServer.Command var _cmd_diff_sync: NetworkCommandServer.Command @@ -70,8 +80,9 @@ var _cmd_diff_sync: NetworkCommandServer.Command static var _logger := NetfoxLogger._for_netfox("NetworkSynchronizationServer") signal _on_input(snapshot: _Snapshot) -signal _on_input_sender(snapshot : _Snapshot) signal _on_state(snapshot: _Snapshot) +signal _on_input_sender(snapshot : _Snapshot) +signal _on_simulator(snapshot: _Snapshot) ## Register a [param property] of [param node] to be synchronized ## as rollback state @@ -125,6 +136,19 @@ func deregister_input_sender(node: Node, property: NodePath) -> void: _input_sender_properties.erase(node, property) _input_sender_owned_properties.erase(node, property) +## Register a [param property] of [param node] to be syncronized +## as simulator state. +func register_simulator(node: Node, property: NodePath) -> void: + _simulator_properties.add(node, property) + if node.is_multiplayer_authority(): + _simulator_owned_properties.add(node, property) + +## Deregister a [param property] of [param node] from being syncronized +## as simulator state. +func deregister_simulator(node: Node, property: NodePath) -> void: + _simulator_properties.erase(node, property) + _simulator_owned_properties.erase(node, property) + ## Register a [param serializer] to use when transmitting ## [param property param] of [param node] over the network func register_schema(node: Node, property: NodePath, serializer: NetworkSchemaSerializer) -> void: @@ -158,6 +182,8 @@ func deregister(node: Node) -> void: _sync_owned_state_properties.erase_subject(node) _input_sender_properties.erase_subject(node) _input_sender_owned_properties.erase_subject(node) + _simulator_properties.erase_subject(node) + _simulator_owned_properties.erase_subject(node) _visibility_filters.erase(node) _schemas.erase_subject(node) @@ -349,6 +375,56 @@ func _synchronize_input_sender(tick: int) -> void: var data := _redundant_serializer.write_for(peer, snapshots, _input_sender_owned_properties) _cmd_input_sender.send(data, peer) +func _synchronize_simulator(tick: int) -> void: + # We don't own state, nothing to synchronize + if _simulator_owned_properties.is_empty(): + return + + # TODO fill historyserver functions here. + # Grab snapshot from NetworkHistoryServer + var snapshot := NetworkHistoryServer._get_synchronizer_state_snapshot(tick) + if not snapshot: + return + + # Figure out whether to send full- or diff state + var is_full := _simulator_full_scheduler.is_now() + if not _simulator_enable_diffs: + is_full = true + + if is_full: + # Send full states + for peer in multiplayer.get_peers(): + var filter := func(subject): return _is_node_visible_to(peer, subject) + + var data := _dense_serializer.write_for(peer, snapshot, _sync_owned_state_properties, filter) + if data.is_empty(): + # Peer can't see anything, send nothing + continue + + _cmd_full_simulator.send(data, peer) + + NetworkPerformance.push_full_state_props(snapshot.size()) + NetworkPerformance.push_sent_state_props(snapshot.size()) + else: + var diff := _Snapshot.make_patch(_last_simulator_state_sent, snapshot) + + # Send diffs + for peer in multiplayer.get_peers(): + var filter := func(subject): return _is_node_visible_to(peer, subject) + + var data := _sparse_serializer.write_for(peer, diff, _simulator_owned_properties, filter) + if data.is_empty(): + # Peer can't see anything, send nothing + continue + + _cmd_diff_simulator.send(data, peer) + + NetworkPerformance.push_full_state_props(snapshot.size()) + NetworkPerformance.push_sent_state_props(diff.size()) + + # Remember last sent state for diffing + # NOTE: This is a shared instance, theoretically shouldn't screw things up + _last_simulator_state_sent = snapshot func _init( p_command_server: _NetworkCommandServer = null, @@ -381,23 +457,14 @@ func _ready(): _cmd_full_state = _command_server.register_command(_handle_full_state, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) _cmd_diff_state = _command_server.register_command(_handle_diff_state, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) _cmd_input = _command_server.register_command(_handle_input, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) + _cmd_input_sender = _command_server.register_command(_handle_input_sender, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) - - _cmd_full_sync = _command_server.register_command(_handle_full_sync, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE_ORDERED) - _cmd_diff_sync = _command_server.register_command(_handle_diff_sync, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE_ORDERED) - -func _handle_input_sender(sender : int, data : PackedByteArray) -> void: - var buffer := StreamPeerBuffer.new() - buffer.data_array = data - var snapshots := _redundant_serializer.read_from(sender, _input_sender_properties, buffer, true) + _cmd_full_simulator = _command_server.register_command(_handle_full_simulator, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE_ORDERED) + _cmd_diff_simulator = _command_server.register_command(_handle_diff_simulator, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE_ORDERED) - for snapshot in snapshots: - snapshot.sanitize(sender) - - _logger.trace("Ingesting input_sender inputs: %s", [snapshot]) - if NetworkHistoryServer._merge_input_sender(snapshot): - _on_input_sender.emit(snapshot) + _cmd_full_sync = _command_server.register_command(_handle_full_sync, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE_ORDERED) + _cmd_diff_sync = _command_server.register_command(_handle_diff_sync, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE_ORDERED) func _handle_input(sender: int, data: PackedByteArray): var buffer := StreamPeerBuffer.new() @@ -449,6 +516,42 @@ func _handle_diff_sync(sender: int, data: PackedByteArray): NetworkHistoryServer._merge_synchronizer_state(snapshot) _logger.trace("Ingested sync diff: %s", [snapshot]) +func _handle_input_sender(sender : int, data : PackedByteArray) -> void: + var buffer := StreamPeerBuffer.new() + buffer.data_array = data + + var snapshots := _redundant_serializer.read_from(sender, _input_sender_properties, buffer, true) + + for snapshot in snapshots: + snapshot.sanitize(sender) + + _logger.trace("Ingesting input_sender inputs: %s", [snapshot]) + if NetworkHistoryServer._merge_input_sender(snapshot): + _on_input_sender.emit(snapshot) + +func _handle_full_simulator(sender : int, data : PackedByteArray) -> void: + var buffer := StreamPeerBuffer.new() + buffer.data_array = data + + var snapshot := _dense_serializer.read_from(sender, _simulator_properties, buffer, true) + snapshot.sanitize(sender) + + # TODO fill networkhistoryserver function here. +# NetworkHistoryServer._merge_simulator_state(snapshot) + # if merged emit _on_simulator + _logger.trace("Ingested simulator full state: %s", [snapshot]) + +func _handle_diff_simulator(sender : int, data : PackedByteArray) -> void: + var buffer := StreamPeerBuffer.new() + buffer.data_array = data + + var snapshot := _sparse_serializer.read_from(sender, _simulator_properties, buffer) + snapshot.sanitize(sender) + + # TODO fill networkhistoryServer functions + #NetworkHistoryServer._merge_simulator_state(snapshot) + _logger.trace("Ingested simulator diff state: %s", [snapshot]) + func _ingest_state(sender: int, snapshot: _Snapshot) -> void: snapshot.sanitize(sender) From b95141457dbaf30298309e14816b663d5e3c8dd6 Mon Sep 17 00:00:00 2001 From: TuysuzTavsan Date: Tue, 12 May 2026 00:22:43 +0300 Subject: [PATCH 15/57] initial work for simulator is complete --- addons/netfox/netfox.gd | 5 ++ .../netfox/servers/network-history-server.gd | 53 +++++++++++++++---- .../servers/network-synchronization-server.gd | 13 ++--- addons/netfox/simulator.gd | 9 ++-- 4 files changed, 58 insertions(+), 22 deletions(-) diff --git a/addons/netfox/netfox.gd b/addons/netfox/netfox.gd index 985dcf4fd..0fce2bced 100644 --- a/addons/netfox/netfox.gd +++ b/addons/netfox/netfox.gd @@ -199,6 +199,11 @@ var SETTINGS: Array[Dictionary] = [ "value": true, "type": TYPE_BOOL }, + { + "name": "netfox/simulator/history_limit", + "value": 64, + "type" : TYPE_INT + }, ] const AUTOLOADS: Array[Dictionary] = [ diff --git a/addons/netfox/servers/network-history-server.gd b/addons/netfox/servers/network-history-server.gd index 7c7b1cf0b..58b783536 100644 --- a/addons/netfox/servers/network-history-server.gd +++ b/addons/netfox/servers/network-history-server.gd @@ -8,20 +8,22 @@ class_name _NetworkHistoryServer ## History is stored for [br] ## 1- rollback state and inputs, ## 2- syncronized states, -## 3- input_sender inputs. +## 3- input_sender inputs, +## 4- simulator states. ## [br][br] -## Keeping history lets rollback restore earlier game states for resimulation, -## and enables [_NetworkSynchronizationServer] to send diff states by comparing -## against historical data. +## Keeping history is needed for rewind operations and it also enables comparing, +## sending diff states. var _rb_input_properties := _PropertyPool.new() var _rb_state_properties := _PropertyPool.new() var _sync_state_properties := _PropertyPool.new() var _input_sender_properties := _PropertyPool.new() +var _simulator_properties := _PropertyPool.new() var _rb_history_size := NetworkRollback.history_limit var _sync_history_size := ProjectSettings.get_setting("netfox/state_synchronizer/history_limit", 64) as int var _input_sender_history_size := ProjectSettings.get_setting("netfox/input_sender/history_limit", 64) as int +var _simulator_history_size := ProjectSettings.get_setting("netfox/simulator/history_limit", 64) as int var _ignored_subjects := _Set.new() @@ -30,12 +32,14 @@ var _rb_input_history := _PerObjectHistory.new(_rb_history_size) var _rb_state_history := _PerObjectHistory.new(_rb_history_size) var _sync_history := _PerObjectHistory.new(_sync_history_size) var _input_sender_history := _PerObjectHistory.new(_input_sender_history_size) +var _simulator_history := _PerObjectHistory.new(_simulator_history_size) # Cached snapshots for syncing var _rb_input_snapshots := _HistoryBuffer.new(_rb_history_size) var _rb_state_snapshots := _HistoryBuffer.new(_rb_history_size) var _sync_state_snapshots := _HistoryBuffer.new(_sync_history_size) var _input_sender_snapshots := _HistoryBuffer.new(_input_sender_history_size) +var _simulator_snapshots := _HistoryBuffer.new(_simulator_history_size) static var _logger := NetfoxLogger._for_netfox("NetworkHistoryServer") @@ -64,13 +68,21 @@ func deregister_sync_state(node: Node, property: NodePath) -> void: _sync_state_properties.erase(node, property) ## Register a input_sender input property -func register_input_sender(node : Node, property : NodePath) -> void: +func register_input_sender(node: Node, property: NodePath) -> void: _input_sender_properties.add(node, property) ## Deregister a input_sender input propert -func deregister_input_sender(node : Node, property : NodePath) -> void: +func deregister_input_sender(node: Node, property: NodePath) -> void: _input_sender_properties.erase(node, property) +## Register a simulator property +func register_simulator(node: Node, property: NodePath) -> void: + _simulator_properties.add(node, property) + +## Deregister a simulator property +func deregister_simulator(node: Node, property: NodePath) -> void: + _simulator_properties.erase(node, property) + ## Deregister a node, no longer tracking any property it had registered using ## any of the [code]register_*()[/code] methods func deregister(node: Node) -> void: @@ -79,16 +91,18 @@ func deregister(node: Node) -> void: _rb_input_properties.erase_subject(node) _sync_state_properties.erase_subject(node) _input_sender_properties.erase_subject(node) + _simulator_properties.erase_subject(node) # Erase from per-object history _rb_state_history.erase_subject(node) _rb_input_history.erase_subject(node) _sync_history.erase_subject(node) _input_sender_history.erase_subject(node) + _simulator_history.erase_subject(node) # Erase from per-tick history for history in [_rb_state_snapshots, _rb_input_snapshots,\ - _sync_state_snapshots, _input_sender_snapshots]: + _sync_state_snapshots, _input_sender_snapshots, _simulator_snapshots]: for value in history.values(): var snapshot := value as _Snapshot @@ -190,6 +204,17 @@ func _record_input_sender(tick: int) -> void: return subject.is_multiplayer_authority() ) +func _record_simulator(tick: int) -> void: + # TODO figure out how to handle recording simulator + # Basicly we only need to record for local authoritative player. + # For now record every simulator since its input authority is a far reference. + # To detect that we need to reach simulator.input_sender. + # By far reference i mean simulator.input_sender.input_property.is_multiplayer_authority ????? + # Better aproach would be to code a flag like is_authoritative_player in simulator + _record(tick, _simulator_history, _simulator_snapshots, _simulator_properties, false, func(subject: Node): + return subject.is_multiplayer_authority() + ) + func _restore_rollback_input(tick: int) -> bool: return _restore_latest(tick, _rb_input_history) @@ -199,9 +224,12 @@ func _restore_rollback_state(tick: int) -> bool: func _restore_synchronizer_state(tick: int) -> bool: return _restore_latest(tick, _sync_history) -func _restore_input_sender(tick : int) -> bool: +func _restore_input_sender(tick: int) -> bool: return _restore_latest(tick, _input_sender_history) +func _restore_simulator(tick: int) -> bool: + return _restore_latest(tick, _simulator_history) + func _get_rollback_input_snapshot(tick: int) -> _Snapshot: return _rb_input_snapshots.get_at(tick) @@ -211,9 +239,12 @@ func _get_rollback_state_snapshot(tick: int) -> _Snapshot: func _get_synchronizer_state_snapshot(tick: int) -> _Snapshot: return _sync_state_snapshots.get_at(tick) -func _get_input_sender_snapshot(tick : int) -> _Snapshot: +func _get_input_sender_snapshot(tick: int) -> _Snapshot: return _input_sender_snapshots.get_at(tick) +func _get_simulator_snapshot(tick: int) -> _Snapshot: + return _simulator_snapshots.get_at(tick) + func _merge_rollback_input(snapshot: _Snapshot) -> bool: _merge_snapshot(snapshot, _rb_input_snapshots, true) return _merge_history(snapshot, _rb_input_history, true) @@ -230,6 +261,10 @@ func _merge_input_sender(snapshot: _Snapshot) -> bool: _merge_snapshot(snapshot, _input_sender_snapshots, true) return _merge_history(snapshot, _input_sender_history, true) +func _merge_simulator(snapshot: _Snapshot) -> bool: + _merge_snapshot(snapshot, _simulator_snapshots, true) + return _merge_history(snapshot, _simulator_history, true) + func _record(tick: int, history: _PerObjectHistory, snapshots: _HistoryBuffer, property_pool: _PropertyPool, only_auth: bool, auth_filter: Callable) -> void: var snapshot := snapshots.get_at(tick, _Snapshot.new(tick)) as _Snapshot if not snapshots.has_at(tick): diff --git a/addons/netfox/servers/network-synchronization-server.gd b/addons/netfox/servers/network-synchronization-server.gd index b2aaa7619..14a6edb15 100644 --- a/addons/netfox/servers/network-synchronization-server.gd +++ b/addons/netfox/servers/network-synchronization-server.gd @@ -380,9 +380,7 @@ func _synchronize_simulator(tick: int) -> void: if _simulator_owned_properties.is_empty(): return - # TODO fill historyserver functions here. - # Grab snapshot from NetworkHistoryServer - var snapshot := NetworkHistoryServer._get_synchronizer_state_snapshot(tick) + var snapshot := NetworkHistoryServer._get_simulator_snapshot(tick) if not snapshot: return @@ -536,10 +534,9 @@ func _handle_full_simulator(sender : int, data : PackedByteArray) -> void: var snapshot := _dense_serializer.read_from(sender, _simulator_properties, buffer, true) snapshot.sanitize(sender) - # TODO fill networkhistoryserver function here. -# NetworkHistoryServer._merge_simulator_state(snapshot) - # if merged emit _on_simulator _logger.trace("Ingested simulator full state: %s", [snapshot]) + if NetworkHistoryServer._merge_simulator(snapshot): + _on_simulator.emit(snapshot) func _handle_diff_simulator(sender : int, data : PackedByteArray) -> void: var buffer := StreamPeerBuffer.new() @@ -548,9 +545,9 @@ func _handle_diff_simulator(sender : int, data : PackedByteArray) -> void: var snapshot := _sparse_serializer.read_from(sender, _simulator_properties, buffer) snapshot.sanitize(sender) - # TODO fill networkhistoryServer functions - #NetworkHistoryServer._merge_simulator_state(snapshot) _logger.trace("Ingested simulator diff state: %s", [snapshot]) + if NetworkHistoryServer._merge_simulator(snapshot): + _on_simulator.emit(snapshot) func _ingest_state(sender: int, snapshot: _Snapshot) -> void: snapshot.sanitize(sender) diff --git a/addons/netfox/simulator.gd b/addons/netfox/simulator.gd index 1bb1863ce..4eeac7d2c 100644 --- a/addons/netfox/simulator.gd +++ b/addons/netfox/simulator.gd @@ -129,7 +129,6 @@ func _notification(what: int) -> void: update_configuration_warnings() elif what == NOTIFICATION_PREDELETE: for node in _sim_nodes + _state_properties.get_subjects(): - ## TODO Add deregister methods to below servers for simulator. NetworkSynchronizationServer.deregister(node) NetworkIdentityServer.deregister_node(node) NetworkHistoryServer.deregister(node) @@ -182,8 +181,8 @@ func process_authority(): # First de-register. for node in _state_properties.get_subjects(): for property in _state_properties.get_properties_of(node): - # TODO add deregister_simulator to NetworkHistoryServer. - # TODO add deregister_simulator to NetworkSyncronizationServer. + NetworkHistoryServer.deregister_simulator(node, property) + NetworkSynchronizationServer.deregister_simulator(node, property) pass # Process authority @@ -192,8 +191,8 @@ func process_authority(): # Register state properties. for node in _state_properties.get_subjects(): for property in _state_properties.get_properties_of(node): - ## TODO add register_simulator to NetworkHistoryServer. - ## TODO add register_simulator to NetworkSynchronizationServer. + NetworkHistoryServer.register_simulator(node, property) + NetworkSynchronizationServer.register_simulator(node, property) pass ## Add a state property. From 1851aca684b7b74a9512767ca3b1fc23f811121b Mon Sep 17 00:00:00 2001 From: tuysuztavsan Date: Tue, 12 May 2026 16:03:24 +0300 Subject: [PATCH 16/57] wip for simulator actual logic --- addons/netfox/input_sender.gd | 46 ++++++------ .../netfox/servers/network-history-server.gd | 5 ++ addons/netfox/simulator.gd | 74 ++++++++++++++++++- 3 files changed, 101 insertions(+), 24 deletions(-) diff --git a/addons/netfox/input_sender.gd b/addons/netfox/input_sender.gd index 5a5eb6ca0..edfd262d7 100644 --- a/addons/netfox/input_sender.gd +++ b/addons/netfox/input_sender.gd @@ -128,11 +128,31 @@ 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() +## Helper function to determine if [InputSender] has authority over its input_properties +## This function iterates over input_properties subjects and checks if they have authority. +## If none of them has authority or no input_node is configured this will return false, +## If any of them has authority this will return true instantly. +## Its developers responsibility to always make sure input_nodes have same configuration. +## TODO make sure to document this responsibility to developer. +func has_authority_over_input_nodes() -> bool: + for subject in _input_properties.get_subjects(): + + # ObjectPool does not guarentee every subject is node. + if not subject is Node: + continue + + # Found input node, check if it has authority + if subject.is_multiplayer_authority(): + return true + + # Did not find any node, or none of them has authority. + return false + func _notification(what: int) -> void: if what == NOTIFICATION_EDITOR_PRE_SAVE: update_configuration_warnings() @@ -165,7 +185,7 @@ func _get_configuration_warnings() -> PackedStringArray: func _reprocess_settings() -> void: if not _properties_dirty or Engine.is_editor_hint(): return - + _properties_dirty = false process_settings() @@ -227,7 +247,7 @@ func _apply_snapshot_for_self(snapshot : _Snapshot) -> void: # If the local peer has authority over input_property node, apply latest inputs # and emit signal local_input. func _apply_and_emit_local_inputs(for_tick : int) -> void: - if not _has_authority_over_input_nodes(): + if not has_authority_over_input_nodes(): return var latest_local_snapshot := NetworkHistoryServer._get_input_sender_snapshot(for_tick) @@ -236,23 +256,3 @@ func _apply_and_emit_local_inputs(for_tick : int) -> void: _logger.trace("Applying local snapshot and emitting local_inputs: %s", [latest_local_snapshot]) _apply_snapshot_for_self(latest_local_snapshot) local_input.emit(for_tick) - -# Helper function to determine if InputSender has authority over its input_properties -# This function iterates over input_properties subjects and checks if they have authority. -# If none of them has authority or no input_node is configured this will return false, -# If any of them has authority this will return true instantly. -# Its developers responsibility to always make sure input_nodes have same configuration. -# TODO make sure to document this responsibility to developer. -func _has_authority_over_input_nodes() -> bool: - for subject in _input_properties.get_subjects(): - - # ObjectPool does not guarentee every subject is node. - if not subject is Node: - continue - - # Found input node, check if it has authority - if subject.is_multiplayer_authority(): - return true - - # Did not find any node, or none of them has authority. - return false diff --git a/addons/netfox/servers/network-history-server.gd b/addons/netfox/servers/network-history-server.gd index 58b783536..fdc842098 100644 --- a/addons/netfox/servers/network-history-server.gd +++ b/addons/netfox/servers/network-history-server.gd @@ -148,6 +148,11 @@ func get_latest_input_for(subjects: Array, tick: int) -> int: func get_latest_input_sender_for(subjects: Array, tick: int) -> int: return _get_latest_for(subjects, tick, _input_sender_history) +## Get the latest tick where any of the [param subjects] had simulator data +## available +func get_latest_simulator_for(subjects: Array, tick: int) -> int: + return _get_latest_for(subjects, tick, _input_sender_history) + ## Return how old is the latest rollback input data for any of the ## [param subjects], in ticks func get_input_age_for(subjects: Array, tick: int) -> int: diff --git a/addons/netfox/simulator.gd b/addons/netfox/simulator.gd index 4eeac7d2c..d127b05c2 100644 --- a/addons/netfox/simulator.gd +++ b/addons/netfox/simulator.gd @@ -49,6 +49,13 @@ class_name Simulator ## It can be coded with _simulated_ticks if you involve some local properties to script ## that has role in godots _physics_process. If we can avoid coding physic stepping we should. +# TODO explore and test order below. +# order insight: +# on before tick, input-sender records and syncronizes inputs +# on tick, input-sender runs its logic and emits its signals but its not realted with simulator. +# on-after-tick simulator will run its own logic depending on work mode explained above as 1-2-3. +# after running its logic, simulator will record and syncronize state depending on mode. + ## The root node for resolving node paths in properties. Defaults to the parent node. @export var root: Node = get_parent() @@ -91,6 +98,9 @@ var _state_properties := _PropertyPool.new() var _properties_dirty: bool = false +# Flag to connect signals only once. +var _signals_connected : bool = false + # Dictionary (root node) -> (managing simulator) # Used to check for foreign roots when gathering simulated nodes. static var _managed_roots := {} @@ -172,6 +182,10 @@ func process_settings() -> void: # Register visibility filter for node in _state_properties.get_subjects(): NetworkSynchronizationServer.register_visibility_filter(node, visibility_filter) + + if not _signals_connected: + _connect_signals() + _signals_connected = true ## Process settings based on authority. ## [br][br] @@ -217,7 +231,65 @@ func _reprocess_settings() -> void: process_settings() -# Find managed nodes recursively from given root, ignoring branches managed by +func _connect_signals() -> void: + NetworkTime.after_tick.connect(_on_after_tick) + +# Do logic depending on mode explained in class description. +func _on_after_tick(delta: float, tick: int) -> void: + + # Return if there is no listened input sender assigned. + if not listened_input_sender: + _logger.warning("%s listened_input_sender is needed for simulator to operate", + [name]) + return + + # Figure out which mode we are operating on. + var has_input_authority := listened_input_sender.has_authority_over_input_nodes() + var has_simulator_authority := is_multiplayer_authority() + + if has_input_authority: + # This is authoritative player + _handle_authoritative_peer(delta, tick) + return + + if has_simulator_authority: + # This is host + _handle_host(delta, tick) + return + + # this is puppet peer. + _handle_puppet_peer(delta, tick) + +# Check if there is a new snapshot from host +# if there is a new snapshot, apply and simulate onwards with buffered inputs. +func _handle_authoritative_peer(_delta: float, tick: int) -> void: + + var latest_input_tick := NetworkHistoryServer.get_latest_simulator_for( + _state_properties.get_subjects(), tick) + + var latest_received_snapshot := NetworkHistoryServer._get_simulator_snapshot(latest_input_tick) + +func _handle_host(_delta: float, _tick: int) -> void: + pass + +func _handle_puppet_peer(_delta: float, _tick: int) -> void: + pass + +# Helper function to apply given snapshot for only this node. +# TODO (same todo with input_sender)? +# Applying whole snapshot and iterating over ticks would be nicer +# if we decide to have singleton for this +func _apply_snapshot_for_self(snapshot : _Snapshot) -> void: + _logger.trace("Applying snapshot for self :%s", [snapshot]) + for subject in _state_properties.get_subjects(): + for property in _state_properties.get_properties_of(subject): + + if snapshot.has_property(subject, property): + var value := snapshot.get_property(subject, property) + # TODO is this should be node.set_indexed ?? + subject.set_indexed(property, value) + +# Find managed nodes recursively from given root, ignoring branches managed by # a different [Simulator]. func _collect_managed_nodes(root: Node) -> Array[Node]: var result: Array[Node] = [] From 7c8927c8911aa65afa7b46dc492fb9ab5323ef50 Mon Sep 17 00:00:00 2001 From: tuysuztavsan Date: Wed, 13 May 2026 14:46:40 +0300 Subject: [PATCH 17/57] another wip for simulator --- addons/netfox/simulator.gd | 58 ++++++++++++++++++++++++++++++-------- 1 file changed, 47 insertions(+), 11 deletions(-) diff --git a/addons/netfox/simulator.gd b/addons/netfox/simulator.gd index d127b05c2..4e6324ec4 100644 --- a/addons/netfox/simulator.gd +++ b/addons/netfox/simulator.gd @@ -52,9 +52,10 @@ class_name Simulator # TODO explore and test order below. # order insight: # on before tick, input-sender records and syncronizes inputs -# on tick, input-sender runs its logic and emits its signals but its not realted with simulator. +# on tick, input-sender runs its logic and emits its signals but its not related with simulator. # on-after-tick simulator will run its own logic depending on work mode explained above as 1-2-3. -# after running its logic, simulator will record and syncronize state depending on mode. +# after running its logic, simulator will record and syncronize state. +# Saving and syncronizing is done via NetworkTime right after emitting after_tick signal. ## The root node for resolving node paths in properties. Defaults to the parent node. @export var root: Node = get_parent() @@ -70,13 +71,8 @@ class_name Simulator ## Set this to true, if you want to code host side logic with client inputs. ## For example: moving a vehicle on server with client inputs. ## NOTE: Dont get confused, if host is also player and owner of [InputSender] -## [Simulator] will run _simulated_tick even though this set to false (default). -@export var simulate_on_host := false - -## If enabled, takes a snapshot immediately upon instantiation, instead of -## waiting for the first network tick. Useful for objects that start moving -## instantly, like projectiles. -@export var record_first_state: bool = true +## [Simulator] will run _simulated_tick even though this set to false. +@export var simulate_on_host := true @export_group("State") ## Properties that define the game state. @@ -101,6 +97,13 @@ var _properties_dirty: bool = false # Flag to connect signals only once. var _signals_connected : bool = false +# Latest input tick we did operation. This is saved to remember. +# TODO should we set this to -1 on process_settings? +var _latest_input_tick : int = -1 + +# Latest snapshot applied from host (source of truth) +var _latest_applied_snapshot : int = -1 + # Dictionary (root node) -> (managing simulator) # Used to check for foreign roots when gathering simulated nodes. static var _managed_roots := {} @@ -249,6 +252,9 @@ func _on_after_tick(delta: float, tick: int) -> void: if has_input_authority: # This is authoritative player + # Even if this is host application, treat this as authoritative_peer since it has + # input authority. + # TODO make sure this is not causing sync or authoritative history loss issues. _handle_authoritative_peer(delta, tick) return @@ -268,13 +274,43 @@ func _handle_authoritative_peer(_delta: float, tick: int) -> void: _state_properties.get_subjects(), tick) var latest_received_snapshot := NetworkHistoryServer._get_simulator_snapshot(latest_input_tick) + -func _handle_host(_delta: float, _tick: int) -> void: - pass +# Host needs to run _simulated_tick with new received inputs. +func _handle_host(delta: float, tick: int) -> void: + if not simulate_on_host: + return + + # Check if we need inputs to catch up. + + if _latest_input_tick == -1: + # This is the first tick host runs. + # Even though we could itarete over saved-inputs, for now we wont. + # Start from latest received input + # TODO Would be better if we did run from last authority_change? + + # -1 so we run this tick. + _latest_input_tick = tick - 1 + + + _logger.trace("host is looping to run simulated ticks, ticks to run: %s", [tick - _latest_input_tick]) + for i in range(_latest_input_tick + 1, tick + 1): + _apply_and_run_simulated_tick(delta, i) + + _latest_input_tick = tick +# For pupper peer we only need to interpolate latest state to new one. +# TODO Do we need to code interpolation? try it first +# TODO add prediction? i dont think its needed func _handle_puppet_peer(_delta: float, _tick: int) -> void: pass +# Helper function that applies inputs and runs simulated_tick on managed nodes. +func _apply_and_run_simulated_tick(_delta : float, tick : int) -> void: + _logger.trace("applying and running simulated tick #%s", [tick]) + # TODO fill + pass + # Helper function to apply given snapshot for only this node. # TODO (same todo with input_sender)? # Applying whole snapshot and iterating over ticks would be nicer From 1862e9a4d7cb07dc966af807cd43e81f207184f6 Mon Sep 17 00:00:00 2001 From: tuysuztavsan Date: Fri, 29 May 2026 01:20:47 +0300 Subject: [PATCH 18/57] notworking --- addons/netfox/input_sender.gd | 7 ++ .../netfox/servers/network-history-server.gd | 2 +- .../servers/network-synchronization-server.gd | 7 +- addons/netfox/simulator.gd | 77 +++++++++++++++---- 4 files changed, 74 insertions(+), 19 deletions(-) diff --git a/addons/netfox/input_sender.gd b/addons/netfox/input_sender.gd index edfd262d7..c0f3cde52 100644 --- a/addons/netfox/input_sender.gd +++ b/addons/netfox/input_sender.gd @@ -153,6 +153,13 @@ func has_authority_over_input_nodes() -> bool: # Did not find any node, or none of them has authority. return false +## Get latest input data available for this [InputSender]. +## Used by [Simulator] node internally. +func get_latest_received_information_tick(current_tick : int) -> int: + return NetworkHistoryServer.get_latest_input_sender_for( + _input_properties.get_subjects(), + current_tick) + func _notification(what: int) -> void: if what == NOTIFICATION_EDITOR_PRE_SAVE: update_configuration_warnings() diff --git a/addons/netfox/servers/network-history-server.gd b/addons/netfox/servers/network-history-server.gd index fdc842098..b9244ac51 100644 --- a/addons/netfox/servers/network-history-server.gd +++ b/addons/netfox/servers/network-history-server.gd @@ -151,7 +151,7 @@ func get_latest_input_sender_for(subjects: Array, tick: int) -> int: ## Get the latest tick where any of the [param subjects] had simulator data ## available func get_latest_simulator_for(subjects: Array, tick: int) -> int: - return _get_latest_for(subjects, tick, _input_sender_history) + return _get_latest_for(subjects, tick, _simulator_history) ## Return how old is the latest rollback input data for any of the ## [param subjects], in ticks diff --git a/addons/netfox/servers/network-synchronization-server.gd b/addons/netfox/servers/network-synchronization-server.gd index 14a6edb15..bd049b881 100644 --- a/addons/netfox/servers/network-synchronization-server.gd +++ b/addons/netfox/servers/network-synchronization-server.gd @@ -394,7 +394,7 @@ func _synchronize_simulator(tick: int) -> void: for peer in multiplayer.get_peers(): var filter := func(subject): return _is_node_visible_to(peer, subject) - var data := _dense_serializer.write_for(peer, snapshot, _sync_owned_state_properties, filter) + var data := _dense_serializer.write_for(peer, snapshot, _simulator_owned_properties, filter) if data.is_empty(): # Peer can't see anything, send nothing continue @@ -458,8 +458,9 @@ func _ready(): _cmd_input_sender = _command_server.register_command(_handle_input_sender, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) - _cmd_full_simulator = _command_server.register_command(_handle_full_simulator, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE_ORDERED) - _cmd_diff_simulator = _command_server.register_command(_handle_diff_simulator, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE_ORDERED) + # TODO which one makes sense ordered or not ? + _cmd_full_simulator = _command_server.register_command(_handle_full_simulator, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) + _cmd_diff_simulator = _command_server.register_command(_handle_diff_simulator, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) _cmd_full_sync = _command_server.register_command(_handle_full_sync, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE_ORDERED) _cmd_diff_sync = _command_server.register_command(_handle_diff_sync, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE_ORDERED) diff --git a/addons/netfox/simulator.gd b/addons/netfox/simulator.gd index 4e6324ec4..627f3c359 100644 --- a/addons/netfox/simulator.gd +++ b/addons/netfox/simulator.gd @@ -200,7 +200,6 @@ func process_authority(): for property in _state_properties.get_properties_of(node): NetworkHistoryServer.deregister_simulator(node, property) NetworkSynchronizationServer.deregister_simulator(node, property) - pass # Process authority _state_properties.set_from_paths(root, state_properties) @@ -210,7 +209,6 @@ func process_authority(): for property in _state_properties.get_properties_of(node): NetworkHistoryServer.register_simulator(node, property) NetworkSynchronizationServer.register_simulator(node, property) - pass ## Add a state property. ## [br][br] @@ -270,10 +268,40 @@ func _on_after_tick(delta: float, tick: int) -> void: # if there is a new snapshot, apply and simulate onwards with buffered inputs. func _handle_authoritative_peer(_delta: float, tick: int) -> void: - var latest_input_tick := NetworkHistoryServer.get_latest_simulator_for( + # Get latest tick where we had sync data available for this simulator. + var latest_simulator_tick := NetworkHistoryServer.get_latest_simulator_for( _state_properties.get_subjects(), tick) - var latest_received_snapshot := NetworkHistoryServer._get_simulator_snapshot(latest_input_tick) + # Apply latest_snapshot. + var latest_received_snapshot := NetworkHistoryServer._get_simulator_snapshot(latest_simulator_tick) + if latest_received_snapshot: + _apply_snapshot_for_self(latest_received_snapshot) + else: + _logger.trace("Apply snapshot called but snapshot is invalid, assuming its first frame\ + and snapshot is not received yet.") + + # Now that we accepted truth from host, we can run simulated_ticks + # with our stored inputs. + # For now ignore the race between this function and saving inputs on + # input_sender's input node. + + _logger.trace("Authoritative peer is looping to run simulated ticks, \ + from inclusive tick %s to exclusive tick %s", [latest_simulator_tick, tick]) + + # TODO double check this range pls. + for i in range(latest_simulator_tick, tick): + _logger.trace("Running simulator tick #%s", [i]) + var local_input_snapshot := NetworkHistoryServer._get_input_sender_snapshot(i) + + # TODO sometimes local_input_snapshot is null, figure out why! + if not local_input_snapshot: + _logger.trace("Authoritative peer is running simulated ticks, \ + local input snapshot is null at tick %s" %i) + continue + + listened_input_sender._apply_snapshot_for_self(local_input_snapshot) + for node in _sim_nodes: + node.call("_simulated_tick", NetworkTime.seconds_between(i, i + 1), i) # Host needs to run _simulated_tick with new received inputs. @@ -283,6 +311,7 @@ func _handle_host(delta: float, tick: int) -> void: # Check if we need inputs to catch up. + # Guard to set _latest_input_tick to current -1 if this is the first time this runs. if _latest_input_tick == -1: # This is the first tick host runs. # Even though we could itarete over saved-inputs, for now we wont. @@ -292,24 +321,42 @@ func _handle_host(delta: float, tick: int) -> void: # -1 so we run this tick. _latest_input_tick = tick - 1 + # Compare input ticks. + var latest_input_tick := listened_input_sender.get_latest_received_information_tick(tick) + if latest_input_tick == _latest_input_tick: + _logger.trace("Host is skipping simulation this tick because there is no new input") + return - _logger.trace("host is looping to run simulated ticks, ticks to run: %s", [tick - _latest_input_tick]) - for i in range(_latest_input_tick + 1, tick + 1): - _apply_and_run_simulated_tick(delta, i) + var ticks_to_run := latest_input_tick - _latest_input_tick + + _logger.trace("Host is looping to run simulated ticks, ticks to run: %s", [ticks_to_run]) + for i in range(_latest_input_tick + 1, latest_input_tick + 1): + + # get and apply input_sender_snapshot + # TODO read below. + # DONT GET CONFUSED! Code below actually overrides properties of input_sender's + # input node. However its not improtant and will not override local inputs + # because THIS IS HOST! Not authoritative peer. + # This is a problem for authority changes and can be fixed easly later. + var snapshot := NetworkHistoryServer._get_input_sender_snapshot(i) + listened_input_sender._apply_snapshot_for_self(snapshot) + for node in _sim_nodes: + node.call("_simulated_tick", NetworkTime.seconds_between(i, i + 1), i) _latest_input_tick = tick # For pupper peer we only need to interpolate latest state to new one. # TODO Do we need to code interpolation? try it first # TODO add prediction? i dont think its needed -func _handle_puppet_peer(_delta: float, _tick: int) -> void: - pass - -# Helper function that applies inputs and runs simulated_tick on managed nodes. -func _apply_and_run_simulated_tick(_delta : float, tick : int) -> void: - _logger.trace("applying and running simulated tick #%s", [tick]) - # TODO fill - pass +func _handle_puppet_peer(_delta: float, tick: int) -> void: + var latest_simulator_tick := NetworkHistoryServer.get_latest_simulator_for( + _state_properties.get_subjects(), tick) + + var latest_received_snapshot := NetworkHistoryServer._get_simulator_snapshot(latest_simulator_tick) + if latest_received_snapshot: + _apply_snapshot_for_self(latest_received_snapshot) + + # TODO interpolation? try with interpolator first. # Helper function to apply given snapshot for only this node. # TODO (same todo with input_sender)? From eb515a54a7d3c810cab7afaa9fad913adddbd3d3 Mon Sep 17 00:00:00 2001 From: tuysuztavsan Date: Fri, 29 May 2026 14:03:30 +0300 Subject: [PATCH 19/57] quick example folder for simulator --- .../scenes/simulated_player.tscn | 33 +++++++++++++++++++ .../scripts/simulated_player.gd | 33 +++++++++++++++++++ .../scripts/simulated_player_input.gd | 21 ++++++++++++ .../simulated_player_example.tscn | 23 +++++++++++++ 4 files changed, 110 insertions(+) create mode 100644 examples/simulated-player/scenes/simulated_player.tscn create mode 100644 examples/simulated-player/scripts/simulated_player.gd create mode 100644 examples/simulated-player/scripts/simulated_player_input.gd create mode 100644 examples/simulated-player/simulated_player_example.tscn diff --git a/examples/simulated-player/scenes/simulated_player.tscn b/examples/simulated-player/scenes/simulated_player.tscn new file mode 100644 index 000000000..1d2871109 --- /dev/null +++ b/examples/simulated-player/scenes/simulated_player.tscn @@ -0,0 +1,33 @@ +[gd_scene load_steps=7 format=3 uid="uid://co2srxfbf8dac"] + +[ext_resource type="Script" path="res://examples/simulated-player/scripts/simulated_player.gd" id="1_4n6wb"] +[ext_resource type="Script" path="res://examples/simulated-player/scripts/simulated_player_input.gd" id="2_8xdxw"] +[ext_resource type="Script" path="res://addons/netfox/input_sender.gd" id="3_po4f2"] +[ext_resource type="Script" path="res://addons/netfox/simulator.gd" id="4_mmxoi"] + +[sub_resource type="CapsuleMesh" id="CapsuleMesh_icq3d"] + +[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_05200"] + +[node name="SimulatedPlayer" type="CharacterBody3D"] +script = ExtResource("1_4n6wb") + +[node name="MeshInstance3D" type="MeshInstance3D" parent="."] +mesh = SubResource("CapsuleMesh_icq3d") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="."] +shape = SubResource("CapsuleShape3D_05200") + +[node name="Input" type="Node" parent="."] +script = ExtResource("2_8xdxw") + +[node name="InputSender" type="Node" parent="." node_paths=PackedStringArray("root")] +script = ExtResource("3_po4f2") +root = NodePath("..") +input_properties = Array[String](["Input:movement", "Input:jump"]) + +[node name="Simulator" type="Node" parent="." node_paths=PackedStringArray("root", "listened_input_sender")] +script = ExtResource("4_mmxoi") +root = NodePath("..") +listened_input_sender = NodePath("../InputSender") +state_properties = Array[String]([":global_transform", ":velocity"]) diff --git a/examples/simulated-player/scripts/simulated_player.gd b/examples/simulated-player/scripts/simulated_player.gd new file mode 100644 index 000000000..d152c8a7e --- /dev/null +++ b/examples/simulated-player/scripts/simulated_player.gd @@ -0,0 +1,33 @@ +extends CharacterBody3D + + +const SPEED = 5.0 +const JUMP_VELOCITY = 4.5 + +# Get the gravity from the project settings to be synced with RigidBody nodes. +var gravity = ProjectSettings.get_setting("physics/3d/default_gravity") + +@onready var input = $Input + + +func _simulated_tick(delta : float, _tick : int): + # Add the gravity. + if not is_on_floor(): + velocity.y -= gravity * delta + + # Handle Jump. + if input.jump and is_on_floor(): + velocity.y = JUMP_VELOCITY + + # Get the input direction and handle the movement/deceleration. + # As good practice, you should replace UI actions with custom gameplay actions. + var input_dir = Vector2(input.movement.x, input.movement.z) + var direction = (transform.basis * Vector3(input_dir.x, 0, input_dir.y)).normalized() + if direction: + velocity.x = direction.x * SPEED + velocity.z = direction.z * SPEED + else: + velocity.x = move_toward(velocity.x, 0, SPEED) + velocity.z = move_toward(velocity.z, 0, SPEED) + + move_and_slide() diff --git a/examples/simulated-player/scripts/simulated_player_input.gd b/examples/simulated-player/scripts/simulated_player_input.gd new file mode 100644 index 000000000..17b360351 --- /dev/null +++ b/examples/simulated-player/scripts/simulated_player_input.gd @@ -0,0 +1,21 @@ +extends Node + +# Input script for simulated player example. + +var movement: Vector3 = Vector3.ZERO +var jump: bool = false + +func _ready(): + NetworkTime.before_tick_loop.connect(_gather) + +func _gather(): + if not is_multiplayer_authority(): + return + + # Get the input direction and handle the movement/deceleration. + # As good practice, you should replace UI actions with custom gameplay actions. + var mx = Input.get_axis("ui_left", "ui_right") + var mz = Input.get_axis("ui_up", "ui_down") + movement = Vector3(mx, 0, mz) + + jump = Input.is_action_pressed("move_jump") diff --git a/examples/simulated-player/simulated_player_example.tscn b/examples/simulated-player/simulated_player_example.tscn new file mode 100644 index 000000000..238aefc5f --- /dev/null +++ b/examples/simulated-player/simulated_player_example.tscn @@ -0,0 +1,23 @@ +[gd_scene load_steps=7 format=3 uid="uid://1m61lxyrckhr"] + +[ext_resource type="PackedScene" uid="uid://cngy6hs8ohodj" path="res://examples/shared/scenes/map-square.tscn" id="1_t21fn"] +[ext_resource type="PackedScene" uid="uid://cncdbq72u50j3" path="res://examples/shared/scenes/environment.tscn" id="2_epr86"] +[ext_resource type="PackedScene" uid="uid://badtpsxn5lago" path="res://examples/shared/ui/network-popup.tscn" id="3_q107i"] +[ext_resource type="PackedScene" uid="uid://bpf1jdr255nr0" path="res://examples/shared/ui/time-display.tscn" id="4_qcrnb"] +[ext_resource type="Script" path="res://examples/shared/scripts/player-spawner.gd" id="5_3uvn6"] +[ext_resource type="PackedScene" uid="uid://co2srxfbf8dac" path="res://examples/simulated-player/scenes/simulated_player.tscn" id="6_15sfs"] + +[node name="SimulatedPlayerExample" type="Node3D"] + +[node name="Square Map" parent="." instance=ExtResource("1_t21fn")] + +[node name="Environment" parent="." instance=ExtResource("2_epr86")] + +[node name="Network Popup" parent="." instance=ExtResource("3_q107i")] + +[node name="Time Display" parent="." instance=ExtResource("4_qcrnb")] + +[node name="Players" type="Node" parent="." node_paths=PackedStringArray("spawn_root")] +script = ExtResource("5_3uvn6") +player_scene = ExtResource("6_15sfs") +spawn_root = NodePath(".") From f3842658935c341b5d0e92f7cde4a26a5023ac53 Mon Sep 17 00:00:00 2001 From: tuysuztavsan Date: Sat, 30 May 2026 01:34:03 +0300 Subject: [PATCH 20/57] rewrite of input-sender but needs more work done to get it work. --- addons/netfox/input_sender.gd | 163 +++++++++++++----- addons/netfox/simulator.gd | 43 ++--- .../scripts/server_side_tank.gd | 7 +- .../scenes/simulated_player.tscn | 1 + 4 files changed, 148 insertions(+), 66 deletions(-) diff --git a/addons/netfox/input_sender.gd b/addons/netfox/input_sender.gd index c0f3cde52..6251741c0 100644 --- a/addons/netfox/input_sender.gd +++ b/addons/netfox/input_sender.gd @@ -8,19 +8,21 @@ class_name InputSender ## [InputSender] is a multi purpose node to use on networked games, ## It provides signals to code host and client side logic. ## [InputSender] signals are tied and emitted on [signal NetworkTime.on_tick]. +## [InputSender] will still emit signals with tick paremeters which belongs to +## their recorded ticks. ## ## @experimental: ## [InputSender] assumes input snapshots arrive as whole. (atomic), if snapshot ## arrives with multiple parts, [InputSender] signals wont be reliable to ## code game logic. -## Emitted when [InputSender] receives input from remote owner of input_properties. +## Emitted if [InputSender] received input from remote owner of input_properties. ## [InputSender] handles applying received input internally before emitting this signal. ## Emitted only if [InputSender] has authority. ## Use this signal to code host side logic. signal network_input(tick : int) -## Emitted for every tick if local peer has authority over input_property nodes. +## Emitted if local peer has authority over input_property nodes. ## [InputSender] will apply latest local inputs for this tick internally before ## emitting this signal. ## Use this signal to code client side logic which doesnt interfere with actual game state. @@ -30,9 +32,13 @@ signal network_input(tick : int) ## using some other method to syncronize game state (Syncronizers). signal local_input(tick : int) -## Emitted when [InputSender] doesnt receive anything from client on [signal NetworkTime.on_tick] -## [InputSender] will apply latest known input internally before emitting this signal. +## Emitted if [InputSender] didnt receive anything from client for a tick on +# [signal NetworkTime.on_tick]. +## [InputSender] will apply latest known input that comes before missing tick +## internally before emitting this signal. ## Emitted only if [InputSender] is authority. +## If host couldnt find known previous input, latest_known_input_tick will be -1. +## In that scenario, [InputSender] will not be able to have correct inputs applied. ## Use this signal to code host side prediction logic. signal missing_input(current_tick : int, latest_known_input_tick : int) @@ -52,9 +58,23 @@ var visibility_filter := PeerVisibilityFilter.new() var _input_properties := _PropertyPool.new() var _properties_dirty: bool = false -var _last_emitted_tick: int = -1 + +# Stored latest ticks +var _last_network_tick : int = -1 +var _last_local_tick : int = -1 +var _last_missing_tick : int = -1 +var _last_emitted_tick : int = -1 + var _logger := NetfoxLogger._for_netfox("InputSender") +# We need these to de-couple input-senders working logic from recording. +# InputSender applies state and emits signals, but this can change saved and synced +# input properties, to prevent that, input-sender will save its pre-logic-inputs +# and apply them whenever logic ends. +var _saved_inputs_snapshot : _PropertySnapshot +var _property_cache: PropertyCache +var _property_entries: Array[PropertyEntry] = [] + # Flag to connect signals only once. var _signals_connected : bool = false @@ -88,6 +108,15 @@ func _enter_tree() -> void: func process_settings() -> void: process_authority() + _property_cache = PropertyCache.new(root) + _property_entries.clear() + + _saved_inputs_snapshot = _PropertySnapshot.new() + + for property in input_properties: + var property_entry = _property_cache.get_entry(property) + _property_entries.push_back(property_entry) + # Register identifiers for node in _input_properties.get_subjects(): NetworkIdentityServer.register_node(node) @@ -105,6 +134,11 @@ func process_settings() -> void: ## Call this whenever the authority of input node changes. ## Make sure to do this at the same time on all peers. func process_authority(): + + _last_local_tick = -1 + _last_missing_tick = -1 + _last_network_tick = -1 + for node in _input_properties.get_subjects(): for property in _input_properties.get_properties_of(node): NetworkHistoryServer.deregister_input_sender(node, property) @@ -205,38 +239,26 @@ func _connect_signals() -> void: # [InputSender] is authority, # If did not receive new network snapshots, applies latest and emits input_missing # with latest snapshot. -func _on_tick(delta: float, tick: int) -> void: - # First handle local_input signalling. - _apply_and_emit_local_inputs(tick) +func _on_tick(_delta: float, _tick: int) -> void: + # Save input states to remember, this is done to avoid changing saved inputs here. + _saved_inputs_snapshot = _PropertySnapshot.extract(_property_entries) + + # Handle authoritative peer first. + if has_authority_over_input_nodes(): + _handle_authoritative_peer() + # Apply saved inputs before returning, this prevents improper input save. + _saved_inputs_snapshot.apply(_property_cache) + return # Move on to the network_input and input_missing signalling. + # input_missing and network_input signals are only emitted on host. if not is_multiplayer_authority(): return - # Get the latest input data available - # Known issue: If input sender is configured with multiple input nodes, - # Any fresh input from one node will trigger re-emitting of other node's inputs? - # TODO: look at above issue. - var latest_input_tick := NetworkHistoryServer.get_latest_input_sender_for( - _input_properties.get_subjects(), tick) - - if latest_input_tick == _last_emitted_tick: - # There is no new input data available - var latest_snapshot := NetworkHistoryServer._get_input_sender_snapshot(latest_input_tick) - if latest_snapshot: - _logger.trace("No new input is received, will emit input_missing after applying \ - snapshot: %s", [latest_snapshot]) - - _apply_snapshot_for_self(latest_snapshot) - missing_input.emit(tick, latest_input_tick) - else: - # Iterate over fresh inputs and emit a signal with fresh inputs applied. - for i in range(_last_emitted_tick + 1, latest_input_tick + 1): - var snapshot := NetworkHistoryServer._get_input_sender_snapshot(i) - if snapshot: - _apply_snapshot_for_self(snapshot) - network_input.emit(i) - _last_emitted_tick = i + _handle_host() + # Apply saved inputs before returning, this prevents improper save. + _saved_inputs_snapshot.apply(_property_cache) + # Helper function to apply given snapshot for only this node. # TODO Applying whole snapshot and iterating over ticks would be nicer @@ -251,15 +273,76 @@ func _apply_snapshot_for_self(snapshot : _Snapshot) -> void: # TODO is this should be node.set_indexed ?? subject.set_indexed(property, value) -# If the local peer has authority over input_property node, apply latest inputs +# If [InputSender] has multiplayer_authority, check for new or missing inputs from +# latest emitted tick and emit signals. +# This function shouldnt run if host also owns the input node. +# In that case, _handle_authoritative_peer function should run. +func _handle_host() -> void: + # Get the latest input data available + # Known issue: If input sender is configured with multiple input nodes, + # Any fresh input from one node will trigger re-emitting of other node's inputs? + # TODO: look at above issue. + var latest_input_tick := NetworkHistoryServer.get_latest_input_sender_for( + _input_properties.get_subjects(), NetworkTime.tick) + + # If this is first iteration, start from current tick -1, so we run at least 1 input. + if _last_emitted_tick == -1: + _last_emitted_tick = NetworkTime.tick - 1 + + var start_tick := _last_emitted_tick + 1 + + for i in range(start_tick, NetworkTime.tick + 1): + var snapshot := NetworkHistoryServer._get_input_sender_snapshot(i) + + if snapshot: + _logger.trace("Applying networked snapshot and emitting network_input with inputs %s", [snapshot]) + _apply_snapshot_for_self(snapshot) + network_input.emit(i) + _last_emitted_tick = i + else: + # We dont have snapshot available for that tick. + # Find latest known input and emit input_missing + var latest_known_tick := NetworkHistoryServer.get_latest_input_sender_for( + _input_properties.get_subjects(), i) + + if latest_known_tick >= 0: + var latest_snapshot := NetworkHistoryServer._get_input_sender_snapshot(latest_known_tick) + if latest_snapshot: + _apply_snapshot_for_self(latest_snapshot) + + _logger.trace("Emitting missing input") + missing_input.emit(i, latest_known_tick) + _last_emitted_tick = i + +# If the local peer has authority over input node, apply latest inputs # and emit signal local_input. -func _apply_and_emit_local_inputs(for_tick : int) -> void: - if not has_authority_over_input_nodes(): +func _handle_authoritative_peer() -> void: + var latest_tick := NetworkHistoryServer.get_latest_input_sender_for(_input_properties.get_subjects(),\ + NetworkTime.tick) + + # Latest tick shouldnt be -1 here anyway since we have information available as local player + # But leave this here until we have more stable structure. + if latest_tick == -1: + _logger.error("Authoritative peer doesnt have any local input snapshot! This shouldnt happen.") return - var latest_local_snapshot := NetworkHistoryServer._get_input_sender_snapshot(for_tick) + var tick_start_inclusive : int = -1 + var tick_end_inclusive : int = NetworkTime.tick - if latest_local_snapshot: - _logger.trace("Applying local snapshot and emitting local_inputs: %s", [latest_local_snapshot]) - _apply_snapshot_for_self(latest_local_snapshot) - local_input.emit(for_tick) + # If this is first iteration, start from current tick, else +1 + if _last_emitted_tick == -1: + tick_start_inclusive = NetworkTime.tick - 1 + else: + tick_start_inclusive = _last_emitted_tick + 1 + + _logger.trace("On authoritative peer, iterating over new inputs and emitting local_input, \ + ticks to handle %s", [tick_end_inclusive - tick_start_inclusive]) + + for i in range(tick_start_inclusive, tick_end_inclusive + 1, 1): + var local_snapshot := NetworkHistoryServer._get_input_sender_snapshot(i) + + if local_snapshot: + _logger.trace("Applying local snapshot and emitting local_inputs: %s", [local_snapshot]) + _apply_snapshot_for_self(local_snapshot) + local_input.emit(i) + _last_emitted_tick = i diff --git a/addons/netfox/simulator.gd b/addons/netfox/simulator.gd index 627f3c359..c15a3e29f 100644 --- a/addons/netfox/simulator.gd +++ b/addons/netfox/simulator.gd @@ -272,13 +272,15 @@ func _handle_authoritative_peer(_delta: float, tick: int) -> void: var latest_simulator_tick := NetworkHistoryServer.get_latest_simulator_for( _state_properties.get_subjects(), tick) + # If its -1 we never received snapshot, thus no need to apply it. + if latest_simulator_tick != -1: # Apply latest_snapshot. - var latest_received_snapshot := NetworkHistoryServer._get_simulator_snapshot(latest_simulator_tick) - if latest_received_snapshot: - _apply_snapshot_for_self(latest_received_snapshot) - else: - _logger.trace("Apply snapshot called but snapshot is invalid, assuming its first frame\ - and snapshot is not received yet.") + var latest_received_snapshot := NetworkHistoryServer._get_simulator_snapshot(latest_simulator_tick) + if latest_received_snapshot: + _apply_snapshot_for_self(latest_received_snapshot) + else: + _logger.trace("Apply snapshot called but snapshot is invalid, assuming its first frame"+\ + " and snapshot is not received yet.") # Now that we accepted truth from host, we can run simulated_ticks # with our stored inputs. @@ -309,22 +311,19 @@ func _handle_host(delta: float, tick: int) -> void: if not simulate_on_host: return - # Check if we need inputs to catch up. + # Get latest received input tick. + var latest_input_tick := listened_input_sender.get_latest_received_information_tick(tick) - # Guard to set _latest_input_tick to current -1 if this is the first time this runs. - if _latest_input_tick == -1: - # This is the first tick host runs. - # Even though we could itarete over saved-inputs, for now we wont. - # Start from latest received input - # TODO Would be better if we did run from last authority_change? - - # -1 so we run this tick. - _latest_input_tick = tick - 1 + if latest_input_tick == -1: + # Never received input. + # Cant run simulation without inputs. + _logger.trace("Host is skipping simulation on #%s because host never received input", [tick]) + return - # Compare input ticks. - var latest_input_tick := listened_input_sender.get_latest_received_information_tick(tick) + # If latest equals our stored latest_tick, this means we already run this simulation. + # Cant run if inputs are not new, return. if latest_input_tick == _latest_input_tick: - _logger.trace("Host is skipping simulation this tick because there is no new input") + _logger.trace("Host is skipping simulation on #%s because there is no new input", [tick]) return var ticks_to_run := latest_input_tick - _latest_input_tick @@ -332,12 +331,6 @@ func _handle_host(delta: float, tick: int) -> void: _logger.trace("Host is looping to run simulated ticks, ticks to run: %s", [ticks_to_run]) for i in range(_latest_input_tick + 1, latest_input_tick + 1): - # get and apply input_sender_snapshot - # TODO read below. - # DONT GET CONFUSED! Code below actually overrides properties of input_sender's - # input node. However its not improtant and will not override local inputs - # because THIS IS HOST! Not authoritative peer. - # This is a problem for authority changes and can be fixed easly later. var snapshot := NetworkHistoryServer._get_input_sender_snapshot(i) listened_input_sender._apply_snapshot_for_self(snapshot) for node in _sim_nodes: diff --git a/examples/server-side-vehicle/scripts/server_side_tank.gd b/examples/server-side-vehicle/scripts/server_side_tank.gd index dc3fad479..b8e3d92b3 100644 --- a/examples/server-side-vehicle/scripts/server_side_tank.gd +++ b/examples/server-side-vehicle/scripts/server_side_tank.gd @@ -121,8 +121,13 @@ func die() -> void: _turret_tilt = 0 _turret_traverse = 0 -func _on_input_sender_local_input(_tick): +func _on_input_sender_local_input(tick): print("Input sender local input is emitted on peer:%s" %multiplayer.get_unique_id()) + _handle_movement(tank_input.movement) + _move_turret(tank_input.mouse_movement) + + if tank_input.fire: + _fire(tick) func _on_input_sender_missing_input(current_tick, latest_known_input_tick): diff --git a/examples/simulated-player/scenes/simulated_player.tscn b/examples/simulated-player/scenes/simulated_player.tscn index 1d2871109..613b6846d 100644 --- a/examples/simulated-player/scenes/simulated_player.tscn +++ b/examples/simulated-player/scenes/simulated_player.tscn @@ -10,6 +10,7 @@ [sub_resource type="CapsuleShape3D" id="CapsuleShape3D_05200"] [node name="SimulatedPlayer" type="CharacterBody3D"] +collision_mask = 3 script = ExtResource("1_4n6wb") [node name="MeshInstance3D" type="MeshInstance3D" parent="."] From eff993840d0fab9a99b968da023e9e1c7feed639 Mon Sep 17 00:00:00 2001 From: tuysuztavsan Date: Sat, 30 May 2026 03:29:19 +0300 Subject: [PATCH 21/57] input sender revisited --- addons/netfox/input_sender.gd | 62 +++++++++++++++++++++++------------ addons/netfox/netfox.gd | 5 +++ 2 files changed, 46 insertions(+), 21 deletions(-) diff --git a/addons/netfox/input_sender.gd b/addons/netfox/input_sender.gd index 6251741c0..89356586b 100644 --- a/addons/netfox/input_sender.gd +++ b/addons/netfox/input_sender.gd @@ -59,11 +59,11 @@ var visibility_filter := PeerVisibilityFilter.new() var _input_properties := _PropertyPool.new() var _properties_dirty: bool = false -# Stored latest ticks +# Stored latest ticks for signals. var _last_network_tick : int = -1 var _last_local_tick : int = -1 -var _last_missing_tick : int = -1 -var _last_emitted_tick : int = -1 +var _missing_ticks : Array[int] = [] +var _missing_inputs_history_size : int = 16 var _logger := NetfoxLogger._for_netfox("InputSender") @@ -136,8 +136,9 @@ func process_settings() -> void: func process_authority(): _last_local_tick = -1 - _last_missing_tick = -1 _last_network_tick = -1 + _missing_inputs_history_size = ProjectSettings.get_setting("netfox/input_sender/missing_input_history", 16) + _missing_ticks = [] for node in _input_properties.get_subjects(): for property in _input_properties.get_properties_of(node): @@ -285,34 +286,53 @@ func _handle_host() -> void: var latest_input_tick := NetworkHistoryServer.get_latest_input_sender_for( _input_properties.get_subjects(), NetworkTime.tick) - # If this is first iteration, start from current tick -1, so we run at least 1 input. - if _last_emitted_tick == -1: - _last_emitted_tick = NetworkTime.tick - 1 - var start_tick := _last_emitted_tick + 1 + # First handle network_inputs. + if _last_network_tick == -1: + _last_network_tick = NetworkTime.tick - 1 - for i in range(start_tick, NetworkTime.tick + 1): + var start_tick := _last_network_tick + + for i in range(start_tick, NetworkTime.tick): var snapshot := NetworkHistoryServer._get_input_sender_snapshot(i) if snapshot: _logger.trace("Applying networked snapshot and emitting network_input with inputs %s", [snapshot]) _apply_snapshot_for_self(snapshot) network_input.emit(i) - _last_emitted_tick = i + _last_network_tick = i else: - # We dont have snapshot available for that tick. - # Find latest known input and emit input_missing + # Consider input is missing + _missing_ticks.push_back(i) + + # Now handle missing_inputs + + var to_erase : Array[int] = [] + + for i in _missing_ticks: + if NetworkTime.tick - i > _missing_inputs_history_size: var latest_known_tick := NetworkHistoryServer.get_latest_input_sender_for( _input_properties.get_subjects(), i) if latest_known_tick >= 0: - var latest_snapshot := NetworkHistoryServer._get_input_sender_snapshot(latest_known_tick) - if latest_snapshot: - _apply_snapshot_for_self(latest_snapshot) + var snapshot := NetworkHistoryServer._get_input_sender_snapshot(latest_known_tick) + _apply_snapshot_for_self(snapshot) + + _logger.trace("Input is missing for more than history size. Considering lost.") + missing_input.emit(NetworkTime.tick, latest_known_tick) - _logger.trace("Emitting missing input") - missing_input.emit(i, latest_known_tick) - _last_emitted_tick = i + to_erase.push_back(i) + continue + + var snapshot := NetworkHistoryServer._get_input_sender_snapshot(i) + + if snapshot: + # We found previously missing input. + _logger.trace("Previously missing input snapshot now valid, emitting network_input with\ + inputs %s", [snapshot]) + _apply_snapshot_for_self(snapshot) + network_input.emit(i) + to_erase.push_back(i) # If the local peer has authority over input node, apply latest inputs # and emit signal local_input. @@ -330,10 +350,10 @@ func _handle_authoritative_peer() -> void: var tick_end_inclusive : int = NetworkTime.tick # If this is first iteration, start from current tick, else +1 - if _last_emitted_tick == -1: + if _last_local_tick == -1: tick_start_inclusive = NetworkTime.tick - 1 else: - tick_start_inclusive = _last_emitted_tick + 1 + tick_start_inclusive = _last_local_tick + 1 _logger.trace("On authoritative peer, iterating over new inputs and emitting local_input, \ ticks to handle %s", [tick_end_inclusive - tick_start_inclusive]) @@ -345,4 +365,4 @@ func _handle_authoritative_peer() -> void: _logger.trace("Applying local snapshot and emitting local_inputs: %s", [local_snapshot]) _apply_snapshot_for_self(local_snapshot) local_input.emit(i) - _last_emitted_tick = i + _last_local_tick = i diff --git a/addons/netfox/netfox.gd b/addons/netfox/netfox.gd index 0fce2bced..acbd60f80 100644 --- a/addons/netfox/netfox.gd +++ b/addons/netfox/netfox.gd @@ -181,6 +181,11 @@ var SETTINGS: Array[Dictionary] = [ "value": 64, "type" : TYPE_INT }, + { + "name": "netfox/input_sender/missing_input_history", + "value": 16, + "type" : TYPE_INT + }, { "name": "netfox/input_sender/enable_input_broadcast", "value": false, From 7a9e4630daf256fda0ad7e9ee7625e9d051d4bc5 Mon Sep 17 00:00:00 2001 From: tuysuztavsan Date: Sat, 30 May 2026 13:45:06 +0300 Subject: [PATCH 22/57] input sender working again after revisit --- addons/netfox/input_sender.gd | 61 +++++++++++-------- .../scripts/server_side_tank.gd | 6 +- 2 files changed, 36 insertions(+), 31 deletions(-) diff --git a/addons/netfox/input_sender.gd b/addons/netfox/input_sender.gd index 89356586b..f637bca37 100644 --- a/addons/netfox/input_sender.gd +++ b/addons/netfox/input_sender.gd @@ -60,7 +60,6 @@ var _input_properties := _PropertyPool.new() var _properties_dirty: bool = false # Stored latest ticks for signals. -var _last_network_tick : int = -1 var _last_local_tick : int = -1 var _missing_ticks : Array[int] = [] var _missing_inputs_history_size : int = 16 @@ -136,7 +135,6 @@ func process_settings() -> void: func process_authority(): _last_local_tick = -1 - _last_network_tick = -1 _missing_inputs_history_size = ProjectSettings.get_setting("netfox/input_sender/missing_input_history", 16) _missing_ticks = [] @@ -279,7 +277,6 @@ func _apply_snapshot_for_self(snapshot : _Snapshot) -> void: # This function shouldnt run if host also owns the input node. # In that case, _handle_authoritative_peer function should run. func _handle_host() -> void: - # Get the latest input data available # Known issue: If input sender is configured with multiple input nodes, # Any fresh input from one node will trigger re-emitting of other node's inputs? # TODO: look at above issue. @@ -287,26 +284,27 @@ func _handle_host() -> void: _input_properties.get_subjects(), NetworkTime.tick) - # First handle network_inputs. - if _last_network_tick == -1: - _last_network_tick = NetworkTime.tick - 1 - - var start_tick := _last_network_tick - - for i in range(start_tick, NetworkTime.tick): - var snapshot := NetworkHistoryServer._get_input_sender_snapshot(i) - + # TODO below. + # This check right here actually doesnt make sense since inputs are always arrived + # with latency in real life. + # but this way or another we still need to check them in a way and loop them over + # for missing inputs anyway so its not really that bad. + if latest_input_tick == NetworkTime.tick: + # We should have a snapshot + var snapshot := NetworkHistoryServer._get_input_sender_snapshot(NetworkTime.tick) if snapshot: - _logger.trace("Applying networked snapshot and emitting network_input with inputs %s", [snapshot]) + _logger.trace("On host applying networked snapshot and emitting network_input with\ + inputs %s", [snapshot]) _apply_snapshot_for_self(snapshot) - network_input.emit(i) - _last_network_tick = i + network_input.emit(NetworkTime.tick) else: # Consider input is missing - _missing_ticks.push_back(i) - - # Now handle missing_inputs + _missing_ticks.push_back(NetworkTime.tick) + else: + # Consider input is missing + _missing_ticks.push_back(NetworkTime.tick) + # Now handle previously missing_inputs var to_erase : Array[int] = [] for i in _missing_ticks: @@ -314,6 +312,8 @@ func _handle_host() -> void: var latest_known_tick := NetworkHistoryServer.get_latest_input_sender_for( _input_properties.get_subjects(), i) + _logger.trace("for tick %s, latest_known_tick is %s", [i, latest_known_tick]) + if latest_known_tick >= 0: var snapshot := NetworkHistoryServer._get_input_sender_snapshot(latest_known_tick) _apply_snapshot_for_self(snapshot) @@ -324,15 +324,24 @@ func _handle_host() -> void: to_erase.push_back(i) continue - var snapshot := NetworkHistoryServer._get_input_sender_snapshot(i) + var latest_known_tick := NetworkHistoryServer.get_latest_input_sender_for( + _input_properties.get_subjects(), i) - if snapshot: - # We found previously missing input. - _logger.trace("Previously missing input snapshot now valid, emitting network_input with\ - inputs %s", [snapshot]) - _apply_snapshot_for_self(snapshot) - network_input.emit(i) - to_erase.push_back(i) + if latest_known_tick == i: + # We now have information available for previously missing input. + var snapshot := NetworkHistoryServer._get_input_sender_snapshot(latest_known_tick) + + if snapshot: + # We found previously missing input. + _logger.trace("Previously missing input snapshot now valid, emitting network_input with\ + inputs %s", [snapshot]) + _apply_snapshot_for_self(snapshot) + network_input.emit(i) + to_erase.push_back(i) + + # Clean up + for i in to_erase: + _missing_ticks.erase(i) # If the local peer has authority over input node, apply latest inputs # and emit signal local_input. diff --git a/examples/server-side-vehicle/scripts/server_side_tank.gd b/examples/server-side-vehicle/scripts/server_side_tank.gd index b8e3d92b3..4e2eabef8 100644 --- a/examples/server-side-vehicle/scripts/server_side_tank.gd +++ b/examples/server-side-vehicle/scripts/server_side_tank.gd @@ -48,11 +48,6 @@ func _unhandled_input(event): if not tank_input.is_multiplayer_authority(): return - if Input.is_action_just_pressed("weapon_fire"): - # Dont fire on host machine as it will fire already on _on_input_sender_new_input_received - if not multiplayer.is_server(): - _fire(NetworkTime.tick) - if event.is_action_pressed("focus"): if focus_camera.current: focus_camera.current = false @@ -100,6 +95,7 @@ func _move_turret(mouse_input : Vector2) -> void: _turret_tilt = clamp(_turret_tilt, deg_to_rad(tilt_lower_limit), deg_to_rad(tilt_upper_limit)) turret.basis = turret.basis.rotated(turret.basis.x, _turret_tilt) +# Fires only on the host.. func _fire(tick : int) -> void: if tick - _last_fire_tick < fire_cooldown_tick: return From 8c2432e45b227de2aca02017b5a251478b07132d Mon Sep 17 00:00:00 2001 From: tuysuztavsan Date: Sat, 30 May 2026 16:37:27 +0300 Subject: [PATCH 23/57] simulator known input issue --- addons/netfox/servers/network-history-server.gd | 8 +------- addons/netfox/simulator.gd | 9 +++++---- .../simulated-player/scenes/simulated_player.tscn | 1 + .../simulated-player/scripts/simulated_player.gd | 15 +++++++++++---- .../scripts/simulated_player_input.gd | 1 + 5 files changed, 19 insertions(+), 15 deletions(-) diff --git a/addons/netfox/servers/network-history-server.gd b/addons/netfox/servers/network-history-server.gd index b9244ac51..f8aae33e6 100644 --- a/addons/netfox/servers/network-history-server.gd +++ b/addons/netfox/servers/network-history-server.gd @@ -210,13 +210,7 @@ func _record_input_sender(tick: int) -> void: ) func _record_simulator(tick: int) -> void: - # TODO figure out how to handle recording simulator - # Basicly we only need to record for local authoritative player. - # For now record every simulator since its input authority is a far reference. - # To detect that we need to reach simulator.input_sender. - # By far reference i mean simulator.input_sender.input_property.is_multiplayer_authority ????? - # Better aproach would be to code a flag like is_authoritative_player in simulator - _record(tick, _simulator_history, _simulator_snapshots, _simulator_properties, false, func(subject: Node): + _record(tick, _simulator_history, _simulator_snapshots, _simulator_properties, true, func(subject: Node): return subject.is_multiplayer_authority() ) diff --git a/addons/netfox/simulator.gd b/addons/netfox/simulator.gd index c15a3e29f..d65bf0b90 100644 --- a/addons/netfox/simulator.gd +++ b/addons/netfox/simulator.gd @@ -252,7 +252,6 @@ func _on_after_tick(delta: float, tick: int) -> void: # This is authoritative player # Even if this is host application, treat this as authoritative_peer since it has # input authority. - # TODO make sure this is not causing sync or authoritative history loss issues. _handle_authoritative_peer(delta, tick) return @@ -273,10 +272,11 @@ func _handle_authoritative_peer(_delta: float, tick: int) -> void: _state_properties.get_subjects(), tick) # If its -1 we never received snapshot, thus no need to apply it. - if latest_simulator_tick != -1: + if latest_simulator_tick >= 0: # Apply latest_snapshot. var latest_received_snapshot := NetworkHistoryServer._get_simulator_snapshot(latest_simulator_tick) if latest_received_snapshot: + _logger.trace("Authoritative peer applying latest received snapshot as truth: %s", [latest_received_snapshot]) _apply_snapshot_for_self(latest_received_snapshot) else: _logger.trace("Apply snapshot called but snapshot is invalid, assuming its first frame"+\ @@ -284,8 +284,6 @@ func _handle_authoritative_peer(_delta: float, tick: int) -> void: # Now that we accepted truth from host, we can run simulated_ticks # with our stored inputs. - # For now ignore the race between this function and saving inputs on - # input_sender's input node. _logger.trace("Authoritative peer is looping to run simulated ticks, \ from inclusive tick %s to exclusive tick %s", [latest_simulator_tick, tick]) @@ -301,6 +299,9 @@ func _handle_authoritative_peer(_delta: float, tick: int) -> void: local input snapshot is null at tick %s" %i) continue + _logger.trace("Authoritative peer is applying input snapshot %s and running tick", + [local_input_snapshot]) + listened_input_sender._apply_snapshot_for_self(local_input_snapshot) for node in _sim_nodes: node.call("_simulated_tick", NetworkTime.seconds_between(i, i + 1), i) diff --git a/examples/simulated-player/scenes/simulated_player.tscn b/examples/simulated-player/scenes/simulated_player.tscn index 613b6846d..f0ca2dc5e 100644 --- a/examples/simulated-player/scenes/simulated_player.tscn +++ b/examples/simulated-player/scenes/simulated_player.tscn @@ -10,6 +10,7 @@ [sub_resource type="CapsuleShape3D" id="CapsuleShape3D_05200"] [node name="SimulatedPlayer" type="CharacterBody3D"] +collision_layer = 3 collision_mask = 3 script = ExtResource("1_4n6wb") diff --git a/examples/simulated-player/scripts/simulated_player.gd b/examples/simulated-player/scripts/simulated_player.gd index d152c8a7e..f8adc92f6 100644 --- a/examples/simulated-player/scripts/simulated_player.gd +++ b/examples/simulated-player/scripts/simulated_player.gd @@ -9,16 +9,16 @@ var gravity = ProjectSettings.get_setting("physics/3d/default_gravity") @onready var input = $Input - func _simulated_tick(delta : float, _tick : int): + print("Running simulated tick.") # Add the gravity. if not is_on_floor(): velocity.y -= gravity * delta - + # Handle Jump. if input.jump and is_on_floor(): velocity.y = JUMP_VELOCITY - + # Get the input direction and handle the movement/deceleration. # As good practice, you should replace UI actions with custom gameplay actions. var input_dir = Vector2(input.movement.x, input.movement.z) @@ -29,5 +29,12 @@ func _simulated_tick(delta : float, _tick : int): else: velocity.x = move_toward(velocity.x, 0, SPEED) velocity.z = move_toward(velocity.z, 0, SPEED) - + + print("input_jump is :%s" %input.jump) + print("input_movement is :%s" %input.movement) + print("velocity is :%s" %velocity) + print("position is before move and slide: %s" %position) + velocity *= NetworkTime.physics_factor move_and_slide() + velocity /= NetworkTime.physics_factor + print("position is after move and slide: %s" %position) diff --git a/examples/simulated-player/scripts/simulated_player_input.gd b/examples/simulated-player/scripts/simulated_player_input.gd index 17b360351..11f97bb0f 100644 --- a/examples/simulated-player/scripts/simulated_player_input.gd +++ b/examples/simulated-player/scripts/simulated_player_input.gd @@ -19,3 +19,4 @@ func _gather(): movement = Vector3(mx, 0, mz) jump = Input.is_action_pressed("move_jump") + print("inputs: movement %s, jump %s" %[movement, jump]) From f68b9e04dc697701a3742ff96dc9a117c77fc801 Mon Sep 17 00:00:00 2001 From: tuysuztavsan Date: Sun, 31 May 2026 02:29:22 +0300 Subject: [PATCH 24/57] initial server work for input-sender! --- addons/netfox/input_sender.gd | 192 ++---------------- addons/netfox/netfox.gd | 6 +- addons/netfox/servers/input-sender-server.gd | 191 +++++++++++++++++ .../servers/network-synchronization-server.gd | 9 +- addons/netfox/simulator.gd | 121 ++++++----- .../simulated_player_example.tscn | 1 + 6 files changed, 294 insertions(+), 226 deletions(-) create mode 100644 addons/netfox/servers/input-sender-server.gd diff --git a/addons/netfox/input_sender.gd b/addons/netfox/input_sender.gd index f637bca37..e736eb660 100644 --- a/addons/netfox/input_sender.gd +++ b/addons/netfox/input_sender.gd @@ -7,40 +7,41 @@ class_name InputSender ## ## [InputSender] is a multi purpose node to use on networked games, ## It provides signals to code host and client side logic. -## [InputSender] signals are tied and emitted on [signal NetworkTime.on_tick]. -## [InputSender] will still emit signals with tick paremeters which belongs to -## their recorded ticks. +## [InputSender] signals are tied and emitted on [signal NetworkTime.after_tick_loop]. ## ## @experimental: ## [InputSender] assumes input snapshots arrive as whole. (atomic), if snapshot ## arrives with multiple parts, [InputSender] signals wont be reliable to ## code game logic. -## Emitted if [InputSender] received input from remote owner of input_properties. -## [InputSender] handles applying received input internally before emitting this signal. -## Emitted only if [InputSender] has authority. +## Emitted if host received input from remote owner of input_properties. +## InputSenderServer handles applying received input internally before emitting this signal. ## Use this signal to code host side logic. signal network_input(tick : int) ## Emitted if local peer has authority over input_property nodes. -## [InputSender] will apply latest local inputs for this tick internally before +## This signal is emitted for host players too. +## InputSenderServer will apply latest local inputs for this tick internally before ## emitting this signal. ## Use this signal to code client side logic which doesnt interfere with actual game state. ## Examples: Playing a sound, showing a visual effect. ## Dont use this signal to code same game logic on client side as it will not likely ## be same with remote host machine, it will cause syncing issues if you are already -## using some other method to syncronize game state (Syncronizers). +## using some other method to syncronize game state (Syncronizers/Simulators). signal local_input(tick : int) -## Emitted if [InputSender] didnt receive anything from client for a tick on -# [signal NetworkTime.on_tick]. -## [InputSender] will apply latest known input that comes before missing tick +## TODO check this documentation. +## Emitted if input is lost. +## Input is considered lost if its received older than missing-input-history or +## never received, under project settings netfox/input-sender. +# [signal NetworkTime.after_tick_loop]. +## InputSenderServer will try to apply latest known input that comes before missing tick ## internally before emitting this signal. ## Emitted only if [InputSender] is authority. ## If host couldnt find known previous input, latest_known_input_tick will be -1. ## In that scenario, [InputSender] will not be able to have correct inputs applied. ## Use this signal to code host side prediction logic. -signal missing_input(current_tick : int, latest_known_input_tick : int) +signal missing_input(for_tick : int, latest_known_input_tick : int) ## The root node for resolving node paths in inputs. Defaults to the parent node. @export var root: Node = get_parent() @@ -59,32 +60,18 @@ var visibility_filter := PeerVisibilityFilter.new() var _input_properties := _PropertyPool.new() var _properties_dirty: bool = false -# Stored latest ticks for signals. -var _last_local_tick : int = -1 -var _missing_ticks : Array[int] = [] -var _missing_inputs_history_size : int = 16 - var _logger := NetfoxLogger._for_netfox("InputSender") # We need these to de-couple input-senders working logic from recording. # InputSender applies state and emits signals, but this can change saved and synced # input properties, to prevent that, input-sender will save its pre-logic-inputs # and apply them whenever logic ends. +# +# TODO we are moving to server pattern, this might be not neccessary after that. var _saved_inputs_snapshot : _PropertySnapshot var _property_cache: PropertyCache var _property_entries: Array[PropertyEntry] = [] -# Flag to connect signals only once. -var _signals_connected : bool = false - -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 - func _enter_tree() -> void: if Engine.is_editor_hint(): return @@ -98,8 +85,12 @@ func _enter_tree() -> void: if not NetworkTime.is_initial_sync_done(): # Wait for time sync to complete await NetworkTime.after_sync + process_settings.call_deferred() +func _exit_tree(): + InputSenderServer._deregister_input_sender(self) + ## Process settings. ## [br][br] ## Call this after any change to configuration. Updates based on authority too @@ -123,21 +114,12 @@ func process_settings() -> void: # Register visibility filter for node in _input_properties.get_subjects(): NetworkSynchronizationServer.register_visibility_filter(node, visibility_filter) - - if not _signals_connected: - _connect_signals() - _signals_connected = true ## Process settings based on authority. ## [br][br] ## Call this whenever the authority of input node changes. ## Make sure to do this at the same time on all peers. func process_authority(): - - _last_local_tick = -1 - _missing_inputs_history_size = ProjectSettings.get_setting("netfox/input_sender/missing_input_history", 16) - _missing_ticks = [] - for node in _input_properties.get_subjects(): for property in _input_properties.get_properties_of(node): NetworkHistoryServer.deregister_input_sender(node, property) @@ -151,6 +133,8 @@ func process_authority(): for property in _input_properties.get_properties_of(node): NetworkHistoryServer.register_input_sender(node, property) NetworkSynchronizationServer.register_input_sender(node, property) + + InputSenderServer._register_input_sender(self) ## Add an input property. ## [br][br] @@ -229,36 +213,6 @@ func _reprocess_settings() -> void: _properties_dirty = false process_settings() -func _connect_signals() -> void: - NetworkTime.on_tick.connect(_on_tick) - -# Applies local snapshot and emits local_input if has authority over input nodes. -# Then -# applies new received network snapshots and emits network_input snapshots if -# [InputSender] is authority, -# If did not receive new network snapshots, applies latest and emits input_missing -# with latest snapshot. -func _on_tick(_delta: float, _tick: int) -> void: - # Save input states to remember, this is done to avoid changing saved inputs here. - _saved_inputs_snapshot = _PropertySnapshot.extract(_property_entries) - - # Handle authoritative peer first. - if has_authority_over_input_nodes(): - _handle_authoritative_peer() - # Apply saved inputs before returning, this prevents improper input save. - _saved_inputs_snapshot.apply(_property_cache) - return - - # Move on to the network_input and input_missing signalling. - # input_missing and network_input signals are only emitted on host. - if not is_multiplayer_authority(): - return - - _handle_host() - # Apply saved inputs before returning, this prevents improper save. - _saved_inputs_snapshot.apply(_property_cache) - - # Helper function to apply given snapshot for only this node. # TODO Applying whole snapshot and iterating over ticks would be nicer # if we decide to have singleton for this @@ -271,107 +225,3 @@ func _apply_snapshot_for_self(snapshot : _Snapshot) -> void: var value := snapshot.get_property(subject, property) # TODO is this should be node.set_indexed ?? subject.set_indexed(property, value) - -# If [InputSender] has multiplayer_authority, check for new or missing inputs from -# latest emitted tick and emit signals. -# This function shouldnt run if host also owns the input node. -# In that case, _handle_authoritative_peer function should run. -func _handle_host() -> void: - # Known issue: If input sender is configured with multiple input nodes, - # Any fresh input from one node will trigger re-emitting of other node's inputs? - # TODO: look at above issue. - var latest_input_tick := NetworkHistoryServer.get_latest_input_sender_for( - _input_properties.get_subjects(), NetworkTime.tick) - - - # TODO below. - # This check right here actually doesnt make sense since inputs are always arrived - # with latency in real life. - # but this way or another we still need to check them in a way and loop them over - # for missing inputs anyway so its not really that bad. - if latest_input_tick == NetworkTime.tick: - # We should have a snapshot - var snapshot := NetworkHistoryServer._get_input_sender_snapshot(NetworkTime.tick) - if snapshot: - _logger.trace("On host applying networked snapshot and emitting network_input with\ - inputs %s", [snapshot]) - _apply_snapshot_for_self(snapshot) - network_input.emit(NetworkTime.tick) - else: - # Consider input is missing - _missing_ticks.push_back(NetworkTime.tick) - else: - # Consider input is missing - _missing_ticks.push_back(NetworkTime.tick) - - # Now handle previously missing_inputs - var to_erase : Array[int] = [] - - for i in _missing_ticks: - if NetworkTime.tick - i > _missing_inputs_history_size: - var latest_known_tick := NetworkHistoryServer.get_latest_input_sender_for( - _input_properties.get_subjects(), i) - - _logger.trace("for tick %s, latest_known_tick is %s", [i, latest_known_tick]) - - if latest_known_tick >= 0: - var snapshot := NetworkHistoryServer._get_input_sender_snapshot(latest_known_tick) - _apply_snapshot_for_self(snapshot) - - _logger.trace("Input is missing for more than history size. Considering lost.") - missing_input.emit(NetworkTime.tick, latest_known_tick) - - to_erase.push_back(i) - continue - - var latest_known_tick := NetworkHistoryServer.get_latest_input_sender_for( - _input_properties.get_subjects(), i) - - if latest_known_tick == i: - # We now have information available for previously missing input. - var snapshot := NetworkHistoryServer._get_input_sender_snapshot(latest_known_tick) - - if snapshot: - # We found previously missing input. - _logger.trace("Previously missing input snapshot now valid, emitting network_input with\ - inputs %s", [snapshot]) - _apply_snapshot_for_self(snapshot) - network_input.emit(i) - to_erase.push_back(i) - - # Clean up - for i in to_erase: - _missing_ticks.erase(i) - -# If the local peer has authority over input node, apply latest inputs -# and emit signal local_input. -func _handle_authoritative_peer() -> void: - var latest_tick := NetworkHistoryServer.get_latest_input_sender_for(_input_properties.get_subjects(),\ - NetworkTime.tick) - - # Latest tick shouldnt be -1 here anyway since we have information available as local player - # But leave this here until we have more stable structure. - if latest_tick == -1: - _logger.error("Authoritative peer doesnt have any local input snapshot! This shouldnt happen.") - return - - var tick_start_inclusive : int = -1 - var tick_end_inclusive : int = NetworkTime.tick - - # If this is first iteration, start from current tick, else +1 - if _last_local_tick == -1: - tick_start_inclusive = NetworkTime.tick - 1 - else: - tick_start_inclusive = _last_local_tick + 1 - - _logger.trace("On authoritative peer, iterating over new inputs and emitting local_input, \ - ticks to handle %s", [tick_end_inclusive - tick_start_inclusive]) - - for i in range(tick_start_inclusive, tick_end_inclusive + 1, 1): - var local_snapshot := NetworkHistoryServer._get_input_sender_snapshot(i) - - if local_snapshot: - _logger.trace("Applying local snapshot and emitting local_inputs: %s", [local_snapshot]) - _apply_snapshot_for_self(local_snapshot) - local_input.emit(i) - _last_local_tick = i diff --git a/addons/netfox/netfox.gd b/addons/netfox/netfox.gd index acbd60f80..ff27ab5b5 100644 --- a/addons/netfox/netfox.gd +++ b/addons/netfox/netfox.gd @@ -259,7 +259,11 @@ const AUTOLOADS: Array[Dictionary] = [ { "name": "InterpolationServer", "path": ROOT + "/servers/interpolation-server.gd" - } + }, + { + "name": "InputSenderServer", + "path": ROOT + "/servers/input-sender-server.gd" + }, ] const TYPES: Array[Dictionary] = [ diff --git a/addons/netfox/servers/input-sender-server.gd b/addons/netfox/servers/input-sender-server.gd new file mode 100644 index 000000000..21552044d --- /dev/null +++ b/addons/netfox/servers/input-sender-server.gd @@ -0,0 +1,191 @@ +extends Node +class_name _InputSenderServer + +# @public class + +## Handles [InputSender] related operations. + +## InputSenderServer assumes input snapshots arrive as whole. (atomic), if snapshot +## arrives with multiple parts, [InputSender] signals wont be reliable to +## code game logic. + +## TODO TODO TODO +## 1- Dont forget to restore input sender state after tick loop. +## 2- Handle local inputs on tick? or after_tick_loop + +static var _logger := NetfoxLogger._for_netfox("InputSenderServer") + +var _history_server : NetworkHistoryServer = null +var _synchronization_server : NetworkSynchronizationServer = null +var _missing_inputs_history_size : int = 16 +var _earliest_input := -1 + +# Maps InputSender -> InputSenderHistory (inner class, look at the end of this script.) +var _per_input_sender_history : Dictionary = {} + +func _ready(): + # Ensure dependencies + if not _history_server: _history_server = NetworkHistoryServer + if not _synchronization_server: _synchronization_server = NetworkSynchronizationServer + + _missing_inputs_history_size = ProjectSettings.get_setting("netfox/input_sender/missing_input_history", 16) + + # Record inputs similiar to rollback so that users can use same methods + # on their input scripts. + NetworkTime.after_tick.connect(func(_dt, tick): + _history_server._record_input_sender(tick) + _synchronization_server._synchronize_input_sender(tick) + ) + + _synchronization_server._on_input_sender.connect(_on_received_input_snapshot) + + # About when we should process our inputs: + # It doesnt make sense to process them when we receive them, as it will break the + # work order of [Simulator]s or [TickInterpolator]s, or we might want this to work + # with physics. It would be unreliable. + # Therefore we need to process them in a fixed point. + # Processing them on every tick doesnt make sense, since host may run multiple + # ticks and we probably wont even have new inputs to process anyway. + # In conculusion it makes sense to process them after a tick loop run. + NetworkTime.after_tick_loop.connect(_after_tick_loop) + +# Whenever we receive input snapshot, we investigate and fetch _earliest_input tick +# This is later used to iterate over saved snapshots and emit signals. +func _on_received_input_snapshot(snapshot : _Snapshot) -> void: + if snapshot.is_empty(): + return + if _earliest_input < 0 or snapshot.tick < _earliest_input: + _logger.trace("Ingested input @%d, earliest @%d->@%d", [snapshot.tick, _earliest_input, snapshot.tick]) + _earliest_input = snapshot.tick + else: + _logger.trace("Ingested input @%d, earliest @%d->@%d", [snapshot.tick, _earliest_input, _earliest_input]) + +# After a tick loop has been run, +# Iterate over snapshots to catch if host received new inputs. +# If so, apply and emit a signal for it via InputSender node so users code can run. +func _after_tick_loop() -> void: + if _earliest_input < 0: + return + + var current_tick : int = NetworkTime.tick + + for i in range(_earliest_input, current_tick + 1): + + if i < current_tick - _missing_inputs_history_size: + # i is older than our history size. + # We need to emit input_missing + + for input_sender in _per_input_sender_history.keys(): + if _is_there_new_input_for_input_sender(input_sender, i): + # Even if there is new input available, it past our history size + # Apply it and emit input missing. + var snapshot = _history_server._get_input_sender_snapshot(i) + if snapshot: + input_sender._apply_snapshot_for_self(snapshot) + input_sender.missing_input.emit(i, i) + else: + var latest_tick = _history_server.get_latest_input_sender_for( + input_sender._input_properties.get_subjects(), i) + + var latest_snapshot := _history_server._get_input_sender_snapshot(latest_tick) + + if latest_snapshot: + # Found previous snapshot relative to missing input. + input_sender._apply_snapshot_for_self(latest_snapshot) + input_sender.missing_input.emit(i, latest_tick) + else: + # Couldnt even foind previous input + input_sender.missing_input.emit(i, -1) + + _set_input_received_for_input_sender(input_sender, i) + + # We iterated over input_senders and emitted missing input because tick is old + # now continue since there is nothing left to do at old ticks. + continue + + #.. + #.. + #. + # Tick i is not older than our history size. + # Iterate over input senders and check if they have new information available. + + for input_sender in _per_input_sender_history.keys(): + if not _is_there_new_input_for_input_sender(input_sender, i): + continue + + # Did not received before + # Received new information for this input sender. + var snapshot := _history_server._get_input_sender_snapshot(i) + if snapshot: + input_sender._apply_snapshot_for_self(snapshot) + input_sender.network_input.emit(i) + _set_input_received_for_input_sender(input_sender, i) + + # Erase old history + for history in _per_input_sender_history.values(): + history.erase_old_ticks(current_tick - _missing_inputs_history_size) + + # Reset earliest_input. + _earliest_input = -1 + +func _register_input_sender(input_sender : InputSender) -> void: + _per_input_sender_history[input_sender] = InputSenderHistory.new() + +func _deregister_input_sender(input_sender : InputSender) -> void: + _per_input_sender_history.erase(input_sender) + +# Helper that sets input received for param input sender on given tick. +func _set_input_received_for_input_sender(input_sender : InputSender, tick : int) -> void: + var history : InputSenderHistory = _per_input_sender_history[input_sender] as InputSenderHistory + if history: + history.set_as_received_for_tick(tick) + +## Check if there is a new input available for given input sender on given tick. +func _is_there_new_input_for_input_sender(input_sender : InputSender, tick : int) -> bool: + var history : InputSenderHistory = _per_input_sender_history[input_sender] as InputSenderHistory + var received_before := history.did_receive_for_tick(tick) + + if received_before: + return false + + # We need to check if snapshot has this input senders properties + var snapshot := _history_server._get_input_sender_snapshot(tick) + if snapshot: + for property_entry in input_sender._property_entries: + if snapshot.has_property(property_entry.node, property_entry.property): + # There is new information available indeed + return true + + # No new information + return false + +func _init(p_history_server: _NetworkHistoryServer = null, p_synchronization_server: _NetworkSynchronizationServer = null): + _history_server = p_history_server + _synchronization_server = p_synchronization_server + +## Inner class to remember and compare +## Did we receive any input before for given tick? +## stored for specific InputSender. +class InputSenderHistory extends RefCounted: + + # Maps ticks to bool (we received input before = true, never received = false) + var _history : Dictionary = {} + + ## Set param tick as received + func set_as_received_for_tick(tick : int) -> void: + _history[tick] = true + + ## Check if we received input for given tick. + ## Returns false if we dont have information for that tick. + func did_receive_for_tick(tick : int) -> bool: + return _history.get(tick, false) + + ## Erase old ticks that we dont need anymore. + func erase_old_ticks(older_than_inclusive : int) -> void: + var to_erase : Array[int] = [] + for tick in _history.keys(): + if tick <= older_than_inclusive: + to_erase.push_back(tick) + + for erase_tick in to_erase: + _history.erase(erase_tick) diff --git a/addons/netfox/servers/network-synchronization-server.gd b/addons/netfox/servers/network-synchronization-server.gd index bd049b881..dc2c65d47 100644 --- a/addons/netfox/servers/network-synchronization-server.gd +++ b/addons/netfox/servers/network-synchronization-server.gd @@ -342,22 +342,19 @@ func _synchronize_input_sender(tick: int) -> void: var snapshots := [] as Array[_Snapshot] var notified_peers := _Set.new() - # By default input sender only sends input to server, check if its enabled - ## TODO double check notified peers. + # By default input sender only sends input to host, check if its enabled. if not _input_sender_enable_broadcast: # Input broadcast is off, only send inputs to host. for node in _input_sender_owned_properties.get_subjects(): notified_peers.add(1) - ## TODO check if this is solid or should be code below. -# notified_peers.add(node.get_multiplayer_authority()) else: # If input broadcast is on, send inputs to everyone for peer in multiplayer.get_peers(): notified_peers.add(peer) # Make sure to not send input to ourselves - # Maybe: Only erase ourselves if this is not host, because listen servers could benefit - # TODO does above comment this make sense? + # Even if host is also a player, host should be able to reach stored inputs via + # recorded snapshots. notified_peers.erase(multiplayer.get_unique_id()) # Prepare snapshot package diff --git a/addons/netfox/simulator.gd b/addons/netfox/simulator.gd index d65bf0b90..250dfdef7 100644 --- a/addons/netfox/simulator.gd +++ b/addons/netfox/simulator.gd @@ -186,9 +186,7 @@ func process_settings() -> void: for node in _state_properties.get_subjects(): NetworkSynchronizationServer.register_visibility_filter(node, visibility_filter) - if not _signals_connected: - _connect_signals() - _signals_connected = true + _connect_signals() ## Process settings based on authority. ## [br][br] @@ -233,7 +231,41 @@ func _reprocess_settings() -> void: process_settings() func _connect_signals() -> void: - NetworkTime.after_tick.connect(_on_after_tick) + if not NetworkTime.after_tick.is_connected(_on_after_tick): + NetworkTime.after_tick.connect(_on_after_tick) + + if listened_input_sender and not listened_input_sender.network_input.is_connected(_on_network_input): + listened_input_sender.network_input.connect(_on_network_input) + + if listened_input_sender and not listened_input_sender.local_input.is_connected(_on_local_input): + listened_input_sender.local_input.connect(_on_local_input) + +func _on_network_input(tick : int) -> void: + # Run this function on host only + if not is_multiplayer_authority(): + return + + # Dont run this function if this is local HOST player. + # Because local host player is handled with on_local_input. + if listened_input_sender.has_authority_over_input_nodes(): + return + + _logger.trace("Simulating tick remote player on host.") + for node in _sim_nodes: + node.call("_simulated_tick", NetworkTime.seconds_between(tick, tick + 1), tick) + +func _on_local_input(tick : int) -> void: + # Run this function on host only. + if not is_multiplayer_authority(): + return + + # Run this function if this is local HOST player. + if not listened_input_sender.has_authority_over_input_nodes(): + return + + _logger.trace("Simulating tick on host player") + for node in _sim_nodes: + node.call("_simulated_tick", NetworkTime.seconds_between(tick, tick + 1), tick) # Do logic depending on mode explained in class description. func _on_after_tick(delta: float, tick: int) -> void: @@ -246,22 +278,15 @@ func _on_after_tick(delta: float, tick: int) -> void: # Figure out which mode we are operating on. var has_input_authority := listened_input_sender.has_authority_over_input_nodes() - var has_simulator_authority := is_multiplayer_authority() + var is_host := is_multiplayer_authority() - if has_input_authority: - # This is authoritative player - # Even if this is host application, treat this as authoritative_peer since it has - # input authority. + if has_input_authority and not is_host: + # This is authoritative player but not host - local non host player. _handle_authoritative_peer(delta, tick) return - if has_simulator_authority: - # This is host - _handle_host(delta, tick) - return - - # this is puppet peer. - _handle_puppet_peer(delta, tick) + if not has_input_authority and not is_host: + _handle_puppet_peer(delta, tick) # Check if there is a new snapshot from host # if there is a new snapshot, apply and simulate onwards with buffered inputs. @@ -305,39 +330,39 @@ func _handle_authoritative_peer(_delta: float, tick: int) -> void: listened_input_sender._apply_snapshot_for_self(local_input_snapshot) for node in _sim_nodes: node.call("_simulated_tick", NetworkTime.seconds_between(i, i + 1), i) - -# Host needs to run _simulated_tick with new received inputs. -func _handle_host(delta: float, tick: int) -> void: - if not simulate_on_host: - return - - # Get latest received input tick. - var latest_input_tick := listened_input_sender.get_latest_received_information_tick(tick) - - if latest_input_tick == -1: - # Never received input. - # Cant run simulation without inputs. - _logger.trace("Host is skipping simulation on #%s because host never received input", [tick]) - return - - # If latest equals our stored latest_tick, this means we already run this simulation. - # Cant run if inputs are not new, return. - if latest_input_tick == _latest_input_tick: - _logger.trace("Host is skipping simulation on #%s because there is no new input", [tick]) - return - - var ticks_to_run := latest_input_tick - _latest_input_tick - - _logger.trace("Host is looping to run simulated ticks, ticks to run: %s", [ticks_to_run]) - for i in range(_latest_input_tick + 1, latest_input_tick + 1): - - var snapshot := NetworkHistoryServer._get_input_sender_snapshot(i) - listened_input_sender._apply_snapshot_for_self(snapshot) - for node in _sim_nodes: - node.call("_simulated_tick", NetworkTime.seconds_between(i, i + 1), i) - - _latest_input_tick = tick +## Host needs to run _simulated_tick with new received inputs. +#func _handle_host(delta: float, tick: int) -> void: +# if not simulate_on_host: +# return +# +# # Get latest received input tick. +# var latest_input_tick := listened_input_sender.get_latest_received_information_tick(tick) +# +# if latest_input_tick == -1: +# # Never received input. +# # Cant run simulation without inputs. +# _logger.trace("Host is skipping simulation on #%s because host never received input", [tick]) +# return +# +# # If latest equals our stored latest_tick, this means we already run this simulation. +# # Cant run if inputs are not new, return. +# if latest_input_tick == _latest_input_tick: +# _logger.trace("Host is skipping simulation on #%s because there is no new input", [tick]) +# return +# +# var ticks_to_run := latest_input_tick - _latest_input_tick +# +# _logger.trace("Host is looping to run simulated ticks, ticks to run: %s", [ticks_to_run]) +# for i in range(_latest_input_tick + 1, latest_input_tick + 1): +# +# var snapshot := NetworkHistoryServer._get_input_sender_snapshot(i) +# if snapshot: +# listened_input_sender._apply_snapshot_for_self(snapshot) +# for node in _sim_nodes: +# node.call("_simulated_tick", NetworkTime.seconds_between(i, i + 1), i) +# +# _latest_input_tick = tick # For pupper peer we only need to interpolate latest state to new one. # TODO Do we need to code interpolation? try it first diff --git a/examples/simulated-player/simulated_player_example.tscn b/examples/simulated-player/simulated_player_example.tscn index 238aefc5f..0ac165d7c 100644 --- a/examples/simulated-player/simulated_player_example.tscn +++ b/examples/simulated-player/simulated_player_example.tscn @@ -10,6 +10,7 @@ [node name="SimulatedPlayerExample" type="Node3D"] [node name="Square Map" parent="." instance=ExtResource("1_t21fn")] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1.65592, 0) [node name="Environment" parent="." instance=ExtResource("2_epr86")] From bc32824699244f8c980afb427248efedf4c5068b Mon Sep 17 00:00:00 2001 From: tuysuztavsan Date: Sun, 31 May 2026 21:01:21 +0300 Subject: [PATCH 25/57] input-sender-server is good enough --- addons/netfox/input_sender.gd | 43 +++- addons/netfox/servers/input-sender-server.gd | 234 ++++++++++++------ .../scripts/server_side_tank.gd | 2 +- 3 files changed, 194 insertions(+), 85 deletions(-) diff --git a/addons/netfox/input_sender.gd b/addons/netfox/input_sender.gd index e736eb660..055a5fabc 100644 --- a/addons/netfox/input_sender.gd +++ b/addons/netfox/input_sender.gd @@ -7,14 +7,16 @@ class_name InputSender ## ## [InputSender] is a multi purpose node to use on networked games, ## It provides signals to code host and client side logic. -## [InputSender] signals are tied and emitted on [signal NetworkTime.after_tick_loop]. +## [InputSender] signals are tied and emitted on [signal NetworkTime.on_tick]. ## ## @experimental: -## [InputSender] assumes input snapshots arrive as whole. (atomic), if snapshot +## InputSenderServer assumes input snapshots arrive as whole. (atomic), if snapshot ## arrives with multiple parts, [InputSender] signals wont be reliable to ## code game logic. ## Emitted if host received input from remote owner of input_properties. +## Emitted for inputs that arrive within missing-input-history range. +## (see project settings netfox/input-sender/missing-input-history) ## InputSenderServer handles applying received input internally before emitting this signal. ## Use this signal to code host side logic. signal network_input(tick : int) @@ -30,19 +32,22 @@ signal network_input(tick : int) ## using some other method to syncronize game state (Syncronizers/Simulators). signal local_input(tick : int) -## TODO check this documentation. -## Emitted if input is lost. -## Input is considered lost if its received older than missing-input-history or -## never received, under project settings netfox/input-sender. -# [signal NetworkTime.after_tick_loop]. -## InputSenderServer will try to apply latest known input that comes before missing tick -## internally before emitting this signal. -## Emitted only if [InputSender] is authority. -## If host couldnt find known previous input, latest_known_input_tick will be -1. -## In that scenario, [InputSender] will not be able to have correct inputs applied. +## Emitted if input is missing. +## Input is considered missing if host did not receive input for a tick more than +## missing-input-history. (See project settings netfox/input-sender). +## Host will try to find and apply previous known inputs before emitting this signal. +## If host cant find any previous known input, latest_known_input_tick will be -1. +## Also see late_input signal. An input will be considered missing at first, +## but later it might arrive late. In that sceneario late_input signal will be emitted too. ## Use this signal to code host side prediction logic. signal missing_input(for_tick : int, latest_known_input_tick : int) +## Emitted when input arrived so late that it past the missing history size. +## (see project settings netfox/input-sender/missing-input-history) +## InputSenderServer will apply received input state internally before emitting this signal. +## If a late input also past the history limit of input-sender, it will be dropped. +signal late_input(for_tick : int) + ## The root node for resolving node paths in inputs. Defaults to the parent node. @export var root: Node = get_parent() @@ -89,6 +94,9 @@ func _enter_tree() -> void: process_settings.call_deferred() func _exit_tree(): + if Engine.is_editor_hint(): + return + InputSenderServer._deregister_input_sender(self) ## Process settings. @@ -225,3 +233,14 @@ func _apply_snapshot_for_self(snapshot : _Snapshot) -> void: var value := snapshot.get_property(subject, property) # TODO is this should be node.set_indexed ?? subject.set_indexed(property, value) + +# Helper function to save current input_properties. +# Used internally by InputSenderServer to record state before overwriting properties +# and emitting signals. +func _save_properties() -> void: + _saved_inputs_snapshot = _PropertySnapshot.extract(_property_entries) + +# Helper function to restore input_properties. +# Used internally by InputSenderServer to restore state after overwriting properties. +func _restore_properties() -> void: + _saved_inputs_snapshot.apply(_property_cache) diff --git a/addons/netfox/servers/input-sender-server.gd b/addons/netfox/servers/input-sender-server.gd index 21552044d..4e23499c8 100644 --- a/addons/netfox/servers/input-sender-server.gd +++ b/addons/netfox/servers/input-sender-server.gd @@ -9,16 +9,16 @@ class_name _InputSenderServer ## arrives with multiple parts, [InputSender] signals wont be reliable to ## code game logic. -## TODO TODO TODO -## 1- Dont forget to restore input sender state after tick loop. -## 2- Handle local inputs on tick? or after_tick_loop - static var _logger := NetfoxLogger._for_netfox("InputSenderServer") var _history_server : NetworkHistoryServer = null var _synchronization_server : NetworkSynchronizationServer = null -var _missing_inputs_history_size : int = 16 -var _earliest_input := -1 + +var _missing_inputs_history_size : int = ProjectSettings.get_setting("netfox/input_sender/missing_input_history", 16) +var _input_sender_history_size : int = ProjectSettings.get_setting("netfox/input_sender/history_limit", 64) + +# Ticks that we received new input snapshots. +var _ticks_that_has_new_snapshot : Array[int] = [] # Maps InputSender -> InputSenderHistory (inner class, look at the end of this script.) var _per_input_sender_history : Dictionary = {} @@ -28,10 +28,12 @@ func _ready(): if not _history_server: _history_server = NetworkHistoryServer if not _synchronization_server: _synchronization_server = NetworkSynchronizationServer - _missing_inputs_history_size = ProjectSettings.get_setting("netfox/input_sender/missing_input_history", 16) - # Record inputs similiar to rollback so that users can use same methods # on their input scripts. + # For good reasons, We do logic and run InputSender signals on NetworkTime.on_tick + # This means local players wont have input information for their first tick. + # Its not really like await processframe but this will only add slight delay that wont have + # Important effects on the game itself. NetworkTime.after_tick.connect(func(_dt, tick): _history_server._record_input_sender(tick) _synchronization_server._synchronize_input_sender(tick) @@ -47,86 +49,164 @@ func _ready(): # Processing them on every tick doesnt make sense, since host may run multiple # ticks and we probably wont even have new inputs to process anyway. # In conculusion it makes sense to process them after a tick loop run. - NetworkTime.after_tick_loop.connect(_after_tick_loop) + # + # But if we run logic on after_tick_loop, our effect on the game state is lost. + # Because most setups tends to use state-syncronizer with input-sender. + # state-syncronzier records on every tick and restores after tick loop. + # + # To make sure our effect isnt lost to this order, we run logic on tick. + NetworkTime.on_tick.connect(_on_tick) # Whenever we receive input snapshot, we investigate and fetch _earliest_input tick # This is later used to iterate over saved snapshots and emit signals. func _on_received_input_snapshot(snapshot : _Snapshot) -> void: if snapshot.is_empty(): return - if _earliest_input < 0 or snapshot.tick < _earliest_input: - _logger.trace("Ingested input @%d, earliest @%d->@%d", [snapshot.tick, _earliest_input, snapshot.tick]) - _earliest_input = snapshot.tick - else: - _logger.trace("Ingested input @%d, earliest @%d->@%d", [snapshot.tick, _earliest_input, _earliest_input]) - -# After a tick loop has been run, -# Iterate over snapshots to catch if host received new inputs. -# If so, apply and emit a signal for it via InputSender node so users code can run. -func _after_tick_loop() -> void: - if _earliest_input < 0: + + if not _ticks_that_has_new_snapshot.has(snapshot.tick): + _ticks_that_has_new_snapshot.push_back(snapshot.tick) + +# After a tick has been run +# Handle new snapshots +# Handle missing inputs. +# Handle local inputs. +# +# Since we are modifying InputSenders before emitting their signals, +# Their input properties will be messed up, this means users inputs wont be +# recorded properly on NetworkTime.after_tick. +# To prevent that, this function records their current state and restores at the +# end of this function. +func _on_tick(_delta : float, tick : int) -> void: + # First save input-sender states. + _save_input_sender_states() + + _handle_new_snapshots(tick) + + # We need to emit missing_input signal for old ticks. + var missing_tick := tick - _missing_inputs_history_size + if missing_tick >= 0: + _handle_missing_tick(missing_tick) + + # DANGER + # We do tick -1 for current local inputs, this is explained on _ready comments. + _handle_local_inputs(tick - 1) + + # Logic is done, restore input-sender states. + _restore_input_sender_states() + + # Now remove older history data that we keep. + _trim_input_sender_histories(tick) + +# Handles new input-sender snapshots. +# This function iterates over _ticks_that_has_new_snapshot. +# If tick is within range of missing-input-history, +# applies/emits network_input. +# If its not within range, +# it applies/emits late_input. +# Clears _ticks_that_has_new_snapshot after done. +func _handle_new_snapshots(current_tick : int) -> void: + if _ticks_that_has_new_snapshot.is_empty(): return - var current_tick : int = NetworkTime.tick + # So ticks are processed in ascending order. + _ticks_that_has_new_snapshot.sort() + - for i in range(_earliest_input, current_tick + 1): + for i in _ticks_that_has_new_snapshot: - if i < current_tick - _missing_inputs_history_size: - # i is older than our history size. - # We need to emit input_missing - - for input_sender in _per_input_sender_history.keys(): - if _is_there_new_input_for_input_sender(input_sender, i): - # Even if there is new input available, it past our history size - # Apply it and emit input missing. - var snapshot = _history_server._get_input_sender_snapshot(i) - if snapshot: - input_sender._apply_snapshot_for_self(snapshot) - input_sender.missing_input.emit(i, i) - else: - var latest_tick = _history_server.get_latest_input_sender_for( - input_sender._input_properties.get_subjects(), i) - - var latest_snapshot := _history_server._get_input_sender_snapshot(latest_tick) - - if latest_snapshot: - # Found previous snapshot relative to missing input. - input_sender._apply_snapshot_for_self(latest_snapshot) - input_sender.missing_input.emit(i, latest_tick) - else: - # Couldnt even foind previous input - input_sender.missing_input.emit(i, -1) - - _set_input_received_for_input_sender(input_sender, i) - - # We iterated over input_senders and emitted missing input because tick is old - # now continue since there is nothing left to do at old ticks. + # This line saves processing because we fetch snapshot once per tick. + var snapshot := _history_server._get_input_sender_snapshot(i) + if not snapshot: + # This situation shouldnt happen anyway. continue - #.. - #.. - #. - # Tick i is not older than our history size. - # Iterate over input senders and check if they have new information available. - + # Iterate over input senders and check if there is new input in tick for input_sender in _per_input_sender_history.keys(): - if not _is_there_new_input_for_input_sender(input_sender, i): + if not _is_there_new_input_for_input_sender(input_sender, i, snapshot): + # No new input, we already processed this one. continue # Did not received before - # Received new information for this input sender. - var snapshot := _history_server._get_input_sender_snapshot(i) - if snapshot: - input_sender._apply_snapshot_for_self(snapshot) + # If tick i past the missing_history_size, consider it late. + input_sender._apply_snapshot_for_self(snapshot) + if i <= current_tick - _missing_inputs_history_size: + # Its a late input. + input_sender.late_input.emit(i) + else: + # Its within range, consider it as network input. input_sender.network_input.emit(i) - _set_input_received_for_input_sender(input_sender, i) + + # Set as handled anyway for both situation. + _set_input_received_for_input_sender(input_sender, i) - # Erase old history - for history in _per_input_sender_history.values(): - history.erase_old_ticks(current_tick - _missing_inputs_history_size) + _ticks_that_has_new_snapshot.clear() + +# Iterates over snapshot for given tick +# Checks _per_input_sender_history for given tick. +# If input_sender did not received anything for this tick +# It assumes input is missing and emits input_missing. +# Call this function after calling _handle_new_snapshots. +# Param for tick must be the value of current_tick - missing_input_history_size. +func _handle_missing_tick(for_tick : int) -> void: + + for input_sender in _per_input_sender_history.keys(): + var input_sender_history := _per_input_sender_history[input_sender] as InputSenderHistory + + if not input_sender_history.did_receive_for_tick(for_tick): + # We didnt receive anything for this input sender for this tick. + # Try to find latest input and emit missing_input. + + var latest_tick := _history_server.get_latest_input_sender_for( + input_sender._input_properties.get_subjects(), for_tick) + + if latest_tick >= 0: + var snapshot := _history_server._get_input_sender_snapshot(latest_tick) + if snapshot: + input_sender._apply_snapshot_for_self(snapshot) + else: + # If no snapshot, we need to emit signal with -1 + latest_tick = -1 + + # We may be able to find snapshot or not, emit anyway + input_sender.missing_input.emit(for_tick, latest_tick) + +# Iterates over input-senders and emits local input with latest recorded input available. +# Only does so if input-sender is authoritative peer. + +# Duplicated comment from _ready: +# +# "For good reasons, We do logic and run InputSender signals on NetworkTime.on_tick +# This means local players wont have input information for their first tick. +# Its not really like await processframe but this will only add slight delay that wont have +# important effects on the game itself." +func _handle_local_inputs(for_tick : int) -> void: - # Reset earliest_input. - _earliest_input = -1 + # Fetch snapshot once before loop to prevent unneccessary fetches. + var snapshot := _history_server._get_input_sender_snapshot(for_tick) + if not snapshot: + # This shouldnt happen anyway. + return + + for input_sender in _per_input_sender_history.keys(): + if input_sender.has_authority_over_input_nodes(): + # Its authoritative player. + + # We know that snapshots are recorded for each tick on NetworkTime.after_tick. + # We can use it, no need to query get_latest_for here. + input_sender._apply_snapshot_for_self(snapshot) + input_sender.local_input.emit(for_tick) + +# Saves all input-sender input properties. +# This is done before messing with properties. +func _save_input_sender_states() -> void: + for input_sender in _per_input_sender_history.keys(): + input_sender._save_properties() + +# Restores all input-sender input properties. +# This is done after messing with properties. +func _restore_input_sender_states() -> void: + for input_sender in _per_input_sender_history.keys(): + input_sender._restore_properties() func _register_input_sender(input_sender : InputSender) -> void: _per_input_sender_history[input_sender] = InputSenderHistory.new() @@ -140,8 +220,8 @@ func _set_input_received_for_input_sender(input_sender : InputSender, tick : int if history: history.set_as_received_for_tick(tick) -## Check if there is a new input available for given input sender on given tick. -func _is_there_new_input_for_input_sender(input_sender : InputSender, tick : int) -> bool: +## Check if there is a new input available for given input sender on given tick within matching snapshot. +func _is_there_new_input_for_input_sender(input_sender : InputSender, tick : int, snapshot : _Snapshot) -> bool: var history : InputSenderHistory = _per_input_sender_history[input_sender] as InputSenderHistory var received_before := history.did_receive_for_tick(tick) @@ -149,7 +229,6 @@ func _is_there_new_input_for_input_sender(input_sender : InputSender, tick : int return false # We need to check if snapshot has this input senders properties - var snapshot := _history_server._get_input_sender_snapshot(tick) if snapshot: for property_entry in input_sender._property_entries: if snapshot.has_property(property_entry.node, property_entry.property): @@ -159,6 +238,17 @@ func _is_there_new_input_for_input_sender(input_sender : InputSender, tick : int # No new information return false +# Erases old input sender history data that we dont need anymore. +func _trim_input_sender_histories(current_tick : int) -> void: + # Since we cant merge older history than input-sender-history + # we can erase data older than that. + + var erase_ticks_older_than_inclusive : int = current_tick - _input_sender_history_size + + if erase_ticks_older_than_inclusive > 0: + for input_sender_history in _per_input_sender_history.values(): + input_sender_history.erase_old_ticks(erase_ticks_older_than_inclusive) + func _init(p_history_server: _NetworkHistoryServer = null, p_synchronization_server: _NetworkSynchronizationServer = null): _history_server = p_history_server _synchronization_server = p_synchronization_server diff --git a/examples/server-side-vehicle/scripts/server_side_tank.gd b/examples/server-side-vehicle/scripts/server_side_tank.gd index 4e2eabef8..577fef997 100644 --- a/examples/server-side-vehicle/scripts/server_side_tank.gd +++ b/examples/server-side-vehicle/scripts/server_side_tank.gd @@ -126,7 +126,7 @@ func _on_input_sender_local_input(tick): _fire(tick) -func _on_input_sender_missing_input(current_tick, latest_known_input_tick): +func _on_input_sender_missing_input(_current_tick, _latest_known_input_tick): print("Input is missing on :%s" %name) From 3cd0d4a7d2d38abff2bde78821656c61277847c4 Mon Sep 17 00:00:00 2001 From: tuysuztavsan Date: Mon, 1 Jun 2026 02:04:42 +0300 Subject: [PATCH 26/57] working simulation server, known jitter issue --- .../{input_sender.gd => input-sender.gd} | 9 +- addons/netfox/netfox.gd | 6 +- addons/netfox/servers/input-sender-server.gd | 6 + addons/netfox/servers/simulator-server.gd | 256 ++++++++++++++++++ addons/netfox/simulator.gd | 211 ++------------- .../scenes/server_side_tank.tscn | 2 +- .../scenes/simulated_player.tscn | 4 +- .../scripts/simulated_player.gd | 9 +- 8 files changed, 305 insertions(+), 198 deletions(-) rename addons/netfox/{input_sender.gd => input-sender.gd} (97%) create mode 100644 addons/netfox/servers/simulator-server.gd diff --git a/addons/netfox/input_sender.gd b/addons/netfox/input-sender.gd similarity index 97% rename from addons/netfox/input_sender.gd rename to addons/netfox/input-sender.gd index 055a5fabc..e95b2576a 100644 --- a/addons/netfox/input_sender.gd +++ b/addons/netfox/input-sender.gd @@ -128,6 +128,8 @@ func process_settings() -> void: ## Call this whenever the authority of input node changes. ## Make sure to do this at the same time on all peers. func process_authority(): + InputSenderServer._deregister_input_sender(self) + for node in _input_properties.get_subjects(): for property in _input_properties.get_properties_of(node): NetworkHistoryServer.deregister_input_sender(node, property) @@ -235,12 +237,13 @@ func _apply_snapshot_for_self(snapshot : _Snapshot) -> void: subject.set_indexed(property, value) # Helper function to save current input_properties. -# Used internally by InputSenderServer to record state before overwriting properties -# and emitting signals. +# Used internally by InputSenderServer/SimulatorServer to record state before +# overwriting properties and emitting signals. func _save_properties() -> void: _saved_inputs_snapshot = _PropertySnapshot.extract(_property_entries) # Helper function to restore input_properties. -# Used internally by InputSenderServer to restore state after overwriting properties. +# Used internally by InputSenderServer/SimulatorServer to restore state after +# overwriting properties. func _restore_properties() -> void: _saved_inputs_snapshot.apply(_property_cache) diff --git a/addons/netfox/netfox.gd b/addons/netfox/netfox.gd index ff27ab5b5..a3c2eafc5 100644 --- a/addons/netfox/netfox.gd +++ b/addons/netfox/netfox.gd @@ -264,6 +264,10 @@ const AUTOLOADS: Array[Dictionary] = [ "name": "InputSenderServer", "path": ROOT + "/servers/input-sender-server.gd" }, + { + "name": "SimulatorServer", + "path": ROOT + "/servers/simulator-server.gd" + }, ] const TYPES: Array[Dictionary] = [ @@ -300,7 +304,7 @@ const TYPES: Array[Dictionary] = [ { "name": "InputSender", "base": "Node", - "script": ROOT + "/input_sender.gd", + "script": ROOT + "/input-sender.gd", "icon": ROOT + "/icons/input-sender.svg" }, { diff --git a/addons/netfox/servers/input-sender-server.gd b/addons/netfox/servers/input-sender-server.gd index 4e23499c8..beb1b11e3 100644 --- a/addons/netfox/servers/input-sender-server.gd +++ b/addons/netfox/servers/input-sender-server.gd @@ -13,6 +13,7 @@ static var _logger := NetfoxLogger._for_netfox("InputSenderServer") var _history_server : NetworkHistoryServer = null var _synchronization_server : NetworkSynchronizationServer = null +var _simulator_server : SimulatorServer = null var _missing_inputs_history_size : int = ProjectSettings.get_setting("netfox/input_sender/missing_input_history", 16) var _input_sender_history_size : int = ProjectSettings.get_setting("netfox/input_sender/history_limit", 64) @@ -27,6 +28,7 @@ func _ready(): # Ensure dependencies if not _history_server: _history_server = NetworkHistoryServer if not _synchronization_server: _synchronization_server = NetworkSynchronizationServer + if not _simulator_server: _simulator_server = SimulatorServer # Record inputs similiar to rollback so that users can use same methods # on their input scripts. @@ -135,6 +137,10 @@ func _handle_new_snapshots(current_tick : int) -> void: else: # Its within range, consider it as network input. input_sender.network_input.emit(i) + + # Notify SimulatorServer about this network_input. + # Why? Read Simulators and SimulatorServer to learn. + _simulator_server._notify_input_sender_received_network_input(input_sender, i) # Set as handled anyway for both situation. _set_input_received_for_input_sender(input_sender, i) diff --git a/addons/netfox/servers/simulator-server.gd b/addons/netfox/servers/simulator-server.gd new file mode 100644 index 000000000..ba7433525 --- /dev/null +++ b/addons/netfox/servers/simulator-server.gd @@ -0,0 +1,256 @@ +extends Node +class_name _SimulatorServer + +# @public class + +## Handles [Simulator] related operations. + +## TODO: We should find a better name for Simulator word. +## +## [Before reading this server, please read InputSender, InputSenderServer, Simulator.] +## +## +## Insight: +## SimulatorServer has to know details about each simulators input-senders. +## Inputs might not always arrive at correct order. +## Inputs might not always arrive. +## Since iterating over every snapshot and check for them is already done by +## InputSenderServer, we will expose a function for InputSenderServer to notify +## us with the input state of simulators. +## SimulatorServer will not consider late or missing inputs to derive simulation. + +var _history_server : _NetworkHistoryServer = null +var _synchronization_server : _NetworkSynchronizationServer = null + +var _simulation_history_size : int = ProjectSettings.get_setting("netfox/simulator/history_limit", 64) + +# Simulators that has input authority. +# These simulators would be your typical local players. +# If this is host, these simulators also owns state. +# But that doesnt need an extra category. +# For this category, we accept the truth from latest known state +# and apply local inputs until we reach current tick. +var _authoritative_simulators : Array[Simulator] = [] + +# Simulators that has no input authority. +# These simulators belong to remote players. +# +# On host: +# These simulators also owns state. +# We run simulation one tick per received network_input. +# +# On other players: +# These simulators are not doing any simulation at all. +# These simulators are accepting latest truth and applying that state. +var _puppet_simulators : Array[Simulator] = [] + +# Maps InputSender to Simulator. +# We keep this to easily reach in reverse fashion when we need it. +var _input_sender_to_simulator : Dictionary = {} + +# Maps simulator to simulator-input-history (inner class at the end of the file) +# SimulatorInputHistories are buffered on _notify_input_sender_received_network_input, +# SimulatorInputHistories are consumed on _after_tick_loop. +var _simulator_to_simulator_input_history : Dictionary = {} + +func _ready(): + # Ensure dependencies + if not _history_server: _history_server = NetworkHistoryServer + if not _synchronization_server: _synchronization_server = NetworkSynchronizationServer + + # Just like rollback, record and synchronize after tick. + # TODO if we find out that physics dont work like this, find better timing. + NetworkTime.after_tick.connect(func(_dt, tick): + _history_server._record_simulator(tick) + _synchronization_server._synchronize_simulator(tick) + ) + + # We do our simulating logic after tick loop, similiar to rollback in general. + # TODO if we find out that physics dont work like this, find better timing. + NetworkTime.after_tick_loop.connect(_after_tick_loop) + +# Do simulating logic depending on authorities. +func _after_tick_loop() -> void: + + _handle_authoritatives() + _handle_puppets() + + # Since we consumed, we can now clear this. + _simulator_to_simulator_input_history.clear() + +func _handle_authoritatives() -> void: + var current_tick := NetworkTime.tick + + if is_multiplayer_authority(): + # This is host. + # On host we need to advance authoritative player with our local inputs. + # In another words - this is a host playing locally. + for simulator in _authoritative_simulators: + + # DANGER we are doing current_tick -1 because input-senders are recorded + # on after-tick. + var input_snapshot := _history_server._get_input_sender_snapshot(current_tick -1) + + if not input_snapshot: + # no input snapshot for some reason, this shouldnt really happen. + continue + + var input_sender := simulator.listened_input_sender + # Save current properties so that we dont mess with input recording. + input_sender._save_properties() + + input_sender._apply_snapshot_for_self(input_snapshot) + # TODO do we use ticktime for delta value??? + simulator._run_simulation(NetworkTime.ticktime, current_tick) + + # Restore input_sender properties after messing with inputs + input_sender._restore_properties() + else: + # This is local authoritative player. + # On local players we need to first accept latest received truth from host. + # Then simulate our local inputs to reach current state. + + for simulator in _authoritative_simulators: + + var latest_state_tick := _history_server.get_latest_simulator_for( + simulator._state_properties.get_subjects(), current_tick) + + if latest_state_tick >= 0: + var latest_state_snapshot := _history_server._get_simulator_snapshot(latest_state_tick) + + if latest_state_snapshot: + simulator._apply_snapshot_for_self(latest_state_snapshot) + + # Whether we found latest snapshot or not, we need to iterate over our local inputs now. + + var simulation_start_tick := latest_state_tick + + # Dont let it past the history size. + if simulation_start_tick < current_tick - _simulation_history_size: + simulation_start_tick = current_tick - _simulation_history_size + + # Dont let it become negative. + if simulation_start_tick < 0: + simulation_start_tick = 0 + + # DANGER since we only have inputs available up to current_tick -1 + # We will stop at current_tick - 1 INCLUSIVE + # Why -1 ? Read InputSenderServer. + + # Record input_sender state before messing with inputs. + simulator.listened_input_sender._save_properties() + + for i in range(simulation_start_tick, current_tick): + var input_snapshot := _history_server._get_input_sender_snapshot(i) + + # We should have input snapshot available for ourselves anyway... + if input_snapshot: + simulator.listened_input_sender._apply_snapshot_for_self(input_snapshot) + # TODO do we use ticktime for delta value??? + simulator._run_simulation(NetworkTime.ticktime, i) + + # Restore input_sender state after messing with inputs. + simulator.listened_input_sender._restore_properties() + +func _handle_puppets() -> void: + var current_tick := NetworkTime.tick + + if is_multiplayer_authority(): + # This is host, + # On host we derive the simulation forward with new network_inputs. + + for simulator in _puppet_simulators: + + # Check if we have new input for this simulator + var simulator_input_history := _simulator_to_simulator_input_history.get(simulator) as SimulatorInputHistory + + if not simulator_input_history: + continue + + var ticks_with_new_inputs : Array[int] = simulator_input_history.get_ticks_with_new_inputs() + + if ticks_with_new_inputs.is_empty(): + continue + + # We dont need to record/restore input_sender state as we dont own input. + + for new_input_tick in ticks_with_new_inputs: + var input_snapshot := _history_server._get_input_sender_snapshot(new_input_tick) + + if not input_snapshot: + # This shouldnt happen anyway. + continue + + simulator.listened_input_sender._apply_snapshot_for_self(input_snapshot) + # TODO do we use ticktime for delta value??? + simulator._run_simulation(NetworkTime.ticktime, new_input_tick) + else: + # This is not host + # We only accept the true state which is broadcasted to us. + + for simulator in _puppet_simulators: + + var latest_state_snapshot_tick := _history_server.get_latest_simulator_for( + simulator._state_properties.get_subjects(), current_tick) + + if latest_state_snapshot_tick >= 0: + var state_snapshot := _history_server._get_simulator_snapshot(latest_state_snapshot_tick) + + if state_snapshot: + simulator._apply_snapshot_for_self(state_snapshot) + +## InputSenderServer will call this function to notify that param input_sender +## received a new network_input on param on_tick. This network_input must also be +## within range of missing_input_history size. +## (See projectSettings netfox/input-sender/missing-input-history) +## (Also read InputSenderServer) +func _notify_input_sender_received_network_input(input_sender : InputSender, on_tick : int) -> void: + var matching_simulator := _input_sender_to_simulator.get(input_sender) as Simulator + + if not matching_simulator: + return + + var simulator_input_history := _simulator_to_simulator_input_history.get(matching_simulator) as SimulatorInputHistory + + if not simulator_input_history: + simulator_input_history = SimulatorInputHistory.new() + _simulator_to_simulator_input_history[matching_simulator] = simulator_input_history + + simulator_input_history.push_back_new_input_tick(on_tick) + +# Register a simulator node. +# Will check for authority over inputs and categorize by it. +func _register_simulator(simulator : Simulator) -> void: + if simulator.has_authority_over_inputs(): + _authoritative_simulators.push_back(simulator) + else: + _puppet_simulators.push_back(simulator) + + _input_sender_to_simulator[simulator.listened_input_sender] = simulator + +# Deregister a simulator node. +func _deregister_simulator(simulator : Simulator) -> void: + _puppet_simulators.erase(simulator) + _authoritative_simulators.erase(simulator) + + _input_sender_to_simulator.erase(_input_sender_to_simulator.find_key(simulator)) + +func _init(p_history_server: _NetworkHistoryServer = null, p_synchronization_server: _NetworkSynchronizationServer = null): + _history_server = p_history_server + _synchronization_server = p_synchronization_server + +## Inner class to keep Simulator related input ticks organized. +## Whenever InputSenderServer notifies us about input-sender-recived-network-input. +## If we have simulator for that given input-sender +## We will store that tick with the help of this class. +## Later on we will consume these stored ticks to run simulation. +class SimulatorInputHistory extends RefCounted: + + var _ticks_with_new_inputs : Array[int] = [] + + func push_back_new_input_tick(new_input_tick : int) -> void: + if not _ticks_with_new_inputs.has(new_input_tick): + _ticks_with_new_inputs.push_back(new_input_tick) + + func get_ticks_with_new_inputs() -> Array[int]: + return _ticks_with_new_inputs diff --git a/addons/netfox/simulator.gd b/addons/netfox/simulator.gd index 250dfdef7..1e5015fe5 100644 --- a/addons/netfox/simulator.gd +++ b/addons/netfox/simulator.gd @@ -16,9 +16,6 @@ class_name Simulator ## On host [Simulator] runs _simulated_tick functions with new inputs which ## is received by [InputSender]. After running _simulated_tick with new received ## inputs, [Simulator] broadcasts ground truth (state properties) to peers. -## Use this to code game logic that must run on host. If you would like to code -## additional host side logic (example: changing team only on host) you can check -## if its host or not in _simulated_tick. [br][br] ## ## 2- Authoritative peer - this [Simulator] doesnt have network authority, but ## [InputSender]s input_node (your custom player_input.gdscript code) belongs to @@ -26,8 +23,6 @@ class_name Simulator ## ## On authoritative peer, [Simulator] runs _simulated_tick with [InputSender]'s ## fresh local inputs (inputs that may or may not have been sent to server at this point). -## Upon receiving ground truth from host, [Simulator] compares difference in state -## and decide whether to use snapping or interpolating depending on threshold. ## After applying true state, [Simulator] re-runs _simulated_tick to reach current ## game state. [br][br] ## @@ -36,26 +31,13 @@ class_name Simulator ## players when you are playing the game. For example your friend is a puppet player ## in your game. [br] ## -## On puppet peers, [Simulator] only applies truth received from host and interpolate -## it. For most games this will be enough. Even with [InputSender] broadcast toggled on from +## On puppet peers, [Simulator] only applies truth received from host +## For most games this will be enough. Even with [InputSender] broadcast toggled on from ## project settings, there is no point in re-running _simulated_ticks because server ## sends states with inputs at the same time. For puppet peers we simply dont know ## their future inputs. [br][br] ## -## ## TODO: Simulator can have option to predict if input_broadcast is on for inputsender. [br] -## -## TODO: what about physics and physic stepping? [br] -## It can be coded with _simulated_ticks if you involve some local properties to script -## that has role in godots _physics_process. If we can avoid coding physic stepping we should. - -# TODO explore and test order below. -# order insight: -# on before tick, input-sender records and syncronizes inputs -# on tick, input-sender runs its logic and emits its signals but its not related with simulator. -# on-after-tick simulator will run its own logic depending on work mode explained above as 1-2-3. -# after running its logic, simulator will record and syncronize state. -# Saving and syncronizing is done via NetworkTime right after emitting after_tick signal. ## The root node for resolving node paths in properties. Defaults to the parent node. @export var root: Node = get_parent() @@ -67,13 +49,6 @@ class_name Simulator ## requires call to [method Simulator.process_settings]. @export var listened_input_sender : InputSender = null -## If true, [Simulator] will run _simulated_tick functions with fresh received inputs. -## Set this to true, if you want to code host side logic with client inputs. -## For example: moving a vehicle on server with client inputs. -## NOTE: Dont get confused, if host is also player and owner of [InputSender] -## [Simulator] will run _simulated_tick even though this set to false. -@export var simulate_on_host := true - @export_group("State") ## Properties that define the game state. ## [br][br] @@ -94,16 +69,6 @@ var _state_properties := _PropertyPool.new() var _properties_dirty: bool = false -# Flag to connect signals only once. -var _signals_connected : bool = false - -# Latest input tick we did operation. This is saved to remember. -# TODO should we set this to -1 on process_settings? -var _latest_input_tick : int = -1 - -# Latest snapshot applied from host (source of truth) -var _latest_applied_snapshot : int = -1 - # Dictionary (root node) -> (managing simulator) # Used to check for foreign roots when gathering simulated nodes. static var _managed_roots := {} @@ -185,8 +150,6 @@ func process_settings() -> void: # Register visibility filter for node in _state_properties.get_subjects(): NetworkSynchronizationServer.register_visibility_filter(node, visibility_filter) - - _connect_signals() ## Process settings based on authority. ## [br][br] @@ -194,6 +157,8 @@ func process_settings() -> void: ## Make sure to do this at the same time on all peers. func process_authority(): # First de-register. + SimulatorServer._deregister_simulator(self) + for node in _state_properties.get_subjects(): for property in _state_properties.get_properties_of(node): NetworkHistoryServer.deregister_simulator(node, property) @@ -202,11 +167,17 @@ func process_authority(): # Process authority _state_properties.set_from_paths(root, state_properties) + if not listened_input_sender: + _logger.error("Simulator needs listened_input_sender configured and valid.") + return + # Register state properties. for node in _state_properties.get_subjects(): for property in _state_properties.get_properties_of(node): NetworkHistoryServer.register_simulator(node, property) NetworkSynchronizationServer.register_simulator(node, property) + + SimulatorServer._register_simulator(self) ## Add a state property. ## [br][br] @@ -222,6 +193,14 @@ func add_state(node: Variant, property: String): _properties_dirty = true _reprocess_settings.call_deferred() +## Check if this [Simulator] has authority over its inputs via listened_input_sender +## This helper is used by SimulatorServer internally. +func has_authority_over_inputs() -> bool: + if not listened_input_sender: + return false + + return listened_input_sender.has_authority_over_input_nodes() + func _reprocess_settings() -> void: if not _properties_dirty or Engine.is_editor_hint(): return @@ -230,153 +209,6 @@ func _reprocess_settings() -> void: process_settings() -func _connect_signals() -> void: - if not NetworkTime.after_tick.is_connected(_on_after_tick): - NetworkTime.after_tick.connect(_on_after_tick) - - if listened_input_sender and not listened_input_sender.network_input.is_connected(_on_network_input): - listened_input_sender.network_input.connect(_on_network_input) - - if listened_input_sender and not listened_input_sender.local_input.is_connected(_on_local_input): - listened_input_sender.local_input.connect(_on_local_input) - -func _on_network_input(tick : int) -> void: - # Run this function on host only - if not is_multiplayer_authority(): - return - - # Dont run this function if this is local HOST player. - # Because local host player is handled with on_local_input. - if listened_input_sender.has_authority_over_input_nodes(): - return - - _logger.trace("Simulating tick remote player on host.") - for node in _sim_nodes: - node.call("_simulated_tick", NetworkTime.seconds_between(tick, tick + 1), tick) - -func _on_local_input(tick : int) -> void: - # Run this function on host only. - if not is_multiplayer_authority(): - return - - # Run this function if this is local HOST player. - if not listened_input_sender.has_authority_over_input_nodes(): - return - - _logger.trace("Simulating tick on host player") - for node in _sim_nodes: - node.call("_simulated_tick", NetworkTime.seconds_between(tick, tick + 1), tick) - -# Do logic depending on mode explained in class description. -func _on_after_tick(delta: float, tick: int) -> void: - - # Return if there is no listened input sender assigned. - if not listened_input_sender: - _logger.warning("%s listened_input_sender is needed for simulator to operate", - [name]) - return - - # Figure out which mode we are operating on. - var has_input_authority := listened_input_sender.has_authority_over_input_nodes() - var is_host := is_multiplayer_authority() - - if has_input_authority and not is_host: - # This is authoritative player but not host - local non host player. - _handle_authoritative_peer(delta, tick) - return - - if not has_input_authority and not is_host: - _handle_puppet_peer(delta, tick) - -# Check if there is a new snapshot from host -# if there is a new snapshot, apply and simulate onwards with buffered inputs. -func _handle_authoritative_peer(_delta: float, tick: int) -> void: - - # Get latest tick where we had sync data available for this simulator. - var latest_simulator_tick := NetworkHistoryServer.get_latest_simulator_for( - _state_properties.get_subjects(), tick) - - # If its -1 we never received snapshot, thus no need to apply it. - if latest_simulator_tick >= 0: - # Apply latest_snapshot. - var latest_received_snapshot := NetworkHistoryServer._get_simulator_snapshot(latest_simulator_tick) - if latest_received_snapshot: - _logger.trace("Authoritative peer applying latest received snapshot as truth: %s", [latest_received_snapshot]) - _apply_snapshot_for_self(latest_received_snapshot) - else: - _logger.trace("Apply snapshot called but snapshot is invalid, assuming its first frame"+\ - " and snapshot is not received yet.") - - # Now that we accepted truth from host, we can run simulated_ticks - # with our stored inputs. - - _logger.trace("Authoritative peer is looping to run simulated ticks, \ - from inclusive tick %s to exclusive tick %s", [latest_simulator_tick, tick]) - - # TODO double check this range pls. - for i in range(latest_simulator_tick, tick): - _logger.trace("Running simulator tick #%s", [i]) - var local_input_snapshot := NetworkHistoryServer._get_input_sender_snapshot(i) - - # TODO sometimes local_input_snapshot is null, figure out why! - if not local_input_snapshot: - _logger.trace("Authoritative peer is running simulated ticks, \ - local input snapshot is null at tick %s" %i) - continue - - _logger.trace("Authoritative peer is applying input snapshot %s and running tick", - [local_input_snapshot]) - - listened_input_sender._apply_snapshot_for_self(local_input_snapshot) - for node in _sim_nodes: - node.call("_simulated_tick", NetworkTime.seconds_between(i, i + 1), i) - -## Host needs to run _simulated_tick with new received inputs. -#func _handle_host(delta: float, tick: int) -> void: -# if not simulate_on_host: -# return -# -# # Get latest received input tick. -# var latest_input_tick := listened_input_sender.get_latest_received_information_tick(tick) -# -# if latest_input_tick == -1: -# # Never received input. -# # Cant run simulation without inputs. -# _logger.trace("Host is skipping simulation on #%s because host never received input", [tick]) -# return -# -# # If latest equals our stored latest_tick, this means we already run this simulation. -# # Cant run if inputs are not new, return. -# if latest_input_tick == _latest_input_tick: -# _logger.trace("Host is skipping simulation on #%s because there is no new input", [tick]) -# return -# -# var ticks_to_run := latest_input_tick - _latest_input_tick -# -# _logger.trace("Host is looping to run simulated ticks, ticks to run: %s", [ticks_to_run]) -# for i in range(_latest_input_tick + 1, latest_input_tick + 1): -# -# var snapshot := NetworkHistoryServer._get_input_sender_snapshot(i) -# if snapshot: -# listened_input_sender._apply_snapshot_for_self(snapshot) -# for node in _sim_nodes: -# node.call("_simulated_tick", NetworkTime.seconds_between(i, i + 1), i) -# -# _latest_input_tick = tick - -# For pupper peer we only need to interpolate latest state to new one. -# TODO Do we need to code interpolation? try it first -# TODO add prediction? i dont think its needed -func _handle_puppet_peer(_delta: float, tick: int) -> void: - var latest_simulator_tick := NetworkHistoryServer.get_latest_simulator_for( - _state_properties.get_subjects(), tick) - - var latest_received_snapshot := NetworkHistoryServer._get_simulator_snapshot(latest_simulator_tick) - if latest_received_snapshot: - _apply_snapshot_for_self(latest_received_snapshot) - - # TODO interpolation? try with interpolator first. - # Helper function to apply given snapshot for only this node. # TODO (same todo with input_sender)? # Applying whole snapshot and iterating over ticks would be nicer @@ -391,6 +223,13 @@ func _apply_snapshot_for_self(snapshot : _Snapshot) -> void: # TODO is this should be node.set_indexed ?? subject.set_indexed(property, value) +# Helper function to run simulation with given parameters. +# This function is used by SimulatorServer internally. +func _run_simulation(delta : float, tick : int) -> void: + for node in _sim_nodes: + if node: + node.call("_simulated_tick", delta, tick) + # Find managed nodes recursively from given root, ignoring branches managed by # a different [Simulator]. func _collect_managed_nodes(root: Node) -> Array[Node]: diff --git a/examples/server-side-vehicle/scenes/server_side_tank.tscn b/examples/server-side-vehicle/scenes/server_side_tank.tscn index aedbfd44a..f804fa18d 100644 --- a/examples/server-side-vehicle/scenes/server_side_tank.tscn +++ b/examples/server-side-vehicle/scenes/server_side_tank.tscn @@ -1,6 +1,6 @@ [gd_scene load_steps=12 format=3 uid="uid://f1annxuory74"] -[ext_resource type="Script" path="res://addons/netfox/input_sender.gd" id="1_c04if"] +[ext_resource type="Script" path="res://addons/netfox/input-sender.gd" id="1_c04if"] [ext_resource type="Script" path="res://examples/server-side-vehicle/scripts/server_side_tank.gd" id="1_jtlcb"] [ext_resource type="PackedScene" uid="uid://uqytq0drkxtf" path="res://examples/server-side-vehicle/scenes/tank_shell.tscn" id="2_ea71k"] [ext_resource type="PackedScene" uid="uid://bsavthtpx4joi" path="res://examples/server-side-vehicle/scenes/server_side_vehicle_info_panel.tscn" id="2_o1mnl"] diff --git a/examples/simulated-player/scenes/simulated_player.tscn b/examples/simulated-player/scenes/simulated_player.tscn index f0ca2dc5e..431aeeb8e 100644 --- a/examples/simulated-player/scenes/simulated_player.tscn +++ b/examples/simulated-player/scenes/simulated_player.tscn @@ -2,7 +2,7 @@ [ext_resource type="Script" path="res://examples/simulated-player/scripts/simulated_player.gd" id="1_4n6wb"] [ext_resource type="Script" path="res://examples/simulated-player/scripts/simulated_player_input.gd" id="2_8xdxw"] -[ext_resource type="Script" path="res://addons/netfox/input_sender.gd" id="3_po4f2"] +[ext_resource type="Script" path="res://addons/netfox/input-sender.gd" id="3_1fmuj"] [ext_resource type="Script" path="res://addons/netfox/simulator.gd" id="4_mmxoi"] [sub_resource type="CapsuleMesh" id="CapsuleMesh_icq3d"] @@ -24,7 +24,7 @@ shape = SubResource("CapsuleShape3D_05200") script = ExtResource("2_8xdxw") [node name="InputSender" type="Node" parent="." node_paths=PackedStringArray("root")] -script = ExtResource("3_po4f2") +script = ExtResource("3_1fmuj") root = NodePath("..") input_properties = Array[String](["Input:movement", "Input:jump"]) diff --git a/examples/simulated-player/scripts/simulated_player.gd b/examples/simulated-player/scripts/simulated_player.gd index f8adc92f6..aa7711a05 100644 --- a/examples/simulated-player/scripts/simulated_player.gd +++ b/examples/simulated-player/scripts/simulated_player.gd @@ -1,8 +1,7 @@ extends CharacterBody3D - -const SPEED = 5.0 -const JUMP_VELOCITY = 4.5 +const SPEED = 30 +const JUMP_VELOCITY = 6.0 # Get the gravity from the project settings to be synced with RigidBody nodes. var gravity = ProjectSettings.get_setting("physics/3d/default_gravity") @@ -34,7 +33,7 @@ func _simulated_tick(delta : float, _tick : int): print("input_movement is :%s" %input.movement) print("velocity is :%s" %velocity) print("position is before move and slide: %s" %position) - velocity *= NetworkTime.physics_factor +# velocity *= NetworkTime.physics_factor move_and_slide() - velocity /= NetworkTime.physics_factor +# velocity /= NetworkTime.physics_factor print("position is after move and slide: %s" %position) From 69d80c7960f75ca55f4d4e9ed0ac379d5c7bba6a Mon Sep 17 00:00:00 2001 From: tuysuztavsan Date: Thu, 16 Jul 2026 16:19:01 +0300 Subject: [PATCH 27/57] clean up for next changes --- addons/netfox/input-sender.gd | 4 + addons/netfox/netfox.gd | 5 + addons/netfox/servers/input-sender-server.gd | 6 - .../servers/network-synchronization-server.gd | 2 + addons/netfox/servers/simulator-server.gd | 261 ++++-------------- .../scripts/simulated_player.gd | 18 +- .../scripts/simulated_player_input.gd | 2 +- 7 files changed, 77 insertions(+), 221 deletions(-) diff --git a/addons/netfox/input-sender.gd b/addons/netfox/input-sender.gd index e95b2576a..14a91589f 100644 --- a/addons/netfox/input-sender.gd +++ b/addons/netfox/input-sender.gd @@ -236,6 +236,10 @@ func _apply_snapshot_for_self(snapshot : _Snapshot) -> void: # TODO is this should be node.set_indexed ?? subject.set_indexed(property, value) +## Predicts inputs for given tick. +func predict_inputs() -> void: + pass + # Helper function to save current input_properties. # Used internally by InputSenderServer/SimulatorServer to record state before # overwriting properties and emitting signals. diff --git a/addons/netfox/netfox.gd b/addons/netfox/netfox.gd index a3c2eafc5..cc5590ae8 100644 --- a/addons/netfox/netfox.gd +++ b/addons/netfox/netfox.gd @@ -209,6 +209,11 @@ var SETTINGS: Array[Dictionary] = [ "value": 64, "type" : TYPE_INT }, + { + "name": "netfox/simulator/host_delay_ticks", + "value": 8, + "type" : TYPE_INT + }, ] const AUTOLOADS: Array[Dictionary] = [ diff --git a/addons/netfox/servers/input-sender-server.gd b/addons/netfox/servers/input-sender-server.gd index beb1b11e3..4e23499c8 100644 --- a/addons/netfox/servers/input-sender-server.gd +++ b/addons/netfox/servers/input-sender-server.gd @@ -13,7 +13,6 @@ static var _logger := NetfoxLogger._for_netfox("InputSenderServer") var _history_server : NetworkHistoryServer = null var _synchronization_server : NetworkSynchronizationServer = null -var _simulator_server : SimulatorServer = null var _missing_inputs_history_size : int = ProjectSettings.get_setting("netfox/input_sender/missing_input_history", 16) var _input_sender_history_size : int = ProjectSettings.get_setting("netfox/input_sender/history_limit", 64) @@ -28,7 +27,6 @@ func _ready(): # Ensure dependencies if not _history_server: _history_server = NetworkHistoryServer if not _synchronization_server: _synchronization_server = NetworkSynchronizationServer - if not _simulator_server: _simulator_server = SimulatorServer # Record inputs similiar to rollback so that users can use same methods # on their input scripts. @@ -137,10 +135,6 @@ func _handle_new_snapshots(current_tick : int) -> void: else: # Its within range, consider it as network input. input_sender.network_input.emit(i) - - # Notify SimulatorServer about this network_input. - # Why? Read Simulators and SimulatorServer to learn. - _simulator_server._notify_input_sender_received_network_input(input_sender, i) # Set as handled anyway for both situation. _set_input_received_for_input_sender(input_sender, i) diff --git a/addons/netfox/servers/network-synchronization-server.gd b/addons/netfox/servers/network-synchronization-server.gd index dc2c65d47..4171f219d 100644 --- a/addons/netfox/servers/network-synchronization-server.gd +++ b/addons/netfox/servers/network-synchronization-server.gd @@ -375,6 +375,7 @@ func _synchronize_input_sender(tick: int) -> void: func _synchronize_simulator(tick: int) -> void: # We don't own state, nothing to synchronize if _simulator_owned_properties.is_empty(): + _logger.trace("No owned simulator property to synchronize, returning.") return var snapshot := NetworkHistoryServer._get_simulator_snapshot(tick) @@ -396,6 +397,7 @@ func _synchronize_simulator(tick: int) -> void: # Peer can't see anything, send nothing continue + _logger.trace("Submitting simulator full state:%s to peer:%s", [snapshot, peer]) _cmd_full_simulator.send(data, peer) NetworkPerformance.push_full_state_props(snapshot.size()) diff --git a/addons/netfox/servers/simulator-server.gd b/addons/netfox/servers/simulator-server.gd index ba7433525..910557d74 100644 --- a/addons/netfox/servers/simulator-server.gd +++ b/addons/netfox/servers/simulator-server.gd @@ -8,50 +8,56 @@ class_name _SimulatorServer ## TODO: We should find a better name for Simulator word. ## ## [Before reading this server, please read InputSender, InputSenderServer, Simulator.] + +## Insight +## +## +## Depending on simulator authority there are 4 options we can operate on: +## +## 1- We have authority over both simulator input/state = host simulator +## 2- We have authority over input but not state = local authoritative simulator +## 3- We have authority over state but not input = host puppet +## 4- We dont have any authority over input/state = client puppet +## +## 1- host simulator: +## - Advance the simulation with the inputs tick - 1. +## (input-sender inputs are recorded on after tick). +## - Save the simulator state for current tick. +## - Broadcast it togather with 2. +## +## 2- local authoritative simulator: +## - We have inputs available locally up to tick -1. +## (input-sender inputs are recorded on after tick). +## - Apply the latest authoritative state received from host. (the truth) +## - Iterate over inputs and reach the current tick - 1. +## +## 3- host puppet. +## - If we dont have inputs buffered yet, dont run the simulation, simply skip. +## - If we have inputs buffered, run the simulation with indexed inputs for 1 simulation tick. +## - Record the simulator state for given simulation tick. +## - Broadcast them. ## +## 4- client puppet. +## - Apply the latest authoritative state received from host. (the truth) ## -## Insight: -## SimulatorServer has to know details about each simulators input-senders. -## Inputs might not always arrive at correct order. -## Inputs might not always arrive. -## Since iterating over every snapshot and check for them is already done by -## InputSenderServer, we will expose a function for InputSenderServer to notify -## us with the input state of simulators. -## SimulatorServer will not consider late or missing inputs to derive simulation. +## 4 can be simply achieved with NetworkHistoryServer._restore_simulator var _history_server : _NetworkHistoryServer = null var _synchronization_server : _NetworkSynchronizationServer = null +var _logger := NetfoxLogger._for_netfox("SimulatorServer") +# History size for simulation. var _simulation_history_size : int = ProjectSettings.get_setting("netfox/simulator/history_limit", 64) -# Simulators that has input authority. -# These simulators would be your typical local players. -# If this is host, these simulators also owns state. -# But that doesnt need an extra category. -# For this category, we accept the truth from latest known state -# and apply local inputs until we reach current tick. -var _authoritative_simulators : Array[Simulator] = [] +# Host side buffering/delay tick count for simulation. +var _simulation_host_delay_ticks : int = ProjectSettings.get_setting("netfox/simulator/host_delay_ticks", 8) -# Simulators that has no input authority. -# These simulators belong to remote players. -# -# On host: -# These simulators also owns state. -# We run simulation one tick per received network_input. -# -# On other players: -# These simulators are not doing any simulation at all. -# These simulators are accepting latest truth and applying that state. -var _puppet_simulators : Array[Simulator] = [] - -# Maps InputSender to Simulator. -# We keep this to easily reach in reverse fashion when we need it. -var _input_sender_to_simulator : Dictionary = {} - -# Maps simulator to simulator-input-history (inner class at the end of the file) -# SimulatorInputHistories are buffered on _notify_input_sender_received_network_input, -# SimulatorInputHistories are consumed on _after_tick_loop. -var _simulator_to_simulator_input_history : Dictionary = {} +# Grouped simulators depending on their authority modes. +# Better readability on code / we only check authority on register. +var _host_simulators : Array[Simulator] = [] +var _local_authoritative_simulators : Array[Simulator] = [] +var _host_puppet_simulators : Array[Simulator] = [] +var _client_puppet_simulators : Array[Simulator] = [] func _ready(): # Ensure dependencies @@ -71,186 +77,31 @@ func _ready(): # Do simulating logic depending on authorities. func _after_tick_loop() -> void: - - _handle_authoritatives() - _handle_puppets() - - # Since we consumed, we can now clear this. - _simulator_to_simulator_input_history.clear() + pass -func _handle_authoritatives() -> void: - var current_tick := NetworkTime.tick - - if is_multiplayer_authority(): - # This is host. - # On host we need to advance authoritative player with our local inputs. - # In another words - this is a host playing locally. - for simulator in _authoritative_simulators: - - # DANGER we are doing current_tick -1 because input-senders are recorded - # on after-tick. - var input_snapshot := _history_server._get_input_sender_snapshot(current_tick -1) - - if not input_snapshot: - # no input snapshot for some reason, this shouldnt really happen. - continue - - var input_sender := simulator.listened_input_sender - # Save current properties so that we dont mess with input recording. - input_sender._save_properties() - - input_sender._apply_snapshot_for_self(input_snapshot) - # TODO do we use ticktime for delta value??? - simulator._run_simulation(NetworkTime.ticktime, current_tick) - - # Restore input_sender properties after messing with inputs - input_sender._restore_properties() - else: - # This is local authoritative player. - # On local players we need to first accept latest received truth from host. - # Then simulate our local inputs to reach current state. - - for simulator in _authoritative_simulators: - - var latest_state_tick := _history_server.get_latest_simulator_for( - simulator._state_properties.get_subjects(), current_tick) - - if latest_state_tick >= 0: - var latest_state_snapshot := _history_server._get_simulator_snapshot(latest_state_tick) - - if latest_state_snapshot: - simulator._apply_snapshot_for_self(latest_state_snapshot) - - # Whether we found latest snapshot or not, we need to iterate over our local inputs now. - - var simulation_start_tick := latest_state_tick - - # Dont let it past the history size. - if simulation_start_tick < current_tick - _simulation_history_size: - simulation_start_tick = current_tick - _simulation_history_size - - # Dont let it become negative. - if simulation_start_tick < 0: - simulation_start_tick = 0 - - # DANGER since we only have inputs available up to current_tick -1 - # We will stop at current_tick - 1 INCLUSIVE - # Why -1 ? Read InputSenderServer. - - # Record input_sender state before messing with inputs. - simulator.listened_input_sender._save_properties() - - for i in range(simulation_start_tick, current_tick): - var input_snapshot := _history_server._get_input_sender_snapshot(i) - - # We should have input snapshot available for ourselves anyway... - if input_snapshot: - simulator.listened_input_sender._apply_snapshot_for_self(input_snapshot) - # TODO do we use ticktime for delta value??? - simulator._run_simulation(NetworkTime.ticktime, i) - - # Restore input_sender state after messing with inputs. - simulator.listened_input_sender._restore_properties() - -func _handle_puppets() -> void: - var current_tick := NetworkTime.tick - - if is_multiplayer_authority(): - # This is host, - # On host we derive the simulation forward with new network_inputs. - - for simulator in _puppet_simulators: - - # Check if we have new input for this simulator - var simulator_input_history := _simulator_to_simulator_input_history.get(simulator) as SimulatorInputHistory - - if not simulator_input_history: - continue - - var ticks_with_new_inputs : Array[int] = simulator_input_history.get_ticks_with_new_inputs() - - if ticks_with_new_inputs.is_empty(): - continue - - # We dont need to record/restore input_sender state as we dont own input. - - for new_input_tick in ticks_with_new_inputs: - var input_snapshot := _history_server._get_input_sender_snapshot(new_input_tick) - - if not input_snapshot: - # This shouldnt happen anyway. - continue - - simulator.listened_input_sender._apply_snapshot_for_self(input_snapshot) - # TODO do we use ticktime for delta value??? - simulator._run_simulation(NetworkTime.ticktime, new_input_tick) - else: - # This is not host - # We only accept the true state which is broadcasted to us. - - for simulator in _puppet_simulators: - - var latest_state_snapshot_tick := _history_server.get_latest_simulator_for( - simulator._state_properties.get_subjects(), current_tick) - - if latest_state_snapshot_tick >= 0: - var state_snapshot := _history_server._get_simulator_snapshot(latest_state_snapshot_tick) - - if state_snapshot: - simulator._apply_snapshot_for_self(state_snapshot) - -## InputSenderServer will call this function to notify that param input_sender -## received a new network_input on param on_tick. This network_input must also be -## within range of missing_input_history size. -## (See projectSettings netfox/input-sender/missing-input-history) -## (Also read InputSenderServer) -func _notify_input_sender_received_network_input(input_sender : InputSender, on_tick : int) -> void: - var matching_simulator := _input_sender_to_simulator.get(input_sender) as Simulator - - if not matching_simulator: - return - - var simulator_input_history := _simulator_to_simulator_input_history.get(matching_simulator) as SimulatorInputHistory - - if not simulator_input_history: - simulator_input_history = SimulatorInputHistory.new() - _simulator_to_simulator_input_history[matching_simulator] = simulator_input_history - - simulator_input_history.push_back_new_input_tick(on_tick) # Register a simulator node. # Will check for authority over inputs and categorize by it. +# Its Simulator's responsibility to only register if input-sender is configured. func _register_simulator(simulator : Simulator) -> void: - if simulator.has_authority_over_inputs(): - _authoritative_simulators.push_back(simulator) + if simulator.is_multiplayer_authority(): + if simulator.listened_input_sender.has_authority_over_input_nodes(): + _host_simulators.push_back(simulator) + else: + _host_puppet_simulators.push_back(simulator) else: - _puppet_simulators.push_back(simulator) - - _input_sender_to_simulator[simulator.listened_input_sender] = simulator + if simulator.listened_input_sender.has_authority_over_input_nodes(): + _local_authoritative_simulators.push_back(simulator) + else: + _client_puppet_simulators.push_back(simulator) # Deregister a simulator node. func _deregister_simulator(simulator : Simulator) -> void: - _puppet_simulators.erase(simulator) - _authoritative_simulators.erase(simulator) - - _input_sender_to_simulator.erase(_input_sender_to_simulator.find_key(simulator)) + _host_simulators.erase(simulator) + _host_puppet_simulators.erase(simulator) + _local_authoritative_simulators.erase(simulator) + _client_puppet_simulators.erase(simulator) func _init(p_history_server: _NetworkHistoryServer = null, p_synchronization_server: _NetworkSynchronizationServer = null): _history_server = p_history_server _synchronization_server = p_synchronization_server - -## Inner class to keep Simulator related input ticks organized. -## Whenever InputSenderServer notifies us about input-sender-recived-network-input. -## If we have simulator for that given input-sender -## We will store that tick with the help of this class. -## Later on we will consume these stored ticks to run simulation. -class SimulatorInputHistory extends RefCounted: - - var _ticks_with_new_inputs : Array[int] = [] - - func push_back_new_input_tick(new_input_tick : int) -> void: - if not _ticks_with_new_inputs.has(new_input_tick): - _ticks_with_new_inputs.push_back(new_input_tick) - - func get_ticks_with_new_inputs() -> Array[int]: - return _ticks_with_new_inputs diff --git a/examples/simulated-player/scripts/simulated_player.gd b/examples/simulated-player/scripts/simulated_player.gd index aa7711a05..762626a03 100644 --- a/examples/simulated-player/scripts/simulated_player.gd +++ b/examples/simulated-player/scripts/simulated_player.gd @@ -1,6 +1,6 @@ extends CharacterBody3D -const SPEED = 30 +const SPEED = 5 const JUMP_VELOCITY = 6.0 # Get the gravity from the project settings to be synced with RigidBody nodes. @@ -9,7 +9,7 @@ var gravity = ProjectSettings.get_setting("physics/3d/default_gravity") @onready var input = $Input func _simulated_tick(delta : float, _tick : int): - print("Running simulated tick.") +# print("Running simulated tick.") # Add the gravity. if not is_on_floor(): velocity.y -= gravity * delta @@ -29,11 +29,11 @@ func _simulated_tick(delta : float, _tick : int): velocity.x = move_toward(velocity.x, 0, SPEED) velocity.z = move_toward(velocity.z, 0, SPEED) - print("input_jump is :%s" %input.jump) - print("input_movement is :%s" %input.movement) - print("velocity is :%s" %velocity) - print("position is before move and slide: %s" %position) -# velocity *= NetworkTime.physics_factor +# print("input_jump is :%s" %input.jump) +# print("input_movement is :%s" %input.movement) +# print("velocity is :%s" %velocity) +# print("position is before move and slide: %s" %position) + velocity *= NetworkTime.physics_factor move_and_slide() -# velocity /= NetworkTime.physics_factor - print("position is after move and slide: %s" %position) + velocity /= NetworkTime.physics_factor +# print("position is after move and slide: %s" %position) diff --git a/examples/simulated-player/scripts/simulated_player_input.gd b/examples/simulated-player/scripts/simulated_player_input.gd index 11f97bb0f..ac41bb3c4 100644 --- a/examples/simulated-player/scripts/simulated_player_input.gd +++ b/examples/simulated-player/scripts/simulated_player_input.gd @@ -19,4 +19,4 @@ func _gather(): movement = Vector3(mx, 0, mz) jump = Input.is_action_pressed("move_jump") - print("inputs: movement %s, jump %s" %[movement, jump]) +# print("inputs: movement %s, jump %s" %[movement, jump]) From b569da5ebe9638f4a8f4b58588aae39c678b633d Mon Sep 17 00:00:00 2001 From: tuysuztavsan Date: Thu, 16 Jul 2026 20:25:08 +0300 Subject: [PATCH 28/57] rewrite, find out why snapshots are sendin inaccurate --- .../netfox/servers/network-history-server.gd | 33 ++++ .../servers/network-synchronization-server.gd | 1 + addons/netfox/servers/simulator-server.gd | 149 ++++++++++++++++-- addons/netfox/simulator.gd | 40 +++-- .../simulated_player_example.tscn | 2 +- 5 files changed, 196 insertions(+), 29 deletions(-) diff --git a/addons/netfox/servers/network-history-server.gd b/addons/netfox/servers/network-history-server.gd index f8aae33e6..59c404549 100644 --- a/addons/netfox/servers/network-history-server.gd +++ b/addons/netfox/servers/network-history-server.gd @@ -214,6 +214,38 @@ func _record_simulator(tick: int) -> void: return subject.is_multiplayer_authority() ) +# Records given simulator for given tick. +func _record_individual_simulator(simulator : Simulator, tick: int) -> void: + var snapshot := _simulator_snapshots.get_at(tick, _Snapshot.new(tick)) as _Snapshot + + if not _simulator_snapshots.has_at(tick): + _simulator_snapshots.set_at(tick, snapshot) + + var property_pool := _PropertyPool.new() + property_pool.set_from_paths(simulator, simulator.state_properties) + + for subject in property_pool.get_subjects(): + assert(subject is Node, "Only nodes supported forn now!") + + var is_auth := subject.is_multiplayer_authority() as bool + + if not is_auth: + continue + if not is_auth and _simulator_history.is_auth(tick, subject): + continue + + var subject_snapshot := _simulator_history.ensure_snapshot(tick, subject, false) + if subject_snapshot == null: + _logger.warning("Dropping recorded tick @%d for subject %s as out-of-bounds", [tick, subject]) + continue + + assert(not property_pool.get_properties_of(subject).is_empty(), "Subject present in property pool without properties! Please report a bug!") + for property in property_pool.get_properties_of(subject): + subject_snapshot.record_property(property) + snapshot.record_property(subject, property) + snapshot.set_auth(subject, is_auth) + subject_snapshot.set_auth(is_auth) + func _restore_rollback_input(tick: int) -> bool: return _restore_latest(tick, _rb_input_history) @@ -321,6 +353,7 @@ func _restore_latest(tick: int, history: _PerObjectHistory) -> bool: any_applied = true match history: + _simulator_history: _logger.trace("Restored simulation state @%d: %s", [tick, snapshot]) _rb_input_history: _logger.trace("Restored input @%d: %s", [tick, snapshot]) _rb_state_history: _logger.trace("Restored state @%d: %s", [tick, snapshot]) diff --git a/addons/netfox/servers/network-synchronization-server.gd b/addons/netfox/servers/network-synchronization-server.gd index 4171f219d..3c9ac8b47 100644 --- a/addons/netfox/servers/network-synchronization-server.gd +++ b/addons/netfox/servers/network-synchronization-server.gd @@ -395,6 +395,7 @@ func _synchronize_simulator(tick: int) -> void: var data := _dense_serializer.write_for(peer, snapshot, _simulator_owned_properties, filter) if data.is_empty(): # Peer can't see anything, send nothing + _logger.trace("Peer cant see anything, not sending.") continue _logger.trace("Submitting simulator full state:%s to peer:%s", [snapshot, peer]) diff --git a/addons/netfox/servers/simulator-server.gd b/addons/netfox/servers/simulator-server.gd index 910557d74..60b81f1f3 100644 --- a/addons/netfox/servers/simulator-server.gd +++ b/addons/netfox/servers/simulator-server.gd @@ -22,7 +22,7 @@ class_name _SimulatorServer ## 1- host simulator: ## - Advance the simulation with the inputs tick - 1. ## (input-sender inputs are recorded on after tick). -## - Save the simulator state for current tick. +## - Save the simulator state for current tick as invidiual fashion. ## - Broadcast it togather with 2. ## ## 2- local authoritative simulator: @@ -40,7 +40,10 @@ class_name _SimulatorServer ## 4- client puppet. ## - Apply the latest authoritative state received from host. (the truth) ## -## 4 can be simply achieved with NetworkHistoryServer._restore_simulator +## +## By restoring to latest state we already handle 4. +## We dont need to keep history of non-host simulators. + var _history_server : _NetworkHistoryServer = null var _synchronization_server : _NetworkSynchronizationServer = null @@ -65,25 +68,149 @@ func _ready(): if not _synchronization_server: _synchronization_server = NetworkSynchronizationServer # Just like rollback, record and synchronize after tick. - # TODO if we find out that physics dont work like this, find better timing. - NetworkTime.after_tick.connect(func(_dt, tick): - _history_server._record_simulator(tick) - _synchronization_server._synchronize_simulator(tick) - ) + # We only need to record _host_puppet_simulators and _host_simulators. + # _host_simulators are saved individually. +# NetworkTime.after_tick.connect(func(_dt, tick): +# +# for simulator in _host_simulators: +# _history_server.ignore(simulator) +# +# for simulator in _local_authoritative_simulators: +# _history_server.ignore(simulator) +# +# for simulator in _client_puppet_simulators: +# _history_server.ignore(simulator) +# +# if tick - _simulation_host_delay_ticks >= 0: +# _history_server._record_simulator(tick - _simulation_host_delay_ticks) +# _synchronization_server._synchronize_simulator(tick - _simulation_host_delay_ticks) +# +# _history_server.flush_ignores() +# ) # We do our simulating logic after tick loop, similiar to rollback in general. # TODO if we find out that physics dont work like this, find better timing. NetworkTime.after_tick_loop.connect(_after_tick_loop) -# Do simulating logic depending on authorities. func _after_tick_loop() -> void: - pass + _handle_host_simulators() + _handle_host_puppet_simulators() + _handle_local_authoritative_simulators() + + # We only need to save host_simulators and _host_puppet_simulators. + for simulator in _local_authoritative_simulators: + _history_server.ignore(simulator) + + for simulator in _client_puppet_simulators: + _history_server.ignore(simulator) + + if NetworkTime.tick - _simulation_host_delay_ticks >= 0: + _history_server._record_simulator(NetworkTime.tick - _simulation_host_delay_ticks) + _synchronization_server._synchronize_simulator(NetworkTime.tick - _simulation_host_delay_ticks) + + _history_server.flush_ignores() + _history_server._restore_simulator(NetworkTime.tick) +## 1- host simulator: +## - Advance the simulation with the inputs tick - 1. +## (input-sender inputs are recorded on after tick). +## - Save the simulator state for current tick. +## - Broadcast it togather with 2. +func _handle_host_simulators() -> void: + var current_tick := NetworkTime.tick + + for simulator in _host_simulators: + + var input_snapshot := _history_server._get_input_sender_snapshot(current_tick - 1) + + if not input_snapshot: + _logger.error("Host simulator: %s should have had inputs available for tick %s\ + skipping simulation.", [simulator, current_tick]) + + continue + + # Save input properties before messing them up. + simulator.listened_input_sender._save_properties() + + simulator.listened_input_sender._apply_snapshot_for_self(input_snapshot) + simulator._run_simulation(NetworkTime.ticktime, current_tick) + + # Restore messed up properties. + simulator.listened_input_sender._restore_properties() + +## 2- local authoritative simulator: +## - We have inputs available locally up to tick -1. +## (input-sender inputs are recorded on after tick). +## - Apply the latest authoritative state received from host. (the truth) +## - Iterate over inputs and reach the current tick - 1. +## +## Latest authoritatiev state is already applied for us before calling this function. +## Iteratre over inputs and reach the current tick - 1. +func _handle_local_authoritative_simulators() -> void: + var current_tick := NetworkTime.tick + + for simulator in _local_authoritative_simulators: + + # Save input properties before messing them up. + simulator.listened_input_sender._save_properties() + + var latest_truth_tick := _history_server.get_latest_simulator_for(simulator.state_properties, current_tick) + + for i in range(latest_truth_tick, current_tick): + + var input_snapshot := _history_server._get_simulator_snapshot(i) + + if not input_snapshot: + _logger.error("Host simulator: %s should have had inputs available for tick %s\ + skipping simulation.", [simulator, current_tick]) + + continue + + simulator.listened_input_sender._apply_snapshot_for_self(input_snapshot) + simulator._run_simulation(NetworkTime.ticktime, current_tick) + _history_server._record_individual_simulator(simulator, current_tick) + + # Restore messed up properties. + simulator.listened_input_sender._restore_properties() + +## 3- host puppet. +## - If we dont have inputs buffered yet, dont run the simulation, simply skip. +## - If we have inputs buffered, run the simulation with indexed inputs for 1 simulation tick. +## - Record the simulator state for given simulation tick. +## - Broadcast them. +func _handle_host_puppet_simulators() -> void: + var current_tick := NetworkTime.tick + + var simulated_tick := current_tick - _simulation_host_delay_ticks + + for simulator in _host_puppet_simulators: + + var latest_input_tick := _history_server.get_latest_input_sender_for( + simulator.listened_input_sender._input_properties.get_subjects(), + simulated_tick + ) + + if latest_input_tick == simulated_tick: + # We have inputs for this tick, run the simulation. + var input_snapshot := _history_server._get_input_sender_snapshot(latest_input_tick) + if not input_snapshot: + _logger.error("No input snapshot found at latest input tick, this shouldnt happen.") + continue + + _logger.trace("Running simulation for %s", [simulator]) + simulator.listened_input_sender._apply_snapshot_for_self(input_snapshot) + simulator._run_simulation(NetworkTime.ticktime, simulated_tick) + else: + # We need to predict this frame. + _logger.warning("No buffered input found, predicting inputs.") + + simulator.listened_input_sender.predict_inputs() + simulator._run_simulation(NetworkTime.ticktime, simulated_tick) # Register a simulator node. # Will check for authority over inputs and categorize by it. # Its Simulator's responsibility to only register if input-sender is configured. -func _register_simulator(simulator : Simulator) -> void: +func register_simulator(simulator : Simulator) -> void: if simulator.is_multiplayer_authority(): if simulator.listened_input_sender.has_authority_over_input_nodes(): _host_simulators.push_back(simulator) @@ -96,7 +223,7 @@ func _register_simulator(simulator : Simulator) -> void: _client_puppet_simulators.push_back(simulator) # Deregister a simulator node. -func _deregister_simulator(simulator : Simulator) -> void: +func deregister_simulator(simulator : Simulator) -> void: _host_simulators.erase(simulator) _host_puppet_simulators.erase(simulator) _local_authoritative_simulators.erase(simulator) diff --git a/addons/netfox/simulator.gd b/addons/netfox/simulator.gd index 1e5015fe5..9a21598de 100644 --- a/addons/netfox/simulator.gd +++ b/addons/netfox/simulator.gd @@ -77,9 +77,14 @@ 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() + + # Reprocess authority on connect + if NetworkEvents.enabled: + # User might change `multiplayer` - `NetworkEvents` handles that + NetworkEvents.on_client_start.connect(func(__): process_settings()) + else: + multiplayer.connected_to_server.connect(process_settings) func _enter_tree() -> void: if Engine.is_editor_hint(): @@ -89,27 +94,28 @@ func _enter_tree() -> void: if not visibility_filter: visibility_filter = PeerVisibilityFilter.new() - + if not visibility_filter.get_parent(): add_child(visibility_filter) - - if not NetworkTime.is_initial_sync_done(): - # Wait for time sync to complete - await NetworkTime.after_sync - - process_settings.call_deferred() func _exit_tree() -> void: + if Engine.is_editor_hint(): + return + _managed_roots.erase(root) + + + for node in _sim_nodes + _state_properties.get_subjects(): + NetworkSynchronizationServer.deregister(node) + NetworkIdentityServer.deregister_node(node) + NetworkHistoryServer.deregister(node) + + SimulatorServer.deregister_simulator(self) + func _notification(what: int) -> void: if what == NOTIFICATION_EDITOR_PRE_SAVE: update_configuration_warnings() - elif what == NOTIFICATION_PREDELETE: - for node in _sim_nodes + _state_properties.get_subjects(): - NetworkSynchronizationServer.deregister(node) - NetworkIdentityServer.deregister_node(node) - NetworkHistoryServer.deregister(node) func _get_configuration_warnings() -> PackedStringArray: if not root: @@ -157,7 +163,7 @@ func process_settings() -> void: ## Make sure to do this at the same time on all peers. func process_authority(): # First de-register. - SimulatorServer._deregister_simulator(self) + SimulatorServer.deregister_simulator(self) for node in _state_properties.get_subjects(): for property in _state_properties.get_properties_of(node): @@ -177,7 +183,7 @@ func process_authority(): NetworkHistoryServer.register_simulator(node, property) NetworkSynchronizationServer.register_simulator(node, property) - SimulatorServer._register_simulator(self) + SimulatorServer.register_simulator(self) ## Add a state property. ## [br][br] diff --git a/examples/simulated-player/simulated_player_example.tscn b/examples/simulated-player/simulated_player_example.tscn index 0ac165d7c..d06bf5bc8 100644 --- a/examples/simulated-player/simulated_player_example.tscn +++ b/examples/simulated-player/simulated_player_example.tscn @@ -10,7 +10,7 @@ [node name="SimulatedPlayerExample" type="Node3D"] [node name="Square Map" parent="." instance=ExtResource("1_t21fn")] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1.65592, 0) +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -3.80249, 0) [node name="Environment" parent="." instance=ExtResource("2_epr86")] From 0cf3b89539c360331c76ef44a77f9d748ed035f6 Mon Sep 17 00:00:00 2001 From: tuysuztavsan Date: Fri, 17 Jul 2026 02:52:55 +0300 Subject: [PATCH 29/57] working except local authoritative simulator --- addons/netfox/input-sender.gd | 23 ++++-- .../netfox/servers/network-history-server.gd | 13 ++-- .../servers/network-synchronization-server.gd | 43 +++-------- addons/netfox/servers/simulator-server.gd | 77 ++++++------------- 4 files changed, 61 insertions(+), 95 deletions(-) diff --git a/addons/netfox/input-sender.gd b/addons/netfox/input-sender.gd index 14a91589f..a265be8f8 100644 --- a/addons/netfox/input-sender.gd +++ b/addons/netfox/input-sender.gd @@ -77,6 +77,23 @@ var _saved_inputs_snapshot : _PropertySnapshot var _property_cache: PropertyCache var _property_entries: Array[PropertyEntry] = [] +func _ready() -> void: + if Engine.is_editor_hint(): + return + + # DANGER we dont do deferred call here, because Simulator node depends on + # has_authority_over_input to determine its register logic. + # Therefore it is needed that input-sender is registered and ready to + # return the result of that helper function. + process_settings() + + # Reprocess authority on connect + if NetworkEvents.enabled: + # User might change `multiplayer` - `NetworkEvents` handles that + NetworkEvents.on_client_start.connect(func(__): process_settings()) + else: + multiplayer.connected_to_server.connect(process_settings) + func _enter_tree() -> void: if Engine.is_editor_hint(): return @@ -86,12 +103,6 @@ func _enter_tree() -> void: if not visibility_filter.get_parent(): add_child(visibility_filter) - - if not NetworkTime.is_initial_sync_done(): - # Wait for time sync to complete - await NetworkTime.after_sync - - process_settings.call_deferred() func _exit_tree(): if Engine.is_editor_hint(): diff --git a/addons/netfox/servers/network-history-server.gd b/addons/netfox/servers/network-history-server.gd index 59c404549..ae18d75d5 100644 --- a/addons/netfox/servers/network-history-server.gd +++ b/addons/netfox/servers/network-history-server.gd @@ -214,7 +214,12 @@ func _record_simulator(tick: int) -> void: return subject.is_multiplayer_authority() ) -# Records given simulator for given tick. +# Records given simulator for given tick even though it doesnt have authority. +# This function is used by SimulatorServer to record simulators that we own +# input but not state. +# As a design perspective, a game should have max 1-2 simulators like this. +# So this is not really expensive and not usually used on servers. +# No auth snapshot is overriden. func _record_individual_simulator(simulator : Simulator, tick: int) -> void: var snapshot := _simulator_snapshots.get_at(tick, _Snapshot.new(tick)) as _Snapshot @@ -225,13 +230,11 @@ func _record_individual_simulator(simulator : Simulator, tick: int) -> void: property_pool.set_from_paths(simulator, simulator.state_properties) for subject in property_pool.get_subjects(): - assert(subject is Node, "Only nodes supported forn now!") + assert(subject is Node, "Only nodes supported for now!") var is_auth := subject.is_multiplayer_authority() as bool - if not is_auth: - continue - if not is_auth and _simulator_history.is_auth(tick, subject): + if _simulator_history.is_auth(tick, subject): continue var subject_snapshot := _simulator_history.ensure_snapshot(tick, subject, false) diff --git a/addons/netfox/servers/network-synchronization-server.gd b/addons/netfox/servers/network-synchronization-server.gd index 3c9ac8b47..981aee5da 100644 --- a/addons/netfox/servers/network-synchronization-server.gd +++ b/addons/netfox/servers/network-synchronization-server.gd @@ -379,7 +379,13 @@ func _synchronize_simulator(tick: int) -> void: return var snapshot := NetworkHistoryServer._get_simulator_snapshot(tick) + if not snapshot: + # No data for tick + return + + if snapshot.is_empty(): + # Nothing to send return # Figure out whether to send full- or diff state @@ -391,38 +397,11 @@ func _synchronize_simulator(tick: int) -> void: # Send full states for peer in multiplayer.get_peers(): var filter := func(subject): return _is_node_visible_to(peer, subject) - - var data := _dense_serializer.write_for(peer, snapshot, _simulator_owned_properties, filter) - if data.is_empty(): - # Peer can't see anything, send nothing - _logger.trace("Peer cant see anything, not sending.") - continue - - _logger.trace("Submitting simulator full state:%s to peer:%s", [snapshot, peer]) - _cmd_full_simulator.send(data, peer) - - NetworkPerformance.push_full_state_props(snapshot.size()) - NetworkPerformance.push_sent_state_props(snapshot.size()) - else: - var diff := _Snapshot.make_patch(_last_simulator_state_sent, snapshot) - - # Send diffs - for peer in multiplayer.get_peers(): - var filter := func(subject): return _is_node_visible_to(peer, subject) - - var data := _sparse_serializer.write_for(peer, diff, _simulator_owned_properties, filter) - if data.is_empty(): - # Peer can't see anything, send nothing - continue - - _cmd_diff_simulator.send(data, peer) - - NetworkPerformance.push_full_state_props(snapshot.size()) - NetworkPerformance.push_sent_state_props(diff.size()) - - # Remember last sent state for diffing - # NOTE: This is a shared instance, theoretically shouldn't screw things up - _last_simulator_state_sent = snapshot + + var packets := _dense_serializer.write_for(peer, snapshot, _simulator_owned_properties, filter) + for packet in packets: + _logger.trace("Submitting simulator full state:%s to peer:%s", [snapshot, peer]) + _cmd_full_simulator.send(packet, peer) func _init( p_command_server: _NetworkCommandServer = null, diff --git a/addons/netfox/servers/simulator-server.gd b/addons/netfox/servers/simulator-server.gd index 60b81f1f3..2530d9783 100644 --- a/addons/netfox/servers/simulator-server.gd +++ b/addons/netfox/servers/simulator-server.gd @@ -67,27 +67,6 @@ func _ready(): if not _history_server: _history_server = NetworkHistoryServer if not _synchronization_server: _synchronization_server = NetworkSynchronizationServer - # Just like rollback, record and synchronize after tick. - # We only need to record _host_puppet_simulators and _host_simulators. - # _host_simulators are saved individually. -# NetworkTime.after_tick.connect(func(_dt, tick): -# -# for simulator in _host_simulators: -# _history_server.ignore(simulator) -# -# for simulator in _local_authoritative_simulators: -# _history_server.ignore(simulator) -# -# for simulator in _client_puppet_simulators: -# _history_server.ignore(simulator) -# -# if tick - _simulation_host_delay_ticks >= 0: -# _history_server._record_simulator(tick - _simulation_host_delay_ticks) -# _synchronization_server._synchronize_simulator(tick - _simulation_host_delay_ticks) -# -# _history_server.flush_ignores() -# ) - # We do our simulating logic after tick loop, similiar to rollback in general. # TODO if we find out that physics dont work like this, find better timing. NetworkTime.after_tick_loop.connect(_after_tick_loop) @@ -97,18 +76,11 @@ func _after_tick_loop() -> void: _handle_host_puppet_simulators() _handle_local_authoritative_simulators() - # We only need to save host_simulators and _host_puppet_simulators. - for simulator in _local_authoritative_simulators: - _history_server.ignore(simulator) - - for simulator in _client_puppet_simulators: - _history_server.ignore(simulator) - if NetworkTime.tick - _simulation_host_delay_ticks >= 0: + # History server only records owned simulator state properties. _history_server._record_simulator(NetworkTime.tick - _simulation_host_delay_ticks) _synchronization_server._synchronize_simulator(NetworkTime.tick - _simulation_host_delay_ticks) - _history_server.flush_ignores() _history_server._restore_simulator(NetworkTime.tick) ## 1- host simulator: @@ -121,19 +93,16 @@ func _handle_host_simulators() -> void: for simulator in _host_simulators: - var input_snapshot := _history_server._get_input_sender_snapshot(current_tick - 1) - - if not input_snapshot: - _logger.error("Host simulator: %s should have had inputs available for tick %s\ - skipping simulation.", [simulator, current_tick]) - - continue - # Save input properties before messing them up. simulator.listened_input_sender._save_properties() - simulator.listened_input_sender._apply_snapshot_for_self(input_snapshot) + # Retrieve the input history and apply manually. + var input_history := _history_server._input_sender_history + for subject in simulator.listened_input_sender._input_properties.get_subjects(): + input_history.ensure_snapshot(current_tick - 1, subject, true).apply() + simulator._run_simulation(NetworkTime.ticktime, current_tick) + _history_server._record_individual_simulator(simulator, current_tick) # Restore messed up properties. simulator.listened_input_sender._restore_properties() @@ -143,9 +112,6 @@ func _handle_host_simulators() -> void: ## (input-sender inputs are recorded on after tick). ## - Apply the latest authoritative state received from host. (the truth) ## - Iterate over inputs and reach the current tick - 1. -## -## Latest authoritatiev state is already applied for us before calling this function. -## Iteratre over inputs and reach the current tick - 1. func _handle_local_authoritative_simulators() -> void: var current_tick := NetworkTime.tick @@ -154,21 +120,28 @@ func _handle_local_authoritative_simulators() -> void: # Save input properties before messing them up. simulator.listened_input_sender._save_properties() - var latest_truth_tick := _history_server.get_latest_simulator_for(simulator.state_properties, current_tick) + var latest_truth_tick := _history_server.get_latest_simulator_for( + simulator._state_properties.get_subjects(), + current_tick) + + var latest_truth_snapshot := _history_server._get_simulator_snapshot(latest_truth_tick) + if latest_truth_snapshot: + simulator._apply_snapshot_for_self(latest_truth_snapshot) + + # Save inputs before messing input properties. + simulator.listened_input_sender._save_properties() + # Retrieve the input history to apply it manually. + var input_history := _history_server._input_sender_history for i in range(latest_truth_tick, current_tick): - var input_snapshot := _history_server._get_simulator_snapshot(i) + for subject in simulator.listened_input_sender._input_properties.get_subjects(): + var snapshot : _ObjectSnapshot = input_history.ensure_snapshot(i, subject, false) + if snapshot: + snapshot.apply() - if not input_snapshot: - _logger.error("Host simulator: %s should have had inputs available for tick %s\ - skipping simulation.", [simulator, current_tick]) - - continue - - simulator.listened_input_sender._apply_snapshot_for_self(input_snapshot) - simulator._run_simulation(NetworkTime.ticktime, current_tick) - _history_server._record_individual_simulator(simulator, current_tick) + simulator._run_simulation(NetworkTime.ticktime, i) + _history_server._record_individual_simulator(simulator, i + 1) # Restore messed up properties. simulator.listened_input_sender._restore_properties() From 25d10f70c5946fc25516cfa8ed2d1bda893468fd Mon Sep 17 00:00:00 2001 From: tuysuztavsan Date: Fri, 17 Jul 2026 16:14:09 +0300 Subject: [PATCH 30/57] synchronizing saving on tick now, restoring after tick loop has been run. local authoritative simu- lator known rubberbanding. Wip for history functions --- addons/netfox/network-time.gd | 4 ++ .../netfox/servers/network-history-server.gd | 48 +++++++++++++++---- addons/netfox/servers/simulator-server.gd | 40 +++++++++------- .../scripts/simulated_player.gd | 10 ++-- 4 files changed, 70 insertions(+), 32 deletions(-) diff --git a/addons/netfox/network-time.gd b/addons/netfox/network-time.gd index 6f5a7df67..d1ae6b81b 100644 --- a/addons/netfox/network-time.gd +++ b/addons/netfox/network-time.gd @@ -573,6 +573,9 @@ func _loop() -> void: # Record data for rollback NetworkRollback._after_tick(tick) + # Simulate, record, synchronize. + SimulatorServer._after_tick(tick) + # Record data for StateSynchronizer NetworkHistoryServer._record_sync_state(tick + 1) NetworkSynchronizationServer._synchronize_sync_state(tick + 1) @@ -614,6 +617,7 @@ func _after_tick_loop() -> void: # Restore state for StateSynchronizer NetworkHistoryServer._restore_synchronizer_state(tick) + NetworkHistoryServer._restore_simulator(tick) InterpolationServer._record_next_state() func _process(delta: float) -> void: diff --git a/addons/netfox/servers/network-history-server.gd b/addons/netfox/servers/network-history-server.gd index ae18d75d5..a4f859b56 100644 --- a/addons/netfox/servers/network-history-server.gd +++ b/addons/netfox/servers/network-history-server.gd @@ -153,6 +153,38 @@ func get_latest_input_sender_for(subjects: Array, tick: int) -> int: func get_latest_simulator_for(subjects: Array, tick: int) -> int: return _get_latest_for(subjects, tick, _simulator_history) +## Get the latest tick where any of the [param subjects] had simulator snapshot available. +## Ignores tick where we dont have full snapshot. (missing properties) +func get_latest_simulator_for_snapshot(subjects: Array, tick: int) -> int: + + var full_subject_ticks : Array[int] = [] + + for index in range(_simulator_snapshots.get_earliest_index(), _simulator_snapshots.get_latest_index() + 1): + var snapshot := _simulator_snapshots.get_at(index) as _Snapshot + + if not snapshot: + continue + + var not_has_any_subject : bool = false + + for subject in subjects: + if not snapshot.has_subject(subject): + not_has_any_subject = true + break + + if not not_has_any_subject: + # We have all subjects in this snapshot. + full_subject_ticks.push_back(snapshot.tick) + + + var latest_tick : int = -1 + if full_subject_ticks.size() > 0: + var latest_in_arr = full_subject_ticks.max() + if latest_in_arr: + latest_tick = latest_in_arr + + return latest_tick + ## Return how old is the latest rollback input data for any of the ## [param subjects], in ticks func get_input_age_for(subjects: Array, tick: int) -> int: @@ -219,15 +251,11 @@ func _record_simulator(tick: int) -> void: # input but not state. # As a design perspective, a game should have max 1-2 simulators like this. # So this is not really expensive and not usually used on servers. -# No auth snapshot is overriden. +# No auth history is overriden. +# No snapshot is written. func _record_individual_simulator(simulator : Simulator, tick: int) -> void: - var snapshot := _simulator_snapshots.get_at(tick, _Snapshot.new(tick)) as _Snapshot - - if not _simulator_snapshots.has_at(tick): - _simulator_snapshots.set_at(tick, snapshot) - var property_pool := _PropertyPool.new() - property_pool.set_from_paths(simulator, simulator.state_properties) + property_pool.set_from_paths(simulator.root, simulator.state_properties) for subject in property_pool.get_subjects(): assert(subject is Node, "Only nodes supported for now!") @@ -237,7 +265,7 @@ func _record_individual_simulator(simulator : Simulator, tick: int) -> void: if _simulator_history.is_auth(tick, subject): continue - var subject_snapshot := _simulator_history.ensure_snapshot(tick, subject, false) + var subject_snapshot := _simulator_history.ensure_snapshot(tick, subject, true) if subject_snapshot == null: _logger.warning("Dropping recorded tick @%d for subject %s as out-of-bounds", [tick, subject]) continue @@ -245,9 +273,9 @@ func _record_individual_simulator(simulator : Simulator, tick: int) -> void: assert(not property_pool.get_properties_of(subject).is_empty(), "Subject present in property pool without properties! Please report a bug!") for property in property_pool.get_properties_of(subject): subject_snapshot.record_property(property) - snapshot.record_property(subject, property) - snapshot.set_auth(subject, is_auth) subject_snapshot.set_auth(is_auth) + + _logger.trace("Recorded simulator state @%d: %s", [tick, subject_snapshot]) func _restore_rollback_input(tick: int) -> bool: return _restore_latest(tick, _rb_input_history) diff --git a/addons/netfox/servers/simulator-server.gd b/addons/netfox/servers/simulator-server.gd index 2530d9783..df1659e78 100644 --- a/addons/netfox/servers/simulator-server.gd +++ b/addons/netfox/servers/simulator-server.gd @@ -66,22 +66,16 @@ func _ready(): # Ensure dependencies if not _history_server: _history_server = NetworkHistoryServer if not _synchronization_server: _synchronization_server = NetworkSynchronizationServer - - # We do our simulating logic after tick loop, similiar to rollback in general. - # TODO if we find out that physics dont work like this, find better timing. - NetworkTime.after_tick_loop.connect(_after_tick_loop) -func _after_tick_loop() -> void: +func _after_tick(_tick : int) -> void: _handle_host_simulators() _handle_host_puppet_simulators() _handle_local_authoritative_simulators() - if NetworkTime.tick - _simulation_host_delay_ticks >= 0: + if NetworkTime.tick - _simulation_host_delay_ticks >= 0: # History server only records owned simulator state properties. _history_server._record_simulator(NetworkTime.tick - _simulation_host_delay_ticks) _synchronization_server._synchronize_simulator(NetworkTime.tick - _simulation_host_delay_ticks) - - _history_server._restore_simulator(NetworkTime.tick) ## 1- host simulator: ## - Advance the simulation with the inputs tick - 1. @@ -117,31 +111,43 @@ func _handle_local_authoritative_simulators() -> void: for simulator in _local_authoritative_simulators: - # Save input properties before messing them up. - simulator.listened_input_sender._save_properties() - - var latest_truth_tick := _history_server.get_latest_simulator_for( + var latest_truth_tick := _history_server.get_latest_simulator_for_snapshot( simulator._state_properties.get_subjects(), current_tick) var latest_truth_snapshot := _history_server._get_simulator_snapshot(latest_truth_tick) - if latest_truth_snapshot: - simulator._apply_snapshot_for_self(latest_truth_snapshot) # Save inputs before messing input properties. simulator.listened_input_sender._save_properties() + if latest_truth_tick < 0 or not latest_truth_snapshot: + _logger.warning("Couldnt find any truth from host, running simulation for \ + only current tick.") + + # Retrieve the input history and apply manually. + var input_history := _history_server._input_sender_history + for subject in simulator.listened_input_sender._input_properties.get_subjects(): + input_history.ensure_snapshot(current_tick - 1, subject, true).apply() + + simulator._run_simulation(NetworkTime.ticktime, current_tick) + _history_server._record_individual_simulator(simulator, current_tick) + + # Restore messed up properties. + simulator.listened_input_sender._restore_properties() + continue + + simulator._apply_snapshot_for_self(latest_truth_snapshot) # Retrieve the input history to apply it manually. var input_history := _history_server._input_sender_history - for i in range(latest_truth_tick, current_tick): + for i in range(latest_truth_tick + 1, current_tick + 1): for subject in simulator.listened_input_sender._input_properties.get_subjects(): - var snapshot : _ObjectSnapshot = input_history.ensure_snapshot(i, subject, false) + var snapshot : _ObjectSnapshot = input_history.ensure_snapshot(i - 1, subject, false) if snapshot: snapshot.apply() simulator._run_simulation(NetworkTime.ticktime, i) - _history_server._record_individual_simulator(simulator, i + 1) + _history_server._record_individual_simulator(simulator, i) # Restore messed up properties. simulator.listened_input_sender._restore_properties() diff --git a/examples/simulated-player/scripts/simulated_player.gd b/examples/simulated-player/scripts/simulated_player.gd index 762626a03..bde1e613d 100644 --- a/examples/simulated-player/scripts/simulated_player.gd +++ b/examples/simulated-player/scripts/simulated_player.gd @@ -1,6 +1,6 @@ extends CharacterBody3D -const SPEED = 5 +const SPEED = 150 const JUMP_VELOCITY = 6.0 # Get the gravity from the project settings to be synced with RigidBody nodes. @@ -23,11 +23,11 @@ func _simulated_tick(delta : float, _tick : int): var input_dir = Vector2(input.movement.x, input.movement.z) var direction = (transform.basis * Vector3(input_dir.x, 0, input_dir.y)).normalized() if direction: - velocity.x = direction.x * SPEED - velocity.z = direction.z * SPEED + velocity.x = direction.x * SPEED * delta + velocity.z = direction.z * SPEED * delta else: - velocity.x = move_toward(velocity.x, 0, SPEED) - velocity.z = move_toward(velocity.z, 0, SPEED) + velocity.x = move_toward(velocity.x, 0, SPEED * delta) + velocity.z = move_toward(velocity.z, 0, SPEED * delta) # print("input_jump is :%s" %input.jump) # print("input_movement is :%s" %input.movement) From d4ae81b27959785ccf30a849c9f5021b8a901554 Mon Sep 17 00:00:00 2001 From: tuysuztavsan Date: Sat, 18 Jul 2026 13:38:53 +0300 Subject: [PATCH 31/57] working simulators --- addons/netfox/servers/simulator-server.gd | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/addons/netfox/servers/simulator-server.gd b/addons/netfox/servers/simulator-server.gd index df1659e78..5bdb2f611 100644 --- a/addons/netfox/servers/simulator-server.gd +++ b/addons/netfox/servers/simulator-server.gd @@ -166,10 +166,10 @@ func _handle_host_puppet_simulators() -> void: var latest_input_tick := _history_server.get_latest_input_sender_for( simulator.listened_input_sender._input_properties.get_subjects(), - simulated_tick + simulated_tick - 1 ) - if latest_input_tick == simulated_tick: + if latest_input_tick == simulated_tick - 1: # We have inputs for this tick, run the simulation. var input_snapshot := _history_server._get_input_sender_snapshot(latest_input_tick) if not input_snapshot: From e4628a7a3cbb3caab5a1743b9202f75d8d62de6f Mon Sep 17 00:00:00 2001 From: TuysuzTavsan Date: Mon, 20 Jul 2026 15:27:52 +0300 Subject: [PATCH 32/57] proper de-register for input-sender --- addons/netfox/input-sender.gd | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/addons/netfox/input-sender.gd b/addons/netfox/input-sender.gd index a265be8f8..52f0c0c12 100644 --- a/addons/netfox/input-sender.gd +++ b/addons/netfox/input-sender.gd @@ -108,6 +108,11 @@ func _exit_tree(): if Engine.is_editor_hint(): return + for node in _input_properties.get_subjects(): + NetworkSynchronizationServer.deregister(node) + NetworkIdentityServer.deregister_node(node) + NetworkHistoryServer.deregister(node) + InputSenderServer._deregister_input_sender(self) ## Process settings. From 57ee19f58ca09fd8b33e85134541397bf0411ace Mon Sep 17 00:00:00 2001 From: TuysuzTavsan Date: Mon, 20 Jul 2026 18:48:10 +0300 Subject: [PATCH 33/57] added is_fresh --- addons/netfox/servers/simulator-server.gd | 98 +++++++++++++------ addons/netfox/simulator.gd | 4 +- .../scripts/simulated_player.gd | 3 +- 3 files changed, 72 insertions(+), 33 deletions(-) diff --git a/addons/netfox/servers/simulator-server.gd b/addons/netfox/servers/simulator-server.gd index 5bdb2f611..722c8b74d 100644 --- a/addons/netfox/servers/simulator-server.gd +++ b/addons/netfox/servers/simulator-server.gd @@ -55,6 +55,9 @@ var _simulation_history_size : int = ProjectSettings.get_setting("netfox/simulat # Host side buffering/delay tick count for simulation. var _simulation_host_delay_ticks : int = ProjectSettings.get_setting("netfox/simulator/host_delay_ticks", 8) +# Node to array of ticks +var _simulated_ticks := {} + # Grouped simulators depending on their authority modes. # Better readability on code / we only check authority on register. var _host_simulators : Array[Simulator] = [] @@ -62,12 +65,41 @@ var _local_authoritative_simulators : Array[Simulator] = [] var _host_puppet_simulators : Array[Simulator] = [] var _client_puppet_simulators : Array[Simulator] = [] +func _init(p_history_server: _NetworkHistoryServer = null, p_synchronization_server: _NetworkSynchronizationServer = null): + _history_server = p_history_server + _synchronization_server = p_synchronization_server + func _ready(): # Ensure dependencies if not _history_server: _history_server = NetworkHistoryServer if not _synchronization_server: _synchronization_server = NetworkSynchronizationServer -func _after_tick(_tick : int) -> void: +# Register a simulator node. +# Will check for authority over inputs and categorize by it. +# Its Simulator's responsibility to only register if input-sender is configured. +func register_simulator(simulator : Simulator) -> void: + if simulator.is_multiplayer_authority(): + if simulator.listened_input_sender.has_authority_over_input_nodes(): + _host_simulators.push_back(simulator) + else: + _host_puppet_simulators.push_back(simulator) + else: + if simulator.listened_input_sender.has_authority_over_input_nodes(): + _local_authoritative_simulators.push_back(simulator) + else: + _client_puppet_simulators.push_back(simulator) + + +# Deregister a simulator node. +func deregister_simulator(simulator : Simulator) -> void: + _host_simulators.erase(simulator) + _host_puppet_simulators.erase(simulator) + _local_authoritative_simulators.erase(simulator) + _client_puppet_simulators.erase(simulator) + + _simulated_ticks.erase(simulator) + +func _after_tick(tick : int) -> void: _handle_host_simulators() _handle_host_puppet_simulators() _handle_local_authoritative_simulators() @@ -76,6 +108,10 @@ func _after_tick(_tick : int) -> void: # History server only records owned simulator state properties. _history_server._record_simulator(NetworkTime.tick - _simulation_host_delay_ticks) _synchronization_server._synchronize_simulator(NetworkTime.tick - _simulation_host_delay_ticks) + + var trim_tick := tick - _simulation_history_size + if trim_tick >= 0: + _trim_ticks_simulated(trim_tick) ## 1- host simulator: ## - Advance the simulation with the inputs tick - 1. @@ -95,8 +131,10 @@ func _handle_host_simulators() -> void: for subject in simulator.listened_input_sender._input_properties.get_subjects(): input_history.ensure_snapshot(current_tick - 1, subject, true).apply() - simulator._run_simulation(NetworkTime.ticktime, current_tick) + var is_fresh := _is_tick_fresh_for(simulator, current_tick) + simulator._run_simulation(NetworkTime.ticktime, current_tick, is_fresh) _history_server._record_individual_simulator(simulator, current_tick) + _set_tick_simulated_for(simulator, current_tick) # Restore messed up properties. simulator.listened_input_sender._restore_properties() @@ -129,8 +167,10 @@ func _handle_local_authoritative_simulators() -> void: for subject in simulator.listened_input_sender._input_properties.get_subjects(): input_history.ensure_snapshot(current_tick - 1, subject, true).apply() - simulator._run_simulation(NetworkTime.ticktime, current_tick) + var is_fresh := _is_tick_fresh_for(simulator, current_tick) + simulator._run_simulation(NetworkTime.ticktime, current_tick, is_fresh) _history_server._record_individual_simulator(simulator, current_tick) + _set_tick_simulated_for(simulator, current_tick) # Restore messed up properties. simulator.listened_input_sender._restore_properties() @@ -146,8 +186,10 @@ func _handle_local_authoritative_simulators() -> void: if snapshot: snapshot.apply() - simulator._run_simulation(NetworkTime.ticktime, i) + var is_fresh := _is_tick_fresh_for(simulator, i) + simulator._run_simulation(NetworkTime.ticktime, i, is_fresh) _history_server._record_individual_simulator(simulator, i) + _set_tick_simulated_for(simulator, i) # Restore messed up properties. simulator.listened_input_sender._restore_properties() @@ -169,6 +211,8 @@ func _handle_host_puppet_simulators() -> void: simulated_tick - 1 ) + var is_fresh := _is_tick_fresh_for(simulator, simulated_tick) + if latest_input_tick == simulated_tick - 1: # We have inputs for this tick, run the simulation. var input_snapshot := _history_server._get_input_sender_snapshot(latest_input_tick) @@ -177,37 +221,33 @@ func _handle_host_puppet_simulators() -> void: continue _logger.trace("Running simulation for %s", [simulator]) + + simulator.listened_input_sender._apply_snapshot_for_self(input_snapshot) - simulator._run_simulation(NetworkTime.ticktime, simulated_tick) + simulator._run_simulation(NetworkTime.ticktime, simulated_tick, is_fresh) + else: # We need to predict this frame. _logger.warning("No buffered input found, predicting inputs.") simulator.listened_input_sender.predict_inputs() - simulator._run_simulation(NetworkTime.ticktime, simulated_tick) + simulator._run_simulation(NetworkTime.ticktime, simulated_tick, is_fresh) + + _set_tick_simulated_for(simulator, simulated_tick) -# Register a simulator node. -# Will check for authority over inputs and categorize by it. -# Its Simulator's responsibility to only register if input-sender is configured. -func register_simulator(simulator : Simulator) -> void: - if simulator.is_multiplayer_authority(): - if simulator.listened_input_sender.has_authority_over_input_nodes(): - _host_simulators.push_back(simulator) - else: - _host_puppet_simulators.push_back(simulator) - else: - if simulator.listened_input_sender.has_authority_over_input_nodes(): - _local_authoritative_simulators.push_back(simulator) - else: - _client_puppet_simulators.push_back(simulator) +func _is_tick_fresh_for(node: Node, tick: int) -> bool: + if not _simulated_ticks.has(node): + return true + var ticks := _simulated_ticks.get(node) as Array[int] + return not ticks.has(tick) -# Deregister a simulator node. -func deregister_simulator(simulator : Simulator) -> void: - _host_simulators.erase(simulator) - _host_puppet_simulators.erase(simulator) - _local_authoritative_simulators.erase(simulator) - _client_puppet_simulators.erase(simulator) +func _set_tick_simulated_for(node: Node, tick: int) -> void: + if not _simulated_ticks.has(node): + _simulated_ticks[node] = [tick] as Array[int] + else: + _simulated_ticks[node].append(tick) -func _init(p_history_server: _NetworkHistoryServer = null, p_synchronization_server: _NetworkSynchronizationServer = null): - _history_server = p_history_server - _synchronization_server = p_synchronization_server +func _trim_ticks_simulated(beginning: int) -> void: + for object in _simulated_ticks: + _simulated_ticks[object] = _simulated_ticks[object]\ + .filter(func(tick): return tick >= beginning) diff --git a/addons/netfox/simulator.gd b/addons/netfox/simulator.gd index 9a21598de..7e2f31723 100644 --- a/addons/netfox/simulator.gd +++ b/addons/netfox/simulator.gd @@ -231,10 +231,10 @@ func _apply_snapshot_for_self(snapshot : _Snapshot) -> void: # Helper function to run simulation with given parameters. # This function is used by SimulatorServer internally. -func _run_simulation(delta : float, tick : int) -> void: +func _run_simulation(delta : float, tick : int, is_fresh : bool) -> void: for node in _sim_nodes: if node: - node.call("_simulated_tick", delta, tick) + node.call("_simulated_tick", delta, tick, is_fresh) # Find managed nodes recursively from given root, ignoring branches managed by # a different [Simulator]. diff --git a/examples/simulated-player/scripts/simulated_player.gd b/examples/simulated-player/scripts/simulated_player.gd index bde1e613d..f267c5a65 100644 --- a/examples/simulated-player/scripts/simulated_player.gd +++ b/examples/simulated-player/scripts/simulated_player.gd @@ -8,8 +8,7 @@ var gravity = ProjectSettings.get_setting("physics/3d/default_gravity") @onready var input = $Input -func _simulated_tick(delta : float, _tick : int): -# print("Running simulated tick.") +func _simulated_tick(delta : float, _tick : int, _is_fresh : bool): # Add the gravity. if not is_on_floor(): velocity.y -= gravity * delta From 0b2ea1d43886e5f0317590dcedec16a7856c37c8 Mon Sep 17 00:00:00 2001 From: TuysuzTavsan Date: Fri, 31 Jul 2026 19:20:03 +0300 Subject: [PATCH 34/57] change simulator restore order in loop --- addons/netfox/network-time.gd | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/addons/netfox/network-time.gd b/addons/netfox/network-time.gd index d1ae6b81b..e46d93722 100644 --- a/addons/netfox/network-time.gd +++ b/addons/netfox/network-time.gd @@ -612,12 +612,15 @@ func _after_tick_loop() -> void: # Run rollback loop NetworkRollback._rollback() + # Restore state for Simulator before emitting after_tick_loop so that + # listeners can run logic after state is set. + NetworkHistoryServer._restore_simulator(tick) + # Emit signal after_tick_loop.emit() # Restore state for StateSynchronizer NetworkHistoryServer._restore_synchronizer_state(tick) - NetworkHistoryServer._restore_simulator(tick) InterpolationServer._record_next_state() func _process(delta: float) -> void: From 7cc249ab9d6da5b4c47463eebe571294d1d01b97 Mon Sep 17 00:00:00 2001 From: tuysuztavsan Date: Sun, 9 Aug 2026 21:24:26 +0300 Subject: [PATCH 35/57] registering projectiles --- addons/netfox/servers/simulator-server.gd | 78 +++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/addons/netfox/servers/simulator-server.gd b/addons/netfox/servers/simulator-server.gd index 722c8b74d..2125ff331 100644 --- a/addons/netfox/servers/simulator-server.gd +++ b/addons/netfox/servers/simulator-server.gd @@ -58,6 +58,14 @@ var _simulation_host_delay_ticks : int = ProjectSettings.get_setting("netfox/sim # Node to array of ticks var _simulated_ticks := {} +# Projectiles that are alive. +var _living_projectiles : Array[Node] = [] + +# Fresh registered projectiles. +# Mapped by tick -> array of projectiles (type of Node) that are estimated to be fired on mapped tick. +# See register_projectile method. +var _fresh_registered_projectiles_by_tick : Dictionary[int, Array] = {} + # Grouped simulators depending on their authority modes. # Better readability on code / we only check authority on register. var _host_simulators : Array[Simulator] = [] @@ -99,6 +107,58 @@ func deregister_simulator(simulator : Simulator) -> void: _simulated_ticks.erase(simulator) +## Register a projectile to be stepped by simulation. +## This method should only be called on host. +func register_projectile(projectile : Node, firing_peer_id : int, fired_tick : int) -> void: + + var has_step_method := projectile.has_method("step") + var has_is_alive_method := projectile.has_method("is_alive") + + if not has_step_method or not has_is_alive_method: + _logger.error("Error registering projectile %s : Projectiles must implement step and is_alive methods." + % projectile.name) + return + + var enet_peer := multiplayer.multiplayer_peer as ENetMultiplayerPeer + if not enet_peer: + _logger.error("Error registering projectile %s : Multiplayer peer is either null or not type of ENet.\n + Only ENetMultiplayerPeer is supported for now." % projectile.name) + return + + var firing_peer := enet_peer.get_peer(firing_peer_id) + + if not firing_peer: + _logger.error("Error registering projectile %s: Firing peer #%s is not found on active peers." + %[projectile.name, firing_peer_id]) + return + + var rtt_ms := firing_peer.get_statistic(ENetPacketPeer.PEER_ROUND_TRIP_TIME) + var half_rtt_sec := (rtt_ms * 0.5) / 1000.0 + var half_rtt_ticks := half_rtt_sec / NetworkTime.ticktime + + var estimated_firing_tick := fired_tick - roundi(half_rtt_ticks) + + if estimated_firing_tick < 0: + _logger.error("Error registering projectile %s: Calculated estimated_firing_tick as negative. Not registering.\n + half rtt: %s, fired tick: %s, estimated firing tick: %s" + %[projectile.name, half_rtt_sec, fired_tick, estimated_firing_tick]) + return + + if estimated_firing_tick < NetworkTime.tick - _simulation_history_size: + _logger.error("Error registering projectile %s: Calculated estimated_firing_tick is older than history.\n + Returning without registering. half rtt: %s, fired tick: %s, estimated firing tick: %s" + %[projectile.name, half_rtt_sec, fired_tick, estimated_firing_tick]) + return + + _logger.debug("Registered projectile %s with stats:\n firing_peer: %s, fired tick: %s, half rtt: %s, + estimated firing tick: %s" %[projectile.name, firing_peer_id, fired_tick, half_rtt_sec, estimated_firing_tick]) + + var existing_projectile_arr := _fresh_registered_projectiles_by_tick.get(estimated_firing_tick) as Array + if existing_projectile_arr: + existing_projectile_arr.push_back(projectile) + else: + _fresh_registered_projectiles_by_tick[estimated_firing_tick] = [projectile] + func _after_tick(tick : int) -> void: _handle_host_simulators() _handle_host_puppet_simulators() @@ -235,6 +295,24 @@ func _handle_host_puppet_simulators() -> void: _set_tick_simulated_for(simulator, simulated_tick) +# Steps every already-caught-up living projectile by a single tick. +# No need to restore world state for living projectiles. +func _step_living_projectiles(current_tick: int) -> void: + + # Iterate in reverse because we are also removing. + var i := _living_projectiles.size() - 1 + while i >= 0: + var projectile : Node = _living_projectiles[i] + + if not is_instance_valid(projectile): + _living_projectiles.remove_at(i) + else: + projectile.step(NetworkTime.ticktime, current_tick) + if not projectile.is_alive(): + _living_projectiles.remove_at(i) + + i -= 1 + func _is_tick_fresh_for(node: Node, tick: int) -> bool: if not _simulated_ticks.has(node): return true From 563161274972f76f25f37c60d828e4137ab97ad9 Mon Sep 17 00:00:00 2001 From: TuysuzTavsan Date: Mon, 10 Aug 2026 02:01:04 +0300 Subject: [PATCH 36/57] initial work is done for projectiles --- addons/netfox/servers/simulator-server.gd | 157 +++++++++++++++++++--- 1 file changed, 138 insertions(+), 19 deletions(-) diff --git a/addons/netfox/servers/simulator-server.gd b/addons/netfox/servers/simulator-server.gd index 2125ff331..668b8b396 100644 --- a/addons/netfox/servers/simulator-server.gd +++ b/addons/netfox/servers/simulator-server.gd @@ -115,43 +115,57 @@ func register_projectile(projectile : Node, firing_peer_id : int, fired_tick : i var has_is_alive_method := projectile.has_method("is_alive") if not has_step_method or not has_is_alive_method: - _logger.error("Error registering projectile %s : Projectiles must implement step and is_alive methods." - % projectile.name) + _logger.error( + "Error registering projectile %s : Projectiles must implement step and is_alive methods.", + [projectile.name] + ) return var enet_peer := multiplayer.multiplayer_peer as ENetMultiplayerPeer if not enet_peer: - _logger.error("Error registering projectile %s : Multiplayer peer is either null or not type of ENet.\n - Only ENetMultiplayerPeer is supported for now." % projectile.name) + _logger.error( + "Error registering projectile %s : Multiplayer peer is either null or not type of ENet.\n" + + "Only ENetMultiplayerPeer is supported for now.", [projectile.name] + ) return var firing_peer := enet_peer.get_peer(firing_peer_id) if not firing_peer: - _logger.error("Error registering projectile %s: Firing peer #%s is not found on active peers." - %[projectile.name, firing_peer_id]) + _logger.error( + "Error registering projectile %s: Firing peer #%s is not found on active peers.", + [projectile.name, firing_peer_id] + ) return var rtt_ms := firing_peer.get_statistic(ENetPacketPeer.PEER_ROUND_TRIP_TIME) var half_rtt_sec := (rtt_ms * 0.5) / 1000.0 var half_rtt_ticks := half_rtt_sec / NetworkTime.ticktime - - var estimated_firing_tick := fired_tick - roundi(half_rtt_ticks) + + # Dont forget that we synchronize current_tick - _simulation_host_delay_ticks + var estimated_firing_tick := fired_tick - roundi(half_rtt_ticks) - _simulation_host_delay_ticks if estimated_firing_tick < 0: - _logger.error("Error registering projectile %s: Calculated estimated_firing_tick as negative. Not registering.\n - half rtt: %s, fired tick: %s, estimated firing tick: %s" - %[projectile.name, half_rtt_sec, fired_tick, estimated_firing_tick]) + _logger.error( + "Error registering projectile %s: Calculated estimated_firing_tick as negative. Not registering.\n" + + "half rtt: %s, fired tick: %s, estimated firing tick: %s", + [projectile.name, half_rtt_sec, fired_tick, estimated_firing_tick] + ) return if estimated_firing_tick < NetworkTime.tick - _simulation_history_size: - _logger.error("Error registering projectile %s: Calculated estimated_firing_tick is older than history.\n - Returning without registering. half rtt: %s, fired tick: %s, estimated firing tick: %s" - %[projectile.name, half_rtt_sec, fired_tick, estimated_firing_tick]) + _logger.error( + "Error registering projectile %s: Calculated estimated_firing_tick is older than history.\n" + + "Returning without registering. half rtt: %s, fired tick: %s, estimated firing tick: %s", + [projectile.name, half_rtt_sec, fired_tick, estimated_firing_tick] + ) return - _logger.debug("Registered projectile %s with stats:\n firing_peer: %s, fired tick: %s, half rtt: %s, - estimated firing tick: %s" %[projectile.name, firing_peer_id, fired_tick, half_rtt_sec, estimated_firing_tick]) + _logger.debug( + "Registered projectile %s with stats:\n" + + "firing_peer: %s, fired tick: %s, half rtt: %s, estimated firing tick: %s", + [projectile.name, firing_peer_id, fired_tick, half_rtt_sec, estimated_firing_tick] + ) var existing_projectile_arr := _fresh_registered_projectiles_by_tick.get(estimated_firing_tick) as Array if existing_projectile_arr: @@ -160,10 +174,15 @@ func register_projectile(projectile : Node, firing_peer_id : int, fired_tick : i _fresh_registered_projectiles_by_tick[estimated_firing_tick] = [projectile] func _after_tick(tick : int) -> void: + + _catch_up_fresh_projectiles(tick) + _handle_host_simulators() _handle_host_puppet_simulators() _handle_local_authoritative_simulators() + _step_living_projectiles(tick) + if NetworkTime.tick - _simulation_host_delay_ticks >= 0: # History server only records owned simulator state properties. _history_server._record_simulator(NetworkTime.tick - _simulation_host_delay_ticks) @@ -219,8 +238,10 @@ func _handle_local_authoritative_simulators() -> void: simulator.listened_input_sender._save_properties() if latest_truth_tick < 0 or not latest_truth_snapshot: - _logger.warning("Couldnt find any truth from host, running simulation for \ - only current tick.") + _logger.warning( + "Couldnt find any truth from host.\n" + + "Running simulation for only current tick." + ) # Retrieve the input history and apply manually. var input_history := _history_server._input_sender_history @@ -298,6 +319,7 @@ func _handle_host_puppet_simulators() -> void: # Steps every already-caught-up living projectile by a single tick. # No need to restore world state for living projectiles. func _step_living_projectiles(current_tick: int) -> void: + var simulated_tick := current_tick - _simulation_host_delay_ticks # Iterate in reverse because we are also removing. var i := _living_projectiles.size() - 1 @@ -305,14 +327,107 @@ func _step_living_projectiles(current_tick: int) -> void: var projectile : Node = _living_projectiles[i] if not is_instance_valid(projectile): + _logger.warning("While stepping living projectiles: projectile at index %s no longer valid, dropping", [i]) _living_projectiles.remove_at(i) else: - projectile.step(NetworkTime.ticktime, current_tick) + _logger.trace("Stepping living projectile: %s at tick #%s", [projectile.name, simulated_tick]) + projectile.step(NetworkTime.ticktime, simulated_tick) + if not projectile.is_alive(): + _logger.trace("After stepping living projectile: %s projectile died at tick %s", + [projectile.name, simulated_tick] + ) + _living_projectiles.remove_at(i) i -= 1 +# Steps the freshly registered projectiles up to current_tick - _simulation_host_delay_ticks (not included). +func _catch_up_fresh_projectiles(current_tick: int) -> void: + if _fresh_registered_projectiles_by_tick.is_empty(): + return + + var simulated_tick := current_tick - _simulation_host_delay_ticks + var oldest_tick : int = _fresh_registered_projectiles_by_tick.keys().min() + + # Ensure oldest tick is not older than our history window. + oldest_tick = maxi(oldest_tick, current_tick - _simulation_history_size) + + _logger.trace( + "Catching up fresh projectiles: oldest_tick=%s, simulated_tick=%s, pending_ticks=%s", + [oldest_tick, simulated_tick, _fresh_registered_projectiles_by_tick.keys()] + ) + + # Temp array to hold projectiles. + var fresh_projectiles : Array[Node] = [] + + for tick in range(oldest_tick, simulated_tick): + _history_server._restore_simulator(tick) + + # Get projectiles registered at this tick and add it our fresh_projectiles array. + var registered_projectiles_arr_at_tick := _fresh_registered_projectiles_by_tick.get(tick) as Array + if registered_projectiles_arr_at_tick: + _logger.trace( + "Tick %s: %s registered projectiles are joining the projectile catch up phase: %s", + [tick, registered_projectiles_arr_at_tick.size(), + registered_projectiles_arr_at_tick.map( + func(p): return _get_projectile_debug_name(p)) + ] + ) + + fresh_projectiles.append_array(registered_projectiles_arr_at_tick) + _fresh_registered_projectiles_by_tick.erase(tick) + + # While iterating over ticks, step every fresh projectile we have. + var i := fresh_projectiles.size() - 1 + while i >= 0: + + var projectile : Node = fresh_projectiles[i] + + if is_instance_valid(projectile): + projectile.step(NetworkTime.ticktime, tick) + _logger.trace("Projectile %s running catch up step at tick: #%s", [projectile.name, tick]) + # Erase from our list if projectile is not alive anymore. + if not projectile.is_alive(): + _logger.trace("Projectile %s is not alive, erasing at tick: #%s", [projectile.name, tick]) + fresh_projectiles.remove_at(i) + else: + _logger.trace("During projectile catch up, projectile at index %s no longer valid, dropping", [i]) + fresh_projectiles.remove_at(i) + + i -= 1 + + # Anything registered for tick exact simulated_tick had no history to replay. + # They join living unstepped and gets its first step together with _step_living_projectiles. + var registered_at_simulated_tick := _fresh_registered_projectiles_by_tick.get(simulated_tick) as Array + if registered_at_simulated_tick: + _logger.trace( + "At Simulated tick #%s, %s projectile(s) joining to living projectiles without catch up.\n" + + "They will be stepped together with living projectiles on this tick. Projectile names %s", + [simulated_tick, registered_at_simulated_tick.size(), + registered_at_simulated_tick.map( + func(p): return _get_projectile_debug_name(p)) + ] + ) + + fresh_projectiles.append_array(registered_at_simulated_tick) + _fresh_registered_projectiles_by_tick.erase(simulated_tick) + + if not fresh_projectiles.is_empty(): + _logger.trace( + "Projectile catch up is over: %s projectile(s) promoted to living: %s", + [fresh_projectiles.size(), fresh_projectiles.map( + func(p): return _get_projectile_debug_name(p)) + ] + ) + + # Append our living projectiles list, they are part of the current simulation at this point, + # and they will be handled with function _step_living_projectiles. + _living_projectiles.append_array(fresh_projectiles) + + # Since we changed state for simulators by calling _restore_simulator, now restore them to latest. + _history_server._restore_simulator(current_tick) + func _is_tick_fresh_for(node: Node, tick: int) -> bool: if not _simulated_ticks.has(node): return true @@ -329,3 +444,7 @@ func _trim_ticks_simulated(beginning: int) -> void: for object in _simulated_ticks: _simulated_ticks[object] = _simulated_ticks[object]\ .filter(func(tick): return tick >= beginning) + +# Helper to get projectile debug name. +func _get_projectile_debug_name(p) -> String: + return p.name if is_instance_valid(p) else "" From a740a521fa9c0785ffc8678b44bcbfaeada822b1 Mon Sep 17 00:00:00 2001 From: TuysuzTavsan Date: Tue, 11 Aug 2026 00:33:09 +0300 Subject: [PATCH 37/57] fix runtime throw because of null casting --- addons/netfox/servers/simulator-server.gd | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/addons/netfox/servers/simulator-server.gd b/addons/netfox/servers/simulator-server.gd index 668b8b396..817dd1666 100644 --- a/addons/netfox/servers/simulator-server.gd +++ b/addons/netfox/servers/simulator-server.gd @@ -167,11 +167,8 @@ func register_projectile(projectile : Node, firing_peer_id : int, fired_tick : i [projectile.name, firing_peer_id, fired_tick, half_rtt_sec, estimated_firing_tick] ) - var existing_projectile_arr := _fresh_registered_projectiles_by_tick.get(estimated_firing_tick) as Array - if existing_projectile_arr: - existing_projectile_arr.push_back(projectile) - else: - _fresh_registered_projectiles_by_tick[estimated_firing_tick] = [projectile] + var existing_projectile_arr := _fresh_registered_projectiles_by_tick.get_or_add(estimated_firing_tick, []) as Array + existing_projectile_arr.push_back(projectile) func _after_tick(tick : int) -> void: @@ -365,8 +362,8 @@ func _catch_up_fresh_projectiles(current_tick: int) -> void: _history_server._restore_simulator(tick) # Get projectiles registered at this tick and add it our fresh_projectiles array. - var registered_projectiles_arr_at_tick := _fresh_registered_projectiles_by_tick.get(tick) as Array - if registered_projectiles_arr_at_tick: + var registered_projectiles_arr_at_tick := _fresh_registered_projectiles_by_tick.get(tick, []) as Array + if not registered_projectiles_arr_at_tick.is_empty(): _logger.trace( "Tick %s: %s registered projectiles are joining the projectile catch up phase: %s", [tick, registered_projectiles_arr_at_tick.size(), @@ -399,8 +396,8 @@ func _catch_up_fresh_projectiles(current_tick: int) -> void: # Anything registered for tick exact simulated_tick had no history to replay. # They join living unstepped and gets its first step together with _step_living_projectiles. - var registered_at_simulated_tick := _fresh_registered_projectiles_by_tick.get(simulated_tick) as Array - if registered_at_simulated_tick: + var registered_at_simulated_tick := _fresh_registered_projectiles_by_tick.get(simulated_tick, []) as Array + if not registered_at_simulated_tick.is_empty(): _logger.trace( "At Simulated tick #%s, %s projectile(s) joining to living projectiles without catch up.\n" + "They will be stepped together with living projectiles on this tick. Projectile names %s", From d1fc4ec07a83195d8ac192405d3c66f03af7be04 Mon Sep 17 00:00:00 2001 From: TuysuzTavsan Date: Thu, 13 Aug 2026 13:23:10 +0300 Subject: [PATCH 38/57] wip for recording simulated collisions. --- addons/netfox/servers/simulator-server.gd | 8 + addons/netfox/simulated-collision-recorder.gd | 162 ++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 addons/netfox/simulated-collision-recorder.gd diff --git a/addons/netfox/servers/simulator-server.gd b/addons/netfox/servers/simulator-server.gd index 817dd1666..899c54130 100644 --- a/addons/netfox/servers/simulator-server.gd +++ b/addons/netfox/servers/simulator-server.gd @@ -61,6 +61,9 @@ var _simulated_ticks := {} # Projectiles that are alive. var _living_projectiles : Array[Node] = [] +# Helper to record and restore collision-shapes / areas that are in specific group. +var _simulated_collision_recorder : _SimulatedCollisionRecorder = _SimulatedCollisionRecorder.new() + # Fresh registered projectiles. # Mapped by tick -> array of projectiles (type of Node) that are estimated to be fired on mapped tick. # See register_projectile method. @@ -81,6 +84,8 @@ func _ready(): # Ensure dependencies if not _history_server: _history_server = NetworkHistoryServer if not _synchronization_server: _synchronization_server = NetworkSynchronizationServer + + _simulated_collision_recorder.setup(get_tree()) # Register a simulator node. # Will check for authority over inputs and categorize by it. @@ -184,10 +189,13 @@ func _after_tick(tick : int) -> void: # History server only records owned simulator state properties. _history_server._record_simulator(NetworkTime.tick - _simulation_host_delay_ticks) _synchronization_server._synchronize_simulator(NetworkTime.tick - _simulation_host_delay_ticks) + + _simulated_collision_recorder.record_tick(NetworkTime.tick - _simulation_host_delay_ticks) var trim_tick := tick - _simulation_history_size if trim_tick >= 0: _trim_ticks_simulated(trim_tick) + _simulated_collision_recorder.trim_before(trim_tick) ## 1- host simulator: ## - Advance the simulation with the inputs tick - 1. diff --git a/addons/netfox/simulated-collision-recorder.gd b/addons/netfox/simulated-collision-recorder.gd new file mode 100644 index 000000000..e28c79033 --- /dev/null +++ b/addons/netfox/simulated-collision-recorder.gd @@ -0,0 +1,162 @@ +extends RefCounted +class_name _SimulatedCollisionRecorder + +## Helper class for recording simulation aware areas and collision shapes. +## +## Used by [_SimulatorServer] internally. + +const SIMULATION_AWARE_GROUP := &"SimulationAware" + +var _logger := NetfoxLogger._for_netfox("SimulationAwareServer") +var _history_size : int = ProjectSettings.get_setting("netfox/simulator/history_limit", 64) + +# tick -> Dictionary[Node, _Snapshot] +var _snapshots_by_tick : Dictionary[int, Dictionary] = {} +var _tracked_nodes : Array[Node] = [] + +# Inner helper struct to hold data together. +class _CollisionSnapshot: + var transform : Transform3D + var disabled : bool + var monitoring : bool + var monitorable : bool + var collision_layer : int + var is_area : bool + +## Setup needs to be called by owner node. +## Because refcounted classes doesnt have access to scene tree. +func setup(tree: SceneTree) -> void: + tree.node_added.connect(_on_node_added) + for node in tree.get_nodes_in_group(SIMULATION_AWARE_GROUP): + _track(node) + +func _on_node_added(node: Node) -> void: + if node.is_in_group(SIMULATION_AWARE_GROUP): + _track(node) + +func _track(node: Node) -> void: + if node in _tracked_nodes: + return + + if not (node is CollisionShape3D or node is Area3D): + _logger.warning( + "Node %s is in %s group but isn't a CollisionShape3D or Area3D, ignoring." + \ + "Dont add nodes that isnt CollisionShape or Area to this group.", + [node.name, SIMULATION_AWARE_GROUP] + ) + return + + _tracked_nodes.push_back(node) + node.tree_exiting.connect(_untrack.bind(node), CONNECT_ONE_SHOT) + +func _untrack(node: Node) -> void: + _tracked_nodes.erase(node) + # Old snapshot dicts aren't scrubbed here. + # Because we remove invalid nodes from our _tracked_nodes on record_tick() + +func record_tick(tick: int) -> void: + var snapshot_dict : Dictionary[Node, _CollisionSnapshot] = {} + + var i := _tracked_nodes.size() - 1 + while i >= 0: + var node := _tracked_nodes[i] + + if not is_instance_valid(node): + # Remove invalid nodes and continue. + _tracked_nodes.remove_at(i) + i -= 1 + continue + + var snap := _CollisionSnapshot.new() + snap.transform = node.global_transform + if node is CollisionShape3D: + snap.disabled = node.disabled + else: # Area3D + snap.is_area = true + snap.monitoring = node.monitoring + snap.monitorable = node.monitorable + snap.collision_layer = node.collision_layer + + # Save per node data. + snapshot_dict[node] = snap + i -= 1 + + # Save snapshot by tick. + _snapshots_by_tick[tick] = snapshot_dict + +func restore_tick(tick: int) -> void: + if not _snapshots_by_tick.has(tick): + _logger.warning( + "No snapshot recorded for tick %s (predates history / never recorded). Skipping restore.", + [tick] + ) + return + + var snapshot_dict := _snapshots_by_tick[tick] as Dictionary + + for node in _tracked_nodes: + if not is_instance_valid(node): + continue + + if snapshot_dict.has(node): + var snap : _Snapshot = snapshot_dict[node] + node.global_transform = snap.transform + if node is CollisionShape3D: + node.disabled = snap.disabled + elif snap.is_area: + node.monitoring = snap.monitoring + node.monitorable = snap.monitorable + node.collision_layer = snap.collision_layer + else: + # Not tracked yet at this historical tick -> didn't exist in the sim yet. + _exclude_from_collision(node) + +func _exclude_from_collision(node: Node) -> void: + if node is CollisionShape3D: + node.disabled = true + elif node is Area3D: + node.monitoring = false + node.monitorable = false + node.collision_layer = 0 + +func capture_live_state() -> Dictionary: + var live : Dictionary[Node, _CollisionSnapshot] = {} + var i := _tracked_nodes.size() - 1 + while i >= 0: + var node := _tracked_nodes[i] + if not is_instance_valid(node): + _tracked_nodes.remove_at(i) + i -= 1 + continue + + var snap := _CollisionSnapshot.new() + snap.transform = node.global_transform + if node is CollisionShape3D: + snap.disabled = node.disabled + else: + snap.is_area = true + snap.monitoring = node.monitoring + snap.monitorable = node.monitorable + snap.collision_layer = node.collision_layer + + live[node] = snap + i -= 1 + return live + +func restore_snapshot(snapshot_dict: Dictionary) -> void: + for node in snapshot_dict: + if not is_instance_valid(node): + continue + var snap : _CollisionSnapshot = snapshot_dict[node] + node.global_transform = snap.transform + if node is CollisionShape3D: + node.disabled = snap.disabled + elif snap.is_area: + node.monitoring = snap.monitoring + node.monitorable = snap.monitorable + node.collision_layer = snap.collision_layer + +func trim_before(beginning: int) -> void: + for tick in _snapshots_by_tick.keys(): + if tick < beginning: + _snapshots_by_tick.erase(tick) From 55d68566fd0b235f4b87d50883da51b192da4c45 Mon Sep 17 00:00:00 2001 From: tuysuztavsan Date: Thu, 13 Aug 2026 16:18:57 +0300 Subject: [PATCH 39/57] simulation aware collision node group update --- addons/netfox/servers/simulator-server.gd | 20 +- addons/netfox/simulated-collision-recorder.gd | 239 ++++++++++++------ 2 files changed, 179 insertions(+), 80 deletions(-) diff --git a/addons/netfox/servers/simulator-server.gd b/addons/netfox/servers/simulator-server.gd index 899c54130..055000db9 100644 --- a/addons/netfox/servers/simulator-server.gd +++ b/addons/netfox/servers/simulator-server.gd @@ -61,7 +61,7 @@ var _simulated_ticks := {} # Projectiles that are alive. var _living_projectiles : Array[Node] = [] -# Helper to record and restore collision-shapes / areas that are in specific group. +# Helper to record and restore simulation aware collision-shapes/areas. var _simulated_collision_recorder : _SimulatedCollisionRecorder = _SimulatedCollisionRecorder.new() # Fresh registered projectiles. @@ -85,7 +85,7 @@ func _ready(): if not _history_server: _history_server = NetworkHistoryServer if not _synchronization_server: _synchronization_server = NetworkSynchronizationServer - _simulated_collision_recorder.setup(get_tree()) + _simulated_collision_recorder.initialize(get_tree()) # Register a simulator node. # Will check for authority over inputs and categorize by it. @@ -192,10 +192,11 @@ func _after_tick(tick : int) -> void: _simulated_collision_recorder.record_tick(NetworkTime.tick - _simulation_host_delay_ticks) + # Trim old history that we dont need. var trim_tick := tick - _simulation_history_size if trim_tick >= 0: _trim_ticks_simulated(trim_tick) - _simulated_collision_recorder.trim_before(trim_tick) + _simulated_collision_recorder.trim_ticks(trim_tick) ## 1- host simulator: ## - Advance the simulation with the inputs tick - 1. @@ -366,8 +367,13 @@ func _catch_up_fresh_projectiles(current_tick: int) -> void: # Temp array to hold projectiles. var fresh_projectiles : Array[Node] = [] + # We need to capture current state of collision aware nodes. Because we will mess with their + # states during our operation. At the end of the operation we will restore to this state snapshot. + var current_collision_state_snapshot_dict := _simulated_collision_recorder.capture_current_state() + for tick in range(oldest_tick, simulated_tick): - _history_server._restore_simulator(tick) + # Restore simulation aware collision nodes. + _simulated_collision_recorder.restore_tick(tick) # Get projectiles registered at this tick and add it our fresh_projectiles array. var registered_projectiles_arr_at_tick := _fresh_registered_projectiles_by_tick.get(tick, []) as Array @@ -430,8 +436,10 @@ func _catch_up_fresh_projectiles(current_tick: int) -> void: # and they will be handled with function _step_living_projectiles. _living_projectiles.append_array(fresh_projectiles) - # Since we changed state for simulators by calling _restore_simulator, now restore them to latest. - _history_server._restore_simulator(current_tick) + # Since we changed states of simulation aware collision nodes by calling + # _simulated_collision_recorder.restore_tick, now we should restore them to current state which + # we recorded before doing this operation. + _simulated_collision_recorder.restore_snapshot(current_collision_state_snapshot_dict) func _is_tick_fresh_for(node: Node, tick: int) -> bool: if not _simulated_ticks.has(node): diff --git a/addons/netfox/simulated-collision-recorder.gd b/addons/netfox/simulated-collision-recorder.gd index e28c79033..9f185c291 100644 --- a/addons/netfox/simulated-collision-recorder.gd +++ b/addons/netfox/simulated-collision-recorder.gd @@ -2,32 +2,98 @@ extends RefCounted class_name _SimulatedCollisionRecorder ## Helper class for recording simulation aware areas and collision shapes. +## Supports CollisionShape3D/2D, Area3D/2D. ## ## Used by [_SimulatorServer] internally. -const SIMULATION_AWARE_GROUP := &"SimulationAware" +const SIMULATION_AWARE_GROUP := &"SimulationAwareCollision" -var _logger := NetfoxLogger._for_netfox("SimulationAwareServer") -var _history_size : int = ProjectSettings.get_setting("netfox/simulator/history_limit", 64) +var _logger := NetfoxLogger._for_netfox("SimulatedCollisionRecorder") -# tick -> Dictionary[Node, _Snapshot] +# tick -> Dictionary[Node, _CollisionSnapshot] var _snapshots_by_tick : Dictionary[int, Dictionary] = {} var _tracked_nodes : Array[Node] = [] -# Inner helper struct to hold data together. +## Inner base struct for recording collision snapshot per type. +## Specific types extends this base struct. +## Having this solves 2 problems: 1- no unncessary memory, 2- no unneccessary checks for type. +## Each derived struct only stores the fields relevant to its own node type. class _CollisionSnapshot: - var transform : Transform3D - var disabled : bool - var monitoring : bool - var monitorable : bool - var collision_layer : int - var is_area : bool - -## Setup needs to be called by owner node. + func restore_node(_node: Node) -> void: + push_error("_CollisionSnapshot.restore_node is abstract, must be overridden.") + +class _ShapeSnapshot3D extends _CollisionSnapshot: + var global_transform : Transform3D + var disabled : bool + + func _init(node: CollisionShape3D) -> void: + global_transform = node.global_transform + disabled = node.disabled + + func restore_node(node: Node) -> void: + node.global_transform = global_transform + node.disabled = disabled + +class _ShapeSnapshot2D extends _CollisionSnapshot: + var global_transform : Transform2D + var disabled : bool + + func _init(node: CollisionShape2D) -> void: + global_transform = node.global_transform + disabled = node.disabled + + func restore_node(node: Node) -> void: + node.global_transform = global_transform + node.disabled = disabled + +class _AreaSnapshot3D extends _CollisionSnapshot: + var global_transform : Transform3D + var monitoring : bool + var monitorable : bool + var collision_layer : int + + func _init(node: Area3D) -> void: + global_transform = node.global_transform + monitoring = node.monitoring + monitorable = node.monitorable + collision_layer = node.collision_layer + + func restore_node(node: Node) -> void: + node.global_transform = global_transform + node.monitoring = monitoring + node.monitorable = monitorable + node.collision_layer = collision_layer + +class _AreaSnapshot2D extends _CollisionSnapshot: + var global_transform : Transform2D + var monitoring : bool + var monitorable : bool + var collision_layer : int + + func _init(node: Area2D) -> void: + global_transform = node.global_transform + monitoring = node.monitoring + monitorable = node.monitorable + collision_layer = node.collision_layer + + func restore_node(node: Node) -> void: + node.global_transform = global_transform + node.monitoring = monitoring + node.monitorable = monitorable + node.collision_layer = collision_layer + +## Initialize needs to be called by owner node. ## Because refcounted classes doesnt have access to scene tree. -func setup(tree: SceneTree) -> void: +func initialize(tree: SceneTree) -> void: tree.node_added.connect(_on_node_added) - for node in tree.get_nodes_in_group(SIMULATION_AWARE_GROUP): + + var existing_nodes := tree.get_nodes_in_group(SIMULATION_AWARE_GROUP) + _logger.trace( + "Initializing collision recorder, found %s existing node(s) in group %s", + [existing_nodes.size(), SIMULATION_AWARE_GROUP] + ) + + for node in existing_nodes: _track(node) func _on_node_added(node: Node) -> void: @@ -37,126 +103,151 @@ func _on_node_added(node: Node) -> void: func _track(node: Node) -> void: if node in _tracked_nodes: return - - if not (node is CollisionShape3D or node is Area3D): + + if not _is_supported(node): _logger.warning( - "Node %s is in %s group but isn't a CollisionShape3D or Area3D, ignoring." + \ - "Dont add nodes that isnt CollisionShape or Area to this group.", + "Node %s is in %s group but isn't a supported type (CollisionShape3D/2D, Area3D/2D), ignoring." + \ + "Only supported types should be in this group.", [node.name, SIMULATION_AWARE_GROUP] ) return - + _tracked_nodes.push_back(node) node.tree_exiting.connect(_untrack.bind(node), CONNECT_ONE_SHOT) + _logger.trace("Tracking node %s, total tracked: %s", [node.name, _tracked_nodes.size()]) +# Called when node's tree_exiting is fired so we can untrack now. +# Old snapshot dicts aren't erased here, we remove invalid nodes while we iterate on record_tick(). func _untrack(node: Node) -> void: _tracked_nodes.erase(node) - # Old snapshot dicts aren't scrubbed here. - # Because we remove invalid nodes from our _tracked_nodes on record_tick() + _logger.trace("Untracked node %s (tree_exiting), total tracked: %s", [node.name, _tracked_nodes.size()]) +# Check if node is type of supported ones. +func _is_supported(node: Node) -> bool: + return node is CollisionShape3D \ + or node is CollisionShape2D \ + or node is Area3D \ + or node is Area2D + +# Takes the snapshot for a node with corresponding snapshot type and returns it. +func _take_snapshot_for_node(node: Node) -> _CollisionSnapshot: + if node is CollisionShape3D: + return _ShapeSnapshot3D.new(node) + elif node is CollisionShape2D: + return _ShapeSnapshot2D.new(node) + elif node is Area3D: + return _AreaSnapshot3D.new(node) + elif node is Area2D: + return _AreaSnapshot2D.new(node) + return null + +## Record the given tick for simulation aware collision nodes. func record_tick(tick: int) -> void: var snapshot_dict : Dictionary[Node, _CollisionSnapshot] = {} - + var i := _tracked_nodes.size() - 1 while i >= 0: var node := _tracked_nodes[i] + # While we are iterating tracked_nodes, remove if anyone is invalid. + if not is_instance_valid(node): - # Remove invalid nodes and continue. + _logger.trace("while recording tick(%s): dropping invalid node at index %s", [tick, i]) _tracked_nodes.remove_at(i) i -= 1 continue - - var snap := _CollisionSnapshot.new() - snap.transform = node.global_transform - if node is CollisionShape3D: - snap.disabled = node.disabled - else: # Area3D - snap.is_area = true - snap.monitoring = node.monitoring - snap.monitorable = node.monitorable - snap.collision_layer = node.collision_layer - # Save per node data. - snapshot_dict[node] = snap + snapshot_dict[node] = _take_snapshot_for_node(node) i -= 1 - # Save snapshot by tick. + _logger.trace("recorded tick(%s): recorded %s node(s)", [tick, snapshot_dict.size()]) _snapshots_by_tick[tick] = snapshot_dict +## Restore the collision aware nodes to given tick. +## If any of the collision aware nodes was not alive for given tick, +## This function will disable them. func restore_tick(tick: int) -> void: if not _snapshots_by_tick.has(tick): _logger.warning( - "No snapshot recorded for tick %s (predates history / never recorded). Skipping restore.", + "No snapshot recorded for tick %s (maybe older history or never recorded). Skipping restore.", [tick] ) return - + var snapshot_dict := _snapshots_by_tick[tick] as Dictionary - + var restored_count := 0 + var excluded_count := 0 + for node in _tracked_nodes: if not is_instance_valid(node): continue - + if snapshot_dict.has(node): - var snap : _Snapshot = snapshot_dict[node] - node.global_transform = snap.transform - if node is CollisionShape3D: - node.disabled = snap.disabled - elif snap.is_area: - node.monitoring = snap.monitoring - node.monitorable = snap.monitorable - node.collision_layer = snap.collision_layer + var snap : _CollisionSnapshot = snapshot_dict[node] + snap.restore_node(node) + restored_count += 1 else: # Not tracked yet at this historical tick -> didn't exist in the sim yet. _exclude_from_collision(node) - + excluded_count += 1 + + _logger.trace( + "restored tick(%s): restored %s node(s), excluded %s node(s) not yet alive at this tick", + [tick, restored_count, excluded_count] + ) + +## Excludes a node from all collision queries. Not snapshot-driven - a node with no +## recorded snapshot at this tick has no data to restore from, only a fact to enforce +## ("this didn't exist yet"), so this stays a plain type check against the live node. func _exclude_from_collision(node: Node) -> void: - if node is CollisionShape3D: + _logger.trace("Excluding node %s from collision (not alive at this historical tick)", [node.name]) + if node is CollisionShape3D or node is CollisionShape2D: node.disabled = true - elif node is Area3D: + elif node is Area3D or node is Area2D: node.monitoring = false node.monitorable = false node.collision_layer = 0 -func capture_live_state() -> Dictionary: +## Capture the current state of simulation aware collision nodes. +## This is used to restore the latest after our operation is done. +## Internally [_SimulatorServer] will restore to this state by calling restore_snapshot. +func capture_current_state() -> Dictionary: var live : Dictionary[Node, _CollisionSnapshot] = {} var i := _tracked_nodes.size() - 1 while i >= 0: var node := _tracked_nodes[i] if not is_instance_valid(node): + _logger.trace("capturing current state(): dropping invalid node at index %s", [i]) _tracked_nodes.remove_at(i) i -= 1 continue - var snap := _CollisionSnapshot.new() - snap.transform = node.global_transform - if node is CollisionShape3D: - snap.disabled = node.disabled - else: - snap.is_area = true - snap.monitoring = node.monitoring - snap.monitorable = node.monitorable - snap.collision_layer = node.collision_layer - - live[node] = snap + live[node] = _take_snapshot_for_node(node) i -= 1 + + _logger.trace("captured current state(): captured %s node(s)", [live.size()]) return live + +## Restore simulation aware collision nodes to given snapshot dict. func restore_snapshot(snapshot_dict: Dictionary) -> void: + var restored_count := 0 + for node in snapshot_dict: + if not is_instance_valid(node): continue - var snap : _CollisionSnapshot = snapshot_dict[node] - node.global_transform = snap.transform - if node is CollisionShape3D: - node.disabled = snap.disabled - elif snap.is_area: - node.monitoring = snap.monitoring - node.monitorable = snap.monitorable - node.collision_layer = snap.collision_layer - -func trim_before(beginning: int) -> void: + + var collision_snapshot : _CollisionSnapshot = snapshot_dict[node] + collision_snapshot.restore_node(node) + restored_count += 1 + + _logger.trace("restored snapshot(): restored %s node(s) to captured live state", [restored_count]) + +## Trim ticks that we dont need anymore. +## Will trim every tick older than param beginning. (not inclusive) +func trim_ticks(beginning: int) -> void: for tick in _snapshots_by_tick.keys(): if tick < beginning: _snapshots_by_tick.erase(tick) + _logger.trace("trimming tick#%s", [tick]) From 37e369016d23bb8c23814c10679b1b134705e686 Mon Sep 17 00:00:00 2001 From: TuysuzTavsan Date: Thu, 13 Aug 2026 23:33:46 +0300 Subject: [PATCH 40/57] changed group name to snake_case --- addons/netfox/simulated-collision-recorder.gd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/netfox/simulated-collision-recorder.gd b/addons/netfox/simulated-collision-recorder.gd index 9f185c291..2ceec4baa 100644 --- a/addons/netfox/simulated-collision-recorder.gd +++ b/addons/netfox/simulated-collision-recorder.gd @@ -6,7 +6,7 @@ class_name _SimulatedCollisionRecorder ## ## Used by [_SimulatorServer] internally. -const SIMULATION_AWARE_GROUP := &"SimulationAwareCollision" +const SIMULATION_AWARE_GROUP := &"simulation_aware_collision" var _logger := NetfoxLogger._for_netfox("SimulatedCollisionRecorder") From 7e9d05dd1be04ed64810196082e06cd88043538b Mon Sep 17 00:00:00 2001 From: TuysuzTavsan Date: Sat, 15 Aug 2026 17:58:14 +0300 Subject: [PATCH 41/57] fix redundant restore --- addons/netfox/servers/network-history-server.gd | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/addons/netfox/servers/network-history-server.gd b/addons/netfox/servers/network-history-server.gd index a4f859b56..05a2edca3 100644 --- a/addons/netfox/servers/network-history-server.gd +++ b/addons/netfox/servers/network-history-server.gd @@ -289,8 +289,11 @@ func _restore_synchronizer_state(tick: int) -> bool: func _restore_input_sender(tick: int) -> bool: return _restore_latest(tick, _input_sender_history) +# Restores non authoritative simulators to known last state. func _restore_simulator(tick: int) -> bool: - return _restore_latest(tick, _simulator_history) + return _restore_latest(tick, _simulator_history, func(subject: Node) -> bool: + return not subject.is_multiplayer_authority() +) func _get_rollback_input_snapshot(tick: int) -> _Snapshot: return _rb_input_snapshots.get_at(tick) @@ -371,10 +374,19 @@ func _record(tick: int, history: _PerObjectHistory, snapshots: _HistoryBuffer, p _rb_state_history: _logger.trace("Recorded state @%d: %s", [tick, snapshot]) -func _restore_latest(tick: int, history: _PerObjectHistory) -> bool: +# Restore latest for given history. Optional filter can be used to filter subjects. +# Return false from filter function to not restore. +# Return true from filter function to restore. +# func(subject: Node) -> bool: +# return not subject.is_multiplayer_authority() +func _restore_latest(tick: int, history: _PerObjectHistory, filter: Callable = Callable()) -> bool: var any_applied := false for subject in history.subjects(): + # Optional filter. example: to only restore subjects we dont have authority over. + if filter.is_valid() and not filter.call(subject): + continue + # Grab latest snapshot up to tick var snapshot := history.get_latest_snapshot(tick, subject) From 34df2dd7649f336ffc9cc0ea38839400ac6f0eec Mon Sep 17 00:00:00 2001 From: TuysuzTavsan Date: Fri, 28 Aug 2026 22:34:41 +0300 Subject: [PATCH 42/57] fix peer_id=1 cant register projectile --- addons/netfox/servers/simulator-server.gd | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/addons/netfox/servers/simulator-server.gd b/addons/netfox/servers/simulator-server.gd index 055000db9..12b9738e4 100644 --- a/addons/netfox/servers/simulator-server.gd +++ b/addons/netfox/servers/simulator-server.gd @@ -136,19 +136,27 @@ func register_projectile(projectile : Node, firing_peer_id : int, fired_tick : i var firing_peer := enet_peer.get_peer(firing_peer_id) - if not firing_peer: + # Allow valid peers only with the exception being server id. + if not firing_peer and firing_peer_id != 1: _logger.error( "Error registering projectile %s: Firing peer #%s is not found on active peers.", [projectile.name, firing_peer_id] ) return - var rtt_ms := firing_peer.get_statistic(ENetPacketPeer.PEER_ROUND_TRIP_TIME) - var half_rtt_sec := (rtt_ms * 0.5) / 1000.0 - var half_rtt_ticks := half_rtt_sec / NetworkTime.ticktime + var rtt_ms := 0.0 + var half_rtt_sec := 0.0 + var half_rtt_ticks := 0.0 + var estimated_firing_tick := fired_tick - _simulation_host_delay_ticks - # Dont forget that we synchronize current_tick - _simulation_host_delay_ticks - var estimated_firing_tick := fired_tick - roundi(half_rtt_ticks) - _simulation_host_delay_ticks + # If firing peer is valid get stats and estimate peers simulation tick. + if firing_peer: + rtt_ms = firing_peer.get_statistic(ENetPacketPeer.PEER_ROUND_TRIP_TIME) + half_rtt_sec = (rtt_ms * 0.5) / 1000.0 + half_rtt_ticks = half_rtt_sec / NetworkTime.ticktime + + # Dont forget that we synchronize current_tick - _simulation_host_delay_ticks + estimated_firing_tick = fired_tick - roundi(half_rtt_ticks) - _simulation_host_delay_ticks if estimated_firing_tick < 0: _logger.error( From 8eb42ddd7718524b6a257fded48b2074c7bcd9b0 Mon Sep 17 00:00:00 2001 From: TuysuzTavsan Date: Sun, 30 Aug 2026 15:03:07 +0300 Subject: [PATCH 43/57] support offline peer --- addons/netfox/servers/simulator-server.gd | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/addons/netfox/servers/simulator-server.gd b/addons/netfox/servers/simulator-server.gd index 12b9738e4..f6ee5c82d 100644 --- a/addons/netfox/servers/simulator-server.gd +++ b/addons/netfox/servers/simulator-server.gd @@ -126,17 +126,23 @@ func register_projectile(projectile : Node, firing_peer_id : int, fired_tick : i ) return - var enet_peer := multiplayer.multiplayer_peer as ENetMultiplayerPeer - if not enet_peer: + ## Only allow Offline peer and ENet peer to register, because only Enet has rtt statistic functions. + ## Offline is for convenience. + var enet_peer := multiplayer.multiplayer_peer + if enet_peer is not ENetMultiplayerPeer and enet_peer is not OfflineMultiplayerPeer: _logger.error( "Error registering projectile %s : Multiplayer peer is either null or not type of ENet.\n" + "Only ENetMultiplayerPeer is supported for now.", [projectile.name] ) return - var firing_peer := enet_peer.get_peer(firing_peer_id) + var firing_peer : ENetPacketPeer = null + + if enet_peer is ENetMultiplayerPeer: + firing_peer = enet_peer.get_peer(firing_peer_id) as ENetPacketPeer # Allow valid peers only with the exception being server id. + # OfflinePeer will return id 1 so should be fine. if not firing_peer and firing_peer_id != 1: _logger.error( "Error registering projectile %s: Firing peer #%s is not found on active peers.", From ed3363a3a47095d74cf1f176e84495f4c5bcb801 Mon Sep 17 00:00:00 2001 From: TuysuzTavsan Date: Thu, 15 Oct 2026 00:57:11 +0300 Subject: [PATCH 44/57] diff states --- .../netfox/servers/data/per-object-history.gd | 24 ++++++ .../netfox/servers/network-history-server.gd | 77 +++++++++++-------- .../servers/network-synchronization-server.gd | 64 ++++++++++----- addons/netfox/servers/simulator-server.gd | 5 +- addons/netfox/simulator.gd | 14 ---- 5 files changed, 118 insertions(+), 66 deletions(-) diff --git a/addons/netfox/servers/data/per-object-history.gd b/addons/netfox/servers/data/per-object-history.gd index 450a4ae96..0ddfb1464 100644 --- a/addons/netfox/servers/data/per-object-history.gd +++ b/addons/netfox/servers/data/per-object-history.gd @@ -44,6 +44,30 @@ func ensure_snapshot(tick: int, subject: Object, carry_forward: bool) -> _Object # NOTE: May return null if tick too old return history.get_at(tick) as _ObjectSnapshot +# Resets the subject's snapshot at the given tick to its latest auth state +# The snapshot is replaced with a copy of the latest auth snapshot before the +# tick, discarding any non-auth (e.g. predicted) data recorded for the tick. If +# there's no earlier auth snapshot, it's replaced with an empty one. +# Does nothing if the snapshot at the tick is already auth. +func reset_to_auth(tick: int, subject: Object) -> void: + if not _data.has(subject): + _data[subject] = _HistoryBuffer.new(_history_size) + var history := _data[subject] as _HistoryBuffer + + var existing := history.get_at(tick) as _ObjectSnapshot + if existing and existing.is_auth(): + return + + # Walk backwards from the previous tick to find the latest auth snapshot + var latest_index := mini(tick - 1, history.get_latest_index()) + for index in range(latest_index, history.get_earliest_index() - 1, -1): + var snapshot := history.get_at(index) as _ObjectSnapshot + if snapshot and snapshot.is_auth(): + history.set_at(tick, snapshot.duplicate()) + return + + history.set_at(tick, _ObjectSnapshot.new(subject)) + func get_latest_snapshot(tick: int, subject: Object) -> _ObjectSnapshot: if not _data.has(subject): return null diff --git a/addons/netfox/servers/network-history-server.gd b/addons/netfox/servers/network-history-server.gd index 05a2edca3..09ace33d3 100644 --- a/addons/netfox/servers/network-history-server.gd +++ b/addons/netfox/servers/network-history-server.gd @@ -153,37 +153,18 @@ func get_latest_input_sender_for(subjects: Array, tick: int) -> int: func get_latest_simulator_for(subjects: Array, tick: int) -> int: return _get_latest_for(subjects, tick, _simulator_history) -## Get the latest tick where any of the [param subjects] had simulator snapshot available. -## Ignores tick where we dont have full snapshot. (missing properties) +## Get the latest tick at or before [param tick] where the simulator snapshot +## has data for all of the [param subjects]. Returns -1 if there's none. func get_latest_simulator_for_snapshot(subjects: Array, tick: int) -> int: - - var full_subject_ticks : Array[int] = [] - - for index in range(_simulator_snapshots.get_earliest_index(), _simulator_snapshots.get_latest_index() + 1): + # Walk backwards from the newest tick, so the first match is the latest one + var latest_index := mini(tick, _simulator_snapshots.get_latest_index()) + + for index in range(latest_index, _simulator_snapshots.get_earliest_index() - 1, -1): var snapshot := _simulator_snapshots.get_at(index) as _Snapshot - - if not snapshot: - continue - - var not_has_any_subject : bool = false - - for subject in subjects: - if not snapshot.has_subject(subject): - not_has_any_subject = true - break - - if not not_has_any_subject: - # We have all subjects in this snapshot. - full_subject_ticks.push_back(snapshot.tick) - - - var latest_tick : int = -1 - if full_subject_ticks.size() > 0: - var latest_in_arr = full_subject_ticks.max() - if latest_in_arr: - latest_tick = latest_in_arr - - return latest_tick + if snapshot and snapshot.has_subjects(subjects): + return index + + return -1 ## Return how old is the latest rollback input data for any of the ## [param subjects], in ticks @@ -289,6 +270,25 @@ func _restore_synchronizer_state(tick: int) -> bool: func _restore_input_sender(tick: int) -> bool: return _restore_latest(tick, _input_sender_history) +# Applies the auth simulator state at the given tick to each of the subjects. +# Values come from the per-object history, so diffs received for the tick are +# already merged on top of the previous auth state. +# +# NOTE: On a fresh connection, if a diff arrives before the first full state, +# the auth state only holds the properties in that diff. Only those are +# applied, the rest are filled in by the next full state. +func _restore_simulator_subjects(subjects: Array, tick: int) -> void: + for subject in subjects: + # Shouldn't happen, the tick is picked from received snapshots. It only + # can if the state was stored in _simulator_snapshots, but was too old + # for _merge_history. Never apply non-auth (e.g. predicted) data as truth. + if not _simulator_history.is_auth(tick, subject): + _logger.warning("No auth simulator state for %s @%d, skipping restore", [subject, tick]) + continue + + var object_snapshot := _simulator_history.get_latest_snapshot(tick, subject) + object_snapshot.apply() + # Restores non authoritative simulators to known last state. func _restore_simulator(tick: int) -> bool: return _restore_latest(tick, _simulator_history, func(subject: Node) -> bool: @@ -326,9 +326,24 @@ func _merge_input_sender(snapshot: _Snapshot) -> bool: _merge_snapshot(snapshot, _input_sender_snapshots, true) return _merge_history(snapshot, _input_sender_history, true) +# Merges a simulator state received from the host, either full or diff. +# +# Works the same way as merging rollback state: the per-tick snapshot keeps what +# was received, and the per-object history is merged with carry-forward. A diff +# only contains properties that changed since the previous tick, carry-forward +# fills in the rest from the previous tick. +# +# Auth data in the history must only ever hold values received from the host. +# Local authoritative simulators record their own predictions into the same +# history (see _record_individual_simulator), so before merging, each subject's +# entry at this tick is reset to its latest auth state. This way carry-forward +# never copies predicted values into auth data. func _merge_simulator(snapshot: _Snapshot) -> bool: - _merge_snapshot(snapshot, _simulator_snapshots, true) - return _merge_history(snapshot, _simulator_history, true) + for subject in snapshot.get_subjects(): + _simulator_history.reset_to_auth(snapshot.tick, subject) + + _merge_snapshot(snapshot, _simulator_snapshots) + return _merge_history(snapshot, _simulator_history) func _record(tick: int, history: _PerObjectHistory, snapshots: _HistoryBuffer, property_pool: _PropertyPool, only_auth: bool, auth_filter: Callable) -> void: var snapshot := snapshots.get_at(tick, _Snapshot.new(tick)) as _Snapshot diff --git a/addons/netfox/servers/network-synchronization-server.gd b/addons/netfox/servers/network-synchronization-server.gd index 981aee5da..161ae57ca 100644 --- a/addons/netfox/servers/network-synchronization-server.gd +++ b/addons/netfox/servers/network-synchronization-server.gd @@ -54,7 +54,6 @@ var _max_packet_size := ProjectSettings.get_setting("netfox/general/max_sync_pac var _input_sender_enable_broadcast := ProjectSettings.get_setting("netfox/input_sender/enable_input_broadcast", false) as bool var _input_sender_redundancy := ProjectSettings.get_setting("netfox/input_sender/input_redundancy", 3) as int -var _last_simulator_state_sent := _Snapshot.new(0) var _simulator_enable_diffs := ProjectSettings.get_setting("netfox/simulator/enable_diff_states", true) as bool var _simulator_full_interval := ProjectSettings.get_setting("netfox/simulator/full_state_interval", 24) as int var _simulator_full_scheduler := _IntervalScheduler.new(_simulator_full_interval) @@ -375,11 +374,10 @@ func _synchronize_input_sender(tick: int) -> void: func _synchronize_simulator(tick: int) -> void: # We don't own state, nothing to synchronize if _simulator_owned_properties.is_empty(): - _logger.trace("No owned simulator property to synchronize, returning.") return - + + # Grab snapshot from NetworkHistoryServer var snapshot := NetworkHistoryServer._get_simulator_snapshot(tick) - if not snapshot: # No data for tick return @@ -387,12 +385,17 @@ func _synchronize_simulator(tick: int) -> void: if snapshot.is_empty(): # Nothing to send return - + # Figure out whether to send full- or diff state var is_full := _simulator_full_scheduler.is_now() if not _simulator_enable_diffs: is_full = true - + + # Check if we have history to diff to + var reference_snapshot := NetworkHistoryServer._get_simulator_snapshot(tick - 1) + if not reference_snapshot: + is_full = true + if is_full: # Send full states for peer in multiplayer.get_peers(): @@ -400,9 +403,27 @@ func _synchronize_simulator(tick: int) -> void: var packets := _dense_serializer.write_for(peer, snapshot, _simulator_owned_properties, filter) for packet in packets: - _logger.trace("Submitting simulator full state:%s to peer:%s", [snapshot, peer]) _cmd_full_simulator.send(packet, peer) + NetworkPerformance.push_full_state_props(snapshot.size()) + NetworkPerformance.push_sent_state_props(snapshot.size()) + else: + var diff := _Snapshot.make_patch(reference_snapshot, snapshot) + if diff.is_empty(): + # Nothing changed, don't send anything + return + + # Send diff states + for peer in multiplayer.get_peers(): + var filter := func(subject): return _is_node_visible_to(peer, subject) + + var packets := _sparse_serializer.write_for(peer, diff, _simulator_owned_properties, filter) + for packet in packets: + _cmd_diff_simulator.send(packet, peer) + + NetworkPerformance.push_full_state_props(snapshot.size()) + NetworkPerformance.push_sent_state_props(diff.size()) + func _init( p_command_server: _NetworkCommandServer = null, p_history_server: _NetworkHistoryServer = null, @@ -437,7 +458,8 @@ func _ready(): _cmd_input_sender = _command_server.register_command(_handle_input_sender, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) - # TODO which one makes sense ordered or not ? + # Unreliable, same as rollback state. Every packet carries its own tick, and + # diffs lost on the way are corrected by the next full state. _cmd_full_simulator = _command_server.register_command(_handle_full_simulator, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) _cmd_diff_simulator = _command_server.register_command(_handle_diff_simulator, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) @@ -507,25 +529,27 @@ func _handle_input_sender(sender : int, data : PackedByteArray) -> void: if NetworkHistoryServer._merge_input_sender(snapshot): _on_input_sender.emit(snapshot) -func _handle_full_simulator(sender : int, data : PackedByteArray) -> void: +func _handle_full_simulator(sender: int, data: PackedByteArray) -> void: var buffer := StreamPeerBuffer.new() buffer.data_array = data - + var snapshot := _dense_serializer.read_from(sender, _simulator_properties, buffer, true) - snapshot.sanitize(sender) - - _logger.trace("Ingested simulator full state: %s", [snapshot]) - if NetworkHistoryServer._merge_simulator(snapshot): - _on_simulator.emit(snapshot) -func _handle_diff_simulator(sender : int, data : PackedByteArray) -> void: + _ingest_simulator(sender, snapshot) + +func _handle_diff_simulator(sender: int, data: PackedByteArray) -> void: var buffer := StreamPeerBuffer.new() buffer.data_array = data - - var snapshot := _sparse_serializer.read_from(sender, _simulator_properties, buffer) + + var diff := _sparse_serializer.read_from(sender, _simulator_properties, buffer) + _logger.trace("Received simulator diff state for @%d", [diff.tick]) + + _ingest_simulator(sender, diff) + +func _ingest_simulator(sender: int, snapshot: _Snapshot) -> void: snapshot.sanitize(sender) - - _logger.trace("Ingested simulator diff state: %s", [snapshot]) + + _logger.trace("Ingesting simulator state: %s", [snapshot]) if NetworkHistoryServer._merge_simulator(snapshot): _on_simulator.emit(snapshot) diff --git a/addons/netfox/servers/simulator-server.gd b/addons/netfox/servers/simulator-server.gd index f6ee5c82d..c30aa8f0f 100644 --- a/addons/netfox/servers/simulator-server.gd +++ b/addons/netfox/servers/simulator-server.gd @@ -277,7 +277,10 @@ func _handle_local_authoritative_simulators() -> void: simulator.listened_input_sender._restore_properties() continue - simulator._apply_snapshot_for_self(latest_truth_snapshot) + # Apply truth from history, which has diffs merged into full state. + _history_server._restore_simulator_subjects( + simulator._state_properties.get_subjects(), + latest_truth_tick) # Retrieve the input history to apply it manually. var input_history := _history_server._input_sender_history for i in range(latest_truth_tick + 1, current_tick + 1): diff --git a/addons/netfox/simulator.gd b/addons/netfox/simulator.gd index 7e2f31723..06bb7f3f2 100644 --- a/addons/netfox/simulator.gd +++ b/addons/netfox/simulator.gd @@ -215,20 +215,6 @@ func _reprocess_settings() -> void: process_settings() -# Helper function to apply given snapshot for only this node. -# TODO (same todo with input_sender)? -# Applying whole snapshot and iterating over ticks would be nicer -# if we decide to have singleton for this -func _apply_snapshot_for_self(snapshot : _Snapshot) -> void: - _logger.trace("Applying snapshot for self :%s", [snapshot]) - for subject in _state_properties.get_subjects(): - for property in _state_properties.get_properties_of(subject): - - if snapshot.has_property(subject, property): - var value := snapshot.get_property(subject, property) - # TODO is this should be node.set_indexed ?? - subject.set_indexed(property, value) - # Helper function to run simulation with given parameters. # This function is used by SimulatorServer internally. func _run_simulation(delta : float, tick : int, is_fresh : bool) -> void: From 660e37cffcbe991cfc5c7dedd169352292daddfa Mon Sep 17 00:00:00 2001 From: TuysuzTavsan Date: Thu, 15 Oct 2026 01:21:22 +0300 Subject: [PATCH 45/57] added schemas --- addons/netfox/input-sender.gd | 51 +++++++++++++++++++++++++++ addons/netfox/simulator.gd | 51 +++++++++++++++++++++++++++ docs/netfox/guides/network-schemas.md | 8 ++--- 3 files changed, 106 insertions(+), 4 deletions(-) diff --git a/addons/netfox/input-sender.gd b/addons/netfox/input-sender.gd index 52f0c0c12..1e971ae87 100644 --- a/addons/netfox/input-sender.gd +++ b/addons/netfox/input-sender.gd @@ -65,6 +65,9 @@ var visibility_filter := PeerVisibilityFilter.new() var _input_properties := _PropertyPool.new() var _properties_dirty: bool = false +# Nodes with a schema registered through this InputSender, see set_schema() +var _schema_nodes := _Set.new() + var _logger := NetfoxLogger._for_netfox("InputSender") # We need these to de-couple input-senders working logic from recording. @@ -176,6 +179,54 @@ func add_input(node: Variant, property: String) -> void: _properties_dirty = true _reprocess_settings.call_deferred() +## Set the schema for transmitting properties over the network. +## [br][br] +## The [param schema] must be a dictionary, with the keys being property path +## strings, and the values are the associated [NetworkSchemaSerializer] objects. +## Properties are interpreted relative to the [member root] node. The schema +## should contain input properties. Properties not specified in the schema will +## use a generic fallback serializer. By using the right serializer for the +## right property, bandwidth usage can be lowered. +## [br][br] +## The schema must be the same on every peer. +## [br][br] +## See [NetworkSchemas] for many common serializers. +## [br][br] +## Example: +## [codeblock] +## input_sender.set_schema({ +## "Input:movement": NetworkSchemas.vec3f32(), +## "Input:aim": NetworkSchemas.vec3f32(), +## "Input:is_firing": NetworkSchemas.bool8() +## }) +## [/codeblock] +func set_schema(schema: Dictionary) -> void: + # Remove previous schema + clear_schema() + + # Register new schema + merge_schema(schema) + +## Add serializers from [param schema]. +## [br][br] +## See [method set_schema] for specifying [param schema]. As opposed to [method +## set_schema], this method updates the schema, instead of overriding it. If +## a property had a serializer specified previously, this will replace it. +func merge_schema(schema: Dictionary) -> void: + for prop in schema: + var prop_entry := PropertyEntry.parse(root, prop) + var serializer := schema[prop] as NetworkSchemaSerializer + NetworkSynchronizationServer.register_schema(prop_entry.node, prop_entry.property, serializer) + _schema_nodes.add(prop_entry.node) + +## Clear any serializers specified earlier. +## [br][br] +## See [method set_schema]. +func clear_schema() -> void: + for node in _schema_nodes: + NetworkSynchronizationServer.deregister_schema_for(node) + _schema_nodes.clear() + ## Helper function to determine if [InputSender] has authority over its input_properties ## This function iterates over input_properties subjects and checks if they have authority. ## If none of them has authority or no input_node is configured this will return false, diff --git a/addons/netfox/simulator.gd b/addons/netfox/simulator.gd index 06bb7f3f2..83c217079 100644 --- a/addons/netfox/simulator.gd +++ b/addons/netfox/simulator.gd @@ -67,6 +67,9 @@ var visibility_filter := PeerVisibilityFilter.new() var _state_properties := _PropertyPool.new() +# Nodes with a schema registered through this Simulator, see set_schema() +var _schema_nodes := _Set.new() + var _properties_dirty: bool = false # Dictionary (root node) -> (managing simulator) @@ -199,6 +202,54 @@ func add_state(node: Variant, property: String): _properties_dirty = true _reprocess_settings.call_deferred() +## Set the schema for transmitting properties over the network. +## [br][br] +## The [param schema] must be a dictionary, with the keys being property path +## strings, and the values are the associated [NetworkSchemaSerializer] objects. +## Properties are interpreted relative to the [member root] node. The schema +## should contain state properties, input properties are set on the +## [member listened_input_sender] instead. Properties not specified in the +## schema will use a generic fallback serializer. By using the right serializer +## for the right property, bandwidth usage can be lowered. +## [br][br] +## The schema must be the same on every peer. +## [br][br] +## See [NetworkSchemas] for many common serializers. +## [br][br] +## Example: +## [codeblock] +## simulator.set_schema({ +## ":global_transform": NetworkSchemas.transform3f32(), +## ":velocity": NetworkSchemas.vec3f32() +## }) +## [/codeblock] +func set_schema(schema: Dictionary) -> void: + # Remove previous schema + clear_schema() + + # Register new schema + merge_schema(schema) + +## Add serializers from [param schema]. +## [br][br] +## See [method set_schema] for specifying [param schema]. As opposed to [method +## set_schema], this method updates the schema, instead of overriding it. If +## a property had a serializer specified previously, this will replace it. +func merge_schema(schema: Dictionary) -> void: + for prop in schema: + var prop_entry := PropertyEntry.parse(root, prop) + var serializer := schema[prop] as NetworkSchemaSerializer + NetworkSynchronizationServer.register_schema(prop_entry.node, prop_entry.property, serializer) + _schema_nodes.add(prop_entry.node) + +## Clear any serializers specified earlier. +## [br][br] +## See [method set_schema]. +func clear_schema() -> void: + for node in _schema_nodes: + NetworkSynchronizationServer.deregister_schema_for(node) + _schema_nodes.clear() + ## Check if this [Simulator] has authority over its inputs via listened_input_sender ## This helper is used by SimulatorServer internally. func has_authority_over_inputs() -> bool: diff --git a/docs/netfox/guides/network-schemas.md b/docs/netfox/guides/network-schemas.md index d6f1e5e44..8fe0a1c14 100644 --- a/docs/netfox/guides/network-schemas.md +++ b/docs/netfox/guides/network-schemas.md @@ -57,10 +57,10 @@ experience. ## Registering a schema -Both [RollbackSynchronizer] and [StateSynchronizer] expose a `set_schema()` -method, that can be used to register the schema used for transmitting -properties over the network. This method takes a dictionary, with the keys -being property path strings, and the values being serializers: +[RollbackSynchronizer], [StateSynchronizer], InputSender and Simulator all +expose a `set_schema()` method, that can be used to register the schema used for +transmitting properties over the network. This method takes a dictionary, with +the keys being property path strings, and the values being serializers: ```gdscript rollback_synchronizer.set_schema({ From 2e4363eaac315c8cf2617330f3008246c1434f64 Mon Sep 17 00:00:00 2001 From: TuysuzTavsan Date: Thu, 15 Oct 2026 01:46:14 +0300 Subject: [PATCH 46/57] stop messing with history server internals --- .../netfox/servers/network-history-server.gd | 61 +++++++++++-------- addons/netfox/servers/simulator-server.gd | 47 +++++++------- 2 files changed, 60 insertions(+), 48 deletions(-) diff --git a/addons/netfox/servers/network-history-server.gd b/addons/netfox/servers/network-history-server.gd index 09ace33d3..c5f52ed88 100644 --- a/addons/netfox/servers/network-history-server.gd +++ b/addons/netfox/servers/network-history-server.gd @@ -227,36 +227,36 @@ func _record_simulator(tick: int) -> void: return subject.is_multiplayer_authority() ) -# Records given simulator for given tick even though it doesnt have authority. -# This function is used by SimulatorServer to record simulators that we own -# input but not state. -# As a design perspective, a game should have max 1-2 simulators like this. -# So this is not really expensive and not usually used on servers. -# No auth history is overriden. -# No snapshot is written. -func _record_individual_simulator(simulator : Simulator, tick: int) -> void: - var property_pool := _PropertyPool.new() - property_pool.set_from_paths(simulator.root, simulator.state_properties) - - for subject in property_pool.get_subjects(): +# Records the current state of the given simulator subjects for the given tick, +# even if we don't have authority over them. +# +# Used by SimulatorServer to record simulators one by one, e.g. predicted state +# of local authoritative simulators (we own the input, but not the state). +# Properties are the ones registered with [method register_simulator]. +# +# Existing auth data is never overwritten. Only the per-object history is +# written, no per-tick snapshot. +func _record_simulator_subjects(subjects: Array, tick: int) -> void: + for subject in subjects: assert(subject is Node, "Only nodes supported for now!") - - var is_auth := subject.is_multiplayer_authority() as bool - + + # Never overwrite auth data, e.g. truth received from the host if _simulator_history.is_auth(tick, subject): continue - - var subject_snapshot := _simulator_history.ensure_snapshot(tick, subject, true) - if subject_snapshot == null: + + var properties := _simulator_properties.get_properties_of(subject) + assert(not properties.is_empty(), "Simulator subject recorded without registered properties! Please report a bug!") + + var object_snapshot := _simulator_history.ensure_snapshot(tick, subject, true) + if object_snapshot == null: _logger.warning("Dropping recorded tick @%d for subject %s as out-of-bounds", [tick, subject]) continue - - assert(not property_pool.get_properties_of(subject).is_empty(), "Subject present in property pool without properties! Please report a bug!") - for property in property_pool.get_properties_of(subject): - subject_snapshot.record_property(property) - subject_snapshot.set_auth(is_auth) - - _logger.trace("Recorded simulator state @%d: %s", [tick, subject_snapshot]) + + for property in properties: + object_snapshot.record_property(property) + object_snapshot.set_auth((subject as Node).is_multiplayer_authority()) + + _logger.trace("Recorded simulator state @%d: %s", [tick, object_snapshot]) func _restore_rollback_input(tick: int) -> bool: return _restore_latest(tick, _rb_input_history) @@ -270,6 +270,15 @@ func _restore_synchronizer_state(tick: int) -> bool: func _restore_input_sender(tick: int) -> bool: return _restore_latest(tick, _input_sender_history) +# Applies the latest input_sender inputs at or before the given tick, but only +# to the given subjects, so a single InputSender can be restored without +# touching the others. Nothing is written to the history. +func _restore_input_sender_subjects(subjects: Array, tick: int) -> void: + for subject in subjects: + var object_snapshot := _input_sender_history.get_latest_snapshot(tick, subject) + if object_snapshot: + object_snapshot.apply() + # Applies the auth simulator state at the given tick to each of the subjects. # Values come from the per-object history, so diffs received for the tick are # already merged on top of the previous auth state. @@ -335,7 +344,7 @@ func _merge_input_sender(snapshot: _Snapshot) -> bool: # # Auth data in the history must only ever hold values received from the host. # Local authoritative simulators record their own predictions into the same -# history (see _record_individual_simulator), so before merging, each subject's +# history (see _record_simulator_subjects), so before merging, each subject's # entry at this tick is reset to its latest auth state. This way carry-forward # never copies predicted values into auth data. func _merge_simulator(snapshot: _Snapshot) -> bool: diff --git a/addons/netfox/servers/simulator-server.gd b/addons/netfox/servers/simulator-server.gd index c30aa8f0f..cb1741a38 100644 --- a/addons/netfox/servers/simulator-server.gd +++ b/addons/netfox/servers/simulator-server.gd @@ -222,17 +222,18 @@ func _handle_host_simulators() -> void: for simulator in _host_simulators: + var input_subjects := simulator.listened_input_sender._input_properties.get_subjects() + var state_subjects := simulator._state_properties.get_subjects() + # Save input properties before messing them up. simulator.listened_input_sender._save_properties() - # Retrieve the input history and apply manually. - var input_history := _history_server._input_sender_history - for subject in simulator.listened_input_sender._input_properties.get_subjects(): - input_history.ensure_snapshot(current_tick - 1, subject, true).apply() + # Apply inputs of the previous tick. + _history_server._restore_input_sender_subjects(input_subjects, current_tick - 1) var is_fresh := _is_tick_fresh_for(simulator, current_tick) simulator._run_simulation(NetworkTime.ticktime, current_tick, is_fresh) - _history_server._record_individual_simulator(simulator, current_tick) + _history_server._record_simulator_subjects(state_subjects, current_tick) _set_tick_simulated_for(simulator, current_tick) # Restore messed up properties. @@ -248,8 +249,11 @@ func _handle_local_authoritative_simulators() -> void: for simulator in _local_authoritative_simulators: + var input_subjects := simulator.listened_input_sender._input_properties.get_subjects() + var state_subjects := simulator._state_properties.get_subjects() + var latest_truth_tick := _history_server.get_latest_simulator_for_snapshot( - simulator._state_properties.get_subjects(), + state_subjects, current_tick) var latest_truth_snapshot := _history_server._get_simulator_snapshot(latest_truth_tick) @@ -263,14 +267,12 @@ func _handle_local_authoritative_simulators() -> void: "Running simulation for only current tick." ) - # Retrieve the input history and apply manually. - var input_history := _history_server._input_sender_history - for subject in simulator.listened_input_sender._input_properties.get_subjects(): - input_history.ensure_snapshot(current_tick - 1, subject, true).apply() + # Apply inputs of the previous tick. + _history_server._restore_input_sender_subjects(input_subjects, current_tick - 1) var is_fresh := _is_tick_fresh_for(simulator, current_tick) simulator._run_simulation(NetworkTime.ticktime, current_tick, is_fresh) - _history_server._record_individual_simulator(simulator, current_tick) + _history_server._record_simulator_subjects(state_subjects, current_tick) _set_tick_simulated_for(simulator, current_tick) # Restore messed up properties. @@ -278,21 +280,16 @@ func _handle_local_authoritative_simulators() -> void: continue # Apply truth from history, which has diffs merged into full state. - _history_server._restore_simulator_subjects( - simulator._state_properties.get_subjects(), - latest_truth_tick) - # Retrieve the input history to apply it manually. - var input_history := _history_server._input_sender_history + _history_server._restore_simulator_subjects(state_subjects, latest_truth_tick) + for i in range(latest_truth_tick + 1, current_tick + 1): - - for subject in simulator.listened_input_sender._input_properties.get_subjects(): - var snapshot : _ObjectSnapshot = input_history.ensure_snapshot(i - 1, subject, false) - if snapshot: - snapshot.apply() + # Apply inputs of the previous tick. If there's no input recorded + # for it, the latest input before it is applied. + _history_server._restore_input_sender_subjects(input_subjects, i - 1) var is_fresh := _is_tick_fresh_for(simulator, i) simulator._run_simulation(NetworkTime.ticktime, i, is_fresh) - _history_server._record_individual_simulator(simulator, i) + _history_server._record_simulator_subjects(state_subjects, i) _set_tick_simulated_for(simulator, i) # Restore messed up properties. @@ -308,6 +305,12 @@ func _handle_host_puppet_simulators() -> void: var simulated_tick := current_tick - _simulation_host_delay_ticks + # Simulating a tick uses the inputs of the tick before it, so the first + # tick with any inputs to simulate is 1. Before that, simulated_tick - 1 is + # negative, and would match the -1 returned for "no input found" below. + if simulated_tick < 1: + return + for simulator in _host_puppet_simulators: var latest_input_tick := _history_server.get_latest_input_sender_for( From b9e16f6a3b3e6dfc097694e544d2a427473213b0 Mon Sep 17 00:00:00 2001 From: TuysuzTavsan Date: Thu, 15 Oct 2026 03:54:10 +0300 Subject: [PATCH 47/57] view delay update --- addons/netfox/netfox.gd | 21 ++ .../servers/network-synchronization-server.gd | 6 +- addons/netfox/servers/simulator-server.gd | 89 ++++---- addons/netfox/view-delay-tracker.gd | 202 ++++++++++++++++++ 4 files changed, 273 insertions(+), 45 deletions(-) create mode 100644 addons/netfox/view-delay-tracker.gd diff --git a/addons/netfox/netfox.gd b/addons/netfox/netfox.gd index cc5590ae8..f5fdd58d6 100644 --- a/addons/netfox/netfox.gd +++ b/addons/netfox/netfox.gd @@ -214,6 +214,27 @@ var SETTINGS: Array[Dictionary] = [ "value": 8, "type" : TYPE_INT }, + { + "name": "netfox/simulator/max_view_delay_ticks", + "value": 30, + "type" : TYPE_INT + }, + { + # Seconds between periodic view delay samples sent by clients + "name": "netfox/simulator/view_delay_send_interval", + "value": 0.25, + "type": TYPE_FLOAT, + "hint": PROPERTY_HINT_RANGE, + "hint_string": "0,2,or_greater" + }, + { + # View delay changes of at least this many seconds are sent immediately + "name": "netfox/simulator/view_delay_change_threshold", + "value": 0.06, + "type": TYPE_FLOAT, + "hint": PROPERTY_HINT_RANGE, + "hint_string": "0,1,or_greater" + }, ] const AUTOLOADS: Array[Dictionary] = [ diff --git a/addons/netfox/servers/network-synchronization-server.gd b/addons/netfox/servers/network-synchronization-server.gd index 161ae57ca..c6199ae29 100644 --- a/addons/netfox/servers/network-synchronization-server.gd +++ b/addons/netfox/servers/network-synchronization-server.gd @@ -550,8 +550,10 @@ func _ingest_simulator(sender: int, snapshot: _Snapshot) -> void: snapshot.sanitize(sender) _logger.trace("Ingesting simulator state: %s", [snapshot]) - if NetworkHistoryServer._merge_simulator(snapshot): - _on_simulator.emit(snapshot) + NetworkHistoryServer._merge_simulator(snapshot) + + # Emitted for every received state, same as rollback's _on_state + _on_simulator.emit(snapshot) func _ingest_state(sender: int, snapshot: _Snapshot) -> void: snapshot.sanitize(sender) diff --git a/addons/netfox/servers/simulator-server.gd b/addons/netfox/servers/simulator-server.gd index cb1741a38..001a2266d 100644 --- a/addons/netfox/servers/simulator-server.gd +++ b/addons/netfox/servers/simulator-server.gd @@ -55,6 +55,10 @@ var _simulation_history_size : int = ProjectSettings.get_setting("netfox/simulat # Host side buffering/delay tick count for simulation. var _simulation_host_delay_ticks : int = ProjectSettings.get_setting("netfox/simulator/host_delay_ticks", 8) +# Maximum view delay accepted from clients, in ticks. Limits how far into the +# past a client can rewind when firing, see register_projectile. +var _max_view_delay_ticks : int = ProjectSettings.get_setting("netfox/simulator/max_view_delay_ticks", 30) + # Node to array of ticks var _simulated_ticks := {} @@ -64,6 +68,10 @@ var _living_projectiles : Array[Node] = [] # Helper to record and restore simulation aware collision-shapes/areas. var _simulated_collision_recorder : _SimulatedCollisionRecorder = _SimulatedCollisionRecorder.new() +# Helper to track how many ticks behind each client sees the host's simulation. +# Used to find the tick a projectile was fired at, see register_projectile. +var _view_delay_tracker : _ViewDelayTracker = _ViewDelayTracker.new() + # Fresh registered projectiles. # Mapped by tick -> array of projectiles (type of Node) that are estimated to be fired on mapped tick. # See register_projectile method. @@ -86,6 +94,7 @@ func _ready(): if not _synchronization_server: _synchronization_server = NetworkSynchronizationServer _simulated_collision_recorder.initialize(get_tree()) + _view_delay_tracker.initialize(self, NetworkCommandServer, _synchronization_server, _simulation_history_size) # Register a simulator node. # Will check for authority over inputs and categorize by it. @@ -114,6 +123,14 @@ func deregister_simulator(simulator : Simulator) -> void: ## Register a projectile to be stepped by simulation. ## This method should only be called on host. +## [br][br] +## [param fired_tick] is the firing peer's tick when it fired, e.g. the tick of +## its fire input, as given by [signal InputSender.network_input]. Don't pass +## the host's current tick. +## [br][br] +## The projectile is caught up from the tick of the host's simulation that the +## firing peer was seeing at [param fired_tick], see [_ViewDelayTracker]. The +## rewind is limited by [code]netfox/simulator/max_view_delay_ticks[/code]. func register_projectile(projectile : Node, firing_peer_id : int, fired_tick : int) -> void: var has_step_method := projectile.has_method("step") @@ -126,69 +143,55 @@ func register_projectile(projectile : Node, firing_peer_id : int, fired_tick : i ) return - ## Only allow Offline peer and ENet peer to register, because only Enet has rtt statistic functions. - ## Offline is for convenience. - var enet_peer := multiplayer.multiplayer_peer - if enet_peer is not ENetMultiplayerPeer and enet_peer is not OfflineMultiplayerPeer: - _logger.error( - "Error registering projectile %s : Multiplayer peer is either null or not type of ENet.\n" + - "Only ENetMultiplayerPeer is supported for now.", [projectile.name] - ) - return - - var firing_peer : ENetPacketPeer = null - - if enet_peer is ENetMultiplayerPeer: - firing_peer = enet_peer.get_peer(firing_peer_id) as ENetPacketPeer - - # Allow valid peers only with the exception being server id. - # OfflinePeer will return id 1 so should be fine. - if not firing_peer and firing_peer_id != 1: - _logger.error( - "Error registering projectile %s: Firing peer #%s is not found on active peers.", - [projectile.name, firing_peer_id] - ) - return - - var rtt_ms := 0.0 - var half_rtt_sec := 0.0 - var half_rtt_ticks := 0.0 - var estimated_firing_tick := fired_tick - _simulation_host_delay_ticks - - # If firing peer is valid get stats and estimate peers simulation tick. - if firing_peer: - rtt_ms = firing_peer.get_statistic(ENetPacketPeer.PEER_ROUND_TRIP_TIME) - half_rtt_sec = (rtt_ms * 0.5) / 1000.0 - half_rtt_ticks = half_rtt_sec / NetworkTime.ticktime - - # Dont forget that we synchronize current_tick - _simulation_host_delay_ticks - estimated_firing_tick = fired_tick - roundi(half_rtt_ticks) - _simulation_host_delay_ticks + var view_delay := _get_view_delay_for(firing_peer_id, fired_tick) + var estimated_firing_tick := fired_tick - view_delay if estimated_firing_tick < 0: _logger.error( "Error registering projectile %s: Calculated estimated_firing_tick as negative. Not registering.\n" + - "half rtt: %s, fired tick: %s, estimated firing tick: %s", - [projectile.name, half_rtt_sec, fired_tick, estimated_firing_tick] + "view delay: %s, fired tick: %s, estimated firing tick: %s", + [projectile.name, view_delay, fired_tick, estimated_firing_tick] ) return if estimated_firing_tick < NetworkTime.tick - _simulation_history_size: _logger.error( "Error registering projectile %s: Calculated estimated_firing_tick is older than history.\n" + - "Returning without registering. half rtt: %s, fired tick: %s, estimated firing tick: %s", - [projectile.name, half_rtt_sec, fired_tick, estimated_firing_tick] + "Returning without registering. view delay: %s, fired tick: %s, estimated firing tick: %s", + [projectile.name, view_delay, fired_tick, estimated_firing_tick] ) return _logger.debug( "Registered projectile %s with stats:\n" + - "firing_peer: %s, fired tick: %s, half rtt: %s, estimated firing tick: %s", - [projectile.name, firing_peer_id, fired_tick, half_rtt_sec, estimated_firing_tick] + "firing_peer: %s, fired tick: %s, view delay: %s, estimated firing tick: %s", + [projectile.name, firing_peer_id, fired_tick, view_delay, estimated_firing_tick] ) var existing_projectile_arr := _fresh_registered_projectiles_by_tick.get_or_add(estimated_firing_tick, []) as Array existing_projectile_arr.push_back(projectile) +# Returns how many ticks behind the given peer saw the host's simulation at +# the given tick of the peer. +func _get_view_delay_for(peer: int, tick: int) -> int: + # The host sees other players where it simulates them, host_delay_ticks + # behind. This also covers offline play. + if peer == multiplayer.get_unique_id(): + return _simulation_host_delay_ticks + + var view_delay := _view_delay_tracker.get_view_delay(peer, tick, -1) + if view_delay < 0: + # No sample for this tick, e.g. the peer just connected. It can't see + # the host's simulation any sooner than the host simulates it. + _logger.debug("No view delay from peer #%s @%d, using host delay", [peer, tick]) + return _simulation_host_delay_ticks + + # The view delay is reported by the client, so don't trust it blindly. + # It can't be less than host_delay_ticks, and can't rewind further than + # max_view_delay_ticks. + var max_view_delay := maxi(_simulation_host_delay_ticks, _max_view_delay_ticks) + return clampi(view_delay, _simulation_host_delay_ticks, max_view_delay) + func _after_tick(tick : int) -> void: _catch_up_fresh_projectiles(tick) diff --git a/addons/netfox/view-delay-tracker.gd b/addons/netfox/view-delay-tracker.gd new file mode 100644 index 000000000..7b83bfb5c --- /dev/null +++ b/addons/netfox/view-delay-tracker.gd @@ -0,0 +1,202 @@ +extends RefCounted +class_name _ViewDelayTracker + +## Helper class for tracking how many ticks behind each client sees the host's +## simulation. +## +## [b]View delay[/b] is the number of ticks between a client's current tick and +## the tick of the host's simulation that the client is seeing. When a client +## fires at tick [code]t[/code], it was seeing the host's simulation at tick +## [code]t - view_delay[/code]. [_SimulatorServer] uses this to find the tick +## to rewind to when registering projectiles. +## [br][br] +## [b]Measuring[/b]: clients measure their view delay every time a simulator +## state arrives from the host, as [code]current tick - state tick[/code], and +## keep that value until the next state arrives. Full states arrive at least +## every [code]netfox/simulator/full_state_interval[/code] ticks, which keeps +## the measurement fresh. +## [br][br] +## [b]Why on arrival[/b]: with diff states, the host sends nothing while +## nothing changes. Computing [code]current tick - newest received tick[/code] +## every tick instead would keep growing in that case, even though the client +## still sees an up to date world. Example, with a real view delay of 10 ticks +## and nothing changing after tick 100: +## [codeblock] +## client tick | arrives | every tick | on arrival +## 110 | state for tick 100 | 10 | 10 +## 120 | nothing | 20 | 10 +## 130 | nothing | 30 | 10 +## 134 | full state for 124 | 10 | 10 +## [/codeblock] +## If the client fires at tick 130, it sees the world as it is at tick 120, +## since nothing changed between 100 and 120. With 30, the host would start the +## projectile at tick 100 and step it 20 ticks too many, so it ends up further +## along its path than where the client fired it. With 10, it starts at 120. +## [br][br] +## The downside is lag spikes: if states are stuck on the network instead, +## the client can't tell that apart from "nothing changed", see the TODO below. +## [br][br] +## [b]Sending[/b]: clients send [code](tick, view delay)[/code] samples to the +## host, 6 bytes each, on an unreliable command. To keep traffic low, a sample +## is sent: +## [br]- periodically, every +## [code]netfox/simulator/view_delay_send_interval[/code] seconds, and +## [br]- immediately, if the view delay changed by at least +## [code]netfox/simulator/view_delay_change_threshold[/code] seconds since the +## last sample sent. +## [br]Changes smaller than that are just network jitter, and are picked up by +## the next periodic sample. A lost sample is fine too, the next one replaces it. +## [br]Both settings are in seconds, so they behave the same at any tick rate. +## They're converted to ticks once, in [method initialize]. +## [br][br] +## [b]Storing[/b]: the host keeps a per peer history of samples, mapping client +## ticks to view delays. This way every projectile is matched with the view +## delay at the tick it was fired, even if fire events from different ticks +## arrive at the same time, e.g. after a lag spike. History older than +## [code]netfox/simulator/history_limit[/code] ticks is dropped automatically. +## [br][br] +## Used by [_SimulatorServer] internally. + +# TODO: Detect lag spikes? During a spike we should use +# current tick - newest received tick as view delay instead? + +var _owner: Node = null +var _cmd_view_delay: _NetworkCommandServer.Command = null +var _history_size: int = 64 +var _logger := NetfoxLogger._for_netfox("ViewDelayTracker") + +# Client side: ticks between periodic samples, set in initialize(). +# netfox/simulator/view_delay_send_interval, rounded up to whole ticks. +# by default is = 1. +var _send_interval_ticks : int = 1 + +# Client side: view delay change that is sent immediately, set in initialize(). +# netfox/simulator/view_delay_change_threshold, rounded up to whole ticks. +# by default is = 2. +var _change_threshold_ticks : int = 2 + +# Client side: latest measured view delay, -1 if nothing measured yet +var _local_view_delay: int = -1 + +# Client side: newest simulator state tick received from the host +var _latest_received_tick: int = -1 + +# Client side: last sample sent, -1 if nothing sent yet +var _last_sent_tick: int = -1 +var _last_sent_view_delay: int = -1 + +# Host side: peer id -> _HistoryBuffer of client tick -> view delay +var _view_delay_histories: Dictionary[int, _HistoryBuffer] = {} + +## Initialize needs to be called by the owner node, from its _ready(). +## Commands must be registered in the same order on every peer, which is +## guaranteed for autoloads. +func initialize(owner: Node, command_server: _NetworkCommandServer, synchronization_server: _NetworkSynchronizationServer, history_size: int) -> void: + _owner = owner + _history_size = history_size + + var send_interval := ProjectSettings.get_setting("netfox/simulator/view_delay_send_interval", 0.25) as float + var change_threshold := ProjectSettings.get_setting("netfox/simulator/view_delay_change_threshold", 0.06) as float + + # At least 1 tick, in case the interval is shorter than a tick + _send_interval_ticks = maxi(1, ceili(send_interval / NetworkTime.ticktime)) + + # At least 2 ticks, so +/- 1 tick of jitter never counts as a change + _change_threshold_ticks = maxi(2, ceili(change_threshold / NetworkTime.ticktime)) + + _cmd_view_delay = command_server.register_command(_handle_view_delay, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) + + synchronization_server._on_simulator.connect(_on_simulator_state) + NetworkTime.after_tick_loop.connect(_send_view_delay) + + owner.multiplayer.peer_disconnected.connect(_on_peer_disconnected) + owner.multiplayer.connected_to_server.connect(_reset_client) + owner.multiplayer.server_disconnected.connect(_reset_client) + +## Returns the view delay [param peer] had at [param tick], or [param default] +## if there's no sample for it, e.g. the peer hasn't reported yet, or +## [param tick] is older than the history. +## [br][br] +## [param tick] is in the peer's ticks, e.g. the tick of its fire input. +func get_view_delay(peer: int, tick: int, default: int) -> int: + var history := _view_delay_histories.get(peer) as _HistoryBuffer + if history == null: + return default + + var view_delay: Variant = history.get_latest_at(tick) + if view_delay == null: + return default + + return view_delay as int + +# Client side, called for every simulator state received from the host. +func _on_simulator_state(snapshot: _Snapshot) -> void: + # Ignore states arriving out of order, the client already sees a newer one + if snapshot.tick < _latest_received_tick: + return + + _latest_received_tick = snapshot.tick + _local_view_delay = maxi(0, NetworkTime.tick - snapshot.tick) + +# Client side, called after every tick loop. Sends a sample if it's time for a +# periodic one, or if the view delay changed enough, see _change_threshold_ticks. +func _send_view_delay() -> void: + if _local_view_delay < 0: + # Nothing measured yet + return + + var multiplayer := _owner.multiplayer + if not multiplayer.has_multiplayer_peer() or multiplayer.is_server(): + return + + if multiplayer.multiplayer_peer.get_connection_status() != MultiplayerPeer.CONNECTION_CONNECTED: + return + + # This is the tick whose inputs are gathered next, so a fire input made + # right after this gets this tick + var tick := NetworkTime.tick + + var is_periodic := _last_sent_tick < 0 or tick - _last_sent_tick >= _send_interval_ticks + var has_changed := absi(_local_view_delay - _last_sent_view_delay) >= _change_threshold_ticks + if not is_periodic and not has_changed: + return + + var buffer := StreamPeerBuffer.new() + buffer.put_u32(tick) + buffer.put_u16(mini(_local_view_delay, 0xFFFF)) + _cmd_view_delay.send(buffer.data_array, MultiplayerPeer.TARGET_PEER_SERVER) + + _last_sent_tick = tick + _last_sent_view_delay = _local_view_delay + +# Host side, stores a sample reported by a client. +func _handle_view_delay(sender: int, data: PackedByteArray) -> void: + if not _owner.multiplayer.is_server(): + return + + if data.size() != 6: + _logger.warning("Received malformed view delay from peer #%d, ignoring", [sender]) + return + + var buffer := StreamPeerBuffer.new() + buffer.data_array = data + var tick := buffer.get_u32() + var view_delay := buffer.get_u16() + + # Ticks older than the history size are dropped by the buffer itself + var history := _view_delay_histories.get(sender) as _HistoryBuffer + if history == null: + history = _HistoryBuffer.new(_history_size) + _view_delay_histories[sender] = history + + history.set_at(tick, view_delay) + +func _on_peer_disconnected(peer: int) -> void: + _view_delay_histories.erase(peer) + +# Client side, start measuring from scratch on every new connection. +func _reset_client() -> void: + _local_view_delay = -1 + _latest_received_tick = -1 + _last_sent_tick = -1 + _last_sent_view_delay = -1 From 0b395e43cb66dd67c9db9e9727f902575ea21a05 Mon Sep 17 00:00:00 2001 From: TuysuzTavsan Date: Thu, 15 Oct 2026 19:43:09 +0300 Subject: [PATCH 48/57] dynamic simulation --- addons/netfox/input-sender.gd | 74 ++++++- addons/netfox/netfox.gd | 14 +- addons/netfox/network-time.gd | 5 +- addons/netfox/servers/input-sender-server.gd | 187 +++++++++++++++++- addons/netfox/servers/simulator-server.gd | 111 ++++++----- ...y-tracker.gd => simulator-time-tracker.gd} | 185 +++++++++++++---- .../scripts/server_side_tank.gd | 17 ++ 7 files changed, 498 insertions(+), 95 deletions(-) rename addons/netfox/{view-delay-tracker.gd => simulator-time-tracker.gd} (51%) diff --git a/addons/netfox/input-sender.gd b/addons/netfox/input-sender.gd index 1e971ae87..b3c131343 100644 --- a/addons/netfox/input-sender.gd +++ b/addons/netfox/input-sender.gd @@ -8,6 +8,40 @@ class_name InputSender ## [InputSender] is a multi purpose node to use on networked games, ## It provides signals to code host and client side logic. ## [InputSender] signals are tied and emitted on [signal NetworkTime.on_tick]. +## [br][br] +## [b]Predicting and merging inputs[/b]: inputs are gathered once per tick, +## the same way as for rollback. Sometimes netfox needs an input that was never +## gathered, or has to fit two gathered inputs into one tick: +## [br]- [b]predict[/b]: an input is needed for a tick, but there is none. This +## happens when a client's input lead grows and a stamp is skipped, and when +## the host simulates a player whose input didn't arrive in time. +## [br]- [b]merge[/b]: two inputs get the same stamp. This happens when a +## client's input lead shrinks. +## [br]Both are rare, see [_SimulatorTimeTracker]. By default, predicting +## copies the last input, and merging keeps the newer input. If the defaults +## don't fit, implement these methods in the [member root] node's script: +## [codeblock] +## # Return the input for a tick that has none. last is the latest input +## # before it. May be called for multiple ticks in a row with the same last. +## func _predict_input(last: Dictionary) -> Dictionary: +## var predicted := last.duplicate() +## predicted["Input:jump"] = false +## return predicted +## +## # Return a single input made from two inputs that got the same tick. +## # older was gathered one tick before newer. +## func _merge_input(older: Dictionary, newer: Dictionary) -> Dictionary: +## var merged := newer.duplicate() +## merged["Input:jump"] = older["Input:jump"] or newer["Input:jump"] +## return merged +## [/codeblock] +## Inputs are dictionaries, with the paths in [member input_properties] as keys, +## and the input values as values. Properties missing from the returned +## dictionary keep the value from [code]last[/code] or [code]newer[/code]. +## [br][br] +## For example, inputs that are only true for a single tick, like a jump press, +## are copied by default when predicting, and the press happens twice. When +## merging, the default drops the older press. ## ## @experimental: ## InputSenderServer assumes input snapshots arrive as whole. (atomic), if snapshot @@ -303,9 +337,43 @@ func _apply_snapshot_for_self(snapshot : _Snapshot) -> void: # TODO is this should be node.set_indexed ?? subject.set_indexed(property, value) -## Predicts inputs for given tick. -func predict_inputs() -> void: - pass +# Returns the current input, see "Predicting and merging inputs" in the class +# description. +func _get_input() -> Dictionary: + return _PropertySnapshot.extract(_property_entries).as_dictionary() + +# Applies the given input. Properties missing from it are left unchanged. +func _set_input(input: Dictionary) -> void: + for property_entry in _property_entries: + var path := property_entry.to_string() + if input.has(path): + property_entry.set_value(input[path]) + +# Returns the predicted input for a tick that has none, using the root node's +# _predict_input() if it has one, see the class description. +func _get_predicted_input(last: Dictionary) -> Dictionary: + if not root or not root.has_method("_predict_input"): + return last.duplicate() + + var predicted: Variant = root.call("_predict_input", last.duplicate()) + if not predicted is Dictionary: + _logger.error("_predict_input() of %s must return a Dictionary, copying the last input instead", [root.name]) + return last.duplicate() + + return last.merged(predicted as Dictionary, true) + +# Returns a single input made from two inputs, using the root node's +# _merge_input() if it has one, see the class description. +func _get_merged_input(older: Dictionary, newer: Dictionary) -> Dictionary: + if not root or not root.has_method("_merge_input"): + return newer.duplicate() + + var merged: Variant = root.call("_merge_input", older.duplicate(), newer.duplicate()) + if not merged is Dictionary: + _logger.error("_merge_input() of %s must return a Dictionary, keeping the newer input instead", [root.name]) + return newer.duplicate() + + return newer.merged(merged as Dictionary, true) # Helper function to save current input_properties. # Used internally by InputSenderServer/SimulatorServer to record state before diff --git a/addons/netfox/netfox.gd b/addons/netfox/netfox.gd index f5fdd58d6..ebd5aa9dc 100644 --- a/addons/netfox/netfox.gd +++ b/addons/netfox/netfox.gd @@ -209,16 +209,20 @@ var SETTINGS: Array[Dictionary] = [ "value": 64, "type" : TYPE_INT }, - { - "name": "netfox/simulator/host_delay_ticks", - "value": 8, - "type" : TYPE_INT - }, { "name": "netfox/simulator/max_view_delay_ticks", "value": 30, "type" : TYPE_INT }, + { + # Clients stamp their inputs ahead by rtt / 2 + jitter + this margin, + # in seconds, so inputs arrive at the host before they're needed + "name": "netfox/simulator/input_lead_margin", + "value": 0.1, + "type": TYPE_FLOAT, + "hint": PROPERTY_HINT_RANGE, + "hint_string": "0,1,or_greater" + }, { # Seconds between periodic view delay samples sent by clients "name": "netfox/simulator/view_delay_send_interval", diff --git a/addons/netfox/network-time.gd b/addons/netfox/network-time.gd index e46d93722..001ed9a64 100644 --- a/addons/netfox/network-time.gd +++ b/addons/netfox/network-time.gd @@ -614,7 +614,10 @@ func _after_tick_loop() -> void: # Restore state for Simulator before emitting after_tick_loop so that # listeners can run logic after state is set. - NetworkHistoryServer._restore_simulator(tick) + # Local authoritative simulators are predicted up to tick + input lead, so + # restore up to there. Other subjects only have states received from the + # host, which are never newer than tick, so this changes nothing for them. + NetworkHistoryServer._restore_simulator(tick + SimulatorServer._get_input_lead()) # Emit signal after_tick_loop.emit() diff --git a/addons/netfox/servers/input-sender-server.gd b/addons/netfox/servers/input-sender-server.gd index 4e23499c8..929e6cfcb 100644 --- a/addons/netfox/servers/input-sender-server.gd +++ b/addons/netfox/servers/input-sender-server.gd @@ -17,6 +17,22 @@ var _synchronization_server : NetworkSynchronizationServer = null var _missing_inputs_history_size : int = ProjectSettings.get_setting("netfox/input_sender/missing_input_history", 16) var _input_sender_history_size : int = ProjectSettings.get_setting("netfox/input_sender/history_limit", 64) +# A lead change skips at most this many stamps. A bigger jump means time +# jumped forward, e.g. after a pause, and the skipped stamps aren't filled. +const MAX_FILLED_STAMPS := 4 + +# Latest tick our own inputs were recorded under, -1 if nothing recorded yet. +# Inputs are stamped with tick + input lead, see _SimulatorTimeTracker. +var _last_recorded_tick : int = -1 + +# Stamp of our latest gathered input that local_input wasn't emitted for yet, +# -1 if none. Filled stamps are predicted, not gathered, so they're not here. +var _pending_local_input_tick : int = -1 + +# Inputs held back for a tick because the input lead shrank, see +# _record_and_send_inputs. Maps InputSender -> input Dictionary. +var _held_inputs : Dictionary[InputSender, Dictionary] = {} + # Ticks that we received new input snapshots. var _ticks_that_has_new_snapshot : Array[int] = [] @@ -34,10 +50,10 @@ func _ready(): # This means local players wont have input information for their first tick. # Its not really like await processframe but this will only add slight delay that wont have # Important effects on the game itself. - NetworkTime.after_tick.connect(func(_dt, tick): - _history_server._record_input_sender(tick) - _synchronization_server._synchronize_input_sender(tick) - ) + NetworkTime.after_tick.connect(_record_and_send_inputs) + + # Ticks start over when NetworkTime starts, so do the stamps. + NetworkTime.after_sync.connect(_reset_stamps) _synchronization_server._on_input_sender.connect(_on_received_input_snapshot) @@ -88,8 +104,13 @@ func _on_tick(_delta : float, tick : int) -> void: _handle_missing_tick(missing_tick) # DANGER - # We do tick -1 for current local inputs, this is explained on _ready comments. - _handle_local_inputs(tick - 1) + # We use the latest recorded inputs, recorded on the previous after_tick. + # This is explained on _ready comments. + # Only once per gathered input: when an input is held back, nothing new + # was recorded, see _record_and_send_inputs. + if _pending_local_input_tick >= 0: + _handle_local_inputs(_pending_local_input_tick) + _pending_local_input_tick = -1 # Logic is done, restore input-sender states. _restore_input_sender_states() @@ -208,11 +229,165 @@ func _restore_input_sender_states() -> void: for input_sender in _per_input_sender_history.keys(): input_sender._restore_properties() +# Records our own inputs and sends them to the host. Called after every tick. +# +# Host: the lead is always 0, so the input gathered on tick T is stamped T. +# Ticks go up by one, so every tick gets a new stamp, and none of the lead +# handling below happens. The only exception is a pause, where time jumps. +# +# Client: the input gathered on tick T is stamped T + lead, so it reaches the +# host before the host simulates that tick, see _SimulatorTimeTracker. While +# the lead stays the same, stamps go up by one every tick, just like ticks: +# +# tick | lead | stamp | sent +# 100 | 5 | 105 | input of 100 +# 101 | 5 | 106 | input of 101 +# +# Lead grows (5 -> 6): tick 102 is stamped 108, so stamp 107 gets no input. +# The host needs an input for every tick it simulates, so 107 gets an input +# predicted from 106, then 108 gets the input of tick 102: +# +# tick | lead | stamp | sent +# 102 | 6 | 108 | predicted input under 107, input of 102 under 108 +# +# Lead shrinks (6 -> 5): tick 201 was stamped 207, and tick 202 would be +# stamped 207 too. What to do with the input of tick 202? +# - Send it under 207: no, 207 was already sent, and the host never lets a +# client change an input it already has (cheating). +# - Send it under 208: no, tick 203 is stamped 208, so the clash just moves +# to the next tick, and the next, forever. +# - Drop it: no, it may contain e.g. a fire press, which would be lost. +# A smaller lead means one stamp less, so two gathered inputs have to share +# one stamp. So we hold the input of tick 202 back, send nothing on this tick, +# and on the next tick send it merged with the input of tick 203 under 208: +# +# tick | lead | stamp | sent +# 201 | 6 | 207 | input of 201 +# 202 | 5 | 207 | nothing, input of 202 held back +# 203 | 5 | 208 | input of 202 merged with input of 203 +# +# The lead changes by at most 1 tick per second, so this is rare. Predicting +# and merging is done by InputSender, so users can customize it. +func _record_and_send_inputs(_delta: float, tick: int) -> void: + var stamped_tick := tick + SimulatorServer._get_input_lead() + + if _last_recorded_tick < 0: + # First input, nothing to continue from + _record_inputs(stamped_tick) + return + + var next_tick := _last_recorded_tick + 1 + var skipped := stamped_tick - next_tick + + if skipped == 0: + # Lead didn't change + _record_inputs(stamped_tick) + elif skipped == -1: + # Lead shrank, this stamp is taken already. Hold this input back, and + # merge it into the next one. + _hold_inputs() + elif skipped < -1: + # Ticks went backwards, which only happens when NetworkTime restarts, + # and we reset on that. Don't hold inputs forever, start over. + _logger.warning( + "Input stamp went back from @%d to @%d, starting over", + [_last_recorded_tick, stamped_tick]) + _held_inputs.clear() + _record_inputs(stamped_tick) + elif skipped > MAX_FILLED_STAMPS: + # Time jumped forward, e.g. after a pause, there's nothing to fill. + _logger.debug( + "Input stamp jumped from @%d to @%d, not filling", + [_last_recorded_tick, stamped_tick]) + _record_inputs(stamped_tick) + else: + # Lead grew, fill the skipped stamps with predicted inputs + _record_predicted_inputs(next_tick, stamped_tick) + _record_inputs(stamped_tick) + +# Records and sends the gathered inputs under the given stamp. If any inputs +# were held back, they're merged into the gathered ones first. +func _record_inputs(stamped_tick: int) -> void: + if _held_inputs.is_empty(): + _record_and_send(stamped_tick) + else: + var merged_inputs : Dictionary[InputSender, Dictionary] = {} + for input_sender in _held_inputs: + if is_instance_valid(input_sender): + merged_inputs[input_sender] = input_sender._get_merged_input( + _held_inputs[input_sender], input_sender._get_input()) + _held_inputs.clear() + + _record_and_send_with(stamped_tick, merged_inputs) + + _pending_local_input_tick = stamped_tick + +# Records and sends predicted inputs under the stamps from from_tick to +# to_tick (exclusive). All of them are predicted from the inputs recorded under +# the stamp before from_tick, same as the host does for missing inputs. +func _record_predicted_inputs(from_tick: int, to_tick: int) -> void: + var predicted_inputs : Dictionary[InputSender, Dictionary] = {} + + for input_sender in _get_local_input_senders(): + # Apply the inputs recorded under the previous stamp to read them + input_sender._save_properties() + _history_server._restore_input_sender_subjects( + input_sender._input_properties.get_subjects(), from_tick - 1) + var last := input_sender._get_input() + input_sender._restore_properties() + + predicted_inputs[input_sender] = input_sender._get_predicted_input(last) + + for stamped_tick in range(from_tick, to_tick): + _record_and_send_with(stamped_tick, predicted_inputs) + +# Holds back the gathered inputs, to be merged into the next ones. +func _hold_inputs() -> void: + for input_sender in _get_local_input_senders(): + if _held_inputs.has(input_sender): + # Already holding one, shouldn't happen since the lead changes + # rarely. Merge, so nothing is lost. + _held_inputs[input_sender] = input_sender._get_merged_input( + _held_inputs[input_sender], input_sender._get_input()) + else: + _held_inputs[input_sender] = input_sender._get_input() + +# Applies the given inputs, records and sends them under the given stamp, then +# puts back the gathered inputs, so input scripts find them as they left them. +func _record_and_send_with(stamped_tick: int, inputs: Dictionary[InputSender, Dictionary]) -> void: + for input_sender in inputs: + input_sender._save_properties() + input_sender._set_input(inputs[input_sender]) + + _record_and_send(stamped_tick) + + for input_sender in inputs: + input_sender._restore_properties() + +func _record_and_send(stamped_tick: int) -> void: + _history_server._record_input_sender(stamped_tick) + _synchronization_server._synchronize_input_sender(stamped_tick) + _last_recorded_tick = stamped_tick + +# InputSenders whose inputs we gather and send. +func _get_local_input_senders() -> Array[InputSender]: + var result : Array[InputSender] = [] + for input_sender in _per_input_sender_history.keys(): + if input_sender.has_authority_over_input_nodes(): + result.push_back(input_sender) + return result + +func _reset_stamps() -> void: + _last_recorded_tick = -1 + _pending_local_input_tick = -1 + _held_inputs.clear() + func _register_input_sender(input_sender : InputSender) -> void: _per_input_sender_history[input_sender] = InputSenderHistory.new() func _deregister_input_sender(input_sender : InputSender) -> void: _per_input_sender_history.erase(input_sender) + _held_inputs.erase(input_sender) # Helper that sets input received for param input sender on given tick. func _set_input_received_for_input_sender(input_sender : InputSender, tick : int) -> void: diff --git a/addons/netfox/servers/simulator-server.gd b/addons/netfox/servers/simulator-server.gd index 001a2266d..e5ee51441 100644 --- a/addons/netfox/servers/simulator-server.gd +++ b/addons/netfox/servers/simulator-server.gd @@ -11,6 +11,11 @@ class_name _SimulatorServer ## Insight ## +## Time model: the host's tick is the truth. The host simulates everyone, its +## own player and remote players, at its current tick, using the inputs of the +## previous tick. Clients stamp their inputs ahead of their own tick (input +## lead), so the inputs arrive before the host needs them. Clients predict +## their own player up to tick + input lead. See [_SimulatorTimeTracker]. ## ## Depending on simulator authority there are 4 options we can operate on: ## @@ -26,15 +31,16 @@ class_name _SimulatorServer ## - Broadcast it togather with 2. ## ## 2- local authoritative simulator: -## - We have inputs available locally up to tick -1. +## - We have inputs available locally up to tick + input lead - 1. ## (input-sender inputs are recorded on after tick). ## - Apply the latest authoritative state received from host. (the truth) -## - Iterate over inputs and reach the current tick - 1. +## - Iterate over inputs and reach tick + input lead. ## ## 3- host puppet. -## - If we dont have inputs buffered yet, dont run the simulation, simply skip. -## - If we have inputs buffered, run the simulation with indexed inputs for 1 simulation tick. -## - Record the simulator state for given simulation tick. +## - Run the simulation for the current tick, with the inputs of tick - 1. +## Clients stamp inputs ahead, so they are usually here already. +## - If inputs are missing, predict them, see InputSender. +## - Record the simulator state for the current tick. ## - Broadcast them. ## ## 4- client puppet. @@ -52,9 +58,6 @@ var _logger := NetfoxLogger._for_netfox("SimulatorServer") # History size for simulation. var _simulation_history_size : int = ProjectSettings.get_setting("netfox/simulator/history_limit", 64) -# Host side buffering/delay tick count for simulation. -var _simulation_host_delay_ticks : int = ProjectSettings.get_setting("netfox/simulator/host_delay_ticks", 8) - # Maximum view delay accepted from clients, in ticks. Limits how far into the # past a client can rewind when firing, see register_projectile. var _max_view_delay_ticks : int = ProjectSettings.get_setting("netfox/simulator/max_view_delay_ticks", 30) @@ -68,9 +71,9 @@ var _living_projectiles : Array[Node] = [] # Helper to record and restore simulation aware collision-shapes/areas. var _simulated_collision_recorder : _SimulatedCollisionRecorder = _SimulatedCollisionRecorder.new() -# Helper to track how many ticks behind each client sees the host's simulation. -# Used to find the tick a projectile was fired at, see register_projectile. -var _view_delay_tracker : _ViewDelayTracker = _ViewDelayTracker.new() +# Helper to track how each client's time relates to the host's: input lead and +# view delay. See _SimulatorTimeTracker. +var _time_tracker : _SimulatorTimeTracker = _SimulatorTimeTracker.new() # Fresh registered projectiles. # Mapped by tick -> array of projectiles (type of Node) that are estimated to be fired on mapped tick. @@ -94,7 +97,7 @@ func _ready(): if not _synchronization_server: _synchronization_server = NetworkSynchronizationServer _simulated_collision_recorder.initialize(get_tree()) - _view_delay_tracker.initialize(self, NetworkCommandServer, _synchronization_server, _simulation_history_size) + _time_tracker.initialize(self, NetworkCommandServer, _synchronization_server, _simulation_history_size) # Register a simulator node. # Will check for authority over inputs and categorize by it. @@ -129,7 +132,7 @@ func deregister_simulator(simulator : Simulator) -> void: ## the host's current tick. ## [br][br] ## The projectile is caught up from the tick of the host's simulation that the -## firing peer was seeing at [param fired_tick], see [_ViewDelayTracker]. The +## firing peer was seeing at [param fired_tick], see [_SimulatorTimeTracker]. The ## rewind is limited by [code]netfox/simulator/max_view_delay_ticks[/code]. func register_projectile(projectile : Node, firing_peer_id : int, fired_tick : int) -> void: @@ -174,23 +177,26 @@ func register_projectile(projectile : Node, firing_peer_id : int, fired_tick : i # Returns how many ticks behind the given peer saw the host's simulation at # the given tick of the peer. func _get_view_delay_for(peer: int, tick: int) -> int: - # The host sees other players where it simulates them, host_delay_ticks - # behind. This also covers offline play. + # The host sees everyone at its current tick, nothing to rewind. + # This also covers offline play. if peer == multiplayer.get_unique_id(): - return _simulation_host_delay_ticks + return 0 - var view_delay := _view_delay_tracker.get_view_delay(peer, tick, -1) + var view_delay := _time_tracker.get_view_delay(peer, tick, -1) if view_delay < 0: - # No sample for this tick, e.g. the peer just connected. It can't see - # the host's simulation any sooner than the host simulates it. - _logger.debug("No view delay from peer #%s @%d, using host delay", [peer, tick]) - return _simulation_host_delay_ticks + # No sample for this tick, e.g. the peer just connected. Without a + # sample, don't rewind at all. + _logger.debug("No view delay from peer #%s @%d, not rewinding", [peer, tick]) + return 0 # The view delay is reported by the client, so don't trust it blindly. - # It can't be less than host_delay_ticks, and can't rewind further than - # max_view_delay_ticks. - var max_view_delay := maxi(_simulation_host_delay_ticks, _max_view_delay_ticks) - return clampi(view_delay, _simulation_host_delay_ticks, max_view_delay) + # It can't rewind further than max_view_delay_ticks. + return clampi(view_delay, 0, maxi(0, _max_view_delay_ticks)) + +# Returns how many ticks ahead we stamp our inputs. Always 0 on the host. +# Used by InputSenderServer and NetworkTime internally. +func _get_input_lead() -> int: + return _time_tracker.get_input_lead() func _after_tick(tick : int) -> void: @@ -202,12 +208,13 @@ func _after_tick(tick : int) -> void: _step_living_projectiles(tick) - if NetworkTime.tick - _simulation_host_delay_ticks >= 0: - # History server only records owned simulator state properties. - _history_server._record_simulator(NetworkTime.tick - _simulation_host_delay_ticks) - _synchronization_server._synchronize_simulator(NetworkTime.tick - _simulation_host_delay_ticks) - - _simulated_collision_recorder.record_tick(NetworkTime.tick - _simulation_host_delay_ticks) + # Everything was simulated at the current tick, so everything is recorded + # and sent under the current tick. + # History server only records owned simulator state properties. + _history_server._record_simulator(tick) + _synchronization_server._synchronize_simulator(tick) + + _simulated_collision_recorder.record_tick(tick) # Trim old history that we dont need. var trim_tick := tick - _simulation_history_size @@ -250,6 +257,10 @@ func _handle_host_simulators() -> void: func _handle_local_authoritative_simulators() -> void: var current_tick := NetworkTime.tick + # Our inputs are stamped this far ahead, so we predict this far ahead too. + # Our own player is shown at current_tick + input_lead. + var predicted_tick := current_tick + _get_input_lead() + for simulator in _local_authoritative_simulators: var input_subjects := simulator.listened_input_sender._input_properties.get_subjects() @@ -271,12 +282,12 @@ func _handle_local_authoritative_simulators() -> void: ) # Apply inputs of the previous tick. - _history_server._restore_input_sender_subjects(input_subjects, current_tick - 1) + _history_server._restore_input_sender_subjects(input_subjects, predicted_tick - 1) - var is_fresh := _is_tick_fresh_for(simulator, current_tick) - simulator._run_simulation(NetworkTime.ticktime, current_tick, is_fresh) - _history_server._record_simulator_subjects(state_subjects, current_tick) - _set_tick_simulated_for(simulator, current_tick) + var is_fresh := _is_tick_fresh_for(simulator, predicted_tick) + simulator._run_simulation(NetworkTime.ticktime, predicted_tick, is_fresh) + _history_server._record_simulator_subjects(state_subjects, predicted_tick) + _set_tick_simulated_for(simulator, predicted_tick) # Restore messed up properties. simulator.listened_input_sender._restore_properties() @@ -285,7 +296,7 @@ func _handle_local_authoritative_simulators() -> void: # Apply truth from history, which has diffs merged into full state. _history_server._restore_simulator_subjects(state_subjects, latest_truth_tick) - for i in range(latest_truth_tick + 1, current_tick + 1): + for i in range(latest_truth_tick + 1, predicted_tick + 1): # Apply inputs of the previous tick. If there's no input recorded # for it, the latest input before it is applied. _history_server._restore_input_sender_subjects(input_subjects, i - 1) @@ -299,14 +310,15 @@ func _handle_local_authoritative_simulators() -> void: simulator.listened_input_sender._restore_properties() ## 3- host puppet. -## - If we dont have inputs buffered yet, dont run the simulation, simply skip. -## - If we have inputs buffered, run the simulation with indexed inputs for 1 simulation tick. -## - Record the simulator state for given simulation tick. +## - Run the simulation for the current tick, with the inputs of tick - 1. +## - If inputs are missing, predict them, see InputSender. +## - Record the simulator state for the current tick. ## - Broadcast them. func _handle_host_puppet_simulators() -> void: var current_tick := NetworkTime.tick - var simulated_tick := current_tick - _simulation_host_delay_ticks + # Host puppets are simulated at the current tick, like everything else. + var simulated_tick := current_tick # Simulating a tick uses the inputs of the tick before it, so the first # tick with any inputs to simulate is 1. Before that, simulated_tick - 1 is @@ -337,10 +349,15 @@ func _handle_host_puppet_simulators() -> void: simulator._run_simulation(NetworkTime.ticktime, simulated_tick, is_fresh) else: - # We need to predict this frame. - _logger.warning("No buffered input found, predicting inputs.") + # The input didn't arrive in time, predict it from the latest one we + # have. Same as a client filling a skipped stamp, see InputSender. + _logger.debug("No input for @%d, predicting it", [simulated_tick - 1]) + + var input_sender := simulator.listened_input_sender + _history_server._restore_input_sender_subjects( + input_sender._input_properties.get_subjects(), simulated_tick - 1) + input_sender._set_input(input_sender._get_predicted_input(input_sender._get_input())) - simulator.listened_input_sender.predict_inputs() simulator._run_simulation(NetworkTime.ticktime, simulated_tick, is_fresh) _set_tick_simulated_for(simulator, simulated_tick) @@ -348,7 +365,7 @@ func _handle_host_puppet_simulators() -> void: # Steps every already-caught-up living projectile by a single tick. # No need to restore world state for living projectiles. func _step_living_projectiles(current_tick: int) -> void: - var simulated_tick := current_tick - _simulation_host_delay_ticks + var simulated_tick := current_tick # Iterate in reverse because we are also removing. var i := _living_projectiles.size() - 1 @@ -371,12 +388,12 @@ func _step_living_projectiles(current_tick: int) -> void: i -= 1 -# Steps the freshly registered projectiles up to current_tick - _simulation_host_delay_ticks (not included). +# Steps the freshly registered projectiles up to current_tick (not included). func _catch_up_fresh_projectiles(current_tick: int) -> void: if _fresh_registered_projectiles_by_tick.is_empty(): return - var simulated_tick := current_tick - _simulation_host_delay_ticks + var simulated_tick := current_tick var oldest_tick : int = _fresh_registered_projectiles_by_tick.keys().min() # Ensure oldest tick is not older than our history window. diff --git a/addons/netfox/view-delay-tracker.gd b/addons/netfox/simulator-time-tracker.gd similarity index 51% rename from addons/netfox/view-delay-tracker.gd rename to addons/netfox/simulator-time-tracker.gd index 7b83bfb5c..26f59d0da 100644 --- a/addons/netfox/view-delay-tracker.gd +++ b/addons/netfox/simulator-time-tracker.gd @@ -1,20 +1,57 @@ extends RefCounted -class_name _ViewDelayTracker +class_name _SimulatorTimeTracker -## Helper class for tracking how many ticks behind each client sees the host's -## simulation. +## Helper class for keeping track of how each client's time relates to the +## host's: the [b]input lead[/b] and the [b]view delay[/b]. ## -## [b]View delay[/b] is the number of ticks between a client's current tick and -## the tick of the host's simulation that the client is seeing. When a client -## fires at tick [code]t[/code], it was seeing the host's simulation at tick +## [b]Time model[/b]: the host's tick is the truth, the host simulates everyone +## at its current tick. Clients stamp their inputs some ticks ahead of their +## own tick, so the inputs arrive at the host before the host needs them. How +## far ahead is the input lead. +## [br][br] +## [b]Input lead[/b] is computed by each client on its own, from the round +## trip time measured by time synchronization: +## [codeblock] +## lead = ceil((rtt / 2 + rtt_jitter + margin) / ticktime) +## [/codeblock] +## where margin is [code]netfox/simulator/input_lead_margin[/code], in +## seconds. Clients with a good connection get a small lead, clients with a bad +## connection a big one, each only pays for its own connection. The host's own +## inputs need no lead, so it is always 0 on the host. +## [br][br] +## The lead is set once time synchronization is done, then moves by at most 1 +## tick every [constant LEAD_CHANGE_INTERVAL] seconds. No input is lost or +## sent twice when it changes, see [_InputSenderServer]: +## [br]- growing by 1 skips a stamp, which is filled with a predicted input, +## see [code]_predict_input[/code] in [InputSender]. +## [br]- shrinking by 1 would stamp two inputs with the same tick, so the +## second one is held for a tick and merged into the next one, see +## [code]_merge_input[/code] in [InputSender]. +## [br][br] +## [b]View delay[/b] is the number of ticks between the tick a client stamps +## its inputs with ([code]tick + lead[/code]) and the tick of the host's +## simulation that the client is seeing. When a client fires an input stamped +## [code]t[/code], it was seeing the host's simulation at tick ## [code]t - view_delay[/code]. [_SimulatorServer] uses this to find the tick ## to rewind to when registering projectiles. ## [br][br] +## [b]Why the lead is part of the view delay[/b]: the host only knows the +## stamped tick of a fire input, so the view delay has to use the same +## numbers. Example, with a lead of 5: +## [codeblock] +## client tick when firing | 100 +## newest state the client sees | 97 +## fire input stamped | 100 + 5 = 105 +## view delay without lead | 100 - 97 = 3 -> host rewinds to 102, wrong +## view delay with lead | 105 - 97 = 8 -> host rewinds to 97, right +## [/codeblock] +## [br] ## [b]Measuring[/b]: clients measure their view delay every time a simulator ## state arrives from the host, as [code]current tick - state tick[/code], and -## keep that value until the next state arrives. Full states arrive at least -## every [code]netfox/simulator/full_state_interval[/code] ticks, which keeps -## the measurement fresh. +## keep that value until the next state arrives. The lead is added when the +## sample is sent. Full states arrive at least every +## [code]netfox/simulator/full_state_interval[/code] ticks, which keeps the +## measurement fresh. ## [br][br] ## [b]Why on arrival[/b]: with diff states, the host sends nothing while ## nothing changes. Computing [code]current tick - newest received tick[/code] @@ -36,9 +73,9 @@ class_name _ViewDelayTracker ## The downside is lag spikes: if states are stuck on the network instead, ## the client can't tell that apart from "nothing changed", see the TODO below. ## [br][br] -## [b]Sending[/b]: clients send [code](tick, view delay)[/code] samples to the -## host, 6 bytes each, on an unreliable command. To keep traffic low, a sample -## is sent: +## [b]Sending[/b]: clients send [code](stamped tick, view delay)[/code] +## samples to the host, 6 bytes each, on an unreliable command. To keep traffic +## low, a sample is sent: ## [br]- periodically, every ## [code]netfox/simulator/view_delay_send_interval[/code] seconds, and ## [br]- immediately, if the view delay changed by at least @@ -49,10 +86,10 @@ class_name _ViewDelayTracker ## [br]Both settings are in seconds, so they behave the same at any tick rate. ## They're converted to ticks once, in [method initialize]. ## [br][br] -## [b]Storing[/b]: the host keeps a per peer history of samples, mapping client -## ticks to view delays. This way every projectile is matched with the view -## delay at the tick it was fired, even if fire events from different ticks -## arrive at the same time, e.g. after a lag spike. History older than +## [b]Storing[/b]: the host keeps a per peer history of samples, mapping +## stamped ticks to view delays. This way every projectile is matched with the +## view delay at the tick it was fired, even if fire events from different +## ticks arrive at the same time, e.g. after a lag spike. History older than ## [code]netfox/simulator/history_limit[/code] ticks is dropped automatically. ## [br][br] ## Used by [_SimulatorServer] internally. @@ -60,10 +97,29 @@ class_name _ViewDelayTracker # TODO: Detect lag spikes? During a spike we should use # current tick - newest received tick as view delay instead? +## The input lead moves by at most 1 tick this often, in seconds, see the +## class description. +const LEAD_CHANGE_INTERVAL := 1.0 + var _owner: Node = null var _cmd_view_delay: _NetworkCommandServer.Command = null var _history_size: int = 64 -var _logger := NetfoxLogger._for_netfox("ViewDelayTracker") +var _logger := NetfoxLogger._for_netfox("SimulatorTimeTracker") + +# Client side: netfox/simulator/input_lead_margin, set in initialize(). +var _input_lead_margin : float = 0.1 + +# Client side: LEAD_CHANGE_INTERVAL in ticks, set in initialize(). +var _lead_change_interval_ticks : int = 1 + +# Client side: current input lead in ticks, 0 until round trip time is known +var _input_lead : int = 0 + +# Client side: whether the lead jumped to its first target yet +var _has_input_lead : bool = false + +# Client side: tick of the last lead change, -1 if never changed +var _last_lead_change_tick : int = -1 # Client side: ticks between periodic samples, set in initialize(). # netfox/simulator/view_delay_send_interval, rounded up to whole ticks. @@ -85,7 +141,7 @@ var _latest_received_tick: int = -1 var _last_sent_tick: int = -1 var _last_sent_view_delay: int = -1 -# Host side: peer id -> _HistoryBuffer of client tick -> view delay +# Host side: peer id -> _HistoryBuffer of stamped tick -> view delay var _view_delay_histories: Dictionary[int, _HistoryBuffer] = {} ## Initialize needs to be called by the owner node, from its _ready(). @@ -95,6 +151,9 @@ func initialize(owner: Node, command_server: _NetworkCommandServer, synchronizat _owner = owner _history_size = history_size + _input_lead_margin = ProjectSettings.get_setting("netfox/simulator/input_lead_margin", 0.1) as float + _lead_change_interval_ticks = maxi(1, ceili(LEAD_CHANGE_INTERVAL / NetworkTime.ticktime)) + var send_interval := ProjectSettings.get_setting("netfox/simulator/view_delay_send_interval", 0.25) as float var change_threshold := ProjectSettings.get_setting("netfox/simulator/view_delay_change_threshold", 0.06) as float @@ -107,17 +166,25 @@ func initialize(owner: Node, command_server: _NetworkCommandServer, synchronizat _cmd_view_delay = command_server.register_command(_handle_view_delay, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) synchronization_server._on_simulator.connect(_on_simulator_state) - NetworkTime.after_tick_loop.connect(_send_view_delay) + NetworkTime.after_sync.connect(_on_after_sync) + NetworkTime.after_tick_loop.connect(_after_tick_loop) owner.multiplayer.peer_disconnected.connect(_on_peer_disconnected) owner.multiplayer.connected_to_server.connect(_reset_client) owner.multiplayer.server_disconnected.connect(_reset_client) +## Returns how many ticks ahead this peer stamps its inputs. Always 0 on the +## host. Only changes between tick loops, so it's the same for a whole loop. +func get_input_lead() -> int: + if _owner.multiplayer.is_server(): + return 0 + return _input_lead + ## Returns the view delay [param peer] had at [param tick], or [param default] ## if there's no sample for it, e.g. the peer hasn't reported yet, or ## [param tick] is older than the history. ## [br][br] -## [param tick] is in the peer's ticks, e.g. the tick of its fire input. +## [param tick] is a tick stamped by the peer, e.g. the tick of its fire input. func get_view_delay(peer: int, tick: int, default: int) -> int: var history := _view_delay_histories.get(peer) as _HistoryBuffer if history == null: @@ -138,36 +205,85 @@ func _on_simulator_state(snapshot: _Snapshot) -> void: _latest_received_tick = snapshot.tick _local_view_delay = maxi(0, NetworkTime.tick - snapshot.tick) -# Client side, called after every tick loop. Sends a sample if it's time for a -# periodic one, or if the view delay changed enough, see _change_threshold_ticks. -func _send_view_delay() -> void: - if _local_view_delay < 0: - # Nothing measured yet +# Client side, called when NetworkTime starts ticking. Round trip time is known +# by then, so the lead is set before the first input is stamped. +func _on_after_sync() -> void: + _input_lead = 0 + _has_input_lead = false + _last_lead_change_tick = -1 + + if _is_connected_client(): + _update_input_lead() + +# Client side, called after every tick loop. The lead is updated here, so it +# stays the same for a whole tick loop. +func _after_tick_loop() -> void: + if not _is_connected_client(): return - var multiplayer := _owner.multiplayer - if not multiplayer.has_multiplayer_peer() or multiplayer.is_server(): + _update_input_lead() + _send_view_delay() + +# Client side, moves the input lead towards its target, see class description. +func _update_input_lead() -> void: + if not NetworkTime.is_initial_sync_done(): + # Round trip time not measured yet + return + + var rtt := NetworkTimeSynchronizer.rtt + var one_way := rtt / 2.0 + NetworkTimeSynchronizer.rtt_jitter + _input_lead_margin + var target := ceili(one_way / NetworkTime.ticktime) + + if not _has_input_lead: + # First measurement, jump straight to it + _input_lead = target + _has_input_lead = true + _last_lead_change_tick = NetworkTime.tick + _logger.debug("Input lead set to %d ticks", [_input_lead]) return - if multiplayer.multiplayer_peer.get_connection_status() != MultiplayerPeer.CONNECTION_CONNECTED: + if target == _input_lead: + return + + if NetworkTime.tick - _last_lead_change_tick < _lead_change_interval_ticks: + return + + _input_lead += signi(target - _input_lead) + _last_lead_change_tick = NetworkTime.tick + _logger.debug("Input lead changed to %d ticks, target %d", [_input_lead, target]) + +# Client side, sends a sample if it's time for a periodic one, or if the view +# delay changed enough, see _change_threshold_ticks. +func _send_view_delay() -> void: + if _local_view_delay < 0: + # Nothing measured yet return # This is the tick whose inputs are gathered next, so a fire input made - # right after this gets this tick - var tick := NetworkTime.tick + # right after this gets this tick stamped with the lead. Both the tick and + # the view delay are in stamped tick numbers, see class description. + var tick := NetworkTime.tick + _input_lead + var view_delay := _local_view_delay + _input_lead var is_periodic := _last_sent_tick < 0 or tick - _last_sent_tick >= _send_interval_ticks - var has_changed := absi(_local_view_delay - _last_sent_view_delay) >= _change_threshold_ticks + var has_changed := absi(view_delay - _last_sent_view_delay) >= _change_threshold_ticks if not is_periodic and not has_changed: return var buffer := StreamPeerBuffer.new() buffer.put_u32(tick) - buffer.put_u16(mini(_local_view_delay, 0xFFFF)) + buffer.put_u16(mini(view_delay, 0xFFFF)) _cmd_view_delay.send(buffer.data_array, MultiplayerPeer.TARGET_PEER_SERVER) _last_sent_tick = tick - _last_sent_view_delay = _local_view_delay + _last_sent_view_delay = view_delay + +func _is_connected_client() -> bool: + var multiplayer := _owner.multiplayer + if not multiplayer.has_multiplayer_peer() or multiplayer.is_server(): + return false + + return multiplayer.multiplayer_peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTED # Host side, stores a sample reported by a client. func _handle_view_delay(sender: int, data: PackedByteArray) -> void: @@ -198,5 +314,8 @@ func _on_peer_disconnected(peer: int) -> void: func _reset_client() -> void: _local_view_delay = -1 _latest_received_tick = -1 + _input_lead = 0 + _has_input_lead = false + _last_lead_change_tick = -1 _last_sent_tick = -1 _last_sent_view_delay = -1 diff --git a/examples/server-side-vehicle/scripts/server_side_tank.gd b/examples/server-side-vehicle/scripts/server_side_tank.gd index 577fef997..56db3eb11 100644 --- a/examples/server-side-vehicle/scripts/server_side_tank.gd +++ b/examples/server-side-vehicle/scripts/server_side_tank.gd @@ -107,6 +107,23 @@ func _fire(tick : int) -> void: shell.global_transform = shell_spawn_point.global_transform shell.firing_tank = self +# Called by InputSender when an input has to be predicted, see InputSender. +# Firing only lasts a single tick, and mouse movement is a per tick delta, so +# repeating them would fire and turn twice. +func _predict_input(last: Dictionary) -> Dictionary: + var predicted := last.duplicate() + predicted["TankInput:fire"] = false + predicted["TankInput:mouse_movement"] = Vector2.ZERO + return predicted + +# Called by InputSender when two inputs have to be merged into one, see +# InputSender. Keep a shot from either input, and add up mouse movement. +func _merge_input(older: Dictionary, newer: Dictionary) -> Dictionary: + var merged := newer.duplicate() + merged["TankInput:fire"] = older["TankInput:fire"] or newer["TankInput:fire"] + merged["TankInput:mouse_movement"] = older["TankInput:mouse_movement"] + newer["TankInput:mouse_movement"] + return merged + # Called only on server. func die() -> void: if not is_multiplayer_authority(): From 04e4c9b9e0c372d2d2ad826dcc7d645932aee694 Mon Sep 17 00:00:00 2001 From: TuysuzTavsan Date: Thu, 15 Oct 2026 20:35:59 +0300 Subject: [PATCH 49/57] fix recording inputs at t -1, better buffers --- addons/netfox/netfox.gd | 25 +++ addons/netfox/servers/input-sender-server.gd | 9 + .../netfox/servers/network-history-server.gd | 10 +- .../servers/network-synchronization-server.gd | 9 + addons/netfox/servers/simulator-server.gd | 203 ++++++++++-------- addons/netfox/simulator-time-tracker.gd | 154 +++++++++---- addons/netfox/simulator.gd | 15 ++ 7 files changed, 299 insertions(+), 126 deletions(-) diff --git a/addons/netfox/netfox.gd b/addons/netfox/netfox.gd index ebd5aa9dc..ce58e0efb 100644 --- a/addons/netfox/netfox.gd +++ b/addons/netfox/netfox.gd @@ -172,27 +172,36 @@ var SETTINGS: Array[Dictionary] = [ }, # Input Sender { + # Each input packet also carries this many previous inputs, in case + # some packets are lost "name": "netfox/input_sender/input_redundancy", "value": 3, "type" : TYPE_INT }, { + # Ticks of past inputs kept. The buffers also get room for inputs + # stamped ahead, see netfox/simulator/max_input_lead "name": "netfox/input_sender/history_limit", "value": 64, "type" : TYPE_INT }, { + # Ticks the host waits for an input before considering it missing. + # Must be smaller than netfox/input_sender/history_limit "name": "netfox/input_sender/missing_input_history", "value": 16, "type" : TYPE_INT }, { + # Send inputs to every peer, not only the host "name": "netfox/input_sender/enable_input_broadcast", "value": false, "type" : TYPE_BOOL }, # Simulator { + # Ticks between full states, diff states are sent in between. + # 0 sends a full state every tick "name": "netfox/simulator/full_state_interval", "value": 24, "type": TYPE_INT, @@ -200,16 +209,21 @@ var SETTINGS: Array[Dictionary] = [ "hint_string": "0,60,or_greater" }, { + # Send only what changed since the previous tick between full states "name": "netfox/simulator/enable_diff_states", "value": true, "type": TYPE_BOOL }, { + # Ticks of past simulator state kept, also how far back projectiles + # can be caught up. The buffers also get room for states predicted + # ahead, see netfox/simulator/max_input_lead "name": "netfox/simulator/history_limit", "value": 64, "type" : TYPE_INT }, { + # Most ticks the host rewinds when catching up a client's projectile "name": "netfox/simulator/max_view_delay_ticks", "value": 30, "type" : TYPE_INT @@ -223,6 +237,17 @@ var SETTINGS: Array[Dictionary] = [ "hint": PROPERTY_HINT_RANGE, "hint_string": "0,1,or_greater" }, + { + # Clients never stamp their inputs further ahead than this, in seconds. + # The host drops inputs stamped further ahead. InputSender and + # Simulator history buffers get this much room for the future, on top + # of their history_limit + "name": "netfox/simulator/max_input_lead", + "value": 1.0, + "type": TYPE_FLOAT, + "hint": PROPERTY_HINT_RANGE, + "hint_string": "0,2,or_greater" + }, { # Seconds between periodic view delay samples sent by clients "name": "netfox/simulator/view_delay_send_interval", diff --git a/addons/netfox/servers/input-sender-server.gd b/addons/netfox/servers/input-sender-server.gd index 929e6cfcb..8fa04d7c7 100644 --- a/addons/netfox/servers/input-sender-server.gd +++ b/addons/netfox/servers/input-sender-server.gd @@ -44,6 +44,15 @@ func _ready(): if not _history_server: _history_server = NetworkHistoryServer if not _synchronization_server: _synchronization_server = NetworkSynchronizationServer + # Missing inputs are looked up missing_input_history ticks back, so they + # have to be inside the history_limit ticks of the past that are kept. + if _missing_inputs_history_size >= _input_sender_history_size: + _logger.warning( + "netfox/input_sender/missing_input_history (%d) must be smaller than " + + "netfox/input_sender/history_limit (%d), using %d", + [_missing_inputs_history_size, _input_sender_history_size, _input_sender_history_size - 1]) + _missing_inputs_history_size = maxi(0, _input_sender_history_size - 1) + # Record inputs similiar to rollback so that users can use same methods # on their input scripts. # For good reasons, We do logic and run InputSender signals on NetworkTime.on_tick diff --git a/addons/netfox/servers/network-history-server.gd b/addons/netfox/servers/network-history-server.gd index c5f52ed88..f83a08bd3 100644 --- a/addons/netfox/servers/network-history-server.gd +++ b/addons/netfox/servers/network-history-server.gd @@ -22,8 +22,14 @@ var _simulator_properties := _PropertyPool.new() var _rb_history_size := NetworkRollback.history_limit var _sync_history_size := ProjectSettings.get_setting("netfox/state_synchronizer/history_limit", 64) as int -var _input_sender_history_size := ProjectSettings.get_setting("netfox/input_sender/history_limit", 64) as int -var _simulator_history_size := ProjectSettings.get_setting("netfox/simulator/history_limit", 64) as int +# InputSender and Simulator histories also hold ticks in the future: clients +# stamp inputs ahead and predict their own state ahead. So their buffers are +# history_limit ticks of the past, plus room for the future, see +# _SimulatorTimeTracker.get_future_history_ticks(). +var _input_sender_history_size := ProjectSettings.get_setting("netfox/input_sender/history_limit", 64) as int \ + + _SimulatorTimeTracker.get_future_history_ticks() +var _simulator_history_size := ProjectSettings.get_setting("netfox/simulator/history_limit", 64) as int \ + + _SimulatorTimeTracker.get_future_history_ticks() var _ignored_subjects := _Set.new() diff --git a/addons/netfox/servers/network-synchronization-server.gd b/addons/netfox/servers/network-synchronization-server.gd index c6199ae29..78efb7849 100644 --- a/addons/netfox/servers/network-synchronization-server.gd +++ b/addons/netfox/servers/network-synchronization-server.gd @@ -523,6 +523,15 @@ func _handle_input_sender(sender : int, data : PackedByteArray) -> void: var snapshots := _redundant_serializer.read_from(sender, _input_sender_properties, buffer, true) for snapshot in snapshots: + # Inputs stamped too far ahead would push everyone's inputs out of the + # history buffers, see _SimulatorTimeTracker. Drop them before they get + # there. + if not SimulatorServer._is_input_tick_accepted(snapshot.tick): + _logger.warning( + "Dropping inputs from peer #%d stamped @%d, too far ahead of @%d", + [sender, snapshot.tick, NetworkTime.tick]) + continue + snapshot.sanitize(sender) _logger.trace("Ingesting input_sender inputs: %s", [snapshot]) diff --git a/addons/netfox/servers/simulator-server.gd b/addons/netfox/servers/simulator-server.gd index e5ee51441..44e82d430 100644 --- a/addons/netfox/servers/simulator-server.gd +++ b/addons/netfox/servers/simulator-server.gd @@ -12,10 +12,24 @@ class_name _SimulatorServer ## Insight ## ## Time model: the host's tick is the truth. The host simulates everyone, its -## own player and remote players, at its current tick, using the inputs of the -## previous tick. Clients stamp their inputs ahead of their own tick (input -## lead), so the inputs arrive before the host needs them. Clients predict -## their own player up to tick + input lead. See [_SimulatorTimeTracker]. +## own player and remote players, at its current tick. Clients stamp their +## inputs ahead of their own tick (input lead), so the inputs arrive before the +## host needs them. Clients predict their own player up to tick + input lead. +## See [_SimulatorTimeTracker]. +## +## Simulating a tick works the same way as rollback: simulating tick T applies +## the inputs of tick T, calls _simulated_tick(delta, T, is_fresh), and the +## resulting state is recorded as the state of tick T + 1. Inputs of tick T +## are recorded on after_tick, before SimulatorServer runs, so they're always +## there for our own inputs. Example for the host, at tick 100: +## +## after_tick | input of tick 100 is recorded +## SimulatorServer | inputs of 100 applied, _simulated_tick(delta, 100) +## | state recorded and sent as tick 101 +## after_tick_loop | state of tick 101 is shown +## +## This also puts Simulators in the same tick numbers as StateSynchronizers, +## which record state after a tick as tick + 1 too. ## ## Depending on simulator authority there are 4 options we can operate on: ## @@ -25,22 +39,23 @@ class_name _SimulatorServer ## 4- We dont have any authority over input/state = client puppet ## ## 1- host simulator: -## - Advance the simulation with the inputs tick - 1. +## - Simulate the current tick with its inputs. ## (input-sender inputs are recorded on after tick). -## - Save the simulator state for current tick as invidiual fashion. +## - Save the resulting state as tick + 1, as invidiual fashion. ## - Broadcast it togather with 2. ## ## 2- local authoritative simulator: -## - We have inputs available locally up to tick + input lead - 1. +## - We have inputs available locally up to tick + input lead. ## (input-sender inputs are recorded on after tick). ## - Apply the latest authoritative state received from host. (the truth) -## - Iterate over inputs and reach tick + input lead. +## - Simulate every tick from the truth up to tick + input lead, which results +## in the state of tick + input lead + 1. ## ## 3- host puppet. -## - Run the simulation for the current tick, with the inputs of tick - 1. +## - Simulate the current tick with its inputs. ## Clients stamp inputs ahead, so they are usually here already. ## - If inputs are missing, predict them, see InputSender. -## - Record the simulator state for the current tick. +## - Save the resulting state as tick + 1. ## - Broadcast them. ## ## 4- client puppet. @@ -55,7 +70,10 @@ var _history_server : _NetworkHistoryServer = null var _synchronization_server : _NetworkSynchronizationServer = null var _logger := NetfoxLogger._for_netfox("SimulatorServer") -# History size for simulation. +# How many ticks of the past we keep for simulation, e.g. recorded collision +# shapes, and how far back projectiles can be caught up. +# The history buffers also have room for the future on top of this, see +# _SimulatorTimeTracker.get_future_history_ticks(). var _simulation_history_size : int = ProjectSettings.get_setting("netfox/simulator/history_limit", 64) # Maximum view delay accepted from clients, in ticks. Limits how far into the @@ -97,7 +115,10 @@ func _ready(): if not _synchronization_server: _synchronization_server = NetworkSynchronizationServer _simulated_collision_recorder.initialize(get_tree()) - _time_tracker.initialize(self, NetworkCommandServer, _synchronization_server, _simulation_history_size) + # View delay samples are stamped ahead like inputs, so they get the same + # room for the future as the Simulator history buffers. + _time_tracker.initialize(self, NetworkCommandServer, _synchronization_server, + _simulation_history_size + _SimulatorTimeTracker.get_future_history_ticks()) # Register a simulator node. # Will check for authority over inputs and categorize by it. @@ -127,13 +148,17 @@ func deregister_simulator(simulator : Simulator) -> void: ## Register a projectile to be stepped by simulation. ## This method should only be called on host. ## [br][br] -## [param fired_tick] is the firing peer's tick when it fired, e.g. the tick of -## its fire input, as given by [signal InputSender.network_input]. Don't pass -## the host's current tick. +## [param fired_tick] is the tick of the fire input, e.g. the tick passed to +## [code]_simulated_tick[/code] when the fire input is applied, or the tick +## given by [signal InputSender.network_input]. ## [br][br] ## The projectile is caught up from the tick of the host's simulation that the ## firing peer was seeing at [param fired_tick], see [_SimulatorTimeTracker]. The ## rewind is limited by [code]netfox/simulator/max_view_delay_ticks[/code]. +## [br][br] +## Projectiles are stepped after Simulators, so stepping a projectile for tick +## T checks it against the world after this tick's movement, the state of +## T + 1. The same is true while catching up. func register_projectile(projectile : Node, firing_peer_id : int, fired_tick : int) -> void: var has_step_method := projectile.has_method("step") @@ -147,7 +172,17 @@ func register_projectile(projectile : Node, firing_peer_id : int, fired_tick : i return var view_delay := _get_view_delay_for(firing_peer_id, fired_tick) - var estimated_firing_tick := fired_tick - view_delay + + # The firing peer saw the state of tick fired_tick - view_delay. Stepping + # tick T checks against the state of T + 1, so start one tick earlier: the + # first step then checks against exactly what the firing peer saw. + # Example: the peer saw the state of 97, so the first step is for tick 96, + # and checks against the state of 97. + var estimated_firing_tick := fired_tick - view_delay - 1 + + # Without a view delay sample, the fire input's tick can be in the future + # for a client, see _get_view_delay_for. Never start later than now. + estimated_firing_tick = mini(estimated_firing_tick, NetworkTime.tick) if estimated_firing_tick < 0: _logger.error( @@ -198,23 +233,30 @@ func _get_view_delay_for(peer: int, tick: int) -> int: func _get_input_lead() -> int: return _time_tracker.get_input_lead() +# Returns whether an input stamped with the given tick is accepted now. Inputs +# stamped too far ahead are dropped, see _SimulatorTimeTracker. +# Used by NetworkSynchronizationServer internally. +func _is_input_tick_accepted(stamped_tick: int) -> bool: + return _time_tracker.is_input_tick_accepted(stamped_tick, NetworkTime.tick) + func _after_tick(tick : int) -> void: _catch_up_fresh_projectiles(tick) - _handle_host_simulators() - _handle_host_puppet_simulators() - _handle_local_authoritative_simulators() + _handle_host_simulators(tick) + _handle_host_puppet_simulators(tick) + _handle_local_authoritative_simulators(tick) _step_living_projectiles(tick) - # Everything was simulated at the current tick, so everything is recorded - # and sent under the current tick. + # Everything was simulated for the current tick, the result is the state of + # the next tick. So everything is recorded and sent under tick + 1. # History server only records owned simulator state properties. - _history_server._record_simulator(tick) - _synchronization_server._synchronize_simulator(tick) + _history_server._record_simulator(tick + 1) + _synchronization_server._synchronize_simulator(tick + 1) - _simulated_collision_recorder.record_tick(tick) + # Collision shapes after this tick's movement, see _catch_up_fresh_projectiles. + _simulated_collision_recorder.record_tick(tick + 1) # Trim old history that we dont need. var trim_tick := tick - _simulation_history_size @@ -223,13 +265,11 @@ func _after_tick(tick : int) -> void: _simulated_collision_recorder.trim_ticks(trim_tick) ## 1- host simulator: -## - Advance the simulation with the inputs tick - 1. +## - Simulate the current tick with its inputs. ## (input-sender inputs are recorded on after tick). -## - Save the simulator state for current tick. +## - Save the resulting state as tick + 1. ## - Broadcast it togather with 2. -func _handle_host_simulators() -> void: - var current_tick := NetworkTime.tick - +func _handle_host_simulators(current_tick: int) -> void: for simulator in _host_simulators: var input_subjects := simulator.listened_input_sender._input_properties.get_subjects() @@ -238,27 +278,30 @@ func _handle_host_simulators() -> void: # Save input properties before messing them up. simulator.listened_input_sender._save_properties() - # Apply inputs of the previous tick. - _history_server._restore_input_sender_subjects(input_subjects, current_tick - 1) + # Apply inputs of this tick. + _history_server._restore_input_sender_subjects(input_subjects, current_tick) var is_fresh := _is_tick_fresh_for(simulator, current_tick) simulator._run_simulation(NetworkTime.ticktime, current_tick, is_fresh) - _history_server._record_simulator_subjects(state_subjects, current_tick) + _history_server._record_simulator_subjects(state_subjects, current_tick + 1) _set_tick_simulated_for(simulator, current_tick) # Restore messed up properties. simulator.listened_input_sender._restore_properties() ## 2- local authoritative simulator: -## - We have inputs available locally up to tick -1. +## - We have inputs available locally up to tick + input lead. ## (input-sender inputs are recorded on after tick). ## - Apply the latest authoritative state received from host. (the truth) -## - Iterate over inputs and reach the current tick - 1. -func _handle_local_authoritative_simulators() -> void: - var current_tick := NetworkTime.tick - - # Our inputs are stamped this far ahead, so we predict this far ahead too. - # Our own player is shown at current_tick + input_lead. +## - Simulate every tick from the truth up to tick + input lead, which results +## in the state of tick + input lead + 1. +## +## Example, with a lead of 5 at tick 100, and the latest truth for tick 97: +## restore the state of 97, then simulate ticks 97 .. 105 with their inputs. +## The result is the state of tick 106, which is shown. +func _handle_local_authoritative_simulators(current_tick: int) -> void: + # Our inputs are stamped this far ahead, so we simulate this far ahead too. + # This is the newest input we have, recorded on this tick's after_tick. var predicted_tick := current_tick + _get_input_lead() for simulator in _local_authoritative_simulators: @@ -266,9 +309,11 @@ func _handle_local_authoritative_simulators() -> void: var input_subjects := simulator.listened_input_sender._input_properties.get_subjects() var state_subjects := simulator._state_properties.get_subjects() + # The host sends the state after its tick T as T + 1, so the truth can be + # a tick ahead of us. Nothing newer than our prediction can be truth. var latest_truth_tick := _history_server.get_latest_simulator_for_snapshot( state_subjects, - current_tick) + predicted_tick + 1) var latest_truth_snapshot := _history_server._get_simulator_snapshot(latest_truth_tick) @@ -281,12 +326,12 @@ func _handle_local_authoritative_simulators() -> void: "Running simulation for only current tick." ) - # Apply inputs of the previous tick. - _history_server._restore_input_sender_subjects(input_subjects, predicted_tick - 1) + # Apply our newest inputs. + _history_server._restore_input_sender_subjects(input_subjects, predicted_tick) var is_fresh := _is_tick_fresh_for(simulator, predicted_tick) simulator._run_simulation(NetworkTime.ticktime, predicted_tick, is_fresh) - _history_server._record_simulator_subjects(state_subjects, predicted_tick) + _history_server._record_simulator_subjects(state_subjects, predicted_tick + 1) _set_tick_simulated_for(simulator, predicted_tick) # Restore messed up properties. @@ -296,74 +341,59 @@ func _handle_local_authoritative_simulators() -> void: # Apply truth from history, which has diffs merged into full state. _history_server._restore_simulator_subjects(state_subjects, latest_truth_tick) - for i in range(latest_truth_tick + 1, predicted_tick + 1): - # Apply inputs of the previous tick. If there's no input recorded - # for it, the latest input before it is applied. - _history_server._restore_input_sender_subjects(input_subjects, i - 1) + for i in range(latest_truth_tick, predicted_tick + 1): + # Apply inputs of this tick. If there's no input recorded for it, + # the latest input before it is applied. + _history_server._restore_input_sender_subjects(input_subjects, i) var is_fresh := _is_tick_fresh_for(simulator, i) simulator._run_simulation(NetworkTime.ticktime, i, is_fresh) - _history_server._record_simulator_subjects(state_subjects, i) + _history_server._record_simulator_subjects(state_subjects, i + 1) _set_tick_simulated_for(simulator, i) # Restore messed up properties. simulator.listened_input_sender._restore_properties() ## 3- host puppet. -## - Run the simulation for the current tick, with the inputs of tick - 1. +## - Simulate the current tick with its inputs. ## - If inputs are missing, predict them, see InputSender. -## - Record the simulator state for the current tick. +## - Save the resulting state as tick + 1, recorded in _after_tick. ## - Broadcast them. -func _handle_host_puppet_simulators() -> void: - var current_tick := NetworkTime.tick - +func _handle_host_puppet_simulators(current_tick: int) -> void: # Host puppets are simulated at the current tick, like everything else. var simulated_tick := current_tick - # Simulating a tick uses the inputs of the tick before it, so the first - # tick with any inputs to simulate is 1. Before that, simulated_tick - 1 is - # negative, and would match the -1 returned for "no input found" below. - if simulated_tick < 1: - return - for simulator in _host_puppet_simulators: + var input_sender := simulator.listened_input_sender + var input_subjects := input_sender._input_properties.get_subjects() + + # Inputs are read from each player's own history, not from the per tick + # snapshot shared by all players. This way, whatever another player + # sends can't keep this player from being simulated. var latest_input_tick := _history_server.get_latest_input_sender_for( - simulator.listened_input_sender._input_properties.get_subjects(), - simulated_tick - 1 + input_subjects, + simulated_tick ) - var is_fresh := _is_tick_fresh_for(simulator, simulated_tick) + # Apply the inputs of this tick, or the latest ones before it. + _history_server._restore_input_sender_subjects(input_subjects, simulated_tick) - if latest_input_tick == simulated_tick - 1: - # We have inputs for this tick, run the simulation. - var input_snapshot := _history_server._get_input_sender_snapshot(latest_input_tick) - if not input_snapshot: - _logger.error("No input snapshot found at latest input tick, this shouldnt happen.") - continue - - _logger.trace("Running simulation for %s", [simulator]) - - - simulator.listened_input_sender._apply_snapshot_for_self(input_snapshot) - simulator._run_simulation(NetworkTime.ticktime, simulated_tick, is_fresh) - - else: + if latest_input_tick != simulated_tick: # The input didn't arrive in time, predict it from the latest one we # have. Same as a client filling a skipped stamp, see InputSender. - _logger.debug("No input for @%d, predicting it", [simulated_tick - 1]) - - var input_sender := simulator.listened_input_sender - _history_server._restore_input_sender_subjects( - input_sender._input_properties.get_subjects(), simulated_tick - 1) + _logger.debug("No input for @%d, predicting it", [simulated_tick]) input_sender._set_input(input_sender._get_predicted_input(input_sender._get_input())) - - simulator._run_simulation(NetworkTime.ticktime, simulated_tick, is_fresh) + + var is_fresh := _is_tick_fresh_for(simulator, simulated_tick) + simulator._run_simulation(NetworkTime.ticktime, simulated_tick, is_fresh) _set_tick_simulated_for(simulator, simulated_tick) # Steps every already-caught-up living projectile by a single tick. -# No need to restore world state for living projectiles. +# No need to restore world state for living projectiles. This runs after +# Simulators, so they see the world after this tick's movement, the state of +# current_tick + 1. func _step_living_projectiles(current_tick: int) -> void: var simulated_tick := current_tick @@ -412,8 +442,9 @@ func _catch_up_fresh_projectiles(current_tick: int) -> void: var current_collision_state_snapshot_dict := _simulated_collision_recorder.capture_current_state() for tick in range(oldest_tick, simulated_tick): - # Restore simulation aware collision nodes. - _simulated_collision_recorder.restore_tick(tick) + # Restore simulation aware collision nodes to the state after this + # tick's movement, same as living projectiles see, see register_projectile. + _simulated_collision_recorder.restore_tick(tick + 1) # Get projectiles registered at this tick and add it our fresh_projectiles array. var registered_projectiles_arr_at_tick := _fresh_registered_projectiles_by_tick.get(tick, []) as Array diff --git a/addons/netfox/simulator-time-tracker.gd b/addons/netfox/simulator-time-tracker.gd index 26f59d0da..a27e01ec1 100644 --- a/addons/netfox/simulator-time-tracker.gd +++ b/addons/netfox/simulator-time-tracker.gd @@ -19,6 +19,33 @@ class_name _SimulatorTimeTracker ## connection a big one, each only pays for its own connection. The host's own ## inputs need no lead, so it is always 0 on the host. ## [br][br] +## [b]Maximum lead[/b]: the lead never goes above +## [code]netfox/simulator/max_input_lead[/code]. Clients with an even worse +## connection still play, but their inputs may arrive late, and the host +## predicts them. The host drops inputs stamped further ahead than the maximum +## lead plus [constant INPUT_TICK_TOLERANCE], see +## [method is_input_tick_accepted]. Without that, a single input stamped far +## in the future would push everyone's inputs out of the history buffers. +## Example, with 64 ticks of history: +## [codeblock] +## host tick | 1000 +## input arrives stamped | 1100 +## per tick input buffer now holds | 1037 .. 1100 +## host needs the inputs of 1000 | gone from the buffer, for every player +## [/codeblock] +## [b]History size[/b]: inputs and predicted states are kept for ticks in the +## future too, so the InputSender and Simulator history buffers hold +## [code]history_limit[/code] ticks of the past, plus room for the future, see +## [method get_future_history_ticks]. Example with the defaults, 64 ticks of +## past and 1 second of maximum lead at 30 ticks per second: +## [codeblock] +## room for the future | 30 + 2 (tolerance) = 32 ticks +## buffer size | 64 + 32 = 96 ticks +## host tick | 1000 +## stamps accepted | up to 1032 +## past always kept | 937 .. 1000, whatever honest clients stamp +## [/codeblock] +## [br] ## The lead is set once time synchronization is done, then moves by at most 1 ## tick every [constant LEAD_CHANGE_INTERVAL] seconds. No input is lost or ## sent twice when it changes, see [_InputSenderServer]: @@ -84,13 +111,16 @@ class_name _SimulatorTimeTracker ## [br]Changes smaller than that are just network jitter, and are picked up by ## the next periodic sample. A lost sample is fine too, the next one replaces it. ## [br]Both settings are in seconds, so they behave the same at any tick rate. -## They're converted to ticks once, in [method initialize]. +## They're converted to ticks with the current tick rate every time they're +## used, so they follow the tick rate even if a client adjusts its tick rate +## to the host's, see [code]netfox/time/tickrate_mismatch_action[/code]. ## [br][br] ## [b]Storing[/b]: the host keeps a per peer history of samples, mapping ## stamped ticks to view delays. This way every projectile is matched with the ## view delay at the tick it was fired, even if fire events from different -## ticks arrive at the same time, e.g. after a lag spike. History older than -## [code]netfox/simulator/history_limit[/code] ticks is dropped automatically. +## ticks arrive at the same time, e.g. after a lag spike. Samples are kept in +## buffers as big as the Simulator history, older ones are dropped +## automatically. Samples stamped too far ahead are ignored, same as inputs. ## [br][br] ## Used by [_SimulatorServer] internally. @@ -101,16 +131,28 @@ class_name _SimulatorTimeTracker ## class description. const LEAD_CHANGE_INTERVAL := 1.0 +## Clients' ticks follow the host's only as close as time synchronization +## gets them. The host accepts inputs stamped this many ticks past the maximum +## lead, so honest clients are never dropped for a tick of clock difference. +## This also covers a client's predicted state, which is one tick past its +## newest input, see [_SimulatorServer]. +const INPUT_TICK_TOLERANCE := 2 + var _owner: Node = null var _cmd_view_delay: _NetworkCommandServer.Command = null var _history_size: int = 64 var _logger := NetfoxLogger._for_netfox("SimulatorTimeTracker") -# Client side: netfox/simulator/input_lead_margin, set in initialize(). +# Settings in seconds, set in initialize(). They're converted to ticks when +# used, see the class description. var _input_lead_margin : float = 0.1 +var _max_input_lead : float = 1.0 +var _view_delay_send_interval : float = 0.25 +var _view_delay_change_threshold : float = 0.06 -# Client side: LEAD_CHANGE_INTERVAL in ticks, set in initialize(). -var _lead_change_interval_ticks : int = 1 +# Room for the future in the history buffers, set in initialize(). Same value +# the history buffers were created with, see get_future_history_ticks(). +var _future_history_ticks : int = 32 # Client side: current input lead in ticks, 0 until round trip time is known var _input_lead : int = 0 @@ -121,20 +163,13 @@ var _has_input_lead : bool = false # Client side: tick of the last lead change, -1 if never changed var _last_lead_change_tick : int = -1 -# Client side: ticks between periodic samples, set in initialize(). -# netfox/simulator/view_delay_send_interval, rounded up to whole ticks. -# by default is = 1. -var _send_interval_ticks : int = 1 +# Client side: latest measured view delay, before adding the lead. Can be -1 +# on very low latency, see _on_simulator_state. Only valid once a state was +# received, see _latest_received_tick. +var _local_view_delay: int = 0 -# Client side: view delay change that is sent immediately, set in initialize(). -# netfox/simulator/view_delay_change_threshold, rounded up to whole ticks. -# by default is = 2. -var _change_threshold_ticks : int = 2 - -# Client side: latest measured view delay, -1 if nothing measured yet -var _local_view_delay: int = -1 - -# Client side: newest simulator state tick received from the host +# Client side: newest simulator state tick received from the host, -1 if +# nothing received yet var _latest_received_tick: int = -1 # Client side: last sample sent, -1 if nothing sent yet @@ -147,21 +182,18 @@ var _view_delay_histories: Dictionary[int, _HistoryBuffer] = {} ## Initialize needs to be called by the owner node, from its _ready(). ## Commands must be registered in the same order on every peer, which is ## guaranteed for autoloads. +## [br][br] +## [param history_size] is the size of the view delay sample buffers, the same +## as the Simulator history buffers, including the room for the future. func initialize(owner: Node, command_server: _NetworkCommandServer, synchronization_server: _NetworkSynchronizationServer, history_size: int) -> void: _owner = owner _history_size = history_size _input_lead_margin = ProjectSettings.get_setting("netfox/simulator/input_lead_margin", 0.1) as float - _lead_change_interval_ticks = maxi(1, ceili(LEAD_CHANGE_INTERVAL / NetworkTime.ticktime)) - - var send_interval := ProjectSettings.get_setting("netfox/simulator/view_delay_send_interval", 0.25) as float - var change_threshold := ProjectSettings.get_setting("netfox/simulator/view_delay_change_threshold", 0.06) as float - - # At least 1 tick, in case the interval is shorter than a tick - _send_interval_ticks = maxi(1, ceili(send_interval / NetworkTime.ticktime)) - - # At least 2 ticks, so +/- 1 tick of jitter never counts as a change - _change_threshold_ticks = maxi(2, ceili(change_threshold / NetworkTime.ticktime)) + _max_input_lead = ProjectSettings.get_setting("netfox/simulator/max_input_lead", 1.0) as float + _view_delay_send_interval = ProjectSettings.get_setting("netfox/simulator/view_delay_send_interval", 0.25) as float + _view_delay_change_threshold = ProjectSettings.get_setting("netfox/simulator/view_delay_change_threshold", 0.06) as float + _future_history_ticks = get_future_history_ticks() _cmd_view_delay = command_server.register_command(_handle_view_delay, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) @@ -173,6 +205,34 @@ func initialize(owner: Node, command_server: _NetworkCommandServer, synchronizat owner.multiplayer.connected_to_server.connect(_reset_client) owner.multiplayer.server_disconnected.connect(_reset_client) +## Returns how many ticks the InputSender and Simulator history buffers keep +## for the future, on top of [code]history_limit[/code] ticks of the past: +## [code]netfox/simulator/max_input_lead[/code] in ticks, plus +## [constant INPUT_TICK_TOLERANCE]. +## [br][br] +## Static, so NetworkHistoryServer can size its buffers before +## [_SimulatorServer] exists. Uses the tick rate at the time it's called. +## Buffers are created at startup, before any tick rate adjustment. +static func get_future_history_ticks() -> int: + var max_input_lead := ProjectSettings.get_setting("netfox/simulator/max_input_lead", 1.0) as float + return maxi(0, ceili(max_input_lead / NetworkTime.ticktime)) + INPUT_TICK_TOLERANCE + +## Returns the maximum input lead in ticks, see the class description. +## [br][br] +## Converted with the current tick rate. If the tick rate went up since the +## history buffers were created, e.g. a client adjusted to a faster host, the +## same seconds would be more ticks than the buffers have room for. So it's +## capped to the room the buffers have. +func get_max_input_lead_ticks() -> int: + var max_ticks := maxi(0, ceili(_max_input_lead / NetworkTime.ticktime)) + return mini(max_ticks, _future_history_ticks - INPUT_TICK_TOLERANCE) + +## Returns whether an input stamped [param stamped_tick] is accepted, when our +## current tick is [param current_tick]. Inputs stamped further ahead than the +## maximum lead are not, see the class description. +func is_input_tick_accepted(stamped_tick: int, current_tick: int) -> bool: + return stamped_tick <= current_tick + get_max_input_lead_ticks() + INPUT_TICK_TOLERANCE + ## Returns how many ticks ahead this peer stamps its inputs. Always 0 on the ## host. Only changes between tick loops, so it's the same for a whole loop. func get_input_lead() -> int: @@ -202,8 +262,12 @@ func _on_simulator_state(snapshot: _Snapshot) -> void: if snapshot.tick < _latest_received_tick: return + # The host sends the state after its tick T as T + 1. On very low latency, + # e.g. LAN, it can arrive while we're still at tick T, so this can be -1. + # Don't clamp it here, the lead is added when sending, and the sum is + # clamped there. _latest_received_tick = snapshot.tick - _local_view_delay = maxi(0, NetworkTime.tick - snapshot.tick) + _local_view_delay = NetworkTime.tick - snapshot.tick # Client side, called when NetworkTime starts ticking. Round trip time is known # by then, so the lead is set before the first input is stamped. @@ -232,7 +296,7 @@ func _update_input_lead() -> void: var rtt := NetworkTimeSynchronizer.rtt var one_way := rtt / 2.0 + NetworkTimeSynchronizer.rtt_jitter + _input_lead_margin - var target := ceili(one_way / NetworkTime.ticktime) + var target := mini(ceili(one_way / NetworkTime.ticktime), get_max_input_lead_ticks()) if not _has_input_lead: # First measurement, jump straight to it @@ -245,7 +309,8 @@ func _update_input_lead() -> void: if target == _input_lead: return - if NetworkTime.tick - _last_lead_change_tick < _lead_change_interval_ticks: + var lead_change_interval_ticks := maxi(1, ceili(LEAD_CHANGE_INTERVAL / NetworkTime.ticktime)) + if NetworkTime.tick - _last_lead_change_tick < lead_change_interval_ticks: return _input_lead += signi(target - _input_lead) @@ -253,9 +318,9 @@ func _update_input_lead() -> void: _logger.debug("Input lead changed to %d ticks, target %d", [_input_lead, target]) # Client side, sends a sample if it's time for a periodic one, or if the view -# delay changed enough, see _change_threshold_ticks. +# delay changed enough, see netfox/simulator/view_delay_change_threshold. func _send_view_delay() -> void: - if _local_view_delay < 0: + if _latest_received_tick < 0: # Nothing measured yet return @@ -263,10 +328,15 @@ func _send_view_delay() -> void: # right after this gets this tick stamped with the lead. Both the tick and # the view delay are in stamped tick numbers, see class description. var tick := NetworkTime.tick + _input_lead - var view_delay := _local_view_delay + _input_lead + var view_delay := maxi(0, _local_view_delay + _input_lead) - var is_periodic := _last_sent_tick < 0 or tick - _last_sent_tick >= _send_interval_ticks - var has_changed := absi(view_delay - _last_sent_view_delay) >= _change_threshold_ticks + # At least 1 tick, in case the interval is shorter than a tick + var send_interval_ticks := maxi(1, ceili(_view_delay_send_interval / NetworkTime.ticktime)) + # At least 2 ticks, so +/- 1 tick of jitter never counts as a change + var change_threshold_ticks := maxi(2, ceili(_view_delay_change_threshold / NetworkTime.ticktime)) + + var is_periodic := _last_sent_tick < 0 or tick - _last_sent_tick >= send_interval_ticks + var has_changed := absi(view_delay - _last_sent_view_delay) >= change_threshold_ticks if not is_periodic and not has_changed: return @@ -299,6 +369,14 @@ func _handle_view_delay(sender: int, data: PackedByteArray) -> void: var tick := buffer.get_u32() var view_delay := buffer.get_u16() + # A sample stamped far ahead would wipe this peer's own samples, same as + # inputs would, see is_input_tick_accepted() + if not is_input_tick_accepted(tick, NetworkTime.tick): + _logger.warning( + "Dropping view delay from peer #%d stamped @%d, too far ahead of @%d", + [sender, tick, NetworkTime.tick]) + return + # Ticks older than the history size are dropped by the buffer itself var history := _view_delay_histories.get(sender) as _HistoryBuffer if history == null: @@ -312,7 +390,7 @@ func _on_peer_disconnected(peer: int) -> void: # Client side, start measuring from scratch on every new connection. func _reset_client() -> void: - _local_view_delay = -1 + _local_view_delay = 0 _latest_received_tick = -1 _input_lead = 0 _has_input_lead = false diff --git a/addons/netfox/simulator.gd b/addons/netfox/simulator.gd index 83c217079..ae4117d45 100644 --- a/addons/netfox/simulator.gd +++ b/addons/netfox/simulator.gd @@ -38,6 +38,21 @@ class_name Simulator ## their future inputs. [br][br] ## ## TODO: Simulator can have option to predict if input_broadcast is on for inputsender. [br] +## [br] +## [b]Ticks[/b]: simulating works the same way as rollback. For tick T, the +## inputs of tick T are applied, then [code]_simulated_tick(delta, T, is_fresh)[/code] +## is called, and the resulting state is recorded as the state of tick T + 1: +## [codeblock] +## func _simulated_tick(delta: float, tick: int, is_fresh: bool) -> void: +## # Inputs of tick are applied here, the state you leave behind +## # is the state of tick + 1 +## velocity = input.movement * speed +## move_and_slide() +## [/codeblock] +## [code]is_fresh[/code] is true the first time a tick is simulated. Local +## authoritative peers simulate the same tick again after receiving the truth +## from the host, with [code]is_fresh[/code] false, so use it for effects like +## sounds that should play once. ## The root node for resolving node paths in properties. Defaults to the parent node. @export var root: Node = get_parent() From a17371113a6bdcc20ee114261ac88110c1baaaa4 Mon Sep 17 00:00:00 2001 From: TuysuzTavsan Date: Thu, 15 Oct 2026 20:48:17 +0300 Subject: [PATCH 50/57] fix input sender mess --- addons/netfox/input-sender.gd | 79 ++-- addons/netfox/netfox.gd | 7 - addons/netfox/servers/input-sender-server.gd | 336 ++++-------------- addons/netfox/servers/simulator-server.gd | 2 +- .../scenes/server_side_tank.tscn | 4 +- .../scripts/server_side_tank.gd | 22 +- 6 files changed, 101 insertions(+), 349 deletions(-) diff --git a/addons/netfox/input-sender.gd b/addons/netfox/input-sender.gd index b3c131343..2ffba3bba 100644 --- a/addons/netfox/input-sender.gd +++ b/addons/netfox/input-sender.gd @@ -5,9 +5,9 @@ class_name InputSender ## Stores inputs and sends them to host. ## [br][br] ## -## [InputSender] is a multi purpose node to use on networked games, -## It provides signals to code host and client side logic. -## [InputSender] signals are tied and emitted on [signal NetworkTime.on_tick]. +## [InputSender] is a multi purpose node to use on networked games. It sends +## inputs to the host, which either runs them through a [Simulator], or emits +## [signal input_applied] to run your own host side logic with them. ## [br][br] ## [b]Predicting and merging inputs[/b]: inputs are gathered once per tick, ## the same way as for rollback. Sometimes netfox needs an input that was never @@ -42,45 +42,29 @@ class_name InputSender ## For example, inputs that are only true for a single tick, like a jump press, ## are copied by default when predicting, and the press happens twice. When ## merging, the default drops the older press. -## -## @experimental: -## InputSenderServer assumes input snapshots arrive as whole. (atomic), if snapshot -## arrives with multiple parts, [InputSender] signals wont be reliable to -## code game logic. -## Emitted if host received input from remote owner of input_properties. -## Emitted for inputs that arrive within missing-input-history range. -## (see project settings netfox/input-sender/missing-input-history) -## InputSenderServer handles applying received input internally before emitting this signal. -## Use this signal to code host side logic. -signal network_input(tick : int) - -## Emitted if local peer has authority over input_property nodes. -## This signal is emitted for host players too. -## InputSenderServer will apply latest local inputs for this tick internally before -## emitting this signal. -## Use this signal to code client side logic which doesnt interfere with actual game state. -## Examples: Playing a sound, showing a visual effect. -## Dont use this signal to code same game logic on client side as it will not likely -## be same with remote host machine, it will cause syncing issues if you are already -## using some other method to syncronize game state (Syncronizers/Simulators). -signal local_input(tick : int) - -## Emitted if input is missing. -## Input is considered missing if host did not receive input for a tick more than -## missing-input-history. (See project settings netfox/input-sender). -## Host will try to find and apply previous known inputs before emitting this signal. -## If host cant find any previous known input, latest_known_input_tick will be -1. -## Also see late_input signal. An input will be considered missing at first, -## but later it might arrive late. In that sceneario late_input signal will be emitted too. -## Use this signal to code host side prediction logic. -signal missing_input(for_tick : int, latest_known_input_tick : int) - -## Emitted when input arrived so late that it past the missing history size. -## (see project settings netfox/input-sender/missing-input-history) -## InputSenderServer will apply received input state internally before emitting this signal. -## If a late input also past the history limit of input-sender, it will be dropped. -signal late_input(for_tick : int) +## Emitted on the host, after every tick, when the inputs of [param tick] are +## applied to the input properties. Use it to run host side logic with the +## inputs, e.g. for physics vehicles synchronized with a [StateSynchronizer]. +## Whatever you change here is recorded as the state of [code]tick + 1[/code], +## the same as with [Simulator] and rollback. +## [br][br] +## Emitted for every player, including the host's own. Clients stamp their +## inputs ahead, so they're usually here in time. If they're not, +## [param is_predicted] is true, and the inputs are predicted from the latest +## ones before [param tick], see "Predicting and merging inputs" above. Inputs +## arriving after their tick was applied are never applied, the host doesn't +## change the past. +## [br][br] +## Not emitted on clients. Only emitted if something is connected to it. +## [codeblock] +## func _on_input_sender_input_applied(tick: int, is_predicted: bool) -> void: +## engine_force = input.movement.y * engine_power +## # Don't fire on a guessed input +## if input.fire and not is_predicted: +## fire(tick) +## [/codeblock] +signal input_applied(tick: int, is_predicted: bool) ## The root node for resolving node paths in inputs. Defaults to the parent node. @export var root: Node = get_parent() @@ -324,19 +308,6 @@ func _reprocess_settings() -> void: _properties_dirty = false process_settings() -# Helper function to apply given snapshot for only this node. -# TODO Applying whole snapshot and iterating over ticks would be nicer -# if we decide to have singleton for this -func _apply_snapshot_for_self(snapshot : _Snapshot) -> void: - _logger.trace("Applying snapshot for self :%s", [snapshot]) - for subject in _input_properties.get_subjects(): - for property in _input_properties.get_properties_of(subject): - - if snapshot.has_property(subject, property): - var value := snapshot.get_property(subject, property) - # TODO is this should be node.set_indexed ?? - subject.set_indexed(property, value) - # Returns the current input, see "Predicting and merging inputs" in the class # description. func _get_input() -> Dictionary: diff --git a/addons/netfox/netfox.gd b/addons/netfox/netfox.gd index ce58e0efb..970d64c0d 100644 --- a/addons/netfox/netfox.gd +++ b/addons/netfox/netfox.gd @@ -185,13 +185,6 @@ var SETTINGS: Array[Dictionary] = [ "value": 64, "type" : TYPE_INT }, - { - # Ticks the host waits for an input before considering it missing. - # Must be smaller than netfox/input_sender/history_limit - "name": "netfox/input_sender/missing_input_history", - "value": 16, - "type" : TYPE_INT - }, { # Send inputs to every peer, not only the host "name": "netfox/input_sender/enable_input_broadcast", diff --git a/addons/netfox/servers/input-sender-server.gd b/addons/netfox/servers/input-sender-server.gd index 8fa04d7c7..cd7c44750 100644 --- a/addons/netfox/servers/input-sender-server.gd +++ b/addons/netfox/servers/input-sender-server.gd @@ -4,19 +4,33 @@ class_name _InputSenderServer # @public class ## Handles [InputSender] related operations. - -## InputSenderServer assumes input snapshots arrive as whole. (atomic), if snapshot -## arrives with multiple parts, [InputSender] signals wont be reliable to -## code game logic. +## +## InputSenderServer does two jobs, after every tick: +## [br]1. [b]Send our own inputs[/b], on every peer: the input properties of +## InputSenders we have authority over are recorded under a stamp and sent to +## the host. On the host, the stamp is the tick itself. On clients, it's +## tick + input lead, see [method _record_and_send_inputs]. +## [br]2. [b]Apply inputs[/b], on the host only: for every InputSender, the +## inputs of the current tick are applied, and +## [signal InputSender.input_applied] is emitted. If the inputs of the tick +## didn't arrive, they're predicted, see [InputSender]. +## [br][br] +## Example, on the host at tick 100, with a client stamping 5 ticks ahead: +## [codeblock] +## after_tick 100 | host's own inputs recorded and stamped 100 +## | inputs of 100 applied for every InputSender, the +## | client's were stamped 100 when it was at tick 95 +## | input_applied(100, false) emitted +## after that | StateSynchronizers record the result as tick 101 +## [/codeblock] +## Inputs that arrive after the host applied their tick are never applied, the +## host already predicted them, and doesn't change the past. static var _logger := NetfoxLogger._for_netfox("InputSenderServer") var _history_server : NetworkHistoryServer = null var _synchronization_server : NetworkSynchronizationServer = null -var _missing_inputs_history_size : int = ProjectSettings.get_setting("netfox/input_sender/missing_input_history", 16) -var _input_sender_history_size : int = ProjectSettings.get_setting("netfox/input_sender/history_limit", 64) - # A lead change skips at most this many stamps. A bigger jump means time # jumped forward, e.g. after a pause, and the skipped stamps aren't filled. const MAX_FILLED_STAMPS := 4 @@ -25,217 +39,69 @@ const MAX_FILLED_STAMPS := 4 # Inputs are stamped with tick + input lead, see _SimulatorTimeTracker. var _last_recorded_tick : int = -1 -# Stamp of our latest gathered input that local_input wasn't emitted for yet, -# -1 if none. Filled stamps are predicted, not gathered, so they're not here. -var _pending_local_input_tick : int = -1 - # Inputs held back for a tick because the input lead shrank, see # _record_and_send_inputs. Maps InputSender -> input Dictionary. var _held_inputs : Dictionary[InputSender, Dictionary] = {} -# Ticks that we received new input snapshots. -var _ticks_that_has_new_snapshot : Array[int] = [] +# Every registered InputSender. +var _input_senders : Array[InputSender] = [] -# Maps InputSender -> InputSenderHistory (inner class, look at the end of this script.) -var _per_input_sender_history : Dictionary = {} +func _init(p_history_server: _NetworkHistoryServer = null, p_synchronization_server: _NetworkSynchronizationServer = null): + _history_server = p_history_server + _synchronization_server = p_synchronization_server func _ready(): # Ensure dependencies if not _history_server: _history_server = NetworkHistoryServer if not _synchronization_server: _synchronization_server = NetworkSynchronizationServer - # Missing inputs are looked up missing_input_history ticks back, so they - # have to be inside the history_limit ticks of the past that are kept. - if _missing_inputs_history_size >= _input_sender_history_size: - _logger.warning( - "netfox/input_sender/missing_input_history (%d) must be smaller than " + - "netfox/input_sender/history_limit (%d), using %d", - [_missing_inputs_history_size, _input_sender_history_size, _input_sender_history_size - 1]) - _missing_inputs_history_size = maxi(0, _input_sender_history_size - 1) - - # Record inputs similiar to rollback so that users can use same methods - # on their input scripts. - # For good reasons, We do logic and run InputSender signals on NetworkTime.on_tick - # This means local players wont have input information for their first tick. - # Its not really like await processframe but this will only add slight delay that wont have - # Important effects on the game itself. - NetworkTime.after_tick.connect(_record_and_send_inputs) + # Inputs are recorded after every tick, the same way as for rollback, so + # users can gather inputs the same way: set the input properties before + # the tick, e.g. on NetworkTime.before_tick_loop or before_tick. + # + # Inputs are applied right after recording them, still on after_tick. + # This way the host's own inputs of this tick are already recorded, and + # whatever input_applied handlers change is recorded by StateSynchronizers + # as the state of tick + 1, which happens after after_tick. + NetworkTime.after_tick.connect(_after_tick) # Ticks start over when NetworkTime starts, so do the stamps. NetworkTime.after_sync.connect(_reset_stamps) - - _synchronization_server._on_input_sender.connect(_on_received_input_snapshot) - - # About when we should process our inputs: - # It doesnt make sense to process them when we receive them, as it will break the - # work order of [Simulator]s or [TickInterpolator]s, or we might want this to work - # with physics. It would be unreliable. - # Therefore we need to process them in a fixed point. - # Processing them on every tick doesnt make sense, since host may run multiple - # ticks and we probably wont even have new inputs to process anyway. - # In conculusion it makes sense to process them after a tick loop run. - # - # But if we run logic on after_tick_loop, our effect on the game state is lost. - # Because most setups tends to use state-syncronizer with input-sender. - # state-syncronzier records on every tick and restores after tick loop. - # - # To make sure our effect isnt lost to this order, we run logic on tick. - NetworkTime.on_tick.connect(_on_tick) -# Whenever we receive input snapshot, we investigate and fetch _earliest_input tick -# This is later used to iterate over saved snapshots and emit signals. -func _on_received_input_snapshot(snapshot : _Snapshot) -> void: - if snapshot.is_empty(): - return +func _after_tick(_delta: float, tick: int) -> void: + _record_and_send_inputs(tick) - if not _ticks_that_has_new_snapshot.has(snapshot.tick): - _ticks_that_has_new_snapshot.push_back(snapshot.tick) + if multiplayer.is_server(): + _apply_inputs(tick) -# After a tick has been run -# Handle new snapshots -# Handle missing inputs. -# Handle local inputs. +# Host side. Applies the inputs of the given tick for every InputSender, and +# emits input_applied. Inputs are read from each player's own history, so +# nothing another player sends can affect it. Missing inputs are predicted +# from the latest ones before the tick, see InputSender. # -# Since we are modifying InputSenders before emitting their signals, -# Their input properties will be messed up, this means users inputs wont be -# recorded properly on NetworkTime.after_tick. -# To prevent that, this function records their current state and restores at the -# end of this function. -func _on_tick(_delta : float, tick : int) -> void: - # First save input-sender states. - _save_input_sender_states() - - _handle_new_snapshots(tick) - - # We need to emit missing_input signal for old ticks. - var missing_tick := tick - _missing_inputs_history_size - if missing_tick >= 0: - _handle_missing_tick(missing_tick) - - # DANGER - # We use the latest recorded inputs, recorded on the previous after_tick. - # This is explained on _ready comments. - # Only once per gathered input: when an input is held back, nothing new - # was recorded, see _record_and_send_inputs. - if _pending_local_input_tick >= 0: - _handle_local_inputs(_pending_local_input_tick) - _pending_local_input_tick = -1 - - # Logic is done, restore input-sender states. - _restore_input_sender_states() - - # Now remove older history data that we keep. - _trim_input_sender_histories(tick) - -# Handles new input-sender snapshots. -# This function iterates over _ticks_that_has_new_snapshot. -# If tick is within range of missing-input-history, -# applies/emits network_input. -# If its not within range, -# it applies/emits late_input. -# Clears _ticks_that_has_new_snapshot after done. -func _handle_new_snapshots(current_tick : int) -> void: - if _ticks_that_has_new_snapshot.is_empty(): - return - - # So ticks are processed in ascending order. - _ticks_that_has_new_snapshot.sort() - - - for i in _ticks_that_has_new_snapshot: - - # This line saves processing because we fetch snapshot once per tick. - var snapshot := _history_server._get_input_sender_snapshot(i) - if not snapshot: - # This situation shouldnt happen anyway. +# InputSenders with nothing connected to input_applied are skipped, there's +# nothing to apply the inputs for. This is checked every tick, so connecting +# at any time works from the next tick on. +func _apply_inputs(tick: int) -> void: + for input_sender in _input_senders: + if not input_sender.input_applied.has_connections(): continue - # Iterate over input senders and check if there is new input in tick - for input_sender in _per_input_sender_history.keys(): - if not _is_there_new_input_for_input_sender(input_sender, i, snapshot): - # No new input, we already processed this one. - continue - - # Did not received before - # If tick i past the missing_history_size, consider it late. - input_sender._apply_snapshot_for_self(snapshot) - if i <= current_tick - _missing_inputs_history_size: - # Its a late input. - input_sender.late_input.emit(i) - else: - # Its within range, consider it as network input. - input_sender.network_input.emit(i) - - # Set as handled anyway for both situation. - _set_input_received_for_input_sender(input_sender, i) - - _ticks_that_has_new_snapshot.clear() - -# Iterates over snapshot for given tick -# Checks _per_input_sender_history for given tick. -# If input_sender did not received anything for this tick -# It assumes input is missing and emits input_missing. -# Call this function after calling _handle_new_snapshots. -# Param for tick must be the value of current_tick - missing_input_history_size. -func _handle_missing_tick(for_tick : int) -> void: - - for input_sender in _per_input_sender_history.keys(): - var input_sender_history := _per_input_sender_history[input_sender] as InputSenderHistory + var input_subjects := input_sender._input_properties.get_subjects() + var latest_input_tick := _history_server.get_latest_input_sender_for(input_subjects, tick) + var is_predicted := latest_input_tick != tick - if not input_sender_history.did_receive_for_tick(for_tick): - # We didnt receive anything for this input sender for this tick. - # Try to find latest input and emit missing_input. - - var latest_tick := _history_server.get_latest_input_sender_for( - input_sender._input_properties.get_subjects(), for_tick) - - if latest_tick >= 0: - var snapshot := _history_server._get_input_sender_snapshot(latest_tick) - if snapshot: - input_sender._apply_snapshot_for_self(snapshot) - else: - # If no snapshot, we need to emit signal with -1 - latest_tick = -1 - - # We may be able to find snapshot or not, emit anyway - input_sender.missing_input.emit(for_tick, latest_tick) - -# Iterates over input-senders and emits local input with latest recorded input available. -# Only does so if input-sender is authoritative peer. - -# Duplicated comment from _ready: -# -# "For good reasons, We do logic and run InputSender signals on NetworkTime.on_tick -# This means local players wont have input information for their first tick. -# Its not really like await processframe but this will only add slight delay that wont have -# important effects on the game itself." -func _handle_local_inputs(for_tick : int) -> void: - - # Fetch snapshot once before loop to prevent unneccessary fetches. - var snapshot := _history_server._get_input_sender_snapshot(for_tick) - if not snapshot: - # This shouldnt happen anyway. - return - - for input_sender in _per_input_sender_history.keys(): - if input_sender.has_authority_over_input_nodes(): - # Its authoritative player. - - # We know that snapshots are recorded for each tick on NetworkTime.after_tick. - # We can use it, no need to query get_latest_for here. - input_sender._apply_snapshot_for_self(snapshot) - input_sender.local_input.emit(for_tick) - -# Saves all input-sender input properties. -# This is done before messing with properties. -func _save_input_sender_states() -> void: - for input_sender in _per_input_sender_history.keys(): + # Save the input properties, so the inputs gathered on this peer are + # untouched by what we apply here. input_sender._save_properties() - -# Restores all input-sender input properties. -# This is done after messing with properties. -func _restore_input_sender_states() -> void: - for input_sender in _per_input_sender_history.keys(): + + # Apply the inputs of this tick, or the latest ones before it. + _history_server._restore_input_sender_subjects(input_subjects, tick) + if is_predicted: + input_sender._set_input(input_sender._get_predicted_input(input_sender._get_input())) + + input_sender.input_applied.emit(tick, is_predicted) + input_sender._restore_properties() # Records our own inputs and sends them to the host. Called after every tick. @@ -277,7 +143,7 @@ func _restore_input_sender_states() -> void: # # The lead changes by at most 1 tick per second, so this is rare. Predicting # and merging is done by InputSender, so users can customize it. -func _record_and_send_inputs(_delta: float, tick: int) -> void: +func _record_and_send_inputs(tick: int) -> void: var stamped_tick := tick + SimulatorServer._get_input_lead() if _last_recorded_tick < 0: @@ -328,8 +194,6 @@ func _record_inputs(stamped_tick: int) -> void: _held_inputs.clear() _record_and_send_with(stamped_tick, merged_inputs) - - _pending_local_input_tick = stamped_tick # Records and sends predicted inputs under the stamps from from_tick to # to_tick (exclusive). All of them are predicted from the inputs recorded under @@ -381,85 +245,19 @@ func _record_and_send(stamped_tick: int) -> void: # InputSenders whose inputs we gather and send. func _get_local_input_senders() -> Array[InputSender]: var result : Array[InputSender] = [] - for input_sender in _per_input_sender_history.keys(): + for input_sender in _input_senders: if input_sender.has_authority_over_input_nodes(): result.push_back(input_sender) return result func _reset_stamps() -> void: _last_recorded_tick = -1 - _pending_local_input_tick = -1 _held_inputs.clear() func _register_input_sender(input_sender : InputSender) -> void: - _per_input_sender_history[input_sender] = InputSenderHistory.new() + if not _input_senders.has(input_sender): + _input_senders.push_back(input_sender) func _deregister_input_sender(input_sender : InputSender) -> void: - _per_input_sender_history.erase(input_sender) + _input_senders.erase(input_sender) _held_inputs.erase(input_sender) - -# Helper that sets input received for param input sender on given tick. -func _set_input_received_for_input_sender(input_sender : InputSender, tick : int) -> void: - var history : InputSenderHistory = _per_input_sender_history[input_sender] as InputSenderHistory - if history: - history.set_as_received_for_tick(tick) - -## Check if there is a new input available for given input sender on given tick within matching snapshot. -func _is_there_new_input_for_input_sender(input_sender : InputSender, tick : int, snapshot : _Snapshot) -> bool: - var history : InputSenderHistory = _per_input_sender_history[input_sender] as InputSenderHistory - var received_before := history.did_receive_for_tick(tick) - - if received_before: - return false - - # We need to check if snapshot has this input senders properties - if snapshot: - for property_entry in input_sender._property_entries: - if snapshot.has_property(property_entry.node, property_entry.property): - # There is new information available indeed - return true - - # No new information - return false - -# Erases old input sender history data that we dont need anymore. -func _trim_input_sender_histories(current_tick : int) -> void: - # Since we cant merge older history than input-sender-history - # we can erase data older than that. - - var erase_ticks_older_than_inclusive : int = current_tick - _input_sender_history_size - - if erase_ticks_older_than_inclusive > 0: - for input_sender_history in _per_input_sender_history.values(): - input_sender_history.erase_old_ticks(erase_ticks_older_than_inclusive) - -func _init(p_history_server: _NetworkHistoryServer = null, p_synchronization_server: _NetworkSynchronizationServer = null): - _history_server = p_history_server - _synchronization_server = p_synchronization_server - -## Inner class to remember and compare -## Did we receive any input before for given tick? -## stored for specific InputSender. -class InputSenderHistory extends RefCounted: - - # Maps ticks to bool (we received input before = true, never received = false) - var _history : Dictionary = {} - - ## Set param tick as received - func set_as_received_for_tick(tick : int) -> void: - _history[tick] = true - - ## Check if we received input for given tick. - ## Returns false if we dont have information for that tick. - func did_receive_for_tick(tick : int) -> bool: - return _history.get(tick, false) - - ## Erase old ticks that we dont need anymore. - func erase_old_ticks(older_than_inclusive : int) -> void: - var to_erase : Array[int] = [] - for tick in _history.keys(): - if tick <= older_than_inclusive: - to_erase.push_back(tick) - - for erase_tick in to_erase: - _history.erase(erase_tick) diff --git a/addons/netfox/servers/simulator-server.gd b/addons/netfox/servers/simulator-server.gd index 44e82d430..616285628 100644 --- a/addons/netfox/servers/simulator-server.gd +++ b/addons/netfox/servers/simulator-server.gd @@ -150,7 +150,7 @@ func deregister_simulator(simulator : Simulator) -> void: ## [br][br] ## [param fired_tick] is the tick of the fire input, e.g. the tick passed to ## [code]_simulated_tick[/code] when the fire input is applied, or the tick -## given by [signal InputSender.network_input]. +## given by [signal InputSender.input_applied]. ## [br][br] ## The projectile is caught up from the tick of the host's simulation that the ## firing peer was seeing at [param fired_tick], see [_SimulatorTimeTracker]. The diff --git a/examples/server-side-vehicle/scenes/server_side_tank.tscn b/examples/server-side-vehicle/scenes/server_side_tank.tscn index f804fa18d..9869e778c 100644 --- a/examples/server-side-vehicle/scenes/server_side_tank.tscn +++ b/examples/server-side-vehicle/scenes/server_side_tank.tscn @@ -308,6 +308,4 @@ radius = 0.2 height = 0.8 sides = 3 -[connection signal="local_input" from="InputSender" to="." method="_on_input_sender_local_input"] -[connection signal="missing_input" from="InputSender" to="." method="_on_input_sender_missing_input"] -[connection signal="network_input" from="InputSender" to="." method="_on_input_sender_network_input"] +[connection signal="input_applied" from="InputSender" to="." method="_on_input_sender_input_applied"] diff --git a/examples/server-side-vehicle/scripts/server_side_tank.gd b/examples/server-side-vehicle/scripts/server_side_tank.gd index 56db3eb11..68fd9a34a 100644 --- a/examples/server-side-vehicle/scripts/server_side_tank.gd +++ b/examples/server-side-vehicle/scripts/server_side_tank.gd @@ -134,22 +134,14 @@ func die() -> void: _turret_tilt = 0 _turret_traverse = 0 -func _on_input_sender_local_input(tick): - print("Input sender local input is emitted on peer:%s" %multiplayer.get_unique_id()) +# Called on the host after every tick, for every tank, with the inputs of the +# tick applied. The tank is a physics vehicle synchronized with a +# StateSynchronizer, so the host moves it and clients see the result. +func _on_input_sender_input_applied(tick: int, is_predicted: bool) -> void: _handle_movement(tank_input.movement) _move_turret(tank_input.mouse_movement) - if tank_input.fire: - _fire(tick) - - -func _on_input_sender_missing_input(_current_tick, _latest_known_input_tick): - print("Input is missing on :%s" %name) - - -func _on_input_sender_network_input(tick): - _handle_movement(tank_input.movement) - _move_turret(tank_input.mouse_movement) - - if tank_input.fire: + # Don't fire on a guessed input. _predict_input already clears fire, this + # is just to be explicit. + if tank_input.fire and not is_predicted: _fire(tick) From d33ac353aa00f4d2dcbc9b7652c15c8885b28ea2 Mon Sep 17 00:00:00 2001 From: Fox's Sake CI Date: Thu, 15 Oct 2026 21:10:56 +0300 Subject: [PATCH 51/57] clean up --- addons/netfox/input-sender.gd | 81 ++++---- addons/netfox/servers/input-sender-server.gd | 53 +++--- .../netfox/servers/network-history-server.gd | 7 +- .../servers/network-synchronization-server.gd | 34 ++-- addons/netfox/servers/simulator-server.gd | 177 +++++++++--------- addons/netfox/simulated-collision-recorder.gd | 54 +++--- addons/netfox/simulator-time-tracker.gd | 4 +- addons/netfox/simulator.gd | 56 +++--- .../scripts/player_spawner.gd | 2 +- .../scripts/server_side_tank.gd | 20 +- .../scripts/server_side_vehicle_info_panel.gd | 6 +- .../server-side-vehicle/scripts/tank_input.gd | 20 +- .../server-side-vehicle/scripts/tank_shell.gd | 4 +- .../scripts/simulated_player.gd | 6 +- 14 files changed, 267 insertions(+), 257 deletions(-) diff --git a/addons/netfox/input-sender.gd b/addons/netfox/input-sender.gd index 2ffba3bba..c6fd894e7 100644 --- a/addons/netfox/input-sender.gd +++ b/addons/netfox/input-sender.gd @@ -92,22 +92,22 @@ var _logger := NetfoxLogger._for_netfox("InputSender") # InputSender applies state and emits signals, but this can change saved and synced # input properties, to prevent that, input-sender will save its pre-logic-inputs # and apply them whenever logic ends. -# +# # TODO we are moving to server pattern, this might be not neccessary after that. -var _saved_inputs_snapshot : _PropertySnapshot +var _saved_inputs_snapshot : _PropertySnapshot var _property_cache: PropertyCache var _property_entries: Array[PropertyEntry] = [] func _ready() -> void: if Engine.is_editor_hint(): return - + # DANGER we dont do deferred call here, because Simulator node depends on # has_authority_over_input to determine its register logic. # Therefore it is needed that input-sender is registered and ready to # return the result of that helper function. process_settings() - + # Reprocess authority on connect if NetworkEvents.enabled: # User might change `multiplayer` - `NetworkEvents` handles that @@ -118,22 +118,22 @@ func _ready() -> void: 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) func _exit_tree(): if Engine.is_editor_hint(): return - + for node in _input_properties.get_subjects(): NetworkSynchronizationServer.deregister(node) NetworkIdentityServer.deregister_node(node) NetworkHistoryServer.deregister(node) - + InputSenderServer._deregister_input_sender(self) ## Process settings. @@ -142,20 +142,20 @@ func _exit_tree(): ## ( calls process_authority ). func process_settings() -> void: process_authority() - + _property_cache = PropertyCache.new(root) _property_entries.clear() - + _saved_inputs_snapshot = _PropertySnapshot.new() - + for property in input_properties: var property_entry = _property_cache.get_entry(property) _property_entries.push_back(property_entry) - + # Register identifiers for node in _input_properties.get_subjects(): NetworkIdentityServer.register_node(node) - + # Register visibility filter for node in _input_properties.get_subjects(): NetworkSynchronizationServer.register_visibility_filter(node, visibility_filter) @@ -166,21 +166,21 @@ func process_settings() -> void: ## Make sure to do this at the same time on all peers. func process_authority(): InputSenderServer._deregister_input_sender(self) - + for node in _input_properties.get_subjects(): for property in _input_properties.get_properties_of(node): NetworkHistoryServer.deregister_input_sender(node, property) NetworkSynchronizationServer.deregister_input_sender(node, property) - + # Process authority _input_properties.set_from_paths(root, input_properties) - + # Register new recorded inputs for node in _input_properties.get_subjects(): for property in _input_properties.get_properties_of(node): NetworkHistoryServer.register_input_sender(node, property) NetworkSynchronizationServer.register_input_sender(node, property) - + InputSenderServer._register_input_sender(self) ## Add an input property. @@ -192,7 +192,7 @@ 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() @@ -253,25 +253,18 @@ func clear_schema() -> void: ## TODO make sure to document this responsibility to developer. func has_authority_over_input_nodes() -> bool: for subject in _input_properties.get_subjects(): - + # ObjectPool does not guarentee every subject is node. if not subject is Node: continue - + # Found input node, check if it has authority if subject.is_multiplayer_authority(): return true - + # Did not find any node, or none of them has authority. return false -## Get latest input data available for this [InputSender]. -## Used by [Simulator] node internally. -func get_latest_received_information_tick(current_tick : int) -> int: - return NetworkHistoryServer.get_latest_input_sender_for( - _input_properties.get_subjects(), - current_tick) - func _notification(what: int) -> void: if what == NOTIFICATION_EDITOR_PRE_SAVE: update_configuration_warnings() @@ -284,27 +277,27 @@ func _notification(what: int) -> void: func _get_configuration_warnings() -> PackedStringArray: if not root: root = get_parent() - + # Check if root exists. if not root: return ["No valid root node found!"] - + var result := PackedStringArray() - + result.append_array(_NetfoxEditorUtils.gather_properties(root, "_get_input_sender_input_properties", func(node, prop): add_input(node, prop) )) - + if _input_properties.is_empty() and input_properties.is_empty(): return ["Input properties are not configured!"] - + return result func _reprocess_settings() -> void: if not _properties_dirty or Engine.is_editor_hint(): return - + _properties_dirty = false process_settings() @@ -325,26 +318,32 @@ func _set_input(input: Dictionary) -> void: func _get_predicted_input(last: Dictionary) -> Dictionary: if not root or not root.has_method("_predict_input"): return last.duplicate() - + var predicted: Variant = root.call("_predict_input", last.duplicate()) if not predicted is Dictionary: _logger.error("_predict_input() of %s must return a Dictionary, copying the last input instead", [root.name]) return last.duplicate() - - return last.merged(predicted as Dictionary, true) + + # Properties missing from the result keep the last value + var result := last.duplicate() + result.merge(predicted as Dictionary, true) + return result # Returns a single input made from two inputs, using the root node's # _merge_input() if it has one, see the class description. func _get_merged_input(older: Dictionary, newer: Dictionary) -> Dictionary: if not root or not root.has_method("_merge_input"): return newer.duplicate() - + var merged: Variant = root.call("_merge_input", older.duplicate(), newer.duplicate()) if not merged is Dictionary: _logger.error("_merge_input() of %s must return a Dictionary, keeping the newer input instead", [root.name]) return newer.duplicate() - - return newer.merged(merged as Dictionary, true) + + # Properties missing from the result keep the newer value + var result := newer.duplicate() + result.merge(merged as Dictionary, true) + return result # Helper function to save current input_properties. # Used internally by InputSenderServer/SimulatorServer to record state before @@ -354,6 +353,6 @@ func _save_properties() -> void: # Helper function to restore input_properties. # Used internally by InputSenderServer/SimulatorServer to restore state after -# overwriting properties. +# overwriting properties. func _restore_properties() -> void: _saved_inputs_snapshot.apply(_property_cache) diff --git a/addons/netfox/servers/input-sender-server.gd b/addons/netfox/servers/input-sender-server.gd index cd7c44750..56ae59c18 100644 --- a/addons/netfox/servers/input-sender-server.gd +++ b/addons/netfox/servers/input-sender-server.gd @@ -41,7 +41,7 @@ var _last_recorded_tick : int = -1 # Inputs held back for a tick because the input lead shrank, see # _record_and_send_inputs. Maps InputSender -> input Dictionary. -var _held_inputs : Dictionary[InputSender, Dictionary] = {} +var _held_inputs : Dictionary = {} # Every registered InputSender. var _input_senders : Array[InputSender] = [] @@ -54,7 +54,7 @@ func _ready(): # Ensure dependencies if not _history_server: _history_server = NetworkHistoryServer if not _synchronization_server: _synchronization_server = NetworkSynchronizationServer - + # Inputs are recorded after every tick, the same way as for rollback, so # users can gather inputs the same way: set the input properties before # the tick, e.g. on NetworkTime.before_tick_loop or before_tick. @@ -64,13 +64,13 @@ func _ready(): # whatever input_applied handlers change is recorded by StateSynchronizers # as the state of tick + 1, which happens after after_tick. NetworkTime.after_tick.connect(_after_tick) - + # Ticks start over when NetworkTime starts, so do the stamps. NetworkTime.after_sync.connect(_reset_stamps) func _after_tick(_delta: float, tick: int) -> void: _record_and_send_inputs(tick) - + if multiplayer.is_server(): _apply_inputs(tick) @@ -84,24 +84,24 @@ func _after_tick(_delta: float, tick: int) -> void: # at any time works from the next tick on. func _apply_inputs(tick: int) -> void: for input_sender in _input_senders: - if not input_sender.input_applied.has_connections(): + if input_sender.input_applied.get_connections().is_empty(): continue - + var input_subjects := input_sender._input_properties.get_subjects() var latest_input_tick := _history_server.get_latest_input_sender_for(input_subjects, tick) var is_predicted := latest_input_tick != tick - + # Save the input properties, so the inputs gathered on this peer are # untouched by what we apply here. input_sender._save_properties() - + # Apply the inputs of this tick, or the latest ones before it. _history_server._restore_input_sender_subjects(input_subjects, tick) if is_predicted: input_sender._set_input(input_sender._get_predicted_input(input_sender._get_input())) - + input_sender.input_applied.emit(tick, is_predicted) - + input_sender._restore_properties() # Records our own inputs and sends them to the host. Called after every tick. @@ -145,15 +145,15 @@ func _apply_inputs(tick: int) -> void: # and merging is done by InputSender, so users can customize it. func _record_and_send_inputs(tick: int) -> void: var stamped_tick := tick + SimulatorServer._get_input_lead() - + if _last_recorded_tick < 0: # First input, nothing to continue from _record_inputs(stamped_tick) return - + var next_tick := _last_recorded_tick + 1 var skipped := stamped_tick - next_tick - + if skipped == 0: # Lead didn't change _record_inputs(stamped_tick) @@ -186,21 +186,23 @@ func _record_inputs(stamped_tick: int) -> void: if _held_inputs.is_empty(): _record_and_send(stamped_tick) else: - var merged_inputs : Dictionary[InputSender, Dictionary] = {} + # InputSender -> input Dictionary + var merged_inputs : Dictionary = {} for input_sender in _held_inputs: if is_instance_valid(input_sender): merged_inputs[input_sender] = input_sender._get_merged_input( _held_inputs[input_sender], input_sender._get_input()) _held_inputs.clear() - + _record_and_send_with(stamped_tick, merged_inputs) # Records and sends predicted inputs under the stamps from from_tick to # to_tick (exclusive). All of them are predicted from the inputs recorded under # the stamp before from_tick, same as the host does for missing inputs. func _record_predicted_inputs(from_tick: int, to_tick: int) -> void: - var predicted_inputs : Dictionary[InputSender, Dictionary] = {} - + # InputSender -> input Dictionary + var predicted_inputs : Dictionary = {} + for input_sender in _get_local_input_senders(): # Apply the inputs recorded under the previous stamp to read them input_sender._save_properties() @@ -208,9 +210,9 @@ func _record_predicted_inputs(from_tick: int, to_tick: int) -> void: input_sender._input_properties.get_subjects(), from_tick - 1) var last := input_sender._get_input() input_sender._restore_properties() - + predicted_inputs[input_sender] = input_sender._get_predicted_input(last) - + for stamped_tick in range(from_tick, to_tick): _record_and_send_with(stamped_tick, predicted_inputs) @@ -227,14 +229,15 @@ func _hold_inputs() -> void: # Applies the given inputs, records and sends them under the given stamp, then # puts back the gathered inputs, so input scripts find them as they left them. -func _record_and_send_with(stamped_tick: int, inputs: Dictionary[InputSender, Dictionary]) -> void: - for input_sender in inputs: +# inputs maps InputSender -> input Dictionary. +func _record_and_send_with(stamped_tick: int, inputs: Dictionary) -> void: + for input_sender: InputSender in inputs: input_sender._save_properties() - input_sender._set_input(inputs[input_sender]) - + input_sender._set_input(inputs[input_sender] as Dictionary) + _record_and_send(stamped_tick) - - for input_sender in inputs: + + for input_sender: InputSender in inputs: input_sender._restore_properties() func _record_and_send(stamped_tick: int) -> void: diff --git a/addons/netfox/servers/network-history-server.gd b/addons/netfox/servers/network-history-server.gd index f83a08bd3..131934f2f 100644 --- a/addons/netfox/servers/network-history-server.gd +++ b/addons/netfox/servers/network-history-server.gd @@ -109,7 +109,7 @@ func deregister(node: Node) -> void: # Erase from per-tick history for history in [_rb_state_snapshots, _rb_input_snapshots,\ _sync_state_snapshots, _input_sender_snapshots, _simulator_snapshots]: - + for value in history.values(): var snapshot := value as _Snapshot snapshot.erase_subject(node) @@ -273,9 +273,6 @@ func _restore_rollback_state(tick: int) -> bool: func _restore_synchronizer_state(tick: int) -> bool: return _restore_latest(tick, _sync_history) -func _restore_input_sender(tick: int) -> bool: - return _restore_latest(tick, _input_sender_history) - # Applies the latest input_sender inputs at or before the given tick, but only # to the given subjects, so a single InputSender can be restored without # touching the others. Nothing is written to the history. @@ -416,7 +413,7 @@ func _restore_latest(tick: int, history: _PerObjectHistory, filter: Callable = C # Optional filter. example: to only restore subjects we dont have authority over. if filter.is_valid() and not filter.call(subject): continue - + # Grab latest snapshot up to tick var snapshot := history.get_latest_snapshot(tick, subject) diff --git a/addons/netfox/servers/network-synchronization-server.gd b/addons/netfox/servers/network-synchronization-server.gd index 78efb7849..9eb187078 100644 --- a/addons/netfox/servers/network-synchronization-server.gd +++ b/addons/netfox/servers/network-synchronization-server.gd @@ -122,7 +122,7 @@ func deregister_sync_state(node: Node, property: NodePath) -> void: _sync_state_properties.erase(node, property) _sync_owned_state_properties.erase(node, property) -## Register a [param property] of [param node] to be synchronized +## Register a [param property] of [param node] to be synchronized ## as input_sender input func register_input_sender(node: Node, property: NodePath) -> void: _input_sender_properties.add(node, property) @@ -337,35 +337,35 @@ func _synchronize_input_sender(tick: int) -> void: # We don't own inputs, nothing to synchronize if _input_sender_owned_properties.is_empty(): return - + var snapshots := [] as Array[_Snapshot] var notified_peers := _Set.new() - + # By default input sender only sends input to host, check if its enabled. if not _input_sender_enable_broadcast: - # Input broadcast is off, only send inputs to host. - for node in _input_sender_owned_properties.get_subjects(): - notified_peers.add(1) + # Input broadcast is off, only send inputs to the host, which is + # always peer 1. + notified_peers.add(MultiplayerPeer.TARGET_PEER_SERVER) else: # If input broadcast is on, send inputs to everyone for peer in multiplayer.get_peers(): notified_peers.add(peer) - + # Make sure to not send input to ourselves # Even if host is also a player, host should be able to reach stored inputs via # recorded snapshots. notified_peers.erase(multiplayer.get_unique_id()) - + # Prepare snapshot package for offset in _input_sender_redundancy: # Grab snapshot from NetworkHistoryServer var snapshot := NetworkHistoryServer._get_input_sender_snapshot(tick - offset) if not snapshot: break - + _logger.trace("Submitting input_sender inputs: %s", [snapshot]) snapshots.append(snapshot) - + _logger.trace("Submitting input_sender inputs to peers: %s", [notified_peers]) for peer in notified_peers: var data := _redundant_serializer.write_for(peer, snapshots, _input_sender_owned_properties) @@ -455,14 +455,14 @@ func _ready(): _cmd_full_state = _command_server.register_command(_handle_full_state, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) _cmd_diff_state = _command_server.register_command(_handle_diff_state, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) _cmd_input = _command_server.register_command(_handle_input, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) - + _cmd_input_sender = _command_server.register_command(_handle_input_sender, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) - + # Unreliable, same as rollback state. Every packet carries its own tick, and # diffs lost on the way are corrected by the next full state. _cmd_full_simulator = _command_server.register_command(_handle_full_simulator, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) _cmd_diff_simulator = _command_server.register_command(_handle_diff_simulator, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE) - + _cmd_full_sync = _command_server.register_command(_handle_full_sync, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE_ORDERED) _cmd_diff_sync = _command_server.register_command(_handle_diff_sync, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE_ORDERED) @@ -519,9 +519,9 @@ func _handle_diff_sync(sender: int, data: PackedByteArray): func _handle_input_sender(sender : int, data : PackedByteArray) -> void: var buffer := StreamPeerBuffer.new() buffer.data_array = data - + var snapshots := _redundant_serializer.read_from(sender, _input_sender_properties, buffer, true) - + for snapshot in snapshots: # Inputs stamped too far ahead would push everyone's inputs out of the # history buffers, see _SimulatorTimeTracker. Drop them before they get @@ -531,9 +531,9 @@ func _handle_input_sender(sender : int, data : PackedByteArray) -> void: "Dropping inputs from peer #%d stamped @%d, too far ahead of @%d", [sender, snapshot.tick, NetworkTime.tick]) continue - + snapshot.sanitize(sender) - + _logger.trace("Ingesting input_sender inputs: %s", [snapshot]) if NetworkHistoryServer._merge_input_sender(snapshot): _on_input_sender.emit(snapshot) diff --git a/addons/netfox/servers/simulator-server.gd b/addons/netfox/servers/simulator-server.gd index 616285628..1f361ef5e 100644 --- a/addons/netfox/servers/simulator-server.gd +++ b/addons/netfox/servers/simulator-server.gd @@ -80,8 +80,8 @@ var _simulation_history_size : int = ProjectSettings.get_setting("netfox/simulat # past a client can rewind when firing, see register_projectile. var _max_view_delay_ticks : int = ProjectSettings.get_setting("netfox/simulator/max_view_delay_ticks", 30) -# Node to array of ticks -var _simulated_ticks := {} +# Node -> latest tick simulated for it, see _is_tick_fresh_for. +var _latest_simulated_ticks : Dictionary = {} # Projectiles that are alive. var _living_projectiles : Array[Node] = [] @@ -94,9 +94,9 @@ var _simulated_collision_recorder : _SimulatedCollisionRecorder = _SimulatedColl var _time_tracker : _SimulatorTimeTracker = _SimulatorTimeTracker.new() # Fresh registered projectiles. -# Mapped by tick -> array of projectiles (type of Node) that are estimated to be fired on mapped tick. +# Mapped by tick (int) -> Array of projectiles (Node) that are estimated to be fired on mapped tick. # See register_projectile method. -var _fresh_registered_projectiles_by_tick : Dictionary[int, Array] = {} +var _fresh_registered_projectiles_by_tick : Dictionary = {} # Grouped simulators depending on their authority modes. # Better readability on code / we only check authority on register. @@ -113,8 +113,9 @@ func _ready(): # Ensure dependencies if not _history_server: _history_server = NetworkHistoryServer if not _synchronization_server: _synchronization_server = NetworkSynchronizationServer - + _simulated_collision_recorder.initialize(get_tree()) + NetworkTime.after_sync.connect(_reset_simulated_ticks) # View delay samples are stamped ahead like inputs, so they get the same # room for the future as the Simulator history buffers. _time_tracker.initialize(self, NetworkCommandServer, _synchronization_server, @@ -142,8 +143,8 @@ func deregister_simulator(simulator : Simulator) -> void: _host_puppet_simulators.erase(simulator) _local_authoritative_simulators.erase(simulator) _client_puppet_simulators.erase(simulator) - - _simulated_ticks.erase(simulator) + + _latest_simulated_ticks.erase(simulator) ## Register a projectile to be stepped by simulation. ## This method should only be called on host. @@ -160,30 +161,30 @@ func deregister_simulator(simulator : Simulator) -> void: ## T checks it against the world after this tick's movement, the state of ## T + 1. The same is true while catching up. func register_projectile(projectile : Node, firing_peer_id : int, fired_tick : int) -> void: - + var has_step_method := projectile.has_method("step") var has_is_alive_method := projectile.has_method("is_alive") - + if not has_step_method or not has_is_alive_method: _logger.error( "Error registering projectile %s : Projectiles must implement step and is_alive methods.", [projectile.name] ) return - + var view_delay := _get_view_delay_for(firing_peer_id, fired_tick) - + # The firing peer saw the state of tick fired_tick - view_delay. Stepping # tick T checks against the state of T + 1, so start one tick earlier: the # first step then checks against exactly what the firing peer saw. # Example: the peer saw the state of 97, so the first step is for tick 96, # and checks against the state of 97. var estimated_firing_tick := fired_tick - view_delay - 1 - + # Without a view delay sample, the fire input's tick can be in the future # for a client, see _get_view_delay_for. Never start later than now. estimated_firing_tick = mini(estimated_firing_tick, NetworkTime.tick) - + if estimated_firing_tick < 0: _logger.error( "Error registering projectile %s: Calculated estimated_firing_tick as negative. Not registering.\n" + @@ -191,7 +192,7 @@ func register_projectile(projectile : Node, firing_peer_id : int, fired_tick : i [projectile.name, view_delay, fired_tick, estimated_firing_tick] ) return - + if estimated_firing_tick < NetworkTime.tick - _simulation_history_size: _logger.error( "Error registering projectile %s: Calculated estimated_firing_tick is older than history.\n" + @@ -199,14 +200,16 @@ func register_projectile(projectile : Node, firing_peer_id : int, fired_tick : i [projectile.name, view_delay, fired_tick, estimated_firing_tick] ) return - + _logger.debug( "Registered projectile %s with stats:\n" + "firing_peer: %s, fired tick: %s, view delay: %s, estimated firing tick: %s", [projectile.name, firing_peer_id, fired_tick, view_delay, estimated_firing_tick] ) - - var existing_projectile_arr := _fresh_registered_projectiles_by_tick.get_or_add(estimated_firing_tick, []) as Array + + if not _fresh_registered_projectiles_by_tick.has(estimated_firing_tick): + _fresh_registered_projectiles_by_tick[estimated_firing_tick] = [] + var existing_projectile_arr := _fresh_registered_projectiles_by_tick[estimated_firing_tick] as Array existing_projectile_arr.push_back(projectile) # Returns how many ticks behind the given peer saw the host's simulation at @@ -216,14 +219,14 @@ func _get_view_delay_for(peer: int, tick: int) -> int: # This also covers offline play. if peer == multiplayer.get_unique_id(): return 0 - + var view_delay := _time_tracker.get_view_delay(peer, tick, -1) if view_delay < 0: # No sample for this tick, e.g. the peer just connected. Without a # sample, don't rewind at all. _logger.debug("No view delay from peer #%s @%d, not rewinding", [peer, tick]) return 0 - + # The view delay is reported by the client, so don't trust it blindly. # It can't rewind further than max_view_delay_ticks. return clampi(view_delay, 0, maxi(0, _max_view_delay_ticks)) @@ -240,28 +243,27 @@ func _is_input_tick_accepted(stamped_tick: int) -> bool: return _time_tracker.is_input_tick_accepted(stamped_tick, NetworkTime.tick) func _after_tick(tick : int) -> void: - + _catch_up_fresh_projectiles(tick) - + _handle_host_simulators(tick) _handle_host_puppet_simulators(tick) _handle_local_authoritative_simulators(tick) - + _step_living_projectiles(tick) - + # Everything was simulated for the current tick, the result is the state of # the next tick. So everything is recorded and sent under tick + 1. # History server only records owned simulator state properties. _history_server._record_simulator(tick + 1) _synchronization_server._synchronize_simulator(tick + 1) - + # Collision shapes after this tick's movement, see _catch_up_fresh_projectiles. _simulated_collision_recorder.record_tick(tick + 1) - + # Trim old history that we dont need. var trim_tick := tick - _simulation_history_size if trim_tick >= 0: - _trim_ticks_simulated(trim_tick) _simulated_collision_recorder.trim_ticks(trim_tick) ## 1- host simulator: @@ -271,21 +273,21 @@ func _after_tick(tick : int) -> void: ## - Broadcast it togather with 2. func _handle_host_simulators(current_tick: int) -> void: for simulator in _host_simulators: - + var input_subjects := simulator.listened_input_sender._input_properties.get_subjects() var state_subjects := simulator._state_properties.get_subjects() - + # Save input properties before messing them up. simulator.listened_input_sender._save_properties() - + # Apply inputs of this tick. _history_server._restore_input_sender_subjects(input_subjects, current_tick) - + var is_fresh := _is_tick_fresh_for(simulator, current_tick) simulator._run_simulation(NetworkTime.ticktime, current_tick, is_fresh) _history_server._record_simulator_subjects(state_subjects, current_tick + 1) _set_tick_simulated_for(simulator, current_tick) - + # Restore messed up properties. simulator.listened_input_sender._restore_properties() @@ -303,54 +305,54 @@ func _handle_local_authoritative_simulators(current_tick: int) -> void: # Our inputs are stamped this far ahead, so we simulate this far ahead too. # This is the newest input we have, recorded on this tick's after_tick. var predicted_tick := current_tick + _get_input_lead() - + for simulator in _local_authoritative_simulators: - + var input_subjects := simulator.listened_input_sender._input_properties.get_subjects() var state_subjects := simulator._state_properties.get_subjects() - + # The host sends the state after its tick T as T + 1, so the truth can be # a tick ahead of us. Nothing newer than our prediction can be truth. var latest_truth_tick := _history_server.get_latest_simulator_for_snapshot( state_subjects, predicted_tick + 1) - + var latest_truth_snapshot := _history_server._get_simulator_snapshot(latest_truth_tick) - + # Save inputs before messing input properties. simulator.listened_input_sender._save_properties() - + if latest_truth_tick < 0 or not latest_truth_snapshot: _logger.warning( "Couldnt find any truth from host.\n" + "Running simulation for only current tick." ) - + # Apply our newest inputs. _history_server._restore_input_sender_subjects(input_subjects, predicted_tick) - + var is_fresh := _is_tick_fresh_for(simulator, predicted_tick) simulator._run_simulation(NetworkTime.ticktime, predicted_tick, is_fresh) _history_server._record_simulator_subjects(state_subjects, predicted_tick + 1) _set_tick_simulated_for(simulator, predicted_tick) - + # Restore messed up properties. simulator.listened_input_sender._restore_properties() continue - + # Apply truth from history, which has diffs merged into full state. _history_server._restore_simulator_subjects(state_subjects, latest_truth_tick) - + for i in range(latest_truth_tick, predicted_tick + 1): # Apply inputs of this tick. If there's no input recorded for it, # the latest input before it is applied. _history_server._restore_input_sender_subjects(input_subjects, i) - + var is_fresh := _is_tick_fresh_for(simulator, i) simulator._run_simulation(NetworkTime.ticktime, i, is_fresh) _history_server._record_simulator_subjects(state_subjects, i + 1) _set_tick_simulated_for(simulator, i) - + # Restore messed up properties. simulator.listened_input_sender._restore_properties() @@ -362,12 +364,12 @@ func _handle_local_authoritative_simulators(current_tick: int) -> void: func _handle_host_puppet_simulators(current_tick: int) -> void: # Host puppets are simulated at the current tick, like everything else. var simulated_tick := current_tick - + for simulator in _host_puppet_simulators: - + var input_sender := simulator.listened_input_sender var input_subjects := input_sender._input_properties.get_subjects() - + # Inputs are read from each player's own history, not from the per tick # snapshot shared by all players. This way, whatever another player # sends can't keep this player from being simulated. @@ -375,19 +377,19 @@ func _handle_host_puppet_simulators(current_tick: int) -> void: input_subjects, simulated_tick ) - + # Apply the inputs of this tick, or the latest ones before it. _history_server._restore_input_sender_subjects(input_subjects, simulated_tick) - + if latest_input_tick != simulated_tick: # The input didn't arrive in time, predict it from the latest one we # have. Same as a client filling a skipped stamp, see InputSender. _logger.debug("No input for @%d, predicting it", [simulated_tick]) input_sender._set_input(input_sender._get_predicted_input(input_sender._get_input())) - + var is_fresh := _is_tick_fresh_for(simulator, simulated_tick) simulator._run_simulation(NetworkTime.ticktime, simulated_tick, is_fresh) - + _set_tick_simulated_for(simulator, simulated_tick) # Steps every already-caught-up living projectile by a single tick. @@ -396,56 +398,56 @@ func _handle_host_puppet_simulators(current_tick: int) -> void: # current_tick + 1. func _step_living_projectiles(current_tick: int) -> void: var simulated_tick := current_tick - + # Iterate in reverse because we are also removing. var i := _living_projectiles.size() - 1 while i >= 0: var projectile : Node = _living_projectiles[i] - + if not is_instance_valid(projectile): _logger.warning("While stepping living projectiles: projectile at index %s no longer valid, dropping", [i]) _living_projectiles.remove_at(i) else: _logger.trace("Stepping living projectile: %s at tick #%s", [projectile.name, simulated_tick]) projectile.step(NetworkTime.ticktime, simulated_tick) - + if not projectile.is_alive(): _logger.trace("After stepping living projectile: %s projectile died at tick %s", [projectile.name, simulated_tick] ) - + _living_projectiles.remove_at(i) - + i -= 1 # Steps the freshly registered projectiles up to current_tick (not included). func _catch_up_fresh_projectiles(current_tick: int) -> void: if _fresh_registered_projectiles_by_tick.is_empty(): return - + var simulated_tick := current_tick var oldest_tick : int = _fresh_registered_projectiles_by_tick.keys().min() - + # Ensure oldest tick is not older than our history window. oldest_tick = maxi(oldest_tick, current_tick - _simulation_history_size) - + _logger.trace( "Catching up fresh projectiles: oldest_tick=%s, simulated_tick=%s, pending_ticks=%s", [oldest_tick, simulated_tick, _fresh_registered_projectiles_by_tick.keys()] ) - + # Temp array to hold projectiles. var fresh_projectiles : Array[Node] = [] - + # We need to capture current state of collision aware nodes. Because we will mess with their # states during our operation. At the end of the operation we will restore to this state snapshot. var current_collision_state_snapshot_dict := _simulated_collision_recorder.capture_current_state() - + for tick in range(oldest_tick, simulated_tick): # Restore simulation aware collision nodes to the state after this # tick's movement, same as living projectiles see, see register_projectile. _simulated_collision_recorder.restore_tick(tick + 1) - + # Get projectiles registered at this tick and add it our fresh_projectiles array. var registered_projectiles_arr_at_tick := _fresh_registered_projectiles_by_tick.get(tick, []) as Array if not registered_projectiles_arr_at_tick.is_empty(): @@ -456,16 +458,16 @@ func _catch_up_fresh_projectiles(current_tick: int) -> void: func(p): return _get_projectile_debug_name(p)) ] ) - + fresh_projectiles.append_array(registered_projectiles_arr_at_tick) _fresh_registered_projectiles_by_tick.erase(tick) - + # While iterating over ticks, step every fresh projectile we have. var i := fresh_projectiles.size() - 1 while i >= 0: - + var projectile : Node = fresh_projectiles[i] - + if is_instance_valid(projectile): projectile.step(NetworkTime.ticktime, tick) _logger.trace("Projectile %s running catch up step at tick: #%s", [projectile.name, tick]) @@ -476,25 +478,25 @@ func _catch_up_fresh_projectiles(current_tick: int) -> void: else: _logger.trace("During projectile catch up, projectile at index %s no longer valid, dropping", [i]) fresh_projectiles.remove_at(i) - + i -= 1 - + # Anything registered for tick exact simulated_tick had no history to replay. # They join living unstepped and gets its first step together with _step_living_projectiles. var registered_at_simulated_tick := _fresh_registered_projectiles_by_tick.get(simulated_tick, []) as Array if not registered_at_simulated_tick.is_empty(): _logger.trace( - "At Simulated tick #%s, %s projectile(s) joining to living projectiles without catch up.\n" + + "At Simulated tick #%s, %s projectile(s) joining to living projectiles without catch up.\n" + "They will be stepped together with living projectiles on this tick. Projectile names %s", [simulated_tick, registered_at_simulated_tick.size(), registered_at_simulated_tick.map( func(p): return _get_projectile_debug_name(p)) ] ) - + fresh_projectiles.append_array(registered_at_simulated_tick) _fresh_registered_projectiles_by_tick.erase(simulated_tick) - + if not fresh_projectiles.is_empty(): _logger.trace( "Projectile catch up is over: %s projectile(s) promoted to living: %s", @@ -502,32 +504,35 @@ func _catch_up_fresh_projectiles(current_tick: int) -> void: func(p): return _get_projectile_debug_name(p)) ] ) - + # Append our living projectiles list, they are part of the current simulation at this point, # and they will be handled with function _step_living_projectiles. _living_projectiles.append_array(fresh_projectiles) - + # Since we changed states of simulation aware collision nodes by calling # _simulated_collision_recorder.restore_tick, now we should restore them to current state which # we recorded before doing this operation. _simulated_collision_recorder.restore_snapshot(current_collision_state_snapshot_dict) +# A tick is fresh the first time it's simulated for a node. Every peer only +# ever simulates forward into new ticks: the host one tick at a time, local +# authoritative peers re-simulate older ticks from the truth, then continue +# into new ones. So a tick is fresh exactly when it's newer than the latest +# tick simulated so far, no need to remember every tick. Example, local +# authoritative peer with a lead of 5: +# +# at tick 100 | simulates 97 .. 105 | fresh: 105 only, 97 .. 104 were done +# at tick 101 | simulates 98 .. 106 | fresh: 106 only func _is_tick_fresh_for(node: Node, tick: int) -> bool: - if not _simulated_ticks.has(node): - return true - var ticks := _simulated_ticks.get(node) as Array[int] - return not ticks.has(tick) + return tick > _latest_simulated_ticks.get(node, -1) func _set_tick_simulated_for(node: Node, tick: int) -> void: - if not _simulated_ticks.has(node): - _simulated_ticks[node] = [tick] as Array[int] - else: - _simulated_ticks[node].append(tick) + if tick > _latest_simulated_ticks.get(node, -1): + _latest_simulated_ticks[node] = tick -func _trim_ticks_simulated(beginning: int) -> void: - for object in _simulated_ticks: - _simulated_ticks[object] = _simulated_ticks[object]\ - .filter(func(tick): return tick >= beginning) +# Ticks start over when NetworkTime starts, so does freshness. +func _reset_simulated_ticks() -> void: + _latest_simulated_ticks.clear() # Helper to get projectile debug name. func _get_projectile_debug_name(p) -> String: diff --git a/addons/netfox/simulated-collision-recorder.gd b/addons/netfox/simulated-collision-recorder.gd index 2ceec4baa..306696714 100644 --- a/addons/netfox/simulated-collision-recorder.gd +++ b/addons/netfox/simulated-collision-recorder.gd @@ -10,8 +10,8 @@ const SIMULATION_AWARE_GROUP := &"simulation_aware_collision" var _logger := NetfoxLogger._for_netfox("SimulatedCollisionRecorder") -# tick -> Dictionary[Node, _CollisionSnapshot] -var _snapshots_by_tick : Dictionary[int, Dictionary] = {} +# tick (int) -> Dictionary of Node -> _CollisionSnapshot +var _snapshots_by_tick : Dictionary = {} var _tracked_nodes : Array[Node] = [] ## Inner base struct for recording collision snapshot per type. @@ -25,11 +25,11 @@ class _CollisionSnapshot: class _ShapeSnapshot3D extends _CollisionSnapshot: var global_transform : Transform3D var disabled : bool - + func _init(node: CollisionShape3D) -> void: global_transform = node.global_transform disabled = node.disabled - + func restore_node(node: Node) -> void: node.global_transform = global_transform node.disabled = disabled @@ -37,11 +37,11 @@ class _ShapeSnapshot3D extends _CollisionSnapshot: class _ShapeSnapshot2D extends _CollisionSnapshot: var global_transform : Transform2D var disabled : bool - + func _init(node: CollisionShape2D) -> void: global_transform = node.global_transform disabled = node.disabled - + func restore_node(node: Node) -> void: node.global_transform = global_transform node.disabled = disabled @@ -51,13 +51,13 @@ class _AreaSnapshot3D extends _CollisionSnapshot: var monitoring : bool var monitorable : bool var collision_layer : int - + func _init(node: Area3D) -> void: global_transform = node.global_transform monitoring = node.monitoring monitorable = node.monitorable collision_layer = node.collision_layer - + func restore_node(node: Node) -> void: node.global_transform = global_transform node.monitoring = monitoring @@ -69,13 +69,13 @@ class _AreaSnapshot2D extends _CollisionSnapshot: var monitoring : bool var monitorable : bool var collision_layer : int - + func _init(node: Area2D) -> void: global_transform = node.global_transform monitoring = node.monitoring monitorable = node.monitorable collision_layer = node.collision_layer - + func restore_node(node: Node) -> void: node.global_transform = global_transform node.monitoring = monitoring @@ -86,13 +86,13 @@ class _AreaSnapshot2D extends _CollisionSnapshot: ## Because refcounted classes doesnt have access to scene tree. func initialize(tree: SceneTree) -> void: tree.node_added.connect(_on_node_added) - + var existing_nodes := tree.get_nodes_in_group(SIMULATION_AWARE_GROUP) _logger.trace( "Initializing collision recorder, found %s existing node(s) in group %s", [existing_nodes.size(), SIMULATION_AWARE_GROUP] ) - + for node in existing_nodes: _track(node) @@ -143,20 +143,21 @@ func _take_snapshot_for_node(node: Node) -> _CollisionSnapshot: ## Record the given tick for simulation aware collision nodes. func record_tick(tick: int) -> void: - var snapshot_dict : Dictionary[Node, _CollisionSnapshot] = {} + # Node -> _CollisionSnapshot + var snapshot_dict : Dictionary = {} var i := _tracked_nodes.size() - 1 while i >= 0: var node := _tracked_nodes[i] - + # While we are iterating tracked_nodes, remove if anyone is invalid. - + if not is_instance_valid(node): _logger.trace("while recording tick(%s): dropping invalid node at index %s", [tick, i]) _tracked_nodes.remove_at(i) i -= 1 continue - + snapshot_dict[node] = _take_snapshot_for_node(node) i -= 1 @@ -173,15 +174,15 @@ func restore_tick(tick: int) -> void: [tick] ) return - + var snapshot_dict := _snapshots_by_tick[tick] as Dictionary var restored_count := 0 var excluded_count := 0 - + for node in _tracked_nodes: if not is_instance_valid(node): continue - + if snapshot_dict.has(node): var snap : _CollisionSnapshot = snapshot_dict[node] snap.restore_node(node) @@ -190,7 +191,7 @@ func restore_tick(tick: int) -> void: # Not tracked yet at this historical tick -> didn't exist in the sim yet. _exclude_from_collision(node) excluded_count += 1 - + _logger.trace( "restored tick(%s): restored %s node(s), excluded %s node(s) not yet alive at this tick", [tick, restored_count, excluded_count] @@ -212,7 +213,8 @@ func _exclude_from_collision(node: Node) -> void: ## This is used to restore the latest after our operation is done. ## Internally [_SimulatorServer] will restore to this state by calling restore_snapshot. func capture_current_state() -> Dictionary: - var live : Dictionary[Node, _CollisionSnapshot] = {} + # Node -> _CollisionSnapshot + var live : Dictionary = {} var i := _tracked_nodes.size() - 1 while i >= 0: var node := _tracked_nodes[i] @@ -224,7 +226,7 @@ func capture_current_state() -> Dictionary: live[node] = _take_snapshot_for_node(node) i -= 1 - + _logger.trace("captured current state(): captured %s node(s)", [live.size()]) return live @@ -232,16 +234,16 @@ func capture_current_state() -> Dictionary: ## Restore simulation aware collision nodes to given snapshot dict. func restore_snapshot(snapshot_dict: Dictionary) -> void: var restored_count := 0 - + for node in snapshot_dict: - + if not is_instance_valid(node): continue - + var collision_snapshot : _CollisionSnapshot = snapshot_dict[node] collision_snapshot.restore_node(node) restored_count += 1 - + _logger.trace("restored snapshot(): restored %s node(s) to captured live state", [restored_count]) ## Trim ticks that we dont need anymore. diff --git a/addons/netfox/simulator-time-tracker.gd b/addons/netfox/simulator-time-tracker.gd index a27e01ec1..f56222909 100644 --- a/addons/netfox/simulator-time-tracker.gd +++ b/addons/netfox/simulator-time-tracker.gd @@ -176,8 +176,8 @@ var _latest_received_tick: int = -1 var _last_sent_tick: int = -1 var _last_sent_view_delay: int = -1 -# Host side: peer id -> _HistoryBuffer of stamped tick -> view delay -var _view_delay_histories: Dictionary[int, _HistoryBuffer] = {} +# Host side: peer id (int) -> _HistoryBuffer of stamped tick -> view delay +var _view_delay_histories: Dictionary = {} ## Initialize needs to be called by the owner node, from its _ready(). ## Commands must be registered in the same order on every peer, which is diff --git a/addons/netfox/simulator.gd b/addons/netfox/simulator.gd index ae4117d45..40c952958 100644 --- a/addons/netfox/simulator.gd +++ b/addons/netfox/simulator.gd @@ -34,7 +34,7 @@ class_name Simulator ## On puppet peers, [Simulator] only applies truth received from host ## For most games this will be enough. Even with [InputSender] broadcast toggled on from ## project settings, there is no point in re-running _simulated_ticks because server -## sends states with inputs at the same time. For puppet peers we simply dont know +## sends states with inputs at the same time. For puppet peers we simply dont know ## their future inputs. [br][br] ## ## TODO: Simulator can have option to predict if input_broadcast is on for inputsender. [br] @@ -94,9 +94,9 @@ static var _managed_roots := {} func _ready() -> void: if Engine.is_editor_hint(): return - + process_settings.call_deferred() - + # Reprocess authority on connect if NetworkEvents.enabled: # User might change `multiplayer` - `NetworkEvents` handles that @@ -107,9 +107,9 @@ func _ready() -> void: func _enter_tree() -> void: if Engine.is_editor_hint(): return - + _managed_roots[root] = self - + if not visibility_filter: visibility_filter = PeerVisibilityFilter.new() @@ -119,15 +119,15 @@ func _enter_tree() -> void: func _exit_tree() -> void: if Engine.is_editor_hint(): return - + _managed_roots.erase(root) - - + + for node in _sim_nodes + _state_properties.get_subjects(): NetworkSynchronizationServer.deregister(node) NetworkIdentityServer.deregister_node(node) NetworkHistoryServer.deregister(node) - + SimulatorServer.deregister_simulator(self) @@ -138,17 +138,17 @@ func _notification(what: int) -> void: 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_simulator_state_properties", func(node, prop): add_state(node, prop) )) - + return result ## Process settings. @@ -157,20 +157,20 @@ func _get_configuration_warnings() -> PackedStringArray: ## ( calls process_authority ). func process_settings() -> void: _sim_nodes.clear() - + process_authority() - + # Gather simulated nodes. var managed_nodes := [root] + _collect_managed_nodes(root) _logger.debug("Filtering managed nodes: %s", [managed_nodes]) for node in managed_nodes: if node.has_method("_simulated_tick"): _sim_nodes.push_back(node) - + # Register identifiers for node in _state_properties.get_subjects(): NetworkIdentityServer.register_node(node) - + # Register visibility filter for node in _state_properties.get_subjects(): NetworkSynchronizationServer.register_visibility_filter(node, visibility_filter) @@ -182,25 +182,25 @@ func process_settings() -> void: func process_authority(): # First de-register. SimulatorServer.deregister_simulator(self) - + for node in _state_properties.get_subjects(): for property in _state_properties.get_properties_of(node): NetworkHistoryServer.deregister_simulator(node, property) NetworkSynchronizationServer.deregister_simulator(node, property) - + # Process authority _state_properties.set_from_paths(root, state_properties) - + if not listened_input_sender: _logger.error("Simulator needs listened_input_sender configured and valid.") return - + # Register state properties. for node in _state_properties.get_subjects(): for property in _state_properties.get_properties_of(node): NetworkHistoryServer.register_simulator(node, property) NetworkSynchronizationServer.register_simulator(node, property) - + SimulatorServer.register_simulator(self) ## Add a state property. @@ -212,7 +212,7 @@ 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() @@ -270,19 +270,19 @@ func clear_schema() -> void: func has_authority_over_inputs() -> bool: if not listened_input_sender: return false - + return listened_input_sender.has_authority_over_input_nodes() func _reprocess_settings() -> void: if not _properties_dirty or Engine.is_editor_hint(): return - + _properties_dirty = false - + process_settings() # Helper function to run simulation with given parameters. -# This function is used by SimulatorServer internally. +# This function is used by SimulatorServer internally. func _run_simulation(delta : float, tick : int, is_fresh : bool) -> void: for node in _sim_nodes: if node: @@ -304,10 +304,10 @@ func _is_foreign_simulator_root(node: Node) -> bool: if not _managed_roots.has(node): # No simulator, treat node as root return false - + if _managed_roots[node] == self: # Node is our own root return false - + # Node is foreign root return true diff --git a/examples/server-side-vehicle/scripts/player_spawner.gd b/examples/server-side-vehicle/scripts/player_spawner.gd index 23d247964..c923812bd 100644 --- a/examples/server-side-vehicle/scripts/player_spawner.gd +++ b/examples/server-side-vehicle/scripts/player_spawner.gd @@ -57,7 +57,7 @@ func _spawn(id: int): if input != null: input.set_multiplayer_authority(id) print("Set input(%s) ownership to %s" % [input.name, id]) - + add_child(avatar) func get_next_spawn_point(peer_id: int, spawn_idx: int = 0) -> Vector3: diff --git a/examples/server-side-vehicle/scripts/server_side_tank.gd b/examples/server-side-vehicle/scripts/server_side_tank.gd index 68fd9a34a..05568ab67 100644 --- a/examples/server-side-vehicle/scripts/server_side_tank.gd +++ b/examples/server-side-vehicle/scripts/server_side_tank.gd @@ -47,7 +47,7 @@ func _unhandled_input(event): # Dont process local inputs on other players tanks if not tank_input.is_multiplayer_authority(): return - + if event.is_action_pressed("focus"): if focus_camera.current: focus_camera.current = false @@ -60,23 +60,23 @@ func _unhandled_input(event): func _handle_movement(movement : Vector2) -> void: if not is_multiplayer_authority(): return - + if movement.y != 0.0: if movement.y < 0: engine_force = engine_power else: engine_force = -engine_power - + else: # No move input engine_force = 0 - + # Brake if tank_input.brake: brake = brake_force else: brake = 0.0 - + # Steering steering = lerp(steering, deg_to_rad(max_steering_angle) * -movement.x, steering_lerp_factor) @@ -85,10 +85,10 @@ func _move_turret(mouse_input : Vector2) -> void: # Return if not host. if not is_multiplayer_authority(): return - + _turret_traverse -= mouse_input.x * traverse_speed _turret_tilt += mouse_input.y * tilt_speed - + turret.basis = _turret_default_transform.basis turret.basis = turret.basis.rotated(Vector3.UP, _turret_traverse) @@ -99,7 +99,7 @@ func _move_turret(mouse_input : Vector2) -> void: func _fire(tick : int) -> void: if tick - _last_fire_tick < fire_cooldown_tick: return - + print("Firing!") _last_fire_tick = tick var shell := shell_scene.instantiate() as Node3D @@ -128,7 +128,7 @@ func _merge_input(older: Dictionary, newer: Dictionary) -> Dictionary: func die() -> void: if not is_multiplayer_authority(): return - + var player_spawner = get_parent() global_position = player_spawner.get_random_spawn_point() _turret_tilt = 0 @@ -140,7 +140,7 @@ func die() -> void: func _on_input_sender_input_applied(tick: int, is_predicted: bool) -> void: _handle_movement(tank_input.movement) _move_turret(tank_input.mouse_movement) - + # Don't fire on a guessed input. _predict_input already clears fire, this # is just to be explicit. if tank_input.fire and not is_predicted: diff --git a/examples/server-side-vehicle/scripts/server_side_vehicle_info_panel.gd b/examples/server-side-vehicle/scripts/server_side_vehicle_info_panel.gd index 8c9475ef5..13142058b 100644 --- a/examples/server-side-vehicle/scripts/server_side_vehicle_info_panel.gd +++ b/examples/server-side-vehicle/scripts/server_side_vehicle_info_panel.gd @@ -14,13 +14,13 @@ func _process(_delta): var tank = get_parent() if not tank: return - + var percentage = (NetworkTime.tick - tank._last_fire_tick) as float / tank.fire_cooldown_tick as float reload_progress_bar.value = percentage * 100.0 - + if reload_progress_bar.value > 99: reload_label.text = "Loaded" else: reload_label.text = "Loading" - + score_label.text = "Score: " + str(tank.score) diff --git a/examples/server-side-vehicle/scripts/tank_input.gd b/examples/server-side-vehicle/scripts/tank_input.gd index 600705666..8cfc85439 100644 --- a/examples/server-side-vehicle/scripts/tank_input.gd +++ b/examples/server-side-vehicle/scripts/tank_input.gd @@ -12,47 +12,51 @@ var _fire_buffer := false func _ready(): NetworkTime.before_tick_loop.connect(_gather) - NetworkTime.after_tick.connect(func(_dt, _t): _gather_always()) + # Fire is gathered for every tick, not only once per tick loop, so a + # press is only sent once even if a loop runs multiple ticks. It's + # gathered before the tick, so InputSenderServer records it on the same + # tick, after the tick. + NetworkTime.before_tick.connect(func(_dt, _t): _gather_always()) func _process(_delta) -> void: if not is_multiplayer_authority(): return - + if Input.is_action_just_pressed("weapon_fire"): _fire_buffer = true func _notification(what): if not is_multiplayer_authority(): return - + if what == NOTIFICATION_WM_WINDOW_FOCUS_IN: Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED) func _gather(): if not is_multiplayer_authority(): return - + # Get the input direction and handle the movement/deceleration. # As good practice, you should replace UI actions with custom gameplay actions. var mx = Input.get_axis("move_west", "move_east") var mz = Input.get_axis("move_north", "move_south") movement = Vector2(mx, mz) brake = Input.is_action_pressed("move_jump") - + mouse_movement = _mouse_movement_buffer if _mouse_movement_buffer else Vector2.ZERO _mouse_movement_buffer = Vector2.ZERO func _gather_always(): if not is_multiplayer_authority(): return - + fire = _fire_buffer _fire_buffer = false func _input(event: InputEvent) -> void: if not is_multiplayer_authority(): return - + if event.is_action_pressed("escape"): if Input.mouse_mode == Input.MOUSE_MODE_CAPTURED: Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE) @@ -62,7 +66,7 @@ func _input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void: if not is_multiplayer_authority(): return - + if Input.mouse_mode == Input.MOUSE_MODE_CAPTURED and event is InputEventMouseMotion: _mouse_movement_buffer.x += event.relative.x _mouse_movement_buffer.y += event.relative.y diff --git a/examples/server-side-vehicle/scripts/tank_shell.gd b/examples/server-side-vehicle/scripts/tank_shell.gd index 5a3f5dcc9..e9c0c704d 100644 --- a/examples/server-side-vehicle/scripts/tank_shell.gd +++ b/examples/server-side-vehicle/scripts/tank_shell.gd @@ -20,6 +20,6 @@ func _on_area_3d_body_entered(body): if firing_tank: print("%s killed another tank +1 score!" %firing_tank.name) firing_tank.score += 1 - - + + queue_free() diff --git a/examples/simulated-player/scripts/simulated_player.gd b/examples/simulated-player/scripts/simulated_player.gd index f267c5a65..8f0070957 100644 --- a/examples/simulated-player/scripts/simulated_player.gd +++ b/examples/simulated-player/scripts/simulated_player.gd @@ -12,11 +12,11 @@ func _simulated_tick(delta : float, _tick : int, _is_fresh : bool): # Add the gravity. if not is_on_floor(): velocity.y -= gravity * delta - + # Handle Jump. if input.jump and is_on_floor(): velocity.y = JUMP_VELOCITY - + # Get the input direction and handle the movement/deceleration. # As good practice, you should replace UI actions with custom gameplay actions. var input_dir = Vector2(input.movement.x, input.movement.z) @@ -27,7 +27,7 @@ func _simulated_tick(delta : float, _tick : int, _is_fresh : bool): else: velocity.x = move_toward(velocity.x, 0, SPEED * delta) velocity.z = move_toward(velocity.z, 0, SPEED * delta) - + # print("input_jump is :%s" %input.jump) # print("input_movement is :%s" %input.movement) # print("velocity is :%s" %velocity) From c4aa0bd317abf5cb049c7cc8f01c5627cf39db44 Mon Sep 17 00:00:00 2001 From: TuysuzTavsan Date: Thu, 15 Oct 2026 21:28:45 +0300 Subject: [PATCH 52/57] more clean up --- addons/netfox/rollback/rollback-synchronizer.gd | 2 +- addons/netfox/servers/network-synchronization-server.gd | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/addons/netfox/rollback/rollback-synchronizer.gd b/addons/netfox/rollback/rollback-synchronizer.gd index 956042fc2..df2c81196 100644 --- a/addons/netfox/rollback/rollback-synchronizer.gd +++ b/addons/netfox/rollback/rollback-synchronizer.gd @@ -86,8 +86,8 @@ func process_settings() -> void: for node in _sim_nodes + _state_properties.get_subjects() + _input_properties.get_subjects(): RollbackSimulationServer.deregister_node(node) _sim_nodes.clear() - process_authority() + process_authority() # Register nodes for simulation and liveness var managed_nodes := [root] + _collect_managed_nodes(root) diff --git a/addons/netfox/servers/network-synchronization-server.gd b/addons/netfox/servers/network-synchronization-server.gd index 9eb187078..0dea8c365 100644 --- a/addons/netfox/servers/network-synchronization-server.gd +++ b/addons/netfox/servers/network-synchronization-server.gd @@ -561,7 +561,8 @@ func _ingest_simulator(sender: int, snapshot: _Snapshot) -> void: _logger.trace("Ingesting simulator state: %s", [snapshot]) NetworkHistoryServer._merge_simulator(snapshot) - # Emitted for every received state, same as rollback's _on_state + # Emitted for every received state, even if it changed nothing. The view + # delay is measured every time a state arrives, see _SimulatorTimeTracker. _on_simulator.emit(snapshot) func _ingest_state(sender: int, snapshot: _Snapshot) -> void: From de4177c7cdc32ec4e3012cb415d2153926e06c61 Mon Sep 17 00:00:00 2001 From: TuysuzTavsan Date: Thu, 15 Oct 2026 21:36:29 +0300 Subject: [PATCH 53/57] typo --- addons/netfox/servers/network-history-server.gd | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/addons/netfox/servers/network-history-server.gd b/addons/netfox/servers/network-history-server.gd index 858cbf297..751348243 100644 --- a/addons/netfox/servers/network-history-server.gd +++ b/addons/netfox/servers/network-history-server.gd @@ -425,12 +425,12 @@ func _record(tick: int, history: _PerObjectHistory, snapshots: _HistoryBuffer, p # return not subject.is_multiplayer_authority() func _restore_latest(tick: int, history: _PerObjectHistory, filter: Callable = Callable()) -> bool: var any_applied := false - -for subject in history.subjects_raw(): + + for subject in history.subjects_raw(): # Optional filter. example: to only restore subjects we dont have authority over. if filter.is_valid() and not filter.call(subject): continue - + # Grab latest snapshot up to tick var snapshot := history.get_latest_snapshot(tick, subject) From 4704c32de3e5a09eb04aea1e8acc8e33bcd27b86 Mon Sep 17 00:00:00 2001 From: TuysuzTavsan Date: Thu, 15 Oct 2026 22:07:09 +0300 Subject: [PATCH 54/57] default input update --- addons/netfox/input-sender.gd | 12 ++++++ addons/netfox/servers/input-sender-server.gd | 9 +++- .../netfox/servers/network-history-server.gd | 2 +- addons/netfox/servers/simulator-server.gd | 42 ++++++++++++++----- 4 files changed, 51 insertions(+), 14 deletions(-) diff --git a/addons/netfox/input-sender.gd b/addons/netfox/input-sender.gd index c6fd894e7..493ef8a03 100644 --- a/addons/netfox/input-sender.gd +++ b/addons/netfox/input-sender.gd @@ -98,6 +98,11 @@ var _saved_inputs_snapshot : _PropertySnapshot var _property_cache: PropertyCache var _property_entries: Array[PropertyEntry] = [] +# The input before anything was gathered or applied, keyed like _get_input(). +# Applied for ticks that have no input at all, e.g. right after a player +# spawned, on every peer the same way. See _SimulatorServer._apply_input_at(). +var _default_input: Dictionary = {} + func _ready() -> void: if Engine.is_editor_hint(): return @@ -152,6 +157,13 @@ func process_settings() -> void: var property_entry = _property_cache.get_entry(property) _property_entries.push_back(property_entry) + # Remember the default input. Only properties seen for the first time, so + # reprocessing settings later never takes gathered values as defaults. + for property_entry in _property_entries: + var path := property_entry.to_string() + if not _default_input.has(path): + _default_input[path] = property_entry.get_value() + # Register identifiers for node in _input_properties.get_subjects(): NetworkIdentityServer.register_node(node) diff --git a/addons/netfox/servers/input-sender-server.gd b/addons/netfox/servers/input-sender-server.gd index 56ae59c18..696d0b114 100644 --- a/addons/netfox/servers/input-sender-server.gd +++ b/addons/netfox/servers/input-sender-server.gd @@ -95,8 +95,13 @@ func _apply_inputs(tick: int) -> void: # untouched by what we apply here. input_sender._save_properties() - # Apply the inputs of this tick, or the latest ones before it. - _history_server._restore_input_sender_subjects(input_subjects, tick) + # Apply the inputs of this tick, or the latest ones before it. If the + # player has no inputs at all yet, e.g. it just spawned, apply its + # default input, the same as SimulatorServer does. + if latest_input_tick < 0: + input_sender._set_input(input_sender._default_input) + else: + _history_server._restore_input_sender_subjects(input_subjects, tick) if is_predicted: input_sender._set_input(input_sender._get_predicted_input(input_sender._get_input())) diff --git a/addons/netfox/servers/network-history-server.gd b/addons/netfox/servers/network-history-server.gd index 751348243..4befaea00 100644 --- a/addons/netfox/servers/network-history-server.gd +++ b/addons/netfox/servers/network-history-server.gd @@ -430,7 +430,7 @@ func _restore_latest(tick: int, history: _PerObjectHistory, filter: Callable = C # Optional filter. example: to only restore subjects we dont have authority over. if filter.is_valid() and not filter.call(subject): continue - + # Grab latest snapshot up to tick var snapshot := history.get_latest_snapshot(tick, subject) diff --git a/addons/netfox/servers/simulator-server.gd b/addons/netfox/servers/simulator-server.gd index 1f361ef5e..ab54af99d 100644 --- a/addons/netfox/servers/simulator-server.gd +++ b/addons/netfox/servers/simulator-server.gd @@ -308,7 +308,6 @@ func _handle_local_authoritative_simulators(current_tick: int) -> void: for simulator in _local_authoritative_simulators: - var input_subjects := simulator.listened_input_sender._input_properties.get_subjects() var state_subjects := simulator._state_properties.get_subjects() # The host sends the state after its tick T as T + 1, so the truth can be @@ -329,7 +328,7 @@ func _handle_local_authoritative_simulators(current_tick: int) -> void: ) # Apply our newest inputs. - _history_server._restore_input_sender_subjects(input_subjects, predicted_tick) + _apply_input_at(simulator.listened_input_sender, predicted_tick) var is_fresh := _is_tick_fresh_for(simulator, predicted_tick) simulator._run_simulation(NetworkTime.ticktime, predicted_tick, is_fresh) @@ -345,8 +344,10 @@ func _handle_local_authoritative_simulators(current_tick: int) -> void: for i in range(latest_truth_tick, predicted_tick + 1): # Apply inputs of this tick. If there's no input recorded for it, - # the latest input before it is applied. - _history_server._restore_input_sender_subjects(input_subjects, i) + # the latest input before it is applied. Right after spawning, the + # truth can be older than our first input, then the default input + # is applied, see _apply_input_at(). + _apply_input_at(simulator.listened_input_sender, i) var is_fresh := _is_tick_fresh_for(simulator, i) simulator._run_simulation(NetworkTime.ticktime, i, is_fresh) @@ -368,18 +369,12 @@ func _handle_host_puppet_simulators(current_tick: int) -> void: for simulator in _host_puppet_simulators: var input_sender := simulator.listened_input_sender - var input_subjects := input_sender._input_properties.get_subjects() # Inputs are read from each player's own history, not from the per tick # snapshot shared by all players. This way, whatever another player # sends can't keep this player from being simulated. - var latest_input_tick := _history_server.get_latest_input_sender_for( - input_subjects, - simulated_tick - ) - # Apply the inputs of this tick, or the latest ones before it. - _history_server._restore_input_sender_subjects(input_subjects, simulated_tick) + var latest_input_tick := _apply_input_at(input_sender, simulated_tick) if latest_input_tick != simulated_tick: # The input didn't arrive in time, predict it from the latest one we @@ -523,6 +518,31 @@ func _catch_up_fresh_projectiles(current_tick: int) -> void: # # at tick 100 | simulates 97 .. 105 | fresh: 105 only, 97 .. 104 were done # at tick 101 | simulates 98 .. 106 | fresh: 106 only +# Applies the inputs of input_sender recorded at or before tick, and returns +# the tick they were recorded at. +# +# If there are none at all, the default input is applied and -1 is returned. +# This happens right after a player spawned: the client re-simulates from the +# truth, which can be older than its first input, and the host simulates the +# player before its first input arrives. Without this, whatever the input +# properties hold at the moment would be applied for every such tick, e.g. +# this frame's mouse movement, once per tick: +# +# client spawned @60, lead 8, first input stamped @68 +# truth @62 arrives, simulate 62 .. 69 +# 62 .. 67 | no input -> default input (was: this frame's live input) +# 68 .. 69 | recorded inputs +func _apply_input_at(input_sender: InputSender, tick: int) -> int: + var input_subjects := input_sender._input_properties.get_subjects() + var latest_input_tick := _history_server.get_latest_input_sender_for(input_subjects, tick) + + if latest_input_tick < 0: + input_sender._set_input(input_sender._default_input) + else: + _history_server._restore_input_sender_subjects(input_subjects, tick) + + return latest_input_tick + func _is_tick_fresh_for(node: Node, tick: int) -> bool: return tick > _latest_simulated_ticks.get(node, -1) From 0f09a45c453e3b8c6dc790fd8ab1c3cf8fdd4827 Mon Sep 17 00:00:00 2001 From: TuysuzTavsan Date: Thu, 15 Oct 2026 23:20:58 +0300 Subject: [PATCH 55/57] diff history lookup preference --- .../netfox/servers/network-history-server.gd | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/addons/netfox/servers/network-history-server.gd b/addons/netfox/servers/network-history-server.gd index 4befaea00..9fc2d362b 100644 --- a/addons/netfox/servers/network-history-server.gd +++ b/addons/netfox/servers/network-history-server.gd @@ -174,15 +174,30 @@ func get_latest_input_sender_for(subjects: Array, tick: int) -> int: func get_latest_simulator_for(subjects: Array, tick: int) -> int: return _get_latest_for(subjects, tick, _simulator_history) -## Get the latest tick at or before [param tick] where the simulator snapshot -## has data for all of the [param subjects]. Returns -1 if there's none. +## Get the latest tick at or before [param tick] where every one of the +## [param subjects] has auth (received) simulator state. Returns -1 if there's +## none. +## [br][br] +## Auth is checked on the per-object history, not on the per-tick snapshot. A +## diff state only carries the subjects whose properties changed, so a subject +## that didn't change is missing from that tick's snapshot even though the host +## sent it. Its auth state is carried forward in the per-object history instead, +## see [method _merge_simulator]. func get_latest_simulator_for_snapshot(subjects: Array, tick: int) -> int: # Walk backwards from the newest tick, so the first match is the latest one var latest_index := mini(tick, _simulator_snapshots.get_latest_index()) for index in range(latest_index, _simulator_snapshots.get_earliest_index() - 1, -1): - var snapshot := _simulator_snapshots.get_at(index) as _Snapshot - if snapshot and snapshot.has_subjects(subjects): + if not _simulator_snapshots.has_at(index): + continue + + var has_all_auth := true + for subject in subjects: + if not _simulator_history.is_auth(index, subject): + has_all_auth = false + break + + if has_all_auth: return index return -1 @@ -366,7 +381,10 @@ func _merge_input_sender(snapshot: _Snapshot) -> bool: # entry at this tick is reset to its latest auth state. This way carry-forward # never copies predicted values into auth data. func _merge_simulator(snapshot: _Snapshot) -> bool: - for subject in snapshot.get_subjects(): + # Auth subjects include the ones a diff sent without any changed property. + # Resetting them carries their latest auth state forward to this tick, so + # the tick counts as truth for them too, see get_latest_simulator_for_snapshot. + for subject in snapshot.get_auth_subjects(): _simulator_history.reset_to_auth(snapshot.tick, subject) _merge_snapshot(snapshot, _simulator_snapshots) From 47edac97e377226538552b5eae3a6b69431baf5e Mon Sep 17 00:00:00 2001 From: TuysuzTavsan Date: Fri, 16 Oct 2026 00:14:50 +0300 Subject: [PATCH 56/57] generated docs --- docs/netfox/guides/simulation.md | 262 ++++++++++++++++++++++++++++++ docs/netfox/nodes/input-sender.md | 152 +++++++++++++++++ docs/netfox/nodes/simulator.md | 136 ++++++++++++++++ mkdocs.yml | 3 + 4 files changed, 553 insertions(+) create mode 100644 docs/netfox/guides/simulation.md create mode 100644 docs/netfox/nodes/input-sender.md create mode 100644 docs/netfox/nodes/simulator.md diff --git a/docs/netfox/guides/simulation.md b/docs/netfox/guides/simulation.md new file mode 100644 index 000000000..9fcadcb5c --- /dev/null +++ b/docs/netfox/guides/simulation.md @@ -0,0 +1,262 @@ +# Simulation + +!!!warning + Simulation is *experimental*, meaning the API may change in breaking ways, + and may be less stable than other features. + + *Simulator* is also a work in progress name, we couldn't find a better one + yet. Suggestions are welcome! + +Simulation is an alternative to [rollback], in the style of Counter-Strike's +netcode. Inputs are sent to the host, the host simulates everyone and +broadcasts the ground truth, and each player predicts only their own +character. + +Provided by the *SimulatorServer* and *InputSenderServer* autoloads. Most of the +time you do not need to use these - the [Simulator] and [InputSender] nodes +handle simulation for you. + +## Simulation or rollback? + +| | Rollback | Simulation | +|---|---|---| +| Host | Rewinds and re-simulates when inputs arrive late | Runs one tick per tick, never changes the past | +| Late inputs | Applied by re-simulating | Predicted, late ones are never applied | +| Clients | Predict and re-simulate every rollback-aware node | Predict only their own player, show the truth for everything else | +| Game state | Kept consistent across every node | Only the simulated node itself | + +Simulation is easier to code than rollback, because the game state is never +rewound under your feet. In exchange, remote objects are not predicted, and +interactions between players are decided by the host alone. + +## Time model + +The host's tick is the truth. The host simulates everyone, its own player and +remote players, at its current tick. Clients stamp their inputs ahead of their +own tick, so the inputs arrive before the host needs them. Clients predict +their own player up to tick + input lead. + +Simulating tick T applies the inputs of tick T, calls `_simulated_tick(delta, +T, is_fresh)`, and the resulting state is recorded as the state of tick T + 1. +Example for the host, at tick 100: + +``` +after_tick | input of tick 100 is recorded +SimulatorServer | inputs of 100 applied, _simulated_tick(delta, 100) + | state recorded and sent as tick 101 +after_tick_loop | state of tick 101 is shown +``` + +This also puts Simulators in the same tick numbers as [StateSynchronizer]s, +which record state after a tick as tick + 1 too. + +An authoritative peer restores the latest truth, then re-simulates up to its +newest input. Example, with a lead of 5 at tick 100, and the latest truth for +tick 97: restore the state of 97, then simulate ticks 97 .. 105 with their +inputs. The result is the state of tick 106, which is shown. + +## Input lead + +How far ahead clients stamp their inputs is the *input lead*. Each client +computes it on its own, from the round trip time measured by +[time synchronization]: + +``` +lead = ceil((rtt / 2 + rtt_jitter + margin) / ticktime) +``` + +Clients with a good connection get a small lead, clients with a bad connection +a big one, each only pays for its own connection. The host's own inputs need no +lead, so it is always 0 on the host. + +The lead never goes above `netfox/simulator/max_input_lead`. Clients with an +even worse connection still play, but their inputs may arrive late, and the +host predicts them. The host drops inputs stamped further ahead than the +maximum lead. + +The lead moves by at most 1 tick every second. No input is lost or sent twice +when it changes: + +* growing by 1 skips a stamp, which is filled with a predicted input +* shrinking by 1 would stamp two inputs with the same tick, so the second one + is held for a tick and merged into the next one + +See [predicting and merging inputs] to customize these. + +## View delay + +*View delay* is the number of ticks between the tick a client stamps its +inputs with (`tick + lead`) and the tick of the host's simulation that the +client is seeing. When a client fires an input stamped `t`, it was seeing the +host's simulation at tick `t - view_delay`. The host uses this to find the tick +to rewind to when [registering projectiles](#projectiles). + +The host only knows the stamped tick of a fire input, so the lead is part of +the view delay. Example, with a lead of 5: + +``` +client tick when firing | 100 +newest state the client sees | 97 +fire input stamped | 100 + 5 = 105 +view delay | 105 - 97 = 8 -> host rewinds to 97 +``` + +Clients measure their view delay every time a simulator state arrives from the +host, and send samples to the host periodically, or immediately if it changed +enough. The view delay is reported by the client, so the host doesn't trust it +blindly: it can't rewind further than `netfox/simulator/max_view_delay_ticks`. + +## Projectiles + +The host records simulation aware collision shapes and areas for every tick. +This gives developers a common past world space, where collisions can be +checked against what the firing player was seeing, with +`force_raycast_update()` or `force_shapecast_update()`. + +Projectiles fired by clients are caught up on the host in this past world. +Register them on the host with +`SimulatorServer.register_projectile(projectile, firing_peer_id, fired_tick)`. + +`fired_tick` is the tick of the fire input, e.g. the tick passed to +`_simulated_tick` when the fire input is applied, or the tick given by +`input_applied`. + +```gdscript +func _simulated_tick(delta: float, tick: int, is_fresh: bool) -> void: + if input.fire and multiplayer.is_server(): + var bullet := bullet_scene.instantiate() as Bullet + get_tree().root.add_child(bullet) + bullet.global_transform = muzzle.global_transform + SimulatorServer.register_projectile(bullet, input.get_multiplayer_authority(), tick) +``` + +Projectiles must implement `step` and `is_alive`. `step` is called once for +every simulated tick: + +```gdscript +extends Node3D +class_name Bullet + +@export var speed: float = 50.0 + +@onready var _ray_cast: RayCast3D = $RayCast3D + +func step(delta: float, tick: int) -> void: + # Check the distance travelled this tick + _ray_cast.target_position = Vector3.FORWARD * speed * delta + _ray_cast.force_raycast_update() + if _ray_cast.is_colliding(): + queue_free() + return + + global_position += -global_basis.z * speed * delta + +func is_alive() -> bool: + return not is_queued_for_deletion() +``` + +The projectile is caught up from the tick of the host's simulation that the +firing peer was seeing at `fired_tick`. Projectiles are stepped after +Simulators, so stepping a projectile for tick T checks it against the world +after this tick's movement, the state of T + 1. The same is true while catching +up. + +### Immediate checks + +A projectile doesn't have to fly. For immediate checks, e.g. a hitscan weapon, +do a single raycast in the first `step`, then free the node. It's not a true +projectile, but it's checked against the same past world: + +```gdscript +func step(delta: float, tick: int) -> void: + _ray_cast.force_raycast_update() + if _ray_cast.is_colliding(): + _hit(_ray_cast.get_collider()) + queue_free() + +func is_alive() -> bool: + return not is_queued_for_deletion() +``` + +### Simulation aware collision + +To catch up projectiles, the host records collision shapes and areas for every +tick, and restores them while catching up. Add `CollisionShape3D`, +`CollisionShape2D`, `Area3D` or `Area2D` nodes to the +`simulation_aware_collision` group to have them recorded. Nodes that were not +alive at a tick are disabled while catching up. + +!!!note + Nodes are picked up when they enter the tree. Add them to the group in the + scene, or in `_enter_tree()`. + +## History size + +Inputs and predicted states are kept for ticks in the future too, so the +InputSender and Simulator history buffers hold `history_limit` ticks of the +past, plus room for the future. Example with the defaults, 64 ticks of past and +1 second of maximum lead at 30 ticks per second: + +``` +room for the future | 30 + 2 (tolerance) = 32 ticks +buffer size | 64 + 32 = 96 ticks +``` + +## Settings + +*netfox/input_sender/input_redundancy*: each input packet also carries this +many previous inputs, in case some packets are lost. + +*netfox/input_sender/history_limit*: ticks of past inputs kept. + +*netfox/input_sender/enable_input_broadcast*: send inputs to every peer, not +only the host. + +*netfox/simulator/full_state_interval*: ticks between full states, diff states +are sent in between. If set to 0, full states are never sent. If set to 1, only +full states are sent. + +*netfox/simulator/enable_diff_states*: send only what changed since the +previous tick between full states. + +*netfox/simulator/history_limit*: ticks of past simulator state kept, also how +far back projectiles can be caught up. + +*netfox/simulator/max_view_delay_ticks*: most ticks the host rewinds when +catching up a client's projectile. + +*netfox/simulator/input_lead_margin*: clients stamp their inputs ahead by `rtt +/ 2 + jitter + margin`, in seconds. + +*netfox/simulator/max_input_lead*: clients never stamp their inputs further +ahead than this, in seconds. The host drops inputs stamped further ahead. + +*netfox/simulator/view_delay_send_interval*: seconds between periodic view +delay samples sent by clients. + +*netfox/simulator/view_delay_change_threshold*: view delay changes of at least +this many seconds are sent immediately. + +## Limitations + +* Lead changes show as a one-tick skip or repeat of the local player. +* Prediction errors are not smoothed, the local player snaps to the truth. +* View delay can't tell a lag spike from "nothing changed". +* Diff states are not acknowledged. A lost diff is only fixed by the next full + state. +* Derived physics state, e.g. `is_on_floor()`, is not restored with the truth + when re-simulating. See [Rollback caveats]. + +## Examples + +See `examples/simulated-player` for a [Simulator] driven character, and +`examples/server-side-vehicle` for a physics tank using `input_applied` and +[predicting and merging inputs]. + +[rollback]: ./network-rollback.md +[Simulator]: ../nodes/simulator.md +[InputSender]: ../nodes/input-sender.md +[StateSynchronizer]: ../nodes/state-synchronizer.md +[time synchronization]: ./network-time-synchronizer.md +[predicting and merging inputs]: ../nodes/input-sender.md#predicting-and-merging-inputs +[Rollback caveats]: ../tutorials/rollback-caveats.md diff --git a/docs/netfox/nodes/input-sender.md b/docs/netfox/nodes/input-sender.md new file mode 100644 index 000000000..fa505a639 --- /dev/null +++ b/docs/netfox/nodes/input-sender.md @@ -0,0 +1,152 @@ +# InputSender + +!!!warning + InputSender is *experimental*, meaning the API may change in breaking + ways, and may be less stable than other features. Feedback is welcome! + +Stores inputs and sends them to the host. The host either runs them through a +[Simulator], or emits `input_applied` to run your own host side logic with +them. + +## Configuring input + +To use *InputSender*, add it as a child to the target node, and configure it: + +*Root* is the root node for resolving node paths in inputs. Defaults to the +parent node. + +*Input properties* define the input for the game simulation. Input is recorded +after every network tick. Make sure that nodes containing input properties are +owned by their respective players. + +!!!note + *InputSender* has authority over its inputs if any of its input nodes has + authority. It's your responsibility to give all input nodes the same + owner. + +See [Property paths] on how to specify properties. + +## Gathering input + +Inputs are gathered once per tick, the same way as for rollback: set the input +properties before the tick, e.g. on `NetworkTime.before_tick_loop`: + +```gdscript +extends Node +class_name PlayerInput + +var movement: Vector3 = Vector3.ZERO + +func _ready() -> void: + NetworkTime.before_tick_loop.connect(_gather) + +func _gather() -> void: + if not is_multiplayer_authority(): + return + + movement = Vector3( + Input.get_axis("move_west", "move_east"), + 0.0, + Input.get_axis("move_north", "move_south") + ) +``` + +Clients stamp their inputs ahead of their own tick, so they arrive at the host +before the host needs them. See [input lead]. + +## Host side logic + +`input_applied(tick, is_predicted)` is emitted on the host, after every tick, +when the inputs of `tick` are applied to the input properties. Use it to run +host side logic with the inputs, e.g. for physics vehicles synchronized with a +[StateSynchronizer]. Whatever you change here is recorded as the state of +`tick + 1`, the same as with [Simulator] and rollback. + +```gdscript +func _on_input_sender_input_applied(tick: int, is_predicted: bool) -> void: + engine_force = input.movement.y * engine_power + # Don't fire on a guessed input + if input.fire and not is_predicted: + fire(tick) +``` + +It's emitted for every player, including the host's own. If the inputs of the +tick didn't arrive in time, `is_predicted` is true. Inputs arriving after their +tick was applied are never applied, the host doesn't change the past. + +The signal is not emitted on clients, and only emitted if something is +connected to it. + +## Predicting and merging inputs + +Sometimes netfox needs an input that was never gathered, or has to fit two +gathered inputs into one tick: + +* **predict**: an input is needed for a tick, but there is none. This happens + when a client's input lead grows and a stamp is skipped, and when the host + simulates a player whose input didn't arrive in time. +* **merge**: two inputs get the same stamp. This happens when a client's input + lead shrinks. + +Both are rare. By default, predicting copies the last input, and merging keeps +the newer input. For example, inputs that are only true for a single tick, like +a jump press, are copied by default when predicting, and the press happens +twice. When merging, the default drops the older press. + +If the defaults don't fit, implement these methods in the *root* node's script: + +```gdscript +# Return the input for a tick that has none. last is the latest input +# before it. May be called for multiple ticks in a row with the same last. +func _predict_input(last: Dictionary) -> Dictionary: + var predicted := last.duplicate() + predicted["Input:jump"] = false + return predicted + +# Return a single input made from two inputs that got the same tick. +# older was gathered one tick before newer. +func _merge_input(older: Dictionary, newer: Dictionary) -> Dictionary: + var merged := newer.duplicate() + merged["Input:jump"] = older["Input:jump"] or newer["Input:jump"] + return merged +``` + +Inputs are dictionaries, with the paths in *input properties* as keys, and the +input values as values. Properties missing from the returned dictionary keep +the value from `last` or `newer`. + +## Schemas + +Use `set_schema()` to pick serializers for input properties, and lower +bandwidth. The schema must be the same on every peer: + +```gdscript +input_sender.set_schema({ + "Input:movement": NetworkSchemas.vec3f32(), + "Input:aim": NetworkSchemas.vec3f32(), + "Input:is_firing": NetworkSchemas.bool8() +}) +``` + +See [Network schemas] for more. + +## Changing configuration + +Call `process_settings()` after any change to configuration, or use +`add_input()`, which updates settings automatically. + +Whenever the authority of the input nodes changes, call `process_authority()`. +Make sure to do this at the same time on all peers. If a [Simulator] listens to +this *InputSender*, update it too, see [Simulator](./simulator.md#changing-ownership). + +## Visibility + +*InputSender* has a `visibility_filter` that decides which peers will receive +updates. See [Visibility management]. + +[Simulator]: ./simulator.md +[StateSynchronizer]: ./state-synchronizer.md +[input lead]: ../guides/simulation.md#input-lead +[Property paths]: ../guides/property-paths.md +[Network schemas]: ../guides/network-schemas.md +[Visibility management]: ../guides/visibility-management.md diff --git a/docs/netfox/nodes/simulator.md b/docs/netfox/nodes/simulator.md new file mode 100644 index 000000000..b270e82b8 --- /dev/null +++ b/docs/netfox/nodes/simulator.md @@ -0,0 +1,136 @@ +# Simulator + +!!!warning + Simulator is *experimental*, meaning the API may change in breaking ways, + and may be less stable than other features. + + *Simulator* is also a work in progress name, we couldn't find a better one + yet. Suggestions are welcome! + +Simulates network logic depending on network authority. The host runs the +simulation one tick per tick and broadcasts the ground truth, the player owning +the input predicts it locally, everyone else shows the truth. + +*Simulator* is an alternative to [RollbackSynchronizer]: the host never +rewinds or re-simulates, and only the local player is predicted. See +[Simulation] on how it works, and when to use which. + +## Configuring state and input + +To use *Simulator*, add it as a child to the target node, next to an +[InputSender], and configure it: + +*Root* is the root node for resolving node paths in properties. Defaults to +the parent node. + +*Listened input sender* is the [InputSender] whose inputs drive the +simulation. *Simulator* needs it assigned to work at all. + +*State properties* define the game state. State properties are recorded for +each tick. When the host broadcasts the truth, *Simulator* accepts it as the +true state and applies it. Make sure that nodes containing state properties +are owned by the host. + +Input properties are configured on the [InputSender]. + +See [Property paths] on how to specify properties. + +!!!warning + It is not recommended to have both state and input properties on the same + node. Nodes with state belong to the host, and nodes with input belong to + the player. + +## Writing simulated scripts + +During setup, *Simulator* finds all nodes under *root* that implement +`_simulated_tick`, and calls them every time a tick is simulated. + +Simulating works the same way as rollback. For tick T, the inputs of tick T are +applied, then `_simulated_tick(delta, T, is_fresh)` is called, and the +resulting state is recorded as the state of tick T + 1: + +```gdscript +extends CharacterBody3D + +@export var speed: float = 4.0 +@export var input: PlayerInput + +func _simulated_tick(delta: float, tick: int, is_fresh: bool) -> void: + # Inputs of tick are applied here, the state you leave behind + # is the state of tick + 1 + velocity = input.movement.normalized() * speed + + velocity *= NetworkTime.physics_factor + move_and_slide() + velocity /= NetworkTime.physics_factor +``` + +Note the usage of `physics_factor` - this is explained in [Rollback caveats]. + +## Peers + +*Simulator* works in one of three ways, depending on authority: + +*Host* - *Simulator* has authority, the input belongs to some other peer, or to +the host itself. The host runs `_simulated_tick` at its current tick with the +inputs received by [InputSender], then broadcasts the ground truth. If the +inputs of the tick didn't arrive in time, they're predicted, see +[InputSender](./input-sender.md#predicting-and-merging-inputs). + +*Authoritative peer* - the input belongs to the local peer, this is your +player. *Simulator* applies the latest truth received from the host, then +re-runs `_simulated_tick` with fresh local inputs, up to the current tick plus +the [input lead](../guides/simulation.md#input-lead). + +*Puppet peer* - neither *Simulator* nor the input has authority, this is how +you see remote players. *Simulator* only applies the truth received from the +host. We simply don't know their future inputs, so there's nothing to +re-simulate. + +## Single fire events + +`is_fresh` is true the first time a tick is simulated. Authoritative peers +simulate the same tick again after receiving the truth from the host, with +`is_fresh` false. Use it for effects like sounds that should play once. + +## Schemas + +Use `set_schema()` to pick serializers for state properties, and lower +bandwidth. The schema must be the same on every peer: + +```gdscript +simulator.set_schema({ + ":global_transform": NetworkSchemas.transform3f32(), + ":velocity": NetworkSchemas.vec3f32() +}) +``` + +Input properties get their schema on the [InputSender]. See [Network schemas] +for more. + +## Changing configuration + +*Simulator* has to do some setup work whenever the state properties change. +Call `process_settings()` after any change to configuration, or use +`add_state()`, which updates settings automatically. + +Changing or assigning the *listened input sender* during runtime is not +recommended, but also requires a call to `process_settings()`. + +## Changing ownership + +Whenever the authority of the [InputSender]'s input nodes changes, call +`process_authority()`. Make sure to do this at the same time on all peers. + +## Visibility + +*Simulator* has a `visibility_filter` that decides which peers will receive +updates. See [Visibility management]. + +[RollbackSynchronizer]: ./rollback-synchronizer.md +[InputSender]: ./input-sender.md +[Simulation]: ../guides/simulation.md +[Property paths]: ../guides/property-paths.md +[Network schemas]: ../guides/network-schemas.md +[Visibility management]: ../guides/visibility-management.md +[Rollback caveats]: ../tutorials/rollback-caveats.md diff --git a/mkdocs.yml b/mkdocs.yml index 133737960..347101da3 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -62,6 +62,7 @@ nav: - 'netfox/guides/network-performance.md' - 'netfox/guides/network-command-server.md' - 'netfox/guides/network-schemas.md' + - 'netfox/guides/simulation.md' - 'netfox/guides/visibility-management.md' - 'netfox/guides/netfox-sharp.md' - Nodes: @@ -70,6 +71,8 @@ nav: - 'netfox/nodes/predictive-synchronizer.md' - 'netfox/nodes/state-synchronizer.md' - 'netfox/nodes/rewindable-action.md' + - 'netfox/nodes/simulator.md' + - 'netfox/nodes/input-sender.md' - netfox.noray: - Guides: - 'netfox.noray/guides/noray.md' From 7ce409c250a42032652ba81a220815c40d51618f Mon Sep 17 00:00:00 2001 From: TuysuzTavsan Date: Sun, 18 Oct 2026 03:40:27 +0300 Subject: [PATCH 57/57] public_info_getters --- addons/netfox/servers/simulator-server.gd | 32 +++++++++++++++++++++++ addons/netfox/simulator-time-tracker.gd | 14 ++++++++++ 2 files changed, 46 insertions(+) diff --git a/addons/netfox/servers/simulator-server.gd b/addons/netfox/servers/simulator-server.gd index ab54af99d..a9794fdc2 100644 --- a/addons/netfox/servers/simulator-server.gd +++ b/addons/netfox/servers/simulator-server.gd @@ -231,6 +231,38 @@ func _get_view_delay_for(peer: int, tick: int) -> int: # It can't rewind further than max_view_delay_ticks. return clampi(view_delay, 0, maxi(0, _max_view_delay_ticks)) +## Returns how many ticks ahead this peer stamps its inputs, see +## [_SimulatorTimeTracker]. Always 0 on the host. +func get_input_lead_ticks() -> int: + return _time_tracker.get_input_lead() + +## Returns the maximum input lead in ticks, see +## [code]netfox/simulator/max_input_lead[/code]. +func get_max_input_lead_ticks() -> int: + return _time_tracker.get_max_input_lead_ticks() + +## Returns the maximum view delay in ticks, see +## [code]netfox/simulator/max_view_delay_ticks[/code]. The host never rewinds +## further than this when registering projectiles, see +## [method register_projectile]. +func get_max_view_delay_ticks() -> int: + return maxi(0, _max_view_delay_ticks) + +## Client side: returns the view delay this peer reports to the host, in ticks, +## or -1 if no simulator state has arrived yet. +## [br][br] +## The input lead is included, so this is how far the host rewinds when this +## peer fires, see [_SimulatorTimeTracker]. +func get_view_delay_ticks() -> int: + return _time_tracker.get_reported_view_delay() + +## Host side: returns the newest view delay reported by [param peer], in ticks, +## or -1 if it hasn't reported yet. +## [br][br] +## Not clamped to [method get_max_view_delay_ticks]. +func get_view_delay_ticks_for(peer: int) -> int: + return _time_tracker.get_latest_view_delay(peer) + # Returns how many ticks ahead we stamp our inputs. Always 0 on the host. # Used by InputSenderServer and NetworkTime internally. func _get_input_lead() -> int: diff --git a/addons/netfox/simulator-time-tracker.gd b/addons/netfox/simulator-time-tracker.gd index f56222909..b8b68b53c 100644 --- a/addons/netfox/simulator-time-tracker.gd +++ b/addons/netfox/simulator-time-tracker.gd @@ -256,6 +256,20 @@ func get_view_delay(peer: int, tick: int, default: int) -> int: return view_delay as int +## Client side: returns the view delay this client reports to the host, lead +## included, or -1 if no simulator state arrived yet. Same value as the next +## sample sent, see [method _send_view_delay]. +func get_reported_view_delay() -> int: + if _latest_received_tick < 0: + return -1 + return maxi(0, _local_view_delay + _input_lead) + +## Host side: returns the newest view delay [param peer] reported, or -1 if it +## hasn't reported yet. Not clamped to [code]max_view_delay_ticks[/code]. +func get_latest_view_delay(peer: int) -> int: + var newest_accepted_tick := NetworkTime.tick + get_max_input_lead_ticks() + INPUT_TICK_TOLERANCE + return get_view_delay(peer, newest_accepted_tick, -1) + # Client side, called for every simulator state received from the host. func _on_simulator_state(snapshot: _Snapshot) -> void: # Ignore states arriving out of order, the client already sees a newer one