105 changed files with 1815 additions and 29 deletions
+2
View File
@@ -3,5 +3,7 @@
<TargetFramework>net8.0</TargetFramework>
<EnableDynamicLoading>true</EnableDynamicLoading>
<RootNamespace>Game</RootNamespace>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup>
</Project>
+20
View File
@@ -66,6 +66,26 @@ move_down={
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194326,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
interact={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null)
]
}
use_left_hand={
"deadzone": 0.2,
"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":1,"canceled":false,"pressed":false,"double_click":false,"script":null)
]
}
use_right_hand={
"deadzone": 0.2,
"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":2,"canceled":false,"pressed":false,"double_click":false,"script":null)
]
}
drop_modifier={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":71,"key_label":0,"unicode":103,"location":0,"echo":false,"script":null)
]
}
[physics]
@@ -0,0 +1,44 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Game.Features.Console.Contracts;
using Game.Features.Items.Contracts;
namespace Game.Features.Console.Application.Commands;
public sealed class SpawnItemCommand : ICommand
{
private readonly IItemSpawner _spawner;
private readonly IItemSpawnPoint _spawnPoint;
public string Name => "spawn_item";
public string Description => "Spawns an item in front of the player.";
public IReadOnlyList<CommandArgSpec> Args { get; }
public SpawnItemCommand(IItemSpawner spawner, IItemSpawnPoint spawnPoint)
{
_spawner = spawner;
_spawnPoint = spawnPoint;
string[] definitions = spawner.DefinitionIds
.Select(id => id.Value)
.OrderBy(id => id, StringComparer.Ordinal)
.ToArray();
Args = new CommandArgsBuilder().AddChoiceArg("item", definitions).Build();
}
public CommandResult Execute(CommandArgValues args)
{
ItemDefinitionId definitionId = new(args.GetString(0));
ItemActionResult result = _spawner.TrySpawn(
definitionId,
_spawnPoint.GetItemSpawnPosition(),
out ItemInstanceId itemId
);
return result.Success
? CommandResult.Ok($"Spawned {definitionId} ({itemId}).")
: CommandResult.Fail(result.Message);
}
}
@@ -0,0 +1 @@
uid://e8m3q6v1r9k4w
@@ -0,0 +1,8 @@
using Game.Features.Interactions.Contracts;
namespace Game.Features.Interactions.Application;
public sealed class InteractionService(IInteractionWorld world) : IInteractionService
{
public InteractionResult TryInteract(InteractionRequest request) => world.TryInteract(request);
}
@@ -0,0 +1 @@
uid://b1m7q4y9c5v2n
@@ -0,0 +1,6 @@
namespace Game.Features.Interactions.Contracts;
public interface IInteractionService
{
InteractionResult TryInteract(InteractionRequest request);
}
@@ -0,0 +1 @@
uid://a3n8v5r1q7m9c
@@ -0,0 +1,8 @@
namespace Game.Features.Interactions.Contracts;
public interface IInteractionTarget
{
InteractionTargetId Id { get; }
bool CanInteract { get; }
InteractionPrompt Prompt { get; }
}
@@ -0,0 +1 @@
uid://c7p2y5n8f1v6m
@@ -0,0 +1,6 @@
namespace Game.Features.Interactions.Contracts;
public interface IInteractionWorld
{
InteractionResult TryInteract(InteractionRequest request);
}
@@ -0,0 +1 @@
uid://d7k1x6m4v9q2b
@@ -0,0 +1,16 @@
using Game.Features.Interactions.Contracts;
using Game.Features.Players.Contracts;
using Godot;
namespace Game.Features.Items.Contracts;
public interface IPlayerInteractionController : IItemSpawnPoint
{
void SetActive(bool isActive);
void InitializeController(
PlayerId playerId,
IItemInteractionService items,
IInteractionService interactions
);
}
@@ -0,0 +1 @@
uid://cvfdyxpc3xkfd
@@ -0,0 +1,3 @@
namespace Game.Features.Interactions.Contracts;
public readonly record struct InteractionPrompt(string Text);
@@ -0,0 +1 @@
uid://b3m8r1x6d9w4k
@@ -0,0 +1,5 @@
using Game.Features.Players.Contracts;
namespace Game.Features.Interactions.Contracts;
public readonly record struct InteractionRequest(PlayerId PlayerId, InteractionTargetId TargetId);
@@ -0,0 +1 @@
uid://e2q7k5n1w8m4d
@@ -0,0 +1,10 @@
namespace Game.Features.Interactions.Contracts;
public readonly record struct InteractionResult(InteractionStatus Status, string Message = "")
{
public bool Success => Status == InteractionStatus.Success;
public static InteractionResult Ok() => new(InteractionStatus.Success);
public static InteractionResult Fail(InteractionStatus status, string message) => new(status, message);
}
@@ -0,0 +1 @@
uid://f9w2d7k4m6q1x
@@ -0,0 +1,9 @@
namespace Game.Features.Interactions.Contracts;
public enum InteractionStatus
{
Success,
TargetNotFound,
Unavailable,
Rejected
}
@@ -0,0 +1 @@
uid://b4y9m3r6v1c8p
@@ -0,0 +1,8 @@
using System;
namespace Game.Features.Interactions.Contracts;
public readonly record struct InteractionTargetId(Guid Value)
{
public override string ToString() => Value.ToString();
}
@@ -0,0 +1 @@
uid://c6m1v8q4r9x2k
@@ -0,0 +1,27 @@
using Game.Godot.Extensions;
using Godot;
namespace Game.Features.Interactions.Godot;
public partial class InteractionPromptController : CanvasLayer
{
[Export] public Label PromptLabel;
public override void _Ready()
{
this.AssertRequiredExports();
Clear();
}
public void ShowPrompt(string text)
{
PromptLabel.Text = text;
PromptLabel.Visible = text.Length > 0;
}
public void Clear()
{
PromptLabel.Text = string.Empty;
PromptLabel.Visible = false;
}
}
@@ -0,0 +1 @@
uid://b9r2w6n4f8k1v
@@ -0,0 +1,26 @@
using Godot;
namespace Game.Features.Interactions.Godot;
public partial class InteractionRaycaster : RayCast3D
{
public T GetTarget<T>() where T : class
{
if (!IsColliding() || GetCollider() is not Node node)
{
return null;
}
while (node is not null)
{
if (node is T target)
{
return target;
}
node = node.GetParent();
}
return null;
}
}
@@ -0,0 +1 @@
uid://e4m7k1x8c3q6h
@@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
using Game.Features.Interactions.Contracts;
using Godot;
namespace Game.Features.Interactions.Godot;
public partial class InteractionWorldController : Node, IInteractionWorld
{
public const string RegistryGroup = "interaction_world_registry";
private readonly Dictionary<InteractionTargetId, WorldInteractionController> _targets = [];
public override void _EnterTree()
{
AddToGroup(RegistryGroup);
}
public void RegisterSceneTargets()
{
foreach (Node node in GetTree().GetNodesInGroup(WorldInteractionController.InteractionGroup))
{
if (node is WorldInteractionController target)
{
Register(target);
}
}
}
public void Register(WorldInteractionController target)
{
if (_targets.TryGetValue(target.Id, out WorldInteractionController existing))
{
if (ReferenceEquals(existing, target))
{
return;
}
throw new InvalidOperationException($"Duplicate interaction target id: {target.Id}.");
}
_targets.Add(target.Id, target);
target.RegisterWith(this);
}
public void Unregister(InteractionTargetId targetId)
{
_targets.Remove(targetId);
}
public InteractionResult TryInteract(InteractionRequest request)
{
if (!_targets.TryGetValue(request.TargetId, out WorldInteractionController target))
{
return InteractionResult.Fail(InteractionStatus.TargetNotFound, $"Target {request.TargetId} was not found.");
}
if (!target.CanInteract)
{
return InteractionResult.Fail(InteractionStatus.Unavailable, "The target is not currently available.");
}
return target.Execute(request.PlayerId);
}
}
@@ -0,0 +1 @@
uid://f6q1c9m4w7v2n
@@ -0,0 +1,248 @@
using System.Collections.Generic;
using Game.Features.Interactions.Contracts;
using Game.Features.Items.Contracts;
using Game.Features.Players.Contracts;
using Game.Godot;
using Game.Godot.Extensions;
using Game.Godot.Mappings;
using Godot;
namespace Game.Features.Interactions.Godot;
public partial class PlayerInteractionController : Node, IPlayerInteractionController
{
[Export]
public Camera3D Camera;
[Export]
public CharacterBody3D Body;
[Export]
public InteractionRaycaster InteractionRaycaster;
[Export]
public RayCast3D DropRaycaster;
[Export]
public InteractionPromptController PromptController;
private IItemInteractionService _items;
private IInteractionService _interactions;
private PlayerId _playerId;
private readonly HashSet<Hand> _dropActions = [];
private readonly Dictionary<Hand, InteractionTargetId?> _activeUseTargets = [];
public override void _Ready()
{
this.AssertRequiredExports();
}
public void SetActive(bool isActive)
{
SetProcess(isActive);
SetProcessUnhandledInput(isActive);
}
public void InitializeController(
PlayerId playerId,
IItemInteractionService items,
IInteractionService interactions
)
{
_playerId = playerId;
_items = items;
_interactions = interactions;
}
public override void _Process(double delta)
{
if (
_items is null
|| _interactions is null
|| GamePause.IsPaused
|| !MouseCapture.IsCaptured
)
{
PromptController.Clear();
return;
}
UpdatePrompt();
}
public override void _UnhandledInput(InputEvent @event)
{
if (
_items is null
|| _interactions is null
|| GamePause.IsPaused
|| !MouseCapture.IsCaptured
)
{
return;
}
if (@event.IsActionPressed(InputAction.UseLeftHand.ActionName()))
{
ProcessHandActionStarted(Hand.Left);
GetViewport().SetInputAsHandled();
return;
}
if (@event.IsActionReleased(InputAction.UseLeftHand.ActionName()))
{
ProcessHandActionCompleted(Hand.Left);
GetViewport().SetInputAsHandled();
return;
}
if (@event.IsActionPressed(InputAction.UseRightHand.ActionName()))
{
ProcessHandActionStarted(Hand.Right);
GetViewport().SetInputAsHandled();
return;
}
if (@event.IsActionReleased(InputAction.UseRightHand.ActionName()))
{
ProcessHandActionCompleted(Hand.Right);
GetViewport().SetInputAsHandled();
return;
}
if (@event.IsActionPressed(InputAction.Interact.ActionName()))
{
IInteractionTarget target = InteractionRaycaster.GetTarget<IInteractionTarget>();
if (target?.CanInteract == true)
{
_interactions.TryInteract(new InteractionRequest(_playerId, target.Id));
}
GetViewport().SetInputAsHandled();
}
}
public Vector3 GetItemSpawnPosition() => CalculateDropPosition();
private void ProcessHandActionStarted(Hand hand)
{
if (Input.IsActionPressed(InputAction.DropModifier.ActionName()))
{
if (_activeUseTargets.Remove(hand, out InteractionTargetId? activeTargetId))
{
_items.TryUse(
new ItemUseRequest(_playerId, hand, ItemActionPhase.Cancelled, activeTargetId)
);
}
_items.TryDrop(hand, CalculateDropPosition());
_dropActions.Add(hand);
return;
}
if (!_items.IsHandFree(hand))
{
ItemUseRequest request = CreateUseRequest(hand, ItemActionPhase.Started);
ItemActionResult result = _items.TryUse(request);
if (result.Success)
{
_activeUseTargets[hand] = request.TargetId;
}
return;
}
IPickupTarget target = InteractionRaycaster.GetTarget<IPickupTarget>();
if (target is not null)
{
_items.TryPickup(target.ItemInfo.Id, hand);
}
}
private void ProcessHandActionCompleted(Hand hand)
{
if (_dropActions.Remove(hand))
{
return;
}
if (_activeUseTargets.Remove(hand, out InteractionTargetId? targetId))
{
_items.TryUse(new ItemUseRequest(_playerId, hand, ItemActionPhase.Completed, targetId));
}
}
private ItemUseRequest CreateUseRequest(Hand hand, ItemActionPhase phase)
{
InteractionTargetId? targetId = InteractionRaycaster.GetTarget<IInteractionTarget>()?.Id;
return new ItemUseRequest(_playerId, hand, phase, targetId);
}
private Vector3 CalculateDropPosition()
{
DropRaycaster.ForceRaycastUpdate();
Vector3 origin = Camera.GlobalPosition;
Vector3 direction = -Camera.GlobalBasis.Z.Normalized();
float distance = DropRaycaster.TargetPosition.Length();
if (DropRaycaster.IsColliding())
{
distance = Mathf.Max(
0.25f,
origin.DistanceTo(DropRaycaster.GetCollisionPoint()) - 0.2f
);
}
return origin + direction * distance;
}
private void UpdatePrompt()
{
IPickupTarget pickupTarget = InteractionRaycaster.GetTarget<IPickupTarget>();
if (pickupTarget is not null)
{
ShowPickupPrompt(pickupTarget.ItemInfo);
return;
}
IInteractionTarget interactionTarget = InteractionRaycaster.GetTarget<IInteractionTarget>();
if (interactionTarget?.CanInteract == true)
{
PromptController.ShowPrompt($"[E] {interactionTarget.Prompt.Text}");
return;
}
PromptController.Clear();
}
private void ShowPickupPrompt(ItemInfo itemInfo)
{
if (itemInfo.HandRequirement == HandRequirement.Both)
{
bool bothHandsFree = _items.IsHandFree(Hand.Left) && _items.IsHandFree(Hand.Right);
PromptController.ShowPrompt(
bothHandsFree
? $"[LPM lub PPM] Podnieś {itemInfo.DisplayName} — obie ręce"
: "Potrzebujesz obu wolnych rąk"
);
return;
}
List<string> actions = [];
if (_items.IsHandFree(Hand.Left))
{
actions.Add($"[LPM] Podnieś {itemInfo.DisplayName} — lewa ręka");
}
if (_items.IsHandFree(Hand.Right))
{
actions.Add($"[PPM] Podnieś {itemInfo.DisplayName} — prawa ręka");
}
PromptController.ShowPrompt(
actions.Count > 0 ? string.Join("\n", actions) : "Brak wolnej ręki"
);
}
}
@@ -0,0 +1 @@
uid://c2v8m5r1y7q4d
@@ -0,0 +1,52 @@
using System;
using Game.Features.Interactions.Contracts;
using Game.Features.Players.Contracts;
using Godot;
namespace Game.Features.Interactions.Godot;
public abstract partial class WorldInteractionController : Node3D, IInteractionTarget
{
public const string InteractionGroup = "world_interactions";
[Export] public string InstanceId;
[Export] public string PromptText = "Wejdź w interakcję";
public InteractionTargetId Id { get; private set; }
public virtual bool CanInteract => true;
public InteractionPrompt Prompt => new(PromptText);
private InteractionWorldController _registry;
public override void _Ready()
{
Guid id = string.IsNullOrWhiteSpace(InstanceId) ? Guid.NewGuid() : Guid.Parse(InstanceId);
Id = new InteractionTargetId(id);
AddToGroup(InteractionGroup);
RegisterWithAvailableWorld();
}
public override void _ExitTree()
{
_registry?.Unregister(Id);
}
public void RegisterWith(InteractionWorldController registry)
{
_registry = registry;
}
public abstract InteractionResult Execute(PlayerId playerId);
private void RegisterWithAvailableWorld()
{
foreach (Node node in GetTree().GetNodesInGroup(InteractionWorldController.RegistryGroup))
{
if (node is InteractionWorldController registry)
{
registry.Register(this);
return;
}
}
}
}
@@ -0,0 +1 @@
uid://d2r8m5v1q9k4x
@@ -0,0 +1,83 @@
using Game.Features.Items.Contracts;
using Game.Features.Items.Domain;
using Godot;
namespace Game.Features.Items.Application;
public sealed class ItemInteractionService(
HandsInventory hands,
IItemRepository itemRepository,
IHeldItemPresenter presenter
) : IItemInteractionService
{
public bool IsHandFree(Hand hand) => hands.IsFree(hand);
public ItemInstanceId? GetHeldItem(Hand hand) => hands.Get(hand);
public ItemActionResult TryPickup(ItemInstanceId itemId, Hand hand)
{
if (!itemRepository.TryGet(itemId, out IItemInstance item))
{
return ItemActionResult.Fail(ItemActionStatus.ItemNotFound, $"Item {itemId} was not found.");
}
if (!hands.TryHold(itemId, item.Info.HandRequirement, hand))
{
return ItemActionResult.Fail(ItemActionStatus.HandOccupied, "The requested hand is not available.");
}
if (presenter.TryShowHeld(item, hand))
{
return ItemActionResult.Ok();
}
hands.Release(hand);
return ItemActionResult.Fail(ItemActionStatus.PresentationFailed, "Could not present the held item.");
}
public ItemActionResult TryUse(ItemUseRequest request)
{
ItemInstanceId? itemId = hands.Get(request.Hand);
if (itemId is null)
{
return ItemActionResult.Fail(ItemActionStatus.HandEmpty, "The requested hand is empty.");
}
if (!itemRepository.TryGet(itemId.Value, out IItemInstance item))
{
return ItemActionResult.Fail(ItemActionStatus.ItemNotFound, $"Item {itemId} was not found.");
}
ItemActionResult result = item.Use(request);
if (result.Success)
{
presenter.Refresh(item);
}
return result;
}
public ItemActionResult TryDrop(Hand hand, Vector3 worldPosition)
{
ItemInstanceId? itemId = hands.Get(hand);
if (itemId is null)
{
return ItemActionResult.Fail(ItemActionStatus.HandEmpty, "The requested hand is empty.");
}
if (!itemRepository.TryGet(itemId.Value, out IItemInstance item))
{
return ItemActionResult.Fail(ItemActionStatus.ItemNotFound, $"Item {itemId} was not found.");
}
hands.Release(hand);
if (presenter.TryShowDropped(item, worldPosition))
{
return ItemActionResult.Ok();
}
hands.TryHold(itemId.Value, item.Info.HandRequirement, hand);
return ItemActionResult.Fail(ItemActionStatus.PresentationFailed, "Could not present the dropped item.");
}
}
@@ -0,0 +1 @@
uid://e6q2h9v5b8k3p
+7
View File
@@ -0,0 +1,7 @@
namespace Game.Features.Items.Contracts;
public enum Hand
{
Left,
Right
}
+1
View File
@@ -0,0 +1 @@
uid://c4n7h2w8m5q1p
@@ -0,0 +1,7 @@
namespace Game.Features.Items.Contracts;
public enum HandRequirement
{
One,
Both
}
@@ -0,0 +1 @@
uid://d8p3k6v1r9y4b
@@ -0,0 +1,10 @@
using Godot;
namespace Game.Features.Items.Contracts;
public interface IHeldItemPresenter
{
bool TryShowHeld(IItemInstance item, Hand hand);
bool TryShowDropped(IItemInstance item, Vector3 worldPosition);
void Refresh(IItemInstance item);
}
@@ -0,0 +1 @@
uid://c1q7y4d9m5x2v
@@ -0,0 +1,7 @@
namespace Game.Features.Items.Contracts;
public interface IItemInstance
{
ItemInfo Info { get; }
ItemActionResult Use(ItemUseRequest request);
}
@@ -0,0 +1 @@
uid://b5x9q3h7m2w8f
@@ -0,0 +1,12 @@
using Godot;
namespace Game.Features.Items.Contracts;
public interface IItemInteractionService
{
bool IsHandFree(Hand hand);
ItemInstanceId? GetHeldItem(Hand hand);
ItemActionResult TryPickup(ItemInstanceId itemId, Hand hand);
ItemActionResult TryUse(ItemUseRequest request);
ItemActionResult TryDrop(Hand hand, Vector3 worldPosition);
}
@@ -0,0 +1 @@
uid://f2k8q5b1w7m4n
@@ -0,0 +1,6 @@
namespace Game.Features.Items.Contracts;
public interface IItemRepository
{
bool TryGet(ItemInstanceId itemId, out IItemInstance item);
}
@@ -0,0 +1 @@
uid://e7n1m6v4k9r3q
@@ -0,0 +1,8 @@
using Godot;
namespace Game.Features.Items.Contracts;
public interface IItemSpawnPoint
{
Vector3 GetItemSpawnPosition();
}
@@ -0,0 +1 @@
uid://a2v6r8m3q7k5d
@@ -0,0 +1,10 @@
using System.Collections.Generic;
using Godot;
namespace Game.Features.Items.Contracts;
public interface IItemSpawner
{
IReadOnlyCollection<ItemDefinitionId> DefinitionIds { get; }
ItemActionResult TrySpawn(ItemDefinitionId definitionId, Vector3 worldPosition, out ItemInstanceId itemId);
}
@@ -0,0 +1 @@
uid://f4m9k2w6b8n1r
@@ -0,0 +1,6 @@
namespace Game.Features.Items.Contracts;
public interface IPickupTarget
{
ItemInfo ItemInfo { get; }
}
@@ -0,0 +1 @@
uid://c9v4g7p2m5x8d
@@ -0,0 +1,8 @@
namespace Game.Features.Items.Contracts;
public enum ItemActionPhase
{
Started,
Completed,
Cancelled
}
@@ -0,0 +1 @@
uid://c9w5d2n8q4m1h
@@ -0,0 +1,10 @@
namespace Game.Features.Items.Contracts;
public readonly record struct ItemActionResult(ItemActionStatus Status, string Message = "")
{
public bool Success => Status == ItemActionStatus.Success;
public static ItemActionResult Ok() => new(ItemActionStatus.Success);
public static ItemActionResult Fail(ItemActionStatus status, string message) => new(status, message);
}
@@ -0,0 +1 @@
uid://a8m4p1x7d3q6k
@@ -0,0 +1,12 @@
namespace Game.Features.Items.Contracts;
public enum ItemActionStatus
{
Success,
ItemNotFound,
HandOccupied,
HandEmpty,
UnsupportedAction,
PresentationFailed,
UnknownDefinition
}
@@ -0,0 +1 @@
uid://f1r6y3b9k5v2m
@@ -0,0 +1,7 @@
namespace Game.Features.Items.Contracts;
public readonly record struct ItemDefinition(
ItemDefinitionId Id,
string DisplayName,
HandRequirement HandRequirement
);
@@ -0,0 +1 @@
uid://e3v8k4r1m7x2p
@@ -0,0 +1,6 @@
namespace Game.Features.Items.Contracts;
public readonly record struct ItemDefinitionId(string Value)
{
public override string ToString() => Value;
}
@@ -0,0 +1 @@
uid://b7q2n9c5w1m6y
+8
View File
@@ -0,0 +1,8 @@
namespace Game.Features.Items.Contracts;
public readonly record struct ItemInfo(
ItemInstanceId Id,
ItemDefinitionId DefinitionId,
string DisplayName,
HandRequirement HandRequirement
);
@@ -0,0 +1 @@
uid://e1r5j8w3n6c9h
@@ -0,0 +1,8 @@
using System;
namespace Game.Features.Items.Contracts;
public readonly record struct ItemInstanceId(Guid Value)
{
public override string ToString() => Value.ToString();
}
@@ -0,0 +1 @@
uid://b6x2f9m4t7k3q
@@ -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
);
@@ -0,0 +1 @@
uid://d6k2v8m5r1c9n
@@ -0,0 +1,19 @@
using Game.Features.Items.Contracts;
namespace Game.Features.Items.Domain.Flashlight;
public sealed class FlashlightItemInstance(ItemInfo info) : IItemInstance
{
public ItemInfo Info { get; } = info;
public bool IsOn { get; private set; }
public ItemActionResult Use(ItemUseRequest request)
{
if (request.Phase == ItemActionPhase.Started)
{
IsOn = !IsOn;
}
return ItemActionResult.Ok();
}
}
@@ -0,0 +1 @@
uid://b8r2n5q9w4m1y
@@ -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);
}
}
}
@@ -0,0 +1 @@
uid://d5w1n8c4q7m2y
@@ -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."
);
}
@@ -0,0 +1 @@
uid://d9p4x1m7v3k6c
@@ -0,0 +1,26 @@
[gd_scene load_steps=3 format=3 uid="uid://dy6p2w9m4c8kr"]
[sub_resource type="StandardMaterial3D" id="Material_flashlight"]
albedo_color = Color(0.08, 0.09, 0.1, 1)
metallic = 0.65
roughness = 0.3
[sub_resource type="CylinderMesh" id="Mesh_flashlight"]
material = SubResource("Material_flashlight")
top_radius = 0.055
bottom_radius = 0.07
height = 0.32
radial_segments = 12
[node name="FlashlightHeldView" type="Node3D"]
[node name="MeshInstance3D" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, -4.371139e-08, -1, 0, 1, -4.371139e-08, 0, 0, -0.08)
mesh = SubResource("Mesh_flashlight")
[node name="SpotLight3D" type="SpotLight3D" parent="."]
visible = false
light_energy = 3.0
shadow_enabled = true
spot_range = 14.0
spot_angle = 24.0
@@ -0,0 +1,47 @@
[gd_scene load_steps=6 format=3 uid="uid://cv8m3q1y6r4nf"]
[ext_resource type="Script" uid="uid://a5q9y2d7m4v8p" path="res://src/Features/Items/Godot/Flashlight/FlashlightItemController.cs" id="1_script"]
[ext_resource type="PackedScene" uid="uid://dy6p2w9m4c8kr" path="res://src/Features/Items/Godot/Flashlight/FlashlightHeldView.tscn" id="2_held"]
[sub_resource type="StandardMaterial3D" id="Material_flashlight"]
albedo_color = Color(0.08, 0.09, 0.1, 1)
metallic = 0.65
roughness = 0.3
[sub_resource type="CylinderMesh" id="Mesh_flashlight"]
material = SubResource("Material_flashlight")
top_radius = 0.055
bottom_radius = 0.07
height = 0.32
radial_segments = 12
[sub_resource type="CapsuleShape3D" id="Shape_flashlight"]
radius = 0.07
height = 0.32
[node name="FlashlightItem" type="RigidBody3D" node_paths=PackedStringArray("Collision", "WorldVisual", "WorldLight")]
mass = 0.4
script = ExtResource("1_script")
DefinitionId = "flashlight"
DisplayName = "latarkę"
Collision = NodePath("CollisionShape3D")
WorldVisual = NodePath("WorldVisual")
HeldViewScene = ExtResource("2_held")
WorldLight = NodePath("WorldVisual/SpotLight3D")
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, -4.371139e-08, -1, 0, 1, -4.371139e-08, 0, 0, 0)
shape = SubResource("Shape_flashlight")
[node name="WorldVisual" type="Node3D" parent="."]
[node name="MeshInstance3D" type="MeshInstance3D" parent="WorldVisual"]
transform = Transform3D(1, 0, 0, 0, -4.371139e-08, -1, 0, 1, -4.371139e-08, 0, 0, 0)
mesh = SubResource("Mesh_flashlight")
[node name="SpotLight3D" type="SpotLight3D" parent="WorldVisual"]
visible = false
light_energy = 3.0
shadow_enabled = true
spot_range = 14.0
spot_angle = 24.0
@@ -0,0 +1,39 @@
using Game.Features.Items.Contracts;
using Game.Features.Items.Domain.Flashlight;
using Game.Features.Items.Godot;
using Godot;
namespace Game.Features.Items.Godot.Flashlight;
public partial class FlashlightItemController : WorldItemController
{
[Export] public SpotLight3D WorldLight;
private SpotLight3D _heldLight;
protected override IItemInstance CreateItemInstance(ItemInfo itemInfo) => new FlashlightItemInstance(itemInfo);
protected override void OnHeldViewCreated(Node3D heldView)
{
_heldLight = heldView.GetNodeOrNull<SpotLight3D>("SpotLight3D");
}
protected override void OnHeldViewRemoving(Node3D heldView)
{
_heldLight = null;
}
protected override void OnPresentationChanged(IItemInstance item)
{
bool isOn = item is FlashlightItemInstance flashlight && flashlight.IsOn;
if (WorldLight is not null)
{
WorldLight.Visible = isOn && !IsHeld;
}
if (_heldLight is not null)
{
_heldLight.Visible = isOn;
}
}
}
@@ -0,0 +1 @@
uid://a5q9y2d7m4v8p
@@ -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<PackedScene> ItemScenes = [];
private readonly Dictionary<ItemDefinitionId, PackedScene> _scenes = [];
public IReadOnlyCollection<ItemDefinitionId> 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<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))
{
return ItemActionResult.Fail(
ItemActionStatus.UnknownDefinition,
$"Unknown item definition: {definitionId}."
);
}
WorldItemController item = scene.Instantiate<WorldItemController>();
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<WorldItemController>();
ItemDefinitionId definitionId = new(prototype.DefinitionId);
prototype.Free();
if (!_scenes.TryAdd(definitionId, scene))
{
throw new InvalidOperationException($"Duplicate item spawn definition: {definitionId}.");
}
}
}
@@ -0,0 +1 @@
uid://c3x9m6w2q8r1b
@@ -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<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 @@
uid://f8n3b6w1k5r9m
@@ -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);
}
}
}
@@ -0,0 +1 @@
uid://e5m2r8q1v7k4n
@@ -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<Node3D>();
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;
}
}
}
}
@@ -0,0 +1 @@
uid://d1x7m4q9v2c6k
@@ -7,6 +7,7 @@ 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,6 +17,7 @@ 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;
@@ -91,7 +92,10 @@ public partial class MultiplayerController : Node, IMultiplayerPeer
AssertNoActiveSession();
Multiplayer.MultiplayerPeer = _multiplayerPeerFactory.CreateHost(port, MultiplayerSession.MaxPlayers - 1);
_session.StartHosting(new PeerId(Multiplayer.GetUniqueId()));
PeerId hostPeerId = new(Multiplayer.GetUniqueId());
_session.StartHosting(hostPeerId);
HostingStarted?.Invoke(hostPeerId);
}
public void Join(string address, int port)
@@ -0,0 +1,4 @@
using Game.Features.Items.Contracts;
using Game.Features.Players.Contracts;
public interface IPlayerController : IMovementModeSwitcher, IItemSpawnPoint { }
@@ -0,0 +1 @@
uid://1i0wvlumjjiu
@@ -0,0 +1,8 @@
using System;
namespace Game.Features.Players.Contracts;
public readonly record struct PlayerId(Guid Value)
{
public override string ToString() => Value.ToString();
}
@@ -0,0 +1 @@
uid://d4m8x1q6v3k9r
@@ -0,0 +1,24 @@
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;
}
+54 -12
View File
@@ -1,25 +1,53 @@
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, IMovementModeSwitcher
public partial class PlayerController : Node3D, IPlayerController
{
[Export] public CharacterBody3D Body;
[Export] public CollisionShape3D Collision;
[Export] public Camera3D Camera;
[Export]
public CharacterBody3D Body;
[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]
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;
private Dictionary<MovementMode, IMovementStrategy> _movementStrategies;
private IMovementStrategy _currentMovementStrategy;
@@ -34,24 +62,38 @@ public partial class PlayerController : Node3D, IMovementModeSwitcher
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);
+95 -1
View File
@@ -2,6 +2,9 @@
[ext_resource type="Script" uid="uid://mjm7ghkibnxa" path="res://src/Features/Players/Godot/PlayerController.cs" id="1_gkqfc"]
[ext_resource type="Texture2D" uid="uid://bymgknsy145ap" path="res://assets/models/simple-character-psx_character_256.png" id="2_tduai"]
[ext_resource type="Script" uid="uid://c2v8m5r1y7q4d" path="res://src/Features/Interactions/Godot/PlayerInteractionController.cs" id="3_interaction"]
[ext_resource type="Script" uid="uid://e4m7k1x8c3q6h" path="res://src/Features/Interactions/Godot/InteractionRaycaster.cs" id="4_raycaster"]
[ext_resource type="Script" uid="uid://b9r2w6n4f8k1v" path="res://src/Features/Interactions/Godot/InteractionPromptController.cs" id="5_prompt"]
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_130ks"]
radius = 0.25
@@ -290,14 +293,28 @@ _surfaces = [{
}]
blend_shape_mode = 0
[node name="Player" type="Node3D" unique_id=26823676 node_paths=PackedStringArray("Body", "Collision", "Camera")]
[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")]
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")
@@ -775,3 +792,80 @@ skeleton = NodePath("..")
[node name="Camera3D" type="Camera3D" parent="CharacterBody3D" unique_id=718409984]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.817, 0)
cull_mask = 1048573
[node name="LeftHandAnchor" type="Node3D" parent="CharacterBody3D/Camera3D"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.22, -0.2, -0.48)
[node name="RightHandAnchor" type="Node3D" parent="CharacterBody3D/Camera3D"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.22, -0.2, -0.48)
[node name="InteractionRaycaster" type="RayCast3D" parent="CharacterBody3D/Camera3D"]
enabled = true
target_position = Vector3(0, 0, -2.5)
collide_with_areas = true
script = ExtResource("4_raycaster")
[node name="DropRaycaster" type="RayCast3D" parent="CharacterBody3D/Camera3D"]
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")]
script = ExtResource("3_interaction")
Camera = NodePath("../CharacterBody3D/Camera3D")
Body = NodePath("../CharacterBody3D")
InteractionRaycaster = NodePath("../CharacterBody3D/Camera3D/InteractionRaycaster")
DropRaycaster = NodePath("../CharacterBody3D/Camera3D/DropRaycaster")
PromptController = NodePath("InteractionUi")
[node name="InteractionUi" type="CanvasLayer" parent="PlayerInteraction" node_paths=PackedStringArray("PromptLabel")]
layer = 50
script = ExtResource("5_prompt")
PromptLabel = NodePath("Root/Prompt")
[node name="Root" type="Control" parent="PlayerInteraction/InteractionUi"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
[node name="Crosshair" type="Label" parent="PlayerInteraction/InteractionUi/Root"]
layout_mode = 0
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -10.0
offset_top = -15.0
offset_right = 10.0
offset_bottom = 15.0
grow_horizontal = 2
grow_vertical = 2
theme_override_font_sizes/font_size = 18
text = "+"
horizontal_alignment = 1
vertical_alignment = 1
[node name="Prompt" type="Label" parent="PlayerInteraction/InteractionUi/Root"]
layout_mode = 0
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -320.0
offset_top = 34.0
offset_right = 320.0
offset_bottom = 94.0
grow_horizontal = 2
grow_vertical = 2
theme_override_colors/font_shadow_color = Color(0, 0, 0, 1)
theme_override_constants/shadow_offset_x = 2
theme_override_constants/shadow_offset_y = 2
theme_override_font_sizes/font_size = 16
horizontal_alignment = 1
vertical_alignment = 1
@@ -0,0 +1,58 @@
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();
}
}
@@ -0,0 +1 @@
uid://5j5el8n12uxm
@@ -0,0 +1,13 @@
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;
}

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