Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef5f7d127f | ||
|
|
db0356eb6f | ||
|
|
888e39e15d | ||
|
|
9fdd565637 | ||
|
|
3127c1b4af |
@@ -3,7 +3,5 @@
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<EnableDynamicLoading>true</EnableDynamicLoading>
|
||||
<RootNamespace>Game</RootNamespace>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -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:
|
||||
|
||||
@@ -15,12 +15,12 @@ public sealed class SpawnItemCommand : ICommand
|
||||
public string Description => "Spawns an item in front of the player.";
|
||||
public IReadOnlyList<CommandArgSpec> Args { get; }
|
||||
|
||||
public SpawnItemCommand(IItemSpawner spawner, IItemSpawnPoint spawnPoint)
|
||||
public SpawnItemCommand(IItemSpawner spawner, IItemDefinitionRegistry registry, IItemSpawnPoint spawnPoint)
|
||||
{
|
||||
_spawner = spawner;
|
||||
_spawnPoint = spawnPoint;
|
||||
|
||||
string[] definitions = spawner.DefinitionIds
|
||||
string[] definitions = registry.DefinitionIds
|
||||
.Select(id => id.Value)
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
namespace Game.Features.Interactions.Contracts;
|
||||
|
||||
public interface IInteractionService
|
||||
public interface IInteractionHandler
|
||||
{
|
||||
InteractionResult TryInteract(InteractionRequest request);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Game.Features.Interactions.Contracts;
|
||||
|
||||
public interface IInteractionWorld
|
||||
{
|
||||
InteractionResult TryInteract(InteractionRequest request);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
uid://d7k1x6m4v9q2b
|
||||
@@ -1,16 +0,0 @@
|
||||
using Game.Features.Interactions.Contracts;
|
||||
using Game.Features.Players.Contracts;
|
||||
using Godot;
|
||||
|
||||
namespace Game.Features.Items.Contracts;
|
||||
|
||||
public interface IPlayerInteractionController : IItemSpawnPoint
|
||||
{
|
||||
void SetActive(bool isActive);
|
||||
|
||||
void InitializeController(
|
||||
PlayerId playerId,
|
||||
IItemInteractionService items,
|
||||
IInteractionService interactions
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
uid://cvfdyxpc3xkfd
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -4,7 +4,7 @@ namespace Game.Features.Interactions.Godot;
|
||||
|
||||
public partial class InteractionRaycaster : RayCast3D
|
||||
{
|
||||
public T GetTarget<T>() where T : class
|
||||
public T GetTargetOrNull<T>() where T : class
|
||||
{
|
||||
if (!IsColliding() || GetCollider() is not Node node)
|
||||
{
|
||||
|
||||
@@ -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<InteractionTargetId, WorldInteractionController> _targets = [];
|
||||
|
||||
public override void _EnterTree()
|
||||
{
|
||||
AddToGroup(RegistryGroup);
|
||||
}
|
||||
|
||||
public void RegisterSceneTargets()
|
||||
{
|
||||
foreach (Node node in GetTree().GetNodesInGroup(WorldInteractionController.InteractionGroup))
|
||||
{
|
||||
if (node is WorldInteractionController target)
|
||||
{
|
||||
Register(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Register(WorldInteractionController target)
|
||||
{
|
||||
if (_targets.TryGetValue(target.Id, out WorldInteractionController existing))
|
||||
{
|
||||
if (ReferenceEquals(existing, target))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Duplicate interaction target id: {target.Id}.");
|
||||
}
|
||||
|
||||
_targets.Add(target.Id, target);
|
||||
target.RegisterWith(this);
|
||||
}
|
||||
|
||||
public void Unregister(InteractionTargetId targetId)
|
||||
{
|
||||
_targets.Remove(targetId);
|
||||
}
|
||||
|
||||
public InteractionResult TryInteract(InteractionRequest request)
|
||||
{
|
||||
if (!_targets.TryGetValue(request.TargetId, out WorldInteractionController target))
|
||||
{
|
||||
return InteractionResult.Fail(InteractionStatus.TargetNotFound, $"Target {request.TargetId} was not found.");
|
||||
}
|
||||
|
||||
if (!target.CanInteract)
|
||||
{
|
||||
return InteractionResult.Fail(InteractionStatus.Unavailable, "The target is not currently available.");
|
||||
}
|
||||
|
||||
return target.Execute(request.PlayerId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,18 @@
|
||||
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, IPlayerInteractionController
|
||||
public partial class PlayerInteractionController : Node, IItemSpawnPoint
|
||||
{
|
||||
[Export]
|
||||
public Camera3D Camera;
|
||||
@@ -26,27 +29,134 @@ public partial class PlayerInteractionController : Node, IPlayerInteractionContr
|
||||
[Export]
|
||||
public InteractionPromptController PromptController;
|
||||
|
||||
[Export]
|
||||
public Node3D LeftHandAnchor;
|
||||
|
||||
[Export]
|
||||
public Node3D RightHandAnchor;
|
||||
|
||||
private IItemInteractionService _items;
|
||||
private IInteractionService _interactions;
|
||||
private IInteractionHandler _interactions;
|
||||
private PlayerId _playerId;
|
||||
private readonly HashSet<Hand> _dropActions = [];
|
||||
private readonly Dictionary<Hand, InteractionTargetId?> _activeUseTargets = [];
|
||||
private readonly ResolvePickupAvailability _resolvePickupAvailability = new();
|
||||
|
||||
public override void _Ready()
|
||||
private ItemUseRequest CreateUseRequest(Hand hand, ItemActionPhase phase)
|
||||
{
|
||||
this.AssertRequiredExports();
|
||||
InteractionTargetId? targetId = InteractionRaycaster
|
||||
.GetTargetOrNull<IInteractionTarget>()
|
||||
?.InteractionTargetId;
|
||||
|
||||
return new ItemUseRequest(_playerId, hand, phase, targetId);
|
||||
}
|
||||
|
||||
public void SetActive(bool isActive)
|
||||
private void ProcessHandActionStarted(Hand hand)
|
||||
{
|
||||
SetProcess(isActive);
|
||||
SetProcessUnhandledInput(isActive);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
public void InitializeController(
|
||||
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,
|
||||
IInteractionService interactions
|
||||
IInteractionHandler interactions
|
||||
)
|
||||
{
|
||||
_playerId = playerId;
|
||||
@@ -54,14 +164,21 @@ public partial class PlayerInteractionController : Node, IPlayerInteractionContr
|
||||
_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
|
||||
|| GamePause.IsPaused
|
||||
|| !MouseCapture.IsCaptured
|
||||
)
|
||||
if (_items is null || _interactions is null)
|
||||
{
|
||||
throw new ControllerNotInitializedException(Name);
|
||||
}
|
||||
|
||||
if (GamePause.IsPaused || !MouseCapture.IsCaptured)
|
||||
{
|
||||
PromptController.Clear();
|
||||
return;
|
||||
@@ -72,12 +189,12 @@ public partial class PlayerInteractionController : Node, IPlayerInteractionContr
|
||||
|
||||
public override void _UnhandledInput(InputEvent @event)
|
||||
{
|
||||
if (
|
||||
_items is null
|
||||
|| _interactions is null
|
||||
|| GamePause.IsPaused
|
||||
|| !MouseCapture.IsCaptured
|
||||
)
|
||||
if (_items is null || _interactions is null)
|
||||
{
|
||||
throw new ControllerNotInitializedException(Name);
|
||||
}
|
||||
|
||||
if (GamePause.IsPaused || !MouseCapture.IsCaptured)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -112,137 +229,15 @@ public partial class PlayerInteractionController : Node, IPlayerInteractionContr
|
||||
|
||||
if (@event.IsActionPressed(InputAction.Interact.ActionName()))
|
||||
{
|
||||
IInteractionTarget target = InteractionRaycaster.GetTarget<IInteractionTarget>();
|
||||
IInteractionTarget target = InteractionRaycaster.GetTargetOrNull<IInteractionTarget>();
|
||||
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<IPickupTarget>();
|
||||
if (target is not null)
|
||||
{
|
||||
_items.TryPickup(target.ItemInfo.Id, hand);
|
||||
}
|
||||
}
|
||||
|
||||
private void ProcessHandActionCompleted(Hand hand)
|
||||
{
|
||||
if (_dropActions.Remove(hand))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_activeUseTargets.Remove(hand, out InteractionTargetId? targetId))
|
||||
{
|
||||
_items.TryUse(new ItemUseRequest(_playerId, hand, ItemActionPhase.Completed, targetId));
|
||||
}
|
||||
}
|
||||
|
||||
private ItemUseRequest CreateUseRequest(Hand hand, ItemActionPhase phase)
|
||||
{
|
||||
InteractionTargetId? targetId = InteractionRaycaster.GetTarget<IInteractionTarget>()?.Id;
|
||||
return new ItemUseRequest(_playerId, hand, phase, targetId);
|
||||
}
|
||||
|
||||
private Vector3 CalculateDropPosition()
|
||||
{
|
||||
DropRaycaster.ForceRaycastUpdate();
|
||||
|
||||
Vector3 origin = Camera.GlobalPosition;
|
||||
Vector3 direction = -Camera.GlobalBasis.Z.Normalized();
|
||||
float distance = DropRaycaster.TargetPosition.Length();
|
||||
|
||||
if (DropRaycaster.IsColliding())
|
||||
{
|
||||
distance = Mathf.Max(
|
||||
0.25f,
|
||||
origin.DistanceTo(DropRaycaster.GetCollisionPoint()) - 0.2f
|
||||
);
|
||||
}
|
||||
|
||||
return origin + direction * distance;
|
||||
}
|
||||
|
||||
private void UpdatePrompt()
|
||||
{
|
||||
IPickupTarget pickupTarget = InteractionRaycaster.GetTarget<IPickupTarget>();
|
||||
if (pickupTarget is not null)
|
||||
{
|
||||
ShowPickupPrompt(pickupTarget.ItemInfo);
|
||||
return;
|
||||
}
|
||||
|
||||
IInteractionTarget interactionTarget = InteractionRaycaster.GetTarget<IInteractionTarget>();
|
||||
if (interactionTarget?.CanInteract == true)
|
||||
{
|
||||
PromptController.ShowPrompt($"[E] {interactionTarget.Prompt.Text}");
|
||||
return;
|
||||
}
|
||||
|
||||
PromptController.Clear();
|
||||
}
|
||||
|
||||
private void ShowPickupPrompt(ItemInfo itemInfo)
|
||||
{
|
||||
if (itemInfo.HandRequirement == HandRequirement.Both)
|
||||
{
|
||||
bool bothHandsFree = _items.IsHandFree(Hand.Left) && _items.IsHandFree(Hand.Right);
|
||||
PromptController.ShowPrompt(
|
||||
bothHandsFree
|
||||
? $"[LPM lub PPM] Podnieś {itemInfo.DisplayName} — obie ręce"
|
||||
: "Potrzebujesz obu wolnych rąk"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
List<string> actions = [];
|
||||
|
||||
if (_items.IsHandFree(Hand.Left))
|
||||
{
|
||||
actions.Add($"[LPM] Podnieś {itemInfo.DisplayName} — lewa ręka");
|
||||
}
|
||||
|
||||
if (_items.IsHandFree(Hand.Right))
|
||||
{
|
||||
actions.Add($"[PPM] Podnieś {itemInfo.DisplayName} — prawa ręka");
|
||||
}
|
||||
|
||||
PromptController.ShowPrompt(
|
||||
actions.Count > 0 ? string.Join("\n", actions) : "Brak wolnej ręki"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
uid://d2r8m5v1q9k4x
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -6,17 +8,19 @@ namespace Game.Features.Items.Application;
|
||||
|
||||
public sealed class ItemInteractionService(
|
||||
HandsInventory hands,
|
||||
IItemRepository itemRepository,
|
||||
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 (!itemRepository.TryGet(itemId, out IItemInstance item))
|
||||
if (!itemRegistry.TryGet(itemId, out IItemInstance item))
|
||||
{
|
||||
return ItemActionResult.Fail(ItemActionStatus.ItemNotFound, $"Item {itemId} was not found.");
|
||||
}
|
||||
@@ -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)
|
||||
@@ -43,7 +68,7 @@ public sealed class ItemInteractionService(
|
||||
return ItemActionResult.Fail(ItemActionStatus.HandEmpty, "The requested hand is empty.");
|
||||
}
|
||||
|
||||
if (!itemRepository.TryGet(itemId.Value, out IItemInstance item))
|
||||
if (!itemRegistry.TryGet(itemId.Value, out IItemInstance item))
|
||||
{
|
||||
return ItemActionResult.Fail(ItemActionStatus.ItemNotFound, $"Item {itemId} was not found.");
|
||||
}
|
||||
@@ -65,7 +90,7 @@ public sealed class ItemInteractionService(
|
||||
return ItemActionResult.Fail(ItemActionStatus.HandEmpty, "The requested hand is empty.");
|
||||
}
|
||||
|
||||
if (!itemRepository.TryGet(itemId.Value, out IItemInstance item))
|
||||
if (!itemRegistry.TryGet(itemId.Value, out IItemInstance item))
|
||||
{
|
||||
return ItemActionResult.Fail(ItemActionStatus.ItemNotFound, $"Item {itemId} was not found.");
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Game.Features.Items.Contracts;
|
||||
|
||||
public interface IItemRepository
|
||||
{
|
||||
bool TryGet(ItemInstanceId itemId, out IItemInstance item);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
uid://e7n1m6v4k9r3q
|
||||
@@ -1,10 +1,12 @@
|
||||
using System.Collections.Generic;
|
||||
using Godot;
|
||||
|
||||
namespace Game.Features.Items.Contracts;
|
||||
|
||||
public interface IItemSpawner
|
||||
{
|
||||
IReadOnlyCollection<ItemDefinitionId> DefinitionIds { get; }
|
||||
ItemActionResult TrySpawn(ItemDefinitionId definitionId, Vector3 worldPosition, out ItemInstanceId itemId);
|
||||
ItemActionResult TrySpawn(
|
||||
ItemDefinitionId definitionId,
|
||||
Vector3 worldPosition,
|
||||
out ItemInstanceId itemId
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using System;
|
||||
using Game.Shared.Domain;
|
||||
|
||||
namespace Game.Features.Items.Contracts;
|
||||
|
||||
public readonly record struct ItemInstanceId(Guid Value)
|
||||
public readonly record struct ItemInstanceId(InstanceId Value)
|
||||
{
|
||||
public static ItemInstanceId From(InstanceId value) => new(value);
|
||||
|
||||
public override string ToString() => Value.ToString();
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ using Godot;
|
||||
|
||||
namespace Game.Features.Items.Godot.Flashlight;
|
||||
|
||||
public partial class FlashlightItemController : WorldItemController
|
||||
public partial class FlashlightItemController : ItemController
|
||||
{
|
||||
[Export] public SpotLight3D WorldLight;
|
||||
|
||||
|
||||
+5
-5
@@ -3,15 +3,15 @@ using Godot;
|
||||
|
||||
namespace Game.Features.Items.Godot;
|
||||
|
||||
public sealed class PlayerHeldItemPresenter(
|
||||
ItemWorldController itemWorld,
|
||||
public sealed class HeldItemPresenter(
|
||||
ItemControllerRegistryController itemWorld,
|
||||
Node3D leftHandAnchor,
|
||||
Node3D rightHandAnchor
|
||||
) : IHeldItemPresenter
|
||||
{
|
||||
public bool TryShowHeld(IItemInstance item, Hand hand)
|
||||
{
|
||||
if (!itemWorld.TryGetController(item.Info.Id, out WorldItemController controller))
|
||||
if (!itemWorld.TryGetController(item.Info.Id, out ItemController controller))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -23,7 +23,7 @@ public sealed class PlayerHeldItemPresenter(
|
||||
|
||||
public bool TryShowDropped(IItemInstance item, Vector3 worldPosition)
|
||||
{
|
||||
if (!itemWorld.TryGetController(item.Info.Id, out WorldItemController controller))
|
||||
if (!itemWorld.TryGetController(item.Info.Id, out ItemController controller))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -34,7 +34,7 @@ public sealed class PlayerHeldItemPresenter(
|
||||
|
||||
public void Refresh(IItemInstance item)
|
||||
{
|
||||
if (itemWorld.TryGetController(item.Info.Id, out WorldItemController controller))
|
||||
if (itemWorld.TryGetController(item.Info.Id, out ItemController controller))
|
||||
{
|
||||
controller.ApplyState(item);
|
||||
}
|
||||
+34
-39
@@ -6,17 +6,30 @@ using Godot;
|
||||
|
||||
namespace Game.Features.Items.Godot;
|
||||
|
||||
public partial class WorldItemController : RigidBody3D, IPickupTarget
|
||||
public partial class ItemController : 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;
|
||||
[Export]
|
||||
public string InstanceKey;
|
||||
|
||||
[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;
|
||||
@@ -25,7 +38,7 @@ public partial class WorldItemController : RigidBody3D, IPickupTarget
|
||||
public ItemDefinition Definition { get; private set; }
|
||||
public IItemInstance Item { get; private set; }
|
||||
|
||||
private ItemWorldController _registry;
|
||||
private ItemControllerRegistryController _registry;
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
@@ -41,20 +54,19 @@ public partial class WorldItemController : RigidBody3D, IPickupTarget
|
||||
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);
|
||||
Definition = new ItemDefinition(
|
||||
new ItemDefinitionId(DefinitionId),
|
||||
DisplayName,
|
||||
HandRequirement
|
||||
);
|
||||
ItemInfo = new ItemInfo(
|
||||
new ItemInstanceId(id),
|
||||
ItemInstanceId.From(this.GetStableInstanceIdFrom(InstanceKey)),
|
||||
Definition.Id,
|
||||
Definition.DisplayName,
|
||||
Definition.HandRequirement
|
||||
);
|
||||
Item = CreateItemInstance(ItemInfo);
|
||||
AddToGroup(ItemGroup);
|
||||
RegisterWithAvailableWorld();
|
||||
ApplyState(Item);
|
||||
}
|
||||
|
||||
@@ -63,7 +75,7 @@ public partial class WorldItemController : RigidBody3D, IPickupTarget
|
||||
_registry?.Unregister(ItemInfo.Id);
|
||||
}
|
||||
|
||||
public void RegisterWith(ItemWorldController registry)
|
||||
public void RegisterWith(ItemControllerRegistryController registry)
|
||||
{
|
||||
_registry = registry;
|
||||
}
|
||||
@@ -113,29 +125,12 @@ public partial class WorldItemController : RigidBody3D, IPickupTarget
|
||||
OnPresentationChanged(item);
|
||||
}
|
||||
|
||||
protected virtual IItemInstance CreateItemInstance(ItemInfo itemInfo) => new PassiveItemInstance(itemInfo);
|
||||
protected virtual IItemInstance CreateItemInstance(ItemInfo itemInfo) =>
|
||||
new PassiveItemInstance(itemInfo);
|
||||
|
||||
protected virtual void OnHeldViewCreated(Node3D heldView)
|
||||
{
|
||||
}
|
||||
protected virtual void OnHeldViewCreated(Node3D heldView) { }
|
||||
|
||||
protected virtual void OnHeldViewRemoving(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
protected virtual void OnPresentationChanged(IItemInstance item) { }
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Game.Features.Items.Contracts;
|
||||
using Godot;
|
||||
|
||||
namespace Game.Features.Items.Godot;
|
||||
|
||||
public partial class ItemControllerRegistryController : Node
|
||||
{
|
||||
private IItemInstanceRegistry _registry;
|
||||
private readonly Dictionary<ItemInstanceId, ItemController> _items = [];
|
||||
|
||||
public IReadOnlyCollection<ItemController> Items => _items.Values;
|
||||
|
||||
public void UseRegistry(IItemInstanceRegistry registry)
|
||||
{
|
||||
_registry = registry;
|
||||
}
|
||||
|
||||
public void RegisterSceneItems()
|
||||
{
|
||||
foreach (Node node in GetTree().GetNodesInGroup(ItemController.ItemGroup))
|
||||
{
|
||||
if (node is ItemController item)
|
||||
{
|
||||
Register(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Register(ItemController item)
|
||||
{
|
||||
if (_items.TryGetValue(item.ItemInfo.Id, out ItemController existing))
|
||||
{
|
||||
if (ReferenceEquals(existing, item))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Duplicate item instance id: {item.ItemInfo.Id}.");
|
||||
}
|
||||
|
||||
_items.Add(item.ItemInfo.Id, item);
|
||||
_registry.Register(item.ItemInfo.Id, item.Item);
|
||||
item.RegisterWith(this);
|
||||
}
|
||||
|
||||
public void Unregister(ItemInstanceId itemId)
|
||||
{
|
||||
_items.Remove(itemId);
|
||||
_registry.Unregister(itemId);
|
||||
}
|
||||
|
||||
public bool TryGetController(ItemInstanceId itemId, out ItemController controller)
|
||||
{
|
||||
return _items.TryGetValue(itemId, out controller);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Game.Features.Items.Contracts;
|
||||
using Game.Godot.Extensions;
|
||||
using Godot;
|
||||
|
||||
namespace Game.Features.Items.Godot;
|
||||
|
||||
public partial class ItemSceneRegistryController : Node
|
||||
{
|
||||
[Export]
|
||||
public ItemControllerRegistryController ItemWorld;
|
||||
|
||||
[Export]
|
||||
public global::Godot.Collections.Array<PackedScene> ItemScenes = [];
|
||||
|
||||
private readonly Dictionary<ItemDefinitionId, PackedScene> _scenes = [];
|
||||
|
||||
private IItemDefinitionRegistry _definitions;
|
||||
|
||||
public void UseRegistry(IItemDefinitionRegistry registry)
|
||||
{
|
||||
_definitions = registry;
|
||||
}
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
this.AssertRequiredExports();
|
||||
}
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
_scenes.Clear();
|
||||
|
||||
foreach (PackedScene scene in ItemScenes)
|
||||
{
|
||||
RegisterItem(scene, throwOnDuplicate: true);
|
||||
}
|
||||
|
||||
foreach (ItemController item in ItemWorld.Items)
|
||||
{
|
||||
string scenePath = item.SceneFilePath;
|
||||
if (string.IsNullOrWhiteSpace(scenePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
RegisterItem(ResourceLoader.Load<PackedScene>(scenePath), throwOnDuplicate: false);
|
||||
}
|
||||
|
||||
if (_scenes.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("The item spawn catalog is empty.");
|
||||
}
|
||||
|
||||
foreach (ItemController item in ItemWorld.Items)
|
||||
{
|
||||
ValidateAgainstCatalog(item);
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGetScene(ItemDefinitionId definitionId, out PackedScene scene)
|
||||
{
|
||||
return _scenes.TryGetValue(definitionId, out scene);
|
||||
}
|
||||
|
||||
private void RegisterItem(PackedScene itemScene, bool throwOnDuplicate)
|
||||
{
|
||||
if (itemScene is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ItemController prototype = itemScene.Instantiate<ItemController>();
|
||||
ItemDefinitionId definitionId = new(prototype.DefinitionId);
|
||||
ItemDefinition definition = new(
|
||||
definitionId,
|
||||
prototype.DisplayName,
|
||||
prototype.HandRequirement
|
||||
);
|
||||
prototype.Free();
|
||||
|
||||
if (!_definitions.TryRegister(definitionId, definition))
|
||||
{
|
||||
if (throwOnDuplicate)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Duplicate item spawn definition: {definitionId}."
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_scenes.Add(definitionId, itemScene);
|
||||
}
|
||||
|
||||
private void ValidateAgainstCatalog(ItemController item)
|
||||
{
|
||||
if (!_definitions.TryGetDefinition(item.Definition.Id, out ItemDefinition canonical))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!item.Definition.Equals(canonical))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{item.Name}: item definition drifted from the catalog for '{item.Definition.Id}'."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://cft7r2j1f0sav
|
||||
@@ -1,6 +1,3 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Game.Features.Items.Contracts;
|
||||
using Game.Godot.Extensions;
|
||||
using Godot;
|
||||
@@ -9,57 +6,29 @@ 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<PackedScene> ItemScenes = [];
|
||||
[Export]
|
||||
public Node3D ItemsRoot;
|
||||
|
||||
private readonly Dictionary<ItemDefinitionId, PackedScene> _scenes = [];
|
||||
[Export]
|
||||
public ItemControllerRegistryController ItemWorld;
|
||||
|
||||
public IReadOnlyCollection<ItemDefinitionId> DefinitionIds => _scenes.Keys.ToArray();
|
||||
[Export]
|
||||
public ItemSceneRegistryController Scenes;
|
||||
|
||||
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<PackedScene>(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))
|
||||
if (!Scenes.TryGetScene(definitionId, out PackedScene scene))
|
||||
{
|
||||
return ItemActionResult.Fail(
|
||||
ItemActionStatus.UnknownDefinition,
|
||||
@@ -67,29 +36,11 @@ public partial class ItemSpawnerController : Node, IItemSpawner
|
||||
);
|
||||
}
|
||||
|
||||
WorldItemController item = scene.Instantiate<WorldItemController>();
|
||||
item.InstanceId = Guid.NewGuid().ToString();
|
||||
ItemController item = scene.Instantiate<ItemController>();
|
||||
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<WorldItemController>();
|
||||
ItemDefinitionId definitionId = new(prototype.DefinitionId);
|
||||
prototype.Free();
|
||||
|
||||
if (!_scenes.TryAdd(definitionId, scene))
|
||||
{
|
||||
throw new InvalidOperationException($"Duplicate item spawn definition: {definitionId}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
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<ItemInstanceId, WorldItemController> _items = [];
|
||||
|
||||
public IReadOnlyCollection<WorldItemController> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Game.Features.Items.Contracts;
|
||||
|
||||
namespace Game.Features.Items.Infrastructure;
|
||||
|
||||
public sealed class ItemDefinitionRegistry : IItemDefinitionRegistry
|
||||
{
|
||||
private readonly Dictionary<ItemDefinitionId, ItemDefinition> _definitionsById = [];
|
||||
|
||||
public IReadOnlyCollection<ItemDefinitionId> DefinitionIds => _definitionsById.Keys.ToArray();
|
||||
|
||||
public bool TryRegister(ItemDefinitionId definitionId, ItemDefinition definition) =>
|
||||
_definitionsById.TryAdd(definitionId, definition);
|
||||
|
||||
public bool TryGetDefinition(ItemDefinitionId definitionId, out ItemDefinition definition) =>
|
||||
_definitionsById.TryGetValue(definitionId, out definition);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://72g8eg0lvlui
|
||||
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Game.Features.Items.Contracts;
|
||||
|
||||
namespace Game.Features.Items.Infrastructure;
|
||||
|
||||
public sealed class ItemInstanceRegistry : IItemInstanceRegistry
|
||||
{
|
||||
private readonly Dictionary<ItemInstanceId, IItemInstance> _itemsById = [];
|
||||
|
||||
public void Register(ItemInstanceId itemId, IItemInstance item)
|
||||
{
|
||||
if (_itemsById.TryGetValue(itemId, out IItemInstance existing))
|
||||
{
|
||||
if (ReferenceEquals(existing, item))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Duplicate item instance id: {itemId}.");
|
||||
}
|
||||
|
||||
_itemsById.Add(itemId, item);
|
||||
}
|
||||
|
||||
public void Unregister(ItemInstanceId itemId)
|
||||
{
|
||||
_itemsById.Remove(itemId);
|
||||
}
|
||||
|
||||
public bool TryGet(ItemInstanceId itemId, out IItemInstance item) =>
|
||||
_itemsById.TryGetValue(itemId, out item);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://dfbylp383il2v
|
||||
@@ -7,7 +7,6 @@ public interface IMultiplayerPeer
|
||||
event Action Connected;
|
||||
event Action ConnectionFailed;
|
||||
event Action Disconnected;
|
||||
event Action<PeerId> HostingStarted;
|
||||
event Action<PeerId> PeerJoined;
|
||||
event Action<PeerId> PeerLeft;
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ public partial class MultiplayerController : Node, IMultiplayerPeer
|
||||
public event Action Connected;
|
||||
public event Action ConnectionFailed;
|
||||
public event Action Disconnected;
|
||||
public event Action<PeerId> HostingStarted;
|
||||
public event Action<PeerId> PeerJoined;
|
||||
public event Action<PeerId> PeerLeft;
|
||||
|
||||
@@ -92,10 +91,7 @@ public partial class MultiplayerController : Node, IMultiplayerPeer
|
||||
AssertNoActiveSession();
|
||||
|
||||
Multiplayer.MultiplayerPeer = _multiplayerPeerFactory.CreateHost(port, MultiplayerSession.MaxPlayers - 1);
|
||||
|
||||
PeerId hostPeerId = new(Multiplayer.GetUniqueId());
|
||||
_session.StartHosting(hostPeerId);
|
||||
HostingStarted?.Invoke(hostPeerId);
|
||||
_session.StartHosting(new PeerId(Multiplayer.GetUniqueId()));
|
||||
}
|
||||
|
||||
public void Join(string address, int port)
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
using Game.Features.Items.Contracts;
|
||||
using Game.Features.Players.Contracts;
|
||||
|
||||
public interface IPlayerController : IMovementModeSwitcher, IItemSpawnPoint { }
|
||||
@@ -1 +0,0 @@
|
||||
uid://1i0wvlumjjiu
|
||||
@@ -1,24 +0,0 @@
|
||||
using Game.Features.Items.Contracts;
|
||||
using Game.Features.Players.Contracts;
|
||||
using Godot;
|
||||
|
||||
namespace Game.Features.Players.Godot;
|
||||
|
||||
public sealed class LocalPlayerController : IPlayerController
|
||||
{
|
||||
public PlayerController Current { get; private set; }
|
||||
|
||||
public void SetCurrent(PlayerController player)
|
||||
{
|
||||
Current = player;
|
||||
}
|
||||
|
||||
public MovementMode ActiveMovementMode => Current?.ActiveMovementMode ?? MovementMode.Ground;
|
||||
|
||||
public void SetMovementMode(MovementMode movementMode)
|
||||
{
|
||||
Current?.SetMovementMode(movementMode);
|
||||
}
|
||||
|
||||
public Vector3 GetItemSpawnPosition() => Current?.GetItemSpawnPosition() ?? Vector3.Zero;
|
||||
}
|
||||
@@ -1,53 +1,25 @@
|
||||
using System.Collections.Generic;
|
||||
using Game.Features.Items.Contracts;
|
||||
using Game.Features.Players.Contracts;
|
||||
using Game.Features.Players.Domain;
|
||||
using Game.Features.Players.Domain.Movement;
|
||||
using Game.Godot;
|
||||
using Game.Godot.Attributes;
|
||||
using Game.Godot.Extensions;
|
||||
using Game.Godot.Mappings;
|
||||
using Godot;
|
||||
|
||||
namespace Game.Features.Players.Godot;
|
||||
|
||||
public partial class PlayerController : Node3D, IPlayerController
|
||||
public partial class PlayerController : Node3D, IMovementModeSwitcher
|
||||
{
|
||||
[Export]
|
||||
public CharacterBody3D Body;
|
||||
[Export] public CharacterBody3D Body;
|
||||
[Export] public CollisionShape3D Collision;
|
||||
[Export] public Camera3D Camera;
|
||||
|
||||
[Export]
|
||||
public CollisionShape3D Collision;
|
||||
|
||||
[Export]
|
||||
public Camera3D Camera;
|
||||
|
||||
[Export]
|
||||
public Node3D LeftHandAnchor;
|
||||
|
||||
[Export]
|
||||
public Node3D RightHandAnchor;
|
||||
|
||||
[Export]
|
||||
public float MoveSpeed = 4.0f;
|
||||
|
||||
[Export]
|
||||
public float JumpVelocity = 4.5f;
|
||||
|
||||
[Export]
|
||||
public float NoclipFlySpeed = 8.0f;
|
||||
|
||||
[Export]
|
||||
public float MouseSensitivity = 0.003f;
|
||||
|
||||
[Export]
|
||||
public float MaxLookAngleDegrees = 89.0f;
|
||||
|
||||
[Export]
|
||||
[RequireInterface(typeof(IPlayerInteractionController))]
|
||||
private Node InteractionController;
|
||||
public IPlayerInteractionController Interaction =>
|
||||
(IPlayerInteractionController)InteractionController;
|
||||
[Export] public float MoveSpeed = 4.0f;
|
||||
[Export] public float JumpVelocity = 4.5f;
|
||||
[Export] public float NoclipFlySpeed = 8.0f;
|
||||
[Export] public float MouseSensitivity = 0.003f;
|
||||
[Export] public float MaxLookAngleDegrees = 89.0f;
|
||||
|
||||
private Dictionary<MovementMode, IMovementStrategy> _movementStrategies;
|
||||
private IMovementStrategy _currentMovementStrategy;
|
||||
@@ -62,38 +34,24 @@ public partial class PlayerController : Node3D, IPlayerController
|
||||
Collision.Disabled = movementMode == MovementMode.Noclip;
|
||||
}
|
||||
|
||||
public Vector3 GetItemSpawnPosition() => Interaction.GetItemSpawnPosition();
|
||||
|
||||
public override void _EnterTree()
|
||||
{
|
||||
SetMultiplayerAuthority(int.Parse(Name.ToString()));
|
||||
}
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
this.AssertRequiredExports();
|
||||
|
||||
_look = new PlayerLook(MouseSensitivity, MaxLookAngleDegrees);
|
||||
|
||||
|
||||
float gravity = (float)ProjectSettings.GetSetting(ProjectSetting.Gravity.SettingPath());
|
||||
_movementStrategies = new Dictionary<MovementMode, IMovementStrategy>
|
||||
{
|
||||
[MovementMode.Ground] = new GroundMovement(MoveSpeed, JumpVelocity, gravity),
|
||||
[MovementMode.Noclip] = new NoclipMovement(NoclipFlySpeed),
|
||||
[MovementMode.Noclip] = new NoclipMovement(NoclipFlySpeed)
|
||||
};
|
||||
_currentMovementStrategy = _movementStrategies[ActiveMovementMode];
|
||||
|
||||
bool isLocal = IsMultiplayerAuthority();
|
||||
Camera.Current = isLocal;
|
||||
SetPhysicsProcess(isLocal);
|
||||
SetProcessUnhandledInput(isLocal);
|
||||
Interaction.SetActive(isLocal);
|
||||
}
|
||||
|
||||
public override void _UnhandledInput(InputEvent @event)
|
||||
{
|
||||
if (@event is not InputEventMouseMotion mouseMotion || !MouseCapture.IsCaptured)
|
||||
return;
|
||||
if (@event is not InputEventMouseMotion mouseMotion || !MouseCapture.IsCaptured) return;
|
||||
|
||||
Body.RotateY(_look.CalculateYawDelta(mouseMotion.Relative.X));
|
||||
_look.UpdatePitch(mouseMotion.Relative.Y);
|
||||
|
||||
@@ -293,28 +293,14 @@ _surfaces = [{
|
||||
}]
|
||||
blend_shape_mode = 0
|
||||
|
||||
[sub_resource type="SceneReplicationConfig" id="SceneReplicationConfig_player_transform"]
|
||||
properties/0/path = NodePath(".:position")
|
||||
properties/0/spawn = true
|
||||
properties/0/replication_mode = 1
|
||||
properties/1/path = NodePath(".:rotation")
|
||||
properties/1/spawn = true
|
||||
properties/1/replication_mode = 1
|
||||
|
||||
[node name="Player" type="Node3D" unique_id=26823676 node_paths=PackedStringArray("Body", "Collision", "Camera", "LeftHandAnchor", "RightHandAnchor", "InteractionController")]
|
||||
[node name="Player" type="Node3D" unique_id=26823676 node_paths=PackedStringArray("Body", "Collision", "Camera")]
|
||||
script = ExtResource("1_gkqfc")
|
||||
Body = NodePath("CharacterBody3D")
|
||||
Collision = NodePath("CharacterBody3D/CollisionShape3D")
|
||||
Camera = NodePath("CharacterBody3D/Camera3D")
|
||||
LeftHandAnchor = NodePath("CharacterBody3D/Camera3D/LeftHandAnchor")
|
||||
RightHandAnchor = NodePath("CharacterBody3D/Camera3D/RightHandAnchor")
|
||||
InteractionController = NodePath("PlayerInteraction")
|
||||
|
||||
[node name="CharacterBody3D" type="CharacterBody3D" parent="." unique_id=1327672780]
|
||||
|
||||
[node name="MultiplayerSynchronizer" type="MultiplayerSynchronizer" parent="CharacterBody3D"]
|
||||
replication_config = SubResource("SceneReplicationConfig_player_transform")
|
||||
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="CharacterBody3D" unique_id=1006347064]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0)
|
||||
shape = SubResource("CapsuleShape3D_130ks")
|
||||
@@ -810,13 +796,15 @@ enabled = true
|
||||
target_position = Vector3(0, 0, -1)
|
||||
collide_with_areas = true
|
||||
|
||||
[node name="PlayerInteraction" type="Node" parent="." node_paths=PackedStringArray("Camera", "Body", "InteractionRaycaster", "DropRaycaster", "PromptController")]
|
||||
[node name="PlayerInteraction" type="Node" parent="." node_paths=PackedStringArray("Camera", "Body", "InteractionRaycaster", "DropRaycaster", "PromptController", "LeftHandAnchor", "RightHandAnchor")]
|
||||
script = ExtResource("3_interaction")
|
||||
Camera = NodePath("../CharacterBody3D/Camera3D")
|
||||
Body = NodePath("../CharacterBody3D")
|
||||
InteractionRaycaster = NodePath("../CharacterBody3D/Camera3D/InteractionRaycaster")
|
||||
DropRaycaster = NodePath("../CharacterBody3D/Camera3D/DropRaycaster")
|
||||
PromptController = NodePath("InteractionUi")
|
||||
LeftHandAnchor = NodePath("../CharacterBody3D/Camera3D/LeftHandAnchor")
|
||||
RightHandAnchor = NodePath("../CharacterBody3D/Camera3D/RightHandAnchor")
|
||||
|
||||
[node name="InteractionUi" type="CanvasLayer" parent="PlayerInteraction" node_paths=PackedStringArray("PromptLabel")]
|
||||
layer = 50
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
using System;
|
||||
using Game.Features.Multiplayer.Contracts;
|
||||
using Game.Godot.Extensions;
|
||||
using Godot;
|
||||
|
||||
namespace Game.Features.Players.Godot;
|
||||
|
||||
public partial class PlayerSpawnerController : MultiplayerSpawner
|
||||
{
|
||||
/// <summary>Container that spawned players are added to.</summary>
|
||||
[Export]
|
||||
public Node3D PlayersRoot;
|
||||
|
||||
/// <summary>Scene instantiated for each peer.</summary>
|
||||
[Export]
|
||||
public PackedScene PlayerScene;
|
||||
|
||||
public event Action<PlayerController> LocalPlayerSpawned;
|
||||
|
||||
private void Spawn(PeerId peerId)
|
||||
{
|
||||
if (!IsMultiplayerAuthority())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
PlayerController player = PlayerScene.Instantiate<PlayerController>();
|
||||
player.Name = peerId.ToString();
|
||||
PlayersRoot.AddChild(player);
|
||||
|
||||
if (peerId.Value == Multiplayer.GetUniqueId())
|
||||
{
|
||||
LocalPlayerSpawned?.Invoke(player);
|
||||
}
|
||||
}
|
||||
|
||||
private void Despawn(PeerId peerId)
|
||||
{
|
||||
if (!IsMultiplayerAuthority())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
PlayersRoot.GetNodeOrNull(peerId.ToString())?.QueueFree();
|
||||
}
|
||||
|
||||
public void UseMultiplayerPeer(IMultiplayerPeer multiplayer)
|
||||
{
|
||||
multiplayer.HostingStarted += Spawn;
|
||||
multiplayer.PeerJoined += Spawn;
|
||||
multiplayer.PeerLeft += Despawn;
|
||||
}
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
this.AssertRequiredExports();
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
uid://5j5el8n12uxm
|
||||
@@ -1,13 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace Game.Godot.Attributes;
|
||||
|
||||
/// <summary>
|
||||
/// Marks an [Export]ed Node field/property as required to implement the given interface.
|
||||
/// Validated by <see cref="Game.Godot.Extensions.NodeExportValidationExtensions.AssertRequiredExports"/>.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = true)]
|
||||
public sealed class RequireInterfaceAttribute(Type interfaceType) : Attribute
|
||||
{
|
||||
public Type InterfaceType { get; } = interfaceType;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
uid://g0fcf6gcpkcm
|
||||
@@ -0,0 +1,15 @@
|
||||
using System;
|
||||
|
||||
namespace Game.Godot.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when a controller is used before it has been initialized.
|
||||
/// </summary>
|
||||
public sealed class ControllerNotInitializedException(string nodeName)
|
||||
: Exception(BuildMessage(nodeName))
|
||||
{
|
||||
public string NodeName { get; } = nodeName;
|
||||
|
||||
private static string BuildMessage(string nodeName)
|
||||
=> $"{nodeName}: controller was used before it was initialized.";
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://dj8kxrwjoyfvi
|
||||
@@ -2,7 +2,6 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using Game.Godot.Attributes;
|
||||
using Game.Godot.Exceptions;
|
||||
using Godot;
|
||||
|
||||
@@ -45,33 +44,11 @@ public static class NodeExportValidationExtensions
|
||||
return missingFields.Concat(missingProperties).ToArray();
|
||||
}
|
||||
|
||||
private static string[] GetInterfaceMismatchWarnings(Node node)
|
||||
{
|
||||
Type nodeType = node.GetType();
|
||||
|
||||
IEnumerable<string> fieldWarnings = nodeType
|
||||
.GetFields(MemberFlags)
|
||||
.SelectMany(field => field.GetCustomAttributes<RequireInterfaceAttribute>()
|
||||
.Select(require => (field.Name, Require: require, Value: field.GetValue(node))))
|
||||
.Where(member => member.Value is not null && !member.Require.InterfaceType.IsInstanceOfType(member.Value))
|
||||
.Select(member => $"{member.Name} ({member.Value.GetType().Name}) does not implement {member.Require.InterfaceType.Name}.");
|
||||
|
||||
IEnumerable<string> propertyWarnings = nodeType
|
||||
.GetProperties(MemberFlags)
|
||||
.SelectMany(property => property.GetCustomAttributes<RequireInterfaceAttribute>()
|
||||
.Select(require => (property.Name, Require: require, Value: property.GetValue(node))))
|
||||
.Where(member => member.Value is not null && !member.Require.InterfaceType.IsInstanceOfType(member.Value))
|
||||
.Select(member => $"{member.Name} ({member.Value.GetType().Name}) does not implement {member.Require.InterfaceType.Name}.");
|
||||
|
||||
return fieldWarnings.Concat(propertyWarnings).ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Throws if any [Export]ed Node field on this node is unassigned, or assigned to a Node
|
||||
/// that does not implement the interface declared via [RequireInterface].
|
||||
/// Throws if any [Export]ed Node field on this node is unassigned.
|
||||
/// </summary>
|
||||
/// <param name="node">Node to check.</param>
|
||||
/// <exception cref="MissingRequiredExportException">One or more exports are missing or invalid.</exception>
|
||||
/// <exception cref="MissingRequiredExportException">One or more exports are missing.</exception>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// public override void _Ready()
|
||||
@@ -83,10 +60,7 @@ public static class NodeExportValidationExtensions
|
||||
/// </example>
|
||||
public static void AssertRequiredExports(this Node node)
|
||||
{
|
||||
List<string> missing = GetMissingExportWarnings(node)
|
||||
.Concat(GetInterfaceMismatchWarnings(node))
|
||||
.ToList();
|
||||
|
||||
List<string> missing = GetMissingExportWarnings(node).ToList();
|
||||
if (missing.Count == 0)
|
||||
{
|
||||
return;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://csng1pjcgsh80
|
||||
+32
-29
@@ -4,10 +4,11 @@ using Game.Features.Console.Godot;
|
||||
using Game.Features.Console.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.Contracts;
|
||||
using Game.Features.Items.Domain;
|
||||
using Game.Features.Items.Godot;
|
||||
using Game.Features.Items.Infrastructure;
|
||||
using Game.Features.Logging.Godot;
|
||||
using Game.Features.Logging.Infrastructure;
|
||||
using Game.Features.Multiplayer.Godot;
|
||||
@@ -25,24 +26,29 @@ public partial class GameBootstrap : Node
|
||||
public ConsoleController Console;
|
||||
|
||||
[Export]
|
||||
public PlayerSpawnerController PlayerSpawner;
|
||||
public PlayerController Player;
|
||||
|
||||
[Export]
|
||||
public ItemWorldController ItemWorld;
|
||||
public PlayerInteractionController PlayerInteraction;
|
||||
|
||||
[Export]
|
||||
public ItemControllerRegistryController ItemWorld;
|
||||
|
||||
[Export]
|
||||
public ItemSceneRegistryController ItemScenes;
|
||||
|
||||
[Export]
|
||||
public ItemSpawnerController ItemSpawner;
|
||||
|
||||
[Export]
|
||||
public InteractionWorldController InteractionWorld;
|
||||
public InteractionsRegistryController InteractionWorld;
|
||||
|
||||
[Export]
|
||||
public MultiplayerController MultiplayerController;
|
||||
|
||||
private readonly LocalPlayerController _localPlayerController = new();
|
||||
|
||||
private FileLogger _fileLogger;
|
||||
private CompositeLogger _logger;
|
||||
private ItemDefinitionRegistry _itemDefinitions;
|
||||
|
||||
private void InitializeLogging()
|
||||
{
|
||||
@@ -60,8 +66,8 @@ public partial class GameBootstrap : Node
|
||||
registry.Register(new HelpCommand(registry));
|
||||
registry.Register(new EchoCommand());
|
||||
registry.Register(new ClearCommand());
|
||||
registry.Register(new NoclipCommand(_localPlayerController));
|
||||
registry.Register(new SpawnItemCommand(ItemSpawner, _localPlayerController));
|
||||
registry.Register(new NoclipCommand(Player));
|
||||
registry.Register(new SpawnItemCommand(ItemSpawner, _itemDefinitions, PlayerInteraction));
|
||||
registry.Register(new HostCommand(MultiplayerController));
|
||||
registry.Register(new JoinCommand(MultiplayerController));
|
||||
|
||||
@@ -79,37 +85,34 @@ public partial class GameBootstrap : Node
|
||||
_logger.Warning("Multiplayer", "Server disconnected.");
|
||||
MultiplayerController.PeerJoined += id => _logger.Info("Multiplayer", $"Peer {id} joined.");
|
||||
MultiplayerController.PeerLeft += id => _logger.Info("Multiplayer", $"Peer {id} left.");
|
||||
|
||||
PlayerSpawner.UseMultiplayerPeer(MultiplayerController);
|
||||
PlayerSpawner.LocalPlayerSpawned += OnLocalPlayerSpawned;
|
||||
}
|
||||
|
||||
private void InitializeItems()
|
||||
{
|
||||
ItemInstanceRegistry itemInstanceRegistry = new();
|
||||
ItemWorld.UseRegistry(itemInstanceRegistry);
|
||||
ItemWorld.RegisterSceneItems();
|
||||
ItemSpawner.Initialize();
|
||||
|
||||
_itemDefinitions = new ItemDefinitionRegistry();
|
||||
ItemScenes.UseRegistry(_itemDefinitions);
|
||||
ItemScenes.Initialize();
|
||||
|
||||
InteractionRegistry interactionRegistry = new();
|
||||
InteractionWorld.UseRegistry(interactionRegistry);
|
||||
InteractionWorld.RegisterSceneTargets();
|
||||
}
|
||||
|
||||
private void OnLocalPlayerSpawned(PlayerController player)
|
||||
{
|
||||
_localPlayerController.SetCurrent(player);
|
||||
|
||||
IPlayerInteractionController interaction = player.Interaction;
|
||||
|
||||
PlayerHeldItemPresenter presenter = new(
|
||||
HeldItemPresenter presenter = new(
|
||||
ItemWorld,
|
||||
player.LeftHandAnchor,
|
||||
player.RightHandAnchor
|
||||
PlayerInteraction.LeftHandAnchor,
|
||||
PlayerInteraction.RightHandAnchor
|
||||
);
|
||||
ItemInteractionService itemInteractions = new(new HandsInventory(), ItemWorld, presenter);
|
||||
InteractionService interactions = new(InteractionWorld);
|
||||
|
||||
interaction.InitializeController(
|
||||
new PlayerId(Guid.NewGuid()),
|
||||
itemInteractions,
|
||||
interactions
|
||||
ItemInteractionService itemInteractions = new(
|
||||
new HandsInventory(),
|
||||
itemInstanceRegistry,
|
||||
presenter
|
||||
);
|
||||
Interact interactions = new(interactionRegistry);
|
||||
PlayerInteraction.Initialize(new PlayerId(Guid.NewGuid()), itemInteractions, interactions);
|
||||
}
|
||||
|
||||
public override void _Ready()
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
[ext_resource type="Script" uid="uid://drctu5y21ey2y" path="res://src/Godot/GameBootstrap.cs" id="3_bootstrap"]
|
||||
[ext_resource type="PackedScene" uid="uid://bk0771dvonxh8" path="res://src/Features/Console/Godot/ConsoleController.tscn" id="4_console"]
|
||||
[ext_resource type="Script" uid="uid://drqx0wiw0sbj1" path="res://src/Features/Multiplayer/Godot/MultiplayerController.cs" id="5_multiplayer"]
|
||||
[ext_resource type="Script" uid="uid://cd87p40r0xwiu" path="res://src/Features/Items/Godot/ItemWorldController.cs" id="6_items"]
|
||||
[ext_resource type="Script" uid="uid://f8n3b6w1k5r9m" path="res://src/Features/Items/Godot/ItemControllerRegistryController.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://c3yam6w2q8r1b" path="res://src/Features/Items/Godot/ItemSpawnerController.cs" id="8_spawner"]
|
||||
[ext_resource type="Script" uid="uid://ccc5q7pvd01bv" path="res://src/Features/Interactions/Godot/InteractionWorldController.cs" id="9_interactions"]
|
||||
[ext_resource type="Script" uid="uid://5j5el8n12uxm" path="res://src/Features/Players/Godot/PlayerSpawnerController.cs" id="10_player_spawner"]
|
||||
[ext_resource type="Script" uid="uid://c3x9m6w2q8r1b" path="res://src/Features/Items/Godot/ItemSpawnerController.cs" id="8_spawner"]
|
||||
[ext_resource type="Script" path="res://src/Features/Items/Godot/ItemSceneRegistryController.cs" id="8b_catalog"]
|
||||
[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")
|
||||
@@ -23,11 +23,13 @@ background_mode = 2
|
||||
sky = SubResource("Sky_1kpcd")
|
||||
glow_enabled = true
|
||||
|
||||
[node name="GameWorld" type="Node" unique_id=222644234 node_paths=PackedStringArray("Console", "PlayerSpawner", "ItemWorld", "ItemSpawner", "InteractionWorld", "MultiplayerController")]
|
||||
[node name="GameWorld" type="Node" unique_id=222644234 node_paths=PackedStringArray("Console", "Player", "PlayerInteraction", "ItemWorld", "ItemScenes", "ItemSpawner", "InteractionWorld", "MultiplayerController")]
|
||||
script = ExtResource("3_bootstrap")
|
||||
Console = NodePath("Console")
|
||||
PlayerSpawner = NodePath("PlayerSpawner")
|
||||
Player = NodePath("Player")
|
||||
PlayerInteraction = NodePath("Player/PlayerInteraction")
|
||||
ItemWorld = NodePath("ItemWorld")
|
||||
ItemScenes = NodePath("ItemScenesRegistry")
|
||||
ItemSpawner = NodePath("ItemSpawner")
|
||||
InteractionWorld = NodePath("InteractionWorld")
|
||||
MultiplayerController = NodePath("Multiplayer")
|
||||
@@ -39,31 +41,29 @@ shadow_enabled = true
|
||||
[node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=847581298]
|
||||
environment = SubResource("Environment_mvtit")
|
||||
|
||||
[node name="PlayersRoot" type="Node3D" parent="." unique_id=26823676]
|
||||
[node name="Player" parent="." unique_id=26823676 instance=ExtResource("2_mvtit")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0)
|
||||
|
||||
[node name="PlayerSpawner" type="MultiplayerSpawner" parent="." unique_id=1573506152 node_paths=PackedStringArray("PlayersRoot")]
|
||||
_spawnable_scenes = PackedStringArray("uid://bka1dmgdi5btd")
|
||||
spawn_path = NodePath("../PlayersRoot")
|
||||
script = ExtResource("10_player_spawner")
|
||||
PlayersRoot = NodePath("../PlayersRoot")
|
||||
PlayerScene = ExtResource("2_mvtit")
|
||||
|
||||
[node name="Console" parent="." unique_id=1947043894 instance=ExtResource("4_console")]
|
||||
|
||||
[node name="Multiplayer" type="Node" parent="." unique_id=1209131013]
|
||||
script = ExtResource("5_multiplayer")
|
||||
|
||||
[node name="ItemWorld" type="Node" parent="." unique_id=1821063866]
|
||||
[node name="ItemWorld" type="Node" parent="."]
|
||||
script = ExtResource("6_items")
|
||||
|
||||
[node name="ItemSpawner" type="Node" parent="." unique_id=483552217 node_paths=PackedStringArray("ItemsRoot", "ItemWorld")]
|
||||
script = ExtResource("8_spawner")
|
||||
ItemsRoot = NodePath("../Entities")
|
||||
[node name="ItemScenesRegistry" type="Node" parent="." node_paths=PackedStringArray("ItemWorld")]
|
||||
script = ExtResource("8b_catalog")
|
||||
ItemWorld = NodePath("../ItemWorld")
|
||||
ItemScenes = Array[PackedScene]([ExtResource("7_flashlight")])
|
||||
|
||||
[node name="InteractionWorld" type="Node" parent="." unique_id=556371192]
|
||||
[node name="ItemSpawner" type="Node" parent="." node_paths=PackedStringArray("ItemsRoot", "ItemWorld", "Scenes")]
|
||||
script = ExtResource("8_spawner")
|
||||
ItemsRoot = NodePath("../Entities")
|
||||
ItemWorld = NodePath("../ItemWorld")
|
||||
Scenes = NodePath("../ItemScenesRegistry")
|
||||
|
||||
[node name="InteractionWorld" type="Node" parent="."]
|
||||
script = ExtResource("9_interactions")
|
||||
|
||||
[node name="Entities" type="Node3D" parent="." unique_id=851492709]
|
||||
@@ -94,10 +94,8 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -4, 0, -4)
|
||||
[node name="floor_ceiling_hr_50" parent="Entities" unique_id=1804933431 instance=ExtResource("2_1kpcd")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -4, 0, 0)
|
||||
|
||||
[node name="FlashlightLeft" parent="Entities" unique_id=2018294910 instance=ExtResource("7_flashlight")]
|
||||
[node name="FlashlightLeft" parent="Entities" instance=ExtResource("7_flashlight")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.25, 0.65, -0.7)
|
||||
InstanceId = "c08790de-b2de-44e6-a73c-a7502ca27e34"
|
||||
|
||||
[node name="FlashlightRight" parent="Entities" unique_id=2042120202 instance=ExtResource("7_flashlight")]
|
||||
[node name="FlashlightRight" parent="Entities" instance=ExtResource("7_flashlight")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.25, 0.65, -0.7)
|
||||
InstanceId = "f71e67cb-786b-49eb-bd56-cb42ad19a754"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://cpikg7xirrc1g
|
||||
Reference in New Issue
Block a user