diff --git a/scene/GameScene.tscn b/scene/GameScene.tscn index 9d340eb..62e943a 100644 --- a/scene/GameScene.tscn +++ b/scene/GameScene.tscn @@ -11,6 +11,5 @@ BlockContainerPath = NodePath("World/Blocks") [node name="World" type="Node" parent="."] [node name="Players" type="Node" parent="World"] -pause_mode = 1 [node name="Blocks" type="Node" parent="World"] diff --git a/src/EscapeMenuAppearance.cs b/src/EscapeMenuAppearance.cs index 5989964..a2a2477 100644 --- a/src/EscapeMenuAppearance.cs +++ b/src/EscapeMenuAppearance.cs @@ -20,7 +20,7 @@ public class EscapeMenuAppearance : CenterContainer ColorSlider.Value = GD.Randf(); ColorPreview.Modulate = Color.FromHsv((float)ColorSlider.Value, 1.0F, 1.0F); - this.GetClient().OnConnected += () => + this.GetClient().Connected += () => this.GetClient().RPC(Player.ChangeAppearance, DisplayName.Text, ColorPreview.Modulate); } @@ -50,7 +50,7 @@ public class EscapeMenuAppearance : CenterContainer if (IsVisibleInTree()) return; var client = this.GetClient(); // TODO: Find a better way to know if we're connected? - if (client.CustomMultiplayer.NetworkPeer?.GetConnectionStatus() == NetworkedMultiplayerPeer.ConnectionStatus.Connected) + if (client.Status == NetworkedMultiplayerPeer.ConnectionStatus.Connected) client.RPC(Player.ChangeAppearance, DisplayName.Text, ColorPreview.Modulate); } } diff --git a/src/EscapeMenuMultiplayer.cs b/src/EscapeMenuMultiplayer.cs index 0caf271..6913129 100644 --- a/src/EscapeMenuMultiplayer.cs +++ b/src/EscapeMenuMultiplayer.cs @@ -1,5 +1,7 @@ +using System; using System.Text.RegularExpressions; using Godot; +using static Godot.NetworkedMultiplayerPeer; public class EscapeMenuMultiplayer : Container { @@ -17,7 +19,7 @@ public class EscapeMenuMultiplayer : Container public Button ClientDisConnect { get; private set; } public LineEdit ClientAddress { get; private set; } - public IntegratedServer Server { get; private set; } + public IntegratedServer IntegratedServer { get; private set; } public override void _Ready() { @@ -30,54 +32,57 @@ public class EscapeMenuMultiplayer : Container ServerPort.PlaceholderText = DEFAULT_PORT.ToString(); ClientAddress.PlaceholderText = $"localhost:{DEFAULT_PORT}"; + this.GetClient().StatusChanged += OnStatusChanged; CallDeferred(nameof(SetupIntegratedServer)); } private void SetupIntegratedServer() { - Server = new IntegratedServer(); - this.GetClient().AddChild(Server); + IntegratedServer = new IntegratedServer(); + this.GetClient().AddChild(IntegratedServer); CallDeferred(nameof(StartIntegratedServerAndConnect)); } private void StartIntegratedServerAndConnect() { - Server.Start(DEFAULT_PORT); - this.GetClient().Connect("localhost", DEFAULT_PORT); + var port = IntegratedServer.Server.StartSingleplayer(); + this.GetClient().Connect("127.0.0.1", port); } + private void OnStatusChanged(ConnectionStatus status) + { + switch (status) { + case ConnectionStatus.Disconnected: + Status.Text = "Disconnected"; + Status.Modulate = Colors.Red; + break; + case ConnectionStatus.Connecting: + Status.Text = "Connecting ..."; + Status.Modulate = Colors.Yellow; + break; + case ConnectionStatus.Connected: + if (IntegratedServer == null) { + Status.Text = "Connected!"; + Status.Modulate = Colors.Green; + } else if (IntegratedServer.Server.IsSingleplayer) { + Status.Text = "Singleplayer"; + Status.Modulate = Colors.White; + } else { + Status.Text = "Server Running"; + Status.Modulate = Colors.Green; + } + break; + } + + ServerPort.Editable = IntegratedServer != null; + ServerOpenClose.Disabled = IntegratedServer == null; + ServerOpenClose.Text = (IntegratedServer?.Server.IsSingleplayer == false) ? "Close Server" : "Open Server"; + ClientDisConnect.Text = ((IntegratedServer != null) || (status == ConnectionStatus.Disconnected)) ? "Connect" : "Disconnect"; - // private void OnNetworkStatusChanged(NetworkStatus status) - // { - // switch (status) { - // case NetworkStatus.NoConnection: - // Status.Text = "No Connection"; - // Status.Modulate = Colors.Red; - // break; - // case NetworkStatus.ServerRunning: - // Status.Text = "Server Running"; - // Status.Modulate = Colors.Green; - // break; - // case NetworkStatus.Connecting: - // Status.Text = "Connecting ..."; - // Status.Modulate = Colors.Yellow; - // break; - // case NetworkStatus.Authenticating: - // Status.Text = "Authenticating ..."; - // Status.Modulate = Colors.YellowGreen; - // break; - // case NetworkStatus.ConnectedToServer: - // Status.Text = "Connected to Server"; - // Status.Modulate = Colors.Green; - // break; - // } - - // var noConnection = status == NetworkStatus.NoConnection; - // ServerPort.Editable = noConnection; - // ServerOpenClose.Text = (status == NetworkStatus.ServerRunning) ? "Stop Server" : "Start Server"; - // ServerOpenClose.Disabled = status > NetworkStatus.ServerRunning; - // ClientAddress.Editable = noConnection; - // ClientDisConnect.Text = (status < NetworkStatus.Connecting) ? "Connect" : "Disconnect"; - // ClientDisConnect.Disabled = status == NetworkStatus.ServerRunning; - // } + var pauseMode = (IntegratedServer?.Server.IsSingleplayer == true) ? PauseModeEnum.Stop : PauseModeEnum.Process; + this.GetClient().GetNode("World").PauseMode = pauseMode; + if (IntegratedServer != null) IntegratedServer.Server.GetNode("World").PauseMode = pauseMode; + + // TODO: Allow starting up the integrated server again when disconnected. + } #pragma warning disable IDE0051 @@ -95,32 +100,62 @@ public class EscapeMenuMultiplayer : Container } } + private void _on_HideAddress_toggled(bool pressed) + => ClientAddress.Secret = pressed; + private void _on_ServerOpenClose_pressed() { - // if (GetTree().NetworkPeer == null) { - // var port = Network.DEFAULT_PORT; - // if (ServerPort.Text.Length > 0) - // port = ushort.Parse(ServerPort.Text); - // Network.Instance.StartServer(port); - // } else Network.Instance.StopServer(); + var server = IntegratedServer?.Server; + var client = this.GetClient(); + if (server?.IsRunning != true) throw new InvalidOperationException(); + + if (server.IsSingleplayer) { + var port = (ServerPort.Text.Length > 0) ? ushort.Parse(ServerPort.Text) : DEFAULT_PORT; + client.Disconnect(); + server.Stop(); + server.Start(port); + client.Connect("127.0.0.1", port); + // TODO: Pause server processing (including packets, RPC, Sync) until client reconnects? + // If we're doing that, also make sure to re-map packet and RPC targets to point to new NetworkID. + } else { + client.Disconnect(); + server.Stop(); + var port = server.StartSingleplayer(); + client.Connect("127.0.0.1", port); + } + + ServerOpenClose.Text = server.IsSingleplayer ? "Open Server" : "Close Server"; + } private void _on_ClientDisConnect_pressed() { - // if (GetTree().NetworkPeer == null) { - // var address = "localhost"; - // var port = DEFAULT_PORT; - // if (ClientAddress.Text.Length > 0) { - // // TODO: Verify input some more, support IPv6? - // var split = ClientAddress.Text.Split(':'); - // address = (split.Length > 1) ? split[0] : ClientAddress.Text; - // port = (split.Length > 1) ? ushort.Parse(split[1]) : port; - // } - // Network.Instance.ConnectToServer(address, port); - // } else Network.Instance.DisconnectFromServer(); - } + var client = this.GetClient(); - private void _on_HideAddress_toggled(bool pressed) - => ClientAddress.Secret = pressed; + if (IntegratedServer != null) { + IntegratedServer.Server.Stop(); + IntegratedServer.GetParent().RemoveChild(IntegratedServer); + IntegratedServer.QueueFree(); + IntegratedServer = null; + + client.Disconnect(); + NetworkSync.ClearAllObjects(); + } + + if (client.Status == ConnectionStatus.Disconnected) { + var address = "localhost"; + var port = DEFAULT_PORT; + if (ClientAddress.Text.Length > 0) { + // TODO: Verify input some more, support IPv6? + var split = ClientAddress.Text.Split(':'); + address = (split.Length > 1) ? split[0] : ClientAddress.Text; + port = (split.Length > 1) ? ushort.Parse(split[1]) : port; + } + client.Connect(address, port); + } else { + client.Disconnect(); + NetworkSync.ClearAllObjects(); + } + } } diff --git a/src/Network/DeSerializer.Impl.cs b/src/Network/DeSerializer.Impl.cs index f0f9f2d..975be69 100644 --- a/src/Network/DeSerializer.Impl.cs +++ b/src/Network/DeSerializer.Impl.cs @@ -184,12 +184,12 @@ public class DictionaryDeSerializerGenerator private readonly IDeSerializer _valueDeSerializer = DeSerializerRegistry.Get(true); - public override void Serialize(Game game, BinaryWriter writer, TDictionary value) + public override void Serialize(Game game, BinaryWriter writer, TDictionary dict) { - writer.Write(value.Count); - foreach (var element in value) { - _keyDeSerializer.Serialize(game, writer, element.Key); - _valueDeSerializer.Serialize(game, writer, element.Value); + writer.Write(dict.Count); + foreach (var (key, value) in dict) { + _keyDeSerializer.Serialize(game, writer, key); + _valueDeSerializer.Serialize(game, writer, value); } } @@ -222,7 +222,12 @@ public class SyncedObjectDeSerializerGenerator public override void Serialize(Game game, BinaryWriter writer, TObj value) => writer.Write(value.GetSyncID()); public override TObj Deserialize(Game game, BinaryReader reader) - => (TObj)game.GetObjectBySyncID(reader.ReadUInt32()); + { + var id = reader.ReadUInt32(); + var value = (TObj)game.GetObjectBySyncID(id); + if (value == null) throw new Exception($"Could not find synced object of type {typeof(TObj)} with ID {id}"); + return value; + } } } diff --git a/src/Network/IntegratedServer.cs b/src/Network/IntegratedServer.cs index 7926d0a..bbd5cec 100644 --- a/src/Network/IntegratedServer.cs +++ b/src/Network/IntegratedServer.cs @@ -16,12 +16,16 @@ public class IntegratedServer : Node _sceneTree.CurrentScene = scene; Server = _sceneTree.Root.GetChild(0); + // Spawn default blocks. + for (var x = -6; x <= 6; x++) { + var block = Server.Spawn(); + block.Position = new BlockPos(x, 3); + block.Color = Color.FromHsv(GD.Randf(), 0.1F, 1.0F); + block.Unbreakable = true; + } } public override void _Process(float delta) => _sceneTree.Idle(delta); public override void _PhysicsProcess(float delta) => _sceneTree.Iteration(delta); public override void _ExitTree() => _sceneTree.Finish(); - - public void Start(ushort port) => Server.Start(port); - public void Stop() => Server.Stop(); } diff --git a/src/Network/NetworkSync.cs b/src/Network/NetworkSync.cs index 4687cf9..74c67f1 100644 --- a/src/Network/NetworkSync.cs +++ b/src/Network/NetworkSync.cs @@ -101,6 +101,19 @@ public static class NetworkSync NetworkPackets.Send(server, new []{ networkID }, packet); } + internal static void ClearAllObjects() + { + foreach (var (node, _) in _statusByObject) { + if (!Godot.Object.IsInstanceValid(node)) continue; + node.GetParent().RemoveChild(node); + node.QueueFree(); + } + + _statusByObject.Clear(); + _statusBySyncID.Clear(); + _dirtyObjects.Clear(); + _syncIDCounter = 1; + } public static uint GetSyncID(this Node obj) => GetSyncStatus(obj).SyncID; diff --git a/src/Objects/Player.cs b/src/Objects/Player.cs index f5dc764..4fa91a1 100644 --- a/src/Objects/Player.cs +++ b/src/Objects/Player.cs @@ -12,7 +12,7 @@ public class Player : KinematicBody2D, IInitializer public Sprite Sprite { get; private set; } - public bool IsLocal { get; private set; } + public bool IsLocal { get; private set; } = false; [SyncProperty] public new Vector2 Position { @@ -61,7 +61,7 @@ public class Player : KinematicBody2D, IInitializer if ((Position.y > 9000) && (this.GetGame() is Server server)) this.RPC(new []{ server.GetNetworkID(this) }, ResetPosition, Vector2.Zero); - this.GetClient()?.RPC(Move, Position); + if (IsLocal) this.GetClient()?.RPC(Move, Position); } public override void _PhysicsProcess(float delta) diff --git a/src/Scenes/Client.cs b/src/Scenes/Client.cs index f8936c9..eebc487 100644 --- a/src/Scenes/Client.cs +++ b/src/Scenes/Client.cs @@ -1,14 +1,18 @@ using System; using System.Collections.Generic; using Godot; +using static Godot.NetworkedMultiplayerPeer; public class Client : Game { [Export] public NodePath CursorPath { get; set; } public Cursor Cursor { get; private set; } - public event Action OnConnected; - public event Action OnDisconnected; + public ConnectionStatus Status => CustomMultiplayer.NetworkPeer?.GetConnectionStatus() ?? ConnectionStatus.Disconnected; + + public event Action Connected; + public event Action Disconnected; + public event Action StatusChanged; public Client() { @@ -36,10 +40,13 @@ public class Client : Game { if (CustomMultiplayer.NetworkPeer != null) throw new InvalidOperationException("Client connection is already open"); - var peer = new NetworkedMultiplayerENet(); + + var peer = new NetworkedMultiplayerENet(); var error = peer.CreateClient(address, port); if (error != Error.Ok) throw new Exception($"Error when connecting: {error}"); CustomMultiplayer.NetworkPeer = peer; + + StatusChanged?.Invoke(Status); } public void Disconnect() @@ -47,12 +54,17 @@ public class Client : Game if (CustomMultiplayer.NetworkPeer == null) return; ((NetworkedMultiplayerENet)CustomMultiplayer.NetworkPeer).CloseConnection(); CustomMultiplayer.NetworkPeer = null; - OnDisconnected?.Invoke(); + + Disconnected?.Invoke(); + StatusChanged?.Invoke(Status); } private void OnConnectedToServer() - => OnConnected?.Invoke(); + { + Connected?.Invoke(); + StatusChanged?.Invoke(Status); + } private void OnPacketReceived(int id, byte[] bytes) { diff --git a/src/Scenes/Server.cs b/src/Scenes/Server.cs index e43e8d9..22ccb39 100644 --- a/src/Scenes/Server.cs +++ b/src/Scenes/Server.cs @@ -8,6 +8,13 @@ public class Server : Game private readonly Dictionary _playersByNetworkID = new Dictionary(); private readonly Dictionary _networkIDByPlayer = new Dictionary(); + private Player _localPlayer = null; + private bool _isLocalPlayerConnected = false; + + public NetworkedMultiplayerENet Peer => (NetworkedMultiplayerENet)CustomMultiplayer.NetworkPeer; + public bool IsRunning => Peer != null; + public bool IsSingleplayer { get; private set; } + public Server() { CustomMultiplayer = new MultiplayerAPI { RootNode = this }; @@ -24,30 +31,48 @@ public class Server : Game } + public ushort StartSingleplayer() + { + for (var retries = 0; ; retries++) { + try { + IsSingleplayer = true; + // TODO: When `get_local_port` is available, just use port 0 for an auto-assigned port. + // Also see this PR: https://github.com/godotengine/godot/pull/48235 + var port = (ushort)GD.RandRange(42000, 43000); + Start(port, "127.0.0.1", 1); + return port; + } catch (Exception ex) { + // Do throw the "Server is already running" exception. + // 3 retries should be well enough to find a random unused port. + if ((ex is InvalidOperationException) || (retries == 2)) throw; + } + } + } public void Start(ushort port) + => Start(port, "*", 32); + private void Start(ushort port, string bindIP, int maxClients) { - if (CustomMultiplayer.NetworkPeer != null) - throw new InvalidOperationException("Server is already running"); - var peer = new NetworkedMultiplayerENet(); - var error = peer.CreateServer(port); + if (IsRunning) throw new InvalidOperationException("Server is already running"); + + var peer = new NetworkedMultiplayerENet(); + peer.SetBindIp(bindIP); + peer.ServerRelay = false; + + var error = peer.CreateServer(port, maxClients); if (error != Error.Ok) throw new Exception($"Error when starting the server: {error}"); - CustomMultiplayer.NetworkPeer = peer; - // Spawn default blocks. - for (var x = -6; x <= 6; x++) { - var block = this.Spawn(); - block.Position = new BlockPos(x, 3); - block.Color = Color.FromHsv(GD.Randf(), 0.1F, 1.0F); - block.Unbreakable = true; - } + CustomMultiplayer.NetworkPeer = peer; } public void Stop() { - if (CustomMultiplayer.NetworkPeer != null) - throw new InvalidOperationException("Server is not running"); - ((NetworkedMultiplayerENet)CustomMultiplayer.NetworkPeer).CloseConnection(); + if (!IsRunning) throw new InvalidOperationException("Server is not running"); + + Peer.CloseConnection(); CustomMultiplayer.NetworkPeer = null; + + IsSingleplayer = false; + _isLocalPlayerConnected = false; } @@ -60,15 +85,34 @@ public class Server : Game private void OnPeerConnected(int id) { var networkID = new NetworkID(id); - NetworkSync.SendAllObjects(this, networkID); - var player = this.Spawn(); - player.Position = Vector2.Zero; - player.Color = Colors.Red; + if (IsSingleplayer) { + if (Peer.GetPeerAddress(id) != "127.0.0.1") + { Peer.DisconnectPeer(id, true); return; } + CustomMultiplayer.RefuseNewNetworkConnections = true; + } - _playersByNetworkID.Add(networkID, player); - _networkIDByPlayer.Add(player, networkID); + Player player; + if ((_localPlayer != null) && !_isLocalPlayerConnected && + (Peer.GetPeerAddress(id) == "127.0.0.1")) { + player = _localPlayer; + _isLocalPlayerConnected = true; + + var oldNetworkID = GetNetworkID(player); + _playersByNetworkID.Remove(oldNetworkID); + _playersByNetworkID.Add(networkID, player); + _networkIDByPlayer[player] = networkID; + } else { + NetworkSync.SendAllObjects(this, networkID); + player = this.Spawn(); + player.Position = Vector2.Zero; + player.Color = Colors.Red; + + _playersByNetworkID.Add(networkID, player); + _networkIDByPlayer.Add(player, networkID); + } + if (IsSingleplayer) _localPlayer = player; player.RPC(new []{ networkID }, player.SetLocal); } @@ -76,6 +120,10 @@ public class Server : Game { var networkID = new NetworkID(id); var player = GetPlayer(networkID); + + // Local player stays around for reconnecting. + if (_localPlayer == player) return; + player.Destroy(); _playersByNetworkID.Remove(networkID); _networkIDByPlayer.Remove(player); diff --git a/src/Utility/Extensions.cs b/src/Utility/Extensions.cs index 365aca7..39db4c6 100644 --- a/src/Utility/Extensions.cs +++ b/src/Utility/Extensions.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using Godot; public static class Extensions @@ -16,6 +17,10 @@ public static class Extensions (instance as IInitializer)?.Initialize(); return instance; } + + public static void Deconstruct( + this KeyValuePair kvp, out TKey key, out TValue value) + { key = kvp.Key; value = kvp.Value; } } public interface IInitializer