diff --git a/src/Features/Console/Application/Commands/HostCommand.cs b/src/Features/Console/Application/Commands/HostCommand.cs new file mode 100644 index 0000000..a5db173 --- /dev/null +++ b/src/Features/Console/Application/Commands/HostCommand.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using Game.Features.Console.Contracts; +using Game.Features.Multiplayer.Contracts; +using Game.Features.Multiplayer.Contracts.Exceptions; + +namespace Game.Features.Console.Application.Commands; + +public sealed class HostCommand(IMultiplayerPeer multiplayer) : ICommand +{ + public string Name => "host"; + public string Description => "Starts hosting a multiplayer session on the given port."; + public IReadOnlyList Args { get; } = new CommandArgsBuilder().AddIntArg("port").Build(); + + public CommandResult Execute(CommandArgValues args) + { + int port = args.GetInt(0); + + try + { + multiplayer.Host(port); + } + catch (MultiplayerException ex) + { + return CommandResult.Fail(ex.Message); + } + + return CommandResult.Ok($"Hosting on port {port}."); + } +} diff --git a/src/Features/Console/Application/Commands/HostCommand.cs.uid b/src/Features/Console/Application/Commands/HostCommand.cs.uid new file mode 100644 index 0000000..d3ee46b --- /dev/null +++ b/src/Features/Console/Application/Commands/HostCommand.cs.uid @@ -0,0 +1 @@ +uid://crmnspnxcut3y diff --git a/src/Features/Console/Application/Commands/JoinCommand.cs b/src/Features/Console/Application/Commands/JoinCommand.cs new file mode 100644 index 0000000..e5cc78d --- /dev/null +++ b/src/Features/Console/Application/Commands/JoinCommand.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using Game.Features.Console.Contracts; +using Game.Features.Multiplayer.Contracts; +using Game.Features.Multiplayer.Contracts.Exceptions; + +namespace Game.Features.Console.Application.Commands; + +public sealed class JoinCommand(IMultiplayerPeer multiplayer) : ICommand +{ + private const string AddressPattern = + @"^(localhost|((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))$"; + + public string Name => "join"; + public string Description => "Joins a multiplayer session at the given address and port."; + + public IReadOnlyList Args { get; } = + new CommandArgsBuilder().AddRegexArg("address", AddressPattern).AddIntArg("port").Build(); + + public CommandResult Execute(CommandArgValues args) + { + string address = args.GetString(0); + int port = args.GetInt(1); + + try + { + multiplayer.Join(address, port); + } + catch (MultiplayerException ex) + { + return CommandResult.Fail(ex.Message); + } + + return CommandResult.Ok($"Connecting to {address}:{port}..."); + } +} diff --git a/src/Features/Console/Application/Commands/JoinCommand.cs.uid b/src/Features/Console/Application/Commands/JoinCommand.cs.uid new file mode 100644 index 0000000..af6e327 --- /dev/null +++ b/src/Features/Console/Application/Commands/JoinCommand.cs.uid @@ -0,0 +1 @@ +uid://c80rskv3u7su7 diff --git a/src/Features/Console/Godot/ConsoleController.cs b/src/Features/Console/Godot/ConsoleController.cs index 57e3871..aa319d7 100644 --- a/src/Features/Console/Godot/ConsoleController.cs +++ b/src/Features/Console/Godot/ConsoleController.cs @@ -33,7 +33,7 @@ public partial class ConsoleController : CanvasLayer } } - private void AppendLine(string line) + public void AppendLine(string line) { OutputLog.AppendText(line + "\n"); } diff --git a/src/Features/Multiplayer/Contracts/Exceptions/MultiplayerAlreadyConnectedException.cs b/src/Features/Multiplayer/Contracts/Exceptions/MultiplayerAlreadyConnectedException.cs new file mode 100644 index 0000000..88fb049 --- /dev/null +++ b/src/Features/Multiplayer/Contracts/Exceptions/MultiplayerAlreadyConnectedException.cs @@ -0,0 +1,6 @@ +namespace Game.Features.Multiplayer.Contracts.Exceptions; + +/// +/// Thrown when trying to host or join while already connected to a session. +/// +public sealed class MultiplayerAlreadyConnectedException() : MultiplayerException("Already connected to a session."); diff --git a/src/Features/Multiplayer/Contracts/Exceptions/MultiplayerAlreadyConnectedException.cs.uid b/src/Features/Multiplayer/Contracts/Exceptions/MultiplayerAlreadyConnectedException.cs.uid new file mode 100644 index 0000000..3195353 --- /dev/null +++ b/src/Features/Multiplayer/Contracts/Exceptions/MultiplayerAlreadyConnectedException.cs.uid @@ -0,0 +1 @@ +uid://dr6f5t6nhh60w diff --git a/src/Features/Multiplayer/Contracts/Exceptions/MultiplayerAlreadyHostingException.cs b/src/Features/Multiplayer/Contracts/Exceptions/MultiplayerAlreadyHostingException.cs new file mode 100644 index 0000000..b1384f9 --- /dev/null +++ b/src/Features/Multiplayer/Contracts/Exceptions/MultiplayerAlreadyHostingException.cs @@ -0,0 +1,6 @@ +namespace Game.Features.Multiplayer.Contracts.Exceptions; + +/// +/// Thrown when trying to host or join while already hosting a session. +/// +public sealed class MultiplayerAlreadyHostingException() : MultiplayerException("Already hosting a session."); diff --git a/src/Features/Multiplayer/Contracts/Exceptions/MultiplayerAlreadyHostingException.cs.uid b/src/Features/Multiplayer/Contracts/Exceptions/MultiplayerAlreadyHostingException.cs.uid new file mode 100644 index 0000000..8f6e82f --- /dev/null +++ b/src/Features/Multiplayer/Contracts/Exceptions/MultiplayerAlreadyHostingException.cs.uid @@ -0,0 +1 @@ +uid://c41cffks8ymh0 diff --git a/src/Features/Multiplayer/Contracts/Exceptions/MultiplayerException.cs b/src/Features/Multiplayer/Contracts/Exceptions/MultiplayerException.cs new file mode 100644 index 0000000..f6288aa --- /dev/null +++ b/src/Features/Multiplayer/Contracts/Exceptions/MultiplayerException.cs @@ -0,0 +1,8 @@ +using System; + +namespace Game.Features.Multiplayer.Contracts.Exceptions; + +/// +/// Base type for multiplayer connection failures. +/// +public abstract class MultiplayerException(string message) : Exception(message); diff --git a/src/Features/Multiplayer/Contracts/Exceptions/MultiplayerException.cs.uid b/src/Features/Multiplayer/Contracts/Exceptions/MultiplayerException.cs.uid new file mode 100644 index 0000000..33239aa --- /dev/null +++ b/src/Features/Multiplayer/Contracts/Exceptions/MultiplayerException.cs.uid @@ -0,0 +1 @@ +uid://ct5t3i77ovfx1 diff --git a/src/Features/Multiplayer/Contracts/Exceptions/MultiplayerHostException.cs b/src/Features/Multiplayer/Contracts/Exceptions/MultiplayerHostException.cs new file mode 100644 index 0000000..831b430 --- /dev/null +++ b/src/Features/Multiplayer/Contracts/Exceptions/MultiplayerHostException.cs @@ -0,0 +1,11 @@ +namespace Game.Features.Multiplayer.Contracts.Exceptions; + +/// +/// Thrown when starting a host fails at the transport level. +/// +public sealed class MultiplayerHostException(int port, string reason) + : MultiplayerException($"Failed to start host on port {port}: {reason}.") +{ + public int Port { get; } = port; + public string Reason { get; } = reason; +} diff --git a/src/Features/Multiplayer/Contracts/Exceptions/MultiplayerHostException.cs.uid b/src/Features/Multiplayer/Contracts/Exceptions/MultiplayerHostException.cs.uid new file mode 100644 index 0000000..6cc4f95 --- /dev/null +++ b/src/Features/Multiplayer/Contracts/Exceptions/MultiplayerHostException.cs.uid @@ -0,0 +1 @@ +uid://scqi785gvntu diff --git a/src/Features/Multiplayer/Contracts/Exceptions/MultiplayerJoinException.cs b/src/Features/Multiplayer/Contracts/Exceptions/MultiplayerJoinException.cs new file mode 100644 index 0000000..e91c77c --- /dev/null +++ b/src/Features/Multiplayer/Contracts/Exceptions/MultiplayerJoinException.cs @@ -0,0 +1,12 @@ +namespace Game.Features.Multiplayer.Contracts.Exceptions; + +/// +/// Thrown when connecting to a host fails at the transport level. +/// +public sealed class MultiplayerJoinException(string address, int port, string reason) + : MultiplayerException($"Failed to connect to {address}:{port}: {reason}.") +{ + public string Address { get; } = address; + public int Port { get; } = port; + public string Reason { get; } = reason; +} diff --git a/src/Features/Multiplayer/Contracts/Exceptions/MultiplayerJoinException.cs.uid b/src/Features/Multiplayer/Contracts/Exceptions/MultiplayerJoinException.cs.uid new file mode 100644 index 0000000..5d2edff --- /dev/null +++ b/src/Features/Multiplayer/Contracts/Exceptions/MultiplayerJoinException.cs.uid @@ -0,0 +1 @@ +uid://q8oyea00yrxy diff --git a/src/Features/Multiplayer/Contracts/IMultiplayerPeer.cs b/src/Features/Multiplayer/Contracts/IMultiplayerPeer.cs new file mode 100644 index 0000000..32edbdf --- /dev/null +++ b/src/Features/Multiplayer/Contracts/IMultiplayerPeer.cs @@ -0,0 +1,18 @@ +using System; + +namespace Game.Features.Multiplayer.Contracts; + +public interface IMultiplayerPeer +{ + event Action Connected; + event Action ConnectionFailed; + event Action Disconnected; + event Action PeerJoined; + event Action PeerLeft; + + void Host(int port); + + void Join(string address, int port); + + void Disconnect(); +} diff --git a/src/Features/Multiplayer/Contracts/IMultiplayerPeer.cs.uid b/src/Features/Multiplayer/Contracts/IMultiplayerPeer.cs.uid new file mode 100644 index 0000000..782f454 --- /dev/null +++ b/src/Features/Multiplayer/Contracts/IMultiplayerPeer.cs.uid @@ -0,0 +1 @@ +uid://bs401ksgc6qdn diff --git a/src/Features/Multiplayer/Contracts/PeerId.cs b/src/Features/Multiplayer/Contracts/PeerId.cs new file mode 100644 index 0000000..9f3734f --- /dev/null +++ b/src/Features/Multiplayer/Contracts/PeerId.cs @@ -0,0 +1,6 @@ +namespace Game.Features.Multiplayer.Contracts; + +public readonly record struct PeerId(long Value) +{ + public override string ToString() => Value.ToString(); +} diff --git a/src/Features/Multiplayer/Contracts/PeerId.cs.uid b/src/Features/Multiplayer/Contracts/PeerId.cs.uid new file mode 100644 index 0000000..c8759bb --- /dev/null +++ b/src/Features/Multiplayer/Contracts/PeerId.cs.uid @@ -0,0 +1 @@ +uid://bix11ttul7aco diff --git a/src/Features/Multiplayer/Domain/MultiplayerSession.cs b/src/Features/Multiplayer/Domain/MultiplayerSession.cs new file mode 100644 index 0000000..8c03f08 --- /dev/null +++ b/src/Features/Multiplayer/Domain/MultiplayerSession.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using Game.Features.Multiplayer.Contracts; + +namespace Game.Features.Multiplayer.Domain; + +public sealed class MultiplayerSession +{ + public const int MaxPlayers = 4; + public IReadOnlyCollection PeerIds => _peerIds; + public bool IsFull => _peerIds.Count >= MaxPlayers; + public bool IsHosting { get; set; } + public bool IsConnected { get; set; } + + private readonly HashSet _peerIds = []; + + public void AddPeer(PeerId peerId) => _peerIds.Add(peerId); + + public void RemovePeer(PeerId peerId) => _peerIds.Remove(peerId); + + public void Reset() + { + _peerIds.Clear(); + IsHosting = false; + IsConnected = false; + } +} diff --git a/src/Features/Multiplayer/Domain/MultiplayerSession.cs.uid b/src/Features/Multiplayer/Domain/MultiplayerSession.cs.uid new file mode 100644 index 0000000..c650756 --- /dev/null +++ b/src/Features/Multiplayer/Domain/MultiplayerSession.cs.uid @@ -0,0 +1 @@ +uid://dbpm807l4bmfk diff --git a/src/Features/Multiplayer/Godot/EnetMultiplayerPeerFactory.cs b/src/Features/Multiplayer/Godot/EnetMultiplayerPeerFactory.cs new file mode 100644 index 0000000..e66ddaa --- /dev/null +++ b/src/Features/Multiplayer/Godot/EnetMultiplayerPeerFactory.cs @@ -0,0 +1,21 @@ +using Game.Features.Multiplayer.Contracts.Exceptions; +using Godot; + +namespace Game.Features.Multiplayer.Godot; + +public sealed class EnetMultiplayerPeerFactory : IMultiplayerPeerFactory +{ + public MultiplayerPeer CreateHost(int port, int maxPeers) + { + ENetMultiplayerPeer peer = new(); + Error error = peer.CreateServer(port, maxPeers); + return error != Error.Ok ? throw new MultiplayerHostException(port, error.ToString()) : peer; + } + + public MultiplayerPeer CreateClient(string address, int port) + { + ENetMultiplayerPeer peer = new(); + Error error = peer.CreateClient(address, port); + return error != Error.Ok ? throw new MultiplayerJoinException(address, port, error.ToString()) : peer; + } +} diff --git a/src/Features/Multiplayer/Godot/EnetMultiplayerPeerFactory.cs.uid b/src/Features/Multiplayer/Godot/EnetMultiplayerPeerFactory.cs.uid new file mode 100644 index 0000000..9b24f15 --- /dev/null +++ b/src/Features/Multiplayer/Godot/EnetMultiplayerPeerFactory.cs.uid @@ -0,0 +1 @@ +uid://dkymguty84h4v diff --git a/src/Features/Multiplayer/Godot/IMultiplayerPeerFactory.cs b/src/Features/Multiplayer/Godot/IMultiplayerPeerFactory.cs new file mode 100644 index 0000000..ce497ce --- /dev/null +++ b/src/Features/Multiplayer/Godot/IMultiplayerPeerFactory.cs @@ -0,0 +1,10 @@ +using Godot; + +namespace Game.Features.Multiplayer.Godot; + +public interface IMultiplayerPeerFactory +{ + MultiplayerPeer CreateHost(int port, int maxPeers); + + MultiplayerPeer CreateClient(string address, int port); +} diff --git a/src/Features/Multiplayer/Godot/IMultiplayerPeerFactory.cs.uid b/src/Features/Multiplayer/Godot/IMultiplayerPeerFactory.cs.uid new file mode 100644 index 0000000..7b37407 --- /dev/null +++ b/src/Features/Multiplayer/Godot/IMultiplayerPeerFactory.cs.uid @@ -0,0 +1 @@ +uid://csk14887ww6q6 diff --git a/src/Features/Multiplayer/Godot/MultiplayerController.cs b/src/Features/Multiplayer/Godot/MultiplayerController.cs new file mode 100644 index 0000000..f92042c --- /dev/null +++ b/src/Features/Multiplayer/Godot/MultiplayerController.cs @@ -0,0 +1,101 @@ +using System; +using Game.Features.Multiplayer.Contracts; +using Game.Features.Multiplayer.Contracts.Exceptions; +using Game.Features.Multiplayer.Domain; +using Godot; + +namespace Game.Features.Multiplayer.Godot; + +public partial class MultiplayerController : Node, IMultiplayerPeer +{ + private readonly MultiplayerSession _session = new(); + private IMultiplayerPeerFactory _multiplayerPeerFactory; + + public event Action Connected; + public event Action ConnectionFailed; + public event Action Disconnected; + public event Action PeerJoined; + public event Action PeerLeft; + + private void OnPeerConnected(long id) + { + PeerId peerId = new(id); + _session.AddPeer(peerId); + PeerJoined?.Invoke(peerId); + } + + private void OnPeerDisconnected(long id) + { + PeerId peerId = new(id); + _session.RemovePeer(peerId); + PeerLeft?.Invoke(peerId); + } + + private void OnConnectedToServer() + { + Connected?.Invoke(); + } + + private void OnConnectionFailed() + { + _session.Reset(); + ConnectionFailed?.Invoke(); + } + + private void OnServerDisconnected() + { + _session.Reset(); + Disconnected?.Invoke(); + } + + private void AssertNoActiveSession() + { + if (_session.IsHosting) + { + throw new MultiplayerAlreadyHostingException(); + } + + if (_session.IsConnected) + { + throw new MultiplayerAlreadyConnectedException(); + } + } + + public void UsePeerFactory(IMultiplayerPeerFactory multiplayerPeerFactory) + { + _multiplayerPeerFactory = multiplayerPeerFactory; + } + + public void Host(int port) + { + AssertNoActiveSession(); + + Multiplayer.MultiplayerPeer = _multiplayerPeerFactory.CreateHost(port, MultiplayerSession.MaxPlayers - 1); + _session.AddPeer(new PeerId(Multiplayer.GetUniqueId())); + _session.IsHosting = true; + } + + public void Join(string address, int port) + { + AssertNoActiveSession(); + + Multiplayer.MultiplayerPeer = _multiplayerPeerFactory.CreateClient(address, port); + _session.IsConnected = true; + } + + public void Disconnect() + { + Multiplayer.MultiplayerPeer?.Close(); + Multiplayer.MultiplayerPeer = null; + _session.Reset(); + } + + public override void _Ready() + { + Multiplayer.PeerConnected += OnPeerConnected; + Multiplayer.PeerDisconnected += OnPeerDisconnected; + Multiplayer.ConnectedToServer += OnConnectedToServer; + Multiplayer.ConnectionFailed += OnConnectionFailed; + Multiplayer.ServerDisconnected += OnServerDisconnected; + } +} diff --git a/src/Features/Multiplayer/Godot/MultiplayerController.cs.uid b/src/Features/Multiplayer/Godot/MultiplayerController.cs.uid new file mode 100644 index 0000000..ada082b --- /dev/null +++ b/src/Features/Multiplayer/Godot/MultiplayerController.cs.uid @@ -0,0 +1 @@ +uid://drqx0wiw0sbj1 diff --git a/src/Godot/GameBootstrap.cs b/src/Godot/GameBootstrap.cs index e796a66..1fb5148 100644 --- a/src/Godot/GameBootstrap.cs +++ b/src/Godot/GameBootstrap.cs @@ -1,6 +1,7 @@ using Game.Features.Console.Application.Commands; using Game.Features.Console.Godot; using Game.Features.Console.Infrastructure; +using Game.Features.Multiplayer.Godot; using Game.Features.Players.Godot; using Game.Godot.Extensions; using Game.Godot.Mappings; @@ -12,6 +13,7 @@ public partial class GameBootstrap : Node { [Export] public ConsoleController Console; [Export] public PlayerController Player; + [Export] public MultiplayerController MultiplayerController; private void RegisterConsoleCommands() { @@ -20,15 +22,28 @@ public partial class GameBootstrap : Node registry.Register(new EchoCommand()); registry.Register(new ClearCommand()); registry.Register(new NoclipCommand(Player)); + registry.Register(new HostCommand(MultiplayerController)); + registry.Register(new JoinCommand(MultiplayerController)); Console.UseCommandRegistry(registry); } + private void InitializeMultiplayer() + { + MultiplayerController.UsePeerFactory(new EnetMultiplayerPeerFactory()); + MultiplayerController.Connected += () => Console.AppendLine("Connected successfully."); + MultiplayerController.ConnectionFailed += () => Console.AppendLine("Connection failed."); + MultiplayerController.Disconnected += () => Console.AppendLine("Server disconnected."); + MultiplayerController.PeerJoined += id => Console.AppendLine($"Peer {id} joined."); + MultiplayerController.PeerLeft += id => Console.AppendLine($"Peer {id} left."); + } + public override void _Ready() { this.AssertRequiredExports(); MouseCapture.Capture(); + InitializeMultiplayer(); RegisterConsoleCommands(); } diff --git a/src/Godot/Scenes/GameWorld.tscn b/src/Godot/Scenes/GameWorld.tscn index b37fd22..a6c6e08 100644 --- a/src/Godot/Scenes/GameWorld.tscn +++ b/src/Godot/Scenes/GameWorld.tscn @@ -2,9 +2,10 @@ [ext_resource type="Texture2D" uid="uid://b5i827vmueyei" path="res://assets/textures/skyboxes/skybox_emx_19.png" id="1_g4n7o"] [ext_resource type="PackedScene" uid="uid://curuqmbndymi6" path="res://src/Godot/Scenes/FloorCeilingHr42.tscn" id="2_1kpcd"] -[ext_resource type="PackedScene" uid="uid://bka1dmgdi5btd" path="res://src/Features/Player/Godot/PlayerNode.tscn" id="2_mvtit"] +[ext_resource type="PackedScene" uid="uid://bka1dmgdi5btd" path="res://src/Features/Players/Godot/PlayerNode.tscn" id="2_mvtit"] [ext_resource type="Script" uid="uid://drctu5y21ey2y" path="res://src/Godot/GameBootstrap.cs" id="3_bootstrap"] -[ext_resource type="PackedScene" path="res://src/Features/Console/Godot/ConsoleController.tscn" id="4_console"] +[ext_resource type="PackedScene" uid="uid://bk0771dvonxh8" path="res://src/Features/Console/Godot/ConsoleController.tscn" id="4_console"] +[ext_resource type="Script" uid="uid://drqx0wiw0sbj1" path="res://src/Features/Multiplayer/Godot/MultiplayerController.cs" id="5_multiplayer"] [sub_resource type="PanoramaSkyMaterial" id="PanoramaSkyMaterial_epykj"] panorama = ExtResource("1_g4n7o") @@ -17,10 +18,11 @@ background_mode = 2 sky = SubResource("Sky_1kpcd") glow_enabled = true -[node name="GameWorld" type="Node" unique_id=222644234 node_paths=PackedStringArray("Console", "Player")] +[node name="GameWorld" type="Node" unique_id=222644234 node_paths=PackedStringArray("Console", "Player", "MultiplayerController")] script = ExtResource("3_bootstrap") Console = NodePath("Console") Player = NodePath("Player") +MultiplayerController = NodePath("Multiplayer") [node name="DirectionalLight3D" type="DirectionalLight3D" parent="." unique_id=1669211403] transform = Transform3D(-0.8660254, -0.43301278, 0.25, 0, 0.49999997, 0.86602545, -0.50000006, 0.75, -0.43301266, 0, 0, 0) @@ -32,7 +34,10 @@ environment = SubResource("Environment_mvtit") [node name="Player" parent="." unique_id=26823676 instance=ExtResource("2_mvtit")] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0) -[node name="Console" parent="." instance=ExtResource("4_console")] +[node name="Console" parent="." unique_id=1947043894 instance=ExtResource("4_console")] + +[node name="Multiplayer" type="Node" parent="." unique_id=1209131013] +script = ExtResource("5_multiplayer") [node name="Entities" type="Node3D" parent="." unique_id=851492709]