Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee835ea295 | |||
| 61d92052ac | |||
| 0ebd0a4a85 | |||
| 59c62b73e6 | |||
| a2bad29f2a | |||
| 0ddada4568 | |||
| 96ea2dd1ea | |||
| 8b6f444d55 | |||
| 29326f4b78 | |||
| e5aa13cef7 | |||
| 7479cbd656 | |||
| f760c8c9f7 | |||
| 8e14fb0f29 | |||
| fd7f9f3a22 | |||
| e002c2fb4a |
+14
-14
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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")]
|
||||
|
||||
+136
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bv82nioivbmnx
|
||||
@@ -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()]
|
||||
@@ -1 +0,0 @@
|
||||
uid://bwd0pe2udb5xo
|
||||
@@ -0,0 +1,188 @@
|
||||
extends Node
|
||||
class_name NetReplication
|
||||
|
||||
## Builds MultiplayerSynchronizers for any node by convention, so no scene needs
|
||||
## a hand-authored replication config.
|
||||
##
|
||||
## Every replicated node gets TWO synchronizers, both generated here:
|
||||
##
|
||||
## NetSync script state (is_dirty, contained_ids, time_cooked, ...).
|
||||
## Always owned by the server. Gameplay outcomes are the server's to
|
||||
## decide, so this never changes hands.
|
||||
## NetXform position + quaternion. Owned by the server while the object is
|
||||
## loose, and handed to a peer for as long as that peer holds it.
|
||||
##
|
||||
## Splitting them is what makes client-side grab prediction work. A
|
||||
## MultiplayerSynchronizer never applies inbound state on the peer that owns it,
|
||||
## so giving the holder NetXform means their own hand drives the object with no
|
||||
## round trip and no fight with the server's copy — while is_dirty and friends
|
||||
## keep flowing one way, server to client, exactly as before.
|
||||
##
|
||||
## The obvious alternative, per-peer visibility (set_visibility_for), does NOT
|
||||
## work: MultiplayerSpawner also uses synchronizer visibility to decide whether
|
||||
## a node should exist on a peer, so hiding an object from its holder despawns
|
||||
## it in their hand. Verified in test/spike/.
|
||||
##
|
||||
## The convention for what gets replicated:
|
||||
## * a Node3D replicates `position` and `quaternion` (never `scale` — nothing
|
||||
## in this game animates scale as gameplay state)
|
||||
## * every script variable, on the node and on any scripted descendant, whose
|
||||
## name does not start with "_" and whose declared type is serialisable
|
||||
##
|
||||
## Underscore-prefixed vars are the opt-out, and they already mark exactly the
|
||||
## state that must not be replicated: @onready node references, cached lookups,
|
||||
## per-frame bookkeeping.
|
||||
|
||||
## SceneReplicationConfig's replication modes. The enum is not exposed under a
|
||||
## friendly name in GDScript, so they are spelled out here.
|
||||
const MODE_ALWAYS := 1
|
||||
const MODE_ON_CHANGE := 2
|
||||
|
||||
const SYNC_NAME := "NetSync"
|
||||
const XFORM_NAME := "NetXform"
|
||||
|
||||
## Types that survive a network round trip. Object/Callable/Signal/RID cannot be
|
||||
## serialised, and are exactly what @onready and cached references hold.
|
||||
const SYNCABLE_TYPES := [
|
||||
TYPE_BOOL, TYPE_INT, TYPE_FLOAT, TYPE_STRING, TYPE_STRING_NAME,
|
||||
TYPE_VECTOR2, TYPE_VECTOR3, TYPE_QUATERNION, TYPE_TRANSFORM3D,
|
||||
TYPE_COLOR, TYPE_PACKED_STRING_ARRAY, TYPE_ARRAY,
|
||||
]
|
||||
|
||||
|
||||
## Adds both synchronizers to `node`, unless they are already there. Idempotent.
|
||||
##
|
||||
## Must run on EVERY peer, not just the authority: the client's copy is built by
|
||||
## MultiplayerSpawner straight from the .tscn, so it only has synchronizers if
|
||||
## something puts them there. NetWorld calls this from the content root's
|
||||
## child_entered_tree on both sides — early enough that they enter the tree
|
||||
## inside the spawner's own add_child(), which is what lets them pick up the
|
||||
## spawn payload (verified in test/spike/: a late joiner receives position and
|
||||
## script state from a synchronizer that exists only at runtime).
|
||||
static func attach(node: Node) -> void:
|
||||
if node is Node3D and not node.has_node(XFORM_NAME):
|
||||
_add_sync(node, XFORM_NAME, _transform_config())
|
||||
if not node.has_node(SYNC_NAME):
|
||||
_add_sync(node, SYNC_NAME, state_config(node))
|
||||
|
||||
|
||||
static func _add_sync(node: Node, sync_name: String, config: SceneReplicationConfig) -> void:
|
||||
var sync := MultiplayerSynchronizer.new()
|
||||
sync.name = sync_name
|
||||
sync.replication_config = config
|
||||
# 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)
|
||||
|
||||
|
||||
## Transform-only config. ALWAYS rather than ON_CHANGE: a carried or simulated
|
||||
## object changes every tick anyway, so ON_CHANGE would only add a comparison
|
||||
## per property per tick. spawn = true so a replicated object arrives already in
|
||||
## the right place instead of sitting at the origin for a frame.
|
||||
static func _transform_config() -> SceneReplicationConfig:
|
||||
var config := SceneReplicationConfig.new()
|
||||
_add(config, ".:position", true, MODE_ALWAYS)
|
||||
_add(config, ".:quaternion", true, MODE_ALWAYS)
|
||||
return config
|
||||
|
||||
|
||||
## Script-state config for `node` and its scripted descendants. Public so the
|
||||
## tests can inspect what the convention picked up without spinning up a session.
|
||||
##
|
||||
## ON_CHANGE, not ALWAYS: the value is then only sent — and, crucially, only
|
||||
## ASSIGNED on the receiving peer — when it actually changes. Under ALWAYS every
|
||||
## replicated setter becomes a per-tick hot path, which is how this project
|
||||
## previously ended up rebuilding every plate's visuals 60 times a second.
|
||||
static func state_config(node: Node) -> SceneReplicationConfig:
|
||||
var config := SceneReplicationConfig.new()
|
||||
for path in _script_var_paths(node, node):
|
||||
_add(config, path, true, MODE_ON_CHANGE)
|
||||
return config
|
||||
|
||||
|
||||
static func _add(config: SceneReplicationConfig, path: String, spawn: bool, mode: int) -> void:
|
||||
var np := NodePath(path)
|
||||
config.add_property(np)
|
||||
config.property_set_spawn(np, spawn)
|
||||
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 "<relative path>:<var>" 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.
|
||||
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"])
|
||||
if prop_name.begins_with("_"):
|
||||
continue
|
||||
if not (prop["usage"] & PROPERTY_USAGE_SCRIPT_VARIABLE):
|
||||
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:
|
||||
continue
|
||||
if child.has_node(SYNC_NAME) or child.has_node(XFORM_NAME):
|
||||
continue
|
||||
paths.append_array(_script_var_paths(root, child))
|
||||
return paths
|
||||
@@ -0,0 +1 @@
|
||||
uid://bmuqb0yywjfyw
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://cgp15pxa7ui1r
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bgo0xq4uupuhi
|
||||
+78
-362
@@ -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,286 +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()
|
||||
|
||||
|
||||
# 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()
|
||||
|
||||
@@ -381,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 := ""
|
||||
|
||||
@@ -467,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())
|
||||
@@ -497,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()
|
||||
@@ -524,7 +239,7 @@ func _handle_cmdline() -> void:
|
||||
join(addr)
|
||||
|
||||
|
||||
# --- Logging ---------------------------------------------------------------
|
||||
# --- logging ---------------------------------------------------------------
|
||||
|
||||
func _open_log() -> void:
|
||||
var dir := OS.get_environment("TEMP")
|
||||
@@ -536,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
|
||||
|
||||
+71
-73
@@ -1,81 +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.
|
||||
|
||||
static func get_stations() -> 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,
|
||||
}},
|
||||
]
|
||||
## 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"},
|
||||
]
|
||||
|
||||
|
||||
static func get_items() -> 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
|
||||
## 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
|
||||
|
||||
|
||||
static func _item(scene: String, name: String, pos: Vector3) -> Dictionary:
|
||||
return {"scene": scene, "name": name, "xform": Transform3D(Basis(), pos), "props": {}}
|
||||
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:
|
||||
found.append(node)
|
||||
return found
|
||||
|
||||
|
||||
## Turns authored nodes into spawn descriptions.
|
||||
func describe(nodes: Array[Node]) -> Array[Dictionary]:
|
||||
var data: Array[Dictionary] = []
|
||||
for node in nodes:
|
||||
data.append(_describe_one(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),
|
||||
}
|
||||
|
||||
|
||||
# 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
|
||||
|
||||
+67
-32
@@ -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():
|
||||
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 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.
|
||||
# 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/hands from the inside.
|
||||
# Don't render your own floating head and hands from the inside.
|
||||
_head.visible = false
|
||||
_left_hand.visible = false
|
||||
_right_hand.visible = false
|
||||
else:
|
||||
set_process(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
|
||||
|
||||
+1
-26
@@ -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
|
||||
|
||||
@@ -23,10 +23,18 @@ func _ready() -> void:
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
# Despawning is a world decision, so only the owner of world logic runs the
|
||||
# clock. Every peer used to run its own copy: the countdown drifted between
|
||||
# them and the blink below toggled `visible` locally, so the same item was
|
||||
# shown on one peer and hidden on the other. Non-owners just follow the
|
||||
# server, which removes the item for everyone when its time is up.
|
||||
if not NetworkManager.owns_world():
|
||||
return
|
||||
|
||||
# if we're held or moving, reset timer and return
|
||||
if _pickable.get_picked_up_by() or _rigid.linear_velocity.length_squared() > pow(minimum_speed_square,2):
|
||||
_time_left = time_to_despawn
|
||||
get_parent().visible = true
|
||||
_set_visible(true)
|
||||
return
|
||||
|
||||
|
||||
@@ -35,7 +43,21 @@ func _process(delta: float) -> void:
|
||||
if _time_left <= 0:
|
||||
print("DespawningItem despawning!" , get_parent())
|
||||
NetworkManager.despawn_item(get_parent())
|
||||
# Stop counting: despawn_item() only queues the free, so without this we
|
||||
# keep re-reporting the same item every frame until it actually goes.
|
||||
set_process(false)
|
||||
return
|
||||
|
||||
# Make item blink in and out before despawning
|
||||
if _time_left < time_to_despawn / 2:
|
||||
get_parent().visible = int(_time_left * 5.0) %2 == 0
|
||||
_set_visible(int(_time_left * 5.0) % 2 == 0)
|
||||
|
||||
|
||||
# `visible` is not a replicated property, so a blink driven only here would make
|
||||
# the item flicker on the host and stay solid on clients. Until it is synced,
|
||||
# only warn when there is nobody else to disagree with.
|
||||
func _set_visible(value: bool) -> void:
|
||||
if NetworkManager.is_online():
|
||||
get_parent().visible = true
|
||||
return
|
||||
get_parent().visible = value
|
||||
|
||||
+44
-96
@@ -5,17 +5,15 @@
|
||||
[ext_resource type="Material" uid="uid://cmia50cfqxxo4" path="res://Textures/dev_material_3d.tres" id="3_gatbn"]
|
||||
[ext_resource type="PackedScene" uid="uid://j7caslh27nor" path="res://Stations/Hob.tscn" id="4_3lufs"]
|
||||
[ext_resource type="Sky" uid="uid://c1abtpwhdm2d5" path="res://Textures/sky/NightSkyHDRI009_16K.tres" id="5_kh26v"]
|
||||
[ext_resource type="PackedScene" uid="uid://c0lknik4noobs" path="res://Items/BurgerBuns.tscn" id="6_kwbxo"]
|
||||
[ext_resource type="PackedScene" uid="uid://c6rift56ql3f8" path="res://Stations/BurgerBunsDispenser.tscn" id="7_tejmp"]
|
||||
[ext_resource type="PackedScene" uid="uid://bp3v1jl8pctro" path="res://Containers/plate.tscn" id="8_xcnr4"]
|
||||
[ext_resource type="PackedScene" uid="uid://dpot5qie20vf6" path="res://Items/burger.tscn" id="9_rwx83"]
|
||||
[ext_resource type="PackedScene" uid="uid://b3m2ag8g5rj4r" path="res://Items/hamburger.tscn" id="10_waew8"]
|
||||
[ext_resource type="PackedScene" uid="uid://e4i6o5oriecx" path="res://Items/PickupCube.tscn" id="11_8wetd"]
|
||||
[ext_resource type="PackedScene" uid="uid://dvrk268s7gkxh" path="res://Stations/sink.tscn" id="12_ah4xa"]
|
||||
[ext_resource type="PackedScene" uid="uid://cnjwtnhwh0i8q" path="res://Stations/dirt_station.tscn" id="13_46xi2"]
|
||||
[ext_resource type="PackedScene" uid="uid://cfc4ho67u4r5e" path="res://Items/cooked_burger.tscn" id="14_vftjt"]
|
||||
[ext_resource type="PackedScene" uid="uid://ck5tuftqmyiue" path="res://Stations/plate_dispenser.tscn" id="14_h88sx"]
|
||||
[ext_resource type="PackedScene" uid="uid://cwnwo4i28upap" path="res://Stations/raw_burger_dispenser.tscn" id="15_m3h4l"]
|
||||
[ext_resource type="PackedScene" uid="uid://efaec6ymgabo" path="res://Stations/Counter.tscn" id="15_tpcje"]
|
||||
[ext_resource type="PackedScene" uid="uid://caf0xanmxbshy" path="res://Stations/table.tscn" id="16_g2k6a"]
|
||||
[ext_resource type="Script" uid="uid://k8ywnvlhcic4" path="res://Scenes/testworldLoad.gd" id="16_m3h4l"]
|
||||
[ext_resource type="PackedScene" uid="uid://clujaf3u776a3" path="res://addons/godot-xr-tools/objects/viewport_2d_in_3d.tscn" id="17_gatbn"]
|
||||
[ext_resource type="PackedScene" uid="uid://xtcei2uvd5li" path="res://UI/game_over_panel.tscn" id="18_3lufs"]
|
||||
[ext_resource type="Script" uid="uid://cwijoksyou1ey" path="res://Scenes/GameOvercontroler.gd" id="19_3lufs"]
|
||||
@@ -43,11 +41,15 @@ size = Vector3(16, 10, 0.1)
|
||||
script = ExtResource("1_m3h4l")
|
||||
|
||||
[node name="XROrigin3D" parent="." unique_id=2055526621 instance=ExtResource("2_8pfaf")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.36171648, 0, 0.81835127)
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.6, 0)
|
||||
|
||||
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="." unique_id=1376644384]
|
||||
transform = Transform3D(-0.8660254, -0.43301278, 0.24999999, 0.3022632, -0.05510214, 0.9516306, -0.39829266, 0.8997021, 0.17860365, 0, 0, 0)
|
||||
|
||||
[node name="Table" parent="." unique_id=1863572470 instance=ExtResource("16_g2k6a")]
|
||||
transform = Transform3D(-4.371139e-08, 0, -1, 0, 1, 0, 1, 0, -4.371139e-08, 2.739665, 0.47583598, -1.1895752)
|
||||
primary_duration = 35.0
|
||||
|
||||
[node name="Floor" type="StaticBody3D" parent="." unique_id=122279514]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.05, 0)
|
||||
|
||||
@@ -55,116 +57,50 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.05, 0)
|
||||
shape = SubResource("BoxShape3D_vlqg6")
|
||||
|
||||
[node name="MeshInstance3D" type="MeshInstance3D" parent="Floor/CollisionShape3D" unique_id=1939997056]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.00018164515, 0.0006352663, -0.002532661)
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.0002836883, 0.0006355047, -0.0054528117)
|
||||
mesh = SubResource("BoxMesh_24d3s")
|
||||
|
||||
[node name="Hob" parent="." unique_id=1687971542 instance=ExtResource("4_3lufs")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.29336345, 0.5, -1.484)
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5, -1.2)
|
||||
|
||||
[node name="Hob2" parent="." unique_id=496572049 instance=ExtResource("4_3lufs")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.6, 0.5, -1.2)
|
||||
|
||||
[node name="Hob3" parent="." unique_id=421567982 instance=ExtResource("4_3lufs")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.2, 0.5, -1.2)
|
||||
|
||||
[node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=1177077250]
|
||||
environment = SubResource("Environment_bvwq1")
|
||||
|
||||
[node name="BurgerBuns" parent="." unique_id=1088240294 instance=ExtResource("6_kwbxo")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.8162017, 1.6081157, -1.4714175)
|
||||
|
||||
[node name="BurgerBunsDispenser" parent="." unique_id=1720683779 instance=ExtResource("7_tejmp")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.832131, 0.014894962, -1.4813508)
|
||||
|
||||
[node name="Plate" parent="." unique_id=190487773 instance=ExtResource("8_xcnr4")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.5717233, 1.5454081, 1.5373346)
|
||||
|
||||
[node name="burger" parent="." unique_id=1417604760 instance=ExtResource("9_rwx83")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.6888188, 1.4195822, -1.7110313)
|
||||
|
||||
[node name="burger2" parent="." unique_id=1584460510 instance=ExtResource("9_rwx83")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.6931299, 1.5300478, -1.71225)
|
||||
|
||||
[node name="burger3" parent="." unique_id=1884095916 instance=ExtResource("9_rwx83")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.69027674, 1.4969791, -1.7210286)
|
||||
|
||||
[node name="burger4" parent="." unique_id=1844818864 instance=ExtResource("9_rwx83")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.69134104, 1.4543622, -1.7210286)
|
||||
|
||||
[node name="Hamburger" parent="." unique_id=761091445 instance=ExtResource("10_waew8")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.9352558, 1.623975, 1.2447833)
|
||||
|
||||
[node name="PickableObject" parent="." unique_id=1675596942 instance=ExtResource("11_8wetd")]
|
||||
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, 0, -2.6077032e-08, 1.4901161e-08, 1, 0.6225724, 1.4792972, -1.0473135)
|
||||
|
||||
[node name="PickableObject2" parent="." unique_id=713087634 instance=ExtResource("11_8wetd")]
|
||||
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, -5.293956e-23, -2.6077032e-08, 1.4901161e-08, 1, 0.6359743, 1.4639391, -1.1673055)
|
||||
|
||||
[node name="PickableObject3" parent="." unique_id=879935619 instance=ExtResource("11_8wetd")]
|
||||
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, 0, -2.6077032e-08, 1.4901161e-08, 1, 0.5142721, 1.4792972, -1.0473135)
|
||||
|
||||
[node name="PickableObject4" parent="." unique_id=198541902 instance=ExtResource("11_8wetd")]
|
||||
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, -5.293956e-23, -2.6077032e-08, 1.4901161e-08, 1, 0.5276739, 1.4639391, -1.1673055)
|
||||
|
||||
[node name="Hamburger2" parent="." unique_id=1349934579 instance=ExtResource("10_waew8")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.9857153, 1.5329368, 0.9472374)
|
||||
|
||||
[node name="PickableObject5" parent="." unique_id=1021333509 instance=ExtResource("11_8wetd")]
|
||||
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, 0, -2.6077032e-08, 1.4901161e-08, 1, 0.73287535, 1.4792972, -1.0473135)
|
||||
|
||||
[node name="PickableObject6" parent="." unique_id=1268843669 instance=ExtResource("11_8wetd")]
|
||||
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, -5.293956e-23, -2.6077032e-08, 1.4901161e-08, 1, 0.7462772, 1.4639391, -1.1673055)
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.8, 0.015, -1.2)
|
||||
|
||||
[node name="Sink" parent="." unique_id=2055277359 instance=ExtResource("12_ah4xa")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.3140475, 0.5, -1.4941733)
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.5997182, 0.5, -1.2)
|
||||
|
||||
[node name="Sink2" parent="." unique_id=1692612192 instance=ExtResource("12_ah4xa")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.1997182, 0.5, -1.2)
|
||||
|
||||
[node name="DirtStation" parent="." unique_id=160842153 instance=ExtResource("13_46xi2")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 2.0864775, 0.5, -1.244947)
|
||||
|
||||
[node name="Plate2" parent="." unique_id=356790445 instance=ExtResource("8_xcnr4")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.5730225, 1.499024, 0.59790254)
|
||||
|
||||
[node name="CookedBurger" parent="." unique_id=1127807542 instance=ExtResource("14_vftjt")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.30671906, 1.4131018, -1.0928738)
|
||||
|
||||
[node name="CookedBurger2" parent="." unique_id=160088590 instance=ExtResource("14_vftjt")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.30671906, 1.4496142, -1.0928738)
|
||||
|
||||
[node name="CookedBurger3" parent="." unique_id=992183367 instance=ExtResource("14_vftjt")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.3344773, 1.4398065, -0.7096845)
|
||||
|
||||
[node name="CookedBurger4" parent="." unique_id=1837607086 instance=ExtResource("14_vftjt")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.30671906, 1.525444, -1.0928738)
|
||||
|
||||
[node name="BurgerBuns2" parent="." unique_id=1210358958 instance=ExtResource("6_kwbxo")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.3596323, 1.4110342, -0.22482127)
|
||||
|
||||
[node name="PickableObject7" parent="." unique_id=641653545 instance=ExtResource("11_8wetd")]
|
||||
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, 0, -2.6077032e-08, 1.4901161e-08, 1, -1.3556751, 1.4792972, 0.28881657)
|
||||
|
||||
[node name="PickableObject8" parent="." unique_id=1733819363 instance=ExtResource("11_8wetd")]
|
||||
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, -5.293956e-23, -2.6077032e-08, 1.4901161e-08, 1, -1.3422732, 1.4639391, 0.16882455)
|
||||
|
||||
[node name="PickableObject9" parent="." unique_id=12419746 instance=ExtResource("11_8wetd")]
|
||||
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, 0, -2.6077032e-08, 1.4901161e-08, 1, -1.4639754, 1.4792972, 0.28881657)
|
||||
|
||||
[node name="PickableObject10" parent="." unique_id=313160217 instance=ExtResource("11_8wetd")]
|
||||
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, -5.293956e-23, -2.6077032e-08, 1.4901161e-08, 1, -1.4505737, 1.4639391, 0.16882455)
|
||||
|
||||
[node name="Counter" parent="." unique_id=1487893288 instance=ExtResource("15_tpcje")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.7124918, 0.5, -1.4886917)
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.8000001, 0.5, -1.2)
|
||||
|
||||
[node name="Counter2" parent="." unique_id=368890752 instance=ExtResource("15_tpcje")]
|
||||
transform = Transform3D(-4.371139e-08, 0, 1, 0, 1, 0, -1, 0, -4.371139e-08, -1.7648025, 0.5, -0.4458799)
|
||||
transform = Transform3D(-4.371139e-08, 0, 1, 0, 1, 0, -1, 0, -4.371139e-08, -1.8, 0.5, -0.6)
|
||||
|
||||
[node name="Counter3" parent="." unique_id=1498817646 instance=ExtResource("15_tpcje")]
|
||||
transform = Transform3D(-4.371139e-08, 0, 1, 0, 1, 0, -1, 0, -4.371139e-08, -1.7648025, 0.5, 0.55367994)
|
||||
transform = Transform3D(-4.371139e-08, 0, 1, 0, 1, 0, -1, 0, -4.371139e-08, -1.8, 0.5, 0)
|
||||
|
||||
[node name="Counter4" parent="." unique_id=906748771 instance=ExtResource("15_tpcje")]
|
||||
transform = Transform3D(-4.371139e-08, 0, 1, 0, 1, 0, -1, 0, -4.371139e-08, -1.7648025, 0.5, 1.5539298)
|
||||
transform = Transform3D(-4.371139e-08, 0, 1, 0, 1, 0, -1, 0, -4.371139e-08, -1.8, 0.5, 0.6)
|
||||
|
||||
[node name="Table" parent="." unique_id=1863572470 instance=ExtResource("16_g2k6a")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 2.139665, 0.9030438, 2.2267122)
|
||||
[node name="Counter5" parent="." unique_id=1487893288 instance=ExtResource("15_tpcje")]
|
||||
transform = Transform3D(-1, 0, -8.742278e-08, 0, 1, 0, 8.742278e-08, 0, -1, -0.6, 0.5, 1.2)
|
||||
|
||||
[node name="Hamburger3" parent="." unique_id=369573848 instance=ExtResource("10_waew8")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.7201865, 1.5329367, 0.14773655)
|
||||
[node name="Counter6" parent="." unique_id=1154936647 instance=ExtResource("15_tpcje")]
|
||||
transform = Transform3D(-1, 0, -8.742278e-08, 0, 1, 0, 8.742278e-08, 0, -1, 0, 0.5, 1.2)
|
||||
|
||||
[node name="Plate3" parent="." unique_id=2004250522 instance=ExtResource("8_xcnr4")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.5818124, 1.499024, -0.4634577)
|
||||
[node name="Counter7" parent="." unique_id=990260272 instance=ExtResource("15_tpcje")]
|
||||
transform = Transform3D(-1, 0, -8.742278e-08, 0, 1, 0, 8.742278e-08, 0, -1, 0.6, 0.5, 1.2)
|
||||
|
||||
[node name="StaticBody3D" type="StaticBody3D" parent="." unique_id=315740174]
|
||||
|
||||
@@ -191,7 +127,19 @@ scene = ExtResource("18_3lufs")
|
||||
viewport_size = Vector2(900, 600)
|
||||
transparent = 0
|
||||
filter = false
|
||||
scene_properties_keys = PackedStringArray("main_menu_panel.gd")
|
||||
scene_properties_keys = PackedStringArray("game_over_panel.gd")
|
||||
|
||||
[node name="controler" type="Node" parent="GameOverPanel" unique_id=1302491889]
|
||||
script = ExtResource("19_3lufs")
|
||||
|
||||
[node name="PlateDispenser" parent="." unique_id=710538846 instance=ExtResource("14_h88sx")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.8, 0, 1.2)
|
||||
|
||||
[node name="RawBurgerDispenser" parent="." unique_id=235630131 instance=ExtResource("15_m3h4l")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.2, 0, 1.2)
|
||||
|
||||
[node name="Node" type="Node" parent="." unique_id=1224933801]
|
||||
script = ExtResource("16_m3h4l")
|
||||
|
||||
[node name="raw_burger" parent="." unique_id=1675596942 instance=ExtResource("9_rwx83")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.6, 1.2, -0.6)
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
[gd_scene format=3 uid="uid://22shbqdvnwgo"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://b4ldkd3ngs0vp" path="res://main.gd" id="1_m3h4l"]
|
||||
[ext_resource type="PackedScene" uid="uid://j5s5wus7tuhb" path="res://XROrigin.tscn" id="2_8pfaf"]
|
||||
[ext_resource type="Material" uid="uid://cmia50cfqxxo4" path="res://Textures/dev_material_3d.tres" id="3_gatbn"]
|
||||
[ext_resource type="PackedScene" uid="uid://j7caslh27nor" path="res://Stations/Hob.tscn" id="4_3lufs"]
|
||||
[ext_resource type="Sky" uid="uid://c1abtpwhdm2d5" path="res://Textures/sky/NightSkyHDRI009_16K.tres" id="5_kh26v"]
|
||||
[ext_resource type="PackedScene" uid="uid://c6rift56ql3f8" path="res://Stations/BurgerBunsDispenser.tscn" id="7_tejmp"]
|
||||
[ext_resource type="PackedScene" uid="uid://dvrk268s7gkxh" path="res://Stations/sink.tscn" id="12_ah4xa"]
|
||||
[ext_resource type="PackedScene" uid="uid://cnjwtnhwh0i8q" path="res://Stations/dirt_station.tscn" id="13_46xi2"]
|
||||
[ext_resource type="PackedScene" uid="uid://ck5tuftqmyiue" path="res://Stations/plate_dispenser.tscn" id="14_h88sx"]
|
||||
[ext_resource type="PackedScene" uid="uid://cwnwo4i28upap" path="res://Stations/raw_burger_dispenser.tscn" id="15_m3h4l"]
|
||||
[ext_resource type="PackedScene" uid="uid://efaec6ymgabo" path="res://Stations/Counter.tscn" id="15_tpcje"]
|
||||
[ext_resource type="PackedScene" uid="uid://caf0xanmxbshy" path="res://Stations/table.tscn" id="16_g2k6a"]
|
||||
[ext_resource type="Script" uid="uid://k8ywnvlhcic4" path="res://Scenes/testworldLoad.gd" id="16_m3h4l"]
|
||||
[ext_resource type="PackedScene" uid="uid://clujaf3u776a3" path="res://addons/godot-xr-tools/objects/viewport_2d_in_3d.tscn" id="17_gatbn"]
|
||||
[ext_resource type="PackedScene" uid="uid://xtcei2uvd5li" path="res://UI/game_over_panel.tscn" id="18_3lufs"]
|
||||
[ext_resource type="Script" uid="uid://cwijoksyou1ey" path="res://Scenes/GameOvercontroler.gd" id="19_3lufs"]
|
||||
|
||||
[sub_resource type="BoxShape3D" id="BoxShape3D_vlqg6"]
|
||||
size = Vector3(16, 0.1, 16)
|
||||
|
||||
[sub_resource type="BoxMesh" id="BoxMesh_24d3s"]
|
||||
material = ExtResource("3_gatbn")
|
||||
size = Vector3(16, 0.1, 16)
|
||||
|
||||
[sub_resource type="Environment" id="Environment_bvwq1"]
|
||||
background_mode = 2
|
||||
sky = ExtResource("5_kh26v")
|
||||
reflected_light_source = 2
|
||||
ssr_enabled = true
|
||||
ssao_enabled = true
|
||||
ssil_enabled = true
|
||||
sdfgi_enabled = true
|
||||
|
||||
[sub_resource type="BoxShape3D" id="BoxShape3D_24d3s"]
|
||||
size = Vector3(16, 10, 0.1)
|
||||
|
||||
[node name="Main" type="Node3D" unique_id=1312265607]
|
||||
script = ExtResource("1_m3h4l")
|
||||
|
||||
[node name="XROrigin3D" parent="." unique_id=2055526621 instance=ExtResource("2_8pfaf")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.6, 0)
|
||||
|
||||
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="." unique_id=1376644384]
|
||||
transform = Transform3D(-0.8660254, -0.43301278, 0.24999999, 0.3022632, -0.05510214, 0.9516306, -0.39829266, 0.8997021, 0.17860365, 0, 0, 0)
|
||||
|
||||
[node name="Floor" type="StaticBody3D" parent="." unique_id=122279514]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.05, 0)
|
||||
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="Floor" unique_id=958841648]
|
||||
shape = SubResource("BoxShape3D_vlqg6")
|
||||
|
||||
[node name="MeshInstance3D" type="MeshInstance3D" parent="Floor/CollisionShape3D" unique_id=1939997056]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.0002836883, 0.0006355047, -0.0054528117)
|
||||
mesh = SubResource("BoxMesh_24d3s")
|
||||
|
||||
[node name="Hob" parent="." unique_id=1687971542 instance=ExtResource("4_3lufs")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5, -1.2)
|
||||
|
||||
[node name="Hob2" parent="." unique_id=496572049 instance=ExtResource("4_3lufs")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.6, 0.5, -1.2)
|
||||
|
||||
[node name="Hob3" parent="." unique_id=421567982 instance=ExtResource("4_3lufs")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.2, 0.5, -1.2)
|
||||
|
||||
[node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=1177077250]
|
||||
environment = SubResource("Environment_bvwq1")
|
||||
|
||||
[node name="BurgerBunsDispenser" parent="." unique_id=1720683779 instance=ExtResource("7_tejmp")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.8, 0.015, -1.2)
|
||||
|
||||
[node name="Sink" parent="." unique_id=2055277359 instance=ExtResource("12_ah4xa")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.5997182, 0.5, -1.2)
|
||||
|
||||
[node name="Sink2" parent="." unique_id=1692612192 instance=ExtResource("12_ah4xa")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.1997182, 0.5, -1.2)
|
||||
|
||||
[node name="DirtStation" parent="." unique_id=160842153 instance=ExtResource("13_46xi2")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.8000001, 0.5, -1.2)
|
||||
|
||||
[node name="Counter2" parent="." unique_id=368890752 instance=ExtResource("15_tpcje")]
|
||||
transform = Transform3D(-4.371139e-08, 0, 1, 0, 1, 0, -1, 0, -4.371139e-08, -1.8, 0.5, -0.6)
|
||||
|
||||
[node name="Counter3" parent="." unique_id=1498817646 instance=ExtResource("15_tpcje")]
|
||||
transform = Transform3D(-4.371139e-08, 0, 1, 0, 1, 0, -1, 0, -4.371139e-08, -1.8, 0.5, 0)
|
||||
|
||||
[node name="Counter4" parent="." unique_id=906748771 instance=ExtResource("15_tpcje")]
|
||||
transform = Transform3D(-4.371139e-08, 0, 1, 0, 1, 0, -1, 0, -4.371139e-08, -1.8, 0.5, 0.6)
|
||||
|
||||
[node name="Counter5" parent="." unique_id=1487893288 instance=ExtResource("15_tpcje")]
|
||||
transform = Transform3D(-1, 0, -8.742278e-08, 0, 1, 0, 8.742278e-08, 0, -1, -0.6, 0.5, 1.2)
|
||||
|
||||
[node name="Counter6" parent="." unique_id=1154936647 instance=ExtResource("15_tpcje")]
|
||||
transform = Transform3D(-1, 0, -8.742278e-08, 0, 1, 0, 8.742278e-08, 0, -1, 0, 0.5, 1.2)
|
||||
|
||||
[node name="Counter7" parent="." unique_id=990260272 instance=ExtResource("15_tpcje")]
|
||||
transform = Transform3D(-1, 0, -8.742278e-08, 0, 1, 0, 8.742278e-08, 0, -1, 0.6, 0.5, 1.2)
|
||||
|
||||
[node name="Table" parent="." unique_id=1863572470 instance=ExtResource("16_g2k6a")]
|
||||
transform = Transform3D(-4.371139e-08, 0, -1, 0, 1, 0, 1, 0, -4.371139e-08, 2.739665, 0.47583598, -1.1895752)
|
||||
|
||||
[node name="StaticBody3D" type="StaticBody3D" parent="." unique_id=315740174]
|
||||
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="StaticBody3D" unique_id=33059670]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 4.689, 8)
|
||||
shape = SubResource("BoxShape3D_24d3s")
|
||||
|
||||
[node name="CollisionShape3D2" type="CollisionShape3D" parent="StaticBody3D" unique_id=2018792171]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 4.689, -8)
|
||||
shape = SubResource("BoxShape3D_24d3s")
|
||||
|
||||
[node name="CollisionShape3D3" type="CollisionShape3D" parent="StaticBody3D" unique_id=757662929]
|
||||
transform = Transform3D(-4.371139e-08, 0, -1, 0, 1, 0, 1, 0, -4.371139e-08, -8, 4.689, 0)
|
||||
shape = SubResource("BoxShape3D_24d3s")
|
||||
|
||||
[node name="CollisionShape3D4" type="CollisionShape3D" parent="StaticBody3D" unique_id=1468386997]
|
||||
transform = Transform3D(-4.371139e-08, 0, -1, 0, 1, 0, 1, 0, -4.371139e-08, 8, 4.689, 0)
|
||||
shape = SubResource("BoxShape3D_24d3s")
|
||||
|
||||
[node name="GameOverPanel" parent="." unique_id=221361954 instance=ExtResource("17_gatbn")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.46454135, 3.4496074, -1.832967)
|
||||
visible = false
|
||||
scene = ExtResource("18_3lufs")
|
||||
viewport_size = Vector2(900, 600)
|
||||
transparent = 0
|
||||
filter = false
|
||||
scene_properties_keys = PackedStringArray("game_over_panel.gd")
|
||||
|
||||
[node name="controler" type="Node" parent="GameOverPanel" unique_id=1302491889]
|
||||
script = ExtResource("19_3lufs")
|
||||
|
||||
[node name="PlateDispenser" parent="." unique_id=710538846 instance=ExtResource("14_h88sx")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.8, 0, 1.2)
|
||||
|
||||
[node name="RawBurgerDispenser" parent="." unique_id=235630131 instance=ExtResource("15_m3h4l")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.2, 0, 1.2)
|
||||
|
||||
[node name="Node" type="Node" parent="." unique_id=1224933801]
|
||||
script = ExtResource("16_m3h4l")
|
||||
@@ -0,0 +1,140 @@
|
||||
[gd_scene format=3 uid="uid://22shbqdvnwgo"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://b4ldkd3ngs0vp" path="res://main.gd" id="1_m3h4l"]
|
||||
[ext_resource type="PackedScene" uid="uid://j5s5wus7tuhb" path="res://XROrigin.tscn" id="2_8pfaf"]
|
||||
[ext_resource type="Material" uid="uid://cmia50cfqxxo4" path="res://Textures/dev_material_3d.tres" id="3_gatbn"]
|
||||
[ext_resource type="PackedScene" uid="uid://j7caslh27nor" path="res://Stations/Hob.tscn" id="4_3lufs"]
|
||||
[ext_resource type="Sky" uid="uid://c1abtpwhdm2d5" path="res://Textures/sky/NightSkyHDRI009_16K.tres" id="5_kh26v"]
|
||||
[ext_resource type="PackedScene" uid="uid://c6rift56ql3f8" path="res://Stations/BurgerBunsDispenser.tscn" id="7_tejmp"]
|
||||
[ext_resource type="PackedScene" uid="uid://dvrk268s7gkxh" path="res://Stations/sink.tscn" id="12_ah4xa"]
|
||||
[ext_resource type="PackedScene" uid="uid://cnjwtnhwh0i8q" path="res://Stations/dirt_station.tscn" id="13_46xi2"]
|
||||
[ext_resource type="PackedScene" uid="uid://ck5tuftqmyiue" path="res://Stations/plate_dispenser.tscn" id="14_h88sx"]
|
||||
[ext_resource type="PackedScene" uid="uid://cwnwo4i28upap" path="res://Stations/raw_burger_dispenser.tscn" id="15_m3h4l"]
|
||||
[ext_resource type="PackedScene" uid="uid://efaec6ymgabo" path="res://Stations/Counter.tscn" id="15_tpcje"]
|
||||
[ext_resource type="PackedScene" uid="uid://caf0xanmxbshy" path="res://Stations/table.tscn" id="16_g2k6a"]
|
||||
[ext_resource type="Script" uid="uid://k8ywnvlhcic4" path="res://Scenes/testworldLoad.gd" id="16_m3h4l"]
|
||||
[ext_resource type="PackedScene" uid="uid://clujaf3u776a3" path="res://addons/godot-xr-tools/objects/viewport_2d_in_3d.tscn" id="17_gatbn"]
|
||||
[ext_resource type="PackedScene" uid="uid://xtcei2uvd5li" path="res://UI/game_over_panel.tscn" id="18_3lufs"]
|
||||
[ext_resource type="Script" uid="uid://cwijoksyou1ey" path="res://Scenes/GameOvercontroler.gd" id="19_3lufs"]
|
||||
|
||||
[sub_resource type="BoxShape3D" id="BoxShape3D_vlqg6"]
|
||||
size = Vector3(16, 0.1, 16)
|
||||
|
||||
[sub_resource type="BoxMesh" id="BoxMesh_24d3s"]
|
||||
material = ExtResource("3_gatbn")
|
||||
size = Vector3(16, 0.1, 16)
|
||||
|
||||
[sub_resource type="Environment" id="Environment_bvwq1"]
|
||||
background_mode = 2
|
||||
sky = ExtResource("5_kh26v")
|
||||
reflected_light_source = 2
|
||||
ssr_enabled = true
|
||||
ssao_enabled = true
|
||||
ssil_enabled = true
|
||||
sdfgi_enabled = true
|
||||
|
||||
[sub_resource type="BoxShape3D" id="BoxShape3D_24d3s"]
|
||||
size = Vector3(16, 10, 0.1)
|
||||
|
||||
[node name="Main" type="Node3D" unique_id=1312265607]
|
||||
script = ExtResource("1_m3h4l")
|
||||
|
||||
[node name="XROrigin3D" parent="." unique_id=2055526621 instance=ExtResource("2_8pfaf")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.6, 0)
|
||||
|
||||
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="." unique_id=1376644384]
|
||||
transform = Transform3D(-0.8660254, -0.43301278, 0.24999999, 0.3022632, -0.05510214, 0.9516306, -0.39829266, 0.8997021, 0.17860365, 0, 0, 0)
|
||||
|
||||
[node name="Floor" type="StaticBody3D" parent="." unique_id=122279514]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.05, 0)
|
||||
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="Floor" unique_id=958841648]
|
||||
shape = SubResource("BoxShape3D_vlqg6")
|
||||
|
||||
[node name="MeshInstance3D" type="MeshInstance3D" parent="Floor/CollisionShape3D" unique_id=1939997056]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.0002836883, 0.0006355047, -0.0054528117)
|
||||
mesh = SubResource("BoxMesh_24d3s")
|
||||
|
||||
[node name="Hob" parent="." unique_id=1687971542 instance=ExtResource("4_3lufs")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5, -1.2)
|
||||
|
||||
[node name="Hob2" parent="." unique_id=496572049 instance=ExtResource("4_3lufs")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.6, 0.5, -1.2)
|
||||
|
||||
[node name="Hob3" parent="." unique_id=421567982 instance=ExtResource("4_3lufs")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.2, 0.5, -1.2)
|
||||
|
||||
[node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=1177077250]
|
||||
environment = SubResource("Environment_bvwq1")
|
||||
|
||||
[node name="BurgerBunsDispenser" parent="." unique_id=1720683779 instance=ExtResource("7_tejmp")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.8, 0.015, -1.2)
|
||||
|
||||
[node name="Sink" parent="." unique_id=2055277359 instance=ExtResource("12_ah4xa")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.5997182, 0.5, -1.2)
|
||||
|
||||
[node name="Sink2" parent="." unique_id=1692612192 instance=ExtResource("12_ah4xa")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.1997182, 0.5, -1.2)
|
||||
|
||||
[node name="DirtStation" parent="." unique_id=160842153 instance=ExtResource("13_46xi2")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.8000001, 0.5, -1.2)
|
||||
|
||||
[node name="Counter2" parent="." unique_id=368890752 instance=ExtResource("15_tpcje")]
|
||||
transform = Transform3D(-4.371139e-08, 0, 1, 0, 1, 0, -1, 0, -4.371139e-08, -1.8, 0.5, -0.6)
|
||||
|
||||
[node name="Counter3" parent="." unique_id=1498817646 instance=ExtResource("15_tpcje")]
|
||||
transform = Transform3D(-4.371139e-08, 0, 1, 0, 1, 0, -1, 0, -4.371139e-08, -1.8, 0.5, 0)
|
||||
|
||||
[node name="Counter4" parent="." unique_id=906748771 instance=ExtResource("15_tpcje")]
|
||||
transform = Transform3D(-4.371139e-08, 0, 1, 0, 1, 0, -1, 0, -4.371139e-08, -1.8, 0.5, 0.6)
|
||||
|
||||
[node name="Counter5" parent="." unique_id=1487893288 instance=ExtResource("15_tpcje")]
|
||||
transform = Transform3D(-1, 0, -8.742278e-08, 0, 1, 0, 8.742278e-08, 0, -1, -0.6, 0.5, 1.2)
|
||||
|
||||
[node name="Counter6" parent="." unique_id=1154936647 instance=ExtResource("15_tpcje")]
|
||||
transform = Transform3D(-1, 0, -8.742278e-08, 0, 1, 0, 8.742278e-08, 0, -1, 0, 0.5, 1.2)
|
||||
|
||||
[node name="Counter7" parent="." unique_id=990260272 instance=ExtResource("15_tpcje")]
|
||||
transform = Transform3D(-1, 0, -8.742278e-08, 0, 1, 0, 8.742278e-08, 0, -1, 0.6, 0.5, 1.2)
|
||||
|
||||
[node name="Table" parent="." unique_id=1863572470 instance=ExtResource("16_g2k6a")]
|
||||
transform = Transform3D(-4.371139e-08, 0, -1, 0, 1, 0, 1, 0, -4.371139e-08, 2.739665, 0.47583598, -1.1895752)
|
||||
|
||||
[node name="StaticBody3D" type="StaticBody3D" parent="." unique_id=315740174]
|
||||
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="StaticBody3D" unique_id=33059670]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 4.689, 8)
|
||||
shape = SubResource("BoxShape3D_24d3s")
|
||||
|
||||
[node name="CollisionShape3D2" type="CollisionShape3D" parent="StaticBody3D" unique_id=2018792171]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 4.689, -8)
|
||||
shape = SubResource("BoxShape3D_24d3s")
|
||||
|
||||
[node name="CollisionShape3D3" type="CollisionShape3D" parent="StaticBody3D" unique_id=757662929]
|
||||
transform = Transform3D(-4.371139e-08, 0, -1, 0, 1, 0, 1, 0, -4.371139e-08, -8, 4.689, 0)
|
||||
shape = SubResource("BoxShape3D_24d3s")
|
||||
|
||||
[node name="CollisionShape3D4" type="CollisionShape3D" parent="StaticBody3D" unique_id=1468386997]
|
||||
transform = Transform3D(-4.371139e-08, 0, -1, 0, 1, 0, 1, 0, -4.371139e-08, 8, 4.689, 0)
|
||||
shape = SubResource("BoxShape3D_24d3s")
|
||||
|
||||
[node name="GameOverPanel" parent="." unique_id=221361954 instance=ExtResource("17_gatbn")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.46454135, 3.4496074, -1.832967)
|
||||
visible = false
|
||||
scene = ExtResource("18_3lufs")
|
||||
viewport_size = Vector2(900, 600)
|
||||
transparent = 0
|
||||
filter = false
|
||||
scene_properties_keys = PackedStringArray("game_over_panel.gd")
|
||||
|
||||
[node name="controler" type="Node" parent="GameOverPanel" unique_id=1302491889]
|
||||
script = ExtResource("19_3lufs")
|
||||
|
||||
[node name="PlateDispenser" parent="." unique_id=710538846 instance=ExtResource("14_h88sx")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.8, 0, 1.2)
|
||||
|
||||
[node name="RawBurgerDispenser" parent="." unique_id=235630131 instance=ExtResource("15_m3h4l")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.2, 0, 1.2)
|
||||
|
||||
[node name="Node" type="Node" parent="." unique_id=1224933801]
|
||||
script = ExtResource("16_m3h4l")
|
||||
+15
-21
@@ -1,15 +1,13 @@
|
||||
[gd_scene format=3 uid="uid://do6mrslyqe5qe"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://b4ldkd3ngs0vp" path="res://main.gd" id="1_57ppd"]
|
||||
[ext_resource type="Script" uid="uid://d1cefhe3yyyds" path="res://Scenes/multiplayer_world.gd" id="1_57ppd"]
|
||||
[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="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://bp3v1jl8pctro" path="res://Containers/plate.tscn" id="10_ay2w6"]
|
||||
[ext_resource type="PackedScene" uid="uid://cwnwo4i28upap" path="res://Stations/raw_burger_dispenser.tscn" id="11_ctden"]
|
||||
[ext_resource type="PackedScene" uid="uid://bbg7dwsbxxh1t" path="res://Scenes/cube_side_dispense.tscn" id="12_57ppd"]
|
||||
[ext_resource type="PackedScene" uid="uid://b3m2ag8g5rj4r" path="res://Items/hamburger.tscn" id="12_hfwnn"]
|
||||
[ext_resource type="PackedScene" uid="uid://ck5tuftqmyiue" path="res://Stations/plate_dispenser.tscn" id="13_irf4n"]
|
||||
[ext_resource type="PackedScene" uid="uid://dvrk268s7gkxh" path="res://Stations/sink.tscn" id="13_o1b3t"]
|
||||
[ext_resource type="PackedScene" uid="uid://clujaf3u776a3" path="res://addons/godot-xr-tools/objects/viewport_2d_in_3d.tscn" id="14_di04w"]
|
||||
@@ -47,9 +45,19 @@ script = ExtResource("1_57ppd")
|
||||
[node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=1177077250]
|
||||
environment = SubResource("Environment_bvwq1")
|
||||
|
||||
[node name="XROrigin3D" parent="." unique_id=2055526621 instance=ExtResource("2_o1b3t")]
|
||||
[node name="XROrigin3D" parent="." unique_id=2055526621 groups=["local_xr_origin"] instance=ExtResource("2_o1b3t")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.9667189, 0)
|
||||
|
||||
[node name="WorldContent" type="Node3D" parent="." unique_id=262398468]
|
||||
|
||||
[node name="Players" type="Node3D" parent="." unique_id=1772076868]
|
||||
|
||||
[node name="ItemsSpawner" type="MultiplayerSpawner" parent="." unique_id=2051771581]
|
||||
spawn_path = NodePath("../WorldContent")
|
||||
|
||||
[node name="PlayersSpawner" type="MultiplayerSpawner" parent="." unique_id=619815427]
|
||||
spawn_path = NodePath("../Players")
|
||||
|
||||
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="." unique_id=1376644384]
|
||||
transform = Transform3D(-0.8660254, -0.43301278, 0.24999999, 0.3022632, -0.05510214, 0.9516306, -0.39829266, 0.8997021, 0.17860365, 0, 0, 0)
|
||||
|
||||
@@ -83,6 +91,7 @@ shape = SubResource("BoxShape3D_24d3s")
|
||||
|
||||
[node name="Table" parent="." unique_id=1863572470 instance=ExtResource("16_di04w")]
|
||||
transform = Transform3D(-1, 0, 8.742277e-08, 0, 1, 0, -8.742277e-08, 0, -1, 3.6294794, 0.54177135, 0.0973109)
|
||||
primary_duration = 35.0
|
||||
|
||||
[node name="Hob" parent="." unique_id=1687971542 instance=ExtResource("4_ctden")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.35077858, 0.50655985, -1.2833805)
|
||||
@@ -105,11 +114,8 @@ transform = Transform3D(-4.371139e-08, 0, 1, 0, 1, 0, -1, 0, -4.371139e-08, -1.5
|
||||
[node name="Counter3" parent="." unique_id=1498817646 instance=ExtResource("15_di04w")]
|
||||
transform = Transform3D(-4.371139e-08, 0, 1, 0, 1, 0, -1, 0, -4.371139e-08, -1.5477214, 0.5115016, 0.49258912)
|
||||
|
||||
[node name="Counter5" parent="." unique_id=200341072 instance=ExtResource("15_di04w")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 2.0304868, 0.5115016, -1.3125262)
|
||||
|
||||
[node name="Counter6" parent="." unique_id=237068408 instance=ExtResource("15_di04w")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 2.6504855, 0.5115016, -1.3154786)
|
||||
[node name="Counter5" parent="Counter3" unique_id=200341072 instance=ExtResource("15_di04w")]
|
||||
transform = Transform3D(-1, 0, 4.371139e-08, 0, 1, 0, -4.371139e-08, 0, -1, -0.12681937, 2.3841858e-07, 3.538899)
|
||||
|
||||
[node name="BurgerBunsDispenser" parent="." unique_id=1720683779 instance=ExtResource("9_sq0s2")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.5242664, 0.008738995, 1.1073059)
|
||||
@@ -133,15 +139,3 @@ script = ExtResource("16_n8fyw")
|
||||
|
||||
[node name="CubeSideDispenser2" parent="." unique_id=200950572 instance=ExtResource("12_57ppd")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.35154933, 0, 1.1119425)
|
||||
|
||||
[node name="Hamburger" parent="." unique_id=1675596942 instance=ExtResource("12_hfwnn")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 2.0753264, 1.3743987, -1.3305619)
|
||||
|
||||
[node name="Hamburger2" parent="." unique_id=1030731163 instance=ExtResource("12_hfwnn")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 2.6262975, 1.3743987, -1.3305619)
|
||||
|
||||
[node name="Plate" parent="." unique_id=190487773 instance=ExtResource("10_ay2w6")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 2.0396392, 1.1679064, -1.3300265)
|
||||
|
||||
[node name="Plate2" parent="." unique_id=1568542970 instance=ExtResource("10_ay2w6")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 2.6286347, 1.1679064, -1.3300265)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
[ext_resource type="PackedScene" uid="uid://j5s5wus7tuhb" path="res://XROrigin.tscn" id="2_g30gi"]
|
||||
[ext_resource type="Material" uid="uid://cmia50cfqxxo4" path="res://Textures/dev_material_3d.tres" id="3_75ecy"]
|
||||
[ext_resource type="Sky" uid="uid://c1abtpwhdm2d5" path="res://Textures/sky/NightSkyHDRI009_16K.tres" id="5_c3xgf"]
|
||||
[ext_resource type="Script" uid="uid://biu4qr3nr1eny" path="res://test/mp_test_driver.gd" id="99_mptst"]
|
||||
|
||||
[sub_resource type="BoxShape3D" id="BoxShape3D_vlqg6"]
|
||||
size = Vector3(15, 0.1, 15)
|
||||
@@ -54,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]
|
||||
@@ -74,3 +74,6 @@ shape = SubResource("BoxShape3D_arao0")
|
||||
[node name="CollisionShape3D4" type="CollisionShape3D" parent="StaticBody3D" unique_id=1431367494]
|
||||
transform = Transform3D(-4.371139e-08, 0, -1, 0, 1, 0, 1, 0, -4.371139e-08, 7.502467, 9.197384, -0.018813023)
|
||||
shape = SubResource("BoxShape3D_arao0")
|
||||
|
||||
[node name="TestDriver" type="Node" parent="."]
|
||||
script = ExtResource("99_mptst")
|
||||
|
||||
+75
-47
@@ -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,36 +69,60 @@ 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 not NetworkManager.owns_world() or _populated or not populate_from_layout:
|
||||
if _populated or not populate_from_layout:
|
||||
return
|
||||
# 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_authored_nodes()
|
||||
|
||||
# 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)
|
||||
layout.queue_free()
|
||||
return
|
||||
|
||||
_populated = true
|
||||
GameManager.meals_in_play = ["hamburger"]
|
||||
var stations := WorldLayout.get_stations()
|
||||
var items := WorldLayout.get_items()
|
||||
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:
|
||||
var objects := layout.describe(authored)
|
||||
layout.queue_free()
|
||||
_remove_authored(authored)
|
||||
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")
|
||||
|
||||
|
||||
## Only the server (or the single offline machine) materialises player
|
||||
## avatars; MultiplayerSpawner replicates the result to everyone else,
|
||||
## including late joiners.
|
||||
# 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):
|
||||
node.get_parent().remove_child(node)
|
||||
node.free()
|
||||
|
||||
|
||||
## 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)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
extends Node
|
||||
|
||||
|
||||
# Called when the node enters the scene tree for the first time.
|
||||
func _ready() -> void:
|
||||
print(get_stations())
|
||||
print(get_items())
|
||||
|
||||
|
||||
|
||||
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
|
||||
data["xform"] = node.transform
|
||||
data["props"] = {}
|
||||
var scrip = node.get_script()
|
||||
if scrip:
|
||||
for i in scrip.get_script_property_list():
|
||||
if i.usage & PROPERTY_USAGE_STORAGE:
|
||||
data["props"][i["name"]] = node.get(i["name"])
|
||||
return data
|
||||
|
||||
|
||||
func get_stations() -> Array[Dictionary]:
|
||||
var Stations = ResourceLoader.list_directory("res://Stations/")
|
||||
|
||||
var Stations2 = []
|
||||
for i in Stations:
|
||||
Stations2.append("res://Stations/" + i)
|
||||
Stations = Stations2
|
||||
|
||||
var Stations_nodes = []
|
||||
for i in get_tree().root.find_children("*", "StaticBody3D", true, false):
|
||||
if i.scene_file_path in Stations:
|
||||
Stations_nodes.append(i)
|
||||
|
||||
var data: Array[Dictionary] = []
|
||||
for i in Stations_nodes:
|
||||
data.append(get_node_data(i))
|
||||
return data
|
||||
|
||||
|
||||
func get_items() -> Array[Dictionary]:
|
||||
var Items = ResourceLoader.list_directory("res://Items/")
|
||||
|
||||
var Items2 = []
|
||||
for i in Items:
|
||||
Items2.append("res://Items/" + i)
|
||||
Items = Items2
|
||||
|
||||
var Items_nodes = []
|
||||
for i in get_tree().root.find_children("*", "XRToolsPickable", true, false):
|
||||
if i.scene_file_path in Items:
|
||||
Items_nodes.append(i)
|
||||
|
||||
var data: Array[Dictionary] = []
|
||||
for i in Items_nodes:
|
||||
data.append(get_node_data(i))
|
||||
return data
|
||||
@@ -0,0 +1 @@
|
||||
uid://k8ywnvlhcic4
|
||||
Binary file not shown.
@@ -0,0 +1,19 @@
|
||||
[remap]
|
||||
|
||||
importer="mp3"
|
||||
type="AudioStreamMP3"
|
||||
uid="uid://clkjwcawdqvta"
|
||||
path="res://.godot/imported/money.mp3-95c717b8402507d80974f4f4287b0b63.mp3str"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://Sounds/money.mp3"
|
||||
dest_files=["res://.godot/imported/money.mp3-95c717b8402507d80974f4f4287b0b63.mp3str"]
|
||||
|
||||
[params]
|
||||
|
||||
loop=false
|
||||
loop_offset=0.0
|
||||
bpm=0.0
|
||||
beat_count=0
|
||||
bar_beats=4
|
||||
+89
-63
@@ -4,6 +4,8 @@
|
||||
[ext_resource type="Script" uid="uid://cquqe4f1m1sw6" path="res://addons/godot-xr-tools/objects/snap_zone.gd" id="2_er1dp"]
|
||||
[ext_resource type="AudioStream" uid="uid://bqtk1adk8umbx" path="res://Sounds/220197__gameaudio__click-basic.wav" id="3_6oyg1"]
|
||||
[ext_resource type="PackedScene" uid="uid://bxbocxdaayvwx" path="res://UI/progress_bar.tscn" id="3_7uuqv"]
|
||||
[ext_resource type="Material" uid="uid://d1djvskv4n4m3" path="res://Textures/hob_off.tres" id="4_8qpxk"]
|
||||
[ext_resource type="Material" uid="uid://db7c7w4apwti7" path="res://Textures/hob_on.tres" id="6_m7l4u"]
|
||||
|
||||
[sub_resource type="BoxShape3D" id="BoxShape3D_kdxnr"]
|
||||
size = Vector3(0.6, 1, 0.6)
|
||||
@@ -11,112 +13,133 @@ size = Vector3(0.6, 1, 0.6)
|
||||
[sub_resource type="BoxShape3D" id="BoxShape3D_7uuqv"]
|
||||
size = Vector3(0.6, 0.12802735, 0.6)
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_di04w"]
|
||||
albedo_color = Color(0, 0, 0, 1)
|
||||
|
||||
[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
|
||||
|
||||
[sub_resource type="Animation" id="Animation_7uuqv"]
|
||||
length = 0.001
|
||||
tracks/0/type = "bezier"
|
||||
tracks/0/type = "value"
|
||||
tracks/0/imported = false
|
||||
tracks/0/enabled = true
|
||||
tracks/0/path = NodePath("CSGCombiner3D/CSGTorus3D:material:albedo_color:r")
|
||||
tracks/0/path = NodePath("CSGCombiner3D/CSGTorus3D3:material")
|
||||
tracks/0/interp = 1
|
||||
tracks/0/loop_wrap = true
|
||||
tracks/0/keys = {
|
||||
"handle_modes": PackedInt32Array(0),
|
||||
"points": PackedFloat32Array(0, -0.25, 0, 0.25, 0),
|
||||
"times": PackedFloat32Array(0)
|
||||
"times": PackedFloat32Array(0),
|
||||
"transitions": PackedFloat32Array(1),
|
||||
"update": 1,
|
||||
"values": [ExtResource("4_8qpxk")]
|
||||
}
|
||||
tracks/1/type = "bezier"
|
||||
tracks/1/type = "value"
|
||||
tracks/1/imported = false
|
||||
tracks/1/enabled = true
|
||||
tracks/1/path = NodePath("CSGCombiner3D/CSGTorus3D:material:albedo_color:g")
|
||||
tracks/1/path = NodePath("CSGCombiner3D/CSGTorus3D2:material")
|
||||
tracks/1/interp = 1
|
||||
tracks/1/loop_wrap = true
|
||||
tracks/1/keys = {
|
||||
"handle_modes": PackedInt32Array(0),
|
||||
"points": PackedFloat32Array(0, -0.25, 0, 0.25, 0),
|
||||
"times": PackedFloat32Array(0)
|
||||
"times": PackedFloat32Array(0),
|
||||
"transitions": PackedFloat32Array(1),
|
||||
"update": 1,
|
||||
"values": [ExtResource("4_8qpxk")]
|
||||
}
|
||||
tracks/2/type = "bezier"
|
||||
tracks/2/type = "value"
|
||||
tracks/2/imported = false
|
||||
tracks/2/enabled = true
|
||||
tracks/2/path = NodePath("CSGCombiner3D/CSGTorus3D:material:albedo_color:b")
|
||||
tracks/2/path = NodePath("CSGCombiner3D/CSGTorus3D:material")
|
||||
tracks/2/interp = 1
|
||||
tracks/2/loop_wrap = true
|
||||
tracks/2/keys = {
|
||||
"handle_modes": PackedInt32Array(0),
|
||||
"points": PackedFloat32Array(0, -0.25, 0, 0.25, 0),
|
||||
"times": PackedFloat32Array(0)
|
||||
"times": PackedFloat32Array(0),
|
||||
"transitions": PackedFloat32Array(1),
|
||||
"update": 1,
|
||||
"values": [ExtResource("4_8qpxk")]
|
||||
}
|
||||
tracks/3/type = "bezier"
|
||||
tracks/3/type = "value"
|
||||
tracks/3/imported = false
|
||||
tracks/3/enabled = true
|
||||
tracks/3/path = NodePath("CSGCombiner3D/CSGTorus3D:material:albedo_color:a")
|
||||
tracks/3/path = NodePath("CSGCombiner3D/CSGTorus3D3:material:emission_energy_multiplier")
|
||||
tracks/3/interp = 1
|
||||
tracks/3/loop_wrap = true
|
||||
tracks/3/keys = {
|
||||
"handle_modes": PackedInt32Array(0),
|
||||
"points": PackedFloat32Array(1, -0.25, 0, 0.25, 0),
|
||||
"times": PackedFloat32Array(0)
|
||||
"times": PackedFloat32Array(0),
|
||||
"transitions": PackedFloat32Array(1),
|
||||
"update": 0,
|
||||
"values": [0.0]
|
||||
}
|
||||
tracks/4/type = "value"
|
||||
tracks/4/imported = false
|
||||
tracks/4/enabled = true
|
||||
tracks/4/path = NodePath("OmniLight3D:light_energy")
|
||||
tracks/4/interp = 1
|
||||
tracks/4/loop_wrap = true
|
||||
tracks/4/keys = {
|
||||
"times": PackedFloat32Array(0),
|
||||
"transitions": PackedFloat32Array(1),
|
||||
"update": 0,
|
||||
"values": [0.0]
|
||||
}
|
||||
|
||||
[sub_resource type="Animation" id="Animation_6oyg1"]
|
||||
resource_name = "hob"
|
||||
loop_mode = 1
|
||||
tracks/0/type = "bezier"
|
||||
tracks/0/type = "value"
|
||||
tracks/0/imported = false
|
||||
tracks/0/enabled = true
|
||||
tracks/0/path = NodePath("CSGCombiner3D/CSGTorus3D:material:albedo_color:r")
|
||||
tracks/0/path = NodePath("CSGCombiner3D/CSGTorus3D3:material")
|
||||
tracks/0/interp = 1
|
||||
tracks/0/loop_wrap = true
|
||||
tracks/0/keys = {
|
||||
"handle_modes": PackedInt32Array(0, 2, 2),
|
||||
"points": PackedFloat32Array(0.5, -0.25, 0, 0.25, 0, 0.8, -0.083333336, 0, 0, 0, 0.5, -0.083333336, 0, 0, 0),
|
||||
"times": PackedFloat32Array(0, 0.5, 1)
|
||||
"times": PackedFloat32Array(0),
|
||||
"transitions": PackedFloat32Array(1),
|
||||
"update": 1,
|
||||
"values": [ExtResource("6_m7l4u")]
|
||||
}
|
||||
tracks/1/type = "bezier"
|
||||
tracks/1/type = "value"
|
||||
tracks/1/imported = false
|
||||
tracks/1/enabled = true
|
||||
tracks/1/path = NodePath("CSGCombiner3D/CSGTorus3D:material:albedo_color:g")
|
||||
tracks/1/path = NodePath("CSGCombiner3D/CSGTorus3D2:material")
|
||||
tracks/1/interp = 1
|
||||
tracks/1/loop_wrap = true
|
||||
tracks/1/keys = {
|
||||
"handle_modes": PackedInt32Array(0, 2, 2),
|
||||
"points": PackedFloat32Array(0, -0.25, 0, 0.25, 0, 0, -0.083333336, 0, 0, 0, 0, -0.083333336, 0, 0, 0),
|
||||
"times": PackedFloat32Array(0, 0.5, 1)
|
||||
"times": PackedFloat32Array(0),
|
||||
"transitions": PackedFloat32Array(1),
|
||||
"update": 1,
|
||||
"values": [ExtResource("6_m7l4u")]
|
||||
}
|
||||
tracks/2/type = "bezier"
|
||||
tracks/2/type = "value"
|
||||
tracks/2/imported = false
|
||||
tracks/2/enabled = true
|
||||
tracks/2/path = NodePath("CSGCombiner3D/CSGTorus3D:material:albedo_color:b")
|
||||
tracks/2/path = NodePath("CSGCombiner3D/CSGTorus3D:material")
|
||||
tracks/2/interp = 1
|
||||
tracks/2/loop_wrap = true
|
||||
tracks/2/keys = {
|
||||
"handle_modes": PackedInt32Array(0, 2, 2),
|
||||
"points": PackedFloat32Array(0, -0.25, 0, 0.25, 0, 0, -0.083333336, 0, 0, 0, 0, -0.083333336, 0, 0, 0),
|
||||
"times": PackedFloat32Array(0, 0.5, 1)
|
||||
"times": PackedFloat32Array(0),
|
||||
"transitions": PackedFloat32Array(1),
|
||||
"update": 1,
|
||||
"values": [ExtResource("6_m7l4u")]
|
||||
}
|
||||
tracks/3/type = "bezier"
|
||||
tracks/3/type = "value"
|
||||
tracks/3/imported = false
|
||||
tracks/3/enabled = true
|
||||
tracks/3/path = NodePath("CSGCombiner3D/CSGTorus3D:material:albedo_color:a")
|
||||
tracks/3/path = NodePath("CSGCombiner3D/CSGTorus3D3:material:emission_energy_multiplier")
|
||||
tracks/3/interp = 1
|
||||
tracks/3/loop_wrap = true
|
||||
tracks/3/keys = {
|
||||
"handle_modes": PackedInt32Array(0, 2, 2),
|
||||
"points": PackedFloat32Array(1, -0.25, 0, 0.25, 0, 1, -0.083333336, 0, 0, 0, 1, -0.083333336, 0, 0, 0),
|
||||
"times": PackedFloat32Array(0, 0.5, 1)
|
||||
"times": PackedFloat32Array(0, 0.5, 1),
|
||||
"transitions": PackedFloat32Array(1, 1, 1),
|
||||
"update": 0,
|
||||
"values": [1.0, 0.0, 1.0]
|
||||
}
|
||||
tracks/4/type = "value"
|
||||
tracks/4/imported = false
|
||||
tracks/4/enabled = true
|
||||
tracks/4/path = NodePath("OmniLight3D:light_energy")
|
||||
tracks/4/interp = 1
|
||||
tracks/4/loop_wrap = true
|
||||
tracks/4/keys = {
|
||||
"times": PackedFloat32Array(0, 0.5, 1),
|
||||
"transitions": PackedFloat32Array(1, 1, 1),
|
||||
"update": 0,
|
||||
"values": [1.0, 0.0, 1.0]
|
||||
}
|
||||
|
||||
[sub_resource type="AnimationLibrary" id="AnimationLibrary_ac5f3"]
|
||||
@@ -165,21 +188,21 @@ transform = Transform3D(0.8943019, 0, 0, 0, 0.8943019, 0, 0, 0, 0.8943019, 0, 0.
|
||||
inner_radius = 0.09153879
|
||||
outer_radius = 0.120084204
|
||||
sides = 32
|
||||
material = SubResource("StandardMaterial3D_di04w")
|
||||
|
||||
[node name="CSGTorus3D" type="CSGTorus3D" parent="CSGCombiner3D" unique_id=1901978664]
|
||||
transform = Transform3D(0.8943019, 0, 0, 0, 0.8943019, 0, 0, 0, 0.8943019, 0, 0.5003869, -7.827557e-05)
|
||||
inner_radius = 0.20426142
|
||||
outer_radius = 0.2321898
|
||||
sides = 32
|
||||
material = SubResource("StandardMaterial3D_di04w")
|
||||
material = ExtResource("4_8qpxk")
|
||||
|
||||
[node name="CSGTorus3D2" type="CSGTorus3D" parent="CSGCombiner3D" unique_id=24541894]
|
||||
transform = Transform3D(0.8943019, 0, 0, 0, 0.8943019, 0, 0, 0, 0.8943019, 0, 0.5003869, -7.827557e-05)
|
||||
inner_radius = 0.15036294
|
||||
outer_radius = 0.17592031
|
||||
sides = 32
|
||||
material = SubResource("StandardMaterial3D_di04w")
|
||||
material = ExtResource("4_8qpxk")
|
||||
|
||||
[node name="CSGTorus3D" type="CSGTorus3D" parent="CSGCombiner3D" unique_id=1901978664]
|
||||
transform = Transform3D(0.8943019, 0, 0, 0, 0.8943019, 0, 0, 0, 0.8943019, 0, 0.5003869, -7.827557e-05)
|
||||
inner_radius = 0.20426142
|
||||
outer_radius = 0.2321898
|
||||
sides = 32
|
||||
material = ExtResource("4_8qpxk")
|
||||
|
||||
[node name="CSGCylinder3D" type="CSGCylinder3D" parent="CSGCombiner3D" unique_id=1107239311]
|
||||
transform = Transform3D(0.8943019, 0, 0, 0, 0.8943019, 0, 0, 0, 0.8943019, 0, 0.4498226, -7.827557e-05)
|
||||
@@ -228,11 +251,14 @@ 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)
|
||||
|
||||
[node name="AnimationPlayer" type="AnimationPlayer" parent="." unique_id=1525460719]
|
||||
libraries/ = SubResource("AnimationLibrary_ac5f3")
|
||||
|
||||
[node name="OmniLight3D" type="OmniLight3D" parent="." unique_id=1618457623]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5053487, 0)
|
||||
light_color = Color(1, 0.13725491, 0, 1)
|
||||
light_energy = 0.0
|
||||
omni_range = 0.5
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
[gd_scene format=3 uid="uid://cwwnbx5uat3fw"]
|
||||
|
||||
[node name="CSGBox3D" type="CSGBox3D" unique_id=725518829]
|
||||
|
||||
+47
-13
@@ -6,24 +6,63 @@ const RECIPE_MANAGER = preload("res://RecipeManager.gd")
|
||||
@onready var snap_zone: XRToolsSnapZone = $XRToolsSnapZone
|
||||
@onready var progress_bar: ProgressBar3D = $ProgressBar3D
|
||||
|
||||
var time_cooked = 0
|
||||
var cooking_result: String
|
||||
var cooking_result_time = 0
|
||||
## Cooking state. All three are replicated (see Hob.tscn's Sync node) and each
|
||||
## refreshes the display when it changes, which is what makes the hob look alive
|
||||
## on a client: only the world owner runs the station's _process and its snap
|
||||
## zone, so a client never reaches the code below by itself and would otherwise
|
||||
## show a hob that never lights up.
|
||||
var time_cooked: float = 0.0: set = _set_time_cooked
|
||||
var cooking_result: String = "": set = _set_cooking_result
|
||||
var cooking_result_time: float = 0.0: set = _set_cooking_result_time
|
||||
|
||||
|
||||
func _set_time_cooked(value: float) -> void:
|
||||
if is_equal_approx(time_cooked, value):
|
||||
return
|
||||
time_cooked = value
|
||||
_refresh_display()
|
||||
|
||||
|
||||
func _set_cooking_result(value: String) -> void:
|
||||
if cooking_result == value:
|
||||
return
|
||||
cooking_result = value
|
||||
_refresh_display()
|
||||
# The flames follow whether we're cooking, on every peer.
|
||||
_play_animation("hob" if not cooking_result.is_empty() else "RESET")
|
||||
|
||||
|
||||
func _set_cooking_result_time(value: float) -> void:
|
||||
if is_equal_approx(cooking_result_time, value):
|
||||
return
|
||||
cooking_result_time = value
|
||||
_refresh_display()
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if not progress_bar or progress_bar is not ProgressBar3D:
|
||||
push_error("Hob missing reference to progressbar, or wrong type:", progress_bar)
|
||||
snap_zone.has_picked_up.connect(_on_object_picked_up)
|
||||
snap_zone.has_dropped.connect(_on_object_dropped)
|
||||
_refresh_progress_bar()
|
||||
_refresh_display()
|
||||
|
||||
|
||||
func _refresh_progress_bar() -> void:
|
||||
func _play_animation(name: String) -> void:
|
||||
# Replicated setters can fire before the node is in the tree (spawn payload),
|
||||
# when the @onready children don't exist yet.
|
||||
var player := get_node_or_null("AnimationPlayer") as AnimationPlayer
|
||||
if player:
|
||||
player.play(name)
|
||||
|
||||
|
||||
func _refresh_display() -> void:
|
||||
# Guarded for the same reason as _play_animation.
|
||||
if not progress_bar:
|
||||
return
|
||||
var progress := 0.0
|
||||
if cooking_result and cooking_result_time > 0:
|
||||
progress = clampf(float(time_cooked) / cooking_result_time, 0.0, 1.0)
|
||||
|
||||
#print("time_cooked: %s / cooking_result_time: %s = progress: %s" % [time_cooked, cooking_result_time, progress])
|
||||
progress_bar.set_progress(progress)
|
||||
progress_bar.set_bar_visible(not cooking_result.is_empty())
|
||||
progress_bar.override_fill_color(Color.RED if cooking_result == "charcoal" else Color.GREEN)
|
||||
@@ -44,25 +83,20 @@ func _on_object_picked_up(_item) -> void:
|
||||
cooking_result = result
|
||||
cooking_result_time = RECIPE_MANAGER.get_cooking_time(_food_item.id)
|
||||
print("Hob: set cooking_result ", cooking_result)
|
||||
_refresh_progress_bar()
|
||||
$AnimationPlayer.play("hob")
|
||||
# The setters above already refresh the display and start the flames.
|
||||
|
||||
|
||||
func _on_object_dropped() -> void:
|
||||
print("Hob: object drop ")
|
||||
$AnimationPlayer.play("RESET")
|
||||
time_cooked = 0
|
||||
cooking_result = ""
|
||||
cooking_result_time = 0
|
||||
_refresh_progress_bar()
|
||||
|
||||
|
||||
func convert_held_to_item(_item: String) -> void:
|
||||
if not NetworkManager.owns_world():
|
||||
return
|
||||
print("Hob converting ", _item)
|
||||
$AnimationPlayer.play("RESET")
|
||||
|
||||
|
||||
var old_pickable = snap_zone.picked_up_object
|
||||
if not old_pickable:
|
||||
@@ -86,7 +120,7 @@ func _process(delta: float) -> void:
|
||||
if cooking_result:
|
||||
#print("Hob cooking_result: ", cooking_result)
|
||||
time_cooked += cook_speed * delta
|
||||
_refresh_progress_bar()
|
||||
_refresh_display()
|
||||
if time_cooked >= cooking_result_time:
|
||||
time_cooked = 0
|
||||
convert_held_to_item(cooking_result)
|
||||
|
||||
+38
-12
@@ -6,10 +6,41 @@ const plate_wash_time: float = 3.0
|
||||
@onready var snap_zone: XRToolsSnapZone = $XRToolsSnapZone
|
||||
@onready var progress_bar: ProgressBar3D = $ProgressBar3D
|
||||
|
||||
var time_washed: float = 0.0
|
||||
var is_washing: bool = false
|
||||
## Washing state. Both are replicated (see sink.tscn's Sync node) and refresh the
|
||||
## display when they change. Only the world owner runs this station's _process
|
||||
## and snap zone, so without this a client never updates its bar — it stayed
|
||||
## frozen on screen long after the plate came out clean.
|
||||
var time_washed: float = 0.0: set = _set_time_washed
|
||||
var is_washing: bool = false: set = _set_is_washing
|
||||
var plate: PlateController = null
|
||||
|
||||
|
||||
func _set_time_washed(value: float) -> void:
|
||||
if is_equal_approx(time_washed, value):
|
||||
return
|
||||
time_washed = value
|
||||
_refresh_progress_bar()
|
||||
|
||||
|
||||
func _set_is_washing(value: bool) -> void:
|
||||
if is_washing == value:
|
||||
return
|
||||
is_washing = value
|
||||
_refresh_progress_bar()
|
||||
_set_effects_playing(is_washing)
|
||||
|
||||
|
||||
# Water, bubbles and the animation follow the washing state on every peer, not
|
||||
# just the one running the logic.
|
||||
func _set_effects_playing(playing: bool) -> void:
|
||||
var player := get_node_or_null("AnimationPlayer") as AnimationPlayer
|
||||
if player:
|
||||
player.play("working" if playing else "RESET")
|
||||
for effect in ["water", "bubbles", "GPUParticles3D"]:
|
||||
var node := get_node_or_null(effect) as Node3D
|
||||
if node:
|
||||
node.visible = playing
|
||||
|
||||
func _ready() -> void:
|
||||
if not progress_bar or progress_bar is not ProgressBar3D:
|
||||
push_error("Sink missing reference to progressbar, or wrong type:", progress_bar)
|
||||
@@ -21,6 +52,8 @@ func _ready() -> void:
|
||||
|
||||
|
||||
func _refresh_progress_bar() -> void:
|
||||
if not progress_bar:
|
||||
return
|
||||
progress_bar.set_progress(clampf(float(time_washed) / plate_wash_time, 0.0, 1.0))
|
||||
progress_bar.set_bar_visible(is_washing)
|
||||
|
||||
@@ -29,12 +62,8 @@ func _on_object_picked_up(_item) -> void:
|
||||
print("Sink: object picked up: ", _item)
|
||||
plate = _item.get_node_or_null("PlateController") as PlateController
|
||||
if plate:
|
||||
# The setter starts the effects and shows the bar, here and on clients.
|
||||
is_washing = plate.is_dirty
|
||||
if is_washing:
|
||||
$AnimationPlayer.play("working")
|
||||
$bubbles.show()
|
||||
$water.show()
|
||||
$GPUParticles3D.show()
|
||||
|
||||
|
||||
func _on_object_dropped() -> void:
|
||||
@@ -46,13 +75,10 @@ func _reset_sink():
|
||||
time_washed = 0
|
||||
is_washing = false
|
||||
plate = null
|
||||
$AnimationPlayer.play("RESET")
|
||||
$water.hide()
|
||||
$bubbles.hide()
|
||||
$GPUParticles3D.hide()
|
||||
_refresh_progress_bar()
|
||||
|
||||
func complete_washing():
|
||||
if not plate:
|
||||
return
|
||||
plate.is_dirty = false
|
||||
_reset_sink()
|
||||
print("Sink washing complete!")
|
||||
|
||||
+1
-9
@@ -15,11 +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(".:progress")
|
||||
properties/0/spawn = true
|
||||
properties/0/replication_mode = 1
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_0gbf8"]
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_bvwq1"]
|
||||
@@ -146,7 +141,7 @@ tracks/5/path = NodePath("AnimationPlayer/AudioStreamPlayer3D:playing")
|
||||
tracks/5/interp = 1
|
||||
tracks/5/loop_wrap = true
|
||||
tracks/5/keys = {
|
||||
"times": PackedFloat32Array(0, 1, 1.7),
|
||||
"times": PackedFloat32Array(0, 0.8494, 1.7),
|
||||
"transitions": PackedFloat32Array(1, 1, 1),
|
||||
"update": 1,
|
||||
"values": [false, true, false]
|
||||
@@ -231,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)
|
||||
|
||||
|
||||
+76
-45
@@ -7,11 +7,13 @@ const GAME_MANAGER = preload("res://GameManager.gd")
|
||||
@export var primary_duration: float = 60.0
|
||||
@export var friend_duration: float = 10.0
|
||||
@export var eating_duration: float = 4.0
|
||||
@export var money_sound: AudioStream
|
||||
|
||||
@onready var label_3d: Label3D = $Label3D
|
||||
@onready var label_3d_time: Label3D = $Label3DTime
|
||||
@onready var snap_zones: Array[XRToolsSnapZone] = []
|
||||
@onready var progress_bar: ProgressBar3D = $ProgressBar3D
|
||||
@onready var audio_player: AudioStreamPlayer3D = $AudioStreamPlayer3D
|
||||
|
||||
const lbl_thinking: String = "Thinking"
|
||||
const lbl_gameover: String = "Game Over!"
|
||||
@@ -27,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:
|
||||
@@ -45,8 +52,13 @@ func _ready() -> void:
|
||||
if not label_3d_time:
|
||||
push_error("Table is missing reference to Label3DTime")
|
||||
if not progress_bar or progress_bar is not ProgressBar3D:
|
||||
push_error("Hob missing reference to progressbar, or wrong type:", progress_bar)
|
||||
push_error("Table missing reference to progressbar, or wrong type:", progress_bar)
|
||||
progress_bar.y_billboard = true
|
||||
# Render whatever state already arrived from the server before we were in
|
||||
# the tree (see the guard in _refresh_display).
|
||||
_refresh_display.call_deferred()
|
||||
if not audio_player:
|
||||
push_error("Table is missing reference to AudioStreamPlayer3D")
|
||||
|
||||
# Get snap_zones for plates, store in array snap_zones
|
||||
for child in get_children():
|
||||
@@ -67,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)
|
||||
|
||||
@@ -91,21 +103,30 @@ 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)
|
||||
GAME_MANAGER.money += food_item.sell_value
|
||||
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()
|
||||
_setState(TableState.EATING)
|
||||
|
||||
|
||||
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)
|
||||
@@ -113,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)
|
||||
|
||||
@@ -137,35 +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:
|
||||
match _state:
|
||||
# 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:
|
||||
TableState.EMPTY:
|
||||
progress_bar.set_bar_visible(false)
|
||||
label_3d.text = "empty"
|
||||
@@ -173,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)
|
||||
|
||||
+11
-19
@@ -2,6 +2,7 @@
|
||||
|
||||
[ext_resource type="Script" uid="uid://caeikc7e3igkd" path="res://Stations/table.gd" id="1_2vcpj"]
|
||||
[ext_resource type="Script" uid="uid://cquqe4f1m1sw6" path="res://addons/godot-xr-tools/objects/snap_zone.gd" id="1_8j1nt"]
|
||||
[ext_resource type="AudioStream" uid="uid://clkjwcawdqvta" path="res://Sounds/money.mp3" id="2_jslhm"]
|
||||
[ext_resource type="AudioStream" uid="uid://daiv8ubwdbidf" path="res://Sounds/220195__gameaudio__click-wooden-1.wav" id="3_0s6ir"]
|
||||
[ext_resource type="PackedScene" uid="uid://bxbocxdaayvwx" path="res://UI/progress_bar.tscn" id="3_kjf1i"]
|
||||
|
||||
@@ -14,19 +15,9 @@ 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")
|
||||
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="." unique_id=228053941]
|
||||
shape = SubResource("BoxShape3D_24d3s")
|
||||
@@ -45,7 +36,7 @@ transform = Transform3D(0.5, 0, 0, 0, 0.5, 0, 0, 0, 0.5, 0, -0.000975132, 0)
|
||||
shape = SubResource("SphereShape3D_dlkho")
|
||||
|
||||
[node name="AudioStreamPlayer3D" type="AudioStreamPlayer3D" parent="XRToolsSnapZone" unique_id=2135860710]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.022546828, -0.5270121, 0.36528283)
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.013531357, -0.015141487, 0.012176305)
|
||||
|
||||
[node name="XRToolsSnapZone2" type="Area3D" parent="." unique_id=694448387 groups=["station"]]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.34755045, 0.5270121, 0)
|
||||
@@ -61,7 +52,7 @@ transform = Transform3D(0.5, 0, 0, 0, 0.5, 0, 0, 0, 0.5, 0, -0.000975132, 0)
|
||||
shape = SubResource("SphereShape3D_dlkho")
|
||||
|
||||
[node name="AudioStreamPlayer3D" type="AudioStreamPlayer3D" parent="XRToolsSnapZone2" unique_id=709529410]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.022546828, -0.5270121, 0.36528283)
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.013531357, -0.015141487, 0.012176305)
|
||||
|
||||
[node name="XRToolsSnapZone3" type="Area3D" parent="." unique_id=1734026785 groups=["station"]]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.012355864, 0.5270121, 0.33038777)
|
||||
@@ -77,7 +68,7 @@ transform = Transform3D(0.5, 0, 0, 0, 0.5, 0, 0, 0, 0.5, 0, -0.000975132, 0)
|
||||
shape = SubResource("SphereShape3D_dlkho")
|
||||
|
||||
[node name="AudioStreamPlayer3D" type="AudioStreamPlayer3D" parent="XRToolsSnapZone3" unique_id=2000860662]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.022546828, -0.5270121, 0.36528283)
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.013531357, -0.015141487, 0.012176305)
|
||||
|
||||
[node name="XRToolsSnapZone4" type="Area3D" parent="." unique_id=1358457948 groups=["station"]]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.022546828, 0.5270121, -0.36528283)
|
||||
@@ -93,13 +84,14 @@ transform = Transform3D(0.5, 0, 0, 0, 0.5, 0, 0, 0, 0.5, 0, -0.000975132, 0)
|
||||
shape = SubResource("SphereShape3D_dlkho")
|
||||
|
||||
[node name="AudioStreamPlayer3D" type="AudioStreamPlayer3D" parent="XRToolsSnapZone4" unique_id=790672192]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.022546828, -0.5270121, 0.36528283)
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.013531357, -0.015141487, 0.012176305)
|
||||
|
||||
[node name="CSGCombiner3D" type="CSGCombiner3D" parent="." unique_id=2085635675]
|
||||
use_collision = true
|
||||
|
||||
[node name="CSGBox3D" type="CSGBox3D" parent="CSGCombiner3D" unique_id=411048765]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.46137518, 0)
|
||||
size = Vector3(0.6, 0.077, 0.6)
|
||||
size = Vector3(1, 0.077, 1)
|
||||
material = SubResource("StandardMaterial3D_24d3s")
|
||||
|
||||
[node name="CSGBox3D2" type="CSGBox3D" parent="CSGCombiner3D" unique_id=2093582402]
|
||||
@@ -186,9 +178,9 @@ 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
|
||||
|
||||
[node name="AudioStreamPlayer3D" type="AudioStreamPlayer3D" parent="." unique_id=724610642]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.2935008, 0)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
[gd_resource type="StandardMaterial3D" format=3 uid="uid://d1djvskv4n4m3"]
|
||||
|
||||
[resource]
|
||||
albedo_color = Color(0, 0, 0, 1)
|
||||
emission_energy_multiplier = 0.0
|
||||
@@ -0,0 +1,6 @@
|
||||
[gd_resource type="StandardMaterial3D" format=3 uid="uid://db7c7w4apwti7"]
|
||||
|
||||
[resource]
|
||||
albedo_color = Color(0.6553854, 0, 0, 1)
|
||||
emission_enabled = true
|
||||
emission = Color(1, 0.27450982, 0, 1)
|
||||
@@ -96,7 +96,7 @@ func _on_john_pressed() -> void:
|
||||
|
||||
func _on_host_pressed() -> void:
|
||||
NetworkManager.request_host()
|
||||
get_tree().change_scene_to_file.call_deferred(MULTIPLAYER_SCENE)
|
||||
get_tree().change_scene_to_file.call_deferred("res://Scenes/JonScene.tscn")
|
||||
|
||||
|
||||
func _on_join_pressed() -> void:
|
||||
|
||||
@@ -25,6 +25,10 @@ func set_bar_visible(value: bool) -> void:
|
||||
progress_bar.visible = value
|
||||
|
||||
|
||||
func is_bar_visible() -> bool:
|
||||
return progress_bar != null and progress_bar.visible
|
||||
|
||||
|
||||
func override_fill_color(color: Color) -> void:
|
||||
var style_box := StyleBoxFlat.new()
|
||||
style_box.bg_color = color
|
||||
|
||||
@@ -437,6 +437,7 @@ func _pick_up_object(target: Node3D) -> void:
|
||||
# Pick up our target. Note, target may do instant drop_and_free
|
||||
picked_up_ranged = not _object_in_grab_area.has(target)
|
||||
picked_up_object = target
|
||||
if target and target.has_method("pick_up"):
|
||||
target.pick_up(self)
|
||||
|
||||
# If object picked up then emit signal
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
# High-level replication rebuild
|
||||
|
||||
Rebuild VRyHungry's multiplayer on Godot's stock `MultiplayerSpawner` and
|
||||
`MultiplayerSynchronizer`, so that adding a new object to the game requires no
|
||||
networking code at all. Replaces the bespoke spawn payloads, per-scene
|
||||
replication configs, and grab-authority handshake in place today.
|
||||
|
||||
Baseline being replaced: `logs/mptest_report.txt`, 146/146 checks passing as of
|
||||
2026-07-28 19:47. That is the bar the rebuild has to clear.
|
||||
|
||||
## The constraint that shapes everything
|
||||
|
||||
`MultiplayerSpawner` replicates **node creation, name, and deletion** — nothing
|
||||
more. All state comes from `MultiplayerSynchronizer` plus a
|
||||
`SceneReplicationConfig`. There is no engine switch for "sync everything
|
||||
regardless of what it is".
|
||||
|
||||
The way to get there anyway: a `SceneReplicationConfig` is a plain `Resource`,
|
||||
so it can be **built in code from a convention** and attached at runtime. One
|
||||
helper then covers every object in the game, and nothing has to be authored
|
||||
per scene.
|
||||
|
||||
## Verified engine behaviour
|
||||
|
||||
Established by `test/spike/`, run against Godot 4.7.stable. These are load
|
||||
bearing — the design below is only correct because these hold.
|
||||
|
||||
| # | Question | Result |
|
||||
|---|----------|--------|
|
||||
| Q1 | Does default auto-spawn carry the spawn properties of a synchronizer attached at **runtime**, not baked into the `.tscn`? | **Yes.** A peer joining 4s after the spawn received `pos=(5,6,7) health=42 label=hello`. |
|
||||
| Q2 | Do `ON_CHANGE` properties reach a late joiner, and do later changes propagate? | **Yes**, both. |
|
||||
| Q3 | Can `set_visibility_for(peer, false)` suppress one peer's updates without despawning the node there? | **No.** The node is despawned and respawned on every visibility flip. Unusable — it would make a held object vanish from the holder's hand. |
|
||||
| Q4 | Can handing one of two synchronizers to a client let it drive the transform locally while server-owned state keeps arriving? | **Yes.** Client showed its own `(1,1,1)` and ignored the server's competing `(-9,-9,-9)`; the server saw `(1,1,1)`; server-owned `health=123` still arrived. |
|
||||
|
||||
Two traps worth recording. `set_visibility_for` is only an *override* on top of
|
||||
`public_visibility`, which defaults to `true` — calling it alone does nothing.
|
||||
And the spike's first version chained relative timers, which drifted enough that
|
||||
the client's observations straddled the server's actions; it now runs both peers
|
||||
on one absolute clock.
|
||||
|
||||
## Design
|
||||
|
||||
### Layer 1 — `Net/net_replication.gd`
|
||||
|
||||
The convention-based config builder. `NetReplication.attach(node)` gives a node
|
||||
two generated synchronizers:
|
||||
|
||||
- **`NetSync`** — script state. Always server-owned; gameplay outcomes are the
|
||||
server's to decide. `ON_CHANGE`.
|
||||
- **`NetXform`** — `position` + `quaternion`. Server-owned while the object is
|
||||
loose, handed to a peer for the duration of a hold. `ALWAYS`.
|
||||
|
||||
What gets replicated, by convention:
|
||||
|
||||
- a `Node3D` replicates `position` and `quaternion` (never `scale` — nothing in
|
||||
this game animates scale as gameplay state)
|
||||
- every script variable, on the node and any scripted descendant, whose name
|
||||
does not start with `_` and whose declared type is serialisable
|
||||
|
||||
Underscore-prefixed vars are the opt-out, and they already mark exactly the
|
||||
state that must not cross the wire: `@onready` references, cached lookups,
|
||||
per-frame bookkeeping.
|
||||
|
||||
`ON_CHANGE` for state is a deliberate correctness choice, not just bandwidth.
|
||||
Under `ALWAYS` the synchronizer *assigns* every property every tick on receiving
|
||||
peers, so every replicated setter becomes a per-frame hot path — which is how
|
||||
this project previously ended up rebuilding every plate's visuals 60 times a
|
||||
second and rewriting four physics properties per item per tick.
|
||||
|
||||
### Layer 2 — `Net/net_world.gd`
|
||||
|
||||
- Spawnable scenes registered by **sorted** directory scan of `Items/`,
|
||||
`Containers/`, `Stations/`, `Prefabs/`. Auto-spawn transmits an *index* into
|
||||
that array, so the order must be identical on every peer or clients build the
|
||||
wrong scene.
|
||||
- `spawn()` = instantiate → set transform/name → `add_child` under the content
|
||||
root. No `spawn_function`, no payload dictionary.
|
||||
- `despawn()` = `queue_free()` on the server. The `_despawn_static_item` RPC
|
||||
disappears: with nothing baked into the world scene, the spawner covers every
|
||||
case.
|
||||
- `NetReplication.attach` runs from the content root's `child_entered_tree` on
|
||||
**both** peers. That is early enough for the synchronizers to enter the tree
|
||||
inside the spawner's own `add_child`, which is what lets them pick up the
|
||||
spawn payload (Q1).
|
||||
- **Client gating becomes one generic rule** applied at the same hook: disable
|
||||
`_process`, disable every `XRToolsSnapZone`, freeze `RigidBody3D`. This
|
||||
replaces `_gate_station` + `gate_existing_stations` and applies to anything,
|
||||
not just things that happen to be stations.
|
||||
|
||||
### Layer 3 — `Net/net_grab.gd` and `Net/net_stations.gd`
|
||||
|
||||
Two RPCs for the entire game: `request_grab(item)` and
|
||||
`request_release(item, xform, lin, ang)`.
|
||||
|
||||
A client's hand picks the item up locally the instant the player grabs — no
|
||||
round trip — and asks the server to confirm. The server grants by moving
|
||||
`NetXform` authority to that peer (Q4), which is precisely what makes the local
|
||||
copy stop applying inbound transform updates. On release, authority returns to
|
||||
the server, which decides where the item actually ends up: server-authoritative
|
||||
snapping into stations lives in `net_stations.gd`, generic over the `station`
|
||||
group rather than per-station.
|
||||
|
||||
Deleted outright: `Net/net_pickable.gd` (209 lines), `net_held_by` and its
|
||||
held-state juggling, `_set_item_authority`, the grant/reject/force-release
|
||||
negotiation, and `_gate_station`.
|
||||
|
||||
### Layer 4 — tests
|
||||
|
||||
`test/mp_test_driver.gd` (1458 lines) splits into orchestration, steps,
|
||||
asserts, snapshot, and report modules. The scenario list and the sync audit
|
||||
carry over unchanged — that audit compares what is actually *rendered* on both
|
||||
peers, which is what caught the plate-visuals desync that targeted assertions
|
||||
missed. Assertions written against old internals (`net_held_by`, "the client is
|
||||
the item's authority after grabbing") are rewritten against the new model.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Replacing a green suite.** Mitigated by running the headless suite after
|
||||
each layer rather than only at the end.
|
||||
- **The harness changes alongside the code it checks.** Mitigated by keeping
|
||||
scenario list and audit logic byte-identical wherever possible, so it remains
|
||||
an independent check rather than one shaped to fit the new code.
|
||||
- **Blanket state replication picks up more vars than the old hand-authored
|
||||
configs.** `ON_CHANGE` makes this cheap, but any setter reached this way still
|
||||
has to be idempotent and null-guarded — a `spawn = true` property fires its
|
||||
setter *before* the node is in the tree, so `@onready` refs are null there.
|
||||
- **Client→client transform** relies on `SceneMultiplayer.server_relay` (on by
|
||||
default) to forward a holder's updates to the other clients. Fine for a
|
||||
listen-server, worth remembering if the topology ever changes.
|
||||
@@ -0,0 +1,6 @@
|
||||
|
||||
|
||||
function_pickup.gd:
|
||||
Add nil check in func _pick_up_object(target: Node3D)
|
||||
440: if target and target.has_method("pick_up"):
|
||||
target.pick_up(self)
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
extends Node
|
||||
class_name MpAsserts
|
||||
|
||||
## The per-step checks. Every one is synchronous and side-effect free: it looks
|
||||
## at the world as it currently is on THIS peer and returns a verdict.
|
||||
##
|
||||
## Most of these are run on both peers for the same step, which is the point —
|
||||
## the server's authoritative outcome has to be what the client sees too.
|
||||
|
||||
var view: MpWorldView
|
||||
var snapshot: MpSnapshot
|
||||
var hand: XRToolsFunctionPickup
|
||||
|
||||
|
||||
func setup(p_view: MpWorldView, p_snapshot: MpSnapshot, p_hand: XRToolsFunctionPickup) -> void:
|
||||
view = p_view
|
||||
snapshot = p_snapshot
|
||||
hand = p_hand
|
||||
|
||||
|
||||
## On the server "snapped" means the zone owns the object. On a client the snap
|
||||
## is server-authoritative and never happens locally, so what must be true there
|
||||
## is that the replicated object actually sits in the zone.
|
||||
func snapped(item_name: String, station: String) -> Dictionary:
|
||||
var item := view.find(item_name)
|
||||
var zone := view.zone_of(station)
|
||||
if not item:
|
||||
return {"ok": false, "detail": "'%s' does not exist on this peer" % item_name}
|
||||
if not zone:
|
||||
return {"ok": false, "detail": "station '%s' has no snap zone on this peer" % station}
|
||||
var dist := item.global_position.distance_to(zone.global_position)
|
||||
var problems: Array[String] = []
|
||||
if dist > MpSteps.SNAP_TOLERANCE:
|
||||
problems.append("%s is %.3fm from %s's zone (tolerance %.2f)"
|
||||
% [item_name, dist, station, MpSteps.SNAP_TOLERANCE])
|
||||
if NetworkManager.owns_world():
|
||||
if zone.picked_up_object != item:
|
||||
problems.append("%s's zone holds %s, not %s" % [station, zone.picked_up_object, item_name])
|
||||
elif not item.is_picked_up():
|
||||
problems.append("%s's zone claims %s but it has no grab driver (half-snapped)" % [station, item_name])
|
||||
if problems.is_empty():
|
||||
return {"ok": true, "detail": "%s is snapped into %s; %s" % [item_name, station, view.diag(item, hand)]}
|
||||
return {"ok": false, "detail": "%s | %s" % [", ".join(problems), view.diag(item, hand)]}
|
||||
|
||||
|
||||
func dirty(plate_name: String, want_dirty: bool) -> Dictionary:
|
||||
var plate := view.find(plate_name)
|
||||
if not plate:
|
||||
return {"ok": false, "detail": "'%s' does not exist on this peer" % plate_name}
|
||||
var pc := plate.get_node_or_null("PlateController")
|
||||
if not pc:
|
||||
return {"ok": false, "detail": "'%s' has no PlateController" % plate_name}
|
||||
if pc.is_dirty != want_dirty:
|
||||
return {"ok": false, "detail": "%s.is_dirty is %s, expected %s; %s"
|
||||
% [plate_name, pc.is_dirty, want_dirty, view.diag(plate, hand)]}
|
||||
return {"ok": true, "detail": "%s.is_dirty == %s as expected" % [plate_name, want_dirty]}
|
||||
|
||||
|
||||
## A consumed object must be gone on EVERY peer, not just the one that consumed it.
|
||||
func gone(item_name: String) -> Dictionary:
|
||||
var item := view.find(item_name)
|
||||
if item and is_instance_valid(item):
|
||||
return {"ok": false, "detail": "'%s' still exists on this peer at %s (it should have been consumed)"
|
||||
% [item_name, item.global_position]}
|
||||
return {"ok": true, "detail": "'%s' is gone, as expected" % item_name}
|
||||
|
||||
|
||||
func food_exists(food_id: String) -> Dictionary:
|
||||
var item := view.find_by_food_id(food_id)
|
||||
if not item:
|
||||
return {"ok": false, "detail": "no item with food id '%s' exists on this peer" % food_id}
|
||||
return {"ok": true, "detail": "'%s' exists: %s at %s" % [food_id, item.name, item.global_position]}
|
||||
|
||||
|
||||
func plate_contains(plate_name: String, food_id: String) -> Dictionary:
|
||||
var plate := view.find(plate_name)
|
||||
if not plate:
|
||||
return {"ok": false, "detail": "'%s' does not exist on this peer" % plate_name}
|
||||
var pc := plate.get_node_or_null("PlateController")
|
||||
if not pc:
|
||||
return {"ok": false, "detail": "'%s' has no PlateController" % plate_name}
|
||||
if not (food_id in pc.contained_ids):
|
||||
return {"ok": false, "detail": "%s holds %s, expected it to contain '%s'"
|
||||
% [plate_name, str(pc.contained_ids), food_id]}
|
||||
return {"ok": true, "detail": "%s contains %s" % [plate_name, str(pc.contained_ids)]}
|
||||
|
||||
|
||||
## Food shown on a plate is a cosmetic child of it, so it has to stay put when
|
||||
## the plate is picked up and carried. If it drifts, the player sees the burger
|
||||
## fly off the plate.
|
||||
func plate_visuals(plate_name: String) -> Dictionary:
|
||||
var plate := view.find(plate_name)
|
||||
if not plate:
|
||||
return {"ok": false, "detail": "'%s' does not exist on this peer" % plate_name}
|
||||
var off := snapshot.max_visual_offset(plate)
|
||||
if off < 0.0:
|
||||
return {"ok": false, "detail": "%s is not showing any food to check" % plate_name}
|
||||
if off > MpSnapshot.MAX_VISUAL_OFFSET:
|
||||
return {"ok": false, "detail": "%s's food has come off the plate: %.3fm away (limit %.2f). %s"
|
||||
% [plate_name, off, MpSnapshot.MAX_VISUAL_OFFSET, snapshot.visual_diag(plate)]}
|
||||
return {"ok": true, "detail": "%s's food is still on it (%.3fm from centre). %s"
|
||||
% [plate_name, off, snapshot.visual_diag(plate)]}
|
||||
|
||||
|
||||
## A station's progress bar must show the same thing to everyone: visible while
|
||||
## the station is working, gone once it has finished. Only the world owner runs
|
||||
## station logic, so a client can only get this right if the display is driven
|
||||
## from replicated state.
|
||||
func station_bar(station_name: String, want_visible: bool) -> Dictionary:
|
||||
var station := view.find(station_name)
|
||||
if not station:
|
||||
return {"ok": false, "detail": "'%s' does not exist on this peer" % station_name}
|
||||
var bar := station.get_node_or_null("ProgressBar3D") as ProgressBar3D
|
||||
if not bar:
|
||||
return {"ok": false, "detail": "%s has no ProgressBar3D" % station_name}
|
||||
var shown := bar.is_bar_visible()
|
||||
var detail := "%s bar visible=%s progress=%.0f%%" % [station_name, shown, bar.get_progress()]
|
||||
if "cooking_result" in station:
|
||||
detail += " cooking='%s'" % station.cooking_result
|
||||
if "is_washing" in station:
|
||||
detail += " washing=%s" % station.is_washing
|
||||
if shown != want_visible:
|
||||
return {"ok": false, "detail": "expected %s's bar to be %s, but %s"
|
||||
% [station_name, "visible" if want_visible else "hidden", detail]}
|
||||
return {"ok": true, "detail": detail}
|
||||
|
||||
|
||||
## A station that has had its object taken away must not still be holding it.
|
||||
func zone_empty(station_name: String) -> Dictionary:
|
||||
var zone := view.zone_of(station_name)
|
||||
if not zone:
|
||||
return {"ok": false, "detail": "station '%s' has no snap zone on this peer" % station_name}
|
||||
if not NetworkManager.owns_world():
|
||||
# Client zones are gated off entirely; they never hold anything.
|
||||
return {"ok": true, "detail": "%s: client zones are gated, nothing to check" % station_name}
|
||||
if is_instance_valid(zone.picked_up_object):
|
||||
return {"ok": false, "detail": "%s's zone still holds %s after it was taken away"
|
||||
% [station_name, zone.picked_up_object]}
|
||||
return {"ok": true, "detail": "%s's zone is empty" % station_name}
|
||||
@@ -0,0 +1 @@
|
||||
uid://dnhqfkoir57q6
|
||||
@@ -0,0 +1,205 @@
|
||||
extends Node
|
||||
class_name MpReport
|
||||
|
||||
## Everything the harness writes down: the ledger of checks, the run log, the
|
||||
## on-screen overlay, and the per-step screenshots.
|
||||
##
|
||||
## Kept apart from the test logic so a step never has to think about where its
|
||||
## output goes — it returns a verdict, and this decides how that is recorded.
|
||||
|
||||
## Lines kept in the on-screen overlay. Enough to show a whole step without the
|
||||
## panel covering the kitchen underneath it.
|
||||
const OVERLAY_LINES := 11
|
||||
|
||||
## Frames are captured at half the viewport's resolution: 60-odd full-size frames
|
||||
## per peer is a lot of pixels to write and then re-encode.
|
||||
const FRAME_SCALE := 0.5
|
||||
|
||||
## Every check, in order: {step, side, ok, detail}.
|
||||
var results: Array[Dictionary] = []
|
||||
|
||||
var _log_file: FileAccess
|
||||
var _log_path := ""
|
||||
var _overlay: Label
|
||||
var _overlay_lines: Array[String] = []
|
||||
|
||||
var _frames_enabled := false
|
||||
var _frames_dir := ""
|
||||
var _frame_index := 0
|
||||
var _frame_labels: Array[String] = []
|
||||
|
||||
|
||||
func setup(is_server: bool, overlay_parent: Node) -> void:
|
||||
var role := "server" if is_server else "client"
|
||||
_log_path = "res://logs/mptest_%s.log" % role
|
||||
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path("res://logs"))
|
||||
_log_file = FileAccess.open(_log_path, FileAccess.WRITE)
|
||||
_build_overlay(overlay_parent)
|
||||
|
||||
|
||||
# --- log -------------------------------------------------------------------
|
||||
|
||||
func log_line(s: String) -> void:
|
||||
print("[MPTEST] %s" % s)
|
||||
if _log_file:
|
||||
_log_file.store_line(s)
|
||||
_log_file.flush()
|
||||
_push_overlay(s)
|
||||
|
||||
|
||||
func banner(s: String) -> void:
|
||||
log_line("")
|
||||
log_line("======== %s ========" % s)
|
||||
|
||||
|
||||
func _build_overlay(parent: Node) -> void:
|
||||
if not parent:
|
||||
return
|
||||
var layer := CanvasLayer.new()
|
||||
parent.add_child(layer)
|
||||
_overlay = Label.new()
|
||||
_overlay.add_theme_font_size_override("font_size", 14)
|
||||
_overlay.add_theme_color_override("font_color", Color.WHITE)
|
||||
_overlay.add_theme_color_override("font_outline_color", Color.BLACK)
|
||||
_overlay.add_theme_constant_override("outline_size", 6)
|
||||
_overlay.set_anchors_preset(Control.PRESET_TOP_WIDE)
|
||||
layer.add_child(_overlay)
|
||||
|
||||
|
||||
func _push_overlay(s: String) -> void:
|
||||
if not _overlay:
|
||||
return
|
||||
_overlay_lines.append(s)
|
||||
while _overlay_lines.size() > OVERLAY_LINES:
|
||||
_overlay_lines.pop_front()
|
||||
_overlay.text = "\n".join(_overlay_lines)
|
||||
|
||||
|
||||
# --- ledger ----------------------------------------------------------------
|
||||
|
||||
func record(step: String, side: String, res: Dictionary) -> void:
|
||||
var ok: bool = res.get("ok", false)
|
||||
var detail: String = str(res.get("detail", ""))
|
||||
results.append({"step": step, "side": side, "ok": ok, "detail": detail})
|
||||
log_line("%s [%s] %s" % ["PASS" if ok else "FAIL", side, step])
|
||||
log_line(" %s" % detail)
|
||||
|
||||
|
||||
func failed_count() -> int:
|
||||
var failed := 0
|
||||
for r in results:
|
||||
if not r["ok"]:
|
||||
failed += 1
|
||||
return failed
|
||||
|
||||
|
||||
func print_summary() -> void:
|
||||
banner("RESULTS")
|
||||
for r in results:
|
||||
log_line("%-4s %-8s %s" % ["PASS" if r["ok"] else "FAIL", r["side"], r["step"]])
|
||||
var failed := failed_count()
|
||||
log_line("%d/%d checks passed" % [results.size() - failed, results.size()])
|
||||
for r in results:
|
||||
if not r["ok"]:
|
||||
log_line("FAILURE %s [%s]: %s" % [r["step"], r["side"], r["detail"]])
|
||||
|
||||
|
||||
## A standalone report written next to the logs, so a run can be read without
|
||||
## scrolling the console — and so the in-editor runner can print it back.
|
||||
func write_report() -> void:
|
||||
var path := "res://logs/mptest_report.txt"
|
||||
var f := FileAccess.open(path, FileAccess.WRITE)
|
||||
if not f:
|
||||
return
|
||||
var failed := failed_count()
|
||||
var passed := results.size() - failed
|
||||
f.store_line("VRyHungry multiplayer test report")
|
||||
f.store_line("run at %s" % Time.get_datetime_string_from_system())
|
||||
f.store_line("")
|
||||
f.store_line("RESULT: %s (%d passed, %d failed, %d total)"
|
||||
% ["ALL CHECKS PASSED" if failed == 0 else "FAILED", passed, failed, results.size()])
|
||||
f.store_line("")
|
||||
if failed > 0:
|
||||
f.store_line("--- failures ---")
|
||||
for r in results:
|
||||
if not r["ok"]:
|
||||
f.store_line("FAIL [%s] %s" % [r["side"], r["step"]])
|
||||
f.store_line(" %s" % r["detail"])
|
||||
f.store_line("")
|
||||
f.store_line("--- every check, in order ---")
|
||||
for r in results:
|
||||
f.store_line("%-4s %-6s %s" % ["PASS" if r["ok"] else "FAIL", r["side"], r["step"]])
|
||||
f.close()
|
||||
log_line("report written to %s" % ProjectSettings.globalize_path(path))
|
||||
|
||||
|
||||
# --- per-step screenshots --------------------------------------------------
|
||||
#
|
||||
# One frame per peer per step, stitched into a side-by-side GIF afterwards (see
|
||||
# test/make_gif.ps1). Seeing both peers' viewports next to each other for the
|
||||
# same step is the fastest way to spot a visual desync: the log tells you
|
||||
# something diverged, the GIF shows you what it looked like.
|
||||
|
||||
func setup_frames(enabled: bool, is_server: bool) -> void:
|
||||
_frames_enabled = enabled
|
||||
if not _frames_enabled:
|
||||
return
|
||||
if DisplayServer.get_name() == "headless":
|
||||
_frames_enabled = false
|
||||
log_line("frame capture disabled: a headless run has no rendered output to grab")
|
||||
return
|
||||
_frames_dir = "res://logs/mptest_frames_%s" % ("server" if is_server else "client")
|
||||
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(_frames_dir))
|
||||
# Clear a previous run's frames, or the GIF splices the two together.
|
||||
var dir := DirAccess.open(_frames_dir)
|
||||
if dir:
|
||||
for f in dir.get_files():
|
||||
if f.ends_with(".png"):
|
||||
dir.remove(f)
|
||||
log_line("capturing a frame per step into %s" % ProjectSettings.globalize_path(_frames_dir))
|
||||
|
||||
|
||||
func frames_enabled() -> bool:
|
||||
return _frames_enabled
|
||||
|
||||
|
||||
func next_frame_index() -> int:
|
||||
_frame_index += 1
|
||||
return _frame_index
|
||||
|
||||
|
||||
func save_frame(index: int, label: String) -> void:
|
||||
if not _frames_enabled:
|
||||
return
|
||||
_frame_index = index
|
||||
# Wait for the frame to actually be drawn, or we capture whatever was in the
|
||||
# buffer before this step's changes landed.
|
||||
await RenderingServer.frame_post_draw
|
||||
var tex := get_viewport().get_texture()
|
||||
if not tex:
|
||||
return
|
||||
var img := tex.get_image()
|
||||
if not img:
|
||||
return
|
||||
if FRAME_SCALE != 1.0:
|
||||
img.resize(int(img.get_width() * FRAME_SCALE), int(img.get_height() * FRAME_SCALE),
|
||||
Image.INTERPOLATE_BILINEAR)
|
||||
# Index-only filenames so ffmpeg's image sequence reader picks them up as
|
||||
# frame_%04d.png; the step name is already legible in the overlay.
|
||||
var err := img.save_png("%s/frame_%04d.png" % [_frames_dir, index])
|
||||
if err != OK:
|
||||
log_line(" could not save frame %d (%s)" % [index, error_string(err)])
|
||||
else:
|
||||
_frame_labels.append("%04d %s" % [index, label])
|
||||
|
||||
|
||||
## Written alongside the frames so a frame number can be traced back to the step
|
||||
## that produced it.
|
||||
func write_frame_index() -> void:
|
||||
if not _frames_enabled or _frame_labels.is_empty():
|
||||
return
|
||||
var f := FileAccess.open("%s/frames.txt" % _frames_dir, FileAccess.WRITE)
|
||||
if f:
|
||||
for line in _frame_labels:
|
||||
f.store_line(line)
|
||||
f.close()
|
||||
@@ -0,0 +1 @@
|
||||
uid://cc0fn6yfqc0ey
|
||||
@@ -0,0 +1,221 @@
|
||||
extends Node
|
||||
class_name MpSnapshot
|
||||
|
||||
## The cross-peer sync audit: both peers describe everything they can see, and
|
||||
## the two descriptions must match.
|
||||
##
|
||||
## This is what actually catches desyncs. Per-step assertions only look at the
|
||||
## one thing the step touched, which misses the whole class of bug where some
|
||||
## OTHER object quietly drifts out of sync — or where the data replicates
|
||||
## perfectly but the thing on screen does not. So the description deliberately
|
||||
## includes what is RENDERED, not just the replicated values behind it: a plate
|
||||
## whose contained_ids arrived but whose visuals were never rebuilt looks empty
|
||||
## to the player, and that has to count as a desync.
|
||||
|
||||
## How far apart the same object may be on two peers before it counts as a desync.
|
||||
const POS_TOLERANCE := 0.08
|
||||
## Physics keeps moving after a step (dropped objects fall, snaps settle), so
|
||||
## give the peers this long to converge before calling a difference a failure.
|
||||
const SETTLE_SEC := 3.0
|
||||
## How far a cosmetic item on a plate may sit from the plate's origin. The plate
|
||||
## is ~0.4m across and the furthest slot ~0.12m out, so past this it has come off.
|
||||
const MAX_VISUAL_OFFSET := 0.3
|
||||
## Local offset at which a cosmetic item counts as having moved from its slot.
|
||||
const VISUAL_DRIFT_EPSILON := 0.05
|
||||
|
||||
## Stations whose on-screen state has to match on every peer.
|
||||
const WATCHED_STATIONS := ["Hob", "Sink", "DirtStation", "Counter", "Counter2"]
|
||||
|
||||
var view: MpWorldView
|
||||
var report: MpReport
|
||||
|
||||
## Named by the driver so a drift warning says which step was running.
|
||||
var current_step := "(before any step)"
|
||||
|
||||
var _drift_reported := {}
|
||||
|
||||
|
||||
func setup(p_view: MpWorldView, p_report: MpReport) -> void:
|
||||
view = p_view
|
||||
report = p_report
|
||||
|
||||
|
||||
func take() -> Dictionary:
|
||||
var out := {}
|
||||
for child in view.pickables():
|
||||
out[str(child.name)] = describe(child as Node3D)
|
||||
for name in WATCHED_STATIONS:
|
||||
var station := view.find(name)
|
||||
if station:
|
||||
out["station:" + name] = describe_station(station)
|
||||
return out
|
||||
|
||||
|
||||
## Everything about an object that should look identical on every peer.
|
||||
func describe(item: Node3D) -> Dictionary:
|
||||
var d := {"pos": item.global_position, "visible": item.visible}
|
||||
var pc := item.get_node_or_null("PlateController")
|
||||
if pc:
|
||||
d["dirty"] = pc.is_dirty
|
||||
d["contents"] = ",".join(pc.contained_ids)
|
||||
var dirty_node := item.get_node_or_null("Dirty")
|
||||
d["dirty_shown"] = dirty_node.visible if dirty_node else false
|
||||
# The cosmetic copies the container builds from contained_ids — literally
|
||||
# what the player sees sitting on the plate.
|
||||
d["meals_shown"] = visual_count(item, "Container/MealContainer")
|
||||
d["sides_shown"] = visual_count(item, "Container/SidesContainer")
|
||||
# Food on a plate is a cosmetic child of the plate, so it must travel with
|
||||
# it. Keys starting with "_" are per-peer diagnostics that compare() skips
|
||||
# (floats will not match exactly across peers); the attached flag is
|
||||
# asserted absolutely instead, because this can — and did — go wrong on
|
||||
# both peers at once, which a diff would miss entirely.
|
||||
var off := max_visual_offset(item)
|
||||
d["_visual_offset"] = off
|
||||
d["visuals_attached"] = off <= MAX_VISUAL_OFFSET
|
||||
return d
|
||||
|
||||
|
||||
## What a station shows the player. Only the world owner runs a station's logic
|
||||
## and snap zone, so its display has to be driven from replicated state — a
|
||||
## client that never updates it shows a hob that never lights up, or a sink bar
|
||||
## still on screen after the plate came out clean.
|
||||
##
|
||||
## Bar VISIBILITY is compared across peers; the progress value is diagnostic only
|
||||
## ("_" prefix), because it changes every tick and the peers are legitimately a
|
||||
## frame apart.
|
||||
func describe_station(station: Node3D) -> Dictionary:
|
||||
var d := {}
|
||||
var bar := station.get_node_or_null("ProgressBar3D") as ProgressBar3D
|
||||
d["bar_visible"] = bar.is_bar_visible() if bar else false
|
||||
d["_bar_progress"] = snappedf(bar.get_progress(), 1.0) if bar else -1.0
|
||||
if "cooking_result" in station:
|
||||
d["cooking"] = str(station.cooking_result)
|
||||
if "is_washing" in station:
|
||||
d["washing"] = bool(station.is_washing)
|
||||
return d
|
||||
|
||||
|
||||
## Largest distance from a plate's origin to any cosmetic item it is displaying.
|
||||
## -1 when the plate is showing nothing.
|
||||
func max_visual_offset(item: Node3D) -> float:
|
||||
var worst := -1.0
|
||||
for path in ["Container/MealContainer", "Container/SidesContainer"]:
|
||||
var root := item.get_node_or_null(path)
|
||||
if not root:
|
||||
continue
|
||||
for c in root.get_children():
|
||||
if c is Node3D and not c.is_queued_for_deletion():
|
||||
worst = maxf(worst, item.global_position.distance_to((c as Node3D).global_position))
|
||||
return snappedf(worst, 0.001)
|
||||
|
||||
|
||||
## What the cosmetic children look like, for diagnosing why they moved.
|
||||
func visual_diag(item: Node3D) -> String:
|
||||
var parts: Array[String] = []
|
||||
for path in ["Container/MealContainer", "Container/SidesContainer"]:
|
||||
var root := item.get_node_or_null(path)
|
||||
if not root:
|
||||
continue
|
||||
for c in root.get_children():
|
||||
var s := "%s global=%s local=%s parent=%s (%.3fm from plate at %s)" % [
|
||||
c.name, (c as Node3D).global_position, (c as Node3D).position,
|
||||
c.get_parent().name,
|
||||
item.global_position.distance_to((c as Node3D).global_position),
|
||||
item.global_position]
|
||||
if c is RigidBody3D:
|
||||
s += " [RigidBody3D freeze=%s freeze_mode=%d layer=%d top_level=%s queued=%s]" % [
|
||||
c.freeze, c.freeze_mode, c.collision_layer, c.top_level,
|
||||
c.is_queued_for_deletion()]
|
||||
parts.append(s)
|
||||
return "; ".join(parts) if parts else "(nothing on the plate)"
|
||||
|
||||
|
||||
func visual_count(item: Node3D, path: String) -> int:
|
||||
var n := item.get_node_or_null(path)
|
||||
if not n:
|
||||
return -1
|
||||
var count := 0
|
||||
for c in n.get_children():
|
||||
if not c.is_queued_for_deletion():
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
## Compares the server's view with the client's, returning the differences.
|
||||
func compare(server: Dictionary, client: Dictionary) -> Array[String]:
|
||||
var problems: Array[String] = []
|
||||
for name in server:
|
||||
if not client.has(name):
|
||||
problems.append("'%s' exists on the server but NOT on the client" % name)
|
||||
for name in client:
|
||||
if not server.has(name):
|
||||
problems.append("'%s' exists on the client but NOT on the server (ghost copy)" % name)
|
||||
for name in server:
|
||||
if not client.has(name):
|
||||
continue
|
||||
var s: Dictionary = server[name]
|
||||
var c: Dictionary = client[name]
|
||||
# Stations are compared on their displayed state, not a position.
|
||||
if s.has("pos") and c.has("pos"):
|
||||
var dist: float = (s["pos"] as Vector3).distance_to(c["pos"])
|
||||
if dist > POS_TOLERANCE:
|
||||
problems.append("%s is %.3fm apart (server %s vs client %s)" % [name, dist, s["pos"], c["pos"]])
|
||||
for key in s:
|
||||
# "_" keys are per-peer diagnostics, not things that must match.
|
||||
if key == "pos" or key.begins_with("_"):
|
||||
continue
|
||||
if s[key] != c[key]:
|
||||
problems.append("%s.%s: server=%s client=%s" % [name, key, s[key], c[key]])
|
||||
# Absolute invariants, checked per peer. A cross-peer diff cannot catch a
|
||||
# fault that happens identically on both sides.
|
||||
for peer_name in ["server", "client"]:
|
||||
var snap: Dictionary = server if peer_name == "server" else client
|
||||
for name in snap:
|
||||
var d: Dictionary = snap[name]
|
||||
if d.has("visuals_attached") and not d["visuals_attached"]:
|
||||
problems.append("on the %s, %s's food has come off the plate (%.3fm from it, limit %.2f)"
|
||||
% [peer_name, name, d.get("_visual_offset", -1.0), MAX_VISUAL_OFFSET])
|
||||
return problems
|
||||
|
||||
|
||||
# --- live watch on plate visuals -------------------------------------------
|
||||
#
|
||||
# The per-step checks tell us food ended up off a plate, but not when or why.
|
||||
# This watches every frame and reports the first frame a cosmetic item departs
|
||||
# from its slot, along with what was happening to the plate at the time.
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if not view or not view.world:
|
||||
return
|
||||
for item in view.roots_children():
|
||||
if not (item is Node3D) or not item.get_node_or_null("PlateController"):
|
||||
continue
|
||||
_watch_plate(item as Node3D)
|
||||
|
||||
|
||||
func _watch_plate(plate: Node3D) -> void:
|
||||
for path in ["Container/MealContainer", "Container/SidesContainer"]:
|
||||
var holder := plate.get_node_or_null(path)
|
||||
if not holder:
|
||||
continue
|
||||
for c in holder.get_children():
|
||||
if not (c is Node3D) or c.is_queued_for_deletion():
|
||||
continue
|
||||
var drift: float = (c as Node3D).position.length()
|
||||
var key := c.get_instance_id()
|
||||
if drift <= VISUAL_DRIFT_EPSILON:
|
||||
_drift_reported.erase(key)
|
||||
continue
|
||||
if _drift_reported.has(key):
|
||||
continue
|
||||
_drift_reported[key] = true
|
||||
report.log_line("VISUAL DRIFT: %s on %s moved to local %s (%.3f from its slot) during '%s'" % [
|
||||
c.name, plate.name, (c as Node3D).position, drift, current_step])
|
||||
report.log_line(" plate: pos=%s freeze=%s held_by=%s xform_authority=%d" % [
|
||||
plate.global_position, plate.freeze if plate is RigidBody3D else "-",
|
||||
plate.get_picked_up_by() if plate.has_method("get_picked_up_by") else "-",
|
||||
view.xform_authority(plate)])
|
||||
if c is RigidBody3D:
|
||||
report.log_line(" visual: freeze=%s mode=%d layer=%d top_level=%s sleeping=%s lin_vel=%s" % [
|
||||
c.freeze, c.freeze_mode, c.collision_layer, c.top_level,
|
||||
c.sleeping, c.linear_velocity])
|
||||
@@ -0,0 +1 @@
|
||||
uid://bcxf7wu740dqs
|
||||
@@ -0,0 +1,245 @@
|
||||
extends Node
|
||||
class_name MpSteps
|
||||
|
||||
## The simulated player actions: reach, grab, carry, drop.
|
||||
##
|
||||
## These drive the real godot-xr-tools pickup path rather than teleporting
|
||||
## objects around, because the point is to test what a player actually does. XR
|
||||
## gives no tracked controller headlessly, so XRToolsFunctionPickup._process
|
||||
## bails out on `_controller.get_is_active()` — the grab is therefore driven by
|
||||
## hand: position the hand, let the grab Area3D register what is in range,
|
||||
## refresh the closest-object pick, then press grip.
|
||||
##
|
||||
## Every action returns {ok: bool, detail: String}.
|
||||
|
||||
## Distance under which an object counts as "at" a snap zone.
|
||||
const SNAP_TOLERANCE := 0.12
|
||||
## Cooking and washing both take ~3s of station time; allow generously for it.
|
||||
const STATION_WORK_TIMEOUT := 20.0
|
||||
|
||||
var view: MpWorldView
|
||||
var report: MpReport
|
||||
var hand: XRToolsFunctionPickup
|
||||
var controller: Node3D
|
||||
|
||||
var _hand_detached := false
|
||||
|
||||
|
||||
func setup(p_view: MpWorldView, p_report: MpReport, p_hand: XRToolsFunctionPickup, p_controller: Node3D) -> void:
|
||||
view = p_view
|
||||
report = p_report
|
||||
hand = p_hand
|
||||
controller = p_controller
|
||||
|
||||
|
||||
# --- waiting ---------------------------------------------------------------
|
||||
|
||||
func wait_frames(n: int) -> void:
|
||||
for i in n:
|
||||
await get_tree().physics_frame
|
||||
|
||||
|
||||
## Poll a condition instead of sleeping a guessed duration, so a slow machine
|
||||
## does not produce a spurious failure and a fast one does not waste seconds.
|
||||
func wait_until(cond: Callable, what: String, timeout: float) -> bool:
|
||||
var deadline := Time.get_ticks_msec() + int(timeout * 1000.0)
|
||||
while Time.get_ticks_msec() < deadline:
|
||||
if cond.call():
|
||||
return true
|
||||
await get_tree().process_frame
|
||||
report.log_line(" timed out after %.0fs waiting for %s" % [timeout, what])
|
||||
return false
|
||||
|
||||
|
||||
## Puts the hand at a world position.
|
||||
##
|
||||
## The controller is a child of the XROrigin, whose PlayerBody keeps moving under
|
||||
## gravity — that drags the hand off any position we place it at. top_level
|
||||
## detaches it into world space. Done lazily on first use, so simply running the
|
||||
## scene by hand leaves a real tracked controller alone.
|
||||
func move_hand_to(pos: Vector3) -> void:
|
||||
if not _hand_detached:
|
||||
_hand_detached = true
|
||||
controller.top_level = true
|
||||
report.log_line(" (detached the test hand from the XR rig so it can be driven directly)")
|
||||
controller.global_position = pos
|
||||
controller.force_update_transform()
|
||||
|
||||
|
||||
# --- actions ---------------------------------------------------------------
|
||||
|
||||
func grab(item: Node3D, label: String) -> Dictionary:
|
||||
if not is_instance_valid(item):
|
||||
return {"ok": false, "detail": "'%s' does not exist on this peer" % label}
|
||||
report.log_line(" reaching for %s at %s" % [label, item.global_position])
|
||||
move_hand_to(item.global_position)
|
||||
await wait_frames(4)
|
||||
hand._update_closest_object()
|
||||
var closest := hand.closest_object
|
||||
report.log_line(" hand at %s, closest grabbable = %s" % [hand.global_position, closest])
|
||||
if not is_instance_valid(closest):
|
||||
return {"ok": false, "detail": "hand found nothing to grab. %s" % view.diag(item, hand)}
|
||||
report.log_line(" pressing grip")
|
||||
hand._on_grip_pressed()
|
||||
await wait_frames(4)
|
||||
if not item.is_picked_up():
|
||||
return {"ok": false, "detail": "grip pressed on %s but %s not picked up. %s"
|
||||
% [closest.name, label, view.diag(item, hand)]}
|
||||
if item.get_picked_up_by() != hand:
|
||||
return {"ok": false, "detail": "%s is held by %s, not our hand. %s"
|
||||
% [label, item.get_picked_up_by(), view.diag(item, hand)]}
|
||||
# Grabbing is predicted locally and confirmed by the server handing us the
|
||||
# object's NetXform. Until that lands the server is still driving it, so
|
||||
# waiting here is what makes the following carry meaningful.
|
||||
report.log_line(" holding %s; waiting for the server to hand over NetXform..." % label)
|
||||
if not await wait_until(func(): return view.xform_authority(item) == multiplayer.get_unique_id(),
|
||||
"NetXform handoff", 5.0):
|
||||
return {"ok": false, "detail": "grabbed %s, but NetXform authority stayed with peer %d. %s"
|
||||
% [label, view.xform_authority(item), view.diag(item, hand)]}
|
||||
report.log_line(" NetXform is ours (peer %d)" % multiplayer.get_unique_id())
|
||||
return {"ok": true, "detail": "grabbed %s via %s; %s" % [label, closest.name, view.diag(item, hand)]}
|
||||
|
||||
|
||||
func drop(item: Node3D, label: String) -> Dictionary:
|
||||
if not is_instance_valid(item):
|
||||
return {"ok": false, "detail": "'%s' does not exist on this peer" % label}
|
||||
if not item.is_picked_up():
|
||||
return {"ok": false, "detail": "nothing to drop: %s is not held. %s" % [label, view.diag(item, hand)]}
|
||||
report.log_line(" releasing grip (held by %s)" % item.get_picked_up_by())
|
||||
hand._on_grip_release()
|
||||
await wait_frames(8)
|
||||
if not is_instance_valid(item):
|
||||
return {"ok": true, "detail": "%s was consumed on release" % label}
|
||||
# Explicitly typed: `item` is a Node3D here, so get_picked_up_by()'s return
|
||||
# type is not known statically and := cannot infer it.
|
||||
var by: Node = item.get_picked_up_by()
|
||||
report.log_line(" after release %s is held by %s" % [label, by])
|
||||
if by == hand:
|
||||
return {"ok": false, "detail": "grip released but our hand still holds %s" % label}
|
||||
# A station catching the object on release is intended — but only on the peer
|
||||
# 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 (zone enabled=%s)"
|
||||
% [by.get_parent().name, label, by.enabled]}
|
||||
if not await wait_until(func(): return not is_instance_valid(item) or view.xform_authority(item) == 1,
|
||||
"NetXform to return to the server", 5.0):
|
||||
return {"ok": false, "detail": "dropped %s, but NetXform authority stayed with peer %d"
|
||||
% [label, view.xform_authority(item)]}
|
||||
return {"ok": true, "detail": "dropped %s (now held by %s)" % [label, by]}
|
||||
|
||||
|
||||
## Grab an object, carry it onto a station's snap zone, and let go there.
|
||||
func place_in_zone(item: Node3D, label: String, station: String) -> Dictionary:
|
||||
var zone := view.zone_of(station)
|
||||
if not zone:
|
||||
return {"ok": false, "detail": "station '%s' has no snap zone on this peer" % station}
|
||||
var res := await grab(item, label)
|
||||
if not res["ok"]:
|
||||
return res
|
||||
res = await carry_to(item, label, zone.global_position)
|
||||
if not res["ok"]:
|
||||
return res
|
||||
res = await drop(item, label)
|
||||
if not res["ok"]:
|
||||
return res
|
||||
report.log_line(" waiting for %s to settle onto %s..." % [label, station])
|
||||
await wait_until(func(): return not is_instance_valid(item) \
|
||||
or item.global_position.distance_to(zone.global_position) <= SNAP_TOLERANCE,
|
||||
"%s to settle onto %s" % [label, station], 5.0)
|
||||
return {"ok": true, "detail": "placed %s into %s; %s" % [label, station, view.diag(item, hand)]}
|
||||
|
||||
|
||||
## Grab an object and carry it into another, holding it there. Used for the
|
||||
## combine (buns into the cooked burger) and for putting food on a plate, both of
|
||||
## which are triggered by an Area3D overlap rather than by dropping.
|
||||
func carry_item_to_item(item: Node3D, label: String, target: Node3D, target_label: String) -> Dictionary:
|
||||
if not is_instance_valid(target):
|
||||
return {"ok": false, "detail": "target '%s' does not exist on this peer" % target_label}
|
||||
var target_pos := target.global_position
|
||||
var res := await grab(item, label)
|
||||
if not res["ok"]:
|
||||
return res
|
||||
res = await carry_to(item, label, target_pos)
|
||||
if not res["ok"] and is_instance_valid(item):
|
||||
return res
|
||||
# Hold it there a moment: the reaction happens on the server, and the object
|
||||
# we are holding may be consumed by it.
|
||||
for i in 60:
|
||||
if not is_instance_valid(item):
|
||||
report.log_line(" %s was consumed (the reaction fired)" % label)
|
||||
return {"ok": true, "detail": "%s consumed on contact with %s" % [label, target_label]}
|
||||
await get_tree().physics_frame
|
||||
# Both uses of this consume the carried object, so still holding it means the
|
||||
# reaction never fired. Let go first, so the next step is not left fighting
|
||||
# our hand.
|
||||
if is_instance_valid(item) and item.is_picked_up():
|
||||
hand._on_grip_release()
|
||||
await wait_frames(8)
|
||||
return {"ok": false, "detail": "%s reached %s but was never consumed - the reaction did not fire; %s"
|
||||
% [label, target_label, view.diag(item, hand)]}
|
||||
|
||||
|
||||
## Carry an object somewhere clear and put it down, freeing the station it was in.
|
||||
func park(item: Node3D, label: String, pos: Vector3) -> Dictionary:
|
||||
var res := await grab(item, label)
|
||||
if not res["ok"]:
|
||||
return res
|
||||
res = await carry_to(item, label, pos)
|
||||
if not res["ok"]:
|
||||
return res
|
||||
return await drop(item, label)
|
||||
|
||||
|
||||
## Move the held object onto a world position. Closed-loop: the hand moves by
|
||||
## whatever the object's remaining error is, since the grab point offsets the
|
||||
## object from the hand by an amount not worth hard-coding.
|
||||
func carry_to(item: Node3D, label: String, target: Vector3) -> Dictionary:
|
||||
if not is_instance_valid(item) or not item.is_picked_up():
|
||||
return {"ok": false, "detail": "cannot carry: %s is not held" % label}
|
||||
report.log_line(" carrying %s from %s to %s" % [label, item.global_position, target])
|
||||
for i in 120:
|
||||
if not is_instance_valid(item):
|
||||
return {"ok": true, "detail": "%s was consumed while being carried" % label}
|
||||
var err := item.global_position - target
|
||||
if err.length() <= 0.01:
|
||||
break
|
||||
controller.global_position -= err
|
||||
await get_tree().physics_frame
|
||||
await get_tree().physics_frame
|
||||
if not is_instance_valid(item):
|
||||
return {"ok": true, "detail": "%s was consumed while being carried" % label}
|
||||
var dist := item.global_position.distance_to(target)
|
||||
report.log_line(" %s is now %.3fm from the target" % [label, dist])
|
||||
if dist > 0.05:
|
||||
return {"ok": false, "detail": "could not carry %s to the target: still %.3fm away" % [label, dist]}
|
||||
return {"ok": true, "detail": "carried %s to within %.3fm" % [label, dist]}
|
||||
|
||||
|
||||
## Wait for a station to produce an object with the given food id (cooking the
|
||||
## burger, or combining into a hamburger). Server-side: stations only run there.
|
||||
func await_food(food_id: String) -> Dictionary:
|
||||
report.log_line(" waiting for a '%s' to appear..." % food_id)
|
||||
var ok := await wait_until(func(): return view.find_by_food_id(food_id) != null,
|
||||
"a '%s' to be produced" % food_id, STATION_WORK_TIMEOUT)
|
||||
if not ok:
|
||||
return {"ok": false, "detail": "no '%s' was produced within %.0fs" % [food_id, STATION_WORK_TIMEOUT]}
|
||||
# Let the spawn replicate before the checks that follow go looking for it.
|
||||
await wait_frames(30)
|
||||
var made := view.find_by_food_id(food_id)
|
||||
return {"ok": true, "detail": "'%s' produced: %s at %s" % [food_id, made.name, made.global_position]}
|
||||
|
||||
|
||||
func await_clean(plate_name: String) -> Dictionary:
|
||||
var plate := view.find(plate_name)
|
||||
if not plate:
|
||||
return {"ok": false, "detail": "'%s' does not exist on this peer" % plate_name}
|
||||
var pc := plate.get_node_or_null("PlateController")
|
||||
report.log_line(" waiting for the sink to wash %s (dirty=%s)..." % [plate_name, pc.is_dirty if pc else "?"])
|
||||
var ok := await wait_until(func(): return pc and not pc.is_dirty,
|
||||
"the sink to wash the plate", STATION_WORK_TIMEOUT)
|
||||
await wait_frames(30)
|
||||
if not ok:
|
||||
return {"ok": false, "detail": "%s was still dirty after %.0fs in the sink; %s"
|
||||
% [plate_name, STATION_WORK_TIMEOUT, view.diag(plate, hand)]}
|
||||
return {"ok": true, "detail": "%s was washed clean" % plate_name}
|
||||
@@ -0,0 +1 @@
|
||||
uid://cnwwp8f22wwk5
|
||||
+255
-906
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,117 @@
|
||||
extends Node
|
||||
class_name MpWorldView
|
||||
|
||||
## Finding things in the world under test, and describing what they are doing.
|
||||
##
|
||||
## Nothing here caches a node. Items are consumed and respawned as the run goes
|
||||
## on — the raw burger becomes a cooked burger, which becomes a hamburger, which
|
||||
## is absorbed into a plate — so a held reference is a freed object waiting to
|
||||
## happen. Everything is looked up at the moment it is used.
|
||||
|
||||
var world: Node3D
|
||||
var report: MpReport
|
||||
|
||||
var _despawn_timers_seen := {}
|
||||
|
||||
|
||||
func setup(p_world: Node3D, p_report: MpReport) -> void:
|
||||
world = p_world
|
||||
report = p_report
|
||||
|
||||
|
||||
## Objects live either baked in the scene root or, once spawned at runtime, under
|
||||
## WorldContent. Look in both.
|
||||
func find(name: String) -> Node3D:
|
||||
var n := world.get_node_or_null(name)
|
||||
if not n:
|
||||
n = world.get_node_or_null("WorldContent/" + name)
|
||||
return n as Node3D
|
||||
|
||||
|
||||
func zone_of(station_name: String) -> XRToolsSnapZone:
|
||||
var s := find(station_name)
|
||||
return s.get_node_or_null("XRToolsSnapZone") as XRToolsSnapZone if s else null
|
||||
|
||||
|
||||
## Cooking and combining spawn their results with engine-assigned names, so the
|
||||
## only stable way to find one is by the food id it carries.
|
||||
func find_by_food_id(id: String) -> Node3D:
|
||||
for child in roots_children():
|
||||
var f := child.get_node_or_null("FoodItem") as FoodItem
|
||||
if f and f.id == id and is_instance_valid(child):
|
||||
return child as Node3D
|
||||
return null
|
||||
|
||||
|
||||
func roots_children() -> Array[Node]:
|
||||
var out: Array[Node] = []
|
||||
for root in [world, world.get_node_or_null("WorldContent")]:
|
||||
if not root:
|
||||
continue
|
||||
for child in root.get_children():
|
||||
out.append(child)
|
||||
return out
|
||||
|
||||
|
||||
func pickables() -> Array[Node]:
|
||||
var out: Array[Node] = []
|
||||
for child in roots_children():
|
||||
if child is XRToolsPickable and not child.is_queued_for_deletion():
|
||||
out.append(child)
|
||||
return out
|
||||
|
||||
|
||||
## Who is currently driving this object's transform.
|
||||
##
|
||||
## Under the high-level model the object's own multiplayer 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
|
||||
|
||||
|
||||
## The test deliberately leaves objects sitting still for minutes at a time,
|
||||
## which DespawningItem treats as litter and removes — it took the raw burger
|
||||
## away before the client had even connected. Hold them indefinitely instead, so
|
||||
## the run tests the kitchen rather than the despawn timer.
|
||||
##
|
||||
## Re-checked before every step rather than only at startup, because cooking and
|
||||
## combining spawn new objects as the run goes on.
|
||||
func disable_despawn_timers() -> void:
|
||||
var stopped := 0
|
||||
for node in world.find_children("*", "DespawningItem", true, false):
|
||||
if _despawn_timers_seen.has(node.get_instance_id()):
|
||||
continue
|
||||
_despawn_timers_seen[node.get_instance_id()] = true
|
||||
node.set_process(false)
|
||||
stopped += 1
|
||||
if stopped > 0:
|
||||
report.log_line("disabled %d DespawningItem timer(s) so test objects don't vanish mid-run" % stopped)
|
||||
|
||||
|
||||
func diag(item: Node3D, hand: XRToolsFunctionPickup = null) -> String:
|
||||
if not is_instance_valid(item):
|
||||
return "item[freed]"
|
||||
var pc := item.get_node_or_null("PlateController")
|
||||
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" % [
|
||||
item.enabled, item.can_pick_up(hand) if hand else "?",
|
||||
item.collision_layer, item.get_picked_up_by()]
|
||||
if pc:
|
||||
s += " dirty=%s contains=%s" % [pc.is_dirty, str(pc.contained_ids)]
|
||||
return s + "]"
|
||||
|
||||
|
||||
func dump_state(stations: Array, hand: XRToolsFunctionPickup) -> void:
|
||||
report.log_line(" stations:")
|
||||
for station in stations:
|
||||
var z := zone_of(station)
|
||||
if z:
|
||||
report.log_line(" %-12s zone holds %s (enabled=%s)" % [station, z.picked_up_object, z.enabled])
|
||||
report.log_line(" items:")
|
||||
for child in pickables():
|
||||
report.log_line(" %s" % diag(child as Node3D, hand))
|
||||
@@ -0,0 +1 @@
|
||||
uid://dxqia06f3e4u4
|
||||
@@ -34,7 +34,7 @@ size = Vector3(15, 20, 0.1)
|
||||
|
||||
[node name="Main" type="Node3D" unique_id=1312265607]
|
||||
script = ExtResource("1_6uucx")
|
||||
populate_from_layout = false
|
||||
populate_from_layout = true
|
||||
|
||||
[node name="XROrigin3D" parent="." unique_id=2055526621 groups=["local_xr_origin"] instance=ExtResource("2_j7vd1")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.36171648, 0, 0.81835127)
|
||||
@@ -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]
|
||||
@@ -100,10 +99,10 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 2, 0.5, 0)
|
||||
script = ExtResource("9_mptst")
|
||||
|
||||
[node name="raw_burger" parent="." unique_id=1675596942 instance=ExtResource("10_51k0c")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.28692234, 1.0149999, 0.3505687)
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.28692234, 1.0149999, 1.1)
|
||||
|
||||
[node name="BurgerBuns" parent="." unique_id=1088240294 instance=ExtResource("11_psgbv")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.40037438, 1.0094403, 0.34120744)
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.40037438, 1.0094403, 1.1)
|
||||
|
||||
[node name="Counter" parent="." unique_id=1487893288 instance=ExtResource("12_j5uvh")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1, 0.5, 0)
|
||||
|
||||
@@ -29,27 +29,28 @@ param(
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$proj = Split-Path -Parent $PSScriptRoot
|
||||
$scene = "res://test/multiPlayerTest.tscn"
|
||||
$serverScene = "res://test/multiPlayerTest.tscn"
|
||||
$clientScene = "res://Scenes/multiPlayer.tscn"
|
||||
|
||||
. (Join-Path $PSScriptRoot "mp_window_layout.ps1")
|
||||
|
||||
function Start-Instance($extraArgs) {
|
||||
$a = "--xr-mode off --resolution 900x600 --path `"$proj`" $scene"
|
||||
if ($extraArgs) { $a += " -- $($extraArgs -join ' ')" }
|
||||
function Start-Instance($extraArgs, $sceneFor) {
|
||||
$a = "--xr-mode off --resolution 900x600 --path `"$proj`" $sceneFor -- --mptest-manual"
|
||||
if ($extraArgs) { $a += " $($extraArgs -join ' ')" }
|
||||
return Start-Process -FilePath $Godot -ArgumentList $a -PassThru
|
||||
}
|
||||
|
||||
if ($Solo) {
|
||||
Write-Host "Opening a single offline window (no networking)."
|
||||
$null = Move-GameWindow (Start-Instance $null) 300 100
|
||||
$null = Move-GameWindow (Start-Instance $null $serverScene) 300 100
|
||||
return
|
||||
}
|
||||
|
||||
Write-Host "Opening SERVER window (left)..."
|
||||
$w = Move-GameWindow (Start-Instance @("--server")) 20 60
|
||||
$w = Move-GameWindow (Start-Instance @("--server") $serverScene) 20 60
|
||||
Start-Sleep -Seconds 5
|
||||
Write-Host "Opening CLIENT window (right)..."
|
||||
$null = Move-GameWindow (Start-Instance @("--join", "127.0.0.1")) (20 + $w + 12) 60
|
||||
$null = Move-GameWindow (Start-Instance @("--join", "127.0.0.1") $clientScene) (20 + $w + 12) 60
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Both windows are up. Click one to focus it, then press:"
|
||||
|
||||
+62
-24
@@ -1,41 +1,69 @@
|
||||
# Runs the headless two-instance multiplayer test (test/multiPlayerTest.tscn).
|
||||
# Runs the two-instance multiplayer test and ALWAYS produces a GIF of the run.
|
||||
#
|
||||
# powershell -File test\run_mp_test.ps1
|
||||
#
|
||||
# Starts a server instance and a client instance of the game with --xr-mode off
|
||||
# (SteamVR's OpenXR runtime crashes a headless process), lets test/mp_test_driver.gd
|
||||
# drive the scripted plate/dirt-station sequence, then prints both logs.
|
||||
# Starts a server instance and a client instance on test/multiPlayerTest.tscn,
|
||||
# lets test/mp_test_driver.gd drive the scripted kitchen sequence, then writes:
|
||||
# logs\mptest_report.txt - every check, pass/fail, plus failure details
|
||||
# logs\mptest_run.gif - both peers side by side, one frame per step
|
||||
# logs\mptest_{server,client}.log - full step logs
|
||||
# Exits non-zero if any check failed.
|
||||
#
|
||||
# The windows are visible because frame capture needs a real framebuffer: a
|
||||
# headless Godot renders nothing, so there would be no GIF. Use -Headless when
|
||||
# you only want the pass/fail result (faster, but no GIF).
|
||||
|
||||
param(
|
||||
[string]$Godot = "C:\Users\Aqua Aurora\Desktop\Godot_v4.7-stable_win64.exe",
|
||||
[int]$TimeoutSec = 120
|
||||
[int]$TimeoutSec = 400,
|
||||
# Seconds to pause between steps, and to hold the final state on screen.
|
||||
[double]$Pause = 0.4,
|
||||
[double]$Hold = 3,
|
||||
# Passed through to make_gif.ps1.
|
||||
[int]$HalfWidth = 900,
|
||||
[double]$SecondsPerStep = 1.2,
|
||||
# Skip the windows entirely. No frames are captured, so no GIF is produced.
|
||||
[switch]$Headless
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$proj = Split-Path -Parent $PSScriptRoot
|
||||
$scene = "res://test/multiPlayerTest.tscn"
|
||||
# The two peers deliberately load DIFFERENT scenes, mirroring the real game:
|
||||
# the host opens whatever world it is running, the client opens the bare
|
||||
# multiplayer scene and must receive the entire layout over the network. That is
|
||||
# also exactly what a client joining mid-session goes through.
|
||||
$serverScene = "res://test/multiPlayerTest.tscn"
|
||||
$clientScene = "res://Scenes/multiPlayer.tscn"
|
||||
$serverLog = Join-Path $proj "logs/mptest_server.log"
|
||||
$clientLog = Join-Path $proj "logs/mptest_client.log"
|
||||
$report = Join-Path $proj "logs/mptest_report.txt"
|
||||
$gif = Join-Path $proj "logs/mptest_run.gif"
|
||||
|
||||
foreach ($f in @($serverLog, $clientLog)) { if (Test-Path $f) { Remove-Item $f -Force } }
|
||||
foreach ($f in @($serverLog, $clientLog, $report, $gif)) { if (Test-Path $f) { Remove-Item $f -Force } }
|
||||
|
||||
function Start-Instance($extraArgs) {
|
||||
# Single argument string with the project path quoted: Start-Process does
|
||||
# not quote array elements, so the space in the path would split it.
|
||||
$a = "--headless --xr-mode off --path `"$proj`" $scene -- $($extraArgs -join ' ')"
|
||||
$p = Start-Process -FilePath $Godot -ArgumentList $a -PassThru -NoNewWindow
|
||||
# Touching .Handle caches it, which is what makes .ExitCode readable later;
|
||||
# without this it comes back empty even after the process has exited.
|
||||
. (Join-Path $PSScriptRoot "mp_window_layout.ps1")
|
||||
|
||||
function Start-Instance($extraArgs, $sceneFor) {
|
||||
# --xr-mode off keeps it on the desktop (SteamVR's OpenXR runtime otherwise
|
||||
# takes over, and two instances can't share a headset anyway).
|
||||
$a = "--xr-mode off"
|
||||
if ($Headless) { $a = "--headless " + $a }
|
||||
$a += " --resolution 900x600 --path `"$proj`" $sceneFor -- $($extraArgs -join ' ') --mptest"
|
||||
if (-not $Headless) { $a += " --mptest-frames" }
|
||||
$a += " --mptest-pause $Pause --mptest-hold $Hold"
|
||||
$p = Start-Process -FilePath $Godot -ArgumentList $a -PassThru -NoNewWindow:$Headless
|
||||
# Touching .Handle caches it, which is what makes .ExitCode readable later.
|
||||
$null = $p.Handle
|
||||
return $p
|
||||
}
|
||||
|
||||
Write-Host "Starting server..."
|
||||
$server = Start-Instance @("--server", "--mptest")
|
||||
Start-Sleep -Seconds 4
|
||||
Write-Host "Starting client..."
|
||||
$client = Start-Instance @("--join", "127.0.0.1", "--mptest")
|
||||
Write-Host "Starting SERVER..."
|
||||
$server = Start-Instance @("--server") $serverScene
|
||||
if (-not $Headless) { $w = Move-GameWindow $server 20 60 }
|
||||
Start-Sleep -Seconds 5
|
||||
Write-Host "Starting CLIENT..."
|
||||
$client = Start-Instance @("--join", "127.0.0.1") $clientScene
|
||||
if (-not $Headless) { $null = Move-GameWindow $client (20 + $w + 12) 60 }
|
||||
|
||||
$deadline = (Get-Date).AddSeconds($TimeoutSec)
|
||||
while ((Get-Date) -lt $deadline -and -not $server.HasExited) { Start-Sleep -Milliseconds 500 }
|
||||
@@ -45,16 +73,26 @@ foreach ($p in @($server, $client)) {
|
||||
}
|
||||
Start-Sleep -Milliseconds 500
|
||||
|
||||
foreach ($pair in @(@("SERVER", $serverLog), @("CLIENT", $clientLog))) {
|
||||
if (-not $Headless) {
|
||||
Write-Host ""
|
||||
Write-Host "======================== $($pair[0]) ========================"
|
||||
if (Test-Path $pair[1]) { Get-Content $pair[1] -Encoding UTF8 } else { Write-Host "(no log written)" }
|
||||
Write-Host "======================== GIF ========================"
|
||||
& powershell -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot "make_gif.ps1") `
|
||||
-HalfWidth $HalfWidth -SecondsPerStep $SecondsPerStep
|
||||
} else {
|
||||
Write-Host ""
|
||||
Write-Host "(headless run: no frames captured, so no GIF - drop -Headless to get one)"
|
||||
}
|
||||
|
||||
# ExitCode is only populated on the process object after a WaitForExit() call,
|
||||
# even when HasExited is already true - without this it reads back empty.
|
||||
Write-Host ""
|
||||
Write-Host "======================== REPORT ========================"
|
||||
if (Test-Path $report) { Get-Content $report -Encoding UTF8 } else { Write-Host "(no report written - the run did not finish)" }
|
||||
|
||||
$server.WaitForExit(2000) | Out-Null
|
||||
$code = if ($server.HasExited) { $server.ExitCode } else { 1 }
|
||||
Write-Host ""
|
||||
Write-Host "report: $report"
|
||||
if (-not $Headless -and (Test-Path $gif)) { Write-Host "gif: $gif" }
|
||||
Write-Host "logs: $serverLog"
|
||||
Write-Host " $clientLog"
|
||||
Write-Host "server exit code: $code"
|
||||
exit $code
|
||||
|
||||
@@ -24,7 +24,8 @@ param(
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$proj = Split-Path -Parent $PSScriptRoot
|
||||
$scene = "res://test/multiPlayerTest.tscn"
|
||||
$serverScene = "res://test/multiPlayerTest.tscn"
|
||||
$clientScene = "res://Scenes/multiPlayer.tscn"
|
||||
$serverLog = Join-Path $proj "logs/mptest_server.log"
|
||||
$clientLog = Join-Path $proj "logs/mptest_client.log"
|
||||
|
||||
@@ -32,10 +33,10 @@ foreach ($f in @($serverLog, $clientLog)) { if (Test-Path $f) { Remove-Item $f -
|
||||
|
||||
. (Join-Path $PSScriptRoot "mp_window_layout.ps1")
|
||||
|
||||
function Start-Instance($extraArgs) {
|
||||
function Start-Instance($extraArgs, $sceneFor) {
|
||||
# --xr-mode off keeps it on the desktop (SteamVR's OpenXR runtime otherwise
|
||||
# takes over, and two instances can't share a headset anyway).
|
||||
$a = "--xr-mode off --resolution 900x600 --path `"$proj`" $scene -- " +
|
||||
$a = "--xr-mode off --resolution 900x600 --path `"$proj`" $sceneFor -- " +
|
||||
"$($extraArgs -join ' ') --mptest --mptest-frames --mptest-pause $Pause --mptest-hold $Hold"
|
||||
$p = Start-Process -FilePath $Godot -ArgumentList $a -PassThru
|
||||
# Touching .Handle caches it, which is what makes .ExitCode readable later.
|
||||
@@ -44,11 +45,11 @@ function Start-Instance($extraArgs) {
|
||||
}
|
||||
|
||||
Write-Host "Starting SERVER window (left)..."
|
||||
$server = Start-Instance @("--server")
|
||||
$server = Start-Instance @("--server") $serverScene
|
||||
$w = Move-GameWindow $server 20 60
|
||||
Start-Sleep -Seconds 5
|
||||
Write-Host "Starting CLIENT window (right)..."
|
||||
$client = Start-Instance @("--join", "127.0.0.1")
|
||||
$client = Start-Instance @("--join", "127.0.0.1") $clientScene
|
||||
$null = Move-GameWindow $client (20 + $w + 12) 60
|
||||
|
||||
Write-Host "Watch the two windows (each has a fixed camera on the test area)."
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
@tool
|
||||
extends EditorScript
|
||||
|
||||
## Runs the whole multiplayer test suite from inside the Godot editor.
|
||||
##
|
||||
## HOW TO USE
|
||||
## 1. Open this file in the editor's script panel.
|
||||
## 2. File > Run (Ctrl+Shift+X).
|
||||
##
|
||||
## It launches two copies of the game (server + client) as separate processes,
|
||||
## waits for them to finish, then prints the full pass/fail report into the
|
||||
## Output panel and builds the side-by-side GIF.
|
||||
##
|
||||
## Everything it produces lands in logs/:
|
||||
## mptest_report.txt every check, pass/fail, plus details for failures
|
||||
## mptest_run.gif both peers side by side, one frame per step
|
||||
## mptest_{server,client}.log full step logs
|
||||
##
|
||||
## NOTE: the editor is blocked while the run is in progress (a couple of
|
||||
## minutes). Watch the two game windows to follow along.
|
||||
|
||||
## Give up if the server hasn't finished in this long.
|
||||
const TIMEOUT_SEC := 400
|
||||
## Seconds each step is held, so the run is watchable and the GIF is readable.
|
||||
const STEP_PAUSE := 0.4
|
||||
## Seconds the final state stays on screen before the instances close.
|
||||
const END_HOLD := 3.0
|
||||
|
||||
## The peers load different scenes on purpose, mirroring the real game: the host
|
||||
## opens whatever world it is running, the client opens the bare multiplayer
|
||||
## scene and has to receive the whole layout over the network — the same path a
|
||||
## client joining mid-session takes.
|
||||
const SERVER_SCENE := "res://test/multiPlayerTest.tscn"
|
||||
const CLIENT_SCENE := "res://Scenes/multiPlayer.tscn"
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var exe := OS.get_executable_path()
|
||||
var project_dir := ProjectSettings.globalize_path("res://").rstrip("/")
|
||||
var logs_dir := ProjectSettings.globalize_path("res://logs")
|
||||
DirAccess.make_dir_recursive_absolute(logs_dir)
|
||||
_clear_previous_results(logs_dir)
|
||||
|
||||
print_rich("[b]Running the multiplayer test suite...[/b]")
|
||||
print(" the editor will be unresponsive until it finishes (~2 minutes)")
|
||||
|
||||
var server_pid := _launch(exe, project_dir, ["--server"], SERVER_SCENE)
|
||||
if server_pid <= 0:
|
||||
push_error("Could not start the server instance")
|
||||
return
|
||||
# Give the host time to come up and open its port before the client dials in.
|
||||
OS.delay_msec(5000)
|
||||
var client_pid := _launch(exe, project_dir, ["--join", "127.0.0.1"], CLIENT_SCENE)
|
||||
if client_pid <= 0:
|
||||
push_error("Could not start the client instance")
|
||||
OS.kill(server_pid)
|
||||
return
|
||||
|
||||
var waited := 0.0
|
||||
while OS.is_process_running(server_pid) and waited < TIMEOUT_SEC:
|
||||
OS.delay_msec(500)
|
||||
waited += 0.5
|
||||
if OS.is_process_running(server_pid):
|
||||
print(" timed out after %ds, stopping the instances" % TIMEOUT_SEC)
|
||||
OS.kill(server_pid)
|
||||
if OS.is_process_running(client_pid):
|
||||
OS.kill(client_pid)
|
||||
OS.delay_msec(500)
|
||||
|
||||
_print_report(logs_dir)
|
||||
_build_gif(project_dir, logs_dir)
|
||||
|
||||
|
||||
func _launch(exe: String, project_dir: String, extra: Array, scene: String) -> int:
|
||||
var args := PackedStringArray([
|
||||
"--xr-mode", "off", "--resolution", "900x600",
|
||||
"--path", project_dir, scene, "--",
|
||||
])
|
||||
for a in extra:
|
||||
args.append(a)
|
||||
# --mptest runs the scripted sequence; --mptest-frames captures the frame per
|
||||
# step that the GIF is stitched from (needs a real window, hence no
|
||||
# --headless here).
|
||||
args.append_array(PackedStringArray([
|
||||
"--mptest", "--mptest-frames",
|
||||
"--mptest-pause", str(STEP_PAUSE), "--mptest-hold", str(END_HOLD),
|
||||
]))
|
||||
return OS.create_process(exe, args)
|
||||
|
||||
|
||||
# Remove the previous run's output so a failed launch can't leave stale results
|
||||
# looking like this run's.
|
||||
func _clear_previous_results(logs_dir: String) -> void:
|
||||
for name in ["mptest_report.txt", "mptest_run.gif", "mptest_server.log", "mptest_client.log"]:
|
||||
DirAccess.remove_absolute(logs_dir.path_join(name))
|
||||
for role in ["server", "client"]:
|
||||
var frames := logs_dir.path_join("mptest_frames_%s" % role)
|
||||
var dir := DirAccess.open(frames)
|
||||
if dir:
|
||||
for f in dir.get_files():
|
||||
if f.ends_with(".png"):
|
||||
dir.remove(f)
|
||||
|
||||
|
||||
func _print_report(logs_dir: String) -> void:
|
||||
var path := logs_dir.path_join("mptest_report.txt")
|
||||
if not FileAccess.file_exists(path):
|
||||
push_error("No report at %s — the run did not finish. Check logs/mptest_server.log" % path)
|
||||
return
|
||||
var text := FileAccess.get_file_as_string(path)
|
||||
print("")
|
||||
# Colour the summary so a failure is obvious in the Output panel.
|
||||
for line in text.split("\n"):
|
||||
if line.begins_with("FAIL") or line.contains("RESULT: FAILED"):
|
||||
print_rich("[color=red]%s[/color]" % line)
|
||||
elif line.contains("ALL CHECKS PASSED"):
|
||||
print_rich("[color=green][b]%s[/b][/color]" % line)
|
||||
elif line.begins_with("PASS"):
|
||||
print_rich("[color=gray]%s[/color]" % line)
|
||||
else:
|
||||
print(line)
|
||||
print("report: %s" % path)
|
||||
|
||||
|
||||
func _build_gif(project_dir: String, logs_dir: String) -> void:
|
||||
var script := project_dir.path_join("test/make_gif.ps1")
|
||||
var out := []
|
||||
var code := OS.execute("powershell", [
|
||||
"-ExecutionPolicy", "Bypass", "-File", script,
|
||||
], out, true)
|
||||
for line in out:
|
||||
print(line)
|
||||
var gif := logs_dir.path_join("mptest_run.gif")
|
||||
if code == 0 and FileAccess.file_exists(gif):
|
||||
print_rich("[b]gif:[/b] %s" % gif)
|
||||
else:
|
||||
push_warning("GIF was not produced (is ffmpeg on PATH?). See the output above.")
|
||||
@@ -0,0 +1 @@
|
||||
uid://crcuhy3n8goep
|
||||
@@ -0,0 +1,24 @@
|
||||
extends Node
|
||||
|
||||
## Throwaway probe: prints how GDScript reports typed-array properties, so
|
||||
## NetReplication's filter can exclude Array[Node3D] / Array[FoodItem] without
|
||||
## guessing at the hint format.
|
||||
|
||||
func _ready() -> void:
|
||||
for path in [
|
||||
"res://Containers/container.gd",
|
||||
"res://Containers/plate_controller.gd",
|
||||
"res://Stations/sink.gd",
|
||||
"res://Stations/item_dispenser.gd",
|
||||
"res://Stations/hob.gd",
|
||||
]:
|
||||
var script: Script = load(path)
|
||||
print("=== ", path)
|
||||
for prop in script.get_script_property_list():
|
||||
if not (prop["usage"] & PROPERTY_USAGE_SCRIPT_VARIABLE):
|
||||
continue
|
||||
print(" name=%s type=%d hint=%d hint_string=%s" % [
|
||||
prop["name"], prop["type"], prop["hint"], prop["hint_string"],
|
||||
])
|
||||
print("TYPE_ARRAY=%d TYPE_OBJECT=%d" % [TYPE_ARRAY, TYPE_OBJECT])
|
||||
get_tree().quit()
|
||||
@@ -0,0 +1 @@
|
||||
uid://ctsonlf12536k
|
||||
@@ -0,0 +1,6 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://bspike0probe0"]
|
||||
|
||||
[ext_resource type="Script" path="res://test/spike/probe.gd" id="1_probe"]
|
||||
|
||||
[node name="Probe" type="Node"]
|
||||
script = ExtResource("1_probe")
|
||||
@@ -0,0 +1,8 @@
|
||||
extends Node3D
|
||||
|
||||
## Spike fixture only. A plain scene with NO authored MultiplayerSynchronizer,
|
||||
## so the run proves the runtime-generated one is what carries the state.
|
||||
|
||||
var health: int = 0
|
||||
var label: String = ""
|
||||
var _private_should_not_replicate: int = 99
|
||||
@@ -0,0 +1 @@
|
||||
uid://cbgmii1peyxu8
|
||||
@@ -0,0 +1,6 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://bspike0item00"]
|
||||
|
||||
[ext_resource type="Script" path="res://test/spike/spike_item.gd" id="1_spike"]
|
||||
|
||||
[node name="SpikeItem" type="Node3D"]
|
||||
script = ExtResource("1_spike")
|
||||
@@ -0,0 +1,222 @@
|
||||
extends Node3D
|
||||
|
||||
## Throwaway spike. Answers the questions about Godot's high-level replication
|
||||
## that the rebuild depends on, before any of the real code is rewritten.
|
||||
##
|
||||
## Q1 Does default (auto) MultiplayerSpawner replication carry the spawn
|
||||
## properties of synchronizers attached AT RUNTIME rather than baked into
|
||||
## the .tscn? [CONFIRMED]
|
||||
## Q2 Do ON_CHANGE properties reach a peer that joins AFTER the value was
|
||||
## set, and do later changes propagate? [CONFIRMED]
|
||||
## Q3 Can set_visibility_for(peer, false) suppress a peer's updates without
|
||||
## despawning the node on it? [REFUTED — the
|
||||
## node is despawned and respawned on every visibility flip, so this
|
||||
## cannot be used to stop the server fighting a client's held object.]
|
||||
## Q4 Does handing ONE of a node's two synchronizers (NetXform) to a client
|
||||
## let that client drive the transform locally — ignoring the server's
|
||||
## inbound updates — while the other (NetSync) keeps pushing server-owned
|
||||
## state to it? This is the grab-prediction mechanism.
|
||||
##
|
||||
## Both peers run on one ABSOLUTE clock (see _at) rather than chains of relative
|
||||
## timers: the first version of this spike drifted enough that the client's
|
||||
## reports straddled the server's actions and read as inconclusive.
|
||||
|
||||
const PORT := 24599
|
||||
const ITEM := "res://test/spike/spike_item.tscn"
|
||||
|
||||
var _log: FileAccess
|
||||
var _is_server := false
|
||||
var _start_ms: int = 0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var args := OS.get_cmdline_user_args()
|
||||
_is_server = args.has("--server")
|
||||
_start_ms = Time.get_ticks_msec()
|
||||
_open_log()
|
||||
|
||||
# Registered identically on both peers BEFORE connecting: auto-spawn sends an
|
||||
# index into this array, so a mismatch builds the wrong scene on the client.
|
||||
$Spawner.add_spawnable_scene(ITEM)
|
||||
|
||||
# The hook under test: every peer attaches the generated synchronizers as the
|
||||
# node enters the tree. On the server that is our own add_child; on the
|
||||
# client it is MultiplayerSpawner's.
|
||||
$Content.child_entered_tree.connect(_on_content_child)
|
||||
|
||||
if _is_server:
|
||||
_run_server()
|
||||
else:
|
||||
_run_client()
|
||||
|
||||
|
||||
func _on_content_child(node: Node) -> void:
|
||||
if node is MultiplayerSynchronizer:
|
||||
return
|
||||
NetReplication.attach(node)
|
||||
_say("attached synchronizers to %s" % node.name)
|
||||
|
||||
|
||||
## Waits until `t` seconds after this scene started. Both peers start within a
|
||||
## few hundred ms of each other, which is close enough to interleave their
|
||||
## actions and observations deterministically.
|
||||
func _at(t: float) -> void:
|
||||
var target := _start_ms + int(t * 1000.0)
|
||||
while Time.get_ticks_msec() < target:
|
||||
await get_tree().process_frame
|
||||
|
||||
|
||||
## set_multiplayer_authority is a local call, so it has to run on every peer for
|
||||
## them to agree on who is driving the transform.
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func _set_xform_authority(path: NodePath, peer: int) -> void:
|
||||
var node := get_node_or_null(path)
|
||||
if not node:
|
||||
return
|
||||
node.get_node(NetReplication.XFORM_NAME).set_multiplayer_authority(peer)
|
||||
_say("NetXform authority of %s -> peer %d" % [node.name, peer])
|
||||
|
||||
|
||||
func _item() -> Node3D:
|
||||
return $Content.get_node_or_null("SpikeItem") as Node3D
|
||||
|
||||
|
||||
# --- server ----------------------------------------------------------------
|
||||
|
||||
func _run_server() -> void:
|
||||
var peer := ENetMultiplayerPeer.new()
|
||||
var err := peer.create_server(PORT, 4)
|
||||
if err != OK:
|
||||
_say("FATAL: create_server failed: %s" % error_string(err))
|
||||
_finish()
|
||||
return
|
||||
multiplayer.multiplayer_peer = peer
|
||||
_say("server up on %d" % PORT)
|
||||
|
||||
# t=1: spawn BEFORE anyone joins, so the client receives it purely as a
|
||||
# late-joiner replay — the same path as joining a running game.
|
||||
await _at(1.0)
|
||||
var item: Node3D = load(ITEM).instantiate()
|
||||
item.name = "SpikeItem"
|
||||
item.position = Vector3(5, 6, 7)
|
||||
item.health = 42
|
||||
item.label = "hello"
|
||||
$Content.add_child(item)
|
||||
_say("spawned SpikeItem at %s health=%d label=%s" % [item.position, item.health, item.label])
|
||||
|
||||
await _at(7.0)
|
||||
if multiplayer.get_peers().is_empty():
|
||||
_say("FATAL: no client connected by t=7")
|
||||
_finish()
|
||||
return
|
||||
var client: int = multiplayer.get_peers()[0]
|
||||
_say("client connected: %d" % client)
|
||||
|
||||
# t=8: change an ON_CHANGE value well after the client joined (Q2b).
|
||||
await _at(8.0)
|
||||
item.health = 7
|
||||
item.label = "changed"
|
||||
_say("changed health=7 label=changed")
|
||||
|
||||
# t=12: hand the transform to the client, as if it had just grabbed the item.
|
||||
await _at(12.0)
|
||||
_set_xform_authority.rpc(item.get_path(), client)
|
||||
|
||||
# t=13: the server now tries to move it and also changes server-owned state.
|
||||
# The move must NOT reach the client (it owns the transform); the state must.
|
||||
await _at(13.0)
|
||||
item.position = Vector3(-9, -9, -9)
|
||||
item.health = 123
|
||||
_say("server set pos=%s health=123 while the CLIENT owns NetXform" % item.position)
|
||||
|
||||
await _at(15.5)
|
||||
_say("Q4 server's view of item pos (expect the CLIENT's 1,1,1) -> %s" % item.position)
|
||||
|
||||
# t=17: take it back, as if the client had released it, and move it.
|
||||
await _at(17.0)
|
||||
_set_xform_authority.rpc(item.get_path(), 1)
|
||||
await _at(17.5)
|
||||
item.position = Vector3(-3, -3, -3)
|
||||
_say("server reclaimed NetXform and set pos=%s" % item.position)
|
||||
|
||||
# Stay alive past the client's last report, so a shutdown-induced despawn
|
||||
# can't be mistaken for anything else.
|
||||
await _at(24.0)
|
||||
_say("server done")
|
||||
_finish()
|
||||
|
||||
|
||||
# --- client ----------------------------------------------------------------
|
||||
|
||||
func _run_client() -> void:
|
||||
# Deliberately late, so the spawn is replayed rather than observed live.
|
||||
await _at(5.0)
|
||||
var peer := ENetMultiplayerPeer.new()
|
||||
var err := peer.create_client("127.0.0.1", PORT)
|
||||
if err != OK:
|
||||
_say("FATAL: create_client failed: %s" % error_string(err))
|
||||
_finish()
|
||||
return
|
||||
multiplayer.multiplayer_peer = peer
|
||||
_say("client connecting...")
|
||||
|
||||
await _at(7.5)
|
||||
_report("Q1/Q2a late-join spawn (expect pos=5,6,7 health=42 label=hello)")
|
||||
|
||||
await _at(10.0)
|
||||
_report("Q2b after change (expect health=7 label=changed)")
|
||||
|
||||
# t=13.5: we now own NetXform. Drive the item ourselves, the way a held
|
||||
# object's grab driver would, and see whether the server's competing write
|
||||
# from t=13 gets ignored here and whether ours reaches the server.
|
||||
await _at(13.5)
|
||||
var item := _item()
|
||||
if item:
|
||||
item.position = Vector3(1, 1, 1)
|
||||
_say("client set pos=(1,1,1) while owning NetXform")
|
||||
|
||||
await _at(15.0)
|
||||
_report("Q4 client owns NetXform (expect pos=1,1,1 — NOT -9; health=123 — server state still arrives)")
|
||||
|
||||
await _at(20.0)
|
||||
_report("Q4b server reclaimed (expect pos=-3,-3,-3)")
|
||||
|
||||
_say("client done")
|
||||
_finish()
|
||||
|
||||
|
||||
func _report(what: String) -> void:
|
||||
var item := _item()
|
||||
if not item:
|
||||
_say("%s -> NO NODE (item absent on client)" % what)
|
||||
return
|
||||
_say("%s -> pos=%s health=%d label=%s private=%d" % [
|
||||
what, item.position, item.health, item.label,
|
||||
item._private_should_not_replicate,
|
||||
])
|
||||
|
||||
|
||||
# --- logging ---------------------------------------------------------------
|
||||
|
||||
func _open_log() -> void:
|
||||
var who := "server" if _is_server else "client"
|
||||
var dir := ProjectSettings.globalize_path("res://logs")
|
||||
DirAccess.make_dir_recursive_absolute(dir)
|
||||
_log = FileAccess.open(dir.path_join("spike_%s.log" % who), FileAccess.WRITE)
|
||||
|
||||
|
||||
func _say(s: String) -> void:
|
||||
var line := "[%s t=%5.1f] %s" % [
|
||||
"SERVER" if _is_server else "CLIENT",
|
||||
(Time.get_ticks_msec() - _start_ms) / 1000.0, s,
|
||||
]
|
||||
print(line)
|
||||
if _log:
|
||||
_log.store_line(line)
|
||||
_log.flush()
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if _log:
|
||||
_log.flush()
|
||||
get_tree().quit()
|
||||
@@ -0,0 +1 @@
|
||||
uid://bh6fdnthnelfa
|
||||
@@ -0,0 +1,11 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://bspike0world0"]
|
||||
|
||||
[ext_resource type="Script" path="res://test/spike/spike_world.gd" id="1_spikew"]
|
||||
|
||||
[node name="SpikeWorld" type="Node3D"]
|
||||
script = ExtResource("1_spikew")
|
||||
|
||||
[node name="Content" type="Node3D" parent="."]
|
||||
|
||||
[node name="Spawner" type="MultiplayerSpawner" parent="."]
|
||||
spawn_path = NodePath("../Content")
|
||||
Reference in New Issue
Block a user