3 Commits
46 changed files with 548 additions and 22 deletions
@@ -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
@@ -8,13 +8,20 @@ public enum CommandArgType
Int,
Float,
Bool,
Choice
Choice,
Regex
}
public sealed record CommandArgSpec(string Name, CommandArgType Type, IReadOnlyList<string> Choices = null)
public sealed record CommandArgSpec(
string Name,
CommandArgType Type,
IReadOnlyList<string> Choices = null,
string Pattern = null
) {
public override string ToString() => Type switch
{
public override string ToString() =>
Type == CommandArgType.Choice && Choices is not null
? string.Join("|", Choices)
: Type.ToString().ToLowerInvariant();
CommandArgType.Choice when Choices is not null => string.Join("|", Choices),
CommandArgType.Regex when Pattern is not null => "regex",
_ => Type.ToString().ToLowerInvariant()
};
}
@@ -36,5 +36,11 @@ public sealed class CommandArgsBuilder
return this;
}
public CommandArgsBuilder AddRegexArg(string name, string pattern)
{
_specs.Add(new CommandArgSpec(name, CommandArgType.Regex, Pattern: pattern));
return this;
}
public IReadOnlyList<CommandArgSpec> Build() => _specs.ToArray();
}
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
using Game.Features.Console.Contracts;
namespace Game.Features.Console.Domain;
@@ -18,6 +19,7 @@ public static class CommandArgValidator
CommandArgType.Float => float.TryParse(rawValue, NumberStyles.Float, CultureInfo.InvariantCulture, out _),
CommandArgType.Bool => bool.TryParse(rawValue, out _),
CommandArgType.Choice => spec.Choices is not null && ChoicesContain(spec.Choices, rawValue),
CommandArgType.Regex => spec.Pattern is not null && Regex.IsMatch(rawValue, spec.Pattern),
_ => throw new ArgumentOutOfRangeException(nameof(spec), spec.Type, null)
};
+17 -11
View File
@@ -1,6 +1,7 @@
using Game.Features.Console.Application;
using Game.Features.Console.Contracts;
using Game.Features.Console.Infrastructure;
using Game.Features.Logging.Contracts;
using Game.Godot;
using Game.Godot.Extensions;
using Game.Godot.Mappings;
@@ -8,7 +9,7 @@ using Godot;
namespace Game.Features.Console.Godot;
public partial class ConsoleController : CanvasLayer
public partial class ConsoleController : CanvasLayer, ILogTarget
{
[Export] public LineEdit CommandInput;
[Export] public RichTextLabel OutputLog;
@@ -33,16 +34,6 @@ public partial class ConsoleController : CanvasLayer
}
}
private void AppendLine(string line)
{
OutputLog.AppendText(line + "\n");
}
public void UseCommandRegistry(ICommandRegistry registry)
{
_commandExecute = new CommandExecute(registry);
}
private void OnInputSubmitted(string text)
{
CommandInput.Clear();
@@ -64,6 +55,21 @@ public partial class ConsoleController : CanvasLayer
}
}
private void AppendLine(string line)
{
OutputLog.AppendText(line + "\n");
}
public void UseCommandRegistry(ICommandRegistry registry)
{
_commandExecute = new CommandExecute(registry);
}
public void WriteLogLine(string logLine)
{
AppendLine(logLine);
}
public override void _Ready()
{
this.AssertRequiredExports();
@@ -0,0 +1,6 @@
namespace Game.Features.Logging.Contracts;
public interface ILogTarget
{
void WriteLogLine(string logLine);
}
@@ -0,0 +1 @@
uid://cm0ugu0l8td0e
@@ -0,0 +1,9 @@
namespace Game.Features.Logging.Contracts;
public interface ILogger
{
void Debug(string category, string message);
void Info(string category, string message);
void Warning(string category, string message);
void Error(string category, string message);
}
@@ -0,0 +1 @@
uid://0hjq84kko3td
@@ -0,0 +1,9 @@
namespace Game.Features.Logging.Contracts;
public enum LogLevel
{
Debug,
Info,
Warning,
Error
}
@@ -0,0 +1 @@
uid://2ee58okj156n
+48
View File
@@ -0,0 +1,48 @@
using System;
using Game.Features.Logging.Contracts;
using Godot;
namespace Game.Features.Logging.Godot;
public sealed class FileLogger : ILogger, IDisposable
{
private const string LogPath = "user://logs/latest.log";
private readonly FileAccess _file;
public FileLogger()
{
DirAccess.MakeDirRecursiveAbsolute(LogPath.GetBaseDir());
_file = FileAccess.Open(LogPath, FileAccess.ModeFlags.Write);
if (_file is null) return;
_file.StoreLine($"=== Log started at {DateTime.Now:yyyy-MM-dd HH:mm:ss} ===");
_file.Flush();
}
private void Write(LogLevel level, string category, string message)
{
if (_file is null) return;
_file.StoreLine($"{DateTime.Now:HH:mm:ss} [{level}] [{category}] {message}");
_file.Flush();
}
public void Debug(string category, string message)
=> Write(LogLevel.Debug, category, message);
public void Info(string category, string message)
=> Write(LogLevel.Info, category, message);
public void Warning(string category, string message)
=> Write(LogLevel.Warning, category, message);
public void Error(string category, string message)
=> Write(LogLevel.Error, category, message);
public void Dispose()
{
_file?.Dispose();
}
}
@@ -0,0 +1 @@
uid://f45qb03i1dua
@@ -0,0 +1,22 @@
using Game.Features.Logging.Contracts;
using Godot;
namespace Game.Features.Logging.Godot;
public sealed class GodotConsoleLogger : ILogger
{
private static string Format(LogLevel level, string category, string message)
=> $"[{level}] [{category}] {message}";
public void Debug(string category, string message)
=> GD.Print(Format(LogLevel.Debug, category, message));
public void Info(string category, string message)
=> GD.Print(Format(LogLevel.Info, category, message));
public void Warning(string category, string message)
=> GD.PushWarning(Format(LogLevel.Warning, category, message));
public void Error(string category, string message)
=> GD.PushError(Format(LogLevel.Error, category, message));
}
@@ -0,0 +1 @@
uid://jyq0in2wtq02
@@ -0,0 +1,39 @@
using System.Collections.Generic;
using Game.Features.Logging.Contracts;
namespace Game.Features.Logging.Infrastructure;
public sealed class CompositeLogger(IReadOnlyList<ILogger> loggers) : ILogger
{
public void Debug(string category, string message)
{
foreach (ILogger logger in loggers)
{
logger.Debug(category, message);
}
}
public void Info(string category, string message)
{
foreach (ILogger logger in loggers)
{
logger.Info(category, message);
}
}
public void Warning(string category, string message)
{
foreach (ILogger logger in loggers)
{
logger.Warning(category, message);
}
}
public void Error(string category, string message)
{
foreach (ILogger logger in loggers)
{
logger.Error(category, message);
}
}
}
@@ -0,0 +1 @@
uid://cumunwgy6i71s
@@ -0,0 +1,21 @@
using Game.Features.Logging.Contracts;
namespace Game.Features.Logging.Infrastructure;
/// <summary>
/// Forwards log entries to an ILogTarget.
/// </summary>
public sealed class LogTargetLogger(ILogTarget target) : ILogger
{
public void Debug(string category, string message)
=> target.WriteLogLine(message);
public void Info(string category, string message)
=> target.WriteLogLine(message);
public void Warning(string category, string message)
=> target.WriteLogLine(message);
public void Error(string category, string message)
=> target.WriteLogLine(message);
}
@@ -0,0 +1 @@
uid://bxnu6wg0qe03r
@@ -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.");
@@ -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.");
@@ -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
+37
View File
@@ -1,6 +1,9 @@
using Game.Features.Console.Application.Commands;
using Game.Features.Console.Godot;
using Game.Features.Console.Infrastructure;
using Game.Features.Logging.Godot;
using Game.Features.Logging.Infrastructure;
using Game.Features.Multiplayer.Godot;
using Game.Features.Players.Godot;
using Game.Godot.Extensions;
using Game.Godot.Mappings;
@@ -12,6 +15,20 @@ public partial class GameBootstrap : Node
{
[Export] public ConsoleController Console;
[Export] public PlayerController Player;
[Export] public MultiplayerController MultiplayerController;
private FileLogger _fileLogger;
private CompositeLogger _logger;
private void InitializeLogging()
{
_fileLogger = new FileLogger();
_logger = new CompositeLogger([
new GodotConsoleLogger(),
new LogTargetLogger(Console),
_fileLogger
]);
}
private void RegisterConsoleCommands()
{
@@ -20,15 +37,30 @@ 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 += () => _logger.Info("Multiplayer", "Connected successfully.");
MultiplayerController.ConnectionFailed += () => _logger.Warning("Multiplayer", "Connection failed.");
MultiplayerController.Disconnected += () => _logger.Warning("Multiplayer", "Server disconnected.");
MultiplayerController.PeerJoined += id => _logger.Info("Multiplayer", $"Peer {id} joined.");
MultiplayerController.PeerLeft += id => _logger.Info("Multiplayer", $"Peer {id} left.");
}
public override void _Ready()
{
this.AssertRequiredExports();
MouseCapture.Capture();
InitializeLogging();
InitializeMultiplayer();
RegisterConsoleCommands();
}
@@ -39,4 +71,9 @@ public partial class GameBootstrap : Node
MouseCapture.Toggle();
}
}
public override void _ExitTree()
{
_fileLogger?.Dispose();
}
}
+9 -4
View File
@@ -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]