Author SHA1 Message Date
Filip d8cc1d0859 feat: replicate players across multiplayer peers 2026-08-26 22:59:29 +02:00
Adrian 00627a7e91 feat: add spawn item console command 2026-08-24 17:26:31 +02:00
Adrian bed12ad335 feat: add two-handed flashlight interactions 2026-08-24 00:48:07 +02:00
Adrian f4331f426c feat: add extensible item inventory and world system 2026-08-23 23:16:49 +02:00
Adrian 25e32f20d9 feat: add player-aware world interaction system 2026-08-23 21:37:12 +02:00
Adrian 1445fc014f feat: implement multiplayer session state management and add invalid port exception 2026-08-25 02:10:54 +02:00
Filip 988f1102f8 feat: add logging feature with console, Godot and file outputs 2026-08-23 20:20:41 +02:00
Filip 5ee80df4c1 feat: add multiplayer session support with ENet host/join 2026-08-23 18:43:31 +02:00
Filip fc14cfe0c3 feat: add regex argument type to console command builder 2026-08-23 18:43:26 +02:00
Adrian 11fedb7f7b feat: project cleanup 2026-08-23 01:35:35 +02:00
Filip eba1af5ff0 feat: add noclip console command 2026-08-22 22:23:02 +02:00
Filip 76694a40f6 feat: split player movement into ground and noclip strategies 2026-08-22 22:22:51 +02:00
Filip 45c66ca7db chore: merge CommandArgType into CommandArgSpec 2026-08-22 22:22:32 +02:00
Filip af98795cae chore: remove redundant console focus logic 2026-08-22 19:54:23 +02:00
Filip adbac21339 refactor: flatten GameBootstrap and move Scenes under Godot 2026-08-22 19:48:03 +02:00
Filip 1086f4a1a7 chore: track generated uid for EntityId.cs 2026-08-22 19:42:39 +02:00
Filip a749d97b99 feat: add in-game debug console UI 2026-08-22 19:40:12 +02:00
Filip 4f57c77b7b refactor: extract shared MouseCapture helper 2026-08-22 19:39:52 +02:00
Filip 9f742f396f feat: add console command framework 2026-08-22 19:39:17 +02:00
Filip c95233b9f9 feat: implement first-person controller 2026-08-22 15:58:21 +02:00
Filip 470e70d51b feat: add game bootstrap for mouse capture handling 2026-08-22 15:55:28 +02:00
Filip 926b703ace feat: add input action and project setting mappings 2026-08-22 15:55:18 +02:00
Filip c702d7b2ab feat: add reusable node export validation 2026-08-22 15:55:09 +02:00
Filip ef6b34bcbd chore: target net9.0 and rename Player feature folder to Players 2026-08-22 15:54:50 +02:00
213 changed files with 3398 additions and 214 deletions
+4 -2
View File
@@ -1,7 +1,9 @@
<Project Sdk="Godot.NET.Sdk/4.7.1">
<Project Sdk="Godot.NET.Sdk/4.7.2">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<EnableDynamicLoading>true</EnableDynamicLoading>
<RootNamespace>Game</RootNamespace>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup>
</Project>
+59 -1
View File
@@ -11,7 +11,7 @@ config_version=5
[application]
config/name="Night Shift"
run/main_scene="uid://p766ipnev5hb"
run/main_scene="uid://c44pljehe0qun"
config/features=PackedStringArray("4.7", "C#", "Forward Plus")
config/icon="res://icon.svg"
@@ -29,6 +29,64 @@ project/assembly_name="NightShift"
naming/scene_name_casing=1
naming/script_name_casing=1
[input]
move_forward={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null)
]
}
move_back={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null)
]
}
move_left={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null)
]
}
move_right={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null)
]
}
jump={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null)
]
}
toggle_console={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194332,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
move_down={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194326,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
interact={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null)
]
}
use_left_hand={
"deadzone": 0.2,
"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":1,"canceled":false,"pressed":false,"double_click":false,"script":null)
]
}
use_right_hand={
"deadzone": 0.2,
"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":2,"canceled":false,"pressed":false,"double_click":false,"script":null)
]
}
drop_modifier={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":71,"key_label":0,"unicode":103,"location":0,"echo":false,"script":null)
]
}
[physics]
3d/physics_engine="Jolt Physics"
-8
View File
@@ -1,8 +0,0 @@
using Godot;
namespace Game.Bootstrap;
public partial class GameBootstrap : Node
{
}
@@ -0,0 +1,25 @@
using System.Collections.Generic;
using Game.Features.Console.Contracts;
using Game.Features.Console.Domain;
namespace Game.Features.Console.Application;
public sealed class CommandExecute(ICommandRegistry registry)
{
public CommandResult Execute(string input)
{
if (!CommandLineParser.TryParse(input, out string name, out IReadOnlyList<string> arguments))
{
return CommandResult.Fail("No command entered.");
}
if (!registry.TryGet(name, out ICommand command))
{
return CommandResult.Fail($"Unknown command: {name}");
}
CommandResult result = CommandArgValidator.Validate(command.Args, arguments);
return !result.Success ? result : command.Execute(new CommandArgValues(arguments));
}
}
@@ -0,0 +1 @@
uid://f1ed8fhpbwik
@@ -0,0 +1,13 @@
using System.Collections.Generic;
using Game.Features.Console.Contracts;
namespace Game.Features.Console.Application.Commands;
public sealed class ClearCommand : ICommand
{
public string Name => "clear";
public string Description => "Clears the console output.";
public IReadOnlyList<CommandArgSpec> Args { get; } = [];
public CommandResult Execute(CommandArgValues args) => CommandResult.Ok(clearOutput: true);
}
@@ -0,0 +1 @@
uid://cmmw8kn2fc3bb
@@ -0,0 +1,13 @@
using System.Collections.Generic;
using Game.Features.Console.Contracts;
namespace Game.Features.Console.Application.Commands;
public sealed class EchoCommand : ICommand
{
public string Name => "echo";
public string Description => "Prints the given text back to the console.";
public IReadOnlyList<CommandArgSpec> Args { get; } = new CommandArgsBuilder().AddStringArg("text").Build();
public CommandResult Execute(CommandArgValues args) => CommandResult.Ok(args.GetString(0));
}
@@ -0,0 +1 @@
uid://cy6dkjf4fxkvj
@@ -0,0 +1,30 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Game.Features.Console.Contracts;
namespace Game.Features.Console.Application.Commands;
public sealed class HelpCommand(ICommandRegistry registry) : ICommand
{
public string Name => "help";
public string Description => "Lists all available commands.";
public IReadOnlyList<CommandArgSpec> Args { get; } = [];
private static string FormatUsage(ICommand command)
{
string args = string.Join(" ", command.Args.Select(spec => $"<{spec.Name}:{spec}>"));
return $"{command.Name} {args}";
}
public CommandResult Execute(CommandArgValues args)
{
IEnumerable<string> lines = registry.All
.OrderBy(command => command.Name, StringComparer.Ordinal)
.Select(command => command.Args.Count == 0
? $"{command.Name} - {command.Description}"
: $"{command.Name} - {command.Description}\n usage: {FormatUsage(command)}");
return CommandResult.Ok($"List of available commands:\n{string.Join("\n", lines)}");
}
}
@@ -0,0 +1 @@
uid://bo7o0t08bb5u5
@@ -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
@@ -0,0 +1,20 @@
using System.Collections.Generic;
using Game.Features.Console.Contracts;
using Game.Features.Players.Contracts;
namespace Game.Features.Console.Application.Commands;
public sealed class NoclipCommand(IMovementModeSwitcher movementModeSwitcher) : ICommand
{
public string Name => "noclip";
public string Description => "Toggles noclip movement mode.";
public IReadOnlyList<CommandArgSpec> Args { get; } = [];
public CommandResult Execute(CommandArgValues args)
{
MovementMode next = movementModeSwitcher.ActiveMovementMode == MovementMode.Noclip ? MovementMode.Ground : MovementMode.Noclip;
movementModeSwitcher.SetMovementMode(next);
return CommandResult.Ok(next == MovementMode.Noclip ? "Noclip enabled." : "Noclip disabled.");
}
}
@@ -0,0 +1 @@
uid://de2nlceiv1per
@@ -0,0 +1,44 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Game.Features.Console.Contracts;
using Game.Features.Items.Contracts;
namespace Game.Features.Console.Application.Commands;
public sealed class SpawnItemCommand : ICommand
{
private readonly IItemSpawner _spawner;
private readonly IItemSpawnPoint _spawnPoint;
public string Name => "spawn_item";
public string Description => "Spawns an item in front of the player.";
public IReadOnlyList<CommandArgSpec> Args { get; }
public SpawnItemCommand(IItemSpawner spawner, IItemSpawnPoint spawnPoint)
{
_spawner = spawner;
_spawnPoint = spawnPoint;
string[] definitions = spawner.DefinitionIds
.Select(id => id.Value)
.OrderBy(id => id, StringComparer.Ordinal)
.ToArray();
Args = new CommandArgsBuilder().AddChoiceArg("item", definitions).Build();
}
public CommandResult Execute(CommandArgValues args)
{
ItemDefinitionId definitionId = new(args.GetString(0));
ItemActionResult result = _spawner.TrySpawn(
definitionId,
_spawnPoint.GetItemSpawnPosition(),
out ItemInstanceId itemId
);
return result.Success
? CommandResult.Ok($"Spawned {definitionId} ({itemId}).")
: CommandResult.Fail(result.Message);
}
}
@@ -0,0 +1 @@
uid://e8m3q6v1r9k4w
@@ -0,0 +1,27 @@
using System.Collections.Generic;
namespace Game.Features.Console.Contracts;
public enum CommandArgType
{
String,
Int,
Float,
Bool,
Choice,
Regex
}
public sealed record CommandArgSpec(
string Name,
CommandArgType Type,
IReadOnlyList<string> Choices = null,
string Pattern = null
) {
public override string ToString() => Type switch
{
CommandArgType.Choice when Choices is not null => string.Join("|", Choices),
CommandArgType.Regex when Pattern is not null => "regex",
_ => Type.ToString().ToLowerInvariant()
};
}
@@ -0,0 +1 @@
uid://c3dojuv6775r1
@@ -0,0 +1,15 @@
using System.Collections.Generic;
using System.Globalization;
namespace Game.Features.Console.Contracts;
public sealed class CommandArgValues(IReadOnlyList<string> values)
{
public string GetString(int index) => values[index];
public int GetInt(int index) => int.Parse(values[index], CultureInfo.InvariantCulture);
public float GetFloat(int index) => float.Parse(values[index], CultureInfo.InvariantCulture);
public bool GetBool(int index) => bool.Parse(values[index]);
}
@@ -0,0 +1 @@
uid://carkvp7i86mq4
@@ -0,0 +1,46 @@
using System.Collections.Generic;
namespace Game.Features.Console.Contracts;
public sealed class CommandArgsBuilder
{
private readonly List<CommandArgSpec> _specs = [];
public CommandArgsBuilder AddStringArg(string name)
{
_specs.Add(new CommandArgSpec(name, CommandArgType.String));
return this;
}
public CommandArgsBuilder AddIntArg(string name)
{
_specs.Add(new CommandArgSpec(name, CommandArgType.Int));
return this;
}
public CommandArgsBuilder AddFloatArg(string name)
{
_specs.Add(new CommandArgSpec(name, CommandArgType.Float));
return this;
}
public CommandArgsBuilder AddBoolArg(string name)
{
_specs.Add(new CommandArgSpec(name, CommandArgType.Bool));
return this;
}
public CommandArgsBuilder AddChoiceArg(string name, IReadOnlyList<string> choices)
{
_specs.Add(new CommandArgSpec(name, CommandArgType.Choice, choices));
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();
}
@@ -0,0 +1 @@
uid://cyviyspt0gye6
@@ -0,0 +1,7 @@
namespace Game.Features.Console.Contracts;
public readonly record struct CommandResult(bool Success, string Message, bool ClearOutput = false)
{
public static CommandResult Ok(string message = "", bool clearOutput = false) => new(true, message, clearOutput);
public static CommandResult Fail(string message) => new(false, message);
}
@@ -0,0 +1 @@
uid://bfvfjkitb1hq7
@@ -0,0 +1,11 @@
using System.Collections.Generic;
namespace Game.Features.Console.Contracts;
public interface ICommand
{
string Name { get; }
string Description { get; }
IReadOnlyList<CommandArgSpec> Args { get; }
CommandResult Execute(CommandArgValues args);
}
@@ -0,0 +1 @@
uid://d18ye7rqu75ej
@@ -0,0 +1,10 @@
using System.Collections.Generic;
namespace Game.Features.Console.Contracts;
public interface ICommandRegistry
{
IReadOnlyCollection<ICommand> All { get; }
void Register(ICommand command);
bool TryGet(string name, out ICommand command);
}
@@ -0,0 +1 @@
uid://bpw55xoj10toe
@@ -0,0 +1,45 @@
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;
public static class CommandArgValidator
{
private static bool ChoicesContain(IReadOnlyList<string> choices, string value) =>
choices.Any(choice => string.Equals(choice, value, StringComparison.OrdinalIgnoreCase));
private static bool MatchesSpec(string rawValue, CommandArgSpec spec) => spec.Type switch
{
CommandArgType.String => true,
CommandArgType.Int => int.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out _),
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)
};
public static CommandResult Validate(IReadOnlyList<CommandArgSpec> specs, IReadOnlyList<string> arguments)
{
if (arguments.Count != specs.Count)
{
return CommandResult.Fail($"Expected {specs.Count} argument(s), got {arguments.Count}.");
}
for (int i = 0; i < specs.Count; i++)
{
CommandArgSpec spec = specs[i];
string rawValue = arguments[i];
if (MatchesSpec(rawValue, spec)) continue;
return CommandResult.Fail($"Argument '{spec.Name}' expected {spec}, got '{rawValue}'.");
}
return CommandResult.Ok();
}
}
@@ -0,0 +1 @@
uid://dtgsw88f1pejl
@@ -0,0 +1,22 @@
using System;
using System.Collections.Generic;
namespace Game.Features.Console.Domain;
public static class CommandLineParser
{
public static bool TryParse(string input, out string name, out IReadOnlyList<string> arguments)
{
string[] tokens = input.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (tokens.Length == 0)
{
name = string.Empty;
arguments = [];
return false;
}
name = tokens[0];
arguments = tokens[1..];
return true;
}
}
@@ -0,0 +1 @@
uid://brr37gdqsgb0m
@@ -0,0 +1,87 @@
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;
using Godot;
namespace Game.Features.Console.Godot;
public partial class ConsoleController : CanvasLayer, ILogTarget
{
[Export] public LineEdit CommandInput;
[Export] public RichTextLabel OutputLog;
private CommandExecute _commandExecute = new(new CommandRegistry());
private MouseCapture.Memento _mouseMemento;
private void Toggle()
{
Visible = !Visible;
if (Visible)
{
GamePause.Pause();
_mouseMemento = MouseCapture.SaveCaptureMode();
CommandInput.GrabFocus();
}
else
{
GamePause.Resume();
_mouseMemento.RestoreCaptureMode();
}
}
private void OnInputSubmitted(string text)
{
CommandInput.Clear();
if (text.Length == 0) return;
AppendLine($"> {text}");
CommandResult result = _commandExecute.Execute(text);
if (result.ClearOutput)
{
OutputLog.Clear();
return;
}
if (result.Message.Length > 0)
{
AppendLine(result.Message);
}
}
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();
Visible = false;
GamePause.SetIgnoresPause(this, true);
}
public override void _Input(InputEvent @event)
{
if (!@event.IsActionPressed(InputAction.ToggleConsole.ActionName())) return;
Toggle();
GetViewport().SetInputAsHandled();
}
}
@@ -0,0 +1 @@
uid://ypsdpciag6r6
@@ -0,0 +1,71 @@
[gd_scene format=3 uid="uid://bk0771dvonxh8"]
[ext_resource type="Script" uid="uid://ypsdpciag6r6" path="res://src/Features/Console/Godot/ConsoleController.cs" id="1_script"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_console"]
content_margin_left = 8.0
content_margin_top = 8.0
content_margin_right = 8.0
content_margin_bottom = 8.0
bg_color = Color(0, 0, 0, 0.85)
[sub_resource type="SystemFont" id="SystemFont_terminal"]
font_names = PackedStringArray("Consolas", "Cascadia Mono", "Courier New")
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_input"]
content_margin_left = 0.0
content_margin_top = 12.0
content_margin_right = 0.0
content_margin_bottom = 0.0
bg_color = Color(0, 0, 0, 0)
[node name="ConsoleController" type="CanvasLayer" unique_id=1711862908 node_paths=PackedStringArray("CommandInput", "OutputLog")]
layer = 100
script = ExtResource("1_script")
CommandInput = NodePath("Panel/Container/InputRow/CommandInput")
OutputLog = NodePath("Panel/Container/OutputLog")
[node name="Panel" type="PanelContainer" parent="." unique_id=515760683]
anchors_preset = 10
anchor_right = 1.0
offset_bottom = 260.0
theme_override_styles/panel = SubResource("StyleBoxFlat_console")
[node name="Container" type="VBoxContainer" parent="Panel" unique_id=37696848]
layout_mode = 2
theme_override_constants/separation = 0
[node name="OutputLog" type="RichTextLabel" parent="Panel/Container" unique_id=1318894518]
custom_minimum_size = Vector2(0, 220)
layout_mode = 2
size_flags_vertical = 3
theme_override_fonts/normal_font = SubResource("SystemFont_terminal")
theme_override_fonts/bold_font = SubResource("SystemFont_terminal")
theme_override_fonts/italics_font = SubResource("SystemFont_terminal")
theme_override_fonts/mono_font = SubResource("SystemFont_terminal")
scroll_following = true
[node name="InputRow" type="HBoxContainer" parent="Panel/Container" unique_id=1183500371]
layout_mode = 2
theme_override_constants/separation = 4
[node name="PromptMargin" type="MarginContainer" parent="Panel/Container/InputRow" unique_id=1356750045]
layout_mode = 2
theme_override_constants/margin_top = 12
[node name="PromptLabel" type="Label" parent="Panel/Container/InputRow/PromptMargin" unique_id=429447266]
layout_mode = 2
theme_override_fonts/font = SubResource("SystemFont_terminal")
text = ">"
[node name="CommandInput" type="LineEdit" parent="Panel/Container/InputRow" unique_id=209466737]
layout_mode = 2
size_flags_horizontal = 3
theme_override_fonts/font = SubResource("SystemFont_terminal")
theme_override_styles/normal = SubResource("StyleBoxFlat_input")
theme_override_styles/read_only = SubResource("StyleBoxFlat_input")
theme_override_styles/focus = SubResource("StyleBoxFlat_input")
keep_editing_on_text_submit = true
caret_blink = true
[connection signal="text_submitted" from="Panel/Container/InputRow/CommandInput" to="." method="OnInputSubmitted"]
@@ -0,0 +1,18 @@
using System.Collections.Generic;
using Game.Features.Console.Contracts;
namespace Game.Features.Console.Infrastructure;
public sealed class CommandRegistry : ICommandRegistry
{
private readonly Dictionary<string, ICommand> _commands = new();
public IReadOnlyCollection<ICommand> All => _commands.Values;
public void Register(ICommand command)
{
_commands[command.Name] = command;
}
public bool TryGet(string name, out ICommand command) => _commands.TryGetValue(name, out command);
}
@@ -0,0 +1 @@
uid://cfcp8d8sh0gf5
@@ -0,0 +1,8 @@
using Game.Features.Interactions.Contracts;
namespace Game.Features.Interactions.Application;
public sealed class InteractionService(IInteractionWorld world) : IInteractionService
{
public InteractionResult TryInteract(InteractionRequest request) => world.TryInteract(request);
}
@@ -0,0 +1 @@
uid://b1m7q4y9c5v2n
@@ -0,0 +1,6 @@
namespace Game.Features.Interactions.Contracts;
public interface IInteractionService
{
InteractionResult TryInteract(InteractionRequest request);
}
@@ -0,0 +1 @@
uid://a3n8v5r1q7m9c
@@ -0,0 +1,8 @@
namespace Game.Features.Interactions.Contracts;
public interface IInteractionTarget
{
InteractionTargetId Id { get; }
bool CanInteract { get; }
InteractionPrompt Prompt { get; }
}
@@ -0,0 +1 @@
uid://c7p2y5n8f1v6m
@@ -0,0 +1,6 @@
namespace Game.Features.Interactions.Contracts;
public interface IInteractionWorld
{
InteractionResult TryInteract(InteractionRequest request);
}
@@ -0,0 +1 @@
uid://d7k1x6m4v9q2b
@@ -0,0 +1,16 @@
using Game.Features.Interactions.Contracts;
using Game.Features.Players.Contracts;
using Godot;
namespace Game.Features.Items.Contracts;
public interface IPlayerInteractionController : IItemSpawnPoint
{
void SetActive(bool isActive);
void InitializeController(
PlayerId playerId,
IItemInteractionService items,
IInteractionService interactions
);
}
@@ -0,0 +1 @@
uid://cvfdyxpc3xkfd
@@ -0,0 +1,3 @@
namespace Game.Features.Interactions.Contracts;
public readonly record struct InteractionPrompt(string Text);
@@ -0,0 +1 @@
uid://b3m8r1x6d9w4k
@@ -0,0 +1,5 @@
using Game.Features.Players.Contracts;
namespace Game.Features.Interactions.Contracts;
public readonly record struct InteractionRequest(PlayerId PlayerId, InteractionTargetId TargetId);
@@ -0,0 +1 @@
uid://e2q7k5n1w8m4d
@@ -0,0 +1,10 @@
namespace Game.Features.Interactions.Contracts;
public readonly record struct InteractionResult(InteractionStatus Status, string Message = "")
{
public bool Success => Status == InteractionStatus.Success;
public static InteractionResult Ok() => new(InteractionStatus.Success);
public static InteractionResult Fail(InteractionStatus status, string message) => new(status, message);
}
@@ -0,0 +1 @@
uid://f9w2d7k4m6q1x
@@ -0,0 +1,9 @@
namespace Game.Features.Interactions.Contracts;
public enum InteractionStatus
{
Success,
TargetNotFound,
Unavailable,
Rejected
}
@@ -0,0 +1 @@
uid://b4y9m3r6v1c8p
@@ -0,0 +1,8 @@
using System;
namespace Game.Features.Interactions.Contracts;
public readonly record struct InteractionTargetId(Guid Value)
{
public override string ToString() => Value.ToString();
}
@@ -0,0 +1 @@
uid://c6m1v8q4r9x2k
@@ -0,0 +1,27 @@
using Game.Godot.Extensions;
using Godot;
namespace Game.Features.Interactions.Godot;
public partial class InteractionPromptController : CanvasLayer
{
[Export] public Label PromptLabel;
public override void _Ready()
{
this.AssertRequiredExports();
Clear();
}
public void ShowPrompt(string text)
{
PromptLabel.Text = text;
PromptLabel.Visible = text.Length > 0;
}
public void Clear()
{
PromptLabel.Text = string.Empty;
PromptLabel.Visible = false;
}
}
@@ -0,0 +1 @@
uid://b9r2w6n4f8k1v
@@ -0,0 +1,26 @@
using Godot;
namespace Game.Features.Interactions.Godot;
public partial class InteractionRaycaster : RayCast3D
{
public T GetTarget<T>() where T : class
{
if (!IsColliding() || GetCollider() is not Node node)
{
return null;
}
while (node is not null)
{
if (node is T target)
{
return target;
}
node = node.GetParent();
}
return null;
}
}
@@ -0,0 +1 @@
uid://e4m7k1x8c3q6h
@@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
using Game.Features.Interactions.Contracts;
using Godot;
namespace Game.Features.Interactions.Godot;
public partial class InteractionWorldController : Node, IInteractionWorld
{
public const string RegistryGroup = "interaction_world_registry";
private readonly Dictionary<InteractionTargetId, WorldInteractionController> _targets = [];
public override void _EnterTree()
{
AddToGroup(RegistryGroup);
}
public void RegisterSceneTargets()
{
foreach (Node node in GetTree().GetNodesInGroup(WorldInteractionController.InteractionGroup))
{
if (node is WorldInteractionController target)
{
Register(target);
}
}
}
public void Register(WorldInteractionController target)
{
if (_targets.TryGetValue(target.Id, out WorldInteractionController existing))
{
if (ReferenceEquals(existing, target))
{
return;
}
throw new InvalidOperationException($"Duplicate interaction target id: {target.Id}.");
}
_targets.Add(target.Id, target);
target.RegisterWith(this);
}
public void Unregister(InteractionTargetId targetId)
{
_targets.Remove(targetId);
}
public InteractionResult TryInteract(InteractionRequest request)
{
if (!_targets.TryGetValue(request.TargetId, out WorldInteractionController target))
{
return InteractionResult.Fail(InteractionStatus.TargetNotFound, $"Target {request.TargetId} was not found.");
}
if (!target.CanInteract)
{
return InteractionResult.Fail(InteractionStatus.Unavailable, "The target is not currently available.");
}
return target.Execute(request.PlayerId);
}
}
@@ -0,0 +1 @@
uid://f6q1c9m4w7v2n
@@ -0,0 +1,248 @@
using System.Collections.Generic;
using Game.Features.Interactions.Contracts;
using Game.Features.Items.Contracts;
using Game.Features.Players.Contracts;
using Game.Godot;
using Game.Godot.Extensions;
using Game.Godot.Mappings;
using Godot;
namespace Game.Features.Interactions.Godot;
public partial class PlayerInteractionController : Node, IPlayerInteractionController
{
[Export]
public Camera3D Camera;
[Export]
public CharacterBody3D Body;
[Export]
public InteractionRaycaster InteractionRaycaster;
[Export]
public RayCast3D DropRaycaster;
[Export]
public InteractionPromptController PromptController;
private IItemInteractionService _items;
private IInteractionService _interactions;
private PlayerId _playerId;
private readonly HashSet<Hand> _dropActions = [];
private readonly Dictionary<Hand, InteractionTargetId?> _activeUseTargets = [];
public override void _Ready()
{
this.AssertRequiredExports();
}
public void SetActive(bool isActive)
{
SetProcess(isActive);
SetProcessUnhandledInput(isActive);
}
public void InitializeController(
PlayerId playerId,
IItemInteractionService items,
IInteractionService interactions
)
{
_playerId = playerId;
_items = items;
_interactions = interactions;
}
public override void _Process(double delta)
{
if (
_items is null
|| _interactions is null
|| GamePause.IsPaused
|| !MouseCapture.IsCaptured
)
{
PromptController.Clear();
return;
}
UpdatePrompt();
}
public override void _UnhandledInput(InputEvent @event)
{
if (
_items is null
|| _interactions is null
|| GamePause.IsPaused
|| !MouseCapture.IsCaptured
)
{
return;
}
if (@event.IsActionPressed(InputAction.UseLeftHand.ActionName()))
{
ProcessHandActionStarted(Hand.Left);
GetViewport().SetInputAsHandled();
return;
}
if (@event.IsActionReleased(InputAction.UseLeftHand.ActionName()))
{
ProcessHandActionCompleted(Hand.Left);
GetViewport().SetInputAsHandled();
return;
}
if (@event.IsActionPressed(InputAction.UseRightHand.ActionName()))
{
ProcessHandActionStarted(Hand.Right);
GetViewport().SetInputAsHandled();
return;
}
if (@event.IsActionReleased(InputAction.UseRightHand.ActionName()))
{
ProcessHandActionCompleted(Hand.Right);
GetViewport().SetInputAsHandled();
return;
}
if (@event.IsActionPressed(InputAction.Interact.ActionName()))
{
IInteractionTarget target = InteractionRaycaster.GetTarget<IInteractionTarget>();
if (target?.CanInteract == true)
{
_interactions.TryInteract(new InteractionRequest(_playerId, target.Id));
}
GetViewport().SetInputAsHandled();
}
}
public Vector3 GetItemSpawnPosition() => CalculateDropPosition();
private void ProcessHandActionStarted(Hand hand)
{
if (Input.IsActionPressed(InputAction.DropModifier.ActionName()))
{
if (_activeUseTargets.Remove(hand, out InteractionTargetId? activeTargetId))
{
_items.TryUse(
new ItemUseRequest(_playerId, hand, ItemActionPhase.Cancelled, activeTargetId)
);
}
_items.TryDrop(hand, CalculateDropPosition());
_dropActions.Add(hand);
return;
}
if (!_items.IsHandFree(hand))
{
ItemUseRequest request = CreateUseRequest(hand, ItemActionPhase.Started);
ItemActionResult result = _items.TryUse(request);
if (result.Success)
{
_activeUseTargets[hand] = request.TargetId;
}
return;
}
IPickupTarget target = InteractionRaycaster.GetTarget<IPickupTarget>();
if (target is not null)
{
_items.TryPickup(target.ItemInfo.Id, hand);
}
}
private void ProcessHandActionCompleted(Hand hand)
{
if (_dropActions.Remove(hand))
{
return;
}
if (_activeUseTargets.Remove(hand, out InteractionTargetId? targetId))
{
_items.TryUse(new ItemUseRequest(_playerId, hand, ItemActionPhase.Completed, targetId));
}
}
private ItemUseRequest CreateUseRequest(Hand hand, ItemActionPhase phase)
{
InteractionTargetId? targetId = InteractionRaycaster.GetTarget<IInteractionTarget>()?.Id;
return new ItemUseRequest(_playerId, hand, phase, targetId);
}
private Vector3 CalculateDropPosition()
{
DropRaycaster.ForceRaycastUpdate();
Vector3 origin = Camera.GlobalPosition;
Vector3 direction = -Camera.GlobalBasis.Z.Normalized();
float distance = DropRaycaster.TargetPosition.Length();
if (DropRaycaster.IsColliding())
{
distance = Mathf.Max(
0.25f,
origin.DistanceTo(DropRaycaster.GetCollisionPoint()) - 0.2f
);
}
return origin + direction * distance;
}
private void UpdatePrompt()
{
IPickupTarget pickupTarget = InteractionRaycaster.GetTarget<IPickupTarget>();
if (pickupTarget is not null)
{
ShowPickupPrompt(pickupTarget.ItemInfo);
return;
}
IInteractionTarget interactionTarget = InteractionRaycaster.GetTarget<IInteractionTarget>();
if (interactionTarget?.CanInteract == true)
{
PromptController.ShowPrompt($"[E] {interactionTarget.Prompt.Text}");
return;
}
PromptController.Clear();
}
private void ShowPickupPrompt(ItemInfo itemInfo)
{
if (itemInfo.HandRequirement == HandRequirement.Both)
{
bool bothHandsFree = _items.IsHandFree(Hand.Left) && _items.IsHandFree(Hand.Right);
PromptController.ShowPrompt(
bothHandsFree
? $"[LPM lub PPM] Podnieś {itemInfo.DisplayName} — obie ręce"
: "Potrzebujesz obu wolnych rąk"
);
return;
}
List<string> actions = [];
if (_items.IsHandFree(Hand.Left))
{
actions.Add($"[LPM] Podnieś {itemInfo.DisplayName} — lewa ręka");
}
if (_items.IsHandFree(Hand.Right))
{
actions.Add($"[PPM] Podnieś {itemInfo.DisplayName} — prawa ręka");
}
PromptController.ShowPrompt(
actions.Count > 0 ? string.Join("\n", actions) : "Brak wolnej ręki"
);
}
}
@@ -0,0 +1 @@
uid://c2v8m5r1y7q4d
@@ -0,0 +1,52 @@
using System;
using Game.Features.Interactions.Contracts;
using Game.Features.Players.Contracts;
using Godot;
namespace Game.Features.Interactions.Godot;
public abstract partial class WorldInteractionController : Node3D, IInteractionTarget
{
public const string InteractionGroup = "world_interactions";
[Export] public string InstanceId;
[Export] public string PromptText = "Wejdź w interakcję";
public InteractionTargetId Id { get; private set; }
public virtual bool CanInteract => true;
public InteractionPrompt Prompt => new(PromptText);
private InteractionWorldController _registry;
public override void _Ready()
{
Guid id = string.IsNullOrWhiteSpace(InstanceId) ? Guid.NewGuid() : Guid.Parse(InstanceId);
Id = new InteractionTargetId(id);
AddToGroup(InteractionGroup);
RegisterWithAvailableWorld();
}
public override void _ExitTree()
{
_registry?.Unregister(Id);
}
public void RegisterWith(InteractionWorldController registry)
{
_registry = registry;
}
public abstract InteractionResult Execute(PlayerId playerId);
private void RegisterWithAvailableWorld()
{
foreach (Node node in GetTree().GetNodesInGroup(InteractionWorldController.RegistryGroup))
{
if (node is InteractionWorldController registry)
{
registry.Register(this);
return;
}
}
}
}
@@ -0,0 +1 @@
uid://d2r8m5v1q9k4x
@@ -0,0 +1,83 @@
using Game.Features.Items.Contracts;
using Game.Features.Items.Domain;
using Godot;
namespace Game.Features.Items.Application;
public sealed class ItemInteractionService(
HandsInventory hands,
IItemRepository itemRepository,
IHeldItemPresenter presenter
) : IItemInteractionService
{
public bool IsHandFree(Hand hand) => hands.IsFree(hand);
public ItemInstanceId? GetHeldItem(Hand hand) => hands.Get(hand);
public ItemActionResult TryPickup(ItemInstanceId itemId, Hand hand)
{
if (!itemRepository.TryGet(itemId, out IItemInstance item))
{
return ItemActionResult.Fail(ItemActionStatus.ItemNotFound, $"Item {itemId} was not found.");
}
if (!hands.TryHold(itemId, item.Info.HandRequirement, hand))
{
return ItemActionResult.Fail(ItemActionStatus.HandOccupied, "The requested hand is not available.");
}
if (presenter.TryShowHeld(item, hand))
{
return ItemActionResult.Ok();
}
hands.Release(hand);
return ItemActionResult.Fail(ItemActionStatus.PresentationFailed, "Could not present the held item.");
}
public ItemActionResult TryUse(ItemUseRequest request)
{
ItemInstanceId? itemId = hands.Get(request.Hand);
if (itemId is null)
{
return ItemActionResult.Fail(ItemActionStatus.HandEmpty, "The requested hand is empty.");
}
if (!itemRepository.TryGet(itemId.Value, out IItemInstance item))
{
return ItemActionResult.Fail(ItemActionStatus.ItemNotFound, $"Item {itemId} was not found.");
}
ItemActionResult result = item.Use(request);
if (result.Success)
{
presenter.Refresh(item);
}
return result;
}
public ItemActionResult TryDrop(Hand hand, Vector3 worldPosition)
{
ItemInstanceId? itemId = hands.Get(hand);
if (itemId is null)
{
return ItemActionResult.Fail(ItemActionStatus.HandEmpty, "The requested hand is empty.");
}
if (!itemRepository.TryGet(itemId.Value, out IItemInstance item))
{
return ItemActionResult.Fail(ItemActionStatus.ItemNotFound, $"Item {itemId} was not found.");
}
hands.Release(hand);
if (presenter.TryShowDropped(item, worldPosition))
{
return ItemActionResult.Ok();
}
hands.TryHold(itemId.Value, item.Info.HandRequirement, hand);
return ItemActionResult.Fail(ItemActionStatus.PresentationFailed, "Could not present the dropped item.");
}
}
@@ -0,0 +1 @@
uid://e6q2h9v5b8k3p
+7
View File
@@ -0,0 +1,7 @@
namespace Game.Features.Items.Contracts;
public enum Hand
{
Left,
Right
}
+1
View File
@@ -0,0 +1 @@
uid://c4n7h2w8m5q1p
@@ -0,0 +1,7 @@
namespace Game.Features.Items.Contracts;
public enum HandRequirement
{
One,
Both
}
@@ -0,0 +1 @@
uid://d8p3k6v1r9y4b
@@ -0,0 +1,10 @@
using Godot;
namespace Game.Features.Items.Contracts;
public interface IHeldItemPresenter
{
bool TryShowHeld(IItemInstance item, Hand hand);
bool TryShowDropped(IItemInstance item, Vector3 worldPosition);
void Refresh(IItemInstance item);
}
@@ -0,0 +1 @@
uid://c1q7y4d9m5x2v
@@ -0,0 +1,7 @@
namespace Game.Features.Items.Contracts;
public interface IItemInstance
{
ItemInfo Info { get; }
ItemActionResult Use(ItemUseRequest request);
}
@@ -0,0 +1 @@
uid://b5x9q3h7m2w8f
@@ -0,0 +1,12 @@
using Godot;
namespace Game.Features.Items.Contracts;
public interface IItemInteractionService
{
bool IsHandFree(Hand hand);
ItemInstanceId? GetHeldItem(Hand hand);
ItemActionResult TryPickup(ItemInstanceId itemId, Hand hand);
ItemActionResult TryUse(ItemUseRequest request);
ItemActionResult TryDrop(Hand hand, Vector3 worldPosition);
}
@@ -0,0 +1 @@
uid://f2k8q5b1w7m4n
@@ -0,0 +1,6 @@
namespace Game.Features.Items.Contracts;
public interface IItemRepository
{
bool TryGet(ItemInstanceId itemId, out IItemInstance item);
}
@@ -0,0 +1 @@
uid://e7n1m6v4k9r3q
@@ -0,0 +1,8 @@
using Godot;
namespace Game.Features.Items.Contracts;
public interface IItemSpawnPoint
{
Vector3 GetItemSpawnPosition();
}
@@ -0,0 +1 @@
uid://a2v6r8m3q7k5d
@@ -0,0 +1,10 @@
using System.Collections.Generic;
using Godot;
namespace Game.Features.Items.Contracts;
public interface IItemSpawner
{
IReadOnlyCollection<ItemDefinitionId> DefinitionIds { get; }
ItemActionResult TrySpawn(ItemDefinitionId definitionId, Vector3 worldPosition, out ItemInstanceId itemId);
}
@@ -0,0 +1 @@
uid://f4m9k2w6b8n1r
@@ -0,0 +1,6 @@
namespace Game.Features.Items.Contracts;
public interface IPickupTarget
{
ItemInfo ItemInfo { get; }
}
@@ -0,0 +1 @@
uid://c9v4g7p2m5x8d
@@ -0,0 +1,8 @@
namespace Game.Features.Items.Contracts;
public enum ItemActionPhase
{
Started,
Completed,
Cancelled
}
@@ -0,0 +1 @@
uid://c9w5d2n8q4m1h
@@ -0,0 +1,10 @@
namespace Game.Features.Items.Contracts;
public readonly record struct ItemActionResult(ItemActionStatus Status, string Message = "")
{
public bool Success => Status == ItemActionStatus.Success;
public static ItemActionResult Ok() => new(ItemActionStatus.Success);
public static ItemActionResult Fail(ItemActionStatus status, string message) => new(status, message);
}
@@ -0,0 +1 @@
uid://a8m4p1x7d3q6k
@@ -0,0 +1,12 @@
namespace Game.Features.Items.Contracts;
public enum ItemActionStatus
{
Success,
ItemNotFound,
HandOccupied,
HandEmpty,
UnsupportedAction,
PresentationFailed,
UnknownDefinition
}
@@ -0,0 +1 @@
uid://f1r6y3b9k5v2m
@@ -0,0 +1,7 @@
namespace Game.Features.Items.Contracts;
public readonly record struct ItemDefinition(
ItemDefinitionId Id,
string DisplayName,
HandRequirement HandRequirement
);
@@ -0,0 +1 @@
uid://e3v8k4r1m7x2p
@@ -0,0 +1,6 @@
namespace Game.Features.Items.Contracts;
public readonly record struct ItemDefinitionId(string Value)
{
public override string ToString() => Value;
}
@@ -0,0 +1 @@
uid://b7q2n9c5w1m6y

Some files were not shown because too many files have changed in this diff Show More