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");
|
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.Application.Commands;
|
||||||
using Game.Features.Console.Godot;
|
using Game.Features.Console.Godot;
|
||||||
using Game.Features.Console.Infrastructure;
|
using Game.Features.Console.Infrastructure;
|
||||||
|
using Game.Features.Multiplayer.Godot;
|
||||||
using Game.Features.Players.Godot;
|
using Game.Features.Players.Godot;
|
||||||
using Game.Godot.Extensions;
|
using Game.Godot.Extensions;
|
||||||
using Game.Godot.Mappings;
|
using Game.Godot.Mappings;
|
||||||
@@ -12,6 +13,7 @@ public partial class GameBootstrap : Node
|
|||||||
{
|
{
|
||||||
[Export] public ConsoleController Console;
|
[Export] public ConsoleController Console;
|
||||||
[Export] public PlayerController Player;
|
[Export] public PlayerController Player;
|
||||||
|
[Export] public MultiplayerController MultiplayerController;
|
||||||
|
|
||||||
private void RegisterConsoleCommands()
|
private void RegisterConsoleCommands()
|
||||||
{
|
{
|
||||||
@@ -20,15 +22,28 @@ public partial class GameBootstrap : Node
|
|||||||
registry.Register(new EchoCommand());
|
registry.Register(new EchoCommand());
|
||||||
registry.Register(new ClearCommand());
|
registry.Register(new ClearCommand());
|
||||||
registry.Register(new NoclipCommand(Player));
|
registry.Register(new NoclipCommand(Player));
|
||||||
|
registry.Register(new HostCommand(MultiplayerController));
|
||||||
|
registry.Register(new JoinCommand(MultiplayerController));
|
||||||
|
|
||||||
Console.UseCommandRegistry(registry);
|
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()
|
public override void _Ready()
|
||||||
{
|
{
|
||||||
this.AssertRequiredExports();
|
this.AssertRequiredExports();
|
||||||
|
|
||||||
MouseCapture.Capture();
|
MouseCapture.Capture();
|
||||||
|
InitializeMultiplayer();
|
||||||
RegisterConsoleCommands();
|
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="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://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="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"]
|
[sub_resource type="PanoramaSkyMaterial" id="PanoramaSkyMaterial_epykj"]
|
||||||
panorama = ExtResource("1_g4n7o")
|
panorama = ExtResource("1_g4n7o")
|
||||||
@@ -17,10 +18,11 @@ background_mode = 2
|
|||||||
sky = SubResource("Sky_1kpcd")
|
sky = SubResource("Sky_1kpcd")
|
||||||
glow_enabled = true
|
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")
|
script = ExtResource("3_bootstrap")
|
||||||
Console = NodePath("Console")
|
Console = NodePath("Console")
|
||||||
Player = NodePath("Player")
|
Player = NodePath("Player")
|
||||||
|
MultiplayerController = NodePath("Multiplayer")
|
||||||
|
|
||||||
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="." unique_id=1669211403]
|
[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)
|
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")]
|
[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)
|
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]
|
[node name="Entities" type="Node3D" parent="." unique_id=851492709]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user