From 9fdd565637f6791fec01a469499bad59e6000939 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20St=C4=99pie=C5=84?= Date: Fri, 28 Aug 2026 22:01:19 +0200 Subject: [PATCH] refactor: restructure interaction system into clean layers --- .../Interactions/Application/Interact.cs | 21 ++ ...eractionService.cs.uid => Interact.cs.uid} | 0 .../Application/InteractionService.cs | 8 - .../Application/ResolvePickupAvailability.cs | 19 ++ .../ResolvePickupAvailability.cs.uid | 1 + ...ctionService.cs => IInteractionHandler.cs} | 2 +- ...vice.cs.uid => IInteractionHandler.cs.uid} | 0 .../Contracts/IInteractionRegistry.cs | 8 + .../Contracts/IInteractionRegistry.cs.uid | 1 + .../Contracts/IInteractionTarget.cs | 6 +- .../Contracts/IInteractionWorld.cs | 6 - .../Contracts/IInteractionWorld.cs.uid | 1 - .../Contracts/InteractionTargetId.cs | 6 +- .../Interactions/Domain/DropPosition.cs | 26 ++ .../Interactions/Domain/DropPosition.cs.uid | 1 + .../Godot/InteractableController.cs | 58 ++++ .../Godot/InteractableController.cs.uid | 1 + .../Godot/InteractionRaycaster.cs | 2 +- .../Godot/InteractionWorldController.cs | 65 ---- .../Godot/InteractionsRegistryController.cs | 47 +++ ... => InteractionsRegistryController.cs.uid} | 0 .../Godot/PlayerInteractionController.cs | 319 ++++++++++-------- .../Godot/WorldInteractionController.cs | 52 --- .../Godot/WorldInteractionController.cs.uid | 1 - .../Infrastructure/InteractionRegistry.cs | 41 +++ .../Infrastructure/InteractionRegistry.cs.uid | 1 + .../Application/ItemInteractionService.cs | 25 ++ .../Extensions/NodeInstanceIdExtensions.cs | 15 + .../NodeInstanceIdExtensions.cs.uid | 1 + src/Godot/GameBootstrap.cs | 10 +- src/Godot/Scenes/GameWorld.tscn | 2 +- src/Shared/Domain/InstanceId.cs | 15 + src/Shared/Domain/InstanceId.cs.uid | 1 + 33 files changed, 474 insertions(+), 288 deletions(-) create mode 100644 src/Features/Interactions/Application/Interact.cs rename src/Features/Interactions/Application/{InteractionService.cs.uid => Interact.cs.uid} (100%) delete mode 100644 src/Features/Interactions/Application/InteractionService.cs create mode 100644 src/Features/Interactions/Application/ResolvePickupAvailability.cs create mode 100644 src/Features/Interactions/Application/ResolvePickupAvailability.cs.uid rename src/Features/Interactions/Contracts/{IInteractionService.cs => IInteractionHandler.cs} (75%) rename src/Features/Interactions/Contracts/{IInteractionService.cs.uid => IInteractionHandler.cs.uid} (100%) create mode 100644 src/Features/Interactions/Contracts/IInteractionRegistry.cs create mode 100644 src/Features/Interactions/Contracts/IInteractionRegistry.cs.uid delete mode 100644 src/Features/Interactions/Contracts/IInteractionWorld.cs delete mode 100644 src/Features/Interactions/Contracts/IInteractionWorld.cs.uid create mode 100644 src/Features/Interactions/Domain/DropPosition.cs create mode 100644 src/Features/Interactions/Domain/DropPosition.cs.uid create mode 100644 src/Features/Interactions/Godot/InteractableController.cs create mode 100644 src/Features/Interactions/Godot/InteractableController.cs.uid delete mode 100644 src/Features/Interactions/Godot/InteractionWorldController.cs create mode 100644 src/Features/Interactions/Godot/InteractionsRegistryController.cs rename src/Features/Interactions/Godot/{InteractionWorldController.cs.uid => InteractionsRegistryController.cs.uid} (100%) delete mode 100644 src/Features/Interactions/Godot/WorldInteractionController.cs delete mode 100644 src/Features/Interactions/Godot/WorldInteractionController.cs.uid create mode 100644 src/Features/Interactions/Infrastructure/InteractionRegistry.cs create mode 100644 src/Features/Interactions/Infrastructure/InteractionRegistry.cs.uid create mode 100644 src/Godot/Extensions/NodeInstanceIdExtensions.cs create mode 100644 src/Godot/Extensions/NodeInstanceIdExtensions.cs.uid create mode 100644 src/Shared/Domain/InstanceId.cs create mode 100644 src/Shared/Domain/InstanceId.cs.uid diff --git a/src/Features/Interactions/Application/Interact.cs b/src/Features/Interactions/Application/Interact.cs new file mode 100644 index 0000000..682d498 --- /dev/null +++ b/src/Features/Interactions/Application/Interact.cs @@ -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); + } +} diff --git a/src/Features/Interactions/Application/InteractionService.cs.uid b/src/Features/Interactions/Application/Interact.cs.uid similarity index 100% rename from src/Features/Interactions/Application/InteractionService.cs.uid rename to src/Features/Interactions/Application/Interact.cs.uid diff --git a/src/Features/Interactions/Application/InteractionService.cs b/src/Features/Interactions/Application/InteractionService.cs deleted file mode 100644 index 4657e77..0000000 --- a/src/Features/Interactions/Application/InteractionService.cs +++ /dev/null @@ -1,8 +0,0 @@ -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); -} diff --git a/src/Features/Interactions/Application/ResolvePickupAvailability.cs b/src/Features/Interactions/Application/ResolvePickupAvailability.cs new file mode 100644 index 0000000..765848a --- /dev/null +++ b/src/Features/Interactions/Application/ResolvePickupAvailability.cs @@ -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); + } +} diff --git a/src/Features/Interactions/Application/ResolvePickupAvailability.cs.uid b/src/Features/Interactions/Application/ResolvePickupAvailability.cs.uid new file mode 100644 index 0000000..f008756 --- /dev/null +++ b/src/Features/Interactions/Application/ResolvePickupAvailability.cs.uid @@ -0,0 +1 @@ +uid://c6mhpn06a47qu diff --git a/src/Features/Interactions/Contracts/IInteractionService.cs b/src/Features/Interactions/Contracts/IInteractionHandler.cs similarity index 75% rename from src/Features/Interactions/Contracts/IInteractionService.cs rename to src/Features/Interactions/Contracts/IInteractionHandler.cs index dbf9365..d579b10 100644 --- a/src/Features/Interactions/Contracts/IInteractionService.cs +++ b/src/Features/Interactions/Contracts/IInteractionHandler.cs @@ -1,6 +1,6 @@ namespace Game.Features.Interactions.Contracts; -public interface IInteractionService +public interface IInteractionHandler { InteractionResult TryInteract(InteractionRequest request); } diff --git a/src/Features/Interactions/Contracts/IInteractionService.cs.uid b/src/Features/Interactions/Contracts/IInteractionHandler.cs.uid similarity index 100% rename from src/Features/Interactions/Contracts/IInteractionService.cs.uid rename to src/Features/Interactions/Contracts/IInteractionHandler.cs.uid diff --git a/src/Features/Interactions/Contracts/IInteractionRegistry.cs b/src/Features/Interactions/Contracts/IInteractionRegistry.cs new file mode 100644 index 0000000..451bee3 --- /dev/null +++ b/src/Features/Interactions/Contracts/IInteractionRegistry.cs @@ -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); +} diff --git a/src/Features/Interactions/Contracts/IInteractionRegistry.cs.uid b/src/Features/Interactions/Contracts/IInteractionRegistry.cs.uid new file mode 100644 index 0000000..7172fab --- /dev/null +++ b/src/Features/Interactions/Contracts/IInteractionRegistry.cs.uid @@ -0,0 +1 @@ +uid://baejppnflmpnn diff --git a/src/Features/Interactions/Contracts/IInteractionTarget.cs b/src/Features/Interactions/Contracts/IInteractionTarget.cs index e6c18c1..855f807 100644 --- a/src/Features/Interactions/Contracts/IInteractionTarget.cs +++ b/src/Features/Interactions/Contracts/IInteractionTarget.cs @@ -1,8 +1,12 @@ +using Game.Features.Players.Contracts; + namespace Game.Features.Interactions.Contracts; public interface IInteractionTarget { - InteractionTargetId Id { get; } + InteractionTargetId InteractionTargetId { get; } bool CanInteract { get; } InteractionPrompt Prompt { get; } + + InteractionResult Execute(PlayerId playerId); } diff --git a/src/Features/Interactions/Contracts/IInteractionWorld.cs b/src/Features/Interactions/Contracts/IInteractionWorld.cs deleted file mode 100644 index 79e4a1a..0000000 --- a/src/Features/Interactions/Contracts/IInteractionWorld.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Game.Features.Interactions.Contracts; - -public interface IInteractionWorld -{ - InteractionResult TryInteract(InteractionRequest request); -} diff --git a/src/Features/Interactions/Contracts/IInteractionWorld.cs.uid b/src/Features/Interactions/Contracts/IInteractionWorld.cs.uid deleted file mode 100644 index 880f095..0000000 --- a/src/Features/Interactions/Contracts/IInteractionWorld.cs.uid +++ /dev/null @@ -1 +0,0 @@ -uid://d7k1x6m4v9q2b diff --git a/src/Features/Interactions/Contracts/InteractionTargetId.cs b/src/Features/Interactions/Contracts/InteractionTargetId.cs index 2b31238..7f3e449 100644 --- a/src/Features/Interactions/Contracts/InteractionTargetId.cs +++ b/src/Features/Interactions/Contracts/InteractionTargetId.cs @@ -1,8 +1,10 @@ -using System; +using Game.Shared.Domain; namespace Game.Features.Interactions.Contracts; -public readonly record struct InteractionTargetId(Guid Value) +public readonly record struct InteractionTargetId(InstanceId Value) { + public static InteractionTargetId From(InstanceId value) => new(value); + public override string ToString() => Value.ToString(); } diff --git a/src/Features/Interactions/Domain/DropPosition.cs b/src/Features/Interactions/Domain/DropPosition.cs new file mode 100644 index 0000000..ecaab89 --- /dev/null +++ b/src/Features/Interactions/Domain/DropPosition.cs @@ -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; + } +} diff --git a/src/Features/Interactions/Domain/DropPosition.cs.uid b/src/Features/Interactions/Domain/DropPosition.cs.uid new file mode 100644 index 0000000..758ee3a --- /dev/null +++ b/src/Features/Interactions/Domain/DropPosition.cs.uid @@ -0,0 +1 @@ +uid://derjakse05m3d diff --git a/src/Features/Interactions/Godot/InteractableController.cs b/src/Features/Interactions/Godot/InteractableController.cs new file mode 100644 index 0000000..3aca615 --- /dev/null +++ b/src/Features/Interactions/Godot/InteractableController.cs @@ -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 Key; + + [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 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(Key)); + AddToGroup(InteractionGroup); + RegisterWithAvailableRegistry(); + } +} diff --git a/src/Features/Interactions/Godot/InteractableController.cs.uid b/src/Features/Interactions/Godot/InteractableController.cs.uid new file mode 100644 index 0000000..4361613 --- /dev/null +++ b/src/Features/Interactions/Godot/InteractableController.cs.uid @@ -0,0 +1 @@ +uid://bh0e1kwa1i4r diff --git a/src/Features/Interactions/Godot/InteractionRaycaster.cs b/src/Features/Interactions/Godot/InteractionRaycaster.cs index faa417a..a557583 100644 --- a/src/Features/Interactions/Godot/InteractionRaycaster.cs +++ b/src/Features/Interactions/Godot/InteractionRaycaster.cs @@ -4,7 +4,7 @@ namespace Game.Features.Interactions.Godot; public partial class InteractionRaycaster : RayCast3D { - public T GetTarget() where T : class + public T GetTargetOrNull() where T : class { if (!IsColliding() || GetCollider() is not Node node) { diff --git a/src/Features/Interactions/Godot/InteractionWorldController.cs b/src/Features/Interactions/Godot/InteractionWorldController.cs deleted file mode 100644 index 22a47f3..0000000 --- a/src/Features/Interactions/Godot/InteractionWorldController.cs +++ /dev/null @@ -1,65 +0,0 @@ -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 _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); - } -} diff --git a/src/Features/Interactions/Godot/InteractionsRegistryController.cs b/src/Features/Interactions/Godot/InteractionsRegistryController.cs new file mode 100644 index 0000000..8e14964 --- /dev/null +++ b/src/Features/Interactions/Godot/InteractionsRegistryController.cs @@ -0,0 +1,47 @@ +using Game.Features.Interactions.Contracts; +using Godot; +using GodotNodeArray = Godot.Collections.Array; + +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); + } +} diff --git a/src/Features/Interactions/Godot/InteractionWorldController.cs.uid b/src/Features/Interactions/Godot/InteractionsRegistryController.cs.uid similarity index 100% rename from src/Features/Interactions/Godot/InteractionWorldController.cs.uid rename to src/Features/Interactions/Godot/InteractionsRegistryController.cs.uid diff --git a/src/Features/Interactions/Godot/PlayerInteractionController.cs b/src/Features/Interactions/Godot/PlayerInteractionController.cs index 0cbe48d..38c2c9d 100644 --- a/src/Features/Interactions/Godot/PlayerInteractionController.cs +++ b/src/Features/Interactions/Godot/PlayerInteractionController.cs @@ -1,6 +1,9 @@ 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.Logging.Contracts; using Game.Features.Players.Contracts; using Game.Godot; using Game.Godot.Extensions; @@ -11,39 +14,177 @@ 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; + [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 const string LogCategory = "PlayerInteraction"; private IItemInteractionService _items; - private IInteractionService _interactions; + private IInteractionHandler _interactions; + private ILogger _logger; private PlayerId _playerId; private readonly HashSet _dropActions = []; - private readonly Dictionary _activeUseTargets = []; + private readonly ResolvePickupAvailability _resolvePickupAvailability = new(); + + private ItemUseRequest CreateUseRequest(Hand hand, ItemActionPhase phase) + { + InteractionTargetId? targetId = InteractionRaycaster + .GetTargetOrNull() + ?.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(); + 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(); + if (pickupTarget is not null) + { + ShowPickupPrompt(pickupTarget.ItemInfo); + return; + } + + IInteractionTarget interactionTarget = InteractionRaycaster.GetTargetOrNull(); + 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 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, + ILogger logger + ) + { + _playerId = playerId; + _items = items; + _interactions = interactions; + _logger = logger; + } + + public Vector3 GetItemSpawnPosition() => CalculateDropPosition(); public override void _Ready() { this.AssertRequiredExports(); } - public void Initialize( - 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) + if (_items is null || _interactions is null) + { + _logger?.Warning(LogCategory, "Skipped frame: controller not yet initialized."); + PromptController.Clear(); + return; + } + + if (GamePause.IsPaused || !MouseCapture.IsCaptured) { PromptController.Clear(); return; @@ -54,7 +195,13 @@ public partial class PlayerInteractionController : Node, IItemSpawnPoint public override void _UnhandledInput(InputEvent @event) { - if (_items is null || _interactions is null || GamePause.IsPaused || !MouseCapture.IsCaptured) + if (_items is null || _interactions is null) + { + _logger?.Warning(LogCategory, "Ignored input: controller not yet initialized."); + return; + } + + if (GamePause.IsPaused || !MouseCapture.IsCaptured) { return; } @@ -89,135 +236,15 @@ public partial class PlayerInteractionController : Node, IItemSpawnPoint if (@event.IsActionPressed(InputAction.Interact.ActionName())) { - IInteractionTarget target = InteractionRaycaster.GetTarget(); + IInteractionTarget target = InteractionRaycaster.GetTargetOrNull(); if (target?.CanInteract == true) { - _interactions.TryInteract(new InteractionRequest(_playerId, target.Id)); + _interactions.TryInteract( + new InteractionRequest(_playerId, target.InteractionTargetId) + ); } 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(); - 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()?.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(); - if (pickupTarget is not null) - { - ShowPickupPrompt(pickupTarget.ItemInfo); - return; - } - - IInteractionTarget interactionTarget = InteractionRaycaster.GetTarget(); - 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 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"); - } } diff --git a/src/Features/Interactions/Godot/WorldInteractionController.cs b/src/Features/Interactions/Godot/WorldInteractionController.cs deleted file mode 100644 index d2a1d23..0000000 --- a/src/Features/Interactions/Godot/WorldInteractionController.cs +++ /dev/null @@ -1,52 +0,0 @@ -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; - } - } - } -} diff --git a/src/Features/Interactions/Godot/WorldInteractionController.cs.uid b/src/Features/Interactions/Godot/WorldInteractionController.cs.uid deleted file mode 100644 index ac116fc..0000000 --- a/src/Features/Interactions/Godot/WorldInteractionController.cs.uid +++ /dev/null @@ -1 +0,0 @@ -uid://d2r8m5v1q9k4x diff --git a/src/Features/Interactions/Infrastructure/InteractionRegistry.cs b/src/Features/Interactions/Infrastructure/InteractionRegistry.cs new file mode 100644 index 0000000..41aa3b1 --- /dev/null +++ b/src/Features/Interactions/Infrastructure/InteractionRegistry.cs @@ -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 _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); +} diff --git a/src/Features/Interactions/Infrastructure/InteractionRegistry.cs.uid b/src/Features/Interactions/Infrastructure/InteractionRegistry.cs.uid new file mode 100644 index 0000000..83a081a --- /dev/null +++ b/src/Features/Interactions/Infrastructure/InteractionRegistry.cs.uid @@ -0,0 +1 @@ +uid://dtnwqalxdu6n2 diff --git a/src/Features/Items/Application/ItemInteractionService.cs b/src/Features/Items/Application/ItemInteractionService.cs index 4814c0d..c5a976a 100644 --- a/src/Features/Items/Application/ItemInteractionService.cs +++ b/src/Features/Items/Application/ItemInteractionService.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; +using Game.Features.Interactions.Contracts; using Game.Features.Items.Contracts; using Game.Features.Items.Domain; using Godot; @@ -10,6 +12,8 @@ public sealed class ItemInteractionService( IHeldItemPresenter presenter ) : IItemInteractionService { + private readonly Dictionary _activeUseTargets = []; + public bool IsHandFree(Hand hand) => hands.IsFree(hand); public ItemInstanceId? GetHeldItem(Hand hand) => hands.Get(hand); @@ -36,6 +40,27 @@ public sealed class ItemInteractionService( } 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) diff --git a/src/Godot/Extensions/NodeInstanceIdExtensions.cs b/src/Godot/Extensions/NodeInstanceIdExtensions.cs new file mode 100644 index 0000000..1bae1b0 --- /dev/null +++ b/src/Godot/Extensions/NodeInstanceIdExtensions.cs @@ -0,0 +1,15 @@ +using Game.Shared.Domain; +using Godot; + +namespace Game.Godot.Extensions; + +public static class NodeInstanceIdExtensions +{ + private static string GetKeyFrom(Node node) => node.GetPath().ToString(); + + public static InstanceId GetStableInstanceId(this Node node) => + InstanceId.FromKey(GetKeyFrom(node)); + + public static InstanceId GetStableInstanceIdFrom(this Node node, string key) => + string.IsNullOrWhiteSpace(key) ? node.GetStableInstanceId() : InstanceId.FromKey(key); +} diff --git a/src/Godot/Extensions/NodeInstanceIdExtensions.cs.uid b/src/Godot/Extensions/NodeInstanceIdExtensions.cs.uid new file mode 100644 index 0000000..67bf66a --- /dev/null +++ b/src/Godot/Extensions/NodeInstanceIdExtensions.cs.uid @@ -0,0 +1 @@ +uid://csng1pjcgsh80 diff --git a/src/Godot/GameBootstrap.cs b/src/Godot/GameBootstrap.cs index 2320d01..5ec8a39 100644 --- a/src/Godot/GameBootstrap.cs +++ b/src/Godot/GameBootstrap.cs @@ -6,6 +6,7 @@ using Game.Features.Logging.Godot; using Game.Features.Logging.Infrastructure; using Game.Features.Interactions.Application; using Game.Features.Interactions.Godot; +using Game.Features.Interactions.Infrastructure; using Game.Features.Items.Application; using Game.Features.Items.Domain; using Game.Features.Items.Godot; @@ -25,7 +26,7 @@ public partial class GameBootstrap : Node [Export] public PlayerInteractionController PlayerInteraction; [Export] public ItemWorldController ItemWorld; [Export] public ItemSpawnerController ItemSpawner; - [Export] public InteractionWorldController InteractionWorld; + [Export] public InteractionsRegistryController InteractionWorld; [Export] public MultiplayerController MultiplayerController; private FileLogger _fileLogger; @@ -69,6 +70,9 @@ public partial class GameBootstrap : Node { ItemWorld.RegisterSceneItems(); ItemSpawner.Initialize(); + + InteractionRegistry interactionRegistry = new(); + InteractionWorld.UseRegistry(interactionRegistry); InteractionWorld.RegisterSceneTargets(); PlayerHeldItemPresenter presenter = new( @@ -77,8 +81,8 @@ public partial class GameBootstrap : Node PlayerInteraction.RightHandAnchor ); ItemInteractionService itemInteractions = new(new HandsInventory(), ItemWorld, presenter); - InteractionService interactions = new(InteractionWorld); - PlayerInteraction.Initialize(new PlayerId(Guid.NewGuid()), itemInteractions, interactions); + Interact interactions = new(interactionRegistry); + PlayerInteraction.Initialize(new PlayerId(Guid.NewGuid()), itemInteractions, interactions, _logger); } public override void _Ready() diff --git a/src/Godot/Scenes/GameWorld.tscn b/src/Godot/Scenes/GameWorld.tscn index 87f71f7..dca6016 100644 --- a/src/Godot/Scenes/GameWorld.tscn +++ b/src/Godot/Scenes/GameWorld.tscn @@ -9,7 +9,7 @@ [ext_resource type="Script" uid="uid://f8n3b6w1k5r9m" path="res://src/Features/Items/Godot/ItemWorldController.cs" id="6_items"] [ext_resource type="PackedScene" uid="uid://cv8m3q1y6r4nf" path="res://src/Features/Items/Godot/Flashlight/FlashlightItem.tscn" id="7_flashlight"] [ext_resource type="Script" uid="uid://c3x9m6w2q8r1b" path="res://src/Features/Items/Godot/ItemSpawnerController.cs" id="8_spawner"] -[ext_resource type="Script" uid="uid://f6q1c9m4w7v2n" path="res://src/Features/Interactions/Godot/InteractionWorldController.cs" id="9_interactions"] +[ext_resource type="Script" uid="uid://f6q1c9m4w7v2n" path="res://src/Features/Interactions/Godot/InteractionsRegistryController.cs" id="9_interactions"] [sub_resource type="PanoramaSkyMaterial" id="PanoramaSkyMaterial_epykj"] panorama = ExtResource("1_g4n7o") diff --git a/src/Shared/Domain/InstanceId.cs b/src/Shared/Domain/InstanceId.cs new file mode 100644 index 0000000..9150376 --- /dev/null +++ b/src/Shared/Domain/InstanceId.cs @@ -0,0 +1,15 @@ +using System; +using System.Security.Cryptography; +using System.Text; + +namespace Game.Shared.Domain; + +public readonly record struct InstanceId(Guid Value) +{ + private static Guid CreateDeterministicId(string source) => + new(MD5.HashData(Encoding.UTF8.GetBytes(source))); + + public static InstanceId FromKey(string key) => new(CreateDeterministicId(key)); + + public override string ToString() => Value.ToString(); +} diff --git a/src/Shared/Domain/InstanceId.cs.uid b/src/Shared/Domain/InstanceId.cs.uid new file mode 100644 index 0000000..9b72acd --- /dev/null +++ b/src/Shared/Domain/InstanceId.cs.uid @@ -0,0 +1 @@ +uid://cpikg7xirrc1g