Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef5f7d127f | ||
|
|
db0356eb6f | ||
|
|
888e39e15d | ||
|
|
9fdd565637 | ||
|
|
3127c1b4af | ||
|
|
00627a7e91 | ||
|
|
bed12ad335 | ||
|
|
f4331f426c | ||
|
|
25e32f20d9 | ||
|
|
1445fc014f | ||
|
|
988f1102f8 | ||
|
|
5ee80df4c1 | ||
|
|
fc14cfe0c3 | ||
|
|
11fedb7f7b | ||
|
|
eba1af5ff0 | ||
|
|
76694a40f6 | ||
|
|
45c66ca7db | ||
|
|
af98795cae | ||
|
|
adbac21339 | ||
|
|
1086f4a1a7 | ||
|
|
a749d97b99 | ||
|
|
4f57c77b7b | ||
|
|
9f742f396f | ||
|
|
c95233b9f9 | ||
|
|
470e70d51b | ||
|
|
926b703ace | ||
|
|
c702d7b2ab | ||
|
|
ef6b34bcbd |
+2
-2
@@ -1,6 +1,6 @@
|
||||
<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>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -247,6 +247,27 @@ Loot -> Enemies.Application ŹLE
|
||||
Combat -> Enemies.Godot ŹLE
|
||||
```
|
||||
|
||||
### Wyjątek: `Godot` -> `Godot`
|
||||
|
||||
`Domain` i `Application` trzymają się reguły bez wyjątków - tam kontrakt broni czegoś realnego (można podmienić implementację, przetestować logikę bez silnika, nie ma przypadkowego sprzężenia).
|
||||
|
||||
`Godot` tego wsparcia nie potrzebuje - kontrolery są celowo "głupie" i nie są objęte testami automatycznymi, więc kontrakt istniałby tam wyłącznie po to, żeby ominąć ograniczenie edytora Godota (`[Export]` nie umie wskazywać na typ-interfejs). Dlatego `Godot` jednego feature'a może importować `Godot` innego feature'a bezpośrednio, najczęściej po to, żeby zawrzeć w scenie węzeł z innego feature'a (`[Export]` na konkretną klasę) i wywołać na nim kilka metod.
|
||||
|
||||
```text
|
||||
Players.Godot -> Interactions.Godot DOBRZE (kontener sceny, wywołanie metod kontrolera)
|
||||
Players.Application -> Interactions.Godot ŹLE (logika feature'a nadal tylko przez Contracts)
|
||||
Players.Domain -> Interactions.Godot ŹLE
|
||||
```
|
||||
|
||||
`GetNode`/`GetNodeOrNull` po ścieżce w scenie między feature'ami dalej jest niepożądane - zależność ma być jawna (typowane pole `[Export]` albo parametr metody `Initialize`/`Use...`), nie odnajdywana w drzewie sceny w runtime.
|
||||
|
||||
Ten wyjątek nie zwalnia z Prawa Demeter. Rozmawiamy tylko z bezpośrednim sąsiadem - jeśli feature trzyma w sobie kontroler innego feature'a, woła na nim metody sam, zamiast wystawiać go dalej do przechodzenia przez łańcuch (`node.Inner.Other.Method()`). Kod spoza feature'a nie powinien wiedzieć, że dany kontroler w ogóle zawiera w sobie coś z innego feature'a.
|
||||
|
||||
```text
|
||||
player.SetMovementMode(mode) DOBRZE (PlayerController sam woła w środku Interaction/Body)
|
||||
player.Interaction.SetActive(isActive) ŹLE (wywołujący łańcuje się przez Playera do Interaction)
|
||||
```
|
||||
|
||||
### Eventy między feature'ami
|
||||
|
||||
Gdy jedno zdarzenie interesuje wiele feature'ów, feature źródłowy publikuje event i nie wie, kto go słucha:
|
||||
|
||||
+59
-1
@@ -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"
|
||||
|
||||
@@ -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, IItemDefinitionRegistry registry, IItemSpawnPoint spawnPoint)
|
||||
{
|
||||
_spawner = spawner;
|
||||
_spawnPoint = spawnPoint;
|
||||
|
||||
string[] definitions = registry.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,21 @@
|
||||
using Game.Features.Interactions.Contracts;
|
||||
|
||||
namespace Game.Features.Interactions.Application;
|
||||
|
||||
public sealed class Interact(IInteractionRegistry registry) : IInteractionHandler
|
||||
{
|
||||
public InteractionResult TryInteract(InteractionRequest request)
|
||||
{
|
||||
if (!registry.TryGetTarget(request.TargetId, out IInteractionTarget 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://b1m7q4y9c5v2n
|
||||
@@ -0,0 +1,19 @@
|
||||
using Game.Features.Items.Contracts;
|
||||
|
||||
namespace Game.Features.Interactions.Application;
|
||||
|
||||
public readonly record struct PickupAvailability(bool LeftHand, bool RightHand, bool RequiresBoth);
|
||||
|
||||
public sealed class ResolvePickupAvailability
|
||||
{
|
||||
public PickupAvailability Resolve(HandRequirement requirement, bool leftHandFree, bool rightHandFree)
|
||||
{
|
||||
if (requirement == HandRequirement.Both)
|
||||
{
|
||||
bool canUseBoth = leftHandFree && rightHandFree;
|
||||
return new PickupAvailability(canUseBoth, canUseBoth, true);
|
||||
}
|
||||
|
||||
return new PickupAvailability(leftHandFree, rightHandFree, false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://c6mhpn06a47qu
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Game.Features.Interactions.Contracts;
|
||||
|
||||
public interface IInteractionHandler
|
||||
{
|
||||
InteractionResult TryInteract(InteractionRequest request);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://a3n8v5r1q7m9c
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Game.Features.Interactions.Contracts;
|
||||
|
||||
public interface IInteractionRegistry
|
||||
{
|
||||
void Register(IInteractionTarget target);
|
||||
void Unregister(InteractionTargetId targetId);
|
||||
bool TryGetTarget(InteractionTargetId targetId, out IInteractionTarget target);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://baejppnflmpnn
|
||||
@@ -0,0 +1,12 @@
|
||||
using Game.Features.Players.Contracts;
|
||||
|
||||
namespace Game.Features.Interactions.Contracts;
|
||||
|
||||
public interface IInteractionTarget
|
||||
{
|
||||
InteractionTargetId InteractionTargetId { get; }
|
||||
bool CanInteract { get; }
|
||||
InteractionPrompt Prompt { get; }
|
||||
|
||||
InteractionResult Execute(PlayerId playerId);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://c7p2y5n8f1v6m
|
||||
@@ -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,10 @@
|
||||
using Game.Shared.Domain;
|
||||
|
||||
namespace Game.Features.Interactions.Contracts;
|
||||
|
||||
public readonly record struct InteractionTargetId(InstanceId Value)
|
||||
{
|
||||
public static InteractionTargetId From(InstanceId value) => new(value);
|
||||
|
||||
public override string ToString() => Value.ToString();
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://c6m1v8q4r9x2k
|
||||
@@ -0,0 +1,26 @@
|
||||
using Godot;
|
||||
|
||||
namespace Game.Features.Interactions.Domain;
|
||||
|
||||
public readonly record struct DropRayHit(
|
||||
Vector3 Origin,
|
||||
Vector3 Direction,
|
||||
float RayLength,
|
||||
bool IsBlocked,
|
||||
Vector3 BlockPoint
|
||||
);
|
||||
|
||||
public static class DropPosition
|
||||
{
|
||||
public static Vector3 CalculateFrom(DropRayHit hit)
|
||||
{
|
||||
float distance = hit.RayLength;
|
||||
|
||||
if (hit.IsBlocked)
|
||||
{
|
||||
distance = Mathf.Max(0.25f, hit.Origin.DistanceTo(hit.BlockPoint) - 0.2f);
|
||||
}
|
||||
|
||||
return hit.Origin + hit.Direction * distance;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://derjakse05m3d
|
||||
@@ -0,0 +1,58 @@
|
||||
using Game.Features.Interactions.Contracts;
|
||||
using Game.Features.Players.Contracts;
|
||||
using Game.Godot.Extensions;
|
||||
using Godot;
|
||||
using Godot.Collections;
|
||||
|
||||
namespace Game.Features.Interactions.Godot;
|
||||
|
||||
public abstract partial class InteractableController : Node3D, IInteractionTarget
|
||||
{
|
||||
public const string InteractionGroup = "interactions";
|
||||
|
||||
[Export]
|
||||
public string InstanceKey;
|
||||
|
||||
[Export]
|
||||
public string PromptText = "Wejdź w interakcję";
|
||||
|
||||
public InteractionTargetId InteractionTargetId { get; private set; }
|
||||
public virtual bool CanInteract => true;
|
||||
public InteractionPrompt Prompt => new(PromptText);
|
||||
|
||||
private InteractionsRegistryController _registry;
|
||||
|
||||
private void RegisterWithAvailableRegistry()
|
||||
{
|
||||
Array<Node> availableRegistries = GetTree()
|
||||
.GetNodesInGroup(InteractionsRegistryController.RegistryGroup);
|
||||
|
||||
foreach (Node node in availableRegistries)
|
||||
{
|
||||
if (node is InteractionsRegistryController registry)
|
||||
{
|
||||
registry.Register(this);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void UseRegistry(InteractionsRegistryController registry)
|
||||
{
|
||||
_registry = registry;
|
||||
}
|
||||
|
||||
public abstract InteractionResult Execute(PlayerId playerId);
|
||||
|
||||
public override void _ExitTree()
|
||||
{
|
||||
_registry?.Unregister(InteractionTargetId);
|
||||
}
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
InteractionTargetId = InteractionTargetId.From(this.GetStableInstanceIdFrom(InstanceKey));
|
||||
AddToGroup(InteractionGroup);
|
||||
RegisterWithAvailableRegistry();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://bh0e1kwa1i4r
|
||||
@@ -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 GetTargetOrNull<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,47 @@
|
||||
using Game.Features.Interactions.Contracts;
|
||||
using Godot;
|
||||
using GodotNodeArray = Godot.Collections.Array<Godot.Node>;
|
||||
|
||||
namespace Game.Features.Interactions.Godot;
|
||||
|
||||
public partial class InteractionsRegistryController : Node
|
||||
{
|
||||
public const string RegistryGroup = "interactions_registry";
|
||||
|
||||
private IInteractionRegistry _registry;
|
||||
|
||||
private GodotNodeArray GetInteractionTargetNodesInScene() =>
|
||||
GetTree().GetNodesInGroup(InteractableController.InteractionGroup);
|
||||
|
||||
public override void _EnterTree()
|
||||
{
|
||||
AddToGroup(RegistryGroup);
|
||||
}
|
||||
|
||||
public void UseRegistry(IInteractionRegistry registry)
|
||||
{
|
||||
_registry = registry;
|
||||
}
|
||||
|
||||
public void RegisterSceneTargets()
|
||||
{
|
||||
foreach (Node node in GetInteractionTargetNodesInScene())
|
||||
{
|
||||
if (node is InteractableController target)
|
||||
{
|
||||
Register(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Register(InteractableController target)
|
||||
{
|
||||
_registry.Register(target);
|
||||
target.UseRegistry(this);
|
||||
}
|
||||
|
||||
public void Unregister(InteractionTargetId targetId)
|
||||
{
|
||||
_registry.Unregister(targetId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://f6q1c9m4w7v2n
|
||||
@@ -0,0 +1,243 @@
|
||||
using System.Collections.Generic;
|
||||
using Game.Features.Interactions.Application;
|
||||
using Game.Features.Interactions.Contracts;
|
||||
using Game.Features.Interactions.Domain;
|
||||
using Game.Features.Items.Contracts;
|
||||
using Game.Features.Players.Contracts;
|
||||
using Game.Godot;
|
||||
using Game.Godot.Exceptions;
|
||||
using Game.Godot.Extensions;
|
||||
using Game.Godot.Mappings;
|
||||
using Godot;
|
||||
|
||||
namespace Game.Features.Interactions.Godot;
|
||||
|
||||
public partial class PlayerInteractionController : Node, IItemSpawnPoint
|
||||
{
|
||||
[Export]
|
||||
public Camera3D Camera;
|
||||
|
||||
[Export]
|
||||
public CharacterBody3D Body;
|
||||
|
||||
[Export]
|
||||
public InteractionRaycaster InteractionRaycaster;
|
||||
|
||||
[Export]
|
||||
public RayCast3D DropRaycaster;
|
||||
|
||||
[Export]
|
||||
public InteractionPromptController PromptController;
|
||||
|
||||
[Export]
|
||||
public Node3D LeftHandAnchor;
|
||||
|
||||
[Export]
|
||||
public Node3D RightHandAnchor;
|
||||
|
||||
private IItemInteractionService _items;
|
||||
private IInteractionHandler _interactions;
|
||||
private PlayerId _playerId;
|
||||
private readonly HashSet<Hand> _dropActions = [];
|
||||
private readonly ResolvePickupAvailability _resolvePickupAvailability = new();
|
||||
|
||||
private ItemUseRequest CreateUseRequest(Hand hand, ItemActionPhase phase)
|
||||
{
|
||||
InteractionTargetId? targetId = InteractionRaycaster
|
||||
.GetTargetOrNull<IInteractionTarget>()
|
||||
?.InteractionTargetId;
|
||||
|
||||
return new ItemUseRequest(_playerId, hand, phase, targetId);
|
||||
}
|
||||
|
||||
private void ProcessHandActionStarted(Hand hand)
|
||||
{
|
||||
if (Input.IsActionPressed(InputAction.DropModifier.ActionName()))
|
||||
{
|
||||
_items.TryUse(new ItemUseRequest(_playerId, hand, ItemActionPhase.Cancelled));
|
||||
_items.TryDrop(hand, CalculateDropPosition());
|
||||
_dropActions.Add(hand);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_items.IsHandFree(hand))
|
||||
{
|
||||
_items.TryUse(CreateUseRequest(hand, ItemActionPhase.Started));
|
||||
return;
|
||||
}
|
||||
|
||||
IPickupTarget target = InteractionRaycaster.GetTargetOrNull<IPickupTarget>();
|
||||
if (target is not null)
|
||||
{
|
||||
_items.TryPickup(target.ItemInfo.Id, hand);
|
||||
}
|
||||
}
|
||||
|
||||
private void ProcessHandActionCompleted(Hand hand)
|
||||
{
|
||||
if (_dropActions.Remove(hand))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_items.TryUse(new ItemUseRequest(_playerId, hand, ItemActionPhase.Completed));
|
||||
}
|
||||
|
||||
private Vector3 CalculateDropPosition()
|
||||
{
|
||||
DropRaycaster.ForceRaycastUpdate();
|
||||
|
||||
bool isBlocked = DropRaycaster.IsColliding();
|
||||
DropRayHit hit = new(
|
||||
Camera.GlobalPosition,
|
||||
-Camera.GlobalBasis.Z.Normalized(),
|
||||
DropRaycaster.TargetPosition.Length(),
|
||||
isBlocked,
|
||||
isBlocked ? DropRaycaster.GetCollisionPoint() : default
|
||||
);
|
||||
|
||||
return DropPosition.CalculateFrom(hit);
|
||||
}
|
||||
|
||||
private void UpdatePrompt()
|
||||
{
|
||||
IPickupTarget pickupTarget = InteractionRaycaster.GetTargetOrNull<IPickupTarget>();
|
||||
if (pickupTarget is not null)
|
||||
{
|
||||
ShowPickupPrompt(pickupTarget.ItemInfo);
|
||||
return;
|
||||
}
|
||||
|
||||
IInteractionTarget interactionTarget = InteractionRaycaster.GetTargetOrNull<IInteractionTarget>();
|
||||
if (interactionTarget?.CanInteract == true)
|
||||
{
|
||||
PromptController.ShowPrompt($"[E] {interactionTarget.Prompt.Text}");
|
||||
return;
|
||||
}
|
||||
|
||||
PromptController.Clear();
|
||||
}
|
||||
|
||||
private void ShowPickupPrompt(ItemInfo itemInfo)
|
||||
{
|
||||
PickupAvailability availability = _resolvePickupAvailability.Resolve(
|
||||
itemInfo.HandRequirement,
|
||||
_items.IsHandFree(Hand.Left),
|
||||
_items.IsHandFree(Hand.Right)
|
||||
);
|
||||
|
||||
if (availability.RequiresBoth)
|
||||
{
|
||||
PromptController.ShowPrompt(
|
||||
availability.LeftHand
|
||||
? $"[LPM lub PPM] Podnieś {itemInfo.DisplayName} — obie ręce"
|
||||
: "Potrzebujesz obu wolnych rąk"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
List<string> actions = [];
|
||||
|
||||
if (availability.LeftHand)
|
||||
{
|
||||
actions.Add($"[LPM] Podnieś {itemInfo.DisplayName} — lewa ręka");
|
||||
}
|
||||
|
||||
if (availability.RightHand)
|
||||
{
|
||||
actions.Add($"[PPM] Podnieś {itemInfo.DisplayName} — prawa ręka");
|
||||
}
|
||||
|
||||
PromptController.ShowPrompt(
|
||||
actions.Count > 0 ? string.Join("\n", actions) : "Brak wolnej ręki"
|
||||
);
|
||||
}
|
||||
|
||||
public void Initialize(
|
||||
PlayerId playerId,
|
||||
IItemInteractionService items,
|
||||
IInteractionHandler interactions
|
||||
)
|
||||
{
|
||||
_playerId = playerId;
|
||||
_items = items;
|
||||
_interactions = interactions;
|
||||
}
|
||||
|
||||
public Vector3 GetItemSpawnPosition() => CalculateDropPosition();
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
this.AssertRequiredExports();
|
||||
}
|
||||
|
||||
public override void _Process(double delta)
|
||||
{
|
||||
if (_items is null || _interactions is null)
|
||||
{
|
||||
throw new ControllerNotInitializedException(Name);
|
||||
}
|
||||
|
||||
if (GamePause.IsPaused || !MouseCapture.IsCaptured)
|
||||
{
|
||||
PromptController.Clear();
|
||||
return;
|
||||
}
|
||||
|
||||
UpdatePrompt();
|
||||
}
|
||||
|
||||
public override void _UnhandledInput(InputEvent @event)
|
||||
{
|
||||
if (_items is null || _interactions is null)
|
||||
{
|
||||
throw new ControllerNotInitializedException(Name);
|
||||
}
|
||||
|
||||
if (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.GetTargetOrNull<IInteractionTarget>();
|
||||
if (target?.CanInteract == true)
|
||||
{
|
||||
_interactions.TryInteract(
|
||||
new InteractionRequest(_playerId, target.InteractionTargetId)
|
||||
);
|
||||
}
|
||||
|
||||
GetViewport().SetInputAsHandled();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://c2v8m5r1y7q4d
|
||||
@@ -0,0 +1,41 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Game.Features.Interactions.Contracts;
|
||||
|
||||
namespace Game.Features.Interactions.Infrastructure;
|
||||
|
||||
public sealed class InteractionRegistry : IInteractionRegistry
|
||||
{
|
||||
private readonly Dictionary<InteractionTargetId, IInteractionTarget> _interactionTargetsById =
|
||||
[];
|
||||
|
||||
public void Register(IInteractionTarget target)
|
||||
{
|
||||
if (
|
||||
_interactionTargetsById.TryGetValue(
|
||||
target.InteractionTargetId,
|
||||
out IInteractionTarget existing
|
||||
)
|
||||
)
|
||||
{
|
||||
if (ReferenceEquals(existing, target))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Duplicate interaction target id: {target.InteractionTargetId}."
|
||||
);
|
||||
}
|
||||
|
||||
_interactionTargetsById.Add(target.InteractionTargetId, target);
|
||||
}
|
||||
|
||||
public void Unregister(InteractionTargetId targetId)
|
||||
{
|
||||
_interactionTargetsById.Remove(targetId);
|
||||
}
|
||||
|
||||
public bool TryGetTarget(InteractionTargetId targetId, out IInteractionTarget target) =>
|
||||
_interactionTargetsById.TryGetValue(targetId, out target);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://dtnwqalxdu6n2
|
||||
@@ -0,0 +1,108 @@
|
||||
using System.Collections.Generic;
|
||||
using Game.Features.Interactions.Contracts;
|
||||
using Game.Features.Items.Contracts;
|
||||
using Game.Features.Items.Domain;
|
||||
using Godot;
|
||||
|
||||
namespace Game.Features.Items.Application;
|
||||
|
||||
public sealed class ItemInteractionService(
|
||||
HandsInventory hands,
|
||||
IItemInstanceRegistry itemRegistry,
|
||||
IHeldItemPresenter presenter
|
||||
) : IItemInteractionService
|
||||
{
|
||||
private readonly Dictionary<Hand, InteractionTargetId?> _activeUseTargets = [];
|
||||
|
||||
public bool IsHandFree(Hand hand) => hands.IsFree(hand);
|
||||
|
||||
public ItemInstanceId? GetHeldItem(Hand hand) => hands.Get(hand);
|
||||
|
||||
public ItemActionResult TryPickup(ItemInstanceId itemId, Hand hand)
|
||||
{
|
||||
if (!itemRegistry.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)
|
||||
{
|
||||
if (request.Phase == ItemActionPhase.Started)
|
||||
{
|
||||
ItemActionResult startResult = ExecuteUse(request);
|
||||
if (startResult.Success)
|
||||
{
|
||||
_activeUseTargets[request.Hand] = request.TargetId;
|
||||
}
|
||||
|
||||
return startResult;
|
||||
}
|
||||
|
||||
if (!_activeUseTargets.Remove(request.Hand, out InteractionTargetId? activeTargetId))
|
||||
{
|
||||
return ItemActionResult.Ok();
|
||||
}
|
||||
|
||||
return ExecuteUse(request with { TargetId = activeTargetId });
|
||||
}
|
||||
|
||||
private ItemActionResult ExecuteUse(ItemUseRequest request)
|
||||
{
|
||||
ItemInstanceId? itemId = hands.Get(request.Hand);
|
||||
if (itemId is null)
|
||||
{
|
||||
return ItemActionResult.Fail(ItemActionStatus.HandEmpty, "The requested hand is empty.");
|
||||
}
|
||||
|
||||
if (!itemRegistry.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 (!itemRegistry.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
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Game.Features.Items.Contracts;
|
||||
|
||||
public enum Hand
|
||||
{
|
||||
Left,
|
||||
Right
|
||||
}
|
||||
@@ -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,10 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Game.Features.Items.Contracts;
|
||||
|
||||
public interface IItemDefinitionRegistry
|
||||
{
|
||||
IReadOnlyCollection<ItemDefinitionId> DefinitionIds { get; }
|
||||
bool TryRegister(ItemDefinitionId definitionId, ItemDefinition definition);
|
||||
bool TryGetDefinition(ItemDefinitionId definitionId, out ItemDefinition definition);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://kjka28plyqeo
|
||||
@@ -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,8 @@
|
||||
namespace Game.Features.Items.Contracts;
|
||||
|
||||
public interface IItemInstanceRegistry
|
||||
{
|
||||
void Register(ItemInstanceId itemId, IItemInstance item);
|
||||
void Unregister(ItemInstanceId itemId);
|
||||
bool TryGet(ItemInstanceId itemId, out IItemInstance item);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://s83yytevpj52
|
||||
@@ -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,8 @@
|
||||
using Godot;
|
||||
|
||||
namespace Game.Features.Items.Contracts;
|
||||
|
||||
public interface IItemSpawnPoint
|
||||
{
|
||||
Vector3 GetItemSpawnPosition();
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://a2v6r8m3q7k5d
|
||||
@@ -0,0 +1,12 @@
|
||||
using Godot;
|
||||
|
||||
namespace Game.Features.Items.Contracts;
|
||||
|
||||
public interface IItemSpawner
|
||||
{
|
||||
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);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user