From f4331f426c581f52578fca6c3a0acd475b7529b5 Mon Sep 17 00:00:00 2001 From: Adrian Mazur Date: Sun, 23 Aug 2026 23:16:49 +0200 Subject: [PATCH] feat: add extensible item inventory and world system --- .../Application/ItemInteractionService.cs | 83 +++++++++++ .../Application/ItemInteractionService.cs.uid | 1 + src/Features/Items/Contracts/Hand.cs | 7 + src/Features/Items/Contracts/Hand.cs.uid | 1 + .../Items/Contracts/HandRequirement.cs | 7 + .../Items/Contracts/HandRequirement.cs.uid | 1 + .../Items/Contracts/IHeldItemPresenter.cs | 10 ++ .../Items/Contracts/IHeldItemPresenter.cs.uid | 1 + src/Features/Items/Contracts/IItemInstance.cs | 7 + .../Items/Contracts/IItemInstance.cs.uid | 1 + .../Contracts/IItemInteractionService.cs | 12 ++ .../Contracts/IItemInteractionService.cs.uid | 1 + .../Items/Contracts/IItemRepository.cs | 6 + .../Items/Contracts/IItemRepository.cs.uid | 1 + .../Items/Contracts/IItemSpawnPoint.cs | 8 + .../Items/Contracts/IItemSpawnPoint.cs.uid | 1 + src/Features/Items/Contracts/IItemSpawner.cs | 10 ++ .../Items/Contracts/IItemSpawner.cs.uid | 1 + src/Features/Items/Contracts/IPickupTarget.cs | 6 + .../Items/Contracts/IPickupTarget.cs.uid | 1 + .../Items/Contracts/ItemActionPhase.cs | 8 + .../Items/Contracts/ItemActionPhase.cs.uid | 1 + .../Items/Contracts/ItemActionResult.cs | 10 ++ .../Items/Contracts/ItemActionResult.cs.uid | 1 + .../Items/Contracts/ItemActionStatus.cs | 12 ++ .../Items/Contracts/ItemActionStatus.cs.uid | 1 + .../Items/Contracts/ItemDefinition.cs | 7 + .../Items/Contracts/ItemDefinition.cs.uid | 1 + .../Items/Contracts/ItemDefinitionId.cs | 6 + .../Items/Contracts/ItemDefinitionId.cs.uid | 1 + src/Features/Items/Contracts/ItemInfo.cs | 8 + src/Features/Items/Contracts/ItemInfo.cs.uid | 1 + .../Items/Contracts/ItemInstanceId.cs | 8 + .../Items/Contracts/ItemInstanceId.cs.uid | 1 + .../Items/Contracts/ItemUseRequest.cs | 11 ++ .../Items/Contracts/ItemUseRequest.cs.uid | 1 + src/Features/Items/Domain/HandsInventory.cs | 83 +++++++++++ .../Items/Domain/HandsInventory.cs.uid | 1 + .../Items/Domain/PassiveItemInstance.cs | 13 ++ .../Items/Domain/PassiveItemInstance.cs.uid | 1 + .../Items/Godot/ItemSpawnerController.cs | 95 ++++++++++++ .../Items/Godot/ItemSpawnerController.cs.uid | 1 + .../Items/Godot/ItemWorldController.cs | 69 +++++++++ .../Items/Godot/ItemWorldController.cs.uid | 1 + .../Items/Godot/PlayerHeldItemPresenter.cs | 42 ++++++ .../Godot/PlayerHeldItemPresenter.cs.uid | 1 + .../Items/Godot/WorldItemController.cs | 141 ++++++++++++++++++ .../Items/Godot/WorldItemController.cs.uid | 1 + 48 files changed, 693 insertions(+) create mode 100644 src/Features/Items/Application/ItemInteractionService.cs create mode 100644 src/Features/Items/Application/ItemInteractionService.cs.uid create mode 100644 src/Features/Items/Contracts/Hand.cs create mode 100644 src/Features/Items/Contracts/Hand.cs.uid create mode 100644 src/Features/Items/Contracts/HandRequirement.cs create mode 100644 src/Features/Items/Contracts/HandRequirement.cs.uid create mode 100644 src/Features/Items/Contracts/IHeldItemPresenter.cs create mode 100644 src/Features/Items/Contracts/IHeldItemPresenter.cs.uid create mode 100644 src/Features/Items/Contracts/IItemInstance.cs create mode 100644 src/Features/Items/Contracts/IItemInstance.cs.uid create mode 100644 src/Features/Items/Contracts/IItemInteractionService.cs create mode 100644 src/Features/Items/Contracts/IItemInteractionService.cs.uid create mode 100644 src/Features/Items/Contracts/IItemRepository.cs create mode 100644 src/Features/Items/Contracts/IItemRepository.cs.uid create mode 100644 src/Features/Items/Contracts/IItemSpawnPoint.cs create mode 100644 src/Features/Items/Contracts/IItemSpawnPoint.cs.uid create mode 100644 src/Features/Items/Contracts/IItemSpawner.cs create mode 100644 src/Features/Items/Contracts/IItemSpawner.cs.uid create mode 100644 src/Features/Items/Contracts/IPickupTarget.cs create mode 100644 src/Features/Items/Contracts/IPickupTarget.cs.uid create mode 100644 src/Features/Items/Contracts/ItemActionPhase.cs create mode 100644 src/Features/Items/Contracts/ItemActionPhase.cs.uid create mode 100644 src/Features/Items/Contracts/ItemActionResult.cs create mode 100644 src/Features/Items/Contracts/ItemActionResult.cs.uid create mode 100644 src/Features/Items/Contracts/ItemActionStatus.cs create mode 100644 src/Features/Items/Contracts/ItemActionStatus.cs.uid create mode 100644 src/Features/Items/Contracts/ItemDefinition.cs create mode 100644 src/Features/Items/Contracts/ItemDefinition.cs.uid create mode 100644 src/Features/Items/Contracts/ItemDefinitionId.cs create mode 100644 src/Features/Items/Contracts/ItemDefinitionId.cs.uid create mode 100644 src/Features/Items/Contracts/ItemInfo.cs create mode 100644 src/Features/Items/Contracts/ItemInfo.cs.uid create mode 100644 src/Features/Items/Contracts/ItemInstanceId.cs create mode 100644 src/Features/Items/Contracts/ItemInstanceId.cs.uid create mode 100644 src/Features/Items/Contracts/ItemUseRequest.cs create mode 100644 src/Features/Items/Contracts/ItemUseRequest.cs.uid create mode 100644 src/Features/Items/Domain/HandsInventory.cs create mode 100644 src/Features/Items/Domain/HandsInventory.cs.uid create mode 100644 src/Features/Items/Domain/PassiveItemInstance.cs create mode 100644 src/Features/Items/Domain/PassiveItemInstance.cs.uid create mode 100644 src/Features/Items/Godot/ItemSpawnerController.cs create mode 100644 src/Features/Items/Godot/ItemSpawnerController.cs.uid create mode 100644 src/Features/Items/Godot/ItemWorldController.cs create mode 100644 src/Features/Items/Godot/ItemWorldController.cs.uid create mode 100644 src/Features/Items/Godot/PlayerHeldItemPresenter.cs create mode 100644 src/Features/Items/Godot/PlayerHeldItemPresenter.cs.uid create mode 100644 src/Features/Items/Godot/WorldItemController.cs create mode 100644 src/Features/Items/Godot/WorldItemController.cs.uid diff --git a/src/Features/Items/Application/ItemInteractionService.cs b/src/Features/Items/Application/ItemInteractionService.cs new file mode 100644 index 0000000..4814c0d --- /dev/null +++ b/src/Features/Items/Application/ItemInteractionService.cs @@ -0,0 +1,83 @@ +using Game.Features.Items.Contracts; +using Game.Features.Items.Domain; +using Godot; + +namespace Game.Features.Items.Application; + +public sealed class ItemInteractionService( + HandsInventory hands, + IItemRepository itemRepository, + IHeldItemPresenter presenter +) : IItemInteractionService +{ + public bool IsHandFree(Hand hand) => hands.IsFree(hand); + + public ItemInstanceId? GetHeldItem(Hand hand) => hands.Get(hand); + + public ItemActionResult TryPickup(ItemInstanceId itemId, Hand hand) + { + if (!itemRepository.TryGet(itemId, out IItemInstance item)) + { + return ItemActionResult.Fail(ItemActionStatus.ItemNotFound, $"Item {itemId} was not found."); + } + + if (!hands.TryHold(itemId, item.Info.HandRequirement, hand)) + { + return ItemActionResult.Fail(ItemActionStatus.HandOccupied, "The requested hand is not available."); + } + + if (presenter.TryShowHeld(item, hand)) + { + return ItemActionResult.Ok(); + } + + hands.Release(hand); + return ItemActionResult.Fail(ItemActionStatus.PresentationFailed, "Could not present the held item."); + } + + public ItemActionResult TryUse(ItemUseRequest request) + { + ItemInstanceId? itemId = hands.Get(request.Hand); + if (itemId is null) + { + return ItemActionResult.Fail(ItemActionStatus.HandEmpty, "The requested hand is empty."); + } + + if (!itemRepository.TryGet(itemId.Value, out IItemInstance item)) + { + return ItemActionResult.Fail(ItemActionStatus.ItemNotFound, $"Item {itemId} was not found."); + } + + ItemActionResult result = item.Use(request); + if (result.Success) + { + presenter.Refresh(item); + } + + return result; + } + + public ItemActionResult TryDrop(Hand hand, Vector3 worldPosition) + { + ItemInstanceId? itemId = hands.Get(hand); + if (itemId is null) + { + return ItemActionResult.Fail(ItemActionStatus.HandEmpty, "The requested hand is empty."); + } + + if (!itemRepository.TryGet(itemId.Value, out IItemInstance item)) + { + return ItemActionResult.Fail(ItemActionStatus.ItemNotFound, $"Item {itemId} was not found."); + } + + hands.Release(hand); + + if (presenter.TryShowDropped(item, worldPosition)) + { + return ItemActionResult.Ok(); + } + + hands.TryHold(itemId.Value, item.Info.HandRequirement, hand); + return ItemActionResult.Fail(ItemActionStatus.PresentationFailed, "Could not present the dropped item."); + } +} diff --git a/src/Features/Items/Application/ItemInteractionService.cs.uid b/src/Features/Items/Application/ItemInteractionService.cs.uid new file mode 100644 index 0000000..0e824d1 --- /dev/null +++ b/src/Features/Items/Application/ItemInteractionService.cs.uid @@ -0,0 +1 @@ +uid://e6q2h9v5b8k3p diff --git a/src/Features/Items/Contracts/Hand.cs b/src/Features/Items/Contracts/Hand.cs new file mode 100644 index 0000000..f7b9cb5 --- /dev/null +++ b/src/Features/Items/Contracts/Hand.cs @@ -0,0 +1,7 @@ +namespace Game.Features.Items.Contracts; + +public enum Hand +{ + Left, + Right +} diff --git a/src/Features/Items/Contracts/Hand.cs.uid b/src/Features/Items/Contracts/Hand.cs.uid new file mode 100644 index 0000000..be1e13b --- /dev/null +++ b/src/Features/Items/Contracts/Hand.cs.uid @@ -0,0 +1 @@ +uid://c4n7h2w8m5q1p diff --git a/src/Features/Items/Contracts/HandRequirement.cs b/src/Features/Items/Contracts/HandRequirement.cs new file mode 100644 index 0000000..fa2b858 --- /dev/null +++ b/src/Features/Items/Contracts/HandRequirement.cs @@ -0,0 +1,7 @@ +namespace Game.Features.Items.Contracts; + +public enum HandRequirement +{ + One, + Both +} diff --git a/src/Features/Items/Contracts/HandRequirement.cs.uid b/src/Features/Items/Contracts/HandRequirement.cs.uid new file mode 100644 index 0000000..a3cd015 --- /dev/null +++ b/src/Features/Items/Contracts/HandRequirement.cs.uid @@ -0,0 +1 @@ +uid://d8p3k6v1r9y4b diff --git a/src/Features/Items/Contracts/IHeldItemPresenter.cs b/src/Features/Items/Contracts/IHeldItemPresenter.cs new file mode 100644 index 0000000..7f347ea --- /dev/null +++ b/src/Features/Items/Contracts/IHeldItemPresenter.cs @@ -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); +} diff --git a/src/Features/Items/Contracts/IHeldItemPresenter.cs.uid b/src/Features/Items/Contracts/IHeldItemPresenter.cs.uid new file mode 100644 index 0000000..f2d9fb5 --- /dev/null +++ b/src/Features/Items/Contracts/IHeldItemPresenter.cs.uid @@ -0,0 +1 @@ +uid://c1q7y4d9m5x2v diff --git a/src/Features/Items/Contracts/IItemInstance.cs b/src/Features/Items/Contracts/IItemInstance.cs new file mode 100644 index 0000000..0f35ad6 --- /dev/null +++ b/src/Features/Items/Contracts/IItemInstance.cs @@ -0,0 +1,7 @@ +namespace Game.Features.Items.Contracts; + +public interface IItemInstance +{ + ItemInfo Info { get; } + ItemActionResult Use(ItemUseRequest request); +} diff --git a/src/Features/Items/Contracts/IItemInstance.cs.uid b/src/Features/Items/Contracts/IItemInstance.cs.uid new file mode 100644 index 0000000..1acc4b7 --- /dev/null +++ b/src/Features/Items/Contracts/IItemInstance.cs.uid @@ -0,0 +1 @@ +uid://b5x9q3h7m2w8f diff --git a/src/Features/Items/Contracts/IItemInteractionService.cs b/src/Features/Items/Contracts/IItemInteractionService.cs new file mode 100644 index 0000000..0962d5a --- /dev/null +++ b/src/Features/Items/Contracts/IItemInteractionService.cs @@ -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); +} diff --git a/src/Features/Items/Contracts/IItemInteractionService.cs.uid b/src/Features/Items/Contracts/IItemInteractionService.cs.uid new file mode 100644 index 0000000..b129d88 --- /dev/null +++ b/src/Features/Items/Contracts/IItemInteractionService.cs.uid @@ -0,0 +1 @@ +uid://f2k8q5b1w7m4n diff --git a/src/Features/Items/Contracts/IItemRepository.cs b/src/Features/Items/Contracts/IItemRepository.cs new file mode 100644 index 0000000..c603ea3 --- /dev/null +++ b/src/Features/Items/Contracts/IItemRepository.cs @@ -0,0 +1,6 @@ +namespace Game.Features.Items.Contracts; + +public interface IItemRepository +{ + bool TryGet(ItemInstanceId itemId, out IItemInstance item); +} diff --git a/src/Features/Items/Contracts/IItemRepository.cs.uid b/src/Features/Items/Contracts/IItemRepository.cs.uid new file mode 100644 index 0000000..f2885ce --- /dev/null +++ b/src/Features/Items/Contracts/IItemRepository.cs.uid @@ -0,0 +1 @@ +uid://e7n1m6v4k9r3q diff --git a/src/Features/Items/Contracts/IItemSpawnPoint.cs b/src/Features/Items/Contracts/IItemSpawnPoint.cs new file mode 100644 index 0000000..2236986 --- /dev/null +++ b/src/Features/Items/Contracts/IItemSpawnPoint.cs @@ -0,0 +1,8 @@ +using Godot; + +namespace Game.Features.Items.Contracts; + +public interface IItemSpawnPoint +{ + Vector3 GetItemSpawnPosition(); +} diff --git a/src/Features/Items/Contracts/IItemSpawnPoint.cs.uid b/src/Features/Items/Contracts/IItemSpawnPoint.cs.uid new file mode 100644 index 0000000..63e18c0 --- /dev/null +++ b/src/Features/Items/Contracts/IItemSpawnPoint.cs.uid @@ -0,0 +1 @@ +uid://a2v6r8m3q7k5d diff --git a/src/Features/Items/Contracts/IItemSpawner.cs b/src/Features/Items/Contracts/IItemSpawner.cs new file mode 100644 index 0000000..aa95994 --- /dev/null +++ b/src/Features/Items/Contracts/IItemSpawner.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using Godot; + +namespace Game.Features.Items.Contracts; + +public interface IItemSpawner +{ + IReadOnlyCollection DefinitionIds { get; } + ItemActionResult TrySpawn(ItemDefinitionId definitionId, Vector3 worldPosition, out ItemInstanceId itemId); +} diff --git a/src/Features/Items/Contracts/IItemSpawner.cs.uid b/src/Features/Items/Contracts/IItemSpawner.cs.uid new file mode 100644 index 0000000..c0bc2ea --- /dev/null +++ b/src/Features/Items/Contracts/IItemSpawner.cs.uid @@ -0,0 +1 @@ +uid://f4m9k2w6b8n1r diff --git a/src/Features/Items/Contracts/IPickupTarget.cs b/src/Features/Items/Contracts/IPickupTarget.cs new file mode 100644 index 0000000..0ef4a5f --- /dev/null +++ b/src/Features/Items/Contracts/IPickupTarget.cs @@ -0,0 +1,6 @@ +namespace Game.Features.Items.Contracts; + +public interface IPickupTarget +{ + ItemInfo ItemInfo { get; } +} diff --git a/src/Features/Items/Contracts/IPickupTarget.cs.uid b/src/Features/Items/Contracts/IPickupTarget.cs.uid new file mode 100644 index 0000000..2e4c603 --- /dev/null +++ b/src/Features/Items/Contracts/IPickupTarget.cs.uid @@ -0,0 +1 @@ +uid://c9v4g7p2m5x8d diff --git a/src/Features/Items/Contracts/ItemActionPhase.cs b/src/Features/Items/Contracts/ItemActionPhase.cs new file mode 100644 index 0000000..4d728d0 --- /dev/null +++ b/src/Features/Items/Contracts/ItemActionPhase.cs @@ -0,0 +1,8 @@ +namespace Game.Features.Items.Contracts; + +public enum ItemActionPhase +{ + Started, + Completed, + Cancelled +} diff --git a/src/Features/Items/Contracts/ItemActionPhase.cs.uid b/src/Features/Items/Contracts/ItemActionPhase.cs.uid new file mode 100644 index 0000000..5d7e72b --- /dev/null +++ b/src/Features/Items/Contracts/ItemActionPhase.cs.uid @@ -0,0 +1 @@ +uid://c9w5d2n8q4m1h diff --git a/src/Features/Items/Contracts/ItemActionResult.cs b/src/Features/Items/Contracts/ItemActionResult.cs new file mode 100644 index 0000000..2c68f79 --- /dev/null +++ b/src/Features/Items/Contracts/ItemActionResult.cs @@ -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); +} diff --git a/src/Features/Items/Contracts/ItemActionResult.cs.uid b/src/Features/Items/Contracts/ItemActionResult.cs.uid new file mode 100644 index 0000000..e963cc2 --- /dev/null +++ b/src/Features/Items/Contracts/ItemActionResult.cs.uid @@ -0,0 +1 @@ +uid://a8m4p1x7d3q6k diff --git a/src/Features/Items/Contracts/ItemActionStatus.cs b/src/Features/Items/Contracts/ItemActionStatus.cs new file mode 100644 index 0000000..c9de44b --- /dev/null +++ b/src/Features/Items/Contracts/ItemActionStatus.cs @@ -0,0 +1,12 @@ +namespace Game.Features.Items.Contracts; + +public enum ItemActionStatus +{ + Success, + ItemNotFound, + HandOccupied, + HandEmpty, + UnsupportedAction, + PresentationFailed, + UnknownDefinition +} diff --git a/src/Features/Items/Contracts/ItemActionStatus.cs.uid b/src/Features/Items/Contracts/ItemActionStatus.cs.uid new file mode 100644 index 0000000..47f8318 --- /dev/null +++ b/src/Features/Items/Contracts/ItemActionStatus.cs.uid @@ -0,0 +1 @@ +uid://f1r6y3b9k5v2m diff --git a/src/Features/Items/Contracts/ItemDefinition.cs b/src/Features/Items/Contracts/ItemDefinition.cs new file mode 100644 index 0000000..c725ac3 --- /dev/null +++ b/src/Features/Items/Contracts/ItemDefinition.cs @@ -0,0 +1,7 @@ +namespace Game.Features.Items.Contracts; + +public readonly record struct ItemDefinition( + ItemDefinitionId Id, + string DisplayName, + HandRequirement HandRequirement +); diff --git a/src/Features/Items/Contracts/ItemDefinition.cs.uid b/src/Features/Items/Contracts/ItemDefinition.cs.uid new file mode 100644 index 0000000..cca080e --- /dev/null +++ b/src/Features/Items/Contracts/ItemDefinition.cs.uid @@ -0,0 +1 @@ +uid://e3v8k4r1m7x2p diff --git a/src/Features/Items/Contracts/ItemDefinitionId.cs b/src/Features/Items/Contracts/ItemDefinitionId.cs new file mode 100644 index 0000000..676bc33 --- /dev/null +++ b/src/Features/Items/Contracts/ItemDefinitionId.cs @@ -0,0 +1,6 @@ +namespace Game.Features.Items.Contracts; + +public readonly record struct ItemDefinitionId(string Value) +{ + public override string ToString() => Value; +} diff --git a/src/Features/Items/Contracts/ItemDefinitionId.cs.uid b/src/Features/Items/Contracts/ItemDefinitionId.cs.uid new file mode 100644 index 0000000..793d0f1 --- /dev/null +++ b/src/Features/Items/Contracts/ItemDefinitionId.cs.uid @@ -0,0 +1 @@ +uid://b7q2n9c5w1m6y diff --git a/src/Features/Items/Contracts/ItemInfo.cs b/src/Features/Items/Contracts/ItemInfo.cs new file mode 100644 index 0000000..46bbf48 --- /dev/null +++ b/src/Features/Items/Contracts/ItemInfo.cs @@ -0,0 +1,8 @@ +namespace Game.Features.Items.Contracts; + +public readonly record struct ItemInfo( + ItemInstanceId Id, + ItemDefinitionId DefinitionId, + string DisplayName, + HandRequirement HandRequirement +); diff --git a/src/Features/Items/Contracts/ItemInfo.cs.uid b/src/Features/Items/Contracts/ItemInfo.cs.uid new file mode 100644 index 0000000..45d6310 --- /dev/null +++ b/src/Features/Items/Contracts/ItemInfo.cs.uid @@ -0,0 +1 @@ +uid://e1r5j8w3n6c9h diff --git a/src/Features/Items/Contracts/ItemInstanceId.cs b/src/Features/Items/Contracts/ItemInstanceId.cs new file mode 100644 index 0000000..442b243 --- /dev/null +++ b/src/Features/Items/Contracts/ItemInstanceId.cs @@ -0,0 +1,8 @@ +using System; + +namespace Game.Features.Items.Contracts; + +public readonly record struct ItemInstanceId(Guid Value) +{ + public override string ToString() => Value.ToString(); +} diff --git a/src/Features/Items/Contracts/ItemInstanceId.cs.uid b/src/Features/Items/Contracts/ItemInstanceId.cs.uid new file mode 100644 index 0000000..65989ae --- /dev/null +++ b/src/Features/Items/Contracts/ItemInstanceId.cs.uid @@ -0,0 +1 @@ +uid://b6x2f9m4t7k3q diff --git a/src/Features/Items/Contracts/ItemUseRequest.cs b/src/Features/Items/Contracts/ItemUseRequest.cs new file mode 100644 index 0000000..ed92298 --- /dev/null +++ b/src/Features/Items/Contracts/ItemUseRequest.cs @@ -0,0 +1,11 @@ +using Game.Features.Interactions.Contracts; +using Game.Features.Players.Contracts; + +namespace Game.Features.Items.Contracts; + +public readonly record struct ItemUseRequest( + PlayerId PlayerId, + Hand Hand, + ItemActionPhase Phase, + InteractionTargetId? TargetId = null +); diff --git a/src/Features/Items/Contracts/ItemUseRequest.cs.uid b/src/Features/Items/Contracts/ItemUseRequest.cs.uid new file mode 100644 index 0000000..46fee88 --- /dev/null +++ b/src/Features/Items/Contracts/ItemUseRequest.cs.uid @@ -0,0 +1 @@ +uid://d6k2v8m5r1c9n diff --git a/src/Features/Items/Domain/HandsInventory.cs b/src/Features/Items/Domain/HandsInventory.cs new file mode 100644 index 0000000..3daa7e0 --- /dev/null +++ b/src/Features/Items/Domain/HandsInventory.cs @@ -0,0 +1,83 @@ +using System; +using Game.Features.Items.Contracts; + +namespace Game.Features.Items.Domain; + +public sealed class HandsInventory +{ + private ItemInstanceId? _leftHand; + private ItemInstanceId? _rightHand; + + public bool IsFree(Hand hand) => Get(hand) is null; + + public ItemInstanceId? Get(Hand hand) => hand switch + { + Hand.Left => _leftHand, + Hand.Right => _rightHand, + _ => throw new ArgumentOutOfRangeException(nameof(hand), hand, null) + }; + + public bool TryHold(ItemInstanceId itemId, HandRequirement requirement, Hand hand) + { + if (_leftHand == itemId || _rightHand == itemId) + { + return false; + } + + if (requirement == HandRequirement.Both) + { + if (_leftHand is not null || _rightHand is not null) + { + return false; + } + + _leftHand = itemId; + _rightHand = itemId; + return true; + } + + if (!IsFree(hand)) + { + return false; + } + + Set(hand, itemId); + return true; + } + + public ItemInstanceId? Release(Hand hand) + { + ItemInstanceId? itemId = Get(hand); + if (itemId is null) + { + return null; + } + + if (_leftHand == itemId) + { + _leftHand = null; + } + + if (_rightHand == itemId) + { + _rightHand = null; + } + + return itemId; + } + + private void Set(Hand hand, ItemInstanceId itemId) + { + switch (hand) + { + case Hand.Left: + _leftHand = itemId; + break; + case Hand.Right: + _rightHand = itemId; + break; + default: + throw new ArgumentOutOfRangeException(nameof(hand), hand, null); + } + } +} diff --git a/src/Features/Items/Domain/HandsInventory.cs.uid b/src/Features/Items/Domain/HandsInventory.cs.uid new file mode 100644 index 0000000..4f34c35 --- /dev/null +++ b/src/Features/Items/Domain/HandsInventory.cs.uid @@ -0,0 +1 @@ +uid://d5w1n8c4q7m2y diff --git a/src/Features/Items/Domain/PassiveItemInstance.cs b/src/Features/Items/Domain/PassiveItemInstance.cs new file mode 100644 index 0000000..eb6fd71 --- /dev/null +++ b/src/Features/Items/Domain/PassiveItemInstance.cs @@ -0,0 +1,13 @@ +using Game.Features.Items.Contracts; + +namespace Game.Features.Items.Domain; + +public sealed class PassiveItemInstance(ItemInfo info) : IItemInstance +{ + public ItemInfo Info { get; } = info; + + public ItemActionResult Use(ItemUseRequest request) => ItemActionResult.Fail( + ItemActionStatus.UnsupportedAction, + $"Item {Info.DefinitionId} cannot be used directly." + ); +} diff --git a/src/Features/Items/Domain/PassiveItemInstance.cs.uid b/src/Features/Items/Domain/PassiveItemInstance.cs.uid new file mode 100644 index 0000000..1bec91a --- /dev/null +++ b/src/Features/Items/Domain/PassiveItemInstance.cs.uid @@ -0,0 +1 @@ +uid://d9p4x1m7v3k6c diff --git a/src/Features/Items/Godot/ItemSpawnerController.cs b/src/Features/Items/Godot/ItemSpawnerController.cs new file mode 100644 index 0000000..c06ec13 --- /dev/null +++ b/src/Features/Items/Godot/ItemSpawnerController.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Game.Features.Items.Contracts; +using Game.Godot.Extensions; +using Godot; + +namespace Game.Features.Items.Godot; + +public partial class ItemSpawnerController : Node, IItemSpawner +{ + [Export] public Node3D ItemsRoot; + [Export] public ItemWorldController ItemWorld; + [Export] public global::Godot.Collections.Array ItemScenes = []; + + private readonly Dictionary _scenes = []; + + public IReadOnlyCollection DefinitionIds => _scenes.Keys.ToArray(); + + public override void _Ready() + { + this.AssertRequiredExports(); + } + + public void Initialize() + { + _scenes.Clear(); + + foreach (PackedScene scene in ItemScenes) + { + RegisterScene(scene); + } + + foreach (WorldItemController item in ItemWorld.Items) + { + string scenePath = item.SceneFilePath; + if (string.IsNullOrWhiteSpace(scenePath)) + { + continue; + } + + PackedScene scene = ResourceLoader.Load(scenePath); + if (scene is not null) + { + _scenes.TryAdd(item.ItemInfo.DefinitionId, scene); + } + } + + if (_scenes.Count == 0) + { + throw new InvalidOperationException("The item spawn catalog is empty."); + } + } + + public ItemActionResult TrySpawn( + ItemDefinitionId definitionId, + Vector3 worldPosition, + out ItemInstanceId itemId + ) { + itemId = default; + + if (!_scenes.TryGetValue(definitionId, out PackedScene scene)) + { + return ItemActionResult.Fail( + ItemActionStatus.UnknownDefinition, + $"Unknown item definition: {definitionId}." + ); + } + + WorldItemController item = scene.Instantiate(); + item.InstanceId = Guid.NewGuid().ToString(); + ItemsRoot.AddChild(item); + item.GlobalPosition = worldPosition; + ItemWorld.Register(item); + itemId = item.ItemInfo.Id; + return ItemActionResult.Ok(); + } + + private void RegisterScene(PackedScene scene) + { + if (scene is null) + { + return; + } + + WorldItemController prototype = scene.Instantiate(); + ItemDefinitionId definitionId = new(prototype.DefinitionId); + prototype.Free(); + + if (!_scenes.TryAdd(definitionId, scene)) + { + throw new InvalidOperationException($"Duplicate item spawn definition: {definitionId}."); + } + } +} diff --git a/src/Features/Items/Godot/ItemSpawnerController.cs.uid b/src/Features/Items/Godot/ItemSpawnerController.cs.uid new file mode 100644 index 0000000..7d39376 --- /dev/null +++ b/src/Features/Items/Godot/ItemSpawnerController.cs.uid @@ -0,0 +1 @@ +uid://c3x9m6w2q8r1b diff --git a/src/Features/Items/Godot/ItemWorldController.cs b/src/Features/Items/Godot/ItemWorldController.cs new file mode 100644 index 0000000..36974d0 --- /dev/null +++ b/src/Features/Items/Godot/ItemWorldController.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using Game.Features.Items.Contracts; +using Godot; + +namespace Game.Features.Items.Godot; + +public partial class ItemWorldController : Node, IItemRepository +{ + public const string RegistryGroup = "item_world_registry"; + + private readonly Dictionary _items = []; + + public IReadOnlyCollection Items => _items.Values; + + public override void _EnterTree() + { + AddToGroup(RegistryGroup); + } + + public void RegisterSceneItems() + { + foreach (Node node in GetTree().GetNodesInGroup(WorldItemController.ItemGroup)) + { + if (node is WorldItemController item) + { + Register(item); + } + } + } + + public void Register(WorldItemController item) + { + if (_items.TryGetValue(item.ItemInfo.Id, out WorldItemController existing)) + { + if (ReferenceEquals(existing, item)) + { + return; + } + + throw new InvalidOperationException($"Duplicate item instance id: {item.ItemInfo.Id}."); + } + + _items.Add(item.ItemInfo.Id, item); + item.RegisterWith(this); + } + + public void Unregister(ItemInstanceId itemId) + { + _items.Remove(itemId); + } + + public bool TryGet(ItemInstanceId itemId, out IItemInstance item) + { + if (_items.TryGetValue(itemId, out WorldItemController controller)) + { + item = controller.Item; + return true; + } + + item = null; + return false; + } + + public bool TryGetController(ItemInstanceId itemId, out WorldItemController controller) + { + return _items.TryGetValue(itemId, out controller); + } +} diff --git a/src/Features/Items/Godot/ItemWorldController.cs.uid b/src/Features/Items/Godot/ItemWorldController.cs.uid new file mode 100644 index 0000000..8d635fd --- /dev/null +++ b/src/Features/Items/Godot/ItemWorldController.cs.uid @@ -0,0 +1 @@ +uid://f8n3b6w1k5r9m diff --git a/src/Features/Items/Godot/PlayerHeldItemPresenter.cs b/src/Features/Items/Godot/PlayerHeldItemPresenter.cs new file mode 100644 index 0000000..d5f38e8 --- /dev/null +++ b/src/Features/Items/Godot/PlayerHeldItemPresenter.cs @@ -0,0 +1,42 @@ +using Game.Features.Items.Contracts; +using Godot; + +namespace Game.Features.Items.Godot; + +public sealed class PlayerHeldItemPresenter( + ItemWorldController itemWorld, + Node3D leftHandAnchor, + Node3D rightHandAnchor +) : IHeldItemPresenter +{ + public bool TryShowHeld(IItemInstance item, Hand hand) + { + if (!itemWorld.TryGetController(item.Info.Id, out WorldItemController controller)) + { + return false; + } + + Node3D anchor = hand == Hand.Left ? leftHandAnchor : rightHandAnchor; + controller.SetHeld(anchor, item); + return true; + } + + public bool TryShowDropped(IItemInstance item, Vector3 worldPosition) + { + if (!itemWorld.TryGetController(item.Info.Id, out WorldItemController controller)) + { + return false; + } + + controller.SetDropped(worldPosition, item); + return true; + } + + public void Refresh(IItemInstance item) + { + if (itemWorld.TryGetController(item.Info.Id, out WorldItemController controller)) + { + controller.ApplyState(item); + } + } +} diff --git a/src/Features/Items/Godot/PlayerHeldItemPresenter.cs.uid b/src/Features/Items/Godot/PlayerHeldItemPresenter.cs.uid new file mode 100644 index 0000000..8ca5df0 --- /dev/null +++ b/src/Features/Items/Godot/PlayerHeldItemPresenter.cs.uid @@ -0,0 +1 @@ +uid://e5m2r8q1v7k4n diff --git a/src/Features/Items/Godot/WorldItemController.cs b/src/Features/Items/Godot/WorldItemController.cs new file mode 100644 index 0000000..87c0b05 --- /dev/null +++ b/src/Features/Items/Godot/WorldItemController.cs @@ -0,0 +1,141 @@ +using System; +using Game.Features.Items.Contracts; +using Game.Features.Items.Domain; +using Game.Godot.Extensions; +using Godot; + +namespace Game.Features.Items.Godot; + +public partial class WorldItemController : RigidBody3D, IPickupTarget +{ + public const string ItemGroup = "world_items"; + + [Export] public string InstanceId; + [Export] public string DefinitionId = "item"; + [Export] public string DisplayName = "Item"; + [Export] public HandRequirement HandRequirement = HandRequirement.One; + [Export] public CollisionShape3D Collision; + [Export] public Node3D WorldVisual; + [Export] public PackedScene HeldViewScene; + + protected Node3D HeldView { get; private set; } + protected bool IsHeld => HeldView is not null; + + public ItemInfo ItemInfo { get; private set; } + public ItemDefinition Definition { get; private set; } + public IItemInstance Item { get; private set; } + + private ItemWorldController _registry; + + public override void _Ready() + { + this.AssertRequiredExports(); + + if (string.IsNullOrWhiteSpace(DefinitionId)) + { + throw new InvalidOperationException($"{Name}: item definition id cannot be empty."); + } + + if (HeldViewScene is null) + { + throw new InvalidOperationException($"{Name}: held view scene is not assigned."); + } + + Guid id = string.IsNullOrWhiteSpace(InstanceId) + ? Guid.NewGuid() + : Guid.Parse(InstanceId); + + Definition = new ItemDefinition(new ItemDefinitionId(DefinitionId), DisplayName, HandRequirement); + ItemInfo = new ItemInfo( + new ItemInstanceId(id), + Definition.Id, + Definition.DisplayName, + Definition.HandRequirement + ); + Item = CreateItemInstance(ItemInfo); + AddToGroup(ItemGroup); + RegisterWithAvailableWorld(); + ApplyState(Item); + } + + public override void _ExitTree() + { + _registry?.Unregister(ItemInfo.Id); + } + + public void RegisterWith(ItemWorldController registry) + { + _registry = registry; + } + + public void SetHeld(Node3D anchor, IItemInstance item) + { + if (IsHeld) + { + return; + } + + Freeze = true; + Sleeping = true; + LinearVelocity = Vector3.Zero; + AngularVelocity = Vector3.Zero; + Collision.SetDeferred(CollisionShape3D.PropertyName.Disabled, true); + WorldVisual.Visible = false; + + HeldView = HeldViewScene.Instantiate(); + anchor.AddChild(HeldView); + HeldView.Transform = Transform3D.Identity; + OnHeldViewCreated(HeldView); + ApplyState(item); + } + + public void SetDropped(Vector3 worldPosition, IItemInstance item) + { + if (HeldView is not null) + { + OnHeldViewRemoving(HeldView); + HeldView.QueueFree(); + HeldView = null; + } + + GlobalPosition = worldPosition; + LinearVelocity = Vector3.Zero; + AngularVelocity = Vector3.Zero; + WorldVisual.Visible = true; + Collision.SetDeferred(CollisionShape3D.PropertyName.Disabled, false); + Freeze = false; + Sleeping = false; + ApplyState(item); + } + + public void ApplyState(IItemInstance item) + { + OnPresentationChanged(item); + } + + protected virtual IItemInstance CreateItemInstance(ItemInfo itemInfo) => new PassiveItemInstance(itemInfo); + + protected virtual void OnHeldViewCreated(Node3D heldView) + { + } + + protected virtual void OnHeldViewRemoving(Node3D heldView) + { + } + + protected virtual void OnPresentationChanged(IItemInstance item) + { + } + + private void RegisterWithAvailableWorld() + { + foreach (Node node in GetTree().GetNodesInGroup(ItemWorldController.RegistryGroup)) + { + if (node is ItemWorldController registry) + { + registry.Register(this); + return; + } + } + } +} diff --git a/src/Features/Items/Godot/WorldItemController.cs.uid b/src/Features/Items/Godot/WorldItemController.cs.uid new file mode 100644 index 0000000..5350961 --- /dev/null +++ b/src/Features/Items/Godot/WorldItemController.cs.uid @@ -0,0 +1 @@ +uid://d1x7m4q9v2c6k