multyplayer

This commit is contained in:
algodoogle
2026-07-16 18:36:49 +01:00
parent 5e0b58a984
commit 1869dbf054
28 changed files with 974 additions and 60 deletions
+5
View File
@@ -23,6 +23,11 @@ func _ready() -> void:
func _on_body_entered(body: Node3D) -> void:
# Container fill mutates world state (reparent/freeze); run only on the
# world owner. Item transforms replicate to clients via each item's
# MultiplayerSynchronizer (global_transform), so contents follow the plate.
if not NetworkManager.owns_world():
return
if not body.is_in_group(target_group):
return
+12
View File
@@ -7,6 +7,12 @@
[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" path="res://Prefabs/net_pickable.gd" id="7_net"]
[sub_resource type="SceneReplicationConfig" id="Repl_item"]
properties/0/path = NodePath(".:global_transform")
properties/0/spawn = true
properties/0/replication_mode = 1
[sub_resource type="CylinderShape3D" id="CylinderShape3D_kek77"]
height = 0.0635376
@@ -61,6 +67,12 @@ hand_pose = SubResource("Resource_2hcq0")
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.16517048, 0.042291984, -0.08604182)
hand_pose = SubResource("Resource_dlngj")
[node name="NetPickable" type="Node" parent="."]
script = ExtResource("7_net")
[node name="MultiplayerSynchronizer" type="MultiplayerSynchronizer" parent="."]
replication_config = SubResource("Repl_item")
[node name="Container" parent="." unique_id=377793422 node_paths=PackedStringArray("meal_positions", "side_positions") instance=ExtResource("1_s5yhh")]
target_group = "platalbe_item"
meal_positions = [NodePath("MealPositions/Slot1")]
+12
View File
@@ -6,6 +6,12 @@
[ext_resource type="Script" uid="uid://dvobm6vcfnqe8" path="res://addons/godot-xr-tools/hands/poses/hand_pose_settings.gd" id="4_mgacb"]
[ext_resource type="PackedScene" uid="uid://ctw7nbntd5pcj" path="res://addons/godot-xr-tools/objects/grab_points/grab_point_hand_right.tscn" id="5_vw7i6"]
[ext_resource type="Animation" uid="uid://d1xnpyc08njjx" path="res://addons/godot-xr-tools/hands/animations/right/Grip 4.res" id="6_cp3eg"]
[ext_resource type="Script" path="res://Prefabs/net_pickable.gd" id="7_net"]
[sub_resource type="SceneReplicationConfig" id="Repl_item"]
properties/0/path = NodePath(".:global_transform")
properties/0/spawn = true
properties/0/replication_mode = 1
[sub_resource type="CylinderShape3D" id="CylinderShape3D_gkni8"]
custom_solver_bias = 0.1
@@ -46,3 +52,9 @@ hand_pose = SubResource("Resource_lc22d")
[node name="GrabPointHandRight" parent="." index="3" unique_id=514404634 instance=ExtResource("5_vw7i6")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.06531982, 0.042291984, -0.08604182)
hand_pose = SubResource("Resource_qyiot")
[node name="NetPickable" type="Node" parent="." index="4"]
script = ExtResource("7_net")
[node name="MultiplayerSynchronizer" type="MultiplayerSynchronizer" parent="." index="5"]
replication_config = SubResource("Repl_item")
+12
View File
@@ -11,6 +11,12 @@
[ext_resource type="PackedScene" uid="uid://7earnjgdmwgx" path="res://Prefabs/cookable_item.tscn" id="10_65ccv"]
[ext_resource type="Script" uid="uid://coidnv8b2yvxr" path="res://Prefabs/combine_recipe.gd" id="11_recipe"]
[ext_resource type="PackedScene" uid="uid://bfj80jh13t6e5" path="res://Prefabs/food_item.tscn" id="11_vjw41"]
[ext_resource type="Script" path="res://Prefabs/net_pickable.gd" id="12_net"]
[sub_resource type="SceneReplicationConfig" id="Repl_item"]
properties/0/path = NodePath(".:global_transform")
properties/0/spawn = true
properties/0/replication_mode = 1
[sub_resource type="BoxShape3D" id="BoxShape3D_m1xbq"]
size = Vector3(0.1, 0.1, 0.1)
@@ -63,3 +69,9 @@ recipes = Array[ExtResource("11_recipe")]([SubResource("Resource_i8ixk")])
[node name="FoodItem" parent="." index="6" unique_id=63948206 instance=ExtResource("11_vjw41")]
id = "cube"
type = 1
[node name="NetPickable" type="Node" parent="." index="7"]
script = ExtResource("12_net")
[node name="MultiplayerSynchronizer" type="MultiplayerSynchronizer" parent="." index="8"]
replication_config = SubResource("Repl_item")
+12
View File
@@ -9,6 +9,12 @@
[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://7earnjgdmwgx" path="res://Prefabs/cookable_item.tscn" id="9_hwyyd"]
[ext_resource type="PackedScene" uid="uid://cfc4ho67u4r5e" path="res://Items/cooked_burger.tscn" id="9_r8jvx"]
[ext_resource type="Script" path="res://Prefabs/net_pickable.gd" id="12_net"]
[sub_resource type="SceneReplicationConfig" id="Repl_item"]
properties/0/path = NodePath(".:global_transform")
properties/0/spawn = true
properties/0/replication_mode = 1
[sub_resource type="CylinderShape3D" id="CylinderShape3D_fco8w"]
height = 0.0338974
@@ -49,3 +55,9 @@ hand_pose = SubResource("Resource_qyiot")
[node name="CookableItem" parent="." index="4" unique_id=280820828 instance=ExtResource("9_hwyyd")]
turns_into = ExtResource("9_r8jvx")
[node name="NetPickable" type="Node" parent="." index="5"]
script = ExtResource("12_net")
[node name="MultiplayerSynchronizer" type="MultiplayerSynchronizer" parent="." index="6"]
replication_config = SubResource("Repl_item")
+48
View File
@@ -0,0 +1,48 @@
extends Control
## The 2D UI rendered inside the world-space menu (XRToolsViewport2DIn3D).
## Lets the player type an IP on a numeric keypad and pick Host / Join / Solo.
## Pure UI: it emits intents; main.gd performs the networking.
signal host_pressed()
signal join_pressed(ip: String)
signal solo_pressed()
var _ip := "127.0.0.1"
@onready var _ip_label: Label = %IPLabel
@onready var _status: Label = %Status
@onready var _keypad: GridContainer = %Keypad
@onready var _host_btn: Button = %HostButton
@onready var _join_btn: Button = %JoinButton
@onready var _solo_btn: Button = %SoloButton
func _ready() -> void:
for child in _keypad.get_children():
if child is Button:
child.pressed.connect(_on_key.bind(child.text))
_host_btn.pressed.connect(func(): host_pressed.emit())
_join_btn.pressed.connect(func(): join_pressed.emit(_ip))
_solo_btn.pressed.connect(func(): solo_pressed.emit())
_refresh()
func _on_key(key: String) -> void:
match key:
"DEL":
_ip = _ip.substr(0, max(0, _ip.length() - 1))
_:
if _ip.length() < 21:
_ip += key
_refresh()
func _refresh() -> void:
_ip_label.text = _ip if not _ip.is_empty() else "_"
## Called by main.gd (via network_menu) to show connection feedback.
func set_status(text: String) -> void:
if _status:
_status.text = text
+1
View File
@@ -0,0 +1 @@
uid://dvivyrspjcjgm
+172
View File
@@ -0,0 +1,172 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://Net/join_panel.gd" id="1_panel"]
[node name="JoinPanel" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
script = ExtResource("1_panel")
[node name="Background" type="ColorRect" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
color = Color(0.09, 0.1, 0.13, 1)
[node name="Margin" type="MarginContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
theme_override_constants/margin_left = 24
theme_override_constants/margin_top = 16
theme_override_constants/margin_right = 24
theme_override_constants/margin_bottom = 16
[node name="VBox" type="VBoxContainer" parent="Margin"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="Title" type="Label" parent="Margin/VBox"]
layout_mode = 2
theme_override_font_sizes/font_size = 26
text = "VRyHungry — Multiplayer"
horizontal_alignment = 1
[node name="IPLabel" type="Label" parent="Margin/VBox"]
unique_name_in_owner = true
layout_mode = 2
theme_override_font_sizes/font_size = 34
text = "127.0.0.1"
horizontal_alignment = 1
[node name="Status" type="Label" parent="Margin/VBox"]
unique_name_in_owner = true
layout_mode = 2
theme_override_colors/font_color = Color(0.8, 0.8, 0.5, 1)
theme_override_font_sizes/font_size = 18
text = "Enter host IP, then Join"
horizontal_alignment = 1
[node name="Keypad" type="GridContainer" parent="Margin/VBox"]
unique_name_in_owner = true
layout_mode = 2
size_flags_vertical = 3
theme_override_constants/h_separation = 8
theme_override_constants/v_separation = 8
columns = 3
[node name="B1" type="Button" parent="Margin/VBox/Keypad"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_font_sizes/font_size = 26
text = "1"
[node name="B2" type="Button" parent="Margin/VBox/Keypad"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_font_sizes/font_size = 26
text = "2"
[node name="B3" type="Button" parent="Margin/VBox/Keypad"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_font_sizes/font_size = 26
text = "3"
[node name="B4" type="Button" parent="Margin/VBox/Keypad"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_font_sizes/font_size = 26
text = "4"
[node name="B5" type="Button" parent="Margin/VBox/Keypad"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_font_sizes/font_size = 26
text = "5"
[node name="B6" type="Button" parent="Margin/VBox/Keypad"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_font_sizes/font_size = 26
text = "6"
[node name="B7" type="Button" parent="Margin/VBox/Keypad"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_font_sizes/font_size = 26
text = "7"
[node name="B8" type="Button" parent="Margin/VBox/Keypad"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_font_sizes/font_size = 26
text = "8"
[node name="B9" type="Button" parent="Margin/VBox/Keypad"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_font_sizes/font_size = 26
text = "9"
[node name="BDot" type="Button" parent="Margin/VBox/Keypad"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_font_sizes/font_size = 26
text = "."
[node name="B0" type="Button" parent="Margin/VBox/Keypad"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_font_sizes/font_size = 26
text = "0"
[node name="BDel" type="Button" parent="Margin/VBox/Keypad"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_font_sizes/font_size = 22
text = "DEL"
[node name="Buttons" type="HBoxContainer" parent="Margin/VBox"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="HostButton" type="Button" parent="Margin/VBox/Buttons"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
custom_minimum_size = Vector2(0, 56)
theme_override_font_sizes/font_size = 24
text = "HOST"
[node name="JoinButton" type="Button" parent="Margin/VBox/Buttons"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
custom_minimum_size = Vector2(0, 56)
theme_override_font_sizes/font_size = 24
text = "JOIN"
[node name="SoloButton" type="Button" parent="Margin/VBox/Buttons"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
custom_minimum_size = Vector2(0, 56)
theme_override_font_sizes/font_size = 24
text = "SOLO"
+269
View File
@@ -0,0 +1,269 @@
extends Node
## Client-server session manager for VRyHungry (listen-server model).
##
## 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.
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.
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 _log_file: FileAccess
func _ready() -> void:
_open_log()
multiplayer.peer_connected.connect(_on_peer_connected)
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
multiplayer.connected_to_server.connect(_on_connected_to_server)
multiplayer.connection_failed.connect(_on_connection_failed)
multiplayer.server_disconnected.connect(_on_server_disconnected)
# --- Public API ------------------------------------------------------------
## Start hosting. The host is peer 1 and also plays (listen server).
func host(port: int = DEFAULT_PORT) -> Error:
var peer := ENetMultiplayerPeer.new()
var err := peer.create_server(port, MAX_CLIENTS)
if err != OK:
log_line("HOST failed to create_server on port %d: %s" % [port, error_string(err)])
return err
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())
return OK
## Join an existing host.
func join(address: String = "127.0.0.1", port: int = DEFAULT_PORT) -> Error:
var peer := ENetMultiplayerPeer.new()
var err := peer.create_client(address, port)
if err != OK:
log_line("JOIN failed to create_client %s:%d: %s" % [address, port, error_string(err)])
return err
multiplayer.multiplayer_peer = peer
log_line("JOIN connecting to %s:%d ..." % [address, port])
return OK
## Leave the session and tear down transport.
func leave() -> void:
if multiplayer.multiplayer_peer:
multiplayer.multiplayer_peer.close()
multiplayer.multiplayer_peer = null
log_line("Session ended")
session_ended.emit()
# --- 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:
if is_online() and not is_server():
return null
var data := {"scene": scene_path, "xform": xform}
if is_online() and _items_spawner:
return _items_spawner.spawn(data)
# Offline: instantiate directly under the world.
var inst := _spawn_item_from_data(data)
if _world and inst:
_world.add_child(inst)
return inst
# 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"]
return inst
# --- Item grab-authority transfer -----------------------------------------
## A client requests authority over an item it just grabbed. Runs on the server.
@rpc("any_peer", "reliable")
func request_item_authority(item_path: NodePath) -> void:
if not is_server():
return
var sender := multiplayer.get_remote_sender_id()
_set_item_authority.rpc(item_path, sender)
## A client releases an item, forwarding its throw velocity so the server can
## resume simulating it. Runs on the server.
@rpc("any_peer", "reliable")
func release_item_authority(item_path: NodePath, lin: Vector3, ang: Vector3) -> void:
if not is_server():
return
_set_item_authority.rpc(item_path, 1)
var item := get_node_or_null(item_path)
if item is RigidBody3D:
item.freeze = false
item.linear_velocity = lin
item.angular_velocity = ang
# 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
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
func is_server() -> bool:
return is_online() and multiplayer.is_server()
## True only when a real ENet session is active. Godot installs a default
## OfflineMultiplayerPeer, so a non-null peer alone does not mean "online".
func is_online() -> bool:
var p := multiplayer.multiplayer_peer
return p != null and not (p is OfflineMultiplayerPeer)
## 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.
func owns_world() -> bool:
return not is_online() or is_server()
# --- Station work-progress seam -------------------------------------------
## 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"):
station.add_work(multiplayer.get_remote_sender_id(), amount)
## Called by main.gd once the world scene is ready, passing its spawners.
func register_world(world: Node, players_spawner: MultiplayerSpawner, items_spawner: MultiplayerSpawner) -> void:
_world = world
_players_spawner = players_spawner
_items_spawner = items_spawner
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 by main.gd after it has registered the world and connected its
## player_joined/left listeners. Kicks off any command-line driven session so
## that session signals never fire before the world is listening.
func world_ready() -> void:
_handle_cmdline()
# --- 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)
func _on_peer_disconnected(peer_id: int) -> void:
log_line("peer_disconnected: %d" % peer_id)
if is_server():
_on_player_absent(peer_id)
func _on_connected_to_server() -> void:
log_line("connected_to_server (my id=%d)" % multiplayer.get_unique_id())
session_started.emit(false)
func _on_connection_failed() -> void:
log_line("connection_failed")
multiplayer.multiplayer_peer = null
connection_failed.emit()
func _on_server_disconnected() -> void:
log_line("server_disconnected")
multiplayer.multiplayer_peer = null
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 -----------------------------------
func _handle_cmdline() -> void:
var args := OS.get_cmdline_user_args()
if args.has("--server"):
log_line("cmdline: --server")
host()
elif args.has("--join"):
var idx := args.find("--join")
var addr := "127.0.0.1"
if idx + 1 < args.size():
addr = args[idx + 1]
log_line("cmdline: --join %s" % addr)
join(addr)
# --- Logging ---------------------------------------------------------------
func _open_log() -> void:
var dir := OS.get_environment("TEMP")
if dir.is_empty():
dir = OS.get_environment("TMPDIR")
if dir.is_empty():
dir = "user://"
var path := dir.path_join("vryhungry_net_%d.log" % OS.get_process_id())
_log_file = FileAccess.open(path, FileAccess.WRITE)
log_line("=== NetworkManager log (pid %d) ===" % OS.get_process_id())
func log_line(s: String) -> void:
var line := "[NET %d] %s" % [multiplayer.get_unique_id() if multiplayer.multiplayer_peer else 0, s]
print(line)
if _log_file:
_log_file.store_line(line)
_log_file.flush()
+1
View File
@@ -0,0 +1 @@
uid://deefoory0vqmm
+37
View File
@@ -0,0 +1,37 @@
extends Node3D
## World-space main menu: hosts the 2D join_panel on an XRToolsViewport2DIn3D so
## the player can point at it with a controller laser. Re-emits the panel's
## intents; main.gd drives the actual networking.
signal host_pressed()
signal join_pressed(ip: String)
signal solo_pressed()
@onready var _vp: XRToolsViewport2DIn3D = $Viewport2Din3D
func _ready() -> void:
# The viewport instantiates its 2D scene during its own _ready; connect once
# that has happened.
_connect_scene.call_deferred()
func _connect_scene() -> void:
_vp.connect_scene_signal("host_pressed", func(): host_pressed.emit())
_vp.connect_scene_signal("join_pressed", func(ip): join_pressed.emit(ip))
_vp.connect_scene_signal("solo_pressed", func(): solo_pressed.emit())
func show_menu() -> void:
visible = true
func hide_menu() -> void:
visible = false
func set_status(text: String) -> void:
var scene := _vp.get_scene_instance()
if scene and scene.has_method("set_status"):
scene.set_status(text)
+1
View File
@@ -0,0 +1 @@
uid://1o2nca75po1f
+13
View File
@@ -0,0 +1,13 @@
[gd_scene load_steps=4 format=3]
[ext_resource type="Script" path="res://Net/network_menu.gd" id="1_menu"]
[ext_resource type="PackedScene" uid="uid://clujaf3u776a3" path="res://addons/godot-xr-tools/objects/viewport_2d_in_3d.tscn" id="2_vp"]
[ext_resource type="PackedScene" path="res://Net/join_panel.tscn" id="3_panel"]
[node name="NetworkMenu" type="Node3D"]
script = ExtResource("1_menu")
[node name="Viewport2Din3D" parent="." instance=ExtResource("2_vp")]
screen_size = Vector2(0.6, 0.4)
scene = ExtResource("3_panel")
viewport_size = Vector2(600, 400)
+48
View File
@@ -0,0 +1,48 @@
[gd_scene load_steps=7 format=3 uid="uid://cnetplayer0001"]
[ext_resource type="Script" path="res://Player/player.gd" id="1_player"]
[sub_resource type="SceneReplicationConfig" id="Repl_player"]
properties/0/path = NodePath(".:head_xform")
properties/0/spawn = true
properties/0/replication_mode = 1
properties/1/path = NodePath(".:left_xform")
properties/1/spawn = true
properties/1/replication_mode = 1
properties/2/path = NodePath(".:right_xform")
properties/2/spawn = true
properties/2/replication_mode = 1
properties/3/path = NodePath(".:net_heartbeat")
properties/3/spawn = true
properties/3/replication_mode = 1
[sub_resource type="StandardMaterial3D" id="Mat_head"]
albedo_color = Color(0.85, 0.7, 0.55, 1)
[sub_resource type="BoxMesh" id="Mesh_head"]
material = SubResource("Mat_head")
size = Vector3(0.18, 0.24, 0.16)
[sub_resource type="StandardMaterial3D" id="Mat_hand"]
albedo_color = Color(0.2, 0.5, 0.9, 1)
[sub_resource type="BoxMesh" id="Mesh_hand"]
material = SubResource("Mat_hand")
size = Vector3(0.09, 0.05, 0.12)
[node name="Player" type="Node3D"]
script = ExtResource("1_player")
[node name="Avatar" type="Node3D" parent="."]
[node name="Head" type="MeshInstance3D" parent="Avatar"]
mesh = SubResource("Mesh_head")
[node name="LeftHand" type="MeshInstance3D" parent="Avatar"]
mesh = SubResource("Mesh_hand")
[node name="RightHand" type="MeshInstance3D" parent="Avatar"]
mesh = SubResource("Mesh_hand")
[node name="MultiplayerSynchronizer" type="MultiplayerSynchronizer" parent="."]
replication_config = SubResource("Repl_player")
+80
View File
@@ -0,0 +1,80 @@
extends Node3D
## One instance per connected peer. The owning peer is the multiplayer
## authority. The local (authority) player builds the full XR rig from
## XROrigin.tscn and feeds its head/hand poses into synced properties; remote
## players show a lightweight avatar driven by those synced poses.
const RIG_SCENE := preload("res://XROrigin.tscn")
# Synced avatar pose. Authority writes these from the XR trackers each frame;
# remotes read them onto the Avatar. net_heartbeat is a monotonic counter used
# to confirm replication is live (also handy for debugging).
@export var head_xform: Transform3D = Transform3D.IDENTITY
@export var left_xform: Transform3D = Transform3D.IDENTITY
@export var right_xform: Transform3D = Transform3D.IDENTITY
@export var net_heartbeat: int = 0
var _rig: Node3D
var _camera: Node3D
var _left: Node3D
var _right: Node3D
var _is_local := false
@onready var _avatar: Node3D = $Avatar
@onready var _avatar_head: Node3D = $Avatar/Head
@onready var _avatar_left: Node3D = $Avatar/LeftHand
@onready var _avatar_right: Node3D = $Avatar/RightHand
func _enter_tree() -> void:
# The spawner names each player by its owning peer id.
set_multiplayer_authority(str(name).to_int())
# (replication config is authored in Player.tscn's MultiplayerSynchronizer)
func _ready() -> void:
_is_local = is_multiplayer_authority()
if _is_local:
_setup_local()
else:
_setup_remote()
if Engine.has_singleton("NetworkManager") or get_node_or_null("/root/NetworkManager"):
NetworkManager.log_line("Player %s ready (authority=%d local=%s)" % [name, get_multiplayer_authority(), str(_is_local)])
func _setup_local() -> void:
# We see through the headset; hide our own avatar mesh.
_avatar.visible = false
_rig = RIG_SCENE.instantiate()
add_child(_rig)
_camera = _rig.get_node_or_null("XRCamera3D")
_left = _rig.get_node_or_null("XRControllerLeftHand")
_right = _rig.get_node_or_null("XRControllerRightHand")
_enable_xr()
func _enable_xr() -> void:
var xr := XRServer.find_interface("OpenXR")
if xr and xr.is_initialized():
get_viewport().use_xr = true
DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_DISABLED)
func _setup_remote() -> void:
_avatar.visible = true
func _process(_delta: float) -> void:
if _is_local:
if _camera:
head_xform = _camera.global_transform
left_xform = _left.global_transform
right_xform = _right.global_transform
net_heartbeat += 1
else:
_avatar_head.global_transform = head_xform
_avatar_left.global_transform = left_xform
_avatar_right.global_transform = right_xform
+1
View File
@@ -0,0 +1 @@
uid://ndrfa2xle033
+10 -5
View File
@@ -54,6 +54,12 @@ func _on_body_entered(body: Node3D) -> void:
if _combining or body == _item:
return
# Combining is a world-state change: only the server/offline host runs it.
# (Clients never have the base item snapped, so their zone won't monitor, but
# guard explicitly in case of overlap during authority handoff.)
if not NetworkManager.owns_world():
return
var other := _find_combinable(body)
if not other:
return
@@ -75,11 +81,9 @@ func _combine(other_body: Node3D, result: PackedScene) -> void:
_combining = false
return
# Spawn the result at the base item's location, in world space.
# Spawn the result (networked) at the base item's location, in world space.
var base_transform := _item.global_transform
var result_instance: Node3D = result.instantiate()
_item.get_tree().current_scene.add_child(result_instance)
result_instance.global_transform = base_transform
var result_instance := NetworkManager.spawn_item(result.resource_path, base_transform)
# Free the base item and consume the incoming item.
snap_zone.drop_object()
@@ -90,7 +94,8 @@ func _combine(other_body: Node3D, result: PackedScene) -> void:
other_body.queue_free()
# Snap the result into the now-empty zone.
snap_zone.pick_up_object(result_instance)
if result_instance:
snap_zone.pick_up_object(result_instance)
# Find a CombinableItem among the direct children of [param node].
+64
View File
@@ -0,0 +1,64 @@
class_name NetPickable
extends Node
## Networks an XRToolsPickable (RigidBody3D). Default authority is the server,
## which simulates free items and syncs their transform. Grabbing an item with a
## hand transfers authority to the grabbing client (its local kinematic grab
## driver then drives the synced transform); releasing returns authority and the
## throw velocity to the server. Non-authority peers keep the body frozen and
## follow the synced transform, so only one machine ever simulates an item.
@onready var _item: XRToolsPickable = get_parent() as XRToolsPickable
var net_held_by := 0 # peer id currently holding (0 = free); kept consistent via NetworkManager
var _was_authority := true
func _ready() -> void:
if not _item:
push_error("NetPickable must be a child of an XRToolsPickable")
return
_item.grabbed.connect(_on_grabbed)
_item.released.connect(_on_released)
_was_authority = _item.is_multiplayer_authority()
_apply_authority_state()
func _physics_process(_delta: float) -> void:
if not NetworkManager.is_online():
return
var mine := _item.is_multiplayer_authority()
if mine != _was_authority:
_was_authority = mine
_apply_authority_state()
# Configure physics for whether we own this item or merely mirror it.
func _apply_authority_state() -> void:
if not _item:
return
if _item.is_multiplayer_authority():
# We own physics: simulate the item whenever it is not held.
if not _item.is_picked_up():
_item.freeze = false
else:
# Someone else owns it: follow the synced transform kinematically.
_item.freeze = true
_item.freeze_mode = RigidBody3D.FREEZE_MODE_KINEMATIC
func _on_grabbed(_pickable: Node3D, by: Node3D) -> void:
if not NetworkManager.is_online():
return
# Only hand grabs transfer authority; snap-zone grabs are server-driven.
if not (by is XRToolsFunctionPickup):
return
NetworkManager.request_item_authority.rpc_id(1, _item.get_path())
func _on_released(_pickable: Node3D, by: Node3D) -> void:
if not NetworkManager.is_online():
return
if not (by is XRToolsFunctionPickup):
return
NetworkManager.release_item_authority.rpc_id(1, _item.get_path(), _item.linear_velocity, _item.angular_velocity)
+1
View File
@@ -0,0 +1 @@
uid://6n784li8n5ck
+8
View File
@@ -16,6 +16,11 @@ material = SubResource("StandardMaterial3D_m5xe2")
[sub_resource type="SphereShape3D" id="SphereShape3D_vlqg6"]
radius = 0.3
[sub_resource type="SceneReplicationConfig" id="Repl_hob"]
properties/0/path = NodePath(".:progress")
properties/0/spawn = true
properties/0/replication_mode = 1
[node name="Hob" type="StaticBody3D" unique_id=1687971542 groups=["station"]]
script = ExtResource("1_7jc4g")
@@ -40,3 +45,6 @@ metadata/_custom_type_script = "uid://cquqe4f1m1sw6"
[node name="CollisionShape3D2" type="CollisionShape3D" parent="XRToolsSnapZone" unique_id=370920392]
transform = Transform3D(0.5, 0, 0, 0, 0.5, 0, 0, 0, 0.5, 0, -0.000975132, 0)
shape = SubResource("SphereShape3D_vlqg6")
[node name="ProgressSync" type="MultiplayerSynchronizer" parent="."]
replication_config = SubResource("Repl_hob")
+32 -29
View File
@@ -1,59 +1,62 @@
extends StaticBody3D
## Cooking station. The cook timer and item conversion run only on the machine
## that owns world logic (server or offline host); `progress` is replicated to
## clients for UI via a MultiplayerSynchronizer. This is the reference
## implementation of the reusable "station work-progress" pattern: a server-owned
## progress value advanced by an input source (here, a timer), which converts the
## held item once it reaches the threshold and spawns the result over the network.
@export var cook_speed = 1
@export var cook_speed := 1.0
@onready var snap_zone: XRToolsSnapZone = $XRToolsSnapZone
var item = null
var progress = 0
var progress: float = 0.0
# Called when the node enters the scene tree for the first time.
func _ready() -> void:
if snap_zone.initial_object:
item = snap_zone.initial_object
print("Hob has item: ", item)
else:
print("Hob is empty")
func convert_item(cookable: CookableItem) -> void:
print("Hob converting ", cookable)
var old_pickable = snap_zone.picked_up_object
if not old_pickable:
push_warning("Hob finished cooking item, but snap zone is missing its reference")
return
print("picked_up_object ", snap_zone.picked_up_object)
var original_transform = old_pickable.global_transform
var new_scene_instance = cookable.turns_into.instantiate()
# Add the new item to the hob's parent (so it lives in the main world space)
get_parent().add_child(new_scene_instance)
new_scene_instance.global_transform = original_transform
var original_transform: Transform3D = old_pickable.global_transform
snap_zone.drop_object()
print("old_pickable ", old_pickable)
# Spawn the result over the network (server) or locally (offline).
var result := NetworkManager.spawn_item(cookable.turns_into.resource_path, original_transform)
# Remove the cooked item (despawn replicates for spawner-managed items).
snap_zone.drop_object()
old_pickable.queue_free()
snap_zone.pick_up_object(new_scene_instance)
# Called every frame. 'delta' is the elapsed time since the previous frame.
func _process(delta: float) -> void:
# Find the CookableItem in held object
# Snap the result into the now-empty zone.
if result:
snap_zone.pick_up_object(result)
func _process(delta: float) -> void:
# Only the world owner runs cooking logic; clients receive `progress` + the
# converted item via replication.
if not NetworkManager.owns_world():
return
# Find the CookableItem in the held object.
var cookable = null
if snap_zone.picked_up_object:
var matches = snap_zone.picked_up_object.get_children().filter(func(c): return c is CookableItem)
if matches.size() > 0:
cookable = matches[0]
# Cook it if exists, then convert item after cooking
# Advance progress and convert once cooked.
if cookable:
progress += cook_speed * delta
print("Cooking ", progress)
if progress >= cookable.cooking_time:
progress = 0
progress = 0.0
convert_item(cookable)
else:
progress = 0
progress = 0.0
+6 -6
View File
@@ -13,10 +13,10 @@ func _ready() -> void:
# Called every frame. 'delta' is the elapsed time since the previous frame.
func _process(delta: float) -> void:
# Only the world owner dispenses, so every client doesn't spawn its own copy.
if not NetworkManager.owns_world():
return
if not snap_zone.picked_up_object:
var new_item = item_scene.instantiate()
# Add the new item to the hob's parent (so it lives in the main world space)
get_parent().add_child(new_item)
new_item.global_transform = snap_zone.transform
snap_zone.pick_up_object(new_item)
var new_item := NetworkManager.spawn_item(item_scene.resource_path, snap_zone.global_transform)
if new_item:
snap_zone.pick_up_object(new_item)
+1 -1
View File
@@ -53,4 +53,4 @@
Plate[`Hamburger`] accepts `Chips` => Plate[`Hamburger`, `Chips`]
Plate[`Hamburger`, `Chips`] accepts `Cheese` => Plate[`Hamburger(Cheese=true)`, `Chips`]
The logic for combining has to be expanded for plates.
- Plates have a property `is_dirty` they get after a customer clears the plate, and is disabled after being processed in `Sink`
- Plates have a property `is_dirty` they get after a customer clears the plate, and is disabled after being processed in `Sink`
+9
View File
@@ -7,6 +7,7 @@
[ext_resource type="PackedScene" uid="uid://diyu06cw06syv" path="res://addons/godot-xr-tools/player/player_body.tscn" id="4_6xr3x"]
[ext_resource type="PackedScene" uid="uid://b6bk2pj8vbj28" path="res://addons/godot-xr-tools/functions/movement_turn.tscn" id="4_rd8py"]
[ext_resource type="Script" uid="uid://ck4yn3hxuobj7" path="res://addons/godot-xr-tools/player/player_body.gd" id="5_rd8py"]
[ext_resource type="PackedScene" uid="uid://cqhw276realc" path="res://addons/godot-xr-tools/functions/function_pointer.tscn" id="6_ptr"]
[node name="XROrigin3D" type="XROrigin3D" unique_id=2055526621]
@@ -25,6 +26,10 @@ strafe = true
[node name="FunctionPickup" parent="XRControllerLeftHand" unique_id=2133415351 instance=ExtResource("3_rd8py")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.009340696, -0.0074635968, 0.024033919)
[node name="FunctionPointer" parent="XRControllerLeftHand" instance=ExtResource("6_ptr")]
show_laser = 2
show_target = true
[node name="XRControllerRightHand" type="XRController3D" parent="." unique_id=202756852]
tracker = &"right_hand"
@@ -39,6 +44,10 @@ smooth_turn_speed = 3.5
[node name="FunctionPickup" parent="XRControllerRightHand" unique_id=1876210450 instance=ExtResource("3_rd8py")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.0074635968, 0.024033919)
[node name="FunctionPointer" parent="XRControllerRightHand" instance=ExtResource("6_ptr")]
show_laser = 2
show_target = true
[node name="PlayerBody" type="CharacterBody3D" parent="." unique_id=1444632058 groups=["player_body"] instance=ExtResource("4_6xr3x")]
process_priority = -100
process_physics_priority = -100
+1 -1
View File
@@ -126,7 +126,7 @@
{
"uuid": "d9cebd68-792e-4d01-a916-7df5f128b4a8",
"title": "Implement Multiplayer Synchronization (Network Architecture)",
"description": "Establish the client-server (or P2P) networking model for the game. Focus on syncing critical game state: physics objects (FoodItems), player actions (gestures/work progress), and station states (Chopping progress).",
"description": "Establish the client-server networking model for the game. Focus on syncing critical game state: physics objects (FoodItems), player actions (gestures/work progress), and station states (Chopping progress).",
"category": "bf4ec62e-526e-4027-876f-0b22bd17f79a",
"steps": [
{
+105 -8
View File
@@ -1,12 +1,109 @@
extends Node3D
var xr_interface :XRInterface
## World manager. Owns the multiplayer spawners and the world-space main menu,
## and turns NetworkManager's session events into spawned/despawned players and
## seeded items.
##
## Startup:
## - Command-line session (--server/--join): skip the menu, behave headlessly.
## - Otherwise: embody a local player (so they can point) and show the menu; the
## Host/Join/Solo buttons start the actual session.
func _ready():
xr_interface = XRServer.find_interface("OpenXR")
if xr_interface and xr_interface.is_initialized():
print("OpenXR started :)")
DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_DISABLED)
get_viewport().use_xr = true
const PLAYER_SCENE := preload("res://Player/Player.tscn")
const CUBE_SCENE := "res://Items/PickupCube.tscn"
@onready var _players_spawner: MultiplayerSpawner = $PlayersSpawner
@onready var _items_spawner: MultiplayerSpawner = $ItemsSpawner
@onready var _menu: Node3D = $NetworkMenu
func _ready() -> void:
NetworkManager.register_world(self, _players_spawner, _items_spawner)
NetworkManager.player_joined.connect(_on_player_joined)
NetworkManager.player_left.connect(_on_player_left)
NetworkManager.session_started.connect(_on_session_started)
NetworkManager.connection_failed.connect(_on_connection_failed)
_menu.host_pressed.connect(_on_menu_host)
_menu.join_pressed.connect(_on_menu_join)
_menu.solo_pressed.connect(_on_menu_solo)
# Start any command-line driven session now that we are listening.
NetworkManager.world_ready()
if NetworkManager.is_online():
# Command-line session: no menu.
_menu.hide_menu()
if _owns_spawning():
_spawn_starting_items()
else:
print("OpenXR failed to start. Check your headset...")
# Show the menu; embody a local player so they can point at it.
_spawn_player(1)
_menu.show_menu()
# --- Menu button handlers --------------------------------------------------
func _on_menu_host() -> void:
_menu.hide_menu()
NetworkManager.host() # keeps the already-spawned local player (id 1)
_spawn_starting_items()
func _on_menu_solo() -> void:
_menu.hide_menu()
_spawn_starting_items()
func _on_menu_join(ip: String) -> void:
_menu.set_status("Connecting to %s ..." % ip)
# Our placeholder id "1" belongs to the host once we join; drop it and let
# the server spawn our real player.
_despawn_player(1)
NetworkManager.join(ip)
func _on_session_started(is_server: bool) -> void:
if not is_server:
# Connected to a host as a client.
_menu.hide_menu()
func _on_connection_failed() -> void:
_menu.set_status("Connection failed — check the IP")
_spawn_player(1) # restore embodiment
_menu.show_menu()
# --- Player / item spawning ------------------------------------------------
func _on_player_joined(peer_id: int) -> void:
if NetworkManager.is_server():
_spawn_player(peer_id)
func _on_player_left(peer_id: int) -> void:
if NetworkManager.is_server():
_despawn_player(peer_id)
func _spawn_player(peer_id: int) -> void:
if has_node(str(peer_id)):
return
var p := PLAYER_SCENE.instantiate()
p.name = str(peer_id)
add_child(p)
func _despawn_player(peer_id: int) -> void:
var n := get_node_or_null(str(peer_id))
if n:
n.queue_free()
func _owns_spawning() -> bool:
return not NetworkManager.is_online() or NetworkManager.is_server()
func _spawn_starting_items() -> void:
NetworkManager.spawn_item(CUBE_SCENE, Transform3D(Basis(), Vector3(-0.705, 1.576, -1.032)))
NetworkManager.spawn_item(CUBE_SCENE, Transform3D(Basis(), Vector3(0.283, 1.561, -1.066)))
+10 -10
View File
@@ -1,14 +1,13 @@
[gd_scene format=3 uid="uid://clw8ai6kngqxb"]
[ext_resource type="Script" uid="uid://b4ldkd3ngs0vp" path="res://main.gd" id="1_0xm2m"]
[ext_resource type="PackedScene" uid="uid://j5s5wus7tuhb" path="res://XROrigin.tscn" id="2_h2yge"]
[ext_resource type="Material" uid="uid://cmia50cfqxxo4" path="res://Textures/dev_material_3d.tres" id="3_272bh"]
[ext_resource type="PackedScene" uid="uid://e4i6o5oriecx" path="res://Items/PickupCube.tscn" id="3_h2yge"]
[ext_resource type="PackedScene" uid="uid://j7caslh27nor" path="res://Stations/Hob.tscn" id="3_lquwl"]
[ext_resource type="PackedScene" uid="uid://efaec6ymgabo" path="res://Stations/Counter.tscn" id="4_7mycd"]
[ext_resource type="Sky" uid="uid://c1abtpwhdm2d5" path="res://Textures/sky/NightSkyHDRI009_16K.tres" id="6_7mycd"]
[ext_resource type="PackedScene" uid="uid://dpot5qie20vf6" path="res://Items/burger.tscn" id="8_5vw27"]
[ext_resource type="PackedScene" uid="uid://bp3v1jl8pctro" path="res://Containers/plate.tscn" id="9_kek77"]
[ext_resource type="PackedScene" path="res://Net/network_menu.tscn" id="10_menu"]
[sub_resource type="BoxShape3D" id="BoxShape3D_vlqg6"]
size = Vector3(5, 0.1, 5)
@@ -30,8 +29,15 @@ sdfgi_enabled = true
[node name="Main" type="Node3D" unique_id=1312265607]
script = ExtResource("1_0xm2m")
[node name="XROrigin3D" parent="." unique_id=2055526621 instance=ExtResource("2_h2yge")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.8820953, 0)
[node name="PlayersSpawner" type="MultiplayerSpawner" parent="."]
spawn_path = NodePath("..")
_spawnable_scenes = PackedStringArray("res://Player/Player.tscn")
[node name="ItemsSpawner" type="MultiplayerSpawner" parent="."]
spawn_path = NodePath("..")
[node name="NetworkMenu" parent="." instance=ExtResource("10_menu")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.5, -0.8)
[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)
@@ -52,12 +58,6 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.29336345, 0.8981018, -1.484
[node name="Counter" parent="." unique_id=1487893288 instance=ExtResource("4_7mycd")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.73186195, 0.8981018, -1.484)
[node name="PickableObject" parent="." unique_id=1675596942 instance=ExtResource("3_h2yge")]
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, 0, -2.6077032e-08, 1.4901161e-08, 1, -0.70505303, 1.5761534, -1.0317149)
[node name="PickableObject2" parent="." unique_id=713087634 instance=ExtResource("3_h2yge")]
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, 0, -2.6077032e-08, 1.4901161e-08, 1, 0.28315204, 1.5607953, -1.0657519)
[node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=1177077250]
environment = SubResource("Environment_bvwq1")
+3
View File
@@ -19,9 +19,12 @@ config/icon="res://icon.svg"
XRToolsUserSettings="*uid://bqgb8i74tm0t"
XRToolsRumbleManager="*uid://by853dk86g1qw"
NetworkManager="*res://Net/network_manager.gd"
[display]
window/size/viewport_width=1920
window/size/viewport_height=1080
window/stretch/mode="canvas_items"
window/stretch/aspect="expand"