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)