diff --git a/Containers/container.gd b/Containers/container.gd index 2ceb2db..4aa4cfd 100644 --- a/Containers/container.gd +++ b/Containers/container.gd @@ -154,19 +154,22 @@ func refresh_visuals(ids: Array[String]) -> void: visual.rotation = positions[idx].rotation -# Strip interactivity/networking from a display-only copy: it's not spawned -# through NetworkManager, so it must never try to sync (its NetPickable child, -# if any, would have no corresponding replicated identity on other peers) or -# be grabbable/collidable. +# Strip interactivity from a display-only copy. It is instantiated straight from +# its scene rather than spawned through NetWorld, so it is not part of the +# replicated world at all — it exists only to be looked at, and must not be +# grabbable or collidable. +# +# It gets no synchronizers either: NetReplication.attach only ever runs for +# direct children of the content root, and this is parented under a plate. func _make_cosmetic(visual: Node3D) -> void: - var net_pickable := visual.get_node_or_null("NetPickable") - if net_pickable: - # Detach and free it outright rather than queue_free(): this runs before + var despawn_timer := visual.get_node_or_null("DespawningItem") + if despawn_timer: + # Detached and freed outright rather than queue_free()d: this runs before # `visual` is added to the tree, and a merely-queued node still enters the - # tree with its parent and runs _ready() (which starts syncing and logging) - # before the queued deletion lands at the end of the frame. - visual.remove_child(net_pickable) - net_pickable.free() + # tree with its parent and runs a frame of _process before the queued + # deletion lands at the end of the frame. + visual.remove_child(despawn_timer) + despawn_timer.free() if visual is RigidBody3D: visual.freeze = true # STATIC, not KINEMATIC: a kinematic body is still driven by the physics @@ -193,9 +196,6 @@ func _make_cosmetic(visual: Node3D) -> void: func _remove_from_physics(visual: Node3D) -> void: if visual is RigidBody3D: PhysicsServer3D.body_set_space((visual as RigidBody3D).get_rid(), RID()) - var despawning_item = visual.get_node_or_null("DespawningItem") - if despawning_item: - NetworkManager.despawn_item(despawning_item) #plate (Pickalbe) diff --git a/Containers/plate.tscn b/Containers/plate.tscn index cd06c7f..4431371 100644 --- a/Containers/plate.tscn +++ b/Containers/plate.tscn @@ -8,7 +8,6 @@ [ext_resource type="Script" uid="uid://dvobm6vcfnqe8" path="res://addons/godot-xr-tools/hands/poses/hand_pose_settings.gd" id="4_dhtvl"] [ext_resource type="PackedScene" uid="uid://ctw7nbntd5pcj" path="res://addons/godot-xr-tools/objects/grab_points/grab_point_hand_right.tscn" id="5_vlcpl"] [ext_resource type="Animation" uid="uid://d1xnpyc08njjx" path="res://addons/godot-xr-tools/hands/animations/right/Grip 4.res" id="6_el51w"] -[ext_resource type="Script" uid="uid://bwd0pe2udb5xo" path="res://Net/net_pickable.gd" id="20_netpk"] [sub_resource type="CylinderShape3D" id="CylinderShape3D_kek77"] height = 0.0635376 @@ -27,23 +26,6 @@ metadata/_custom_type_script = "uid://dvobm6vcfnqe8" [sub_resource type="StandardMaterial3D" id="StandardMaterial3D_vlqg6"] albedo_color = Color(0.36656043, 0.16690676, 0.12531222, 1) -[sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_np_plate"] -properties/0/path = NodePath(".:position") -properties/0/spawn = false -properties/0/replication_mode = 1 -properties/1/path = NodePath(".:quaternion") -properties/1/spawn = false -properties/1/replication_mode = 1 -properties/2/path = NodePath("NetPickable:net_held_by") -properties/2/spawn = true -properties/2/replication_mode = 1 -properties/3/path = NodePath("PlateController:contained_ids") -properties/3/spawn = true -properties/3/replication_mode = 1 -properties/4/path = NodePath("PlateController:is_dirty") -properties/4/spawn = true -properties/4/replication_mode = 1 - [node name="Plate" type="RigidBody3D" unique_id=190487773] collision_layer = 4 collision_mask = 196615 @@ -153,6 +135,3 @@ polygon = PackedVector2Array(-0.053057775, 0.2586278, 0.09469998, 0.40791017, 0. depth = 0.02 material = SubResource("StandardMaterial3D_vlqg6") -[node name="NetPickable" type="MultiplayerSynchronizer" parent="." unique_id=1770751620] -replication_config = SubResource("SceneReplicationConfig_np_plate") -script = ExtResource("20_netpk") diff --git a/Containers/plate_controller.gd b/Containers/plate_controller.gd index b617482..37f686f 100644 --- a/Containers/plate_controller.gd +++ b/Containers/plate_controller.gd @@ -30,11 +30,11 @@ func _process(_delta: float) -> void: func _set_contained_ids(value: Array[String]) -> void: - # Only rebuild when the contents actually changed. This property is - # replicated in ALWAYS mode, so the synchronizer assigns it every network - # tick on every peer that doesn't own the plate — and refresh_visuals() - # frees and re-instantiates a scene per item each time. That was thousands - # of throwaway nodes per run (and a log line from each one's NetPickable). + # Only rebuild when the contents actually changed. refresh_visuals() frees and + # re-instantiates a scene per item, so a redundant call is thousands of + # throwaway nodes over a session. NetReplication uses ON_CHANGE for state, so + # the synchronizer should not be assigning this unless it really changed — but + # a local write can still repeat a value, and this stays cheap either way. if contained_ids == value: return contained_ids = value diff --git a/Items/BurgerBuns.tscn b/Items/BurgerBuns.tscn index 59a7237..ab7e42b 100644 --- a/Items/BurgerBuns.tscn +++ b/Items/BurgerBuns.tscn @@ -9,7 +9,6 @@ [ext_resource type="PackedScene" uid="uid://3lr2dhy62rhk" path="res://Prefabs/combinable_item.tscn" id="7_wb51u"] [ext_resource type="PackedScene" uid="uid://bfj80jh13t6e5" path="res://Prefabs/food_item.tscn" id="8_t9y3x"] [ext_resource type="PackedScene" uid="uid://b5ukku8i0hilb" path="res://Prefabs/despawning_item.tscn" id="10_46e7r"] -[ext_resource type="Script" uid="uid://bwd0pe2udb5xo" path="res://Net/net_pickable.gd" id="20_netpk"] [sub_resource type="CylinderShape3D" id="CylinderShape3D_vlqg6"] height = 0.10708985 @@ -28,17 +27,6 @@ metadata/_custom_type_script = "uid://dvobm6vcfnqe8" [sub_resource type="StandardMaterial3D" id="StandardMaterial3D_hoqox"] albedo_color = Color(0.6784191, 0.54546416, 0.016760282, 1) -[sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_np_buns"] -properties/0/path = NodePath(".:position") -properties/0/spawn = false -properties/0/replication_mode = 1 -properties/1/path = NodePath(".:quaternion") -properties/1/spawn = false -properties/1/replication_mode = 1 -properties/2/path = NodePath("NetPickable:net_held_by") -properties/2/spawn = true -properties/2/replication_mode = 1 - [node name="BurgerBuns" unique_id=1088240294 instance=ExtResource("1_g1t48")] [node name="CollisionShape3D" parent="." index="0"] @@ -86,8 +74,4 @@ type = 2 [node name="CombinableItem" parent="." index="5" unique_id=996936271 instance=ExtResource("7_wb51u")] -[node name="NetPickable" type="MultiplayerSynchronizer" parent="." index="6" unique_id=2086885420] -replication_config = SubResource("SceneReplicationConfig_np_buns") -script = ExtResource("20_netpk") - [node name="DespawningItem" parent="." index="7" unique_id=303090111 instance=ExtResource("10_46e7r")] diff --git a/Items/Charcoal.tscn b/Items/Charcoal.tscn index c9ec3f1..8c4bb50 100644 --- a/Items/Charcoal.tscn +++ b/Items/Charcoal.tscn @@ -9,7 +9,6 @@ [ext_resource type="PackedScene" uid="uid://bfj80jh13t6e5" path="res://Prefabs/food_item.tscn" id="7_mde49"] [ext_resource type="PackedScene" uid="uid://3lr2dhy62rhk" path="res://Prefabs/combinable_item.tscn" id="8_wmrff"] [ext_resource type="PackedScene" uid="uid://b5ukku8i0hilb" path="res://Prefabs/despawning_item.tscn" id="10_6ypk4"] -[ext_resource type="Script" uid="uid://bwd0pe2udb5xo" path="res://Net/net_pickable.gd" id="20_netpk"] [sub_resource type="CylinderShape3D" id="CylinderShape3D_gkni8"] height = 0.1 @@ -34,17 +33,6 @@ script = ExtResource("4_mgacb") closed_pose = ExtResource("6_cp3eg") metadata/_custom_type_script = "uid://dvobm6vcfnqe8" -[sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_np_charcoal"] -properties/0/path = NodePath(".:position") -properties/0/spawn = false -properties/0/replication_mode = 1 -properties/1/path = NodePath(".:quaternion") -properties/1/spawn = false -properties/1/replication_mode = 1 -properties/2/path = NodePath("NetPickable:net_held_by") -properties/2/spawn = true -properties/2/replication_mode = 1 - [node name="PickableObject" unique_id=1675596942 instance=ExtResource("1_r73y2")] [node name="CollisionShape3D" parent="." index="0"] @@ -67,8 +55,4 @@ type = 2 [node name="CombinableItem" parent="." index="5" unique_id=996936271 instance=ExtResource("8_wmrff")] -[node name="NetPickable" type="MultiplayerSynchronizer" parent="." index="6" unique_id=1070495412] -replication_config = SubResource("SceneReplicationConfig_np_charcoal") -script = ExtResource("20_netpk") - [node name="DespawningItem" parent="." index="7" unique_id=303090111 instance=ExtResource("10_6ypk4")] diff --git a/Items/PickupCube.tscn b/Items/PickupCube.tscn index 901274e..d246f66 100644 --- a/Items/PickupCube.tscn +++ b/Items/PickupCube.tscn @@ -9,7 +9,6 @@ [ext_resource type="PackedScene" uid="uid://3lr2dhy62rhk" path="res://Prefabs/combinable_item.tscn" id="7_bdp75"] [ext_resource type="PackedScene" uid="uid://b5ukku8i0hilb" path="res://Prefabs/despawning_item.tscn" id="10_uygc5"] [ext_resource type="PackedScene" uid="uid://bfj80jh13t6e5" path="res://Prefabs/food_item.tscn" id="11_vjw41"] -[ext_resource type="Script" uid="uid://bwd0pe2udb5xo" path="res://Net/net_pickable.gd" id="20_netpk"] [sub_resource type="BoxShape3D" id="BoxShape3D_m1xbq"] size = Vector3(0.1, 0.1, 0.1) @@ -31,17 +30,6 @@ script = ExtResource("4_hc3f7") closed_pose = ExtResource("6_bdp75") metadata/_custom_type_script = "uid://dvobm6vcfnqe8" -[sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_np_cube"] -properties/0/path = NodePath(".:position") -properties/0/spawn = false -properties/0/replication_mode = 1 -properties/1/path = NodePath(".:quaternion") -properties/1/spawn = false -properties/1/replication_mode = 1 -properties/2/path = NodePath("NetPickable:net_held_by") -properties/2/spawn = true -properties/2/replication_mode = 1 - [node name="PickableObject" unique_id=1675596942 groups=["platalbe_item"] instance=ExtResource("1_dbtw8")] [node name="CollisionShape3D" parent="." index="0"] @@ -64,8 +52,4 @@ type = 1 [node name="CombinableItem" parent="." index="5" unique_id=996936271 instance=ExtResource("7_bdp75")] -[node name="NetPickable" type="MultiplayerSynchronizer" parent="." index="6" unique_id=1858332513] -replication_config = SubResource("SceneReplicationConfig_np_cube") -script = ExtResource("20_netpk") - [node name="DespawningItem" parent="." index="7" unique_id=303090111 instance=ExtResource("10_uygc5")] diff --git a/Items/burger.tscn b/Items/burger.tscn index 993fd91..f86c589 100644 --- a/Items/burger.tscn +++ b/Items/burger.tscn @@ -9,7 +9,6 @@ [ext_resource type="Animation" uid="uid://d1xnpyc08njjx" path="res://addons/godot-xr-tools/hands/animations/right/Grip 4.res" id="7_wqxjj"] [ext_resource type="PackedScene" uid="uid://b5ukku8i0hilb" path="res://Prefabs/despawning_item.tscn" id="10_l4hsd"] [ext_resource type="PackedScene" uid="uid://bfj80jh13t6e5" path="res://Prefabs/food_item.tscn" id="10_yxtxs"] -[ext_resource type="Script" uid="uid://bwd0pe2udb5xo" path="res://Net/net_pickable.gd" id="20_netpk"] [sub_resource type="CylinderShape3D" id="CylinderShape3D_fco8w"] height = 0.0338974 @@ -28,17 +27,6 @@ script = ExtResource("5_w8sii") closed_pose = ExtResource("7_wqxjj") metadata/_custom_type_script = "uid://dvobm6vcfnqe8" -[sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_np_burger"] -properties/0/path = NodePath(".:position") -properties/0/spawn = false -properties/0/replication_mode = 1 -properties/1/path = NodePath(".:quaternion") -properties/1/spawn = false -properties/1/replication_mode = 1 -properties/2/path = NodePath("NetPickable:net_held_by") -properties/2/spawn = true -properties/2/replication_mode = 1 - [node name="raw_burger" unique_id=1675596942 instance=ExtResource("1_fco8w")] [node name="CollisionShape3D" parent="." index="0"] @@ -63,8 +51,4 @@ hand_pose = SubResource("Resource_qyiot") id = "raw_burger" type = 2 -[node name="NetPickable" type="MultiplayerSynchronizer" parent="." index="5" unique_id=1382968688] -replication_config = SubResource("SceneReplicationConfig_np_burger") -script = ExtResource("20_netpk") - [node name="DespawningItem" parent="." index="6" unique_id=303090111 instance=ExtResource("10_l4hsd")] diff --git a/Items/cooked_burger.tscn b/Items/cooked_burger.tscn index 1909b01..54675ee 100644 --- a/Items/cooked_burger.tscn +++ b/Items/cooked_burger.tscn @@ -9,7 +9,6 @@ [ext_resource type="PackedScene" uid="uid://b5ukku8i0hilb" path="res://Prefabs/despawning_item.tscn" id="10_6p8pj"] [ext_resource type="PackedScene" uid="uid://3lr2dhy62rhk" path="res://Prefabs/combinable_item.tscn" id="10_ut7mg"] [ext_resource type="PackedScene" uid="uid://bfj80jh13t6e5" path="res://Prefabs/food_item.tscn" id="10_zz42p"] -[ext_resource type="Script" uid="uid://bwd0pe2udb5xo" path="res://Net/net_pickable.gd" id="20_netpk"] [sub_resource type="CylinderShape3D" id="CylinderShape3D_fco8w"] height = 0.04777527 @@ -28,17 +27,6 @@ script = ExtResource("5_ychvb") closed_pose = ExtResource("7_rl64h") metadata/_custom_type_script = "uid://dvobm6vcfnqe8" -[sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_np_cookedburger"] -properties/0/path = NodePath(".:position") -properties/0/spawn = false -properties/0/replication_mode = 1 -properties/1/path = NodePath(".:quaternion") -properties/1/spawn = false -properties/1/replication_mode = 1 -properties/2/path = NodePath("NetPickable:net_held_by") -properties/2/spawn = true -properties/2/replication_mode = 1 - [node name="CookedBurger" unique_id=1675596942 instance=ExtResource("1_ut7mg")] [node name="CollisionShape3D" parent="." index="0"] @@ -65,8 +53,4 @@ type = 2 [node name="CombinableItem" parent="." index="5" unique_id=996936271 instance=ExtResource("10_ut7mg")] -[node name="NetPickable" type="MultiplayerSynchronizer" parent="." index="6" unique_id=925955029] -replication_config = SubResource("SceneReplicationConfig_np_cookedburger") -script = ExtResource("20_netpk") - [node name="DespawningItem" parent="." index="7" unique_id=303090111 instance=ExtResource("10_6p8pj")] diff --git a/Items/hamburger.tscn b/Items/hamburger.tscn index e11b269..8e79173 100644 --- a/Items/hamburger.tscn +++ b/Items/hamburger.tscn @@ -8,7 +8,6 @@ [ext_resource type="Animation" uid="uid://d1xnpyc08njjx" path="res://addons/godot-xr-tools/hands/animations/right/Grip 4.res" id="6_fh0f6"] [ext_resource type="PackedScene" uid="uid://bfj80jh13t6e5" path="res://Prefabs/food_item.tscn" id="7_yy3y8"] [ext_resource type="PackedScene" uid="uid://b5ukku8i0hilb" path="res://Prefabs/despawning_item.tscn" id="9_ej8jm"] -[ext_resource type="Script" uid="uid://bwd0pe2udb5xo" path="res://Net/net_pickable.gd" id="20_netpk"] [sub_resource type="CylinderShape3D" id="CylinderShape3D_fco8w"] height = 0.10392761 @@ -30,17 +29,6 @@ albedo_color = Color(0.6784191, 0.54546416, 0.016760282, 1) [sub_resource type="StandardMaterial3D" id="StandardMaterial3D_6l01i"] albedo_color = Color(0.29, 0.101500005, 0, 1) -[sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_np_hamburger"] -properties/0/path = NodePath(".:position") -properties/0/spawn = false -properties/0/replication_mode = 1 -properties/1/path = NodePath(".:quaternion") -properties/1/spawn = false -properties/1/replication_mode = 1 -properties/2/path = NodePath("NetPickable:net_held_by") -properties/2/spawn = true -properties/2/replication_mode = 1 - [node name="Hamburger" unique_id=1675596942 groups=["platalbe_item"] instance=ExtResource("1_3gf3l")] gravity_scale = 0.04 @@ -95,8 +83,4 @@ id = "hamburger" type = 0 sell_value = 4 -[node name="NetPickable" type="MultiplayerSynchronizer" parent="." index="6" unique_id=56164120] -replication_config = SubResource("SceneReplicationConfig_np_hamburger") -script = ExtResource("20_netpk") - [node name="DespawningItem" parent="." index="7" unique_id=303090111 instance=ExtResource("9_ej8jm")] diff --git a/Net/net_grab.gd b/Net/net_grab.gd new file mode 100644 index 0000000..2813ce8 --- /dev/null +++ b/Net/net_grab.gd @@ -0,0 +1,136 @@ +extends Node + +## The whole interaction layer: two RPCs, for every object in the game. +## +## Registered as the "NetGrab" autoload so its RPCs resolve to the same node path +## on every peer. +## +## A hand picks something up locally the instant the player grabs it — no round +## trip, no waiting — and tells the server. The server answers by moving the +## object's NetXform to that peer, which is what actually makes the prediction +## work: a MultiplayerSynchronizer never applies inbound state on the peer that +## owns it, so from that moment the holder's own hand drives the object and the +## server's copy follows instead of fighting it. Releasing hands NetXform back, +## and the server decides where the object really ends up. +## +## Note what is NOT here. There is no per-object networking component, no +## replicated "who is holding this" field to keep in step with the authority, and +## no grant/reject/force-release negotiation. Authority is the single source of +## truth for who simulates an object, and NetWorld.apply_physics_role reads it. + + +## Starts watching a pickable. Called by NetWorld for every replicated object, so +## no scene has to include a networking node to take part. +func watch(item: XRToolsPickable) -> void: + if item.picked_up.is_connected(_on_picked_up): + return + item.picked_up.connect(_on_picked_up) + item.dropped.connect(_on_dropped) + + +func _on_picked_up(item: Node3D) -> void: + if not NetworkManager.is_online(): + return + # A station snap zone grabbing something is the server placing it, not a + # player taking it, so it must not move authority anywhere. + if not (item.get_picked_up_by() is XRToolsFunctionPickup): + return + NetworkManager.log_line("%s grabbed by hand, claiming it" % item.name) + if NetworkManager.is_server(): + _claim(item.get_path(), 1) + else: + # rpc_id() to ourselves is rejected by Godot, which is why the server + # branch calls straight through instead. + _request_grab.rpc_id(1, item.get_path()) + + +func _on_dropped(item: Node3D) -> void: + if not NetworkManager.is_online(): + return + # Only the peer that was actually driving the object reports a release. A drop + # caused by losing authority (see NetWorld.apply_physics_role) must not be + # echoed back as if the player had let go. + var xform := item.get_node_or_null(NetReplication.XFORM_NAME) + if not xform or not xform.is_multiplayer_authority(): + return + # Our own final transform travels with the release. We were driving the object + # right up to this moment and our position updates ride the synchronizer's + # separate, unordered channel, which this reliable message routinely + # overtakes — so the server's copy can still be back where the object was + # before we carried it away. Deciding the snap from that stale position + # teleports the object straight back into the station it was just taken from. + if NetworkManager.is_server(): + _settle(item.get_path(), item.global_transform, item.linear_velocity, item.angular_velocity) + else: + _request_release.rpc_id( + 1, item.get_path(), item.global_transform, + item.linear_velocity, item.angular_velocity + ) + + +# --- server side ----------------------------------------------------------- + +@rpc("any_peer", "reliable") +func _request_grab(item_path: NodePath) -> void: + if not NetworkManager.is_server(): + return + _claim(item_path, multiplayer.get_remote_sender_id()) + + +@rpc("any_peer", "reliable") +func _request_release(item_path: NodePath, xform: Transform3D, lin: Vector3, ang: Vector3) -> void: + if not NetworkManager.is_server(): + return + _settle(item_path, xform, lin, ang) + + +func _claim(item_path: NodePath, peer: int) -> void: + var item := get_node_or_null(item_path) + if not item: + return + NetworkManager.log_line("grant %s to peer %d" % [item.name, peer]) + # Order matters: hand the object over first, so the station's zone has already + # stopped owning it by the time it is told to let go. + _set_xform_authority.rpc(item_path, peer) + NetStations.release_from_zones(self, item) + + +func _settle(item_path: NodePath, xform: Transform3D, lin: Vector3, ang: Vector3) -> void: + var item := get_node_or_null(item_path) + if not item: + return + NetworkManager.log_line("release %s" % item.name) + if item is Node3D: + item.global_transform = xform + _set_xform_authority.rpc(item_path, 1) + if item is RigidBody3D: + item.linear_velocity = lin + item.angular_velocity = ang + NetStations.try_snap.call_deferred(self, item) + + +## set_multiplayer_authority is a local call, so every peer has to run it for +## them to agree on who is driving the object. +@rpc("authority", "call_local", "reliable") +func _set_xform_authority(item_path: NodePath, peer: int) -> void: + var item := get_node_or_null(item_path) + if not item: + return + var xform := item.get_node_or_null(NetReplication.XFORM_NAME) + if not xform: + return + xform.set_multiplayer_authority(peer) + NetworkManager.apply_physics_role(item) + + +## Hands everything a departing peer was holding back to the server. Without +## this, anything still in their hand when they dropped out would be frozen +## forever on every remaining peer, waiting on an authority that has gone. +func reclaim_from(peer: int) -> void: + if not NetworkManager.is_server(): + return + for item in NetworkManager.replicated_objects(): + var xform := item.get_node_or_null(NetReplication.XFORM_NAME) + if xform and xform.get_multiplayer_authority() == peer: + NetworkManager.log_line("reclaiming %s from departed peer %d" % [item.name, peer]) + _settle(item.get_path(), item.global_transform, Vector3.ZERO, Vector3.ZERO) diff --git a/Net/net_grab.gd.uid b/Net/net_grab.gd.uid new file mode 100644 index 0000000..ad3db20 --- /dev/null +++ b/Net/net_grab.gd.uid @@ -0,0 +1 @@ +uid://bv82nioivbmnx diff --git a/Net/net_pickable.gd b/Net/net_pickable.gd deleted file mode 100644 index 430ccec..0000000 --- a/Net/net_pickable.gd +++ /dev/null @@ -1,209 +0,0 @@ -extends MultiplayerSynchronizer - -## Networked sync component for a pickable item. Added as a child literally -## named "NetPickable" (network_manager.gd's authority RPCs already expect -## this) of every net-synced pickable, replicating its transform and held -## state. Only the current multiplayer authority (the server while loose, or -## whichever peer is holding it) actually simulates physics for the item; -## every other peer freezes their local copy and just follows the synced -## transform. - -## 0 = loose/server-simulated; otherwise the peer id currently holding it. -## Replicated at spawn and on change so a late joiner sees the current holder. -var net_held_by: int = 0: set = _set_net_held_by - -var _pickable: XRToolsPickable - -# This item's own baked freeze_mode (e.g. plate.tscn bakes KINEMATIC, not the -# RigidBody3D default of STATIC) — captured once so it can be restored when -# this peer regains ownership, instead of getting stuck on whatever -# apply_held_state() last forced it to while non-authority. -var _original_freeze_mode: int - -# Same idea for the pickable's authored `enabled` flag, which the non-authority -# branch of apply_held_state() clears while someone else is holding the item. -var _original_enabled: bool - -# Whether the "our own hand still holds this" guard has already been logged for -# the current grab. apply_held_state() runs every network tick, so without this -# the guard message repeats for as long as you hold the item. -var _grab_race_logged := false - - -func _ready() -> void: - _pickable = get_parent() as XRToolsPickable - if not _pickable: - push_error("NetPickable must be a child of an XRToolsPickable") - return - _original_freeze_mode = _pickable.freeze_mode - _original_enabled = _pickable.enabled - _pickable.picked_up.connect(_on_picked_up) - _pickable.dropped.connect(_on_dropped) - # Deferred: the pickable root captures its own original_collision_mask/ - # original_collision_layer via @onready, which runs AFTER this child's - # _ready() but BEFORE the root's _ready() body. Calling apply_held_state - # synchronously here would freeze/mask the item before that capture runs, - # permanently corrupting the "restore on drop" values. - apply_held_state.call_deferred() - - -func _set_net_held_by(value: int) -> void: - var old := net_held_by - net_held_by = value - if old != value and NetworkManager.is_online(): - print("%s net_held_by: %d -> %d (local state: %s, authority=%d)" % [ - _pickable.name, old, value, _holder_desc(), get_multiplayer_authority() - ]) - apply_held_state() - - -## Puts the item in the right physics state for whether this peer currently -## owns it. Called locally after net_held_by changes, and directly by -## NetworkManager._set_item_authority right after an authority handoff. -## -## IMPORTANT: this runs on every network tick, not just on a real change. -## net_held_by is replicated in ALWAYS mode, so the synchronizer assigns it every -## tick on non-authority peers — unchanged value included — and that assignment -## lands in _set_net_held_by(), which calls this. So every branch here has to be -## idempotent and silent when there is nothing to do: otherwise each item logs a -## line and rewrites four physics properties every tick on every peer that -## doesn't own it. -func apply_held_state() -> void: - if not _pickable: - return - if not NetworkManager.is_online() or is_multiplayer_authority(): - _grab_race_logged = false - # We own this item's simulation (offline, loose+server, or currently - # holding it). If it's not actively in our own hand right now, make - # sure it isn't still left frozen/collision-less from a previous - # non-authority period (e.g. right after regaining authority when a - # client released it) — while actually held, XRToolsPickable's own - # pick_up()/let_go() already manage these fields, so leave those be. - if not _pickable.is_picked_up(): - var changed := _pickable.freeze_mode != _original_freeze_mode \ - or _pickable.collision_mask != _pickable.original_collision_mask - if changed: - if NetworkManager.is_online(): - print( - "%s: reclaiming ownership, restoring freeze_mode %d->%d collision_mask %d->%d" % [ - _pickable.name, _pickable.freeze_mode, _original_freeze_mode, - _pickable.collision_mask, _pickable.original_collision_mask - ] - ) - _pickable.freeze_mode = _original_freeze_mode - _pickable.collision_mask = _pickable.original_collision_mask - # Unlike freeze/collision (which XRToolsPickable manages itself while - # held), `enabled` is only ever written by the non-authority branch - # below, so it must be restored here or it stays false forever: once a - # client grabbed this item, every other peer set enabled=false, and - # regaining authority left it that way. On the server that silently - # broke everything downstream — hands couldn't pick the item up again, - # and a station snap zone would "snap" it (emitting has_picked_up, so - # e.g. a plate still got marked dirty) while pick_up() bailed out on - # the disabled item, leaving the zone holding an item with no grab - # driver that then fell out of the station. - if _pickable.enabled != _original_enabled: - if NetworkManager.is_online(): - print("%s: reclaiming ownership, restoring enabled %s->%s" % [ - _pickable.name, _pickable.enabled, _original_enabled - ]) - _pickable.enabled = _original_enabled - return - # A net_held_by/position sync update can race ahead of the - # authority-handoff RPC that's about to confirm a grab we just made - # optimistically (they travel on different channels with no ordering - # guarantee). Don't let a stale sync value yank an item out of our own - # hand mid-grab — only an explicit force_release_item rejection, or - # actually losing authority for real, should end a grab we initiated. - if _pickable.is_picked_up() and _pickable.get_picked_up_by() is XRToolsFunctionPickup: - # Log once per grab, not once per tick. - if NetworkManager.is_online() and not _grab_race_logged: - _grab_race_logged = true - print( - "%s: ignoring non-authority sync (net_held_by=%d) — still actively held by our own hand (grab-race guard)" % [ - _pickable.name, net_held_by - ] - ) - return - _grab_race_logged = false - # Someone else owns it: stop simulating locally, just follow the sync. - if _pickable.is_picked_up(): - if NetworkManager.is_online(): - print( - "%s: was held by %s on this peer, but authority now says peer %d owns it — force-dropping" % [ - _pickable.name, _holder_desc(), net_held_by - ] - ) - _pickable.drop() - # Bail out when we're already in the follow-the-sync state. Without this the - # writes below (and the line logged with them) repeated every tick for every - # item on every non-authority peer — 90% of the log, plus four redundant - # physics-property writes per item per tick. The comparison also means we - # still re-apply if something else perturbs the state (e.g. let_go() - # restoring the collision mask after a force-drop). - var want_enabled := (net_held_by == 0) - if _pickable.freeze \ - and _pickable.freeze_mode == RigidBody3D.FREEZE_MODE_KINEMATIC \ - and _pickable.collision_mask == 0 \ - and _pickable.enabled == want_enabled: - return - if NetworkManager.is_online(): - print("%s: freezing (non-authority, owner=peer %d)" % [_pickable.name, net_held_by]) - _pickable.freeze = true - _pickable.freeze_mode = RigidBody3D.FREEZE_MODE_KINEMATIC - _pickable.collision_mask = 0 - _pickable.enabled = want_enabled - - -## Local hand grab (not a station snap zone, which is server-only): request -## authority immediately so the throw/drop can be reconciled, but let the grab -## happen instantly here rather than waiting on the round trip. -func _on_picked_up(_p) -> void: - var by := _pickable.get_picked_up_by() - if not (by is XRToolsFunctionPickup): - # e.g. a station snap zone grabbed it (server-side auto-snap, or the - # addon's own "grab out of a snap zone" shortcut mid-cascade) — not a - # player-initiated hand grab, so no authority request from here. - if NetworkManager.is_online(): - print("%s picked up by %s (not a hand) — no authority request" % [_pickable.name, _holder_desc()]) - return - if NetworkManager.is_online(): - print("%s grabbed by hand (authority was peer %d), requesting authority" % [_pickable.name, net_held_by]) - NetworkManager.request_item_authority_from(_pickable.get_path()) - - -func _on_dropped(_p) -> void: - # Only forward if we're actually still the authority — a drop caused by - # apply_held_state() losing authority (see above) must not re-report. - if not is_multiplayer_authority(): - if NetworkManager.is_online(): - print("%s dropped locally, but we aren't its authority (peer %d is) — not reporting" % [_pickable.name, net_held_by]) - return - if NetworkManager.is_online(): - print("%s dropped, reporting release to server (lin=%s ang=%s)" % [ - _pickable.name, _pickable.linear_velocity, _pickable.angular_velocity - ]) - # Send our own final transform too: we were the authority until now, and the - # server's copy may not have received our last position sync yet. - NetworkManager.release_item_authority_from( - _pickable.get_path(), _pickable.linear_velocity, _pickable.angular_velocity, - _pickable.global_transform - ) - - -## Human-readable description of what's currently holding this item on THIS -## peer, for diagnosing desyncs between a hand's own "what am I holding" -## bookkeeping and the item's actual grab state (see the snap-zone-grab race -## in NetworkManager._try_snap_into_station for a real example). -func _holder_desc() -> String: - if not _pickable or not _pickable.is_picked_up(): - return "loose" - var by := _pickable.get_picked_up_by() - if not by: - return "held(no grabber?)" - if by is XRToolsFunctionPickup: - return "hand(%s)" % by.get_path() - if by is XRToolsSnapZone: - var station := by.get_parent() - return "zone(%s)" % (station.name if station else str(by.get_path())) - return "other(%s: %s)" % [by.get_class(), by.get_path()] diff --git a/Net/net_pickable.gd.uid b/Net/net_pickable.gd.uid deleted file mode 100644 index 8dc4951..0000000 --- a/Net/net_pickable.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bwd0pe2udb5xo diff --git a/Net/net_replication.gd b/Net/net_replication.gd index 5094041..1b71159 100644 --- a/Net/net_replication.gd +++ b/Net/net_replication.gd @@ -73,6 +73,12 @@ static func _add_sync(node: Node, sync_name: String, config: SceneReplicationCon # Sync every network tick. ON_CHANGE properties are only sent when they # actually change regardless of this interval. sync.replication_interval = 0.0 + # Inherit the node's authority rather than defaulting to the server. Items are + # server-owned and this changes nothing for them, but a player avatar sets its + # authority to the peer it belongs to in _enter_tree — which has already run by + # the time we get here — and its synchronizer has to agree, or the owning peer + # would be receiving its own head and hands back from the server. + sync.set_multiplayer_authority(node.get_multiplayer_authority()) node.add_child(sync) @@ -108,6 +114,47 @@ static func _add(config: SceneReplicationConfig, path: String, spawn: bool, mode config.property_set_replication_mode(np, mode) +## Whether a script comes from a third-party addon rather than this game. +## +## Addon components are local plumbing, and replicating their configuration is +## not merely wasteful — it is actively wrong. godot-xr-tools' snap zones and +## pickables both expose a public `enabled`, which is precisely the flag each +## peer has to set for ITSELF: a client disables its stations' snap zones because +## placement is the server's decision, and disables a pickable another player is +## holding. Replicating those meant the server helpfully sent `enabled = true` +## straight back over every client's gate, so stations on clients went on +## grabbing objects out of the local player's hands. +## +## Their values are authored in the scene file and therefore already identical on +## every peer, so nothing is lost by leaving them alone. +static func _is_addon(script: Script) -> bool: + return script.resource_path.begins_with("res://addons/") + + +## Whether a typed array holds something that can cross the wire. +## +## An array of nodes cannot, and this game has several: ItemContainer's +## meal_positions/side_positions are Array[Node3D], and contained_items is +## Array[FoodItem]. Replicating one would try to serialise live node references. +## +## GDScript reports the element type three different ways, so all three are +## handled here (verified against the real scripts): +## Array[String] hint 23, hint_string "4:" -> element type 4 +## Array[Node3D] hint 23, hint_string "24/34:Node3D" -> element type 24 +## Array[FoodItem] hint 31, hint_string "FoodItem" -> a class name +## An untyped Array reports an empty hint_string and is excluded too: it can hold +## anything, including nodes, so there is no safe answer. +static func _is_syncable_array(prop: Dictionary) -> bool: + var hint_string := str(prop["hint_string"]) + if hint_string.is_empty(): + return false + var head := hint_string.split(":")[0].split("/")[0] + if not head.is_valid_int(): + # A bare class name, e.g. "FoodItem". + return false + return SYNCABLE_TYPES.has(head.to_int()) and head.to_int() != TYPE_ARRAY + + ## Every ":" on `node` and its scripted descendants. ## Descends through children but stops at anything carrying its own synchronizer ## — that subtree replicates itself and must not be replicated twice. @@ -115,6 +162,11 @@ static func _script_var_paths(root: Node, node: Node) -> Array[String]: var paths: Array[String] = [] var prefix: String = "." if node == root else str(root.get_path_to(node)) var script: Script = node.get_script() as Script + # Note the descent below still happens for an addon-scripted node — only its + # own properties are skipped. plate.tscn's root is godot-xr-tools' pickable.gd + # and its PlateController child is where the game state actually lives. + if script and _is_addon(script): + script = null if script: for prop in script.get_script_property_list(): var prop_name := str(prop["name"]) @@ -124,6 +176,8 @@ static func _script_var_paths(root: Node, node: Node) -> Array[String]: continue if not SYNCABLE_TYPES.has(prop["type"]): continue + if prop["type"] == TYPE_ARRAY and not _is_syncable_array(prop): + continue paths.append("%s:%s" % [prefix, prop_name]) for child in node.get_children(): if child is MultiplayerSynchronizer or child is MultiplayerSpawner: diff --git a/Net/net_stations.gd b/Net/net_stations.gd new file mode 100644 index 0000000..ec3f44e --- /dev/null +++ b/Net/net_stations.gd @@ -0,0 +1,64 @@ +extends Node +class_name NetStations + +## Server-side placement rules for stations. Generic over the "station" group — +## there is nothing per-station here, and nothing any individual station scene +## has to opt into. +## +## Placement is a server decision for the same reason cooking is: every peer runs +## its own copy of a snap zone, so letting each decide independently means the +## same object gets grabbed in two places at once. + + +## Every snap zone belonging to a station. Some stations (Table) have more than +## one, so this is not a one-per-station lookup. +static func zones(context: Node) -> Array: + var found := [] + for station in context.get_tree().get_nodes_in_group("station"): + for child in station.get_children(): + if child is XRToolsSnapZone: + found.append(child) + return found + + +## Frees an object from whatever station is currently holding it, because +## someone has just taken it by hand. +static func release_from_zones(context: Node, item: Node) -> void: + for zone in zones(context): + if zone.picked_up_object == item: + zone.drop_object() + # Make the zone forget it as well. These zones are snap_mode=RANGE, so + # every frame they re-grab anything still listed in their grab area — and + # Jolt does NOT emit body_exited when let_go() switches the object's + # collision layer back out of the zone's mask, so the entry never clears + # on its own. The station then snatches the object straight back off the + # player who just took it and teleports it home. Bringing it near again + # re-adds it properly, and releasing next to a station is handled + # explicitly by try_snap below. + if zone._object_in_grab_area.has(item): + zone._object_in_grab_area.erase(item) + + +## Snaps a just-released object into the nearest empty station zone in range. +## +## Call this DEFERRED. XRToolsFunctionPickup's own "grab an object out of a snap +## zone" path calls zone.drop_object() before it calls pick_up() on the hand's +## behalf, and drop_object() synchronously fires the `dropped` signal that lands +## here. Running inline would immediately re-snap the object into the very zone +## it is still physically inside, stealing it before the hand's own pick_up() +## later in the same call stack ever runs — leaving the hand pointing at an +## object whose grab driver belongs to the zone, which crashes on the next +## controller press. The is_picked_up() check below is the second guard, for the +## case where something grabs it for real before this runs. +static func try_snap(context: Node, item: Node) -> void: + if not (item is Node3D) or not is_instance_valid(item): + return + if item.has_method("is_picked_up") and item.is_picked_up(): + return + for zone in zones(context): + if is_instance_valid(zone.picked_up_object): + continue + if zone.global_position.distance_to(item.global_position) <= zone.grab_distance: + NetworkManager.log_line("snapped %s into %s" % [item.name, zone.get_parent().name]) + zone.pick_up_object(item) + return diff --git a/Net/net_stations.gd.uid b/Net/net_stations.gd.uid new file mode 100644 index 0000000..ffcb4e2 --- /dev/null +++ b/Net/net_stations.gd.uid @@ -0,0 +1 @@ +uid://cgp15pxa7ui1r diff --git a/Net/net_world.gd b/Net/net_world.gd new file mode 100644 index 0000000..b8f362d --- /dev/null +++ b/Net/net_world.gd @@ -0,0 +1,251 @@ +extends Node +class_name NetWorld + +## Owns the replicated contents of the world: what exists, where it lives, and +## which peer is allowed to simulate it. +## +## Everything here runs on Godot's stock MultiplayerSpawner in its DEFAULT mode. +## There is no spawn_function and no payload dictionary: the server instantiates +## a scene, sets it up, and adds it under the spawn path — the spawner replicates +## the creation (and later the deletion) to every peer, including late joiners, +## and the synchronizers NetReplication attaches carry the state. +## +## The two hooks that make this generic, rather than something each object opts +## into: +## +## * content_root.child_entered_tree -> NetReplication.attach, on EVERY peer. +## The client's copy is built by the spawner straight from the .tscn, so this +## is what gives it synchronizers at all. It also runs early enough for them +## to pick up the spawn payload. +## * the same hook, on clients only -> _gate. One rule decides what a client is +## not allowed to simulate, for any object, whatever it happens to be. + +## Directories scanned for spawnable scenes. Anything in them can be spawned +## over the network without being registered by hand anywhere. +const SPAWNABLE_DIRS := [ + "res://Items/", + "res://Containers/", + "res://Stations/", + "res://Prefabs/", +] + +var _spawner: MultiplayerSpawner +var _content_root: Node + + +## Wires this up to the world scene's spawner and content root. Must run on every +## peer BEFORE it connects, so the spawnable list is identical by the time any +## spawn packet can arrive. +func setup(spawner: MultiplayerSpawner, content_root: Node) -> void: + _spawner = spawner + _content_root = content_root + _register_spawnables() + _content_root.child_entered_tree.connect(_on_content_child_entered) + + +## Registers every scene under SPAWNABLE_DIRS, sorted. +## +## The sort is not cosmetic. Auto-spawn puts an INDEX into this list on the wire, +## not a path, so a peer whose list is ordered differently instantiates the wrong +## scene entirely. Directory listing order is not guaranteed to match across +## machines, so it is pinned here. +func _register_spawnables() -> void: + var scenes: Array[String] = [] + for dir in SPAWNABLE_DIRS: + for file in ResourceLoader.list_directory(dir): + if file.ends_with(".tscn"): + scenes.append(dir + file) + scenes.sort() + for scene in scenes: + _spawner.add_spawnable_scene(scene) + NetworkManager.log_line("Registered %d spawnable scenes" % scenes.size()) + + +func _on_content_child_entered(node: Node) -> void: + # Our own synchronizers re-enter here as children of the node, not of the + # content root, so this only ever sees spawned roots — but the guard is cheap + # and makes the intent explicit. + if node is MultiplayerSynchronizer: + return + NetReplication.attach(node) + # Grab handling is wired up centrally, so no scene has to carry a networking + # component to be pickable over the network. + if node is XRToolsPickable: + NetGrab.watch(node) + if not NetworkManager.owns_world(): + _gate(node) + # Deferred, in this order, because both depend on the node being fully + # constructed: XRToolsPickable captures original_collision_mask in an @onready, + # which has not run yet at child_entered_tree time. Reading it now would + # record 0 as the object's authored collision mask and it would never collide + # with anything again. + _remember_authored.call_deferred(node) + apply_physics_role.call_deferred(node) + + +# --- spawning -------------------------------------------------------------- + +## Creates a networked object. Server-only when online (the spawner replicates +## it from there); works directly when offline. +## +## `props` are applied BEFORE the node enters the tree, so they ride the spawn +## packet as the synchronizer's spawn properties and every peer builds the object +## already configured. Note that this means their setters run before the node is +## in the tree, where @onready references are still null — any setter reachable +## this way has to be null-guarded. +func spawn(scene_path: String, xform: Transform3D, node_name: String = "", props: Dictionary = {}) -> Node: + if not NetworkManager.owns_world(): + return null + var scene: PackedScene = load(scene_path) + if not scene: + push_error("NetWorld.spawn: could not load scene %s" % scene_path) + return null + var inst := scene.instantiate() + if node_name != "": + inst.name = node_name + for key in props: + inst.set(key, props[key]) + if inst is Node3D: + inst.transform = xform + _content_root.add_child(inst) + # global_transform can only be honoured once the node has a parent to be + # global relative to. Content roots are normally at the origin, but a debug + # scene is free to move one. + if inst is Node3D: + inst.global_transform = xform + NetworkManager.log_line("spawn: %s as %s" % [scene_path.get_file(), inst.name]) + return inst + + +## Destroys a networked object everywhere. The spawner broadcasts the despawn +## when a tracked node leaves the tree on the authority, so freeing it here is +## the whole implementation — there is no despawn RPC any more. +## +## Callers reach this holding all sorts of nodes (a component such as +## DespawningItem, or a visual deep inside a plate), so it walks up to the object +## the spawner actually knows about. Getting that wrong used to leave a ghost +## copy on every client, which then blocked the station it was sitting in. +func despawn(node: Node) -> void: + if not NetworkManager.owns_world() or not is_instance_valid(node): + return + var root := _spawned_root(node) + if not root: + # Not part of the replicated world at all (a cosmetic copy parented under + # a plate, say). Freeing it locally is all that was ever meant. + node.queue_free() + return + NetworkManager.log_line("despawn: %s" % root.name) + root.queue_free() + + +# The ancestor that is a direct child of the content root — i.e. the node the +# spawner tracks — or null if this node is not part of the replicated world. +func _spawned_root(node: Node) -> Node: + var current := node + while current: + if current.get_parent() == _content_root: + return current + current = current.get_parent() + return null + + +## Every replicated object currently in the world. +func objects() -> Array[Node]: + var found: Array[Node] = [] + if _content_root: + for child in _content_root.get_children(): + found.append(child) + return found + + +# --- what a client is not allowed to simulate ------------------------------ + +## The one gating rule, applied to every replicated object on peers that do not +## own world logic. +## +## Stations decide things (what has cooked, what is clean, what snaps where) and +## those decisions are the server's, so a client neither runs their logic nor +## lets their snap zones grab anything. Without this each peer independently +## grabs and simulates the same shared object, and the copies drift apart. +## +## Note what is NOT here: display code. A client still has to show a lit hob and +## a dirty plate, so anything visual must be driven from a replicated value's +## setter rather than from _process — which is where it belongs anyway, since +## that is the only version that also works for a late joiner. +func _gate(node: Node) -> void: + if node.is_in_group("station"): + node.set_process(false) + var zones := _snap_zones_of(node) + for zone in zones: + zone.enabled = false + zone.set_process(false) + NetworkManager.log_line("gated %s (%d snap zones)" % [node.name, zones.size()]) + + +func _snap_zones_of(node: Node) -> Array: + var zones := [] + for child in node.get_children(): + if child is XRToolsSnapZone: + zones.append(child) + return zones + + +## Puts a body into the right physics state for whether this peer is currently +## driving it, which is exactly "do we own its NetXform". +## +## The authority IS the state here. The old implementation carried a replicated +## `net_held_by` peer id alongside the authority and reconciled the two by hand, +## which is what made held items get stuck frozen, or disabled forever, when the +## two disagreed. There is only one source of truth now. +## +## Called when an object is created and again whenever its NetXform changes +## hands, which is the complete set of moments the answer can change. +func apply_physics_role(node: Node) -> void: + if not (node is RigidBody3D): + return + var xform := node.get_node_or_null(NetReplication.XFORM_NAME) + if not xform: + return + var body: RigidBody3D = node + if xform.is_multiplayer_authority(): + # We simulate it: restore whatever the scene authored. XRToolsPickable + # manages freeze and collision itself while an object is actually in a + # hand, so a held object is left alone. + if body is XRToolsPickable and body.is_picked_up(): + return + body.freeze = false + body.freeze_mode = body.get_meta("net_freeze_mode", body.freeze_mode) + if body is XRToolsPickable: + body.collision_mask = body.original_collision_mask + # `enabled` has to be restored explicitly. Nothing else ever writes it + # back: the non-authority branch below clears it, so once any peer had + # held this object every other peer left it disabled forever. On the + # server that quietly broke everything downstream — hands could no + # longer pick the object up, while a snap zone still reported having + # grabbed it. + body.enabled = body.get_meta("net_enabled", body.enabled) + return + # Someone else drives it: stop simulating and just follow the sync. Kinematic + # rather than static so the incoming transform can still move it. + if body is XRToolsPickable and body.is_picked_up(): + body.drop() + body.freeze = true + body.freeze_mode = RigidBody3D.FREEZE_MODE_KINEMATIC + body.collision_mask = 0 + if body is XRToolsPickable: + # Still grabbable if the SERVER owns it, because that just means the object + # is lying around loose — being able to pick those up is the entire point. + # Only an object held by another player is off limits, and that is exactly + # when NetXform belongs to a peer other than 1. + body.enabled = xform.get_multiplayer_authority() == 1 + + +# Records the state the scene authored, before gating has a chance to overwrite +# it, so reclaiming an object restores what it was built with rather than +# whatever the frozen-follower state last forced on it. +func _remember_authored(node: Node) -> void: + if not (node is RigidBody3D) or node.has_meta("net_freeze_mode"): + return + node.set_meta("net_freeze_mode", node.freeze_mode) + if node is XRToolsPickable: + node.set_meta("net_enabled", node.enabled) diff --git a/Net/net_world.gd.uid b/Net/net_world.gd.uid new file mode 100644 index 0000000..3db9168 --- /dev/null +++ b/Net/net_world.gd.uid @@ -0,0 +1 @@ +uid://bgo0xq4uupuhi diff --git a/Net/network_manager.gd b/Net/network_manager.gd index efd08a7..ab7e53d 100644 --- a/Net/network_manager.gd +++ b/Net/network_manager.gd @@ -1,36 +1,34 @@ extends Node -## Client-server session manager for VRyHungry (listen-server model). +## Session manager for VRyHungry (listen-server model: the host is peer 1 and +## also plays). ## -## Registered as the "NetworkManager" autoload. Owns transport (ENet), tracks -## the session, and is the single place that reassigns multiplayer authority -## (only the server does so). The world scene (main.gd) registers its spawners -## here via [method register_world]; higher layers (players, items, stations) -## build on top of this in later phases. +## Registered as the "NetworkManager" autoload. This owns the transport and the +## session lifecycle, and nothing else — what exists in the world and who is +## allowed to simulate it belongs to NetWorld, and interaction belongs to +## NetGrab. The spawn_item/despawn_item pair below are deliberately thin +## forwards, so game code keeps one obvious place to call. const DEFAULT_PORT := 24565 const MAX_CLIENTS := 7 ## Emitted on every peer (including the server for its own local player) when a ## player peer joins. On the server this fires for each remote peer; the server -## uses it to spawn that peer's player. +## uses it to spawn that peer's avatar. signal player_joined(peer_id: int) signal player_left(peer_id: int) signal session_started(is_server: bool) signal session_ended() signal connection_failed() -# World hooks, registered by main.gd once the scene tree exists. var _world: Node = null -var _players_spawner: MultiplayerSpawner = null -var _items_spawner: MultiplayerSpawner = null -var _content_root: Node = null +var _net_world: NetWorld = null var _log_file: FileAccess -## Reason the session ended, shown by the menu on the next _ready() (see +## Reason the session ended, shown by the menu on its next _ready() (see ## take_status). Avoids depending on a live signal connection to a panel that -## doesn't exist yet at the moment the session actually ends. +## does not exist yet at the moment the session actually ends. var last_status := "" @@ -43,9 +41,9 @@ func _ready() -> void: multiplayer.server_disconnected.connect(_on_server_disconnected) -# --- Public API ------------------------------------------------------------ +# --- session --------------------------------------------------------------- -## Start hosting. The host is peer 1 and also plays (listen server). +## Start hosting. The host is peer 1 and also plays. func host(port: int = DEFAULT_PORT) -> Error: var peer := ENetMultiplayerPeer.new() var err := peer.create_server(port, MAX_CLIENTS) @@ -55,8 +53,7 @@ func host(port: int = DEFAULT_PORT) -> Error: multiplayer.multiplayer_peer = peer log_line("HOST started on port %d (peer id %d)" % [port, multiplayer.get_unique_id()]) session_started.emit(true) - # The host's own local player joins immediately. - _on_player_present(multiplayer.get_unique_id()) + player_joined.emit(multiplayer.get_unique_id()) return OK @@ -79,9 +76,9 @@ func leave() -> void: session_ended.emit() -# Restore Godot's default OfflineMultiplayerPeer (rather than leaving the peer -# null), so is_multiplayer_authority()/get_unique_id() keep working while we are -# back in single-player / menu state. +# Restore Godot's default OfflineMultiplayerPeer rather than leaving the peer +# null: is_multiplayer_authority() and get_unique_id() both throw on a null peer, +# and plenty of code keeps calling them while we are back in menu state. func _go_offline() -> void: if multiplayer.multiplayer_peer: multiplayer.multiplayer_peer.close() @@ -89,297 +86,6 @@ func _go_offline() -> void: unregister_world() -# --- Item spawning --------------------------------------------------------- - -## Spawn a networked item. Server-only when online (replicates to all peers via -## the ItemsSpawner, including late joiners); works directly when offline. -## node_name gives the spawned node a deterministic, identical name on every -## peer (needed for NodePath-based RPCs to resolve it); props are applied to -## the instance before it enters the tree, so exported vars land correctly. -## Returns the new node on the machine that owns spawning, else null. -func spawn_item(scene_path: String, xform: Transform3D, node_name: String = "", props: Dictionary = {}) -> Node: - if is_online() and not is_server(): - return null - var data := {"scene": scene_path, "xform": xform, "name": node_name, "props": props} - var via := "spawner" if (is_online() and _items_spawner) else "offline" - log_line("spawn_item: %s (name=%s, via=%s)" % [scene_path.get_file(), node_name, via]) - if is_online() and _items_spawner: - return _items_spawner.spawn(data) - # Offline: instantiate directly under the registered content root, or (for - # scenes that never call register_world, e.g. the offline menu/dev scenes) - # the current scene, so this keeps working without every offline scene - # needing to opt in. - var inst := _spawn_item_from_data(data) - if inst: - var parent: Node = _content_root if _content_root else get_tree().current_scene - if parent: - parent.add_child(inst) - return inst - - -## Despawn a server-spawned item. MultiplayerSpawner broadcasts a despawn to -## every peer when a tracked node exits the tree on the authority, so this is -## the single seam for destroying spawned items (works offline too). -func despawn_item(node: Node) -> void: - if not owns_world() or not is_instance_valid(node): - return - log_line("despawn_item: %s" % node.name) - # Items that came from the ItemsSpawner are despawned on every peer - # automatically when they leave the tree here. Items baked into a scene file - # are unknown to the spawner, so their removal has to be broadcast - # explicitly — otherwise every client keeps a ghost copy of an item the - # server has consumed, which then blocks the station it was sitting in and - # gets grabbed instead of the real item that replaced it. - if is_online() and not _is_spawner_tracked(node): - _despawn_static_item.rpc(node.get_path()) - node.queue_free() - - -# Items the ItemsSpawner replicates live under its spawn path; anything else was -# baked into the scene file and the spawner knows nothing about it. -func _is_spawner_tracked(node: Node) -> bool: - return _content_root != null and _content_root.is_ancestor_of(node) - - -@rpc("authority", "call_remote", "reliable") -func _despawn_static_item(path: NodePath) -> void: - var node := get_node_or_null(path) - if node: - log_line("despawn_static_item: freeing %s (the server consumed it)" % node.name) - node.queue_free() - - -# MultiplayerSpawner custom spawn function: runs on every peer to build the node -# from the replicated payload. -func _spawn_item_from_data(data: Variant) -> Node: - var scene: PackedScene = load(data["scene"]) - if not scene: - push_error("spawn_item: could not load scene %s" % str(data.get("scene"))) - return null - var inst := scene.instantiate() - if inst is Node3D: - inst.transform = data["xform"] - if data.get("name", "") != "": - inst.name = data["name"] - for key in data.get("props", {}): - inst.set(key, data["props"][key]) - if not owns_world(): - _gate_station(inst) - return inst - - -# Stations run their own logic and auto-grab (XRToolsSnapZone with -# snap_mode=RANGE) identically on every peer by default, which would let each -# peer independently grab/simulate the same shared object. Disable both on -# every peer except the one that owns world logic; the server-authoritative -# item-authority RPCs are what let clients still grab a server-held item by -# hand. Runs before the node enters the tree, so its own _ready() sees the -# final (disabled) state. -func _gate_station(node: Node) -> void: - if not (node is StaticBody3D): - return - for child in node.get_children(): - if child is XRToolsSnapZone: - child.enabled = false - child.set_process(false) - node.set_process(false) - log_line("gated station (non-owner peer): %s" % node.name) - - -## Gate every station already sitting in the scene tree, for peers that don't -## own world logic. Stations that arrive through spawn_item() are gated as they -## are built (see _spawn_item_from_data), but ones baked into a scene file never -## pass through there — leaving a client running its own snap zones, which then -## grab items straight out of the local hand and fight the server's -## authoritative placement. Idempotent, so it's safe on every session start. -func gate_existing_stations() -> void: - if owns_world(): - return - for station in get_tree().get_nodes_in_group("station"): - _gate_station(station) - - -# --- Item grab-authority transfer ----------------------------------------- - -## Called by NetPickable when this peer grabs an item by hand. Godot rejects -## rpc_id() targeting your own peer id ("RPC on yourself is not allowed"), so -## when we ARE the server this runs the logic directly instead of round- -## tripping an RPC to ourselves — otherwise every host-side grab/drop was -## silently failing to run its server-side half (no denial checks, and -## crucially no auto-snap-into-station on release). -func request_item_authority_from(item_path: NodePath) -> void: - if is_server(): - _grant_or_reject_item_authority(item_path, multiplayer.get_unique_id()) - else: - _request_item_authority_rpc.rpc_id(1, item_path) - - -@rpc("any_peer", "reliable") -func _request_item_authority_rpc(item_path: NodePath) -> void: - if not is_server(): - return - _grant_or_reject_item_authority(item_path, multiplayer.get_remote_sender_id()) - - -## Runs on the server (called directly if the requester IS the server, or via -## the RPC above otherwise). If the item was snapped into a station, the -## station releases it so the grabber cleanly takes ownership. -func _grant_or_reject_item_authority(item_path: NodePath, sender: int) -> void: - var item := get_node_or_null(item_path) - if item: - var np := item.get_node_or_null("NetPickable") - if np and np.net_held_by != 0 and np.net_held_by != sender: - # Already legitimately held by a different live peer: reject the - # requester's optimistic client-side grab instead of stealing it. - log_line("request_item_authority: DENIED %s to peer %d (already held by %d)" % [item.name, sender, np.net_held_by]) - _force_release_item_to(sender, item_path) - return - log_line("request_item_authority: granting %s to peer %d" % [str(item.name) if item else str(item_path), sender]) - # Assign authority + held state first (disables the item on the server so its - # snap zone won't re-grab it), then release it from any station. - _set_item_authority.rpc(item_path, sender) - if item: - _release_from_snap_zones(item) - - -## Called by NetPickable when this peer releases an item, forwarding its throw -## velocity so the server can resume simulating it. Same self-RPC issue as -## above: runs directly if we're the server. -func release_item_authority_from(item_path: NodePath, lin: Vector3, ang: Vector3, xform: Transform3D) -> void: - if is_server(): - _do_release_item_authority(item_path, lin, ang, xform, multiplayer.get_unique_id()) - else: - _release_item_authority_rpc.rpc_id(1, item_path, lin, ang, xform) - - -@rpc("any_peer", "reliable") -func _release_item_authority_rpc(item_path: NodePath, lin: Vector3, ang: Vector3, xform: Transform3D) -> void: - if not is_server(): - return - _do_release_item_authority(item_path, lin, ang, xform, multiplayer.get_remote_sender_id()) - - -## Runs on the server. If released next to a station, the server snaps it in -## (server-authoritative placement). -func _do_release_item_authority(item_path: NodePath, lin: Vector3, ang: Vector3, xform: Transform3D, sender: int) -> void: - log_line("release_item_authority: %s released by peer %d" % [str(item_path), sender]) - _set_item_authority.rpc(item_path, 1) - var item := get_node_or_null(item_path) - if item is RigidBody3D: - # Adopt the releasing peer's own final transform rather than trusting our - # copy's. That peer was the item's authority right up to this moment, and - # its position updates travel on the synchronizer's separate, unordered - # channel — this reliable RPC routinely overtakes them, leaving our copy - # still sitting where the item was BEFORE the peer carried it away. The - # snap decision below then reads that stale position and teleports the - # item straight back into the station it was just picked up from. - item.global_transform = xform - item.freeze = false - item.linear_velocity = lin - item.angular_velocity = ang - _try_snap_into_station.call_deferred(item) - - -# All station snap zones in the world (every XRToolsSnapZone child of a node in -# the "station" group — some stations, e.g. Table, have more than one). -func _station_snap_zones() -> Array: - var zones := [] - for station in get_tree().get_nodes_in_group("station"): - for child in station.get_children(): - if child is XRToolsSnapZone: - zones.append(child) - return zones - - -# If the item is snapped into any station, drop it from that station. -func _release_from_snap_zones(item: Node) -> void: - for zone in _station_snap_zones(): - if zone.picked_up_object == item: - log_line("releasing %s from %s's snap zone (authority just granted elsewhere)" % [item.name, zone.get_parent().name]) - zone.drop_object() - # Make the zone forget the item as well. These zones are snap_mode=RANGE, - # so every frame they re-grab anything still listed in their grab area - # that can be picked up — and Jolt does not emit body_exited when let_go() - # switches the item's collision layer back out of the zone's mask, so the - # entry goes stale and never clears. The station then snatches the item - # straight back off the player who just took it, teleporting it home. - # Bringing it near again re-adds it properly (a held item is on the layer - # the zone watches), and releasing next to a station is handled - # explicitly by _try_snap_into_station. - if zone._object_in_grab_area.has(item): - zone._object_in_grab_area.erase(item) - - -# Snap the item into the nearest empty station snap zone within grab range. -# -# Called deferred from _do_release_item_authority: XRToolsFunctionPickup's own -# "grab an item out of a snap zone" path calls zone.drop_object() BEFORE it -# calls pick_up() on the hand's behalf. drop_object()'s let_go() synchronously -# fires the pickable's `dropped` signal, which (via NetPickable) lands here — -# if this ran synchronously it would immediately re-snap the item into the -# very same zone it's still physically inside, stealing it away before the -# hand's own pick_up() call (later in the same call stack) ever runs. That -# leaves XRToolsFunctionPickup.picked_up_object pointing at an item whose -# _grab_driver actually belongs to the zone — a stale reference that crashes -# (null _grab_driver) the next time a controller button is pressed. Deferring -# lets the hand's pick_up() go first; the is_picked_up() check below is a -# second guard in case the item gets grabbed for real before this runs. -func _try_snap_into_station(item: Node) -> void: - if not (item is Node3D): - return - if item.has_method("is_picked_up") and item.is_picked_up(): - var by: Node = null - if item.has_method("get_picked_up_by"): - by = item.get_picked_up_by() - log_line("skipped snapping %s: already held by %s (grab-race guard)" % [item.name, by.get_path() if by else "?"]) - return - for zone in _station_snap_zones(): - if is_instance_valid(zone.picked_up_object): - continue - if zone.global_position.distance_to(item.global_position) <= zone.grab_distance: - log_line("snapped %s into %s" % [item.name, zone.get_parent().name]) - zone.pick_up_object(item) - return - log_line("no station in range to snap %s into (or none empty)" % item.name) - - -# Server broadcasts an authority assignment so every peer agrees on who owns the -# item (set_multiplayer_authority is a local call and must run everywhere). -@rpc("authority", "call_local", "reliable") -func _set_item_authority(item_path: NodePath, peer: int) -> void: - var item := get_node_or_null(item_path) - if not item: - return - log_line("_set_item_authority: %s -> peer %d" % [item.name, peer]) - item.set_multiplayer_authority(peer) # recursive: item + synchronizer + NetPickable - var np := item.get_node_or_null("NetPickable") - if np: - np.net_held_by = 0 if peer == 1 else peer - np.apply_held_state() - - -## Rejects peer's optimistic grab (the item was already legitimately held by -## someone else). Same self-RPC concern: if the rejected peer is the server -## itself, apply it directly rather than rpc_id-ing ourselves. -func _force_release_item_to(peer: int, item_path: NodePath) -> void: - if peer == 1: - _do_force_release(item_path) - else: - force_release_item.rpc_id(peer, item_path) - - -@rpc("authority", "reliable") -func force_release_item(item_path: NodePath) -> void: - _do_force_release(item_path) - - -func _do_force_release(item_path: NodePath) -> void: - log_line("force_release_item: dropping %s (server rejected our grab)" % str(item_path)) - var item := get_node_or_null(item_path) - if item and item.has_method("drop"): - item.drop() - - func is_server() -> bool: return is_online() and multiplayer.is_server() @@ -392,71 +98,69 @@ func is_online() -> bool: ## True on the machine that owns authoritative world logic: the server when -## online, or the single player when offline. Station logic and spawning should -## only run where this is true, so state has one source of truth. +## online, or the single player when offline. Station logic and spawning only run +## where this is true, so state has one source of truth. func owns_world() -> bool: return not is_online() or is_server() -# --- Station work-progress seam ------------------------------------------- +# --- world ----------------------------------------------------------------- -## Reusable entry point for a client to contribute work to a station (e.g. a -## future chopping/gesture station). The client detects the gesture locally and -## calls this; the server validates and accumulates. Timer-driven stations like -## the Hob don't need it, but it is the drop-in seam for input-driven ones. -@rpc("any_peer", "reliable") -func submit_work(station_path: NodePath, amount: float) -> void: - if not is_server(): - return - var station := get_node_or_null(station_path) - if station and station.has_method("add_work"): - log_line("submit_work: peer %d contributed %.2f to %s" % [multiplayer.get_remote_sender_id(), amount, station.name]) - station.add_work(multiplayer.get_remote_sender_id(), amount) - - -## Called by the world scene once it's ready, passing its spawners. Must run -## before world_ready()/host()/join() on every peer so the custom spawn -## function is installed before any spawn packet can arrive. -func register_world(world: Node, players_spawner: MultiplayerSpawner, items_spawner: MultiplayerSpawner) -> void: +## Called by the world scene once its tree exists. Must run before +## world_ready()/host()/join() on every peer, so the spawnable scene list is +## registered before any spawn packet can arrive. +func register_world(world: Node, net_world: NetWorld) -> void: _world = world - _players_spawner = players_spawner - _items_spawner = items_spawner - _content_root = items_spawner.get_node(items_spawner.spawn_path) if items_spawner else world - if _items_spawner: - _items_spawner.spawn_function = _spawn_item_from_data - log_line("World registered (players_spawner=%s items_spawner=%s)" % [str(players_spawner != null), str(items_spawner != null)]) + _net_world = net_world + log_line("World registered") -## Called when the world scene goes away (disconnect, leaving the session) so -## the autoload doesn't hold stale/freed references across a scene reload. +## Called when the world scene goes away (disconnect, leaving the session) so the +## autoload does not hold freed references across a scene reload. func unregister_world() -> void: _world = null - _content_root = null - _players_spawner = null - _items_spawner = null + _net_world = null -## Returns the reason the last session ended (if any) and clears it. The menu -## pulls this on its own _ready() rather than depending on a live signal -## connection to a panel that doesn't exist yet when the session ends. -func take_status() -> String: - var s := last_status - last_status = "" - return s +## Creates a networked object. See NetWorld.spawn. +func spawn_item(scene_path: String, xform: Transform3D, node_name: String = "", props: Dictionary = {}) -> Node: + if not _net_world: + push_error("spawn_item called with no world registered: %s" % scene_path) + return null + return _net_world.spawn(scene_path, xform, node_name, props) -## Called by main.gd after it has registered the world and connected its -## player_joined/left listeners. Kicks off any menu- or command-line-driven -## session so that session signals never fire before the world is listening. +## Destroys a networked object everywhere. See NetWorld.despawn. +func despawn_item(node: Node) -> void: + if _net_world: + _net_world.despawn(node) + + +## Puts an object into the right physics state for whether this peer drives it. +func apply_physics_role(node: Node) -> void: + if _net_world: + _net_world.apply_physics_role(node) + + +## Every replicated object currently in the world. +func replicated_objects() -> Array[Node]: + if not _net_world: + return [] + return _net_world.objects() + + +## Called by the world scene after it has registered itself and connected its +## listeners. Kicks off any menu- or command-line-driven session, so session +## signals never fire before the world is listening for them. func world_ready() -> void: consume_pending_session() -# --- Menu-driven session request ------------------------------------------- +# --- menu-driven session request ------------------------------------------- # Set by the main menu's Host/Join buttons before switching to the multiplayer -# scene; consumed once that scene's world is ready to listen for session -# signals (avoids a race between change_scene_to_file and connection callbacks). +# scene; consumed once that scene is ready to listen for session signals (avoids +# a race between change_scene_to_file and the connection callbacks). var pending_action := "" var pending_ip := "" @@ -478,18 +182,29 @@ func consume_pending_session() -> void: _handle_cmdline() -# --- Session signal handlers ---------------------------------------------- +## Returns the reason the last session ended (if any) and clears it. The menu +## pulls this on its own _ready() rather than depending on a live signal +## connection to a panel that does not exist yet when the session ends. +func take_status() -> String: + var s := last_status + last_status = "" + return s + + +# --- session signal handlers ---------------------------------------------- func _on_peer_connected(peer_id: int) -> void: log_line("peer_connected: %d" % peer_id) - # Only the server reacts by materialising that peer's player. if is_server(): - _on_player_present(peer_id) + player_joined.emit(peer_id) func _on_peer_disconnected(peer_id: int) -> void: log_line("peer_disconnected: %d" % peer_id) if is_server(): - _on_player_absent(peer_id) + # Anything still in their hand would otherwise stay frozen on every + # remaining peer, waiting on an authority that has gone. + NetGrab.reclaim_from(peer_id) + player_left.emit(peer_id) func _on_connected_to_server() -> void: log_line("connected_to_server (my id=%d)" % multiplayer.get_unique_id()) @@ -508,18 +223,7 @@ func _on_server_disconnected() -> void: session_ended.emit() -# Player materialise/dematerialise. Phase 2 wires these to the PlayersSpawner; -# for now they announce presence so the transport layer is independently testable. -func _on_player_present(peer_id: int) -> void: - log_line("player_present: %d" % peer_id) - player_joined.emit(peer_id) - -func _on_player_absent(peer_id: int) -> void: - log_line("player_absent: %d" % peer_id) - player_left.emit(peer_id) - - -# --- Command-line driven test bootstrap ----------------------------------- +# --- command-line driven test bootstrap ----------------------------------- func _handle_cmdline() -> void: var args := OS.get_cmdline_user_args() @@ -535,7 +239,7 @@ func _handle_cmdline() -> void: join(addr) -# --- Logging --------------------------------------------------------------- +# --- logging --------------------------------------------------------------- func _open_log() -> void: var dir := OS.get_environment("TEMP") @@ -547,6 +251,7 @@ func _open_log() -> void: _log_file = FileAccess.open(path, FileAccess.WRITE) log_line("=== NetworkManager log (pid %d) ===" % OS.get_process_id()) + func log_line(s: String) -> void: var id := 0 var p := multiplayer.multiplayer_peer diff --git a/Net/world_layout.gd b/Net/world_layout.gd index 72ee21e..f54d860 100644 --- a/Net/world_layout.gd +++ b/Net/world_layout.gd @@ -1,155 +1,79 @@ extends Node class_name WorldLayout -## Data-driven description of what's in the multiplayer world. The server (or -## the single machine, when offline) spawns every entry through -## NetworkManager.spawn_item() instead of baking these into the scene file, so -## a joining client receives them from the server rather than assuming its own -## copy of the scene matches. Swapping the contents of these two functions is -## the only change needed for a future varying/procedural layout. +## Reads the kitchen a scene file authored and describes it as data, so the +## server can respawn it as replicated objects. ## -## Positions below are transcribed verbatim from the previous baked layout in -## Scenes/multiPlayer.tscn so the starting world is unchanged. +## The authored nodes are a template, not the live world. Baking them into the +## scene would mean each peer ran its own unsynced copy; spawning them means the +## client receives the server's, through the same path as a mid-session join. + +## Directories whose scenes count as world content, and the base type to look for +## in each. Anything instanced from these is picked up automatically — adding a +## new station or item needs no change here. +const SOURCES := [ + {"dir": "res://Stations/", "type": "StaticBody3D"}, + {"dir": "res://Items/", "type": "XRToolsPickable"}, + {"dir": "res://Containers/", "type": "XRToolsPickable"}, +] -func get_node_data(node): - # {"scene": "res://Stations/Hob.tscn", "name": "Hob", - # "xform": Transform3D(Basis(), Vector3(0.29336345, 0.8981018, -1.484)), "props": {}}, - var data = {} - data["scene"] = node.scene_file_path - data["name"] = node.name - # global, not local: the copies are respawned under WorldContent, so an - # authored node that was nested inside another (JonScene parents Counter5 - # under Counter3) would otherwise land in the wrong place. - data["xform"] = node.global_transform - data["props"] = {} - var scrip = node.get_script() - if scrip: - for i in scrip.get_script_property_list(): - # Only authored configuration — i.e. @export vars, which are the ones - # the editor exposes. Plain script variables are live runtime state: - # copying those and replaying them into a fresh instance re-runs their - # setters before the node is in the tree, so any setter touching an - # @onready reference blows up (Table's _state calls into its progress - # bar, which is still null at that point). - if not (i.usage & PROPERTY_USAGE_EDITOR): - continue - if str(i["name"]).begins_with("_"): - continue - data["props"][i["name"]] = node.get(i["name"]) - return data - - -## The authored station nodes sitting in the current scene: anything instanced -## from res://Stations/. Exposed as nodes (not just data) because every peer has -## to remove these originals — the server replaces them with replicated copies, -## and a client that kept its own would end up showing two of everything. -func get_station_nodes() -> Array[Node]: - return _authored_nodes("res://Stations/", "StaticBody3D") - - -## Likewise for authored items. Containers/ counts as items too — plates and -## trays are things the player carries, and a client that never received one -## would have an incomplete world. -func get_item_nodes() -> Array[Node]: - var found := _authored_nodes("res://Items/", "XRToolsPickable") - found.append_array(_authored_nodes("res://Containers/", "XRToolsPickable")) +## Every authored node in the current scene, stations first. +## +## Order matters: stations have to exist before items, so an item spawning on top +## of one lands in a snap zone that is already there. +func get_authored_nodes() -> Array[Node]: + var found: Array[Node] = [] + for source in SOURCES: + for node in _instances_of(source["dir"], source["type"]): + if not found.has(node): + found.append(node) return found -func _authored_nodes(dir: String, type: String) -> Array[Node]: +func _instances_of(dir: String, type: String) -> Array[Node]: var scenes := [] for file in ResourceLoader.list_directory(dir): scenes.append(dir + file) var found: Array[Node] = [] for node in get_tree().root.find_children("*", type, true, false): - if node.scene_file_path in scenes and not found.has(node): + if node.scene_file_path in scenes: found.append(node) return found -func get_stations() -> Array[Dictionary]: +## Turns authored nodes into spawn descriptions. +func describe(nodes: Array[Node]) -> Array[Dictionary]: var data: Array[Dictionary] = [] - for node in get_station_nodes(): - data.append(get_node_data(node)) + for node in nodes: + data.append(_describe_one(node)) return data -func get_items() -> Array[Dictionary]: - var data: Array[Dictionary] = [] - for node in get_item_nodes(): - data.append(get_node_data(node)) - return data +func _describe_one(node: Node) -> Dictionary: + return { + "scene": node.scene_file_path, + "name": node.name, + # Global, not local: the copies are respawned under one content root, so + # an authored node nested inside another (JonScene parents Counter5 under + # Counter3) would otherwise land in the wrong place. + "xform": node.global_transform, + "props": _authored_props(node), + } - - -static func get_stations_old() -> Array[Dictionary]: - return [ - {"scene": "res://Stations/Hob.tscn", "name": "Hob", - "xform": Transform3D(Basis(), Vector3(0.29336345, 0.8981018, -1.484)), "props": {}}, - {"scene": "res://Stations/BurgerBunsDispenser.tscn", "name": "BurgerBunsDispenser", - "xform": Transform3D(Basis(), Vector3(-1.832131, 0.40028095, -1.4813508)), "props": {}}, - {"scene": "res://Stations/sink.tscn", "name": "Sink", - "xform": Transform3D(Basis(), Vector3(1.3140475, 0.9061539, -1.4941733)), "props": {}}, - {"scene": "res://Stations/dirt_station.tscn", "name": "DirtStation", - "xform": Transform3D(Basis(), Vector3(2.0864775, 0.8981018, -1.244947)), "props": {}}, - {"scene": "res://Stations/Counter.tscn", "name": "Counter", - "xform": Transform3D(Basis(), Vector3(-0.7124918, 0.90304357, -1.4886917)), "props": {}}, - {"scene": "res://Stations/Counter.tscn", "name": "Counter2", - "xform": Transform3D(Vector3(-4.371139e-08, 0.0, 1.0), Vector3(0.0, 1.0, 0.0), Vector3(-1.0, 0.0, -4.371139e-08), Vector3(-1.7648025, 0.90304357, -0.4458799)), "props": {}}, - {"scene": "res://Stations/Counter.tscn", "name": "Counter3", - "xform": Transform3D(Vector3(-4.371139e-08, 0.0, 1.0), Vector3(0.0, 1.0, 0.0), Vector3(-1.0, 0.0, -4.371139e-08), Vector3(-1.7648025, 0.90304357, 0.55367994)), "props": {}}, - {"scene": "res://Stations/Counter.tscn", "name": "Counter4", - "xform": Transform3D(Vector3(-4.371139e-08, 0.0, 1.0), Vector3(0.0, 1.0, 0.0), Vector3(-1.0, 0.0, -4.371139e-08), Vector3(-1.7648025, 0.90304357, 1.5539298)), "props": {}}, - {"scene": "res://Stations/table.tscn", "name": "Table", - "xform": Transform3D(Basis(), Vector3(1.633146, 0.9030438, 1.1343781)), - "props": { - "initial_thinking_time": 8.0, - "initial_primary_time": 40.0, - "initial_friend_time": 3.0, - "initial_eating_time": 3.0, - }}, - ] - - -static func get_items_old() -> Array[Dictionary]: - var items: Array[Dictionary] = [] - - items.append(_item("res://Items/BurgerBuns.tscn", "BurgerBuns", Vector3(-1.8162017, 1.6081157, -1.4714175))) - items.append(_item("res://Items/BurgerBuns.tscn", "BurgerBuns2", Vector3(-1.3596323, 1.4110342, -0.22482127))) - - items.append(_item("res://Containers/plate.tscn", "Plate", Vector3(-1.5717233, 1.5454081, 1.5373346))) - items.append(_item("res://Containers/plate.tscn", "Plate2", Vector3(-1.5730225, 1.499024, 0.59790254))) - items.append(_item("res://Containers/plate.tscn", "Plate3", Vector3(-1.5818124, 1.499024, -0.4634577))) - - items.append(_item("res://Items/burger.tscn", "burger", Vector3(0.6888188, 1.4195822, -1.7110313))) - items.append(_item("res://Items/burger.tscn", "burger2", Vector3(0.6931299, 1.5300478, -1.71225))) - items.append(_item("res://Items/burger.tscn", "burger3", Vector3(0.69027674, 1.4969791, -1.7210286))) - items.append(_item("res://Items/burger.tscn", "burger4", Vector3(0.69134104, 1.4543622, -1.7210286))) - - items.append(_item("res://Items/hamburger.tscn", "Hamburger", Vector3(-1.9352558, 1.623975, 1.2447833))) - items.append(_item("res://Items/hamburger.tscn", "Hamburger2", Vector3(-1.9857153, 1.5329368, 0.9472374))) - items.append(_item("res://Items/hamburger.tscn", "Hamburger3", Vector3(-1.7201865, 1.5329367, 0.14773655))) - - items.append(_item("res://Items/cooked_burger.tscn", "CookedBurger", Vector3(-0.30671906, 1.4131018, -1.0928738))) - items.append(_item("res://Items/cooked_burger.tscn", "CookedBurger2", Vector3(-0.30671906, 1.4496142, -1.0928738))) - items.append(_item("res://Items/cooked_burger.tscn", "CookedBurger3", Vector3(-1.3344773, 1.4398065, -0.7096845))) - items.append(_item("res://Items/cooked_burger.tscn", "CookedBurger4", Vector3(-0.30671906, 1.525444, -1.0928738))) - - items.append(_item("res://Items/PickupCube.tscn", "PickableObject", Vector3(0.6225724, 1.4792972, -1.0473135))) - items.append(_item("res://Items/PickupCube.tscn", "PickableObject2", Vector3(0.6359743, 1.4639391, -1.1673055))) - items.append(_item("res://Items/PickupCube.tscn", "PickableObject3", Vector3(0.5142721, 1.4792972, -1.0473135))) - items.append(_item("res://Items/PickupCube.tscn", "PickableObject4", Vector3(0.5276739, 1.4639391, -1.1673055))) - items.append(_item("res://Items/PickupCube.tscn", "PickableObject5", Vector3(0.73287535, 1.4792972, -1.0473135))) - items.append(_item("res://Items/PickupCube.tscn", "PickableObject6", Vector3(0.7462772, 1.4639391, -1.1673055))) - items.append(_item("res://Items/PickupCube.tscn", "PickableObject7", Vector3(-1.3556751, 1.4792972, 0.28881657))) - items.append(_item("res://Items/PickupCube.tscn", "PickableObject8", Vector3(-1.3422732, 1.4639391, 0.16882455))) - items.append(_item("res://Items/PickupCube.tscn", "PickableObject9", Vector3(-1.4639754, 1.4792972, 0.28881657))) - items.append(_item("res://Items/PickupCube.tscn", "PickableObject10", Vector3(-1.4505737, 1.4639391, 0.16882455))) - - return items - - -static func _item(scene: String, name: String, pos: Vector3) -> Dictionary: - return {"scene": scene, "name": name, "xform": Transform3D(Basis(), pos), "props": {}} +# Only authored configuration — @export vars, the ones the editor exposes. Plain +# script variables are live runtime state, and replaying those into a fresh +# instance re-runs their setters before the node is in the tree, where any setter +# touching an @onready reference blows up. +func _authored_props(node: Node) -> Dictionary: + var props := {} + var script: Script = node.get_script() as Script + if not script: + return props + for prop in script.get_script_property_list(): + var prop_name := str(prop["name"]) + if not (prop["usage"] & PROPERTY_USAGE_EDITOR) or prop_name.begins_with("_"): + continue + props[prop_name] = node.get(prop_name) + return props diff --git a/Player/net_player.gd b/Player/net_player.gd index bcf006a..8aa5fa7 100644 --- a/Player/net_player.gd +++ b/Player/net_player.gd @@ -1,71 +1,106 @@ extends Node3D -## Networked representation of one connected player. The owning peer's local -## XR rig (found via the "local_xr_origin" group) drives Head/LeftHand/ -## RightHand each frame; the MultiplayerSynchronizer replicates those -## transforms so every other peer sees a matching avatar. +## Networked representation of one connected player. +## +## The owning peer's local XR rig drives the three transforms below every frame; +## the generated NetSync replicates them, and every other peer applies them to +## the avatar's head and hands. They are ordinary script variables rather than an +## authored replication config, so the avatar goes through exactly the same +## convention as every other replicated object. @onready var _head: Node3D = $Head @onready var _left_hand: Node3D = $LeftHand @onready var _right_hand: Node3D = $RightHand +## Replicated pose. Underscore-free by design — that is what marks a variable as +## replicated (see NetReplication). Written by the owning peer, applied by +## everyone else through the setters. +var head_xform: Transform3D = Transform3D.IDENTITY: set = _set_head_xform +var left_xform: Transform3D = Transform3D.IDENTITY: set = _set_left_xform +var right_xform: Transform3D = Transform3D.IDENTITY: set = _set_right_xform + var _local_camera: Node3D var _local_left_hand: Node3D var _local_right_hand: Node3D func _enter_tree() -> void: - # MultiplayerSynchronizer resolves authority when this node enters the - # tree, so authority must be set here rather than in _ready(), or the - # first synced frames go the wrong direction. + # The avatar's name is the peer id it belongs to. Authority has to be claimed + # here rather than in _ready(): NetReplication reads it when it attaches the + # synchronizers, which happens as this node enters the tree. set_multiplayer_authority(str(name).to_int()) func _ready() -> void: # LeftHand/RightHand are the full godot-xr-tools glove scenes, whose root - # node carries hand.gd (XRToolsHand). That script sets top_level = true - # and every physics frame does global_transform = get_parent(). - # global_transform * offset — i.e. it actively repositions itself to - # track a live XRController3D parent. Here the parent is just this - # NetPlayer node, not a controller, so left running it fights (and mostly - # wins, since it runs every physics tick regardless of our own _process) - # against both the local authority's transform copy below and the - # replicated values on other peers — the exact "hands stuck near origin, - # only occasionally correct" symptom. Disable it everywhere; nothing else - # in that script matters here since _controller is always null without a - # real controller ancestor (grip/trigger animation already no-ops). + # carries hand.gd. That script sets top_level = true and every physics frame + # repositions itself to track a live XRController3D parent. Here the parent is + # just this avatar, not a controller, so leaving it running fights both the + # local copy below and the replicated values on other peers — and mostly wins, + # since it runs every physics tick regardless of our _process. That was the + # "hands stuck near the origin, only occasionally correct" symptom. Nothing + # else in that script matters here; _controller is always null without a real + # controller ancestor, so the grip animation already no-ops. _left_hand.set_physics_process(false) _right_hand.set_physics_process(false) - if is_multiplayer_authority(): - var origin := get_tree().get_first_node_in_group("local_xr_origin") - if origin: - _local_camera = origin.get_node_or_null("XRCamera3D") - _local_left_hand = origin.get_node_or_null("XRControllerLeftHand") - _local_right_hand = origin.get_node_or_null("XRControllerRightHand") - # Every peer's rig is baked at the same spot in the scene; spread - # joiners out along X so they don't start stacked on top of each - # other. Peer ids from ENet are large effectively-random 32-bit - # numbers, so this must be bounded, AND kept well within the - # floor's actual footprint (15x15, so ~7.5 units from center) — - # a previous version used up to 12 units and could place a - # joining player off the edge of the floor. - var peer_id := str(name).to_int() - if peer_id != 1: - var slot := absi(peer_id) % 4 - origin.position += Vector3((slot - 1.5) * 1.2, 0, 0) - # Don't render your own floating head/hands from the inside. - _head.visible = false - _left_hand.visible = false - _right_hand.visible = false - else: + if not is_multiplayer_authority(): set_process(false) + # State that arrived in the spawn packet was applied before these @onready + # references existed, so render it now. + _apply_pose() + return + + var origin := get_tree().get_first_node_in_group("local_xr_origin") + if origin: + _local_camera = origin.get_node_or_null("XRCamera3D") + _local_left_hand = origin.get_node_or_null("XRControllerLeftHand") + _local_right_hand = origin.get_node_or_null("XRControllerRightHand") + # Every peer's rig is baked at the same spot, so spread joiners along X + # rather than starting them stacked on top of each other. ENet peer ids + # are large effectively-random 32-bit numbers, so this has to be bounded + # AND kept well inside the floor's footprint — an earlier version used up + # to 12 units and could drop a joining player off the edge. + var peer_id := str(name).to_int() + if peer_id != 1: + var slot := absi(peer_id) % 4 + origin.position += Vector3((slot - 1.5) * 1.2, 0, 0) + # Don't render your own floating head and hands from the inside. + _head.visible = false + _left_hand.visible = false + _right_hand.visible = false func _process(_delta: float) -> void: if _local_camera: - _head.global_transform = _local_camera.global_transform + head_xform = _local_camera.global_transform if _local_left_hand: - _left_hand.global_transform = _local_left_hand.global_transform + left_xform = _local_left_hand.global_transform if _local_right_hand: - _right_hand.global_transform = _local_right_hand.global_transform + right_xform = _local_right_hand.global_transform + + +# The setters are null-guarded because a spawn-replicated property fires its +# setter BEFORE the node is in the tree, when @onready references are still null. +func _set_head_xform(value: Transform3D) -> void: + head_xform = value + if _head: + _head.global_transform = value + + +func _set_left_xform(value: Transform3D) -> void: + left_xform = value + if _left_hand: + _left_hand.global_transform = value + + +func _set_right_xform(value: Transform3D) -> void: + right_xform = value + if _right_hand: + _right_hand.global_transform = value + + +func _apply_pose() -> void: + _head.global_transform = head_xform + _left_hand.global_transform = left_xform + _right_hand.global_transform = right_xform diff --git a/Player/net_player.tscn b/Player/net_player.tscn index 1913654..5563a98 100644 --- a/Player/net_player.tscn +++ b/Player/net_player.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=5 format=3] +[gd_scene load_steps=4 format=3] [ext_resource type="Script" path="res://Player/net_player.gd" id="1_np001"] [ext_resource type="PackedScene" uid="uid://bq86r4yll8po" path="res://addons/godot-xr-tools/hands/scenes/lowpoly/left_fullglove_low.tscn" id="2_np002"] @@ -8,26 +8,6 @@ radius = 0.09 height = 0.22 -[sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_np005"] -properties/0/path = NodePath("Head:position") -properties/0/spawn = false -properties/0/replication_mode = 1 -properties/1/path = NodePath("Head:quaternion") -properties/1/spawn = false -properties/1/replication_mode = 1 -properties/2/path = NodePath("LeftHand:position") -properties/2/spawn = false -properties/2/replication_mode = 1 -properties/3/path = NodePath("LeftHand:quaternion") -properties/3/spawn = false -properties/3/replication_mode = 1 -properties/4/path = NodePath("RightHand:position") -properties/4/spawn = false -properties/4/replication_mode = 1 -properties/5/path = NodePath("RightHand:quaternion") -properties/5/spawn = false -properties/5/replication_mode = 1 - [node name="NetPlayer" type="Node3D"] script = ExtResource("1_np001") @@ -37,8 +17,3 @@ mesh = SubResource("CapsuleMesh_np004") [node name="LeftHand" parent="." instance=ExtResource("2_np002")] [node name="RightHand" parent="." instance=ExtResource("3_np003")] - -[node name="Sync" type="MultiplayerSynchronizer" parent="."] -root_path = NodePath("..") -replication_config = SubResource("SceneReplicationConfig_np005") -replication_interval = 0.033 diff --git a/Scenes/JonScene.tscn b/Scenes/JonScene.tscn index 4bccff0..8d13f94 100644 --- a/Scenes/JonScene.tscn +++ b/Scenes/JonScene.tscn @@ -4,7 +4,6 @@ [ext_resource type="PackedScene" uid="uid://j5s5wus7tuhb" path="res://XROrigin.tscn" id="2_o1b3t"] [ext_resource type="Material" uid="uid://cmia50cfqxxo4" path="res://Textures/dev_material_3d.tres" id="3_irf4n"] [ext_resource type="PackedScene" uid="uid://j7caslh27nor" path="res://Stations/Hob.tscn" id="4_ctden"] -[ext_resource type="Script" uid="uid://ctsqvhicswj8m" path="res://Scenes/add_spawnables.gd" id="5_n8fyw"] [ext_resource type="Sky" uid="uid://c1abtpwhdm2d5" path="res://Textures/sky/NightSkyHDRI009_16K.tres" id="7_n8fyw"] [ext_resource type="PackedScene" uid="uid://c6rift56ql3f8" path="res://Stations/BurgerBunsDispenser.tscn" id="9_sq0s2"] [ext_resource type="PackedScene" uid="uid://cwnwo4i28upap" path="res://Stations/raw_burger_dispenser.tscn" id="11_ctden"] @@ -54,12 +53,9 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.9667189, 0) [node name="Players" type="Node3D" parent="." unique_id=1772076868] [node name="ItemsSpawner" type="MultiplayerSpawner" parent="." unique_id=2051771581] -_spawnable_scenes = PackedStringArray("uid://cwwnbx5uat3fw") spawn_path = NodePath("../WorldContent") -script = ExtResource("5_n8fyw") [node name="PlayersSpawner" type="MultiplayerSpawner" parent="." unique_id=619815427] -_spawnable_scenes = PackedStringArray("res://Player/net_player.tscn") spawn_path = NodePath("../Players") [node name="DirectionalLight3D" type="DirectionalLight3D" parent="." unique_id=1376644384] diff --git a/Scenes/add_spawnables.gd b/Scenes/add_spawnables.gd deleted file mode 100644 index d20cab4..0000000 --- a/Scenes/add_spawnables.gd +++ /dev/null @@ -1,10 +0,0 @@ -extends MultiplayerSpawner - - -func _ready() -> void: - add_spawnable_scene("res://Stations/csg_box_3d.tscn") - - - -func _process(delta: float) -> void: - pass diff --git a/Scenes/add_spawnables.gd.uid b/Scenes/add_spawnables.gd.uid deleted file mode 100644 index 341408f..0000000 --- a/Scenes/add_spawnables.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://ctsqvhicswj8m diff --git a/Scenes/multiPlayer.tscn b/Scenes/multiPlayer.tscn index c3043ae..2223c80 100644 --- a/Scenes/multiPlayer.tscn +++ b/Scenes/multiPlayer.tscn @@ -55,7 +55,6 @@ environment = SubResource("Environment_bvwq1") spawn_path = NodePath("../WorldContent") [node name="PlayersSpawner" type="MultiplayerSpawner" parent="." unique_id=106645565] -_spawnable_scenes = PackedStringArray("res://Player/net_player.tscn") spawn_path = NodePath("../Players") [node name="StaticBody3D" type="StaticBody3D" parent="." unique_id=4404969] diff --git a/Scenes/multiplayer_world.gd b/Scenes/multiplayer_world.gd index 4848afc..c0404bf 100644 --- a/Scenes/multiplayer_world.gd +++ b/Scenes/multiplayer_world.gd @@ -1,26 +1,26 @@ extends Node3D -## World script for the multiplayer scene. Consumes the host/join request set -## by the main menu, spawns the world's stations/items on whichever machine -## owns the world (server, or the local player when offline), spawns/despawns -## a player avatar per connected peer, and returns to the menu if the session -## ends. +## World script for the multiplayer scene. Starts the host/join the menu asked +## for, turns the authored kitchen into replicated objects, gives every connected +## peer an avatar, and returns to the menu when the session ends. ## -## The world's actual content (stations, items) is NOT baked into this scene — -## it's spawned at runtime from Net/world_layout.gd via NetworkManager, so a -## joining client receives it from the server (MultiplayerSpawner replays -## existing spawns to late joiners) instead of relying on its own local copy -## matching. +## The world's contents are NOT left baked into the scene file. Whatever the +## scene authors is treated as a template: the server spawns real copies of it +## through NetWorld and every peer drops its own authored originals. A client +## therefore shows the server's world rather than assuming its local copy of the +## scene matches — which is also exactly the path a player joining mid-session +## takes. -const PLAYER_SCENE := preload("res://Player/net_player.tscn") +const PLAYER_SCENE := "res://Player/net_player.tscn" -## Whether to spawn the full WorldLayout on the machine that owns the world. -## The real game scene wants this; focused debug scenes (test/) bake their own -## handful of stations and items instead and turn it off, so the thing under -## test isn't sharing the world with a second copy of the whole kitchen. +## Whether to build the full kitchen on the machine that owns the world. The real +## game scene wants this; focused debug scenes bake their own handful of objects +## and turn it off, so the thing under test is not sharing the world with a +## second copy of the whole kitchen. @export var populate_from_layout: bool = true var xr_interface: XRInterface +var _net_world: NetWorld var _populated := false @@ -30,34 +30,38 @@ func _ready() -> void: DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_DISABLED) get_viewport().use_xr = true - NetworkManager.register_world(self, $PlayersSpawner, $ItemsSpawner) + # Both roots go through the same generic replication hook, so a player avatar + # is just another replicated object — nothing about it is special-cased. + _net_world = NetWorld.new() + _net_world.name = "NetWorld" + add_child(_net_world) + _net_world.setup($ItemsSpawner, $WorldContent) + $PlayersSpawner.add_spawnable_scene(PLAYER_SCENE) + $Players.child_entered_tree.connect(_on_player_entered) + + NetworkManager.register_world(self, _net_world) NetworkManager.player_joined.connect(_on_player_joined) NetworkManager.player_left.connect(_on_player_left) NetworkManager.session_started.connect(_on_session_started) NetworkManager.session_ended.connect(_on_session_ended) NetworkManager.connection_failed.connect(_on_connection_failed) - # world_ready() is what actually calls host()/join() (or the cmdline - # equivalent). Populating before this point is wrong for EVERY case, not + # world_ready() is what actually calls host()/join() (or the command-line + # equivalent). Populating before this point would be wrong for every case, not # just offline: is_online() is still false until host()/join() runs, so - # owns_world() would read true for a joining client too, and it would - # build its own local copy instead of receiving the server's via the - # spawner. host() emits session_started synchronously, which populates - # via _on_session_started below; the explicit call after world_ready() - # only matters for the case where neither host() nor join() ran (no - # pending session, no cmdline args) — running this scene directly offline. + # owns_world() reads true for a joining client too and it would build its own + # copy instead of receiving the server's. host() emits session_started + # synchronously, which populates via _on_session_started; the call after + # world_ready() only covers the case where neither ran — this scene opened + # directly, offline. NetworkManager.world_ready() _populate_world_if_owner() - get_tree().create_timer(3.0).timeout.connect(_log_world_state) - -# Temporary-ish sanity check: confirms WorldContent actually ended up -# populated on this peer (whether by spawning it or by receiving it via -# replication), so a silent replication failure shows up in the net log -# instead of just an empty-looking world. -func _log_world_state() -> void: - NetworkManager.log_line("World state: WorldContent=%d children, Players=%d children" % [$WorldContent.get_child_count(), $Players.get_child_count()]) +func _on_player_entered(node: Node) -> void: + if node is MultiplayerSynchronizer: + return + NetReplication.attach(node) func _exit_tree() -> void: @@ -65,29 +69,23 @@ func _exit_tree() -> void: func _on_session_started(_is_server: bool) -> void: - NetworkManager.gate_existing_stations() _populate_world_if_owner() -## Spawns the world's stations/items exactly once, on the machine that owns -## world logic (server or offline). Safe to call multiple times/entry points. +## Turns the authored template into replicated objects, exactly once, on the +## machine that owns world logic. Safe to call from several entry points. func _populate_world_if_owner() -> void: if _populated or not populate_from_layout: return - # WorldLayout reads the live scene tree to find what to replicate, so it - # needs to be an instance sitting in that tree — its methods can't be called - # on the class itself. + # WorldLayout reads the live scene tree to find what to replicate, so it has + # to be an instance sitting in that tree. var layout := WorldLayout.new() add_child(layout) - var authored := layout.get_station_nodes() - authored.append_array(layout.get_item_nodes()) + var authored := layout.get_authored_nodes() - # The stations and items authored into the scene file are a *template*, not - # the live world. Only the server turns them into real objects, spawned - # through NetworkManager so they replicate. Every peer therefore drops its - # own authored copies: the client would otherwise show its local originals - # on top of the server's replicated ones, and the two sets would drift apart - # because only the server's are synced. + # Every peer drops its authored copies. The client would otherwise show its + # local originals on top of the server's replicated ones, and the two sets + # would drift apart because only the server's are synced. if not NetworkManager.owns_world(): NetworkManager.log_line("Clearing %d authored nodes; the server's copies replace them" % authored.size()) _remove_authored(authored) @@ -96,21 +94,17 @@ func _populate_world_if_owner() -> void: _populated = true GameManager.meals_in_play = ["hamburger"] - var stations := layout.get_stations() - var items := layout.get_items() + var objects := layout.describe(authored) layout.queue_free() _remove_authored(authored) - NetworkManager.log_line("Populating world: %d stations, %d items" % [stations.size(), items.size()]) - for d in stations: - NetworkManager.spawn_item(d["scene"], d["xform"], d["name"], d["props"]) - for d in items: + NetworkManager.log_line("Populating world: %d objects" % objects.size()) + for d in objects: NetworkManager.spawn_item(d["scene"], d["xform"], d["name"], d["props"]) NetworkManager.log_line("World populated") -# Free the authored template nodes. Done immediately rather than with -# queue_free() so the names are released before the replicated copies are -# spawned under the same ones. +# Freed immediately rather than queue_free()d, so the names are released before +# the replicated copies are spawned under the same ones. func _remove_authored(nodes: Array[Node]) -> void: for node in nodes: if is_instance_valid(node): @@ -118,15 +112,17 @@ func _remove_authored(nodes: Array[Node]) -> void: node.free() -## Only the server (or the single offline machine) materialises player -## avatars; MultiplayerSpawner replicates the result to everyone else, -## including late joiners. +## Only the server (or the single offline machine) creates avatars; +## MultiplayerSpawner replicates the result to everyone else, late joiners +## included. func _on_player_joined(peer_id: int) -> void: if not NetworkManager.owns_world() or $Players.has_node(str(peer_id)): return - var p := PLAYER_SCENE.instantiate() + var p: Node = load(PLAYER_SCENE).instantiate() + # The name is the peer id, which is how net_player.gd knows whose avatar it + # is on every peer — MultiplayerSpawner replicates the name. p.name = str(peer_id) - $Players.add_child(p, true) + $Players.add_child(p) NetworkManager.log_line("Spawned avatar for peer %d" % peer_id) diff --git a/Stations/Hob.tscn b/Stations/Hob.tscn index a0a6ad3..e65dfb8 100644 --- a/Stations/Hob.tscn +++ b/Stations/Hob.tscn @@ -15,17 +15,6 @@ size = Vector3(0.6, 0.12802735, 0.6) [sub_resource type="StandardMaterial3D" id="StandardMaterial3D_gkb3v"] -[sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_np_hob"] -properties/0/path = NodePath(".:time_cooked") -properties/0/spawn = true -properties/0/replication_mode = 1 -properties/1/path = NodePath(".:cooking_result_time") -properties/1/spawn = true -properties/1/replication_mode = 1 -properties/2/path = NodePath(".:cooking_result") -properties/2/spawn = true -properties/2/replication_mode = 1 - [sub_resource type="Animation" id="Animation_7uuqv"] length = 0.001 tracks/0/type = "value" @@ -262,9 +251,6 @@ size = Vector3(0.55, 0.835, 0.072) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.003479004, -0.45215607, 0.24655426) size = Vector3(0.55, 0.096, 0.14) -[node name="Sync" type="MultiplayerSynchronizer" parent="." unique_id=768992259] -replication_config = SubResource("SceneReplicationConfig_np_hob") - [node name="ProgressBar3D" parent="." unique_id=654673176 instance=ExtResource("3_7uuqv")] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.74736404, -0.09216304) diff --git a/Stations/csg_box_3d.tscn b/Stations/csg_box_3d.tscn index 3c17705..3f4bc5e 100644 --- a/Stations/csg_box_3d.tscn +++ b/Stations/csg_box_3d.tscn @@ -1,3 +1,4 @@ [gd_scene format=3 uid="uid://cwwnbx5uat3fw"] [node name="CSGBox3D" type="CSGBox3D" unique_id=725518829] + diff --git a/Stations/sink.tscn b/Stations/sink.tscn index 01f2933..778540d 100644 --- a/Stations/sink.tscn +++ b/Stations/sink.tscn @@ -15,14 +15,6 @@ stereo = true [sub_resource type="BoxShape3D" id="BoxShape3D_ai6d4"] size = Vector3(0.5, 0.128, 0.5) -[sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_np_sink"] -properties/0/path = NodePath(".:time_washed") -properties/0/spawn = true -properties/0/replication_mode = 1 -properties/1/path = NodePath(".:is_washing") -properties/1/spawn = true -properties/1/replication_mode = 1 - [sub_resource type="StandardMaterial3D" id="StandardMaterial3D_0gbf8"] [sub_resource type="StandardMaterial3D" id="StandardMaterial3D_bvwq1"] @@ -234,9 +226,6 @@ transform = Transform3D(1, 0, 0, 0, 0.9367828, -0.34991136, 0, 0.34991136, 0.936 operation = 2 size = Vector3(1, 1.2053223, 0.352417) -[node name="Sync" type="MultiplayerSynchronizer" parent="." unique_id=1601242574] -replication_config = SubResource("SceneReplicationConfig_np_sink") - [node name="ProgressBar3D" parent="." unique_id=654673176 instance=ExtResource("4_1pinr")] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.8164611, -0.035980098) diff --git a/Stations/table.gd b/Stations/table.gd index 7446284..988bd98 100644 --- a/Stations/table.gd +++ b/Stations/table.gd @@ -29,16 +29,21 @@ enum TableState { EATING } -## State is server-authoritative and synced (see table.tscn's Sync node); -## state transitions only ever run where NetworkManager.owns_world() is true -## (the whole station's _process is gated off elsewhere for non-owners, see -## NetworkManager._gate_station). The setters below just refresh the display, -## so both the server (via _setState) and clients (via incoming sync) show -## the same text. Use _setState(), never assign _state directly. -var _state: TableState = TableState.EMPTY: set = _set_state -var _state_time: float = 0.0: set = _set_state_time -var _state_duration: float = 0.0 # set in _set_state_time -var _unsatisfied_orders: Array[String] = []: set = _set_unsatisfied_orders +## State is server-authoritative and replicated. Transitions only ever run where +## NetworkManager.owns_world() is true — the whole station's _process is gated +## off for non-owners (NetWorld._gate) — and the setters below just refresh the +## display, so the server (via _setState) and clients (via incoming sync) show +## the same thing. Use _setState(), never assign `state` directly. +## +## These are deliberately not underscore-prefixed: that prefix is what marks a +## variable as private and unreplicated (see NetReplication), and this is exactly +## the state that has to reach every peer. state_duration is replicated for the +## same reason — the progress bar divides by it, and on a client that never ran a +## transition it would otherwise still be zero. +var state: TableState = TableState.EMPTY: set = _set_state +var state_time: float = 0.0: set = _set_state_time +var state_duration: float = 0.0 # set in _setState alongside state_time +var unsatisfied_orders: Array[String] = []: set = _set_unsatisfied_orders func _ready() -> void: @@ -74,7 +79,7 @@ func _ready() -> void: func _on_object_picked_up(_item) -> void: print("Table: object picked up: ", _item) - if _state == TableState.EATING: + if state == TableState.EATING: return _absorb_item_if_correct(_item) @@ -98,14 +103,21 @@ func _absorb_item_if_correct(_item: Node) -> void: # Absorm items from the plate we want for food_item in plate.container.contained_items: print("Table: held a plate with FoodItem: ", food_item.id) - if food_item.id in _unsatisfied_orders: + if food_item.id in unsatisfied_orders: print("Table: held FoodItem is in unsatisfied orders, removing it") - _unsatisfied_orders.erase(food_item.id) - if _unsatisfied_orders.size() > 0 and _state != TableState.EATING: + # Reassign rather than mutate in place. This property has a setter that + # refreshes the label, and mutating an array never fires it — so the + # peer that actually served the food would be the one peer whose table + # still showed the order outstanding. duplicate() also preserves the + # Array[String] typing the property requires. + var remaining := unsatisfied_orders.duplicate() + remaining.erase(food_item.id) + unsatisfied_orders = remaining + if unsatisfied_orders.size() > 0 and state != TableState.EATING: _setState(TableState.WAITING_FRIEND) # Table has everything it wants. Start eating - elif _unsatisfied_orders.is_empty() and _state != TableState.EATING: + elif unsatisfied_orders.is_empty() and state != TableState.EATING: GAME_MANAGER.money += food_item.sell_value audio_player.stream = money_sound audio_player.play() @@ -114,7 +126,7 @@ func _absorb_item_if_correct(_item: Node) -> void: func place_order() -> void: print("Table: place_order()") - var new_orders = _unsatisfied_orders.duplicate() + var new_orders = unsatisfied_orders.duplicate() new_orders.append(GAME_MANAGER.get_random_meal()) new_orders.append(GAME_MANAGER.get_random_meal()) _set_unsatisfied_orders(new_orders) @@ -122,7 +134,9 @@ func place_order() -> void: func satisfyAllOrders() -> void: print("Table: satisfyAllOrders()") - _unsatisfied_orders.clear() + # Assigned, not cleared in place, for the same reason as in + # _absorb_item_if_correct: clear() would not fire the setter. + unsatisfied_orders = [] clearAllPlates() _setState(TableState.EMPTY) @@ -146,42 +160,42 @@ func _set_snap_zones_enabled(value: bool) -> void: ## Server-only state transition: sets the new state's timer and assigns -## _state (whose setter refreshes the display on every peer). +## state (whose setter refreshes the display on every peer). func _setState(newState: TableState) -> void: - print("Table set _state: ", TableState.keys()[newState]) + print("Table set state: ", TableState.keys()[newState]) match newState: TableState.EMPTY: - _state_time = 5.0 - _state_duration = 5.0 + state_time = 5.0 + state_duration = 5.0 TableState.THINKING: - _state_time = thinking_duration - _state_duration = thinking_duration # Can't be done in _set_state(), will set duration inside timer + state_time = thinking_duration + state_duration = thinking_duration # Can't be done in _set_state(), will set duration inside timer TableState.WAITING_PRIMARY: - _state_time = primary_duration - _state_duration = primary_duration + state_time = primary_duration + state_duration = primary_duration TableState.WAITING_FRIEND: - _state_time = friend_duration - _state_duration = friend_duration + state_time = friend_duration + state_duration = friend_duration TableState.EATING: - _state_time = eating_duration - _state_duration = eating_duration + state_time = eating_duration + state_duration = eating_duration _set_snap_zones_enabled(false) - _state = newState + state = newState ## Pure presentation, driven off the current (locally authoritative or ## synced-from-server) state. Runs on every peer. func _refresh_display() -> void: - # _state, _state_time and _unsatisfied_orders are replicated with spawn=true, + # state, state_time and unsatisfied_orders are replicated with spawn=true, # and MultiplayerSpawner applies a spawn payload BEFORE the node enters the # tree — so these setters fire while the @onready children below are still # null. Bail out until _ready() has resolved them; _ready() calls back in # once it has, so nothing that arrived early is lost. if not progress_bar or not label_3d or not label_3d_time: return - match _state: + match state: TableState.EMPTY: progress_bar.set_bar_visible(false) label_3d.text = "empty" @@ -189,47 +203,48 @@ func _refresh_display() -> void: progress_bar.set_bar_visible(false) label_3d.text = lbl_thinking TableState.WAITING_PRIMARY: - label_3d.text = "%s\n%s" % [TableState.keys()[_state], "\n".join(_unsatisfied_orders)] + label_3d.text = "%s\n%s" % [TableState.keys()[state], "\n".join(unsatisfied_orders)] progress_bar.set_bar_visible(true) - progress_bar.override_fill_color(Color.RED if _state_time < 10 else Color.YELLOW) + progress_bar.override_fill_color(Color.RED if state_time < 10 else Color.YELLOW) TableState.WAITING_FRIEND: - label_3d.text = "%s\n%s" % [TableState.keys()[_state], "\n".join(_unsatisfied_orders)] + label_3d.text = "%s\n%s" % [TableState.keys()[state], "\n".join(unsatisfied_orders)] progress_bar.set_bar_visible(true) - progress_bar.override_fill_color(Color.RED if _state_time < 5 else Color.YELLOW) + progress_bar.override_fill_color(Color.RED if state_time < 5 else Color.YELLOW) TableState.EATING: label_3d.text = lbl_eating progress_bar.set_bar_visible(false) - label_3d_time.text = "%.1f" % _state_time - progress_bar.set_progress(clampf(float(_state_time) / _state_duration, 0.0, 1.0)) + label_3d_time.text = "%.1f" % state_time + var progress := state_time / state_duration if state_duration > 0.0 else 0.0 + progress_bar.set_progress(clampf(progress, 0.0, 1.0)) func _set_state(value: TableState) -> void: - _state = value + state = value _refresh_display() func _set_state_time(value: float) -> void: - _state_time = value + state_time = value _refresh_display() func _set_unsatisfied_orders(value: Array[String]) -> void: - _unsatisfied_orders = value + unsatisfied_orders = value _refresh_display() func _process(delta: float) -> void: # Wait for state to finish - if _state_time > 0.0: - _state_time = max(0.0, _state_time - delta) + if state_time > 0.0: + state_time = max(0.0, state_time - delta) return if GAME_MANAGER.game_state == GAME_MANAGER.GameState.GAME_OVER: return # When state timer is finished, do this stuff before moving to next state - match _state: + match state: TableState.EMPTY: print("Table State EMPTY finish") _setState(TableState.THINKING) diff --git a/Stations/table.tscn b/Stations/table.tscn index 9640887..285f622 100644 --- a/Stations/table.tscn +++ b/Stations/table.tscn @@ -15,17 +15,6 @@ radius = 0.6 [sub_resource type="StandardMaterial3D" id="StandardMaterial3D_24d3s"] albedo_color = Color(0.31, 0.21576, 0.1333, 1) -[sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_np_table"] -properties/0/path = NodePath(".:_state") -properties/0/spawn = true -properties/0/replication_mode = 1 -properties/1/path = NodePath(".:_state_time") -properties/1/spawn = true -properties/1/replication_mode = 1 -properties/2/path = NodePath(".:_unsatisfied_orders") -properties/2/spawn = true -properties/2/replication_mode = 1 - [node name="Table" type="StaticBody3D" unique_id=1863572470 groups=["station"]] script = ExtResource("1_2vcpj") money_sound = ExtResource("2_jslhm") @@ -189,9 +178,6 @@ pixel_size = 0.003 billboard = 2 text = "20.1s" -[node name="Sync" type="MultiplayerSynchronizer" parent="." unique_id=2000411505] -replication_config = SubResource("SceneReplicationConfig_np_table") - [node name="ProgressBar3D" parent="." unique_id=654673176 instance=ExtResource("3_kjf1i")] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.3676775, 0) y_billboard = true diff --git a/project.godot b/project.godot index 16b6ec2..5acf4ca 100644 --- a/project.godot +++ b/project.godot @@ -20,6 +20,7 @@ config/icon="res://icon.svg" XRToolsUserSettings="*uid://bqgb8i74tm0t" XRToolsRumbleManager="*uid://by853dk86g1qw" NetworkManager="*res://Net/network_manager.gd" +NetGrab="*res://Net/net_grab.gd" GlobalKeyEvents="*uid://c60unagog5oi1" Signals="*uid://fuux6cxfjwdc" diff --git a/test/mp_test_driver.gd b/test/mp_test_driver.gd index cfca263..0df44a1 100644 --- a/test/mp_test_driver.gd +++ b/test/mp_test_driver.gd @@ -544,10 +544,10 @@ func _do_grab(item: Node3D, label: String) -> Dictionary: return {"ok": false, "detail": "%s is held by %s, not our hand. %s" % [label, item.get_picked_up_by(), _diag(item)]} _log(" holding %s; waiting for authority..." % label) - if not await _wait_until(func(): return item.get_multiplayer_authority() == multiplayer.get_unique_id(), + if not await _wait_until(func(): return _xform_authority(item) == multiplayer.get_unique_id(), "authority handoff", 5.0): - return {"ok": false, "detail": "grabbed %s, but authority stayed with peer %d. %s" - % [label, item.get_multiplayer_authority(), _diag(item)]} + return {"ok": false, "detail": "grabbed %s, but NetXform authority stayed with peer %d. %s" + % [label, _xform_authority(item), _diag(item)]} _log(" authority is ours (peer %d)" % multiplayer.get_unique_id()) return {"ok": true, "detail": "grabbed %s via %s; %s" % [label, closest.name, _diag(item)]} @@ -572,12 +572,13 @@ func _do_drop(item: Node3D, label: String) -> Dictionary: # that owns world logic. A client's own snap zone doing it means that peer is # running station logic it has no authority for. if by is XRToolsSnapZone and not NetworkManager.owns_world(): - return {"ok": false, "detail": "%s on this client grabbed %s out of our hand; station snap zones must only run on the world owner" - % [by.get_parent().name, label]} - if not await _wait_until(func(): return not is_instance_valid(item) or item.get_multiplayer_authority() == 1, + return {"ok": false, "detail": "%s on this client grabbed %s out of our hand; station snap zones must only run on the world owner (zone enabled=%s snap_mode=%d processing=%s in_grab_area=%d)" + % [by.get_parent().name, label, by.enabled, by.snap_mode, + by.is_processing(), by._object_in_grab_area.size()]} + if not await _wait_until(func(): return not is_instance_valid(item) or _xform_authority(item) == 1, "authority return to server", 5.0): - return {"ok": false, "detail": "dropped %s, but authority stayed with peer %d" - % [label, item.get_multiplayer_authority()]} + return {"ok": false, "detail": "dropped %s, but NetXform authority stayed with peer %d" + % [label, _xform_authority(item)]} return {"ok": true, "detail": "dropped %s (now held by %s)" % [label, by]} @@ -1197,14 +1198,22 @@ func _check_zone_empty(station_name: String) -> Dictionary: # --- Diagnostics ----------------------------------------------------------- +## Who is currently driving this object's transform. Under the high-level model +## the object's own authority never changes — only its NetXform's does, and that +## is what "who is holding this" means now. +func _xform_authority(item: Node) -> int: + if not is_instance_valid(item): + return 0 + var xform := item.get_node_or_null(NetReplication.XFORM_NAME) + return xform.get_multiplayer_authority() if xform else 0 + + func _diag(item: Node3D) -> String: if not is_instance_valid(item): return "item[freed]" - var np := item.get_node_or_null("NetPickable") var pc := item.get_node_or_null("PlateController") - var s := "%s[pos=%s authority=%d net_held_by=%s" % [ - item.name, item.global_position, item.get_multiplayer_authority(), - np.net_held_by if np else "-", + var s := "%s[pos=%s xform_authority=%d" % [ + item.name, item.global_position, _xform_authority(item), ] if item is XRToolsPickable: s += " enabled=%s can_pick_up=%s layer=%d held_by=%s" % [ diff --git a/test/multiPlayerTest.tscn b/test/multiPlayerTest.tscn index e9e4a38..d309b30 100644 --- a/test/multiPlayerTest.tscn +++ b/test/multiPlayerTest.tscn @@ -63,7 +63,6 @@ environment = SubResource("Environment_bvwq1") spawn_path = NodePath("../WorldContent") [node name="PlayersSpawner" type="MultiplayerSpawner" parent="." unique_id=106645565] -_spawnable_scenes = PackedStringArray("res://Player/net_player.tscn") spawn_path = NodePath("../Players") [node name="StaticBody3D" type="StaticBody3D" parent="." unique_id=4404969]