Set up a 4 player Nakama party system

This commit is contained in:
2026-04-16 23:18:25 -05:00
parent 80a27e4d4e
commit 59560d3006
19 changed files with 1137 additions and 64 deletions

7
.gitignore vendored
View File

@@ -1,3 +1,10 @@
# Godot 4+ specific ignores
.godot/
/android/
# Nakama server runtime state
server/data/.cookie
# Claude
.claude/
CLAUDE.md

View File

@@ -49,8 +49,8 @@ class User extends RefCounted:
func _init(p_presence) -> void:
presence = p_presence
signal match_join_error (exception)
signal match_joined ()
signal match_join_error(exception)
signal match_joined()
func _set_readonly(_value) -> void:
pass
@@ -314,7 +314,12 @@ func _on_nakama_socket_received_match_state(data) -> void:
return
# Ensure that any meta messages are coming from the host!
if data.presence.session_id != _id_map[1]:
# MERCURY PATCH: when 4+ peers join via matchmaker simultaneously, the host's
# broadcast ASSIGN_PEER_IDs can race ahead of the whispered CLAIM_HOST. Auto-claim
# the sender as host on first meta message if CLAIM_HOST hasn't landed yet.
if not _id_map.has(1):
_map_id_to_session(1, data.presence.session_id)
elif data.presence.session_id != _id_map[1]:
push_error("Received meta message from user %s who isn't the host: %s" % [data.presence.session_id, content])
return
@@ -357,7 +362,7 @@ func _on_multiplayer_peer_packet_generated(peer_id: int, buffer: PackedByteArray
if not _id_map.has(peer_id):
push_error("Attempting to send RPC to unknown peer id: %s" % peer_id)
return
target_presences = [ _users[_id_map[peer_id]].presence ]
target_presences = [_users[_id_map[peer_id]].presence]
_nakama_socket.send_match_state_raw_async(_match_id, rpc_op_code, buffer, target_presences)
else:
push_error("RPC sent while the NakamaMultiplayerBridge isn't connected!")

View File

@@ -18,4 +18,3 @@ The generated code is designed to be supported Godot Engine `3.1+`.
### Limitations
The code generator has __only__ been checked against the Swagger specification generated for Nakama server. YMMV.

View File

@@ -1,61 +1,45 @@
[gd_scene load_steps=15 format=3 uid="uid://biwfc6l2lnu11"]
[gd_scene load_steps=7 format=3 uid="uid://biwfc6l2lnu11"]
[ext_resource type="Script" uid="uid://b83vmbx10mlqe" path="res://ship_controller.gd" id="1_0xm2m"]
[ext_resource type="PackedScene" uid="uid://iogkdi8dv0nv" path="res://projectile.tscn" id="2_1bvp3"]
[ext_resource type="Script" uid="uid://inqwtwfui2tr" path="res://ship_camera.gd" id="2_h2yge"]
[ext_resource type="Script" uid="uid://bq9r3m7xnf4t" path="res://src/components/medium_detector.gd" id="4_md"]
[ext_resource type="PackedScene" uid="uid://c7h1s4t8rbna" path="res://scenes/environment/water_body.tscn" id="5_wb"]
[ext_resource type="Resource" uid="uid://dv83nqxw14kl" path="res://src/resources/vacuum_profile.tres" id="6_vp"]
[ext_resource type="Resource" uid="uid://cmw9ptf2n8r1" path="res://src/resources/water_profile.tres" id="7_wp"]
[ext_resource type="Script" uid="uid://bn4h2pe7qr6s" path="res://src/environment/underwater_effect.gd" id="8_ue"]
[ext_resource type="Script" uid="uid://inqwtwfui2tr" path="res://ship_camera.gd" id="1_cam"]
[ext_resource type="PackedScene" uid="uid://c7h1s4t8rbna" path="res://scenes/environment/water_body.tscn" id="2_wb"]
[ext_resource type="Script" uid="uid://bn4h2pe7qr6s" path="res://src/environment/underwater_effect.gd" id="3_ue"]
[ext_resource type="Script" path="res://src/raid/raid_scene.gd" id="4_raid"]
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_h2yge"]
albedo_color = Color(0.8117647, 0.3254902, 0, 1)
[sub_resource type="ProceduralSkyMaterial" id="ProceduralSkyMaterial_sky"]
[sub_resource type="BoxMesh" id="BoxMesh_1bvp3"]
material = SubResource("StandardMaterial3D_h2yge")
[sub_resource type="Sky" id="Sky_sky"]
sky_material = SubResource("ProceduralSkyMaterial_sky")
[sub_resource type="BoxShape3D" id="BoxShape3D_lquwl"]
[sub_resource type="ProceduralSkyMaterial" id="ProceduralSkyMaterial_h2yge"]
[sub_resource type="Sky" id="Sky_1bvp3"]
sky_material = SubResource("ProceduralSkyMaterial_h2yge")
[sub_resource type="Environment" id="Environment_0xm2m"]
[sub_resource type="Environment" id="Environment_env"]
background_mode = 2
background_energy_multiplier = 1.93
sky = SubResource("Sky_1bvp3")
sky = SubResource("Sky_sky")
ambient_light_source = 3
[node name="Main" type="Node3D"]
script = ExtResource("4_raid")
spawn_points = [NodePath("Spawn1"), NodePath("Spawn2"), NodePath("Spawn3"), NodePath("Spawn4")]
[node name="Ship" type="CharacterBody3D" parent="." node_paths=PackedStringArray("ship_model", "muzzle")]
script = ExtResource("1_0xm2m")
projectile_scene = ExtResource("2_1bvp3")
ship_model = NodePath("ShipModel")
muzzle = NodePath("Muzzle")
vacuum_profile = ExtResource("6_vp")
water_profile = ExtResource("7_wp")
[node name="Spawn1" type="Marker3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0)
[node name="ShipModel" type="MeshInstance3D" parent="Ship"]
mesh = SubResource("BoxMesh_1bvp3")
[node name="Spawn2" type="Marker3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 6, 0, 0)
[node name="HoverRay" type="RayCast3D" parent="Ship"]
target_position = Vector3(0, -5, 0)
[node name="Spawn3" type="Marker3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 12, 0, 0)
[node name="Muzzle" type="Marker3D" parent="Ship"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, -1.1504534)
[node name="Spawn4" type="Marker3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 18, 0, 0)
[node name="CollisionShape3D" type="CollisionShape3D" parent="Ship"]
shape = SubResource("BoxShape3D_lquwl")
[node name="Ships" type="Node3D" parent="."]
[node name="MediumDetector" type="Node" parent="Ship"]
script = ExtResource("4_md")
[node name="MultiplayerSpawner" type="MultiplayerSpawner" parent="."]
_spawnable_scenes = PackedStringArray("res://scenes/ship/player_ship.tscn")
spawn_path = NodePath("../Ships")
[node name="CameraRig" type="Node3D" parent="." node_paths=PackedStringArray("target")]
script = ExtResource("2_h2yge")
target = NodePath("../Ship")
[node name="CameraRig" type="Node3D" parent="."]
script = ExtResource("1_cam")
follow_distance = 8.57
position_smoothing = 18.56
rotation_smoothing = 14.5
@@ -93,12 +77,12 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -16.63602, 0)
size = Vector3(500, 1, 500)
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
environment = SubResource("Environment_0xm2m")
environment = SubResource("Environment_env")
[node name="WaterBody" parent="." instance=ExtResource("5_wb")]
[node name="WaterBody" parent="." instance=ExtResource("2_wb")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 10, -80)
[node name="UnderwaterEffect" type="Node" parent="." node_paths=PackedStringArray("world_environment", "medium_detector")]
script = ExtResource("8_ue")
script = ExtResource("3_ue")
world_environment = NodePath("../WorldEnvironment")
medium_detector = NodePath("../Ship/MediumDetector")
medium_detector = NodePath("")

View File

@@ -22,10 +22,13 @@ config/icon="res://icon.svg"
[autoload]
Nakama="*uid://dpwa6ykdkxalb"
GameState="*res://src/autoload/game_state.gd"
NetworkManager="*res://src/autoload/network_manager.gd"
[file_customization]
folder_colors={
"res://addons/": "gray",
"res://assets/": "red",
"res://scenes/": "green",
"res://src/": "blue"

View File

@@ -0,0 +1,51 @@
[gd_scene load_steps=10 format=3 uid="uid://bship00mercury"]
[ext_resource type="Script" uid="uid://b83vmbx10mlqe" path="res://ship_controller.gd" id="1_sc"]
[ext_resource type="PackedScene" uid="uid://iogkdi8dv0nv" path="res://projectile.tscn" id="2_pr"]
[ext_resource type="Script" uid="uid://bq9r3m7xnf4t" path="res://src/components/medium_detector.gd" id="3_md"]
[ext_resource type="Resource" uid="uid://dv83nqxw14kl" path="res://src/resources/vacuum_profile.tres" id="4_vp"]
[ext_resource type="Resource" uid="uid://cmw9ptf2n8r1" path="res://src/resources/water_profile.tres" id="5_wp"]
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_body"]
albedo_color = Color(0.8117647, 0.3254902, 0, 1)
[sub_resource type="BoxMesh" id="BoxMesh_body"]
material = SubResource("StandardMaterial3D_body")
[sub_resource type="BoxShape3D" id="BoxShape3D_body"]
[sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_sync"]
properties/0/path = NodePath(".:_net_position")
properties/0/spawn = true
properties/0/replication_mode = 1
properties/1/path = NodePath(".:_net_rotation")
properties/1/spawn = true
properties/1/replication_mode = 1
[node name="PlayerShip" type="CharacterBody3D" node_paths=PackedStringArray("ship_model", "muzzle")]
script = ExtResource("1_sc")
projectile_scene = ExtResource("2_pr")
ship_model = NodePath("ShipModel")
muzzle = NodePath("Muzzle")
vacuum_profile = ExtResource("4_vp")
water_profile = ExtResource("5_wp")
[node name="ShipModel" type="MeshInstance3D" parent="."]
mesh = SubResource("BoxMesh_body")
[node name="HoverRay" type="RayCast3D" parent="."]
target_position = Vector3(0, -5, 0)
[node name="Muzzle" type="Marker3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, -1.1504534)
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
shape = SubResource("BoxShape3D_body")
[node name="MediumDetector" type="Node" parent="."]
script = ExtResource("3_md")
[node name="MultiplayerSynchronizer" type="MultiplayerSynchronizer" parent="."]
replication_interval = 0.05
delta_interval = 0.05
replication_config = SubResource("SceneReplicationConfig_sync")

128
scenes/ui/lobby.tscn Normal file
View File

@@ -0,0 +1,128 @@
[gd_scene format=3 uid="uid://lobbyscene01"]
[ext_resource type="Script" uid="uid://c2e2hr52twq1g" path="res://src/ui/lobby.gd" id="1_lobby"]
[node name="Lobby" type="Control" unique_id=806926705]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_lobby")
[node name="MarginContainer" type="MarginContainer" parent="." unique_id=1444189391]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/margin_left = 40
theme_override_constants/margin_top = 40
theme_override_constants/margin_right = 40
theme_override_constants/margin_bottom = 40
[node name="VBox" type="VBoxContainer" parent="MarginContainer" unique_id=64128373]
layout_mode = 2
theme_override_constants/separation = 12
[node name="Title" type="Label" parent="MarginContainer/VBox" unique_id=1378626941]
layout_mode = 2
text = "MERCURY"
horizontal_alignment = 1
[node name="StatusLabel" type="Label" parent="MarginContainer/VBox" unique_id=1694908981]
unique_name_in_owner = true
layout_mode = 2
text = "Disconnected."
horizontal_alignment = 1
[node name="Sep1" type="HSeparator" parent="MarginContainer/VBox" unique_id=344221306]
layout_mode = 2
[node name="UsernameInput" type="LineEdit" parent="MarginContainer/VBox" unique_id=1287803797]
unique_name_in_owner = true
layout_mode = 2
placeholder_text = "Username"
max_length = 32
[node name="ConnectButton" type="Button" parent="MarginContainer/VBox" unique_id=1510757468]
unique_name_in_owner = true
layout_mode = 2
text = "Connect"
[node name="PartyPanel" type="VBoxContainer" parent="MarginContainer/VBox" unique_id=86865304]
unique_name_in_owner = true
visible = false
layout_mode = 2
theme_override_constants/separation = 8
[node name="PartyHeader" type="Label" parent="MarginContainer/VBox/PartyPanel" unique_id=733845403]
layout_mode = 2
text = "— Party —"
horizontal_alignment = 1
[node name="PartyIdLabel" type="Label" parent="MarginContainer/VBox/PartyPanel" unique_id=972674843]
unique_name_in_owner = true
layout_mode = 2
[node name="CreatePartyButton" type="Button" parent="MarginContainer/VBox/PartyPanel" unique_id=2073392331]
unique_name_in_owner = true
layout_mode = 2
text = "Create Party"
[node name="JoinPartyInput" type="LineEdit" parent="MarginContainer/VBox/PartyPanel" unique_id=797778305]
unique_name_in_owner = true
layout_mode = 2
placeholder_text = "Party ID"
[node name="JoinPartyButton" type="Button" parent="MarginContainer/VBox/PartyPanel" unique_id=1492288876]
unique_name_in_owner = true
layout_mode = 2
text = "Join Party"
[node name="LeavePartyButton" type="Button" parent="MarginContainer/VBox/PartyPanel" unique_id=2045553293]
unique_name_in_owner = true
visible = false
layout_mode = 2
text = "Leave Party"
[node name="MemberList" type="ItemList" parent="MarginContainer/VBox/PartyPanel" unique_id=463044316]
unique_name_in_owner = true
visible = false
custom_minimum_size = Vector2(0, 100)
layout_mode = 2
[node name="MatchPanel" type="VBoxContainer" parent="MarginContainer/VBox" unique_id=2012319053]
unique_name_in_owner = true
visible = false
layout_mode = 2
theme_override_constants/separation = 8
[node name="MatchHeader" type="Label" parent="MarginContainer/VBox/MatchPanel" unique_id=905684598]
layout_mode = 2
text = "— Matchmaking —"
horizontal_alignment = 1
[node name="MatchStatusLabel" type="Label" parent="MarginContainer/VBox/MatchPanel" unique_id=316580224]
unique_name_in_owner = true
layout_mode = 2
horizontal_alignment = 1
[node name="QuickMatchButton" type="Button" parent="MarginContainer/VBox/MatchPanel" unique_id=118879329]
unique_name_in_owner = true
layout_mode = 2
text = "Quick Match"
[node name="CancelMatchButton" type="Button" parent="MarginContainer/VBox/MatchPanel" unique_id=300414327]
unique_name_in_owner = true
visible = false
layout_mode = 2
text = "Cancel"
[node name="StartRaidButton" type="Button" parent="MarginContainer/VBox/MatchPanel" unique_id=859649703]
unique_name_in_owner = true
visible = false
layout_mode = 2
text = "Start Raid"

View File

@@ -5,4 +5,8 @@ database:
logger:
level: DEBUG
session:
token_expiry_sec: 7200
token_expiry_sec: 7200
matchmaker:
interval_sec: 1
max_intervals: 30
max_tickets: 3

View File

@@ -64,20 +64,33 @@ var _visual_extra_pitch: float = 0.0
var _medium_blend: float = 0.0
var _target_medium_blend: float = 0.0
## Networked transform — authority writes these each physics tick;
## non-authority peers interpolate toward them to smooth out the 20Hz sync.
var _net_position: Vector3 = Vector3.ZERO
var _net_rotation: Vector3 = Vector3.ZERO
const _NET_LERP_SPEED: float = 20.0
@onready var _medium_detector: MediumDetector = $MediumDetector
func _ready() -> void:
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
_medium_detector.medium_changed.connect(_on_medium_changed)
_boost_energy = boost_max
_current_yaw = rotation.y
_current_pitch = rotation.x
_target_yaw = _current_yaw
_target_pitch = _current_pitch
_medium_detector.medium_changed.connect(_on_medium_changed)
_net_position = global_position
_net_rotation = rotation
if not is_multiplayer_authority():
set_process_unhandled_input(false)
return
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
func _unhandled_input(event: InputEvent) -> void:
if not is_multiplayer_authority():
return
if event is InputEventMouseMotion:
_target_yaw -= event.relative.x * mouse_sensitivity
_target_pitch -= event.relative.y * mouse_sensitivity
@@ -93,12 +106,25 @@ func _unhandled_input(event: InputEvent) -> void:
func _physics_process(delta: float) -> void:
_update_medium_blend(delta)
if not is_multiplayer_authority():
_interpolate_to_net_state(delta)
return
_handle_turning(delta)
_handle_boost(delta)
_handle_movement(delta)
_apply_velocity(delta)
_handle_visual_tilt(delta)
_handle_shooting(delta)
_net_position = global_position
_net_rotation = rotation
func _interpolate_to_net_state(delta: float) -> void:
var alpha: float = clampf(_NET_LERP_SPEED * delta, 0.0, 1.0)
global_position = global_position.lerp(_net_position, alpha)
rotation.x = lerp_angle(rotation.x, _net_rotation.x, alpha)
rotation.y = lerp_angle(rotation.y, _net_rotation.y, alpha)
rotation.z = lerp_angle(rotation.z, _net_rotation.z, alpha)
# ── Medium blending ───────────────────────────────────────────────────────────
@@ -263,18 +289,20 @@ func _handle_shooting(delta: float) -> void:
if Input.is_action_pressed("shoot") and _fire_cooldown <= 0.0 and projectile_scene:
_fire_cooldown = 1.0 / fire_rate
var bullet := projectile_scene.instantiate() as Node3D
get_tree().current_scene.add_child(bullet)
var spawn_pos: Vector3 = muzzle.global_position if muzzle else global_position + (-transform.basis.z * 2.0)
var spawn_dir: Vector3 = -global_transform.basis.z
_fire_projectile.rpc(spawn_pos, spawn_dir)
if muzzle:
bullet.global_position = muzzle.global_position
bullet.global_transform.basis = muzzle.global_transform.basis
else:
bullet.global_position = global_position + (-transform.basis.z * 2.0)
bullet.global_transform.basis = global_transform.basis
if bullet.has_method("set_direction"):
bullet.set_direction(-global_transform.basis.z)
@rpc("authority", "call_local", "reliable")
func _fire_projectile(spawn_pos: Vector3, spawn_dir: Vector3) -> void:
if not projectile_scene:
return
var bullet := projectile_scene.instantiate() as Node3D
get_tree().current_scene.add_child(bullet)
bullet.global_position = spawn_pos
if bullet.has_method("set_direction"):
bullet.set_direction(spawn_dir)
# ── Public API ────────────────────────────────────────────────────────────────

View File

@@ -0,0 +1,70 @@
## GameState — High-level session state machine and local player tracking.
## Authority: Local (each client tracks its own view of the session).
class_name GameStateClass
extends Node
signal state_changed(new_state: Phase)
signal party_members_updated(members: Array)
enum Phase {
OFFLINE,
CONNECTED,
IN_PARTY,
MATCHMAKING,
IN_MATCH,
IN_RAID,
}
var current_phase: Phase = Phase.OFFLINE:
set(value):
if current_phase == value:
return
current_phase = value
state_changed.emit(value)
var local_user_id: String = ""
var local_username: String = ""
var local_session_id: String = ""
var party_id: String = ""
var party_leader_session_id: String = ""
var party_members: Array = []:
set(value):
party_members = value
party_members_updated.emit(value)
var match_id: String = ""
var match_peers: Dictionary = {}
var is_party_leader: bool:
get: return local_session_id == party_leader_session_id
func reset() -> void:
current_phase = Phase.OFFLINE
local_user_id = ""
local_username = ""
local_session_id = ""
party_id = ""
party_leader_session_id = ""
party_members = []
match_id = ""
match_peers.clear()
func reset_party() -> void:
party_id = ""
party_leader_session_id = ""
party_members = []
if current_phase == Phase.IN_PARTY:
current_phase = Phase.CONNECTED
func reset_match() -> void:
match_id = ""
match_peers.clear()
if current_phase == Phase.IN_MATCH or current_phase == Phase.IN_RAID:
if party_id != "":
current_phase = Phase.IN_PARTY
else:
current_phase = Phase.CONNECTED

View File

@@ -0,0 +1 @@
uid://cdsu8g1nvddwo

View File

@@ -0,0 +1,371 @@
## NetworkManager — Owns Nakama client/socket/session and the NakamaMultiplayerBridge.
## All transport logic lives here. Game code uses signals and standard Godot multiplayer.
## Authority: N/A (autoload singleton).
class_name NetworkManagerClass
extends Node
# --- Signals ---
signal connection_state_changed(connected: bool)
signal authentication_failed(message: String)
signal party_created(party_id: String)
signal party_joined(party_id: String)
signal party_left()
signal party_presence_changed()
signal matchmaking_started()
signal matchmaking_cancelled()
signal match_joined()
signal match_left()
signal match_join_failed(message: String)
signal peer_joined(peer_id: int)
signal peer_left(peer_id: int)
signal raid_starting()
# --- State ---
var client: NakamaClient
var socket: NakamaSocket
var session: NakamaSession
var bridge: NakamaMultiplayerBridge
var _matchmaker_ticket: String = ""
var _is_matchmaking: bool = false
# --- Lifecycle ---
func _ready() -> void:
client = Nakama.create_client(
NakamaConfig.SERVER_KEY,
NakamaConfig.HOST,
NakamaConfig.PORT,
NakamaConfig.SCHEME,
)
multiplayer.peer_connected.connect(_on_peer_connected)
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
func _on_peer_connected(id: int) -> void:
GameState.match_peers[id] = true
peer_joined.emit(id)
func _on_peer_disconnected(id: int) -> void:
GameState.match_peers.erase(id)
peer_left.emit(id)
func connect_to_server(username: String) -> Error:
if session != null and session.valid and not session.expired:
return OK
var device_id: String = "%s_%s" % [OS.get_unique_id(), username]
if OS.get_unique_id() == "":
device_id = _generate_device_id()
session = await client.authenticate_device_async(device_id, username, true)
if session.is_exception():
var msg: String = "Authentication failed: %s" % session.get_exception().message
authentication_failed.emit(msg)
session = null
return ERR_CANT_CONNECT
GameState.local_user_id = session.user_id
GameState.local_username = session.username
socket = Nakama.create_socket_from(client)
await socket.connect_async(session)
GameState.local_session_id = session.user_id
_bind_socket_signals()
GameState.current_phase = GameState.Phase.CONNECTED
connection_state_changed.emit(true)
return OK
func disconnect_from_server() -> void:
var socket_alive: bool = socket != null and socket.is_connected_to_host()
if _is_matchmaking and socket_alive:
cancel_matchmaking()
if GameState.party_id != "" and socket_alive:
await leave_party()
if bridge != null:
if socket_alive:
bridge.leave()
bridge = null
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
if socket != null:
if socket_alive:
socket.close()
socket = null
session = null
GameState.reset()
connection_state_changed.emit(false)
# --- Party ---
func create_party() -> Error:
if socket == null:
return ERR_UNCONFIGURED
var party: NakamaRTAPI.Party = await socket.create_party_async(
true, NakamaConfig.MAX_PARTY_SIZE
)
if party.is_exception():
push_error("Failed to create party: %s" % party.get_exception().message)
return ERR_CANT_CREATE
_apply_party(party)
party_created.emit(GameState.party_id)
return OK
func join_party(party_id: String) -> Error:
if socket == null:
return ERR_UNCONFIGURED
var result: NakamaAsyncResult = await socket.join_party_async(party_id)
if result.is_exception():
push_error("Failed to join party: %s" % result.get_exception().message)
return ERR_CANT_CONNECT
return OK
func leave_party() -> Error:
if socket == null or GameState.party_id == "":
return ERR_UNCONFIGURED
var result: NakamaAsyncResult = await socket.leave_party_async(GameState.party_id)
if result.is_exception():
push_error("Failed to leave party: %s" % result.get_exception().message)
return ERR_CANT_CONNECT
GameState.reset_party()
party_left.emit()
return OK
# --- Matchmaking ---
func start_matchmaking() -> Error:
if socket == null:
return ERR_UNCONFIGURED
var string_props: Dictionary = {"game_mode": "raid"}
var numeric_props: Dictionary = {}
# In a party, only the leader submits the ticket. Other members receive
# the ticket via received_party_matchmaker_ticket and start their bridge there.
if GameState.party_id != "" and not GameState.is_party_leader:
_is_matchmaking = true
GameState.current_phase = GameState.Phase.MATCHMAKING
matchmaking_started.emit()
return OK
if GameState.party_id != "" and GameState.is_party_leader:
var party_ticket: NakamaRTAPI.PartyMatchmakerTicket = await socket.add_matchmaker_party_async(
GameState.party_id,
NakamaConfig.MATCHMAKER_QUERY,
NakamaConfig.MATCHMAKER_MIN,
NakamaConfig.MATCHMAKER_MAX,
string_props,
numeric_props,
)
if party_ticket.is_exception():
push_error("Party matchmaking failed: %s" % party_ticket.get_exception().message)
return ERR_CANT_CONNECT
_matchmaker_ticket = party_ticket.ticket
_arm_bridge_with_ticket(party_ticket)
else:
var solo_ticket: NakamaRTAPI.MatchmakerTicket = await socket.add_matchmaker_async(
NakamaConfig.MATCHMAKER_QUERY,
NakamaConfig.MATCHMAKER_MIN,
NakamaConfig.MATCHMAKER_MAX,
string_props,
numeric_props,
)
if solo_ticket.is_exception():
push_error("Solo matchmaking failed: %s" % solo_ticket.get_exception().message)
return ERR_CANT_CONNECT
_matchmaker_ticket = solo_ticket.ticket
_arm_bridge_with_ticket(solo_ticket)
_is_matchmaking = true
GameState.current_phase = GameState.Phase.MATCHMAKING
matchmaking_started.emit()
return OK
func _arm_bridge_with_ticket(ticket) -> void:
if bridge != null:
return
bridge = NakamaMultiplayerBridge.new(socket)
bridge.match_joined.connect(_on_bridge_match_joined)
bridge.match_join_error.connect(_on_bridge_match_join_error)
multiplayer.multiplayer_peer = bridge.multiplayer_peer
bridge.start_matchmaking(ticket)
func cancel_matchmaking() -> Error:
if socket == null or _matchmaker_ticket == "":
return ERR_UNCONFIGURED
await socket.remove_matchmaker_async(_matchmaker_ticket)
_matchmaker_ticket = ""
_is_matchmaking = false
if GameState.party_id != "":
GameState.current_phase = GameState.Phase.IN_PARTY
else:
GameState.current_phase = GameState.Phase.CONNECTED
matchmaking_cancelled.emit()
return OK
# --- Match / Bridge ---
func leave_match() -> void:
if bridge != null:
bridge.leave()
bridge = null
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
GameState.reset_match()
match_left.emit()
func is_host() -> bool:
return multiplayer.is_server()
# --- Raid Launch ---
func start_raid() -> void:
print("[NetworkManager] start_raid called. is_host=%s peer_id=%d" % [is_host(), multiplayer.get_unique_id()])
if not is_host():
return
_notify_raid_start.rpc()
@rpc("authority", "call_local", "reliable")
func _notify_raid_start() -> void:
print("[NetworkManager] _notify_raid_start received on peer_id=%d" % multiplayer.get_unique_id())
GameState.current_phase = GameState.Phase.IN_RAID
raid_starting.emit()
var err: Error = get_tree().change_scene_to_file("res://main.tscn")
if err != OK:
push_error("Failed to change scene: %s" % error_string(err))
# --- Socket Signal Handlers ---
func _bind_socket_signals() -> void:
socket.received_party.connect(_on_received_party)
socket.received_party_close.connect(_on_party_close)
socket.received_party_presence.connect(_on_party_presence)
socket.received_party_leader.connect(_on_party_leader)
socket.received_party_matchmaker_ticket.connect(_on_party_matchmaker_ticket)
socket.closed.connect(_on_socket_closed)
func _on_received_party(party: NakamaRTAPI.Party) -> void:
_apply_party(party)
party_joined.emit(GameState.party_id)
func _on_party_close(_party_close: NakamaRTAPI.PartyClose) -> void:
GameState.reset_party()
party_left.emit()
func _on_party_presence(event: NakamaRTAPI.PartyPresenceEvent) -> void:
for presence: NakamaRTAPI.UserPresence in event.joins:
if not _has_party_member(presence.session_id):
GameState.party_members.append({
"user_id": presence.user_id,
"username": presence.username,
"session_id": presence.session_id,
})
for presence: NakamaRTAPI.UserPresence in event.leaves:
GameState.party_members = GameState.party_members.filter(
func(m: Dictionary) -> bool: return m.session_id != presence.session_id
)
GameState.party_members = GameState.party_members
party_presence_changed.emit()
func _on_party_leader(leader: NakamaRTAPI.PartyLeader) -> void:
GameState.party_leader_session_id = leader.presence.session_id
party_presence_changed.emit()
func _on_party_matchmaker_ticket(ticket: NakamaRTAPI.PartyMatchmakerTicket) -> void:
# Non-leader party members receive the ticket here and arm their bridge with it
# so the bridge can join the match when received_matchmaker_matched fires.
if bridge != null:
return
_matchmaker_ticket = ticket.ticket
_arm_bridge_with_ticket(ticket)
func _on_bridge_match_joined() -> void:
GameState.match_id = bridge.match_id
GameState.current_phase = GameState.Phase.IN_MATCH
match_joined.emit()
func _on_bridge_match_join_error(exception: NakamaException) -> void:
bridge = null
var msg: String = "Match join failed: %s" % exception.message
match_join_failed.emit(msg)
if GameState.party_id != "":
GameState.current_phase = GameState.Phase.IN_PARTY
else:
GameState.current_phase = GameState.Phase.CONNECTED
func _on_socket_closed() -> void:
disconnect_from_server()
# --- Helpers ---
func _apply_party(party: NakamaRTAPI.Party) -> void:
GameState.party_id = party.party_id
GameState.party_leader_session_id = party.leader.session_id
var members: Array = []
members.append({
"user_id": party.self_presence.user_id,
"username": party.self_presence.username,
"session_id": party.self_presence.session_id,
})
GameState.local_session_id = party.self_presence.session_id
for presence: NakamaRTAPI.UserPresence in party.presences:
if presence.session_id != party.self_presence.session_id:
members.append({
"user_id": presence.user_id,
"username": presence.username,
"session_id": presence.session_id,
})
GameState.party_members = members
GameState.current_phase = GameState.Phase.IN_PARTY
func _has_party_member(session_id: String) -> bool:
for member: Dictionary in GameState.party_members:
if member.session_id == session_id:
return true
return false
func _generate_device_id() -> String:
var chars: String = "abcdefghijklmnopqrstuvwxyz0123456789"
var id: String = ""
for i: int in range(32):
id += chars[randi() % chars.length()]
return id

View File

@@ -0,0 +1 @@
uid://d4bs7nl3kmj84

View File

@@ -0,0 +1,14 @@
## NakamaConfig — Server endpoint constants. Swap host/port/key for production.
## Authority: N/A (constants).
class_name NakamaConfig
extends RefCounted
const SERVER_KEY: String = "defaultkey"
const HOST: String = "127.0.0.1"
const PORT: int = 7350
const SCHEME: String = "http"
const MAX_PARTY_SIZE: int = 4
const MATCHMAKER_MIN: int = 4
const MATCHMAKER_MAX: int = 4
const MATCHMAKER_QUERY: String = "+properties.game_mode:raid"

View File

@@ -0,0 +1 @@
uid://bd7pc1t01yddk

171
src/raid/raid_scene.gd Normal file
View File

@@ -0,0 +1,171 @@
## RaidScene — Spawns one ship per peer and attaches the local camera to the
## local player's ship. Authority: server spawns; all peers replicate via
## MultiplayerSpawner. Scaffold only — gameplay/encounter logic is separate.
class_name RaidScene
extends Node3D
const PLAYER_SHIP_SCENE: PackedScene = preload("res://scenes/ship/player_ship.tscn")
@export var spawn_points: Array[NodePath] = []
@onready var _ships: Node3D = $Ships
@onready var _spawner: MultiplayerSpawner = $MultiplayerSpawner
@onready var _camera: ShipCamera = $CameraRig
## Server-only: tracks which peers have confirmed their scene is loaded.
var _ready_peers: Dictionary = {}
var _has_spawned: bool = false
## Client-only: set to true once the server has acknowledged this peer.
var _ready_acked: bool = false
func _ready() -> void:
_spawner.spawn_function = _spawn_ship
_spawner.spawned.connect(_on_ship_spawned)
_ships.child_entered_tree.connect(_on_ship_added)
if not multiplayer.has_multiplayer_peer():
# Single-player / editor preview: spawn immediately with no network.
_spawn_all()
return
multiplayer.peer_connected.connect(_on_peer_connected)
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
# Handshake: every peer (including host) reports readiness to the server.
# Retry periodically in case the server's RaidScene hasn't loaded yet when
# the first report fires (the RPC target path must exist server-side, or
# the message is dropped).
_send_ready_loop()
func _send_ready_loop() -> void:
while not _ready_acked and is_inside_tree():
_report_ready.rpc_id(1)
await get_tree().create_timer(0.2).timeout
@rpc("any_peer", "call_local", "reliable")
func _report_ready() -> void:
if not multiplayer.is_server():
return
var sender_id: int = multiplayer.get_remote_sender_id()
if sender_id == 0:
sender_id = multiplayer.get_unique_id()
var first_time: bool = not _ready_peers.has(sender_id)
_ready_peers[sender_id] = true
# Acknowledge back to the sender so they can stop retrying.
if sender_id != multiplayer.get_unique_id():
_ack_ready.rpc_id(sender_id)
else:
_ready_acked = true
if first_time:
print("[RaidScene] peer %d ready (%d/%d)" % [
sender_id, _ready_peers.size(), multiplayer.get_peers().size() + 1
])
_try_spawn_all()
@rpc("authority", "call_remote", "reliable")
func _ack_ready() -> void:
_ready_acked = true
func _try_spawn_all() -> void:
if _has_spawned:
return
var expected: int = multiplayer.get_peers().size() + 1
if _ready_peers.size() < expected:
return
_has_spawned = true
_spawn_all()
func _spawn_all() -> void:
var peers: Array[int] = []
if multiplayer.has_multiplayer_peer():
peers.append(multiplayer.get_unique_id())
for peer_id: int in multiplayer.get_peers():
peers.append(peer_id)
else:
peers.append(1)
peers.sort()
var idx: int = 0
for peer_id: int in peers:
_spawner.spawn({
"peer_id": peer_id,
"spawn_index": idx,
})
idx += 1
func _on_peer_connected(peer_id: int) -> void:
if not multiplayer.is_server() or not _has_spawned:
return
_spawner.spawn({
"peer_id": peer_id,
"spawn_index": _ships.get_child_count(),
})
func _on_peer_disconnected(peer_id: int) -> void:
_ready_peers.erase(peer_id)
var ship: Node = _ships.get_node_or_null(str(peer_id))
if ship != null:
ship.queue_free()
func _spawn_ship(data: Variant) -> Node:
var payload: Dictionary = data
var peer_id: int = int(payload.peer_id)
var spawn_index: int = int(payload.spawn_index)
var ship: Node3D = PLAYER_SHIP_SCENE.instantiate()
ship.name = str(peer_id)
ship.set_multiplayer_authority(peer_id, true)
ship.position = _spawn_position_for(spawn_index)
var sync: MultiplayerSynchronizer = ship.get_node_or_null("MultiplayerSynchronizer")
if sync != null:
sync.set_multiplayer_authority(peer_id, true)
print("[RaidScene] _spawn_ship peer_id=%d local_id=%d ship_auth=%d sync_auth=%d" % [
peer_id, multiplayer.get_unique_id(),
ship.get_multiplayer_authority(),
sync.get_multiplayer_authority() if sync != null else -1,
])
return ship
func _on_ship_spawned(ship: Node) -> void:
var sync: MultiplayerSynchronizer = ship.get_node_or_null("MultiplayerSynchronizer")
print("[RaidScene] spawned ship name=%s ship_auth=%d sync_auth=%d local_id=%d is_auth=%s" % [
ship.name, ship.get_multiplayer_authority(),
sync.get_multiplayer_authority() if sync != null else -1,
multiplayer.get_unique_id(), str(ship.is_multiplayer_authority()),
])
_try_attach_camera(ship)
func _on_ship_added(ship: Node) -> void:
# Fallback: fires whenever any ship is added to _ships, even if the
# MultiplayerSpawner.spawned signal doesn't fire on this peer for some reason.
_try_attach_camera(ship)
func _try_attach_camera(ship: Node) -> void:
if _camera.target != null:
return
if ship.name != str(multiplayer.get_unique_id()):
return
_camera.target = ship as ShipController
print("[RaidScene] Camera attached to local ship (peer %d)" % multiplayer.get_unique_id())
func _spawn_position_for(index: int) -> Vector3:
if spawn_points.size() > 0:
var marker: Marker3D = get_node_or_null(spawn_points[index % spawn_points.size()])
if marker != null:
return marker.position
return Vector3(index * 6.0, 0.0, 0.0)

View File

@@ -0,0 +1 @@
uid://dwxwf5tf35pc3

233
src/ui/lobby.gd Normal file
View File

@@ -0,0 +1,233 @@
## Lobby — UI for connect, party, matchmaking, and raid launch.
## Authority: Local (UI only).
class_name Lobby
extends Control
@onready var _status_label: Label = %StatusLabel
@onready var _username_input: LineEdit = %UsernameInput
@onready var _connect_button: Button = %ConnectButton
@onready var _party_panel: VBoxContainer = %PartyPanel
@onready var _create_party_button: Button = %CreatePartyButton
@onready var _party_id_label: Label = %PartyIdLabel
@onready var _join_party_input: LineEdit = %JoinPartyInput
@onready var _join_party_button: Button = %JoinPartyButton
@onready var _leave_party_button: Button = %LeavePartyButton
@onready var _member_list: ItemList = %MemberList
@onready var _match_panel: VBoxContainer = %MatchPanel
@onready var _quick_match_button: Button = %QuickMatchButton
@onready var _cancel_match_button: Button = %CancelMatchButton
@onready var _start_raid_button: Button = %StartRaidButton
@onready var _match_status_label: Label = %MatchStatusLabel
var _connecting: bool = false
func _ready() -> void:
_connect_button.pressed.connect(_on_connect_pressed)
_create_party_button.pressed.connect(_on_create_party_pressed)
_join_party_button.pressed.connect(_on_join_party_pressed)
_leave_party_button.pressed.connect(_on_leave_party_pressed)
_quick_match_button.pressed.connect(_on_quick_match_pressed)
_cancel_match_button.pressed.connect(_on_cancel_match_pressed)
_start_raid_button.pressed.connect(_on_start_raid_pressed)
NetworkManager.connection_state_changed.connect(_on_connection_state_changed)
NetworkManager.authentication_failed.connect(_on_auth_failed)
NetworkManager.party_created.connect(_on_party_created)
NetworkManager.party_joined.connect(_on_party_joined)
NetworkManager.party_left.connect(_on_party_left)
NetworkManager.party_presence_changed.connect(_refresh_party_ui)
NetworkManager.matchmaking_started.connect(_on_matchmaking_started)
NetworkManager.matchmaking_cancelled.connect(_on_matchmaking_cancelled)
NetworkManager.match_joined.connect(_on_match_joined)
NetworkManager.match_join_failed.connect(_on_match_join_failed)
NetworkManager.peer_joined.connect(_on_peer_joined)
NetworkManager.peer_left.connect(_on_peer_left)
GameState.state_changed.connect(_on_state_changed)
_refresh_ui()
_maybe_autoconnect_from_cli()
func _maybe_autoconnect_from_cli() -> void:
var username: String = _cli_arg("username")
if username == "":
return
_username_input.text = username
_on_connect_pressed()
func _cli_arg(key: String) -> String:
var prefix: String = "--%s=" % key
var args: PackedStringArray = OS.get_cmdline_args()
for i: int in range(args.size()):
var arg: String = args[i]
if arg.begins_with(prefix):
return arg.substr(prefix.length())
if arg == "--%s" % key and i + 1 < args.size():
return args[i + 1]
return ""
func _on_connect_pressed() -> void:
if _connecting:
return
var username: String = _username_input.text.strip_edges()
if username == "":
_status_label.text = "Enter a username."
return
_connecting = true
_connect_button.disabled = true
_status_label.text = "Connecting..."
var err: Error = await NetworkManager.connect_to_server(username)
_connecting = false
if err != OK:
_connect_button.disabled = false
func _on_connection_state_changed(connected: bool) -> void:
if connected:
_status_label.text = "Connected as %s" % GameState.local_username
else:
_status_label.text = "Disconnected."
_refresh_ui()
func _on_auth_failed(message: String) -> void:
_status_label.text = message
_connect_button.disabled = false
func _on_create_party_pressed() -> void:
_create_party_button.disabled = true
var err: Error = await NetworkManager.create_party()
if err != OK:
_create_party_button.disabled = false
func _on_join_party_pressed() -> void:
var pid: String = _join_party_input.text.strip_edges()
if pid == "":
return
_join_party_button.disabled = true
var err: Error = await NetworkManager.join_party(pid)
if err != OK:
_join_party_button.disabled = false
func _on_leave_party_pressed() -> void:
await NetworkManager.leave_party()
func _on_party_created(party_id: String) -> void:
_party_id_label.text = "Party: %s" % party_id
_refresh_party_ui()
_refresh_ui()
func _on_party_joined(party_id: String) -> void:
_party_id_label.text = "Party: %s" % party_id
print("PARTY ID:", party_id)
_refresh_party_ui()
_refresh_ui()
func _on_party_left() -> void:
_party_id_label.text = ""
_refresh_party_ui()
_refresh_ui()
func _refresh_party_ui() -> void:
_member_list.clear()
for member: Dictionary in GameState.party_members:
var label: String = member.username
if member.session_id == GameState.party_leader_session_id:
label += " (leader)"
_member_list.add_item(label)
func _on_quick_match_pressed() -> void:
_quick_match_button.disabled = true
_match_status_label.text = "Searching..."
await NetworkManager.start_matchmaking()
func _on_cancel_match_pressed() -> void:
await NetworkManager.cancel_matchmaking()
_match_status_label.text = "Cancelled."
func _on_matchmaking_started() -> void:
_refresh_ui()
func _on_matchmaking_cancelled() -> void:
_refresh_ui()
func _on_match_joined() -> void:
_match_status_label.text = "Match found! Peers: %d" % (GameState.match_peers.size() + 1)
_refresh_ui()
func _on_match_join_failed(message: String) -> void:
_match_status_label.text = message
_refresh_ui()
func _on_peer_joined(peer_id: int) -> void:
_match_status_label.text = "Match — Peers: %d" % (GameState.match_peers.size() + 1)
func _on_peer_left(peer_id: int) -> void:
_match_status_label.text = "Match — Peers: %d" % (GameState.match_peers.size() + 1)
func _on_start_raid_pressed() -> void:
NetworkManager.start_raid()
func _on_state_changed(_new_state: GameState.Phase) -> void:
_refresh_ui()
func _refresh_ui() -> void:
var phase: GameState.Phase = GameState.current_phase
var connected: bool = phase != GameState.Phase.OFFLINE
var in_party: bool = phase == GameState.Phase.IN_PARTY
var matchmaking: bool = phase == GameState.Phase.MATCHMAKING
var in_match: bool = phase == GameState.Phase.IN_MATCH
var idle_connected: bool = phase == GameState.Phase.CONNECTED
_username_input.visible = not connected
_connect_button.visible = not connected
_connect_button.disabled = false
_party_panel.visible = connected and not in_match
_create_party_button.visible = idle_connected
_create_party_button.disabled = false
_join_party_input.visible = idle_connected
_join_party_button.visible = idle_connected
_join_party_button.disabled = false
_leave_party_button.visible = in_party
_member_list.visible = in_party
_match_panel.visible = connected
var can_queue: bool = idle_connected or (in_party and GameState.is_party_leader)
_quick_match_button.visible = can_queue and not matchmaking and not in_match
_quick_match_button.disabled = false
_cancel_match_button.visible = matchmaking
_start_raid_button.visible = in_match and NetworkManager.is_host()
if in_match and not NetworkManager.is_host():
_match_status_label.text = "In match (peer %d) — waiting for host to start the raid..." % multiplayer.get_unique_id()
elif in_match and NetworkManager.is_host():
_match_status_label.text = "In match (host, peer 1) — %d peers connected. Click Start Raid." % (GameState.match_peers.size() + 1)
elif not matchmaking and not in_match:
_match_status_label.text = ""

1
src/ui/lobby.gd.uid Normal file
View File

@@ -0,0 +1 @@
uid://c2e2hr52twq1g