diff --git a/README.md b/README.md index 7a7412ca..ade54f76 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,8 @@ and netfox. * [Multiplayer FPS](examples/multiplayer-fps) * [Rollback Debugger](examples/rollback-debugger) * [Property Configuration](examples/property-configuration) +* [Input prediction](examples/input-prediction) +* [NPCs with rollback](examples/rollback-npc) #### Example game diff --git a/addons/netfox.extras/plugin.cfg b/addons/netfox.extras/plugin.cfg index 31d75b0e..a834c80d 100644 --- a/addons/netfox.extras/plugin.cfg +++ b/addons/netfox.extras/plugin.cfg @@ -3,5 +3,5 @@ name="netfox.extras" description="Game-specific utilities for Netfox" author="Tamas Galffy and contributors" -version="1.25.6" +version="1.26.0" script="netfox-extras.gd" diff --git a/addons/netfox.internals/plugin.cfg b/addons/netfox.internals/plugin.cfg index 7335cf3a..8998f767 100644 --- a/addons/netfox.internals/plugin.cfg +++ b/addons/netfox.internals/plugin.cfg @@ -3,5 +3,5 @@ name="netfox.internals" description="Shared internals for netfox addons" author="Tamas Galffy and contributors" -version="1.25.6" +version="1.26.0" script="plugin.gd" diff --git a/addons/netfox.noray/plugin.cfg b/addons/netfox.noray/plugin.cfg index 85f19fc0..6651a2f8 100644 --- a/addons/netfox.noray/plugin.cfg +++ b/addons/netfox.noray/plugin.cfg @@ -3,5 +3,5 @@ name="netfox.noray" description="Bulletproof your connectivity with noray integration for netfox" author="Tamas Galffy and contributors" -version="1.25.6" +version="1.26.0" script="netfox-noray.gd" diff --git a/addons/netfox/plugin.cfg b/addons/netfox/plugin.cfg index 55814847..214057fd 100644 --- a/addons/netfox/plugin.cfg +++ b/addons/netfox/plugin.cfg @@ -3,5 +3,5 @@ name="netfox" description="Shared internals for netfox addons" author="Tamas Galffy and contributors" -version="1.25.6" +version="1.26.0" script="netfox.gd" diff --git a/addons/netfox/rollback/rollback-synchronizer.gd b/addons/netfox/rollback/rollback-synchronizer.gd index 1f1c0c64..7e7deb43 100644 --- a/addons/netfox/rollback/rollback-synchronizer.gd +++ b/addons/netfox/rollback/rollback-synchronizer.gd @@ -79,6 +79,7 @@ var _states := _PropertyHistoryBuffer.new() var _inputs := _PropertyHistoryBuffer.new() var _latest_state_tick: int var _earliest_input_tick: int +var _last_simulated_tick: int # Maps peers (int) to acknowledged ticks (int) var _ackd_state: Dictionary = {} @@ -112,8 +113,11 @@ func process_settings() -> void: _states.clear() _inputs.clear() _ackd_state.clear() + _latest_state_tick = NetworkTime.tick - 1 _earliest_input_tick = NetworkTime.tick + _last_simulated_tick = NetworkTime.tick - 1 + _next_full_state_tick = NetworkTime.tick _next_diff_ack_tick = NetworkTime.tick @@ -357,7 +361,6 @@ func _prepare_tick(tick: int) -> void: # Save data for input prediction _has_input = retrieved_tick != -1 _input_tick = retrieved_tick - _is_predicted_tick = not _inputs.has(tick) # Reset the set of simulated and ignored nodes _simset.clear() @@ -369,21 +372,36 @@ func _prepare_tick(tick: int) -> void: NetworkRollback.notify_simulated(node) func _can_simulate(node: Node, tick: int) -> bool: - if not enable_prediction and not _inputs.has(tick): - # Don't simulate if prediction is not allowed and input is unknown + if not enable_prediction and _is_predicted_tick_for(node, tick): + # Don't simulate if prediction is not allowed and tick is predicted return false if NetworkRollback.is_mutated(node, tick): # Mutated nodes are always resimulated return true + if input_properties.is_empty(): + # If we're running inputless and own the node, simulate it if we haven't + if node.is_multiplayer_authority(): + return tick > _last_simulated_tick + # If we're running inputless and don't own the node, only run as prediction + return enable_prediction if node.is_multiplayer_authority(): # Simulate from earliest input # Don't simulate frames we don't have input for - return tick >= _earliest_input_tick + return tick >= _earliest_input_tick and _inputs.has(tick) else: # Simulate ONLY if we have state from server # Simulate from latest authorative state - anything the server confirmed we don't rerun # Don't simulate frames we don't have input for - return tick >= _latest_state_tick + return tick >= _latest_state_tick and _inputs.has(tick) + +func _is_predicted_tick_for(node: Node, tick: int) -> bool: + if input_properties.is_empty(): + # We're running without inputs + # It's only predicted if we don't own the node + return not node.is_multiplayer_authority() + else: + # We have input properties, it's only predicted if we don't have the input for the tick + return not _inputs.has(tick) func _process_tick(tick: int) -> void: # Simulate rollback tick @@ -397,6 +415,7 @@ func _process_tick(tick: int) -> void: continue var is_fresh := _freshness_store.is_fresh(node, tick) + _is_predicted_tick = _is_predicted_tick_for(node, tick) NetworkRollback.process_rollback(node, NetworkTime.ticktime, tick, is_fresh) if _skipset.has(node): @@ -407,12 +426,13 @@ func _process_tick(tick: int) -> void: func _record_tick(tick: int) -> void: # Broadcast state we own - if not _auth_state_property_entries.is_empty() and not _is_predicted_tick: + if not _auth_state_property_entries.is_empty(): var full_state := _PropertySnapshot.new() for property in _auth_state_property_entries: if _can_simulate(property.node, tick - 1) \ and not _skipset.has(property.node) \ + and not _is_predicted_tick_for(property.node, tick - 1) \ or NetworkRollback.is_mutated(property.node, tick - 1): # Only broadcast if we've simulated the node # NOTE: _can_simulate checks mutations, but to override _skipset @@ -492,6 +512,9 @@ func _record_tick(tick: int) -> void: _states.set_snapshot(tick, merge_state.merge(record_state)) + # Ack simulation for tick + _last_simulated_tick = maxi(_last_simulated_tick, tick - 1) + # Push metrics NetworkPerformance.push_rollback_nodes_simulated(_simset.size()) diff --git a/examples/rollback-npc/README.md b/examples/rollback-npc/README.md new file mode 100644 index 00000000..955e8faf --- /dev/null +++ b/examples/rollback-npc/README.md @@ -0,0 +1,11 @@ +# Rollback NPC example + +A demo game, demonstrating how to implement server-controlled NPCs that +participate in rollback. + +With `RollbackSynchronizer` supporting no input, NPCs ( and other nodes without +input ) can be built the same way as e.g. player nodes. + +To edit and/or run, open the Godot project in the repository root, and open the +scene in this directory. + diff --git a/examples/rollback-npc/rollback-npc.tscn b/examples/rollback-npc/rollback-npc.tscn new file mode 100644 index 00000000..0111f849 --- /dev/null +++ b/examples/rollback-npc/rollback-npc.tscn @@ -0,0 +1,37 @@ +[gd_scene load_steps=7 format=3 uid="uid://cg0vfsftxxwv0"] + +[ext_resource type="PackedScene" uid="uid://cngy6hs8ohodj" path="res://examples/shared/scenes/map-square.tscn" id="1_os5l0"] +[ext_resource type="PackedScene" uid="uid://cncdbq72u50j3" path="res://examples/shared/scenes/environment.tscn" id="2_and7e"] +[ext_resource type="Script" path="res://examples/shared/scripts/player-spawner.gd" id="3_1ga2x"] +[ext_resource type="PackedScene" uid="uid://badtpsxn5lago" path="res://examples/shared/ui/network-popup.tscn" id="3_1tn81"] +[ext_resource type="PackedScene" uid="uid://bl30av5bcvd4d" path="res://examples/rollback-npc/scenes/player.tscn" id="4_pr3ur"] +[ext_resource type="PackedScene" uid="uid://n5t5ahafwaln" path="res://examples/rollback-npc/scenes/npc.tscn" id="6_3tc5x"] + +[node name="rollback-npc" type="Node3D"] + +[node name="Square Map" parent="." instance=ExtResource("1_os5l0")] + +[node name="Environment" parent="." instance=ExtResource("2_and7e")] + +[node name="Player Spawner" type="Node" parent="." node_paths=PackedStringArray("spawn_root")] +script = ExtResource("3_1ga2x") +player_scene = ExtResource("4_pr3ur") +spawn_root = NodePath(".") + +[node name="UI" type="Control" parent="."] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Network Popup" parent="UI" instance=ExtResource("3_1tn81")] +layout_mode = 1 +offset_left = -180.0 +offset_top = -120.0 +offset_right = 180.0 +offset_bottom = 120.0 + +[node name="NPC" parent="." instance=ExtResource("6_3tc5x")] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 6, 1, -6) diff --git a/examples/rollback-npc/scenes/npc.tscn b/examples/rollback-npc/scenes/npc.tscn new file mode 100644 index 00000000..6c807885 --- /dev/null +++ b/examples/rollback-npc/scenes/npc.tscn @@ -0,0 +1,51 @@ +[gd_scene load_steps=7 format=3 uid="uid://n5t5ahafwaln"] + +[ext_resource type="Script" path="res://examples/rollback-npc/scripts/npc.gd" id="1_6wds5"] +[ext_resource type="Script" path="res://addons/netfox/rollback/rollback-synchronizer.gd" id="2_a8m5g"] +[ext_resource type="Script" path="res://addons/netfox/tick-interpolator.gd" id="3_ql8cj"] + +[sub_resource type="SphereMesh" id="SphereMesh_3ov8j"] + +[sub_resource type="CylinderMesh" id="CylinderMesh_tsqs5"] +top_radius = 0.0 +height = 0.5 + +[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_2yxbx"] +height = 1.5 + +[node name="NPC" type="CharacterBody3D"] +collision_layer = 2 +collision_mask = 3 +script = ExtResource("1_6wds5") + +[node name="Mesh" type="Node3D" parent="."] + +[node name="Body" type="MeshInstance3D" parent="Mesh"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.25, 0) +mesh = SubResource("SphereMesh_3ov8j") +skeleton = NodePath("../..") + +[node name="Hat" type="MeshInstance3D" parent="Mesh"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5, 0) +mesh = SubResource("CylinderMesh_tsqs5") +skeleton = NodePath("../..") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="."] +shape = SubResource("CapsuleShape3D_2yxbx") + +[node name="RollbackSynchronizer" type="Node" parent="." node_paths=PackedStringArray("root")] +script = ExtResource("2_a8m5g") +root = NodePath("..") +enable_prediction = true +state_properties = Array[String]([":position", ":velocity"]) + +[node name="TickInterpolator" type="Node" parent="." node_paths=PackedStringArray("root")] +script = ExtResource("3_ql8cj") +root = NodePath("..") +properties = Array[String]([":position"]) + +[node name="Label3D" type="Label3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0) +billboard = 1 +text = "NPC" +font_size = 96 diff --git a/examples/rollback-npc/scenes/player.tscn b/examples/rollback-npc/scenes/player.tscn new file mode 100644 index 00000000..539bc505 --- /dev/null +++ b/examples/rollback-npc/scenes/player.tscn @@ -0,0 +1,37 @@ +[gd_scene load_steps=8 format=3 uid="uid://bl30av5bcvd4d"] + +[ext_resource type="Script" path="res://examples/rollback-npc/scripts/player.gd" id="1_d0kje"] +[ext_resource type="Script" path="res://examples/rollback-npc/scripts/player-input.gd" id="2_us2sq"] +[ext_resource type="Script" path="res://addons/netfox/rollback/rollback-synchronizer.gd" id="3_bf3yl"] +[ext_resource type="Script" path="res://addons/netfox/tick-interpolator.gd" id="4_4lenk"] + +[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_bmc6p"] + +[sub_resource type="CapsuleMesh" id="CapsuleMesh_t0alo"] +material = SubResource("StandardMaterial3D_bmc6p") + +[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_1dm6n"] + +[node name="Player" type="CharacterBody3D" groups=["Players"]] +collision_mask = 3 +script = ExtResource("1_d0kje") + +[node name="MeshInstance3D" type="MeshInstance3D" parent="."] +mesh = SubResource("CapsuleMesh_t0alo") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="."] +shape = SubResource("CapsuleShape3D_1dm6n") + +[node name="Input" type="Node" parent="."] +script = ExtResource("2_us2sq") + +[node name="RollbackSynchronizer" type="Node" parent="." node_paths=PackedStringArray("root")] +script = ExtResource("3_bf3yl") +root = NodePath("..") +state_properties = Array[String]([":transform", ":velocity"]) +input_properties = Array[String](["Input:movement"]) + +[node name="TickInterpolator" type="Node" parent="." node_paths=PackedStringArray("root")] +script = ExtResource("4_4lenk") +root = NodePath("..") +properties = Array[String]([":transform"]) diff --git a/examples/rollback-npc/scripts/npc.gd b/examples/rollback-npc/scripts/npc.gd new file mode 100644 index 00000000..2108f2eb --- /dev/null +++ b/examples/rollback-npc/scripts/npc.gd @@ -0,0 +1,76 @@ +@tool +extends CharacterBody3D + +@export var speed: float = 2.0 +@export var sensor_radius: float = 4.0 +@export var min_radius: float = 1.5 + +@onready var label := $Label3D as Label3D +@onready var rbs := $RollbackSynchronizer as RollbackSynchronizer + +# Get the gravity from the project settings to be synced with RigidBody nodes. +var gravity = ProjectSettings.get_setting(&"physics/3d/default_gravity") + +func _get_rollback_state_properties() -> Array: + return [ + "position", + "velocity" + ] + +func _get_interpolated_properties() -> Array: + return [ + "position" + ] + +func _rollback_tick(dt, _tick, _is_fresh: bool): + label.text = "pre" if rbs.is_predicting() else "sim" + + # Add gravity + _force_update_is_on_floor() + if not is_on_floor(): + velocity.y -= gravity * dt + + var target_motion := Vector3.ZERO + var nearby_player := _find_nearby_player() + if nearby_player: + target_motion = nearby_player.global_position - global_position + target_motion.y = 0. + + target_motion = target_motion.normalized() * speed + + velocity.x = move_toward(velocity.x, target_motion.x, speed / 0.35 * dt) + velocity.z = move_toward(velocity.z, target_motion.z, speed / 0.35 * dt) + + # move_and_slide assumes physics delta + # multiplying velocity by NetworkTime.physics_factor compensates for it + velocity *= NetworkTime.physics_factor + move_and_slide() + velocity /= NetworkTime.physics_factor + +func _find_nearby_player() -> Node3D: + var players := get_tree().get_nodes_in_group(&"Players") + if players.is_empty(): + return null + + var sensor_radius_squared := pow(sensor_radius, 2.0) + var min_radius_squared := pow(min_radius, 2.0) + + var closest_player: Node3D = null + var closest_distance := INF + for player in players: + var distance := global_position.distance_squared_to(player.global_position) + + if distance >= sensor_radius_squared or distance <= min_radius_squared: + continue + + if distance < closest_distance: + closest_distance = distance + closest_player = player + + return closest_player + +func _force_update_is_on_floor(): + var old_velocity = velocity + velocity = Vector3.ZERO + move_and_slide() + velocity = old_velocity diff --git a/examples/rollback-npc/scripts/npc.gd.uid b/examples/rollback-npc/scripts/npc.gd.uid new file mode 100644 index 00000000..2bfad08f --- /dev/null +++ b/examples/rollback-npc/scripts/npc.gd.uid @@ -0,0 +1 @@ +uid://7ru5vkblxide diff --git a/examples/rollback-npc/scripts/player-input.gd b/examples/rollback-npc/scripts/player-input.gd new file mode 100644 index 00000000..b458c752 --- /dev/null +++ b/examples/rollback-npc/scripts/player-input.gd @@ -0,0 +1,16 @@ +@tool +extends BaseNetInput + +var movement: Vector3 + +func _get_rollback_input_properties() -> Array: + return [ + "movement" + ] + +func _gather(): + movement = Vector3( + Input.get_axis("move_west", "move_east"), + 0.0, + Input.get_axis("move_north", "move_south") + ) diff --git a/examples/rollback-npc/scripts/player-input.gd.uid b/examples/rollback-npc/scripts/player-input.gd.uid new file mode 100644 index 00000000..554df13a --- /dev/null +++ b/examples/rollback-npc/scripts/player-input.gd.uid @@ -0,0 +1 @@ +uid://brtfiqnk5alw6 diff --git a/examples/rollback-npc/scripts/player.gd b/examples/rollback-npc/scripts/player.gd new file mode 100644 index 00000000..47fd1106 --- /dev/null +++ b/examples/rollback-npc/scripts/player.gd @@ -0,0 +1,65 @@ +@tool +extends CharacterBody3D + +@export var speed = 5.0 +@export var input: Node + +# Get the gravity from the project settings to be synced with RigidBody nodes. +var gravity = ProjectSettings.get_setting(&"physics/3d/default_gravity") + +func _get_rollback_state_properties() -> Array: + return [ + "transform", + "velocity" + ] + +func _get_interpolated_properties() -> Array: + return [ + "transform" + ] + +func _ready(): + if Engine.is_editor_hint(): return + + if input == null: + input = $Input + + position = Vector3(0, 4, 0) + + # Assign a random color + var player_id := input.get_multiplayer_authority() + var mesh := $MeshInstance3D as MeshInstance3D + + var color := Color.from_hsv((hash(player_id) % 256) / 256.0, 1.0, 1.0) + var material := mesh.get_active_material(0) as StandardMaterial3D + material = material.duplicate() + material.albedo_color = color + mesh.set_surface_override_material(0, material) + +func _rollback_tick(dt, _tick, _is_fresh: bool): + # Add gravity + _force_update_is_on_floor() + if not is_on_floor(): + velocity.y -= gravity * dt + + var input_dir = input.movement + var direction = (transform.basis * Vector3(input_dir.x, 0, input_dir.z)).normalized() + if direction: + velocity.x = direction.x * speed + velocity.z = direction.z * speed + else: + velocity.x = move_toward(velocity.x, 0, speed) + velocity.z = move_toward(velocity.z, 0, speed) + + # move_and_slide assumes physics delta + # multiplying velocity by NetworkTime.physics_factor compensates for it + velocity *= NetworkTime.physics_factor + move_and_slide() + velocity /= NetworkTime.physics_factor + +func _force_update_is_on_floor(): + var old_velocity = velocity + velocity = Vector3.ZERO + move_and_slide() + velocity = old_velocity + diff --git a/examples/rollback-npc/scripts/player.gd.uid b/examples/rollback-npc/scripts/player.gd.uid new file mode 100644 index 00000000..d5afad0a --- /dev/null +++ b/examples/rollback-npc/scripts/player.gd.uid @@ -0,0 +1 @@ +uid://cn33wgc64tn4k diff --git a/examples/shared/scenes/environment.tscn b/examples/shared/scenes/environment.tscn new file mode 100644 index 00000000..112070a1 --- /dev/null +++ b/examples/shared/scenes/environment.tscn @@ -0,0 +1,22 @@ +[gd_scene load_steps=4 format=3 uid="uid://cncdbq72u50j3"] + +[sub_resource type="ProceduralSkyMaterial" id="ProceduralSkyMaterial_bioft"] + +[sub_resource type="Sky" id="Sky_n7qj2"] +sky_material = SubResource("ProceduralSkyMaterial_bioft") + +[sub_resource type="Environment" id="Environment_ge6yj"] +background_mode = 2 +sky = SubResource("Sky_n7qj2") + +[node name="Environment" type="Node"] + +[node name="DirectionalLight3D" type="DirectionalLight3D" parent="."] +transform = Transform3D(0.5, 0.612372, -0.612372, 0, 0.707107, 0.707107, 0.866025, -0.353553, 0.353553, -6.7361, 8.77817, 3.88909) +shadow_enabled = true + +[node name="Camera3D" type="Camera3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 0.5, 0.866025, 0, -0.866025, 0.5, 0, 14.3564, 8.86602) + +[node name="WorldEnvironment" type="WorldEnvironment" parent="."] +environment = SubResource("Environment_ge6yj") diff --git a/examples/shared/scenes/map-square.tscn b/examples/shared/scenes/map-square.tscn new file mode 100644 index 00000000..a89c5d12 --- /dev/null +++ b/examples/shared/scenes/map-square.tscn @@ -0,0 +1,39 @@ +[gd_scene format=3 uid="uid://cngy6hs8ohodj"] + +[node name="Square Map" type="Node3D"] + +[node name="CSGCombiner3D" type="CSGCombiner3D" parent="."] +use_collision = true +collision_layer = 2 + +[node name="CSGFloor" type="CSGBox3D" parent="CSGCombiner3D"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.25, 0) +size = Vector3(16, 0.5, 16) + +[node name="CSGWall" type="CSGBox3D" parent="CSGCombiner3D"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 8) +size = Vector3(16, 2, 1) + +[node name="CSGWall2" type="CSGBox3D" parent="CSGCombiner3D"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, -8) +size = Vector3(16, 2, 1) + +[node name="CSGWall3" type="CSGBox3D" parent="CSGCombiner3D"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 8, 1, 0) +size = Vector3(1, 2, 16) + +[node name="CSGWall4" type="CSGBox3D" parent="CSGCombiner3D"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -8, 1, 0) +size = Vector3(1, 2, 16) + +[node name="CSGCorner" type="CSGCylinder3D" parent="CSGCombiner3D"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 8, 1, 8) + +[node name="CSGCorner2" type="CSGCylinder3D" parent="CSGCombiner3D"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 8, 1, -8) + +[node name="CSGCorner3" type="CSGCylinder3D" parent="CSGCombiner3D"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -8, 1, -8) + +[node name="CSGCorner4" type="CSGCylinder3D" parent="CSGCombiner3D"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -8, 1, 8)