This commit is contained in:
algodoogle
2026-07-25 19:26:43 +01:00
parent f7024db98d
commit 596d777fae
32 changed files with 832 additions and 257 deletions
+74
View File
@@ -0,0 +1,74 @@
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
func _ready() -> void:
_pickable = get_parent() as XRToolsPickable
if not _pickable:
push_error("NetPickable must be a child of an XRToolsPickable")
return
_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:
net_held_by = value
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.
func apply_held_state() -> void:
if not _pickable:
return
if not NetworkManager.is_online() or is_multiplayer_authority():
# We own this item's simulation (offline, loose+server, or currently
# holding it): leave physics alone, XRToolsPickable manages the rest.
return
# Someone else owns it: stop simulating locally, just follow the sync.
if _pickable.is_picked_up():
_pickable.drop()
_pickable.freeze = true
_pickable.freeze_mode = RigidBody3D.FREEZE_MODE_KINEMATIC
_pickable.collision_mask = 0
_pickable.enabled = (net_held_by == 0)
## 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):
return
NetworkManager.request_item_authority.rpc_id(1, _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():
return
NetworkManager.release_item_authority.rpc_id(
1, _pickable.get_path(), _pickable.linear_velocity, _pickable.angular_velocity
)
+1
View File
@@ -0,0 +1 @@
uid://bwd0pe2udb5xo
+99 -13
View File
@@ -24,9 +24,15 @@ signal connection_failed()
var _world: Node = null
var _players_spawner: MultiplayerSpawner = null
var _items_spawner: MultiplayerSpawner = null
var _content_root: Node = null
var _log_file: FileAccess
## Reason the session ended, shown by the menu on the 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.
var last_status := ""
func _ready() -> void:
_open_log()
@@ -80,26 +86,43 @@ func _go_offline() -> void:
if multiplayer.multiplayer_peer:
multiplayer.multiplayer_peer.close()
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
unregister_world()
# --- Item spawning ---------------------------------------------------------
## Spawn a networked item. Server-only when online (replicates to all peers via
## the ItemsSpawner); works directly when offline. Returns the new node on the
## machine that owns spawning, else null.
func spawn_item(scene_path: String, xform: Transform3D) -> Node:
## 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}
var data := {"scene": scene_path, "xform": xform, "name": node_name, "props": props}
if is_online() and _items_spawner:
return _items_spawner.spawn(data)
# Offline: instantiate directly under the world.
# 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 _world and inst:
_world.add_child(inst)
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 owns_world() and is_instance_valid(node):
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:
@@ -110,9 +133,32 @@ func _spawn_item_from_data(data: Variant) -> Node:
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)
# --- Item grab-authority transfer -----------------------------------------
## A client (or host) requests authority over an item it just grabbed. Runs on
@@ -123,10 +169,17 @@ func request_item_authority(item_path: NodePath) -> void:
if not is_server():
return
var sender := multiplayer.get_remote_sender_id()
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.
force_release_item.rpc_id(sender, item_path)
return
# 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)
var item := get_node_or_null(item_path)
if item:
_release_from_snap_zones(item)
@@ -147,13 +200,14 @@ func release_item_authority(item_path: NodePath, lin: Vector3, ang: Vector3) ->
_try_snap_into_station(item)
# All station snap zones in the world (nodes in the "station" group).
# 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"):
var zone = station.get_node_or_null("XRToolsSnapZone")
if zone:
zones.append(zone)
for child in station.get_children():
if child is XRToolsSnapZone:
zones.append(child)
return zones
@@ -190,6 +244,15 @@ func _set_item_authority(item_path: NodePath, peer: int) -> void:
np.apply_held_state()
## Server tells a specific client that its optimistic grab was rejected (the
## item was already legitimately held by someone else). The client drops it.
@rpc("authority", "reliable")
func force_release_item(item_path: NodePath) -> void:
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()
@@ -223,16 +286,37 @@ func submit_work(station_path: NodePath, amount: float) -> void:
station.add_work(multiplayer.get_remote_sender_id(), amount)
## Called by main.gd once the world scene is ready, passing its spawners.
## 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:
_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)])
## Called when the world scene goes away (disconnect, leaving the session) so
## the autoload doesn't hold stale/freed references across a scene reload.
func unregister_world() -> void:
_world = null
_content_root = null
_players_spawner = null
_items_spawner = 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
## 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.
@@ -286,11 +370,13 @@ func _on_connected_to_server() -> void:
func _on_connection_failed() -> void:
log_line("connection_failed")
_go_offline()
last_status = "Could not connect"
connection_failed.emit()
func _on_server_disconnected() -> void:
log_line("server_disconnected")
_go_offline()
last_status = "Host disconnected"
session_ended.emit()
+81
View File
@@ -0,0 +1,81 @@
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.
##
## Positions below are transcribed verbatim from the previous baked layout in
## Scenes/multiPlayer.tscn so the starting world is unchanged.
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,
}},
]
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
static func _item(scene: String, name: String, pos: Vector3) -> Dictionary:
return {"scene": scene, "name": name, "xform": Transform3D(Basis(), pos), "props": {}}
+1
View File
@@ -0,0 +1 @@
uid://donvkica3drtx