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/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 diff --git a/addons/netfox/input-sender.gd b/addons/netfox/input-sender.gd new file mode 100644 index 000000000..e95b2576a --- /dev/null +++ b/addons/netfox/input-sender.gd @@ -0,0 +1,249 @@ +@tool +extends Node +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]. +## +## @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) + +## 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() + +var _input_properties := _PropertyPool.new() +var _properties_dirty: bool = false + +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] = [] + +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() + +func _exit_tree(): + if Engine.is_editor_hint(): + return + + InputSenderServer._deregister_input_sender(self) + +## 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() + + _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) + +## 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(): + 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. +## [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() + +## 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 + +## 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() + 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() + +# 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) + +# Helper function to save current input_properties. +# 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/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 e7592fba2..a3c2eafc5 100644 --- a/addons/netfox/netfox.gd +++ b/addons/netfox/netfox.gd @@ -169,7 +169,46 @@ var SETTINGS: Array[Dictionary] = [ "name": "netfox/events/enabled", "value": true, "type": TYPE_BOOL - } + }, + # Input Sender + { + "name": "netfox/input_sender/input_redundancy", + "value": 3, + "type" : TYPE_INT + }, + { + "name": "netfox/input_sender/history_limit", + "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, + "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 + }, + { + "name": "netfox/simulator/history_limit", + "value": 64, + "type" : TYPE_INT + }, ] const AUTOLOADS: Array[Dictionary] = [ @@ -220,7 +259,15 @@ const AUTOLOADS: Array[Dictionary] = [ { "name": "InterpolationServer", "path": ROOT + "/servers/interpolation-server.gd" - } + }, + { + "name": "InputSenderServer", + "path": ROOT + "/servers/input-sender-server.gd" + }, + { + "name": "SimulatorServer", + "path": ROOT + "/servers/simulator-server.gd" + }, ] const TYPES: Array[Dictionary] = [ @@ -254,6 +301,18 @@ const TYPES: Array[Dictionary] = [ "script": ROOT + "/rollback/predictive-synchronizer.gd", "icon": ROOT + "/icons/predictive-synchronizer.svg" }, + { + "name": "InputSender", + "base": "Node", + "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/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/input-sender-server.gd b/addons/netfox/servers/input-sender-server.gd new file mode 100644 index 000000000..beb1b11e3 --- /dev/null +++ b/addons/netfox/servers/input-sender-server.gd @@ -0,0 +1,287 @@ +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. + +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) + +# 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 = {} + +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. + # 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) + ) + + _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 + + 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 + + # 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. + 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) + + # 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) + + _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: + + # 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() + +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 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/network-history-server.gd b/addons/netfox/servers/network-history-server.gd index 6d511f022..f8aae33e6 100644 --- a/addons/netfox/servers/network-history-server.gd +++ b/addons/netfox/servers/network-history-server.gd @@ -5,19 +5,25 @@ 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- 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() @@ -25,11 +31,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 _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") @@ -57,6 +67,22 @@ 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 input_sender input property +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: + _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: @@ -64,14 +90,20 @@ func deregister(node: Node) -> void: _rb_state_properties.erase_subject(node) _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]: + 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) @@ -111,6 +143,16 @@ 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_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, _simulator_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: @@ -161,6 +203,17 @@ func _record_sync_state(tick: int) -> void: return subject.is_multiplayer_authority() ) +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() + ) + +func _record_simulator(tick: int) -> void: + _record(tick, _simulator_history, _simulator_snapshots, _simulator_properties, true, func(subject: Node): + return subject.is_multiplayer_authority() + ) + func _restore_rollback_input(tick: int) -> bool: return _restore_latest(tick, _rb_input_history) @@ -170,6 +223,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: + 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) @@ -179,6 +238,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: + 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) @@ -191,6 +256,14 @@ func _merge_synchronizer_state(snapshot: _Snapshot) -> bool: _merge_snapshot(snapshot, _sync_state_snapshots, true) return _merge_history(snapshot, _sync_history) +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 430b2ac95..dc2c65d47 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,10 @@ 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_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 @@ -36,8 +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_redundancy := NetworkRollback.input_redundancy +var _rb_input_redundancy := NetworkRollback.input_redundancy var _last_sync_state_sent := _Snapshot.new(0) var _sync_enable_diffs := ProjectSettings.get_setting("netfox/state_synchronizer/enable_diff_states", true) as bool @@ -48,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 @@ -58,6 +69,11 @@ 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_full_simulator: NetworkCommandServer.Command +var _cmd_diff_simulator: NetworkCommandServer.Command + var _cmd_full_sync: NetworkCommandServer.Command var _cmd_diff_sync: NetworkCommandServer.Command @@ -65,6 +81,8 @@ static var _logger := NetfoxLogger._for_netfox("NetworkSynchronizationServer") signal _on_input(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 @@ -105,6 +123,32 @@ 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 input_sender input +func register_input_sender(node: Node, property: NodePath) -> void: + _input_sender_properties.add(node, property) + if node.is_multiplayer_authority(): + _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(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: @@ -136,6 +180,10 @@ 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_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) @@ -175,7 +223,7 @@ func _synchronize_input(tick: int) -> void: notified_peers.erase(multiplayer.get_unique_id()) # Prepare snapshot package - for offset in _input_redundancy: + for offset in _rb_input_redundancy: # Grab snapshot from NetworkHistoryServer var snapshot := NetworkHistoryServer._get_rollback_input_snapshot(tick - offset) if not snapshot: @@ -286,6 +334,93 @@ func _synchronize_sync_state(tick: int) -> void: # NOTE: This is a shared instance, theoretically shouldn't screw things up _last_sync_state_sent = snapshot +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) + 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) + _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 + + var snapshot := NetworkHistoryServer._get_simulator_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, _simulator_owned_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, p_history_server: _NetworkHistoryServer = null, @@ -317,7 +452,13 @@ 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) + + # 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) @@ -371,6 +512,41 @@ 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) + + _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() + buffer.data_array = data + + var snapshot := _sparse_serializer.read_from(sender, _simulator_properties, buffer) + snapshot.sanitize(sender) + + _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/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 new file mode 100644 index 000000000..1e5015fe5 --- /dev/null +++ b/addons/netfox/simulator.gd @@ -0,0 +1,255 @@ +@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. +## +## 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). +## 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 +## 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] + +## 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 + +@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] + +# 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 + +# 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(): + 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 + + _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(): + 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. + 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. +## [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() + +## 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 + + _properties_dirty = false + + 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) -> 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]: + 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 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..f804fa18d --- /dev/null +++ b/examples/server-side-vehicle/scenes/server_side_tank.tscn @@ -0,0 +1,313 @@ +[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 + +[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" 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) +linear_damp = 0.13 +angular_damp = 0.1 +script = ExtResource("1_jtlcb") +turret = NodePath("Turret") +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", "TankInput:fire"]) + +[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", "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") + +[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="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 +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 +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) +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 +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) +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 +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) +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 +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) +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 +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) +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 +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) +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 +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) +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 +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"] 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/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_arena.tscn b/examples/server-side-vehicle/scenes/tank_arena.tscn new file mode 100644 index 000000000..cc7db0e81 --- /dev/null +++ b/examples/server-side-vehicle/scenes/tank_arena.tscn @@ -0,0 +1,353 @@ +[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"] +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="."] + +[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/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 new file mode 100644 index 000000000..23d247964 --- /dev/null +++ b/examples/server-side-vehicle/scripts/player_spawner.gd @@ -0,0 +1,74 @@ +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 + avatar.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("TankInput") + 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 + # 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 + +# 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 new file mode 100644 index 000000000..577fef997 --- /dev/null +++ b/examples/server-side-vehicle/scripts/server_side_tank.gd @@ -0,0 +1,138 @@ +extends VehicleBody3D + +## Script example for server side coded tank. + +@export_category("movement") +@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.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 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 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(): + _last_fire_tick = NetworkTime.tick + if tank_input.get_multiplayer_authority() == multiplayer.get_unique_id(): + 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 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 + +# 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 + + 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) + +# 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) + +# Fires only on the host.. +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 + +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): + 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) 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..8c9475ef5 --- /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 = "Score: " + str(tank.score) 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..600705666 --- /dev/null +++ b/examples/server-side-vehicle/scripts/tank_input.gd @@ -0,0 +1,68 @@ +extends Node + +## ServerSideTank input script + +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") + 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) + else: + Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED) + +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 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/examples/simulated-player/scenes/simulated_player.tscn b/examples/simulated-player/scenes/simulated_player.tscn new file mode 100644 index 000000000..431aeeb8e --- /dev/null +++ b/examples/simulated-player/scenes/simulated_player.tscn @@ -0,0 +1,35 @@ +[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_1fmuj"] +[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"] +collision_layer = 3 +collision_mask = 3 +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_1fmuj") +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..aa7711a05 --- /dev/null +++ b/examples/simulated-player/scripts/simulated_player.gd @@ -0,0 +1,39 @@ +extends CharacterBody3D + +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") + +@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) + 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) + + 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 new file mode 100644 index 000000000..11f97bb0f --- /dev/null +++ b/examples/simulated-player/scripts/simulated_player_input.gd @@ -0,0 +1,22 @@ +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") + print("inputs: movement %s, jump %s" %[movement, 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..0ac165d7c --- /dev/null +++ b/examples/simulated-player/simulated_player_example.tscn @@ -0,0 +1,24 @@ +[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")] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1.65592, 0) + +[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(".") 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]