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
+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)