feat: add multiplayer session support with ENet host/join
This commit is contained in:
@@ -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<CommandArgSpec> 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}.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://crmnspnxcut3y
|
||||
@@ -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<CommandArgSpec> 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}...");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://c80rskv3u7su7
|
||||
@@ -33,7 +33,7 @@ public partial class ConsoleController : CanvasLayer
|
||||
}
|
||||
}
|
||||
|
||||
private void AppendLine(string line)
|
||||
public void AppendLine(string line)
|
||||
{
|
||||
OutputLog.AppendText(line + "\n");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Game.Features.Multiplayer.Contracts.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when trying to host or join while already connected to a session.
|
||||
/// </summary>
|
||||
public sealed class MultiplayerAlreadyConnectedException() : MultiplayerException("Already connected to a session.");
|
||||
+1
@@ -0,0 +1 @@
|
||||
uid://dr6f5t6nhh60w
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Game.Features.Multiplayer.Contracts.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when trying to host or join while already hosting a session.
|
||||
/// </summary>
|
||||
public sealed class MultiplayerAlreadyHostingException() : MultiplayerException("Already hosting a session.");
|
||||
+1
@@ -0,0 +1 @@
|
||||
uid://c41cffks8ymh0
|
||||
@@ -0,0 +1,8 @@
|
||||
using System;
|
||||
|
||||
namespace Game.Features.Multiplayer.Contracts.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Base type for multiplayer connection failures.
|
||||
/// </summary>
|
||||
public abstract class MultiplayerException(string message) : Exception(message);
|
||||
@@ -0,0 +1 @@
|
||||
uid://ct5t3i77ovfx1
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Game.Features.Multiplayer.Contracts.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when starting a host fails at the transport level.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://scqi785gvntu
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Game.Features.Multiplayer.Contracts.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when connecting to a host fails at the transport level.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://q8oyea00yrxy
|
||||
@@ -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<PeerId> PeerJoined;
|
||||
event Action<PeerId> PeerLeft;
|
||||
|
||||
void Host(int port);
|
||||
|
||||
void Join(string address, int port);
|
||||
|
||||
void Disconnect();
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://bs401ksgc6qdn
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Game.Features.Multiplayer.Contracts;
|
||||
|
||||
public readonly record struct PeerId(long Value)
|
||||
{
|
||||
public override string ToString() => Value.ToString();
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://bix11ttul7aco
|
||||
@@ -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<PeerId> PeerIds => _peerIds;
|
||||
public bool IsFull => _peerIds.Count >= MaxPlayers;
|
||||
public bool IsHosting { get; set; }
|
||||
public bool IsConnected { get; set; }
|
||||
|
||||
private readonly HashSet<PeerId> _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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://dbpm807l4bmfk
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://dkymguty84h4v
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://csk14887ww6q6
|
||||
@@ -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<PeerId> PeerJoined;
|
||||
public event Action<PeerId> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://drqx0wiw0sbj1
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user