diff --git a/CharacterController.cs b/CharacterController.cs
index 3e3fdeb..5c624c6 100644
--- a/CharacterController.cs
+++ b/CharacterController.cs
@@ -4,9 +4,20 @@
using System;
using System.Text.Json.Serialization;
+///
+/// Handles local player movement and basic player node initialization.
+/// Movement is applied only when this node is the multiplayer authority.
+///
public partial class CharacterController : CharacterBody2D
{
+ ///
+ /// Horizontal movement speed.
+ ///
public const float Speed = 300.0f;
+
+ ///
+ /// Upward impulse used for jump.
+ ///
public const float JumpVelocity = -400.0f;
public PlayerInfo Info;
@@ -14,31 +25,24 @@ public partial class CharacterController : CharacterBody2D
// Get the gravity from the project settings to be synced with RigidBody nodes.
public float gravity = ProjectSettings.GetSetting("physics/2d/default_gravity").AsSingle();
- public override void _Ready()
- {
- base._Ready();
- NakamaClient.Client.PlayerDataSync += onPlayerDataSync;
- }
+ public override void _Ready()
+ {
+ base._Ready();
+ }
+ ///
+ /// Sets the player's visible name and initial spawn position.
+ ///
+ /// Player display identifier.
+ /// Spawn world position.
public void SetupPlayer(string name, Vector2 position){
GlobalPosition = position;
GetNode("Label").Text = name;
}
- private void onPlayerDataSync(string data)
- {
- var playerData = JsonConvert.DeserializeObject(data);
-
- if(playerData.Id == Name){
- GlobalPosition = playerData.Position;
- RotationDegrees = playerData.RotationDegrees;
- }
- }
-
-
- public override void _PhysicsProcess(double delta)
+ public override void _PhysicsProcess(double delta)
{
- if(Name == NakamaClient.Session.Username){
+ if (IsMultiplayerAuthority()){
Vector2 velocity = Velocity;
// Add the gravity.
@@ -64,16 +68,6 @@ public override void _PhysicsProcess(double delta)
Velocity = velocity;
MoveAndSlide();
- syncData();
}
}
-
- private void syncData(){
- PlayerSyncData playerSyncData = new PlayerSyncData(){
- Position = GlobalPosition,
- RotationDegrees = RotationDegrees,
- Id = Name
- };
- NakamaClient.SyncData(JsonConvert.SerializeObject(playerSyncData), 1);
- }
}
diff --git a/CharacterController.cs.uid b/CharacterController.cs.uid
new file mode 100644
index 0000000..eabcc19
--- /dev/null
+++ b/CharacterController.cs.uid
@@ -0,0 +1 @@
+uid://caxbbb8xkusye
diff --git a/ChatChannel.cs b/ChatChannel.cs
index 0acb1b1..454c8de 100644
--- a/ChatChannel.cs
+++ b/ChatChannel.cs
@@ -1,5 +1,15 @@
+///
+/// Represents a chat tab/channel binding used by the UI.
+///
public class ChatChannel
{
+ ///
+ /// Nakama channel identifier.
+ ///
public string ID;
+
+ ///
+ /// Human-readable label shown in the tab container.
+ ///
public string Label;
}
\ No newline at end of file
diff --git a/ChatChannel.cs.uid b/ChatChannel.cs.uid
new file mode 100644
index 0000000..4d0ca9e
--- /dev/null
+++ b/ChatChannel.cs.uid
@@ -0,0 +1 @@
+uid://byjfha1fb6nlf
diff --git a/ChatMessage.cs b/ChatMessage.cs
index 82c6ce7..6e42902 100644
--- a/ChatMessage.cs
+++ b/ChatMessage.cs
@@ -1,7 +1,25 @@
+///
+/// Serializable chat payload exchanged through Nakama chat channels.
+///
public class ChatMessage
{
+ ///
+ /// Message body content.
+ ///
public string Message;
+
+ ///
+ /// Sender username.
+ ///
public string User;
+
+ ///
+ /// Logical room/channel identifier used by the sample.
+ ///
public string ID;
+
+ ///
+ /// Message type marker used by the UI router.
+ ///
public int Type;
}
\ No newline at end of file
diff --git a/ChatMessage.cs.uid b/ChatMessage.cs.uid
new file mode 100644
index 0000000..a388154
--- /dev/null
+++ b/ChatMessage.cs.uid
@@ -0,0 +1 @@
+uid://b57selabvodv1
diff --git a/GameManager.cs b/GameManager.cs
index 0ba60bd..f53dc26 100644
--- a/GameManager.cs
+++ b/GameManager.cs
@@ -1,9 +1,15 @@
using Godot;
using System;
+///
+/// Coordinates the transition from UI/lobby to gameplay level.
+///
public partial class GameManager : Node2D
{
+ ///
+ /// Level scene instantiated when the start-game signal is received.
+ ///
[Export]
public PackedScene LevelToLoad;
// Called when the node enters the scene tree for the first time.
diff --git a/GameManager.cs.uid b/GameManager.cs.uid
new file mode 100644
index 0000000..c40c32c
--- /dev/null
+++ b/GameManager.cs.uid
@@ -0,0 +1 @@
+uid://s4oj34akvow5
diff --git a/Nakama cSharp Tutorial.csproj b/Nakama cSharp Tutorial.csproj
index 48c56bd..2053d9c 100644
--- a/Nakama cSharp Tutorial.csproj
+++ b/Nakama cSharp Tutorial.csproj
@@ -1,13 +1,11 @@
-
+
- net6.0
- net7.0
- net8.0
+ net8.0
true
NakamacSharpTutorial
-
+
\ No newline at end of file
diff --git a/Nakama cSharp Tutorial.csproj.old b/Nakama cSharp Tutorial.csproj.old
new file mode 100644
index 0000000..48c56bd
--- /dev/null
+++ b/Nakama cSharp Tutorial.csproj.old
@@ -0,0 +1,13 @@
+
+
+ net6.0
+ net7.0
+ net8.0
+ true
+ NakamacSharpTutorial
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Nakama/NakamaMultiplayerBridge.cs b/Nakama/NakamaMultiplayerBridge.cs
new file mode 100644
index 0000000..d23d942
--- /dev/null
+++ b/Nakama/NakamaMultiplayerBridge.cs
@@ -0,0 +1,536 @@
+using Godot;
+using Nakama;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+///
+/// Bridges Nakama realtime match traffic to Godot's multiplayer API.
+/// It handles peer-id assignment, presence tracking, and RPC packet routing.
+///
+public partial class NakamaMultiplayerBridge : RefCounted
+{
+ public enum MatchState
+ {
+ Disconnected,
+ Joining,
+ Connected,
+ SocketClosed
+ }
+
+ public enum MetaMessageType
+ {
+ ClaimHost,
+ AssignPeerId
+ }
+
+ // JSON Structure for lightning-fast Meta Message parsing
+ public struct MetaMessage
+ {
+ [JsonPropertyName("type")]
+ public MetaMessageType Type { get; set; }
+
+ [JsonPropertyName("session_id")]
+ public string SessionId { get; set; }
+
+ [JsonPropertyName("peer_id")]
+ public int PeerId { get; set; }
+ }
+
+ public class User
+ {
+ public IUserPresence Presence { get; set; }
+ public int PeerId { get; set; } = 0;
+
+ public User(IUserPresence presence)
+ {
+ Presence = presence;
+ }
+ }
+
+ ///
+ /// Emitted when match join/setup fails.
+ ///
+ [Signal] public delegate void MatchJoinErrorEventHandler(string exceptionMessage);
+
+ ///
+ /// Emitted when bridge setup is complete and the multiplayer peer is ready.
+ ///
+ [Signal] public delegate void MatchJoinedEventHandler();
+
+ private readonly ISocket _nakamaSocket;
+ public ISocket NakamaSocket => _nakamaSocket;
+
+ private MatchState _matchState = MatchState.Disconnected;
+ public MatchState State => _matchState;
+
+ private string _matchId = "";
+ public string MatchId => _matchId;
+
+ // Assuming NakamaMultiplayerPeer is either ported to C# or wrapped
+ private NakamaMultiplayerPeer _multiplayerPeer = new NakamaMultiplayerPeer();
+ public NakamaMultiplayerPeer MultiplayerPeer => _multiplayerPeer;
+
+ public long MetaOpCode { get; set; } = 9001;
+ public long RpcOpCode { get; set; } = 9002;
+
+ private string _mySessionId = "";
+ private int _myPeerId = 0;
+ private string _matchmakerTicket = "";
+
+ // Internal data structures optimized for C#
+ private readonly Dictionary _idMap = new();
+ private readonly Dictionary _users = new();
+
+ public NakamaMultiplayerBridge(ISocket socket)
+ {
+ _nakamaSocket = socket;
+
+ // Native C# Event subscriptions for Nakama SDK
+ _nakamaSocket.ReceivedMatchPresence += OnNakamaSocketReceivedMatchPresence;
+ _nakamaSocket.ReceivedMatchmakerMatched += OnNakamaSocketReceivedMatchmakerMatched;
+ _nakamaSocket.ReceivedMatchState += OnNakamaSocketReceivedMatchState;
+ _nakamaSocket.Closed += OnNakamaSocketClosed;
+
+ _multiplayerPeer.PacketGenerated += OnMultiplayerPeerPacketGenerated;
+ _multiplayerPeer.SetConnectionStatus((int)Godot.MultiplayerPeer.ConnectionStatus.Connecting);
+ }
+
+ ///
+ /// Creates a new Nakama match and configures the local user as host.
+ ///
+ public async void CreateMatch()
+ {
+ if (_matchState != MatchState.Disconnected)
+ {
+ GD.PushError($"Cannot create match when state is {_matchState}");
+ return;
+ }
+
+ _matchState = MatchState.Joining;
+ _multiplayerPeer.SetConnectionStatus((int)Godot.MultiplayerPeer.ConnectionStatus.Connecting);
+
+ try
+ {
+ var res = await _nakamaSocket.CreateMatchAsync();
+ SetupMatch(res);
+ SetupHost();
+ }
+ catch (Exception e)
+ {
+ EmitSignal(SignalName.MatchJoinError, e.Message);
+ Leave();
+ }
+ }
+
+ ///
+ /// Joins an existing match by id.
+ ///
+ /// Target match id.
+ public async void JoinMatch(string pMatchId)
+ {
+ if (_matchState != MatchState.Disconnected)
+ {
+ GD.PushError($"Cannot join match when state is {_matchState}");
+ return;
+ }
+
+ _matchState = MatchState.Joining;
+ _multiplayerPeer.SetConnectionStatus((int)Godot.MultiplayerPeer.ConnectionStatus.Connecting);
+
+ try
+ {
+ var res = await _nakamaSocket.JoinMatchAsync(pMatchId);
+ SetupMatch(res);
+ }
+ catch (Exception e)
+ {
+ EmitSignal(SignalName.MatchJoinError, e.Message);
+ Leave();
+ }
+ }
+
+ ///
+ /// Creates or joins a named match (server-side match label/id flow).
+ ///
+ /// Named lobby/match value.
+ public async void JoinNamedMatch(string matchName)
+ {
+ if (_matchState != MatchState.Disconnected)
+ {
+ GD.PushError($"Cannot join match when state is {_matchState}");
+ return;
+ }
+
+ _matchState = MatchState.Joining;
+ _multiplayerPeer.SetConnectionStatus((int)Godot.MultiplayerPeer.ConnectionStatus.Connecting);
+
+ try
+ {
+ var res = await _nakamaSocket.CreateMatchAsync(matchName);
+ SetupMatch(res);
+
+ if (res.Size == 0 || (res.Size == 1 && !res.Presences.Any()))
+ {
+ SetupHost();
+ }
+ }
+ catch (Exception e)
+ {
+ EmitSignal(SignalName.MatchJoinError, e.Message);
+ Leave();
+ }
+ }
+
+ ///
+ /// Starts bridge matchmaking flow from a previously created matchmaker ticket.
+ ///
+ /// Nakama matchmaker ticket.
+ public void StartMatchmaking(IMatchmakerTicket ticket)
+ {
+ if (_matchState != MatchState.Disconnected)
+ {
+ GD.PushError($"Cannot start matchmaking when state is {_matchState}");
+ return;
+ }
+
+ if (ticket == null)
+ {
+ GD.PushError("Null ticket passed into StartMatchmaking()");
+ return;
+ }
+
+ _matchState = MatchState.Joining;
+ _multiplayerPeer.SetConnectionStatus((int)Godot.MultiplayerPeer.ConnectionStatus.Connecting);
+ _matchmakerTicket = ticket.Ticket;
+ }
+
+ private async void OnNakamaSocketReceivedMatchmakerMatched(IMatchmakerMatched matchmakerMatched)
+ {
+ if (_matchmakerTicket != matchmakerMatched.Ticket) return;
+
+ var sessionIds = matchmakerMatched.Users.Select(u => u.Presence.SessionId).ToList();
+ sessionIds.Sort();
+
+ try
+ {
+ var res = await _nakamaSocket.JoinMatchAsync(matchmakerMatched);
+ SetupMatch(res);
+
+ // If our session is the first alphabetically, then we'll be the host.
+ if (_mySessionId == sessionIds[0])
+ {
+ SetupHost();
+ foreach (var presence in res.Presences)
+ {
+ if (presence.SessionId != _mySessionId)
+ {
+ HostAddPeer(presence);
+ }
+ }
+ }
+ }
+ catch (Exception e)
+ {
+ EmitSignal(SignalName.MatchJoinError, e.Message);
+ Leave();
+ }
+ }
+
+ private void OnNakamaSocketClosed(string reason)
+ {
+ _matchState = MatchState.SocketClosed;
+ Cleanup();
+ }
+
+ ///
+ /// Gets the Nakama presence associated with a Godot peer id.
+ ///
+ /// Godot peer id.
+ /// User presence when found; otherwise null .
+ public IUserPresence GetUserPresenceForPeer(int peerId)
+ {
+ if (_idMap.TryGetValue(peerId, out string sessionId))
+ {
+ if (_users.TryGetValue(sessionId, out User user))
+ {
+ return user.Presence;
+ }
+ }
+ return null;
+ }
+
+ ///
+ /// Returns a snapshot of currently known user presences for roster hydration.
+ ///
+ public IReadOnlyList GetKnownPresences()
+ {
+ return _users.Values
+ .Select(user => user.Presence)
+ .Where(presence => presence != null)
+ .ToList();
+ }
+
+ ///
+ /// Leaves current match/matchmaker context and resets bridge state.
+ ///
+ public async void Leave()
+ {
+ if (_matchState == MatchState.Disconnected) return;
+ _matchState = MatchState.Disconnected;
+
+ try
+ {
+ if (!string.IsNullOrEmpty(_matchId))
+ await _nakamaSocket.LeaveMatchAsync(_matchId);
+
+ if (!string.IsNullOrEmpty(_matchmakerTicket))
+ await _nakamaSocket.RemoveMatchmakerAsync(_matchmakerTicket);
+ }
+ catch (Exception e)
+ {
+ GD.PushError($"Error leaving match: {e.Message}");
+ }
+
+ Cleanup();
+ }
+
+ private void Cleanup()
+ {
+ foreach (var peerId in _idMap.Keys)
+ {
+ _multiplayerPeer.EmitSignal("peer_disconnected", peerId);
+ }
+
+ _matchId = "";
+ _matchmakerTicket = "";
+ _mySessionId = "";
+ _myPeerId = 0;
+
+ _idMap.Clear();
+ _users.Clear();
+
+ _multiplayerPeer.SetConnectionStatus((int)Godot.MultiplayerPeer.ConnectionStatus.Disconnected);
+ }
+
+ private void SetupMatch(IMatch res)
+ {
+ _matchId = res.Id;
+ _mySessionId = res.Self.SessionId;
+
+ _users[_mySessionId] = new User(res.Self);
+
+ foreach (var presence in res.Presences)
+ {
+ if (!_users.ContainsKey(presence.SessionId))
+ {
+ _users[presence.SessionId] = new User(presence);
+ }
+ }
+ }
+
+ private void SetupHost()
+ {
+ _myPeerId = 1;
+ MapIdToSession(1, _mySessionId);
+ _matchState = MatchState.Connected;
+
+ _multiplayerPeer.Initialize(_myPeerId);
+ EmitSignal(SignalName.MatchJoined);
+ }
+
+ private int GenerateId(string sessionId)
+ {
+ int peerId = sessionId.GetHashCode() & 0x7FFFFFFF;
+
+ while (peerId <= 1 || _idMap.ContainsKey(peerId))
+ {
+ peerId++;
+ if (peerId > 0x7FFFFFFF || peerId <= 0)
+ {
+ peerId = new Random().Next() & 0x7FFFFFFF;
+ }
+ }
+ return peerId;
+ }
+
+ private void MapIdToSession(int peerId, string sessionId)
+ {
+ _idMap[peerId] = sessionId;
+ if (_users.TryGetValue(sessionId, out User user))
+ {
+ user.PeerId = peerId;
+ }
+ }
+
+ private void HostAddPeer(IUserPresence presence)
+ {
+ int peerId = GenerateId(presence.SessionId);
+ MapIdToSession(peerId, presence.SessionId);
+
+ // Tell them we are the host
+ var hostMsg = new MetaMessage { Type = MetaMessageType.ClaimHost };
+ _nakamaSocket.SendMatchStateAsync(_matchId, MetaOpCode, JsonSerializer.SerializeToUtf8Bytes(hostMsg), new[] { presence });
+
+ // Tell them about all the other connected peers
+ foreach (var kvp in _idMap)
+ {
+ int otherPeerId = kvp.Key;
+ string otherSessionId = kvp.Value;
+
+ if (otherSessionId == presence.SessionId || otherSessionId == _mySessionId)
+ continue;
+
+ var assignOtherMsg = new MetaMessage {
+ Type = MetaMessageType.AssignPeerId,
+ SessionId = otherSessionId,
+ PeerId = otherPeerId
+ };
+ _nakamaSocket.SendMatchStateAsync(_matchId, MetaOpCode, JsonSerializer.SerializeToUtf8Bytes(assignOtherMsg), new[] { presence });
+ }
+
+ // Assign them a peer_id (tell everyone about it)
+ var assignSelfMsg = new MetaMessage {
+ Type = MetaMessageType.AssignPeerId,
+ SessionId = presence.SessionId,
+ PeerId = peerId
+ };
+ _nakamaSocket.SendMatchStateAsync(_matchId, MetaOpCode, JsonSerializer.SerializeToUtf8Bytes(assignSelfMsg));
+
+ _multiplayerPeer.EmitSignal("peer_connected", peerId);
+ }
+
+ private void OnNakamaSocketReceivedMatchPresence(IMatchPresenceEvent ev)
+ {
+ if (_matchState == MatchState.Disconnected || ev.MatchId != _matchId) return;
+
+ foreach (var presence in ev.Joins)
+ {
+ if (!_users.ContainsKey(presence.SessionId))
+ {
+ _users[presence.SessionId] = new User(presence);
+ }
+
+ if (_myPeerId == 1 && _users[presence.SessionId].PeerId == 0)
+ {
+ HostAddPeer(presence);
+ }
+ }
+
+ foreach (var presence in ev.Leaves)
+ {
+ if (!_users.TryGetValue(presence.SessionId, out User user)) continue;
+
+ int peerId = user.PeerId;
+ _multiplayerPeer.EmitSignal("peer_disconnected", peerId);
+
+ _users.Remove(presence.SessionId);
+ _idMap.Remove(peerId);
+ }
+ }
+
+ private void OnNakamaSocketReceivedMatchState(IMatchState data)
+ {
+ if (_matchState == MatchState.Disconnected || data.MatchId != _matchId) return;
+
+ if (data.OpCode == MetaOpCode)
+ {
+ MetaMessage content;
+ try
+ {
+ // Instantly deserialize directly from the byte array
+ content = JsonSerializer.Deserialize(data.State);
+ }
+ catch
+ {
+ return;
+ }
+
+ if (content.Type == MetaMessageType.ClaimHost)
+ {
+ if (_idMap.ContainsKey(1))
+ {
+ GD.PushError($"User {data.UserPresence.SessionId} claiming to be host, when user {_idMap[1]} has already claimed it");
+ }
+ else
+ {
+ MapIdToSession(1, data.UserPresence.SessionId);
+ }
+ return;
+ }
+
+ if (!_idMap.TryGetValue(1, out string hostSession) || data.UserPresence.SessionId != hostSession)
+ {
+ GD.PushError($"Received meta message from user {data.UserPresence.SessionId} who isn't the host.");
+ return;
+ }
+
+ if (content.Type == MetaMessageType.AssignPeerId)
+ {
+ string sessionId = content.SessionId;
+ int peerId = content.PeerId;
+
+ if (_users.TryGetValue(sessionId, out User user) && user.PeerId != 0)
+ {
+ GD.PushError($"Attempting to assign peer id {peerId} to {sessionId} which already has id {user.PeerId}");
+ return;
+ }
+
+ MapIdToSession(peerId, sessionId);
+
+ if (_mySessionId == sessionId)
+ {
+ _matchState = MatchState.Connected;
+ _multiplayerPeer.Initialize(peerId);
+ _multiplayerPeer.SetConnectionStatus((int)Godot.MultiplayerPeer.ConnectionStatus.Connected);
+ EmitSignal(SignalName.MatchJoined);
+ _multiplayerPeer.EmitSignal("peer_connected", 1);
+ }
+ else
+ {
+ _multiplayerPeer.EmitSignal("peer_connected", peerId);
+ }
+ }
+ else
+ {
+ GD.PushError($"Received meta message with unknown type: {content.Type}");
+ }
+ }
+ else if (data.OpCode == RpcOpCode)
+ {
+ string fromSessionId = data.UserPresence.SessionId;
+ if (!_users.TryGetValue(fromSessionId, out User fromUser) || fromUser.PeerId == 0)
+ {
+ GD.PushError($"Received RPC from {fromSessionId} which isn't assigned a peer id");
+ return;
+ }
+
+ _multiplayerPeer.DeliverPacket(data.State, fromUser.PeerId);
+ }
+ }
+
+ private void OnMultiplayerPeerPacketGenerated(int peerId, byte[] buffer)
+ {
+ if (_matchState == MatchState.Connected)
+ {
+ IEnumerable targetPresences = null;
+ if (peerId > 0)
+ {
+ if (!_idMap.TryGetValue(peerId, out string targetSession))
+ {
+ GD.PushError($"Attempting to send RPC to unknown peer id: {peerId}");
+ return;
+ }
+ targetPresences = new[] { _users[targetSession].Presence };
+ }
+
+ _nakamaSocket.SendMatchStateAsync(_matchId, RpcOpCode, buffer, targetPresences);
+ }
+ else
+ {
+ GD.PushError("RPC sent while the NakamaMultiplayerBridge isn't connected!");
+ }
+ }
+}
\ No newline at end of file
diff --git a/Nakama/NakamaMultiplayerBridge.cs.uid b/Nakama/NakamaMultiplayerBridge.cs.uid
new file mode 100644
index 0000000..a601d8a
--- /dev/null
+++ b/Nakama/NakamaMultiplayerBridge.cs.uid
@@ -0,0 +1 @@
+uid://fj5a6la868v5
diff --git a/Nakama/NakamaMultiplayerPeer.cs b/Nakama/NakamaMultiplayerPeer.cs
new file mode 100644
index 0000000..d04855e
--- /dev/null
+++ b/Nakama/NakamaMultiplayerPeer.cs
@@ -0,0 +1,189 @@
+using Godot;
+using System;
+using System.Collections.Generic;
+
+///
+/// Custom implementation backed by Nakama match messages.
+///
+public partial class NakamaMultiplayerPeer : MultiplayerPeerExtension
+{
+ private const int MaxPacketSize = 1 << 24;
+
+ private int _selfId = 0;
+ private ConnectionStatus _connectionStatus = ConnectionStatus.Disconnected;
+ private bool _refusingNewConnections = false;
+ private int _targetId = 0;
+
+ // Using a readonly struct to prevent GC allocations and maximize performance
+ public readonly struct Packet
+ {
+ public readonly byte[] Data;
+ public readonly int From;
+
+ public Packet(byte[] data, int from)
+ {
+ Data = data;
+ From = from;
+ }
+ }
+
+ // Queue gives us O(1) Enqueue and Dequeue, far outperforming GDScript's Array pop_front()
+ private readonly Queue _incomingPackets = new Queue();
+
+ ///
+ /// Raised when Godot generates an outgoing packet.
+ /// First argument is target peer id, second is raw payload.
+ ///
+ public event Action PacketGenerated;
+
+ // --- MultiplayerPeerExtension Overrides ---
+
+ public override byte[] _GetPacketScript()
+ {
+ if (_incomingPackets.Count == 0)
+ {
+ return Array.Empty(); // More efficient than new byte[0]
+ }
+
+ return _incomingPackets.Dequeue().Data;
+ }
+
+ public override MultiplayerPeer.TransferModeEnum _GetPacketMode()
+ {
+ return MultiplayerPeer.TransferModeEnum.Reliable;
+ }
+
+ public override int _GetPacketChannel()
+ {
+ return 0;
+ }
+
+ public override Error _PutPacketScript(byte[] pBuffer)
+ {
+ PacketGenerated?.Invoke(_targetId, pBuffer);
+ return Error.Ok;
+ }
+
+ public override int _GetAvailablePacketCount()
+ {
+ return _incomingPackets.Count;
+ }
+
+ public override int _GetMaxPacketSize()
+ {
+ return MaxPacketSize;
+ }
+
+ public override void _SetTransferChannel(int pChannel)
+ {
+ // Not implemented/needed for this Nakama structure
+ }
+
+ public override int _GetTransferChannel()
+ {
+ return 0;
+ }
+
+ public override void _SetTransferMode(MultiplayerPeer.TransferModeEnum pMode)
+ {
+ // Not implemented/needed for this Nakama structure
+ }
+
+ public override MultiplayerPeer.TransferModeEnum _GetTransferMode()
+ {
+ return MultiplayerPeer.TransferModeEnum.Reliable;
+ }
+
+ public override void _SetTargetPeer(int pPeerId)
+ {
+ _targetId = pPeerId;
+ }
+
+ public override int _GetPacketPeer()
+ {
+ if (_connectionStatus != ConnectionStatus.Connected)
+ {
+ return 1;
+ }
+
+ if (_incomingPackets.Count == 0)
+ {
+ return 1;
+ }
+
+ // We Peek() here because Godot extracts the sender's peer ID
+ // right BEFORE it extracts the actual packet data.
+ return _incomingPackets.Peek().From;
+ }
+
+ public override bool _IsServer()
+ {
+ return _selfId == 1;
+ }
+
+ public override void _Poll()
+ {
+ // Polling is handled via the socket connection dynamically
+ }
+
+ public override int _GetUniqueId()
+ {
+ return _selfId;
+ }
+
+ public override void _SetRefuseNewConnections(bool pEnable)
+ {
+ _refusingNewConnections = pEnable;
+ }
+
+ public override bool _IsRefusingNewConnections()
+ {
+ return _refusingNewConnections;
+ }
+
+ public override ConnectionStatus _GetConnectionStatus()
+ {
+ return _connectionStatus;
+ }
+
+ // --- Custom Initialization and Delivery Methods ---
+
+ ///
+ /// Initializes this peer with the local unique peer id.
+ ///
+ /// Assigned local peer id.
+ public void Initialize(int pSelfId)
+ {
+ if (_connectionStatus != ConnectionStatus.Connecting)
+ {
+ return;
+ }
+
+ _selfId = pSelfId;
+ if (_selfId == 1)
+ {
+ _connectionStatus = ConnectionStatus.Connected;
+ }
+ }
+
+ ///
+ /// Updates the underlying connection status.
+ ///
+ /// Integer value of .
+ public void SetConnectionStatus(int pConnectionStatus)
+ {
+ // Safe cast from int to Godot's built-in enum
+ _connectionStatus = (ConnectionStatus)pConnectionStatus;
+ }
+
+ ///
+ /// Queues an incoming packet so Godot can consume it on polling.
+ ///
+ /// Raw packet bytes.
+ /// Sender peer id.
+ public void DeliverPacket(byte[] pData, int pFromPeerId)
+ {
+ var packet = new Packet(pData, pFromPeerId);
+ _incomingPackets.Enqueue(packet);
+ }
+}
\ No newline at end of file
diff --git a/Nakama/NakamaMultiplayerPeer.cs.uid b/Nakama/NakamaMultiplayerPeer.cs.uid
new file mode 100644
index 0000000..5316c07
--- /dev/null
+++ b/Nakama/NakamaMultiplayerPeer.cs.uid
@@ -0,0 +1 @@
+uid://dae0o5ggxikma
diff --git a/NakamaClient.cs b/NakamaClient.cs
index 3d35141..f6d111f 100644
--- a/NakamaClient.cs
+++ b/NakamaClient.cs
@@ -10,6 +10,10 @@
using Microsoft.VisualBasic;
using System.Threading.Tasks;
+///
+/// Main client controller for authentication, social features, matchmaking,
+/// and multiplayer game start orchestration.
+///
public partial class NakamaClient : Control
{
// Called when the node enters the scene tree for the first time.
@@ -17,11 +21,24 @@ public partial class NakamaClient : Control
private static Client client;
public static ISession Session;
private static ISocket socket;
- private static IMatch match;
+ private NakamaMultiplayerBridge _multiplayerBridge;
+ public static NakamaMultiplayerBridge MultiplayerBridge => Client?._multiplayerBridge;
public static NakamaClient Client;
+ ///
+ /// Runtime roster of players tracked for lobby and spawn flows.
+ /// Key = username.
+ ///
public static Dictionary Players = new(); // houses all the players in the game
+ [Export]
+ public string NakamaScheme = "http";
+ [Export]
+ public string NakamaHost = "127.0.0.1";
+ [Export]
+ public int NakamaPort = 7350;
+ [Export]
+ public string NakamaKey = "defaultkey";
[Signal]
public delegate void PlayerDataSyncEventHandler(string data);
@@ -48,7 +65,7 @@ public override void _Ready()
if (Client != null)
{
GD.Print("removing second instance");
-
+
QueueFree();
}
else
@@ -59,9 +76,9 @@ public override void _Ready()
readyAsync();
}
- private async void readyAsync()
+ private void readyAsync()
{
- client = new Client("http", "198.199.80.118", 7350, "defaultkey");
+ client = new Client(NakamaScheme, NakamaHost, NakamaPort, NakamaKey);
client.Timeout = 500;
//var session = await client.AuthenticateDeviceAsync(OS.GetUniqueId());
@@ -86,139 +103,139 @@ public async void _on_login_button_button_down()
socket.ReceivedChannelMessage += onChannelMessage;
subToFriendsChannels();
- }
-
- public async void _on_join_button_down()
- {
- //socket = Socket.From(client);
- //await socket.ConnectAsync(Session);
+ _multiplayerBridge = new NakamaMultiplayerBridge(socket);
- socket.ReceivedMatchPresence += onMatchPresence;
- socket.ReceivedMatchState += onMatchState;
+ // 2. Connect to the bridge's custom signals (using C# Events)
+ _multiplayerBridge.MatchJoinError += OnMatchJoinError;
+ _multiplayerBridge.MatchJoined += OnBridgeMatchJoined;
-
+ // 3. Assign the Godot MultiplayerPeer
+ // Use GetTree().GetMultiplayer() if you are outside a Node,
+ // or just Multiplayer if you are inside a Node class.
+ Multiplayer.MultiplayerPeer = _multiplayerBridge.MultiplayerPeer;
+ }
+
+ public async void _on_join_button_down()
+ {
+ if (_multiplayerBridge == null)
+ {
+ GD.PushWarning("Multiplayer bridge not initialized. Login first.");
+ return;
+ }
- match = await socket.CreateMatchAsync(GetNode("MatchMaking/LobbyName").Text);
-
- AddToChat(GetNode("MatchMaking/LobbyName").Text, GetNode("MatchMaking/LobbyName").Text, ChannelType.Room, false, false);
-
- GD.Print($"Created Match with ID: {match.Id}");
+ var lobbyName = GetNode("MatchMaking/LobbyName").Text;
+ await AddToChat(lobbyName, lobbyName, ChannelType.Room, false, false);
+ _multiplayerBridge.JoinNamedMatch(lobbyName);
+ }
- await socket.JoinMatchAsync(match.Id);
+ private void OnBridgeMatchJoined()
+ {
+ CallDeferred(nameof(OnBridgeMatchJoinedDeferred));
+ }
+
+ ///
+ /// Runs on the main thread after the bridge signals a successful match join.
+ /// Rebuilds local roster to keep scene spawning deterministic across peers.
+ ///
+ private void OnBridgeMatchJoinedDeferred()
+ {
+ if (Multiplayer == null || Multiplayer.MultiplayerPeer == null)
+ {
+ GD.PushWarning("Bridge reported match joined, but Multiplayer is not ready yet.");
+ return;
+ }
- GD.Print($"Joined Match with id: {match.Id}");
+ Players.Clear();
- foreach (var item in match.Presences)
+ var bridge = MultiplayerBridge;
+ if (bridge != null)
{
- if (!Players.ContainsKey(item.Username))
+ foreach (var presence in bridge.GetKnownPresences())
{
- Players.Add(item.Username, new PlayerInfo { Id = item.Username });
- CallDeferred(nameof(EmitPlayerJoinGameSignal), item.Username);
+ EnsurePlayerEntry(presence?.Username);
}
}
- if (match.Presences.Count() == 0)
- {
- IsHost = true;
- }
+
+ EnsurePlayerEntry(Session?.Username);
+
+ var peerId = Multiplayer.GetUniqueId();
+ IsHost = peerId == 1;
+ GD.Print($"Bridge connected to match. PeerId: {peerId} (Host: {IsHost}). Players in roster: {Players.Count}");
}
- private void onMatchState(IMatchState state)
+ ///
+ /// Ensures a username exists in and emits join signal on first insert.
+ ///
+ /// Username to ensure in roster.
+ private void EnsurePlayerEntry(string username)
{
- string data = Encoding.UTF8.GetString(state.State);
- GD.Print($"Recieved data from user: {data}");
- switch (state.OpCode)
+ if (string.IsNullOrWhiteSpace(username))
{
- case 0:
- CallDeferred(nameof(EmitPlayerJoinGameSignal), data);
- break;
-
- case 1:
- CallDeferred(nameof(EmitPlayerSyncDataSignal), data);
- break;
-
- case 2:
- CallDeferred(nameof(EmitStartGameSignal), data);
- break;
-
- case 3:
- CallDeferred(nameof(EmitReadyGameSignal), data);
- Players[data].Status = 1;
- if (IsHost)
- {
- if (Players.Any(x => x.Value.Status == 0))
- {
- return;
- }
- GD.Print("Host start Game");
- SyncData("", 2);
- CallDeferred(nameof(EmitStartGameSignal), data);
-
- }
- break;
+ return;
+ }
+ if (!Players.ContainsKey(username))
+ {
+ Players[username] = new PlayerInfo { Id = username };
+ CallDeferred(nameof(EmitPlayerJoinGameSignal), username);
}
}
+ private void OnMatchJoinError(string exceptionMessage)
+ {
+ GD.PushError($"Match join error: {exceptionMessage}");
+ }
+
public void EmitPlayerJoinGameSignal(string data) => EmitSignal(SignalName.PlayerJoinGame, data);
public void EmitPlayerLeaveGameSignal(string data) => EmitSignal(SignalName.PlayerLeaveGame, data);
- public void EmitPlayerSyncDataSignal(string data) => EmitSignal(SignalName.PlayerDataSync, data);
public void EmitStartGameSignal(string data) => EmitSignal(SignalName.StartGame, data);
public void EmitReadyGameSignal(string data) => EmitSignal(SignalName.ReadyGame, data);
- private void onMatchPresence(IMatchPresenceEvent @event)
+ public void _on_ping_button_down()
{
- foreach (var item in @event.Joins)
+ if (Multiplayer == null || Multiplayer.MultiplayerPeer == null)
{
- if (!Players.ContainsKey(item.Username))
- {
- Players.Add(item.Username, new PlayerInfo { Id = item.Username });
- CallDeferred(nameof(EmitPlayerJoinGameSignal), item.Username);
- }
+ GD.PushWarning("Multiplayer peer is null. Join a match first.");
+ return;
}
- foreach (var item in @event.Leaves)
+ if (Multiplayer.MultiplayerPeer.GetConnectionStatus() != MultiplayerPeer.ConnectionStatus.Connected)
{
- if (Players.ContainsKey(item.Username))
- {
- Players.Remove(item.Username);
- CallDeferred(nameof(EmitPlayerLeaveGameSignal), item.Username);
- }
+ GD.PushWarning($"Multiplayer peer is not connected (status: {Multiplayer.MultiplayerPeer.GetConnectionStatus()}).");
+ return;
}
+
+ Rpc(nameof(OnPingRpc), Session?.Username ?? "Unknown");
}
- public async void _on_ping_button_down()
+ [Rpc(MultiplayerApi.RpcMode.AnyPeer)]
+ private void OnPingRpc(string senderName)
{
- var data = Encoding.UTF8.GetBytes("Hello World!");
-
- await socket.SendMatchStateAsync(match.Id, 1, data);
+ var remotePeerId = Multiplayer.GetRemoteSenderId();
+ GD.Print($"RPC ping received from peer {remotePeerId} ({senderName})");
}
public async void _on_start_matchmake_button_down()
{
- socket = Socket.From(client);
- await socket.ConnectAsync(Session);
+ if (_multiplayerBridge == null)
+ {
+ GD.PushWarning("Multiplayer bridge not initialized. Login first.");
+ return;
+ }
- socket.ReceivedMatchPresence += onMatchPresence;
- socket.ReceivedMatchState += onMatchState;
- socket.ReceivedMatchmakerMatched += onMatchmakerMatched;
var query = "+properties.skill:>50 properties.mode:deathmatch";
var stringProps = new Dictionary { { "mode", "deathmatch" } };
var numericProps = new Dictionary { { "skill", 100 } };
var matchmakerTicket = await socket.AddMatchmakerAsync(query, 2, 8, stringProps, numericProps);
- GD.Print($"created match ticket with ticket {matchmakerTicket.Ticket}");
-
- }
-
- private async void onMatchmakerMatched(IMatchmakerMatched matched)
- {
+ GD.Print($"[MM] Created matchmaker ticket: {matchmakerTicket.Ticket}");
+ _multiplayerBridge.StartMatchmaking(matchmakerTicket);
+ GD.Print("[MM] Bridge matchmaking started. Waiting for match assignment...");
- match = await socket.JoinMatchAsync(matched);
- GD.Print($"joined match with id {match.Id}");
}
#region Store Section
private async void _on_store_data_button_down()
@@ -292,12 +309,77 @@ private async void _on_delete_friend_button_down()
await client.DeleteFriendsAsync(Session, null, new[] { GetNode("Friends/FriendName").Text });
}
- public static async void SyncData(string data, int opcode) => await socket.SendMatchStateAsync(match.Id, opcode, data);
-
public void _on_ready_start_button_down()
{
- Players[Session.Username].Status = 1;
- SyncData(Session.Username, 3);
+ GD.Print("[READY] Ready/Start button pressed.");
+
+ if (Multiplayer == null || Multiplayer.MultiplayerPeer == null)
+ {
+ GD.PushWarning("Multiplayer peer is null. Join a match first.");
+ return;
+ }
+
+ if (Multiplayer.MultiplayerPeer.GetConnectionStatus() != MultiplayerPeer.ConnectionStatus.Connected)
+ {
+ GD.PushWarning($"Multiplayer peer is not connected (status: {Multiplayer.MultiplayerPeer.GetConnectionStatus()}).");
+ return;
+ }
+
+ var username = Session?.Username;
+ if (string.IsNullOrWhiteSpace(username))
+ {
+ GD.PushWarning("[READY] Session username is empty; cannot send ready RPC.");
+ return;
+ }
+
+ EnsurePlayerEntry(username);
+ GD.Print($"[READY] Sending ready for '{username}' (local peer {Multiplayer.GetUniqueId()}).");
+ OnReadyRpc(username);
+ Rpc(nameof(OnReadyRpc), username);
+ }
+
+ [Rpc(MultiplayerApi.RpcMode.AnyPeer)]
+ private void OnReadyRpc(string username)
+ {
+ if (string.IsNullOrWhiteSpace(username))
+ {
+ GD.PushWarning("[READY] Received ready RPC with empty username.");
+ return;
+ }
+
+ EnsurePlayerEntry(username);
+ Players[username].Status = 1;
+ CallDeferred(nameof(EmitReadyGameSignal), username);
+ GD.Print($"[READY] '{username}' marked ready. Ready players: {Players.Count(x => x.Value.Status == 1)}/{Players.Count}.");
+
+ if (!IsHost)
+ {
+ GD.Print("[READY] Local peer is not host; waiting for host start signal.");
+ return;
+ }
+
+ if (Players.Any(x => x.Value.Status == 0))
+ {
+ GD.Print("[READY] Host waiting: not all players are ready yet.");
+ return;
+ }
+
+ if (Multiplayer == null || Multiplayer.MultiplayerPeer == null || Multiplayer.MultiplayerPeer.GetConnectionStatus() != MultiplayerPeer.ConnectionStatus.Connected)
+ {
+ GD.PushWarning("[READY] Host cannot broadcast start; multiplayer peer is not connected.");
+ return;
+ }
+
+ GD.Print("[READY] Host confirmed all players ready. Broadcasting start game RPC.");
+ OnStartGameRpc();
+ Rpc(nameof(OnStartGameRpc));
+ }
+
+ [Rpc(MultiplayerApi.RpcMode.AnyPeer)]
+ private void OnStartGameRpc()
+ {
+ GD.Print("[START] Start game RPC received.");
+ CallDeferred(nameof(EmitStartGameSignal), Session?.Username ?? "");
}
#endregion
#region Group Section
@@ -476,9 +558,9 @@ public async void _on_ban_user_button_down(){
#region Chat
private void onChannelMessage(IApiChannelMessage message)
- {
+ {
GD.Print(message);
- ChatMessage currentMessage = JsonParser.FromJson(message.Content);
+ ChatMessage currentMessage = JsonParser.FromJson(message.Content);
if(currentMessage.Type == 0){
CallDeferred(nameof(onChannelMessageDeffered), currentMessage.ID, currentMessage.Message, currentMessage.User);
}
@@ -527,19 +609,19 @@ public async void _on_submit_chat_button_down(){
private async void subToFriendsChannels(){
var groupResult = await client.ListGroupsAsync(Session, null, 100, null);
foreach (var item in groupResult.Groups)
- {
- await AddToChat(item.Id, item.Name, ChannelType.Group);
- }
+ {
+ await AddToChat(item.Id, item.Name, ChannelType.Group);
+ }
var result = await client.ListFriendsAsync(Session, null, 100, "");
foreach (var item in result.Friends)
- {
- await AddToChat(item.User.Id, item.User.DisplayName, ChannelType.DirectMessage);
- }
- }
+ {
+ await AddToChat(item.User.Id, item.User.DisplayName, ChannelType.DirectMessage);
+ }
+ }
- private async Task AddToChat(string id, string displayName, ChannelType channelType, bool presistant = true, bool publicRoom = false)
- {
+ private async Task AddToChat(string id, string displayName, ChannelType channelType, bool presistant = true, bool publicRoom = false)
+ {
try
{
var channel = await socket.JoinChatAsync(id, channelType, presistant, publicRoom);
@@ -571,10 +653,10 @@ private async Task AddToChat(string id, string displayName, ChannelType channelT
GD.Print("Error cant join chat " + displayName);
}
-
- }
+
+ }
- private async void onChatTabChanged(IChannel channel, long index){
+ private async void onChatTabChanged(IChannel channel, long index){
if((chatChannels[(int)index].ID ) == channel.Id){
currentChat = channel;
diff --git a/NakamaClient.cs.uid b/NakamaClient.cs.uid
new file mode 100644
index 0000000..7b23664
--- /dev/null
+++ b/NakamaClient.cs.uid
@@ -0,0 +1 @@
+uid://dqpbw1hbdvpja
diff --git a/PlayerInfo.cs b/PlayerInfo.cs
index e7ec2c7..f46f937 100644
--- a/PlayerInfo.cs
+++ b/PlayerInfo.cs
@@ -2,11 +2,33 @@
namespace NakamacSharpTutorial;
+///
+/// Stores lobby and profile information for a connected player.
+///
public class PlayerInfo
{
+ ///
+ /// Display name used in social/group features.
+ ///
public string Name;
+
+ ///
+ /// Sample progression level.
+ ///
public int Level;
+
+ ///
+ /// Sample matchmaking skill value.
+ ///
public int Skill;
+
+ ///
+ /// Unique runtime identifier (mapped to username in this sample).
+ ///
public string Id; // players unique id
+
+ ///
+ /// Ready/status flag used during pre-game flow.
+ ///
public int Status = 0; // if the player is ready or not or any other status
}
diff --git a/PlayerInfo.cs.uid b/PlayerInfo.cs.uid
new file mode 100644
index 0000000..a4967b1
--- /dev/null
+++ b/PlayerInfo.cs.uid
@@ -0,0 +1 @@
+uid://c53nh7bpa3n8
diff --git a/PlayerSyncData.cs b/PlayerSyncData.cs
deleted file mode 100644
index a24a1fd..0000000
--- a/PlayerSyncData.cs
+++ /dev/null
@@ -1,11 +0,0 @@
-using System;
-using Godot;
-
-namespace NakamacSharpTutorial;
-
-public class PlayerSyncData
-{
- public string Id;
- public Vector2 Position;
- public float RotationDegrees;
-}
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..9bbeab4
--- /dev/null
+++ b/README.md
@@ -0,0 +1,150 @@
+# Nakama cSharp Tutorial (Godot + C#)
+
+A multiplayer sample project built with **Godot (C#/.NET)** and **Nakama**.
+
+This project demonstrates:
+- Authentication with Nakama
+- Match join/host flow
+- Multiplayer peer bridging (`NakamaMultiplayerPeer` + `NakamaMultiplayerBridge`)
+- RPC-based gameplay communication
+- Player authority assignment per peer
+- Basic chat, group, and storage examples
+
+---
+
+## Requirements
+
+Before running the project, install:
+
+- **Godot 4.6 .NET** (or compatible 4.x .NET version)
+- **.NET SDK 8.0+**
+- A running **Nakama server**
+
+Optional but recommended:
+- Git
+- VS Code (or JetBrains Rider)
+
+---
+
+## Clone the project
+
+```bash
+git clone
+cd Nakama-cSharp-Tutorial
+```
+
+---
+
+## Install dependencies (NuGet)
+
+This project uses NuGet packages (including `NakamaClient`).
+
+### 1) Restore packages
+
+```bash
+dotnet restore
+```
+
+### 2) Install/Update NakamaClient package (if needed)
+
+```bash
+dotnet add package NakamaClient
+```
+
+If you want to pin the version used by this project:
+
+```bash
+dotnet add package NakamaClient --version 3.21.2
+```
+
+The project also uses Newtonsoft.Json:
+
+```bash
+dotnet add package Newtonsoft.Json --version 13.0.3
+```
+
+---
+
+## Configure Nakama connection
+
+Open the project in Godot and select the `NakamaClient` node (script: `NakamaClient.cs`).
+
+Set exported fields as needed:
+- `NakamaScheme` (example: `http`)
+- `NakamaHost` (example: `127.0.0.1`)
+- `NakamaPort` (example: `7350`)
+- `NakamaKey` (example: `defaultkey`)
+
+---
+
+## Run the project
+
+### Option A: Run from Godot
+1. Open project in Godot.
+2. Build C# solution when prompted.
+3. Run the main scene.
+
+### Option B: Build with dotnet
+
+```bash
+dotnet build
+```
+
+Then run from Godot editor.
+
+---
+
+## Multiplayer quick test (2 clients)
+
+1. Start Nakama server.
+2. Launch two game instances.
+3. Login with two different users.
+4. In both clients, use the same lobby name and click **Join**.
+5. Click **Ready/Start**.
+
+Expected behavior:
+- Both players are created in the scene.
+- Authority is assigned to each player peer.
+- No `MultiplayerSynchronizer` missing-node startup errors.
+
+---
+
+## Project structure (important files)
+
+- `NakamaClient.cs` - login, matchmaking, ready/start, and high-level network flow
+- `Nakama/NakamaMultiplayerBridge.cs` - maps Nakama match traffic to Godot multiplayer
+- `Nakama/NakamaMultiplayerPeer.cs` - custom `MultiplayerPeerExtension`
+- `SceneManager.cs` - player instantiation and authority setup
+- `CharacterController.cs` - movement logic with authority checks
+
+---
+
+## Troubleshooting
+
+### NuGet package not found
+Run:
+
+```bash
+dotnet restore
+dotnet nuget locals all --clear
+dotnet restore
+```
+
+### Build errors in C# scripts
+Make sure your .NET SDK is 8.0+:
+
+```bash
+dotnet --version
+```
+
+### Cannot connect to Nakama
+Check:
+- Server is running
+- Host/Port/Scheme/Key values in `NakamaClient` exports
+- Firewall/network access
+
+---
+
+## License
+
+This project is provided under the terms in [LICENSE](LICENSE).
diff --git a/SceneManager.cs b/SceneManager.cs
index 7ab774c..73edbd1 100644
--- a/SceneManager.cs
+++ b/SceneManager.cs
@@ -2,7 +2,11 @@
using Godot.Collections;
using System;
using System.Linq;
+using Nakama;
+///
+/// Spawns player nodes for current lobby users and assigns multiplayer authority.
+///
public partial class SceneManager : Node2D
{
[Export]
@@ -11,18 +15,44 @@ public partial class SceneManager : Node2D
// Called when the node enters the scene tree for the first time.
public override void _Ready()
{
+ if (PlayerScene == null)
+ {
+ GD.PushError("[SPAWN] PlayerScene is null; cannot spawn players.");
+ return;
+ }
+
spawnPoints = GetTree().GetNodesInGroup("SpawnPoint");
int index = 0;
+ var usernames = NakamaClient.Players.Keys.OrderBy(k => k).ToList();
// 1 2
// 2 1
- foreach (var key in NakamaClient.Players.Keys.OrderBy(k => k))
+ foreach (var key in usernames)
{
var player = PlayerScene.Instantiate();
player.Name = NakamaClient.Players[key].Id;
- if(spawnPoints[index] is Node2D spawnpoint){
- player.SetupPlayer(NakamaClient.Players[key].Id, spawnpoint.GlobalPosition);
+ var authorityPeerId = ResolvePeerIdForUsername(NakamaClient.Players[key].Id);
+ if (authorityPeerId > 0)
+ {
+ player.SetMultiplayerAuthority(authorityPeerId);
+ GD.Print($"[AUTH] Player '{player.Name}' authority set to peer {authorityPeerId}.");
+ }
+ else
+ {
+ GD.PushWarning($"[AUTH] Could not resolve authority peer for '{player.Name}'.");
+ }
+
+ Vector2 spawnPosition = Vector2.Zero;
+ if (index < spawnPoints.Count && spawnPoints[index] is Node2D spawnpoint)
+ {
+ spawnPosition = spawnpoint.GlobalPosition;
}
+ else
+ {
+ GD.PushWarning($"[SPAWN] Missing spawn point for player '{player.Name}' at index {index}. Using Vector2.Zero.");
+ }
+
+ player.SetupPlayer(NakamaClient.Players[key].Id, spawnPosition);
AddChild(player);
index++;
}
@@ -33,4 +63,39 @@ public override void _Ready()
public override void _Process(double delta)
{
}
+
+ ///
+ /// Resolves a Godot peer id from a username using local multiplayer state and bridge mappings.
+ ///
+ /// Username to resolve.
+ /// Peer id when resolved; otherwise 0.
+ private int ResolvePeerIdForUsername(string username)
+ {
+ if (string.IsNullOrWhiteSpace(username) || Multiplayer == null || Multiplayer.MultiplayerPeer == null)
+ {
+ return 0;
+ }
+
+ if (NakamaClient.Session != null && username == NakamaClient.Session.Username)
+ {
+ return Multiplayer.GetUniqueId();
+ }
+
+ var bridge = NakamaClient.MultiplayerBridge;
+ if (bridge == null)
+ {
+ return 0;
+ }
+
+ foreach (var peerId in Multiplayer.GetPeers())
+ {
+ var presence = bridge.GetUserPresenceForPeer(peerId);
+ if (presence != null && presence.Username == username)
+ {
+ return peerId;
+ }
+ }
+
+ return 0;
+ }
}
diff --git a/SceneManager.cs.uid b/SceneManager.cs.uid
new file mode 100644
index 0000000..713f208
--- /dev/null
+++ b/SceneManager.cs.uid
@@ -0,0 +1 @@
+uid://d2jq5aqt04tfs
diff --git a/TestScene.tscn b/TestScene.tscn
index 668c536..2224f2e 100644
--- a/TestScene.tscn
+++ b/TestScene.tscn
@@ -1,20 +1,20 @@
-[gd_scene load_steps=2 format=3 uid="uid://2r6fgbtgriyb"]
+[gd_scene format=3 uid="uid://2r6fgbtgriyb"]
-[ext_resource type="Script" path="res://NakamaClient.cs" id="1_4lgs5"]
+[ext_resource type="Script" uid="uid://dqpbw1hbdvpja" path="res://NakamaClient.cs" id="1_4lgs5"]
-[node name="MainUI" type="Control"]
+[node name="MainUI" type="Control" unique_id=1033373455]
layout_mode = 3
anchors_preset = 0
script = ExtResource("1_4lgs5")
-[node name="LoginPanel" type="Panel" parent="."]
+[node name="LoginPanel" type="Panel" parent="." unique_id=726260194]
layout_mode = 0
offset_left = 46.0
offset_top = 31.0
offset_right = 267.0
offset_bottom = 193.0
-[node name="Username" type="LineEdit" parent="LoginPanel"]
+[node name="Username" type="LineEdit" parent="LoginPanel" unique_id=1875270073]
layout_mode = 0
offset_left = 81.0
offset_top = 15.0
@@ -22,7 +22,7 @@ offset_right = 210.0
offset_bottom = 46.0
text = "test@gmail.com"
-[node name="Password" type="LineEdit" parent="LoginPanel"]
+[node name="Password" type="LineEdit" parent="LoginPanel" unique_id=369449718]
layout_mode = 0
offset_left = 81.0
offset_top = 58.0
@@ -30,21 +30,21 @@ offset_right = 210.0
offset_bottom = 89.0
text = "password"
-[node name="Label" type="Label" parent="LoginPanel"]
+[node name="Label" type="Label" parent="LoginPanel" unique_id=192963011]
layout_mode = 0
offset_top = 20.0
offset_right = 83.0
offset_bottom = 43.0
text = "UserName"
-[node name="Label2" type="Label" parent="LoginPanel"]
+[node name="Label2" type="Label" parent="LoginPanel" unique_id=380306824]
layout_mode = 0
offset_top = 60.0
offset_right = 83.0
offset_bottom = 83.0
text = "Password"
-[node name="LoginButton" type="Button" parent="LoginPanel"]
+[node name="LoginButton" type="Button" parent="LoginPanel" unique_id=2128094329]
layout_mode = 0
offset_left = 118.0
offset_top = 100.0
@@ -52,14 +52,14 @@ offset_right = 204.0
offset_bottom = 132.0
text = "Submit"
-[node name="MatchMaking" type="Panel" parent="."]
+[node name="MatchMaking" type="Panel" parent="." unique_id=1783711301]
layout_mode = 0
offset_left = 344.0
offset_top = 27.0
offset_right = 654.0
offset_bottom = 185.0
-[node name="Join" type="Button" parent="MatchMaking"]
+[node name="Join" type="Button" parent="MatchMaking" unique_id=1131309755]
layout_mode = 0
offset_left = 165.0
offset_top = 59.0
@@ -67,7 +67,7 @@ offset_right = 296.0
offset_bottom = 96.0
text = "Join"
-[node name="StartMatchmake" type="Button" parent="MatchMaking"]
+[node name="StartMatchmake" type="Button" parent="MatchMaking" unique_id=1617857301]
layout_mode = 0
offset_left = 160.0
offset_top = 109.0
@@ -75,7 +75,7 @@ offset_right = 302.0
offset_bottom = 146.0
text = "Start Matchmake"
-[node name="Ping" type="Button" parent="MatchMaking"]
+[node name="Ping" type="Button" parent="MatchMaking" unique_id=90370057]
layout_mode = 0
offset_left = 16.0
offset_top = 59.0
@@ -83,7 +83,7 @@ offset_right = 147.0
offset_bottom = 96.0
text = "Ping"
-[node name="Label" type="Label" parent="MatchMaking"]
+[node name="Label" type="Label" parent="MatchMaking" unique_id=846798949]
layout_mode = 0
offset_left = 10.0
offset_top = 18.0
@@ -91,7 +91,7 @@ offset_right = 132.0
offset_bottom = 41.0
text = "Name Of Match"
-[node name="LobbyName" type="LineEdit" parent="MatchMaking"]
+[node name="LobbyName" type="LineEdit" parent="MatchMaking" unique_id=1562162743]
layout_mode = 0
offset_left = 141.0
offset_top = 14.0
@@ -99,7 +99,7 @@ offset_right = 295.0
offset_bottom = 45.0
text = "test"
-[node name="ReadyStart" type="Button" parent="MatchMaking"]
+[node name="ReadyStart" type="Button" parent="MatchMaking" unique_id=529745597]
layout_mode = 0
offset_left = 13.0
offset_top = 112.0
@@ -107,7 +107,7 @@ offset_right = 144.0
offset_bottom = 143.0
text = "Ready/Start"
-[node name="Storage" type="Panel" parent="."]
+[node name="Storage" type="Panel" parent="." unique_id=1208130517]
visible = false
layout_mode = 0
offset_left = 11.0
@@ -115,7 +115,7 @@ offset_top = 511.0
offset_right = 203.0
offset_bottom = 636.0
-[node name="StoreData" type="Button" parent="Storage"]
+[node name="StoreData" type="Button" parent="Storage" unique_id=2018660026]
layout_mode = 0
offset_left = 15.0
offset_top = 8.0
@@ -123,7 +123,7 @@ offset_right = 179.0
offset_bottom = 39.0
text = "Store Data"
-[node name="GetData" type="Button" parent="Storage"]
+[node name="GetData" type="Button" parent="Storage" unique_id=446961341]
layout_mode = 0
offset_left = 15.0
offset_top = 46.0
@@ -131,7 +131,7 @@ offset_right = 181.0
offset_bottom = 77.0
text = "Get Data From Store"
-[node name="ListData" type="Button" parent="Storage"]
+[node name="ListData" type="Button" parent="Storage" unique_id=1019750484]
layout_mode = 0
offset_left = 15.0
offset_top = 81.0
@@ -139,7 +139,7 @@ offset_right = 181.0
offset_bottom = 112.0
text = "List Data From Store"
-[node name="Friends" type="Panel" parent="."]
+[node name="Friends" type="Panel" parent="." unique_id=506924310]
visible = false
layout_mode = 0
offset_left = 356.0
@@ -147,7 +147,7 @@ offset_top = 246.0
offset_right = 644.0
offset_bottom = 426.0
-[node name="GetFriends" type="Button" parent="Friends"]
+[node name="GetFriends" type="Button" parent="Friends" unique_id=1184972914]
layout_mode = 0
offset_left = 9.0
offset_top = 68.0
@@ -155,7 +155,7 @@ offset_right = 127.0
offset_bottom = 103.0
text = "Get Friend"
-[node name="AddFriend" type="Button" parent="Friends"]
+[node name="AddFriend" type="Button" parent="Friends" unique_id=1986417501]
layout_mode = 0
offset_left = 153.0
offset_top = 68.0
@@ -163,7 +163,7 @@ offset_right = 271.0
offset_bottom = 103.0
text = "Add Friend"
-[node name="BlockFriend" type="Button" parent="Friends"]
+[node name="BlockFriend" type="Button" parent="Friends" unique_id=36232398]
layout_mode = 0
offset_left = 11.0
offset_top = 129.0
@@ -171,7 +171,7 @@ offset_right = 129.0
offset_bottom = 164.0
text = "Block Friend"
-[node name="DeleteFriend" type="Button" parent="Friends"]
+[node name="DeleteFriend" type="Button" parent="Friends" unique_id=245963301]
layout_mode = 0
offset_left = 150.0
offset_top = 129.0
@@ -179,7 +179,7 @@ offset_right = 268.0
offset_bottom = 164.0
text = "Delete Friend"
-[node name="FriendName" type="LineEdit" parent="Friends"]
+[node name="FriendName" type="LineEdit" parent="Friends" unique_id=1332598673]
layout_mode = 0
offset_left = 22.0
offset_top = 22.0
@@ -187,14 +187,14 @@ offset_right = 269.0
offset_bottom = 53.0
placeholder_text = "Name Of Friend"
-[node name="Group" type="Panel" parent="."]
+[node name="Group" type="Panel" parent="." unique_id=1393646522]
layout_mode = 0
offset_left = 36.0
offset_top = 219.0
offset_right = 532.0
offset_bottom = 619.0
-[node name="GroupName" type="LineEdit" parent="Group"]
+[node name="GroupName" type="LineEdit" parent="Group" unique_id=2071761121]
layout_mode = 0
offset_left = 23.0
offset_top = 18.0
@@ -202,7 +202,7 @@ offset_right = 245.0
offset_bottom = 49.0
placeholder_text = "Name Of Group"
-[node name="CreateGroup" type="Button" parent="Group"]
+[node name="CreateGroup" type="Button" parent="Group" unique_id=1961387222]
layout_mode = 0
offset_left = 16.0
offset_top = 61.0
@@ -210,7 +210,7 @@ offset_right = 101.0
offset_bottom = 95.0
text = "Create Group"
-[node name="GetGroup" type="Button" parent="Group"]
+[node name="GetGroup" type="Button" parent="Group" unique_id=596015656]
layout_mode = 0
offset_left = 143.0
offset_top = 105.0
@@ -218,7 +218,7 @@ offset_right = 252.0
offset_bottom = 139.0
text = "Get Group"
-[node name="JoinGroup" type="Button" parent="Group"]
+[node name="JoinGroup" type="Button" parent="Group" unique_id=1290442675]
layout_mode = 0
offset_left = 20.0
offset_top = 153.0
@@ -226,7 +226,7 @@ offset_right = 129.0
offset_bottom = 187.0
text = "Join Group"
-[node name="PromoteUser" type="Button" parent="Group"]
+[node name="PromoteUser" type="Button" parent="Group" unique_id=22003878]
layout_mode = 0
offset_left = 20.0
offset_top = 201.0
@@ -234,7 +234,7 @@ offset_right = 129.0
offset_bottom = 235.0
text = "Promote User"
-[node name="DemoteUser" type="Button" parent="Group"]
+[node name="DemoteUser" type="Button" parent="Group" unique_id=1073464516]
layout_mode = 0
offset_left = 145.0
offset_top = 201.0
@@ -242,7 +242,7 @@ offset_right = 254.0
offset_bottom = 235.0
text = "Demote User"
-[node name="KickUser" type="Button" parent="Group"]
+[node name="KickUser" type="Button" parent="Group" unique_id=1392736763]
layout_mode = 0
offset_left = 145.0
offset_top = 245.0
@@ -250,7 +250,7 @@ offset_right = 254.0
offset_bottom = 279.0
text = "Kick User"
-[node name="BanUser" type="Button" parent="Group"]
+[node name="BanUser" type="Button" parent="Group" unique_id=1490041880]
layout_mode = 0
offset_left = 18.0
offset_top = 245.0
@@ -258,7 +258,7 @@ offset_right = 127.0
offset_bottom = 279.0
text = "Ban User"
-[node name="LeaveGroup" type="Button" parent="Group"]
+[node name="LeaveGroup" type="Button" parent="Group" unique_id=1093349187]
layout_mode = 0
offset_left = 142.0
offset_top = 153.0
@@ -266,7 +266,7 @@ offset_right = 251.0
offset_bottom = 187.0
text = "Leave Group"
-[node name="CloseGroup" type="Button" parent="Group"]
+[node name="CloseGroup" type="Button" parent="Group" unique_id=299189686]
layout_mode = 0
offset_left = 17.0
offset_top = 106.0
@@ -274,7 +274,7 @@ offset_right = 129.0
offset_bottom = 140.0
text = "Close Group"
-[node name="DeleteGroup" type="Button" parent="Group"]
+[node name="DeleteGroup" type="Button" parent="Group" unique_id=1148830100]
layout_mode = 0
offset_left = 142.0
offset_top = 62.0
@@ -282,14 +282,14 @@ offset_right = 253.0
offset_bottom = 96.0
text = "Delete Group"
-[node name="GroupMembers" type="Panel" parent="Group"]
+[node name="GroupMembers" type="Panel" parent="Group" unique_id=1669786988]
layout_mode = 0
offset_left = 788.0
offset_top = 148.0
offset_right = 1002.0
offset_bottom = 379.0
-[node name="VBoxContainer" type="VBoxContainer" parent="Group/GroupMembers"]
+[node name="VBoxContainer" type="VBoxContainer" parent="Group/GroupMembers" unique_id=906780290]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
@@ -301,7 +301,7 @@ offset_bottom = -3.0
grow_horizontal = 2
grow_vertical = 2
-[node name="RichTextLabel" type="RichTextLabel" parent="Group/GroupMembers"]
+[node name="RichTextLabel" type="RichTextLabel" parent="Group/GroupMembers" unique_id=1673048444]
layout_mode = 1
anchors_preset = 10
anchor_right = 1.0
@@ -311,7 +311,7 @@ grow_horizontal = 2
bbcode_enabled = true
text = "[center][b]Group Members"
-[node name="VBoxContainer" type="VBoxContainer" parent="Group"]
+[node name="VBoxContainer" type="VBoxContainer" parent="Group" unique_id=638352780]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
@@ -323,7 +323,7 @@ offset_bottom = -3.0
grow_horizontal = 2
grow_vertical = 2
-[node name="RichTextLabel" type="RichTextLabel" parent="Group"]
+[node name="RichTextLabel" type="RichTextLabel" parent="Group" unique_id=197707446]
layout_mode = 1
anchors_preset = 10
anchor_right = 1.0
@@ -334,35 +334,38 @@ grow_horizontal = 2
bbcode_enabled = true
text = "[center][b]Group List"
-[node name="EditGroup" type="Panel" parent="Group"]
+[node name="EditGroup" type="Panel" parent="Group" unique_id=1047898050]
layout_mode = 0
offset_left = 503.0
offset_top = 79.0
offset_right = 770.0
offset_bottom = 377.0
-[node name="GroupName" type="LineEdit" parent="Group/EditGroup"]
+[node name="GroupName" type="LineEdit" parent="Group/EditGroup" unique_id=861720056]
+layout_mode = 0
offset_left = 21.0
offset_top = 18.0
offset_right = 243.0
offset_bottom = 49.0
placeholder_text = "Name Of Group"
-[node name="GroupDescription" type="LineEdit" parent="Group/EditGroup"]
+[node name="GroupDescription" type="LineEdit" parent="Group/EditGroup" unique_id=1984168584]
+layout_mode = 0
offset_left = 21.0
offset_top = 62.0
offset_right = 243.0
offset_bottom = 93.0
placeholder_text = "Description Of Group"
-[node name="AvatarURL" type="LineEdit" parent="Group/EditGroup"]
+[node name="AvatarURL" type="LineEdit" parent="Group/EditGroup" unique_id=467978388]
+layout_mode = 0
offset_left = 21.0
offset_top = 142.0
offset_right = 243.0
offset_bottom = 173.0
placeholder_text = "Avatar URL"
-[node name="CloseOpenGroup" type="CheckButton" parent="Group/EditGroup"]
+[node name="CloseOpenGroup" type="CheckButton" parent="Group/EditGroup" unique_id=1668202125]
layout_mode = 0
offset_left = 20.0
offset_top = 104.0
@@ -370,14 +373,14 @@ offset_right = 115.0
offset_bottom = 135.0
text = "Open "
-[node name="OptionButton" type="OptionButton" parent="Group/EditGroup"]
+[node name="OptionButton" type="OptionButton" parent="Group/EditGroup" unique_id=1558099889]
layout_mode = 0
offset_left = 99.0
offset_top = 191.0
offset_right = 251.0
offset_bottom = 224.0
-item_count = 3
selected = 1
+item_count = 3
popup/item_0/text = "English"
popup/item_0/id = 0
popup/item_1/text = "Spanish"
@@ -385,7 +388,7 @@ popup/item_1/id = 1
popup/item_2/text = "Japanese"
popup/item_2/id = 2
-[node name="Label" type="Label" parent="Group/EditGroup"]
+[node name="Label" type="Label" parent="Group/EditGroup" unique_id=1996324921]
layout_mode = 0
offset_left = 11.0
offset_top = 197.0
@@ -393,7 +396,7 @@ offset_right = 86.0
offset_bottom = 220.0
text = "Language"
-[node name="EditGroupButton" type="Button" parent="Group/EditGroup"]
+[node name="EditGroupButton" type="Button" parent="Group/EditGroup" unique_id=1367830912]
layout_mode = 0
offset_left = 145.0
offset_top = 245.0
@@ -401,14 +404,14 @@ offset_right = 244.0
offset_bottom = 281.0
text = "Submit"
-[node name="Chat" type="Panel" parent="."]
+[node name="Chat" type="Panel" parent="." unique_id=1297610916]
layout_mode = 0
offset_left = 689.0
offset_top = 18.0
offset_right = 1138.0
offset_bottom = 293.0
-[node name="SelectedChat" type="LineEdit" parent="Chat"]
+[node name="SelectedChat" type="LineEdit" parent="Chat" unique_id=2136779485]
layout_mode = 0
offset_left = 16.0
offset_top = 27.0
@@ -416,7 +419,7 @@ offset_right = 170.0
offset_bottom = 58.0
placeholder_text = "Chat Name"
-[node name="JoinChat" type="Button" parent="Chat"]
+[node name="JoinChat" type="Button" parent="Chat" unique_id=532739523]
layout_mode = 0
offset_left = 12.0
offset_top = 73.0
@@ -424,7 +427,7 @@ offset_right = 169.0
offset_bottom = 104.0
text = "Join Chat"
-[node name="JoinGroupChat" type="Button" parent="Chat"]
+[node name="JoinGroupChat" type="Button" parent="Chat" unique_id=1853348348]
layout_mode = 0
offset_left = 12.0
offset_top = 118.0
@@ -432,7 +435,7 @@ offset_right = 169.0
offset_bottom = 149.0
text = "Join Group Chat"
-[node name="JoinDirectChat" type="Button" parent="Chat"]
+[node name="JoinDirectChat" type="Button" parent="Chat" unique_id=456145735]
layout_mode = 0
offset_left = 12.0
offset_top = 162.0
@@ -440,21 +443,21 @@ offset_right = 169.0
offset_bottom = 193.0
text = "Join Direct Chat"
-[node name="TabContainer" type="TabContainer" parent="Chat"]
+[node name="TabContainer" type="TabContainer" parent="Chat" unique_id=1895171998]
layout_mode = 0
offset_left = 190.0
offset_top = -1.0
offset_right = 446.0
offset_bottom = 228.0
-[node name="ChatText" type="LineEdit" parent="Chat"]
+[node name="ChatText" type="LineEdit" parent="Chat" unique_id=1197337634]
layout_mode = 0
offset_left = 193.0
offset_top = 237.0
offset_right = 393.0
offset_bottom = 268.0
-[node name="SubmitChat" type="Button" parent="Chat"]
+[node name="SubmitChat" type="Button" parent="Chat" unique_id=144705330]
layout_mode = 0
offset_left = 402.0
offset_top = 237.0
diff --git a/Texture/TX Chest Animation.png.import b/Texture/TX Chest Animation.png.import
index 4539228..8b7a584 100644
--- a/Texture/TX Chest Animation.png.import
+++ b/Texture/TX Chest Animation.png.import
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/TX Chest Animation.png-e842fba40aff9e192c293c
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
@@ -25,6 +27,10 @@ mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
diff --git a/Texture/TX FX Flame.png.import b/Texture/TX FX Flame.png.import
index 48e8c63..2cb240d 100644
--- a/Texture/TX FX Flame.png.import
+++ b/Texture/TX FX Flame.png.import
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/TX FX Flame.png-80d15c79101d825f142cfc9a8dd71
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
@@ -25,6 +27,10 @@ mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
diff --git a/Texture/TX FX Torch Flame.png.import b/Texture/TX FX Torch Flame.png.import
index d400acd..2dfa178 100644
--- a/Texture/TX FX Torch Flame.png.import
+++ b/Texture/TX FX Torch Flame.png.import
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/TX FX Torch Flame.png-3f0823327d6ab4c4c921196
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
@@ -25,6 +27,10 @@ mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
diff --git a/Texture/TX Tileset Ground.png.import b/Texture/TX Tileset Ground.png.import
index bd2b3db..4046fd9 100644
--- a/Texture/TX Tileset Ground.png.import
+++ b/Texture/TX Tileset Ground.png.import
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/TX Tileset Ground.png-09c49e674b4e05fa0e17401
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
@@ -25,6 +27,10 @@ mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
diff --git a/Texture/TX Village Props.png.import b/Texture/TX Village Props.png.import
index 4bea66c..87fc574 100644
--- a/Texture/TX Village Props.png.import
+++ b/Texture/TX Village Props.png.import
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/TX Village Props.png-111f6fb80172e48598527cc8
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
@@ -25,6 +27,10 @@ mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
diff --git a/game_manager.tscn b/game_manager.tscn
index 2e04089..37271eb 100644
--- a/game_manager.tscn
+++ b/game_manager.tscn
@@ -1,22 +1,23 @@
-[gd_scene load_steps=4 format=3 uid="uid://b422hd2avc0yi"]
+[gd_scene format=3 uid="uid://b422hd2avc0yi"]
-[ext_resource type="Script" path="res://GameManager.cs" id="1_n5me8"]
+[ext_resource type="Script" uid="uid://s4oj34akvow5" path="res://GameManager.cs" id="1_n5me8"]
[ext_resource type="PackedScene" uid="uid://dptajwlb40ilf" path="res://TestLevel.tscn" id="2_rt2xi"]
[ext_resource type="PackedScene" uid="uid://2r6fgbtgriyb" path="res://TestScene.tscn" id="2_ybjp3"]
-[node name="GameManager" type="Node2D"]
+[node name="GameManager" type="Node2D" unique_id=1221946422]
script = ExtResource("1_n5me8")
LevelToLoad = ExtResource("2_rt2xi")
-[node name="UI" type="Control" parent="."]
+[node name="UI" type="Control" parent="." unique_id=2138017755]
layout_mode = 3
anchors_preset = 0
offset_right = 1150.0
offset_bottom = 649.0
-[node name="Node2D" parent="UI" instance=ExtResource("2_ybjp3")]
+[node name="Node2D" parent="UI" unique_id=154996482 instance=ExtResource("2_ybjp3")]
+layout_mode = 0
offset_left = -1.0
offset_right = 1154.0
offset_bottom = 653.0
-[node name="World" type="Node2D" parent="."]
+[node name="World" type="Node2D" parent="." unique_id=2124703484]
diff --git a/icon.svg.import b/icon.svg.import
index f572be2..5d58816 100644
--- a/icon.svg.import
+++ b/icon.svg.import
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.cte
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
@@ -25,6 +27,10 @@ mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
diff --git a/player.tscn b/player.tscn
index ffb113d..79cec36 100644
--- a/player.tscn
+++ b/player.tscn
@@ -1,25 +1,33 @@
-[gd_scene load_steps=4 format=3 uid="uid://cknogbxebb10q"]
+[gd_scene format=3 uid="uid://cknogbxebb10q"]
-[ext_resource type="Script" path="res://CharacterController.cs" id="1_gdga8"]
+[ext_resource type="Script" uid="uid://caxbbb8xkusye" path="res://CharacterController.cs" id="1_gdga8"]
[ext_resource type="Texture2D" uid="uid://dbedibclr5b55" path="res://icon.svg" id="2_wafxa"]
[sub_resource type="RectangleShape2D" id="RectangleShape2D_85hy8"]
-[node name="Player" type="CharacterBody2D"]
+[sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_onrkg"]
+properties/0/path = NodePath(".:position")
+properties/0/spawn = true
+properties/0/replication_mode = 1
+
+[node name="Player" type="CharacterBody2D" unique_id=484417896]
script = ExtResource("1_gdga8")
-[node name="Sprite2D" type="Sprite2D" parent="."]
+[node name="Sprite2D" type="Sprite2D" parent="." unique_id=2110312538]
position = Vector2(-1.90735e-06, -1.90735e-06)
scale = Vector2(0.15625, 0.15625)
texture = ExtResource("2_wafxa")
-[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
+[node name="CollisionShape2D" type="CollisionShape2D" parent="." unique_id=1139746439]
shape = SubResource("RectangleShape2D_85hy8")
-[node name="Label" type="Label" parent="."]
+[node name="Label" type="Label" parent="." unique_id=722230666]
offset_left = -39.0
offset_top = -29.0
offset_right = 40.0
offset_bottom = -6.0
theme_override_font_sizes/font_size = 13
text = "username"
+
+[node name="MultiplayerSynchronizer" type="MultiplayerSynchronizer" parent="." unique_id=1200182064]
+replication_config = SubResource("SceneReplicationConfig_onrkg")
diff --git a/project.godot b/project.godot
index 4b6f7e3..a50fca6 100644
--- a/project.godot
+++ b/project.godot
@@ -8,10 +8,14 @@
config_version=5
+[animation]
+
+compatibility/default_parent_skeleton_in_mesh_instance_3d=true
+
[application]
config/name="Nakama cSharp Tutorial"
-config/features=PackedStringArray("4.2", "C#", "Forward Plus")
+config/features=PackedStringArray("4.6", "C#", "Forward Plus")
config/icon="res://icon.svg"
[dotnet]