feat(shop): add box pickup/carry, pour-into-shelf, and package flight visuals
- Carryable: generic pickup/carry component, cosmetic per-peer follow of the carrier's HandSocket instead of NGO reparenting (camera pitch isn't networked, so root-level reparenting wouldn't tilt with the view) - PlayerCarry: mutual exclusion with PlayerHands, server-timed hold-to-pour tick (client only signals start/stop, never per-unit requests) - IPourSource/IPourTarget: decouple PlayerInteractor from concrete Shop types, same pattern as IInteractable - Lieferkarton: gains product data (productId/quantity), two-stage Interact() (open, then pick up), IPourSource - ShelfSlot: implements IPourTarget, fills the previous RefreshVisuals TODO - PackageDisplay: renders quantity as a proper column x row grid that stacks into further layers as space runs out, reused by both containers - FlyingPackage: small non-networked cosmetic visual that arcs a package from the carried box to its exact landing slot in the shelf grid on every successful pour tick, broadcast via Rpc(SendTo.Everyone) - StorageCrate: rejects pickup while the player is carrying a box Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
using AfterHours.Networking;
|
||||
using Unity.Netcode;
|
||||
using Unity.Netcode.Components;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AfterHours.Interaction
|
||||
{
|
||||
/// <summary>
|
||||
/// Macht ein Objekt aufheb- und tragbar. Reines Trage-Handwerkszeug ohne
|
||||
/// Fachwissen über Inhalt/Produkte – das bleibt beim jeweiligen Objekt
|
||||
/// (z. B. Lieferkarton). Positionierung beim Tragen ist rein kosmetisch
|
||||
/// und läuft auf jedem Peer selbst (siehe ShelfSlot.RefreshVisuals-Muster),
|
||||
/// kein NGO-Reparenting – siehe Plan-Begründung in Lieferkarton.cs.
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(NetworkObject))]
|
||||
public sealed class Carryable : NetworkBehaviour
|
||||
{
|
||||
[SerializeField] private Collider[] _collidersToDisableWhileCarried;
|
||||
[SerializeField] private NetworkTransform _networkTransform;
|
||||
[SerializeField] private Rigidbody _rigidbody;
|
||||
|
||||
private readonly NetworkVariable<bool> _isCarried = new(
|
||||
false,
|
||||
NetworkVariableReadPermission.Everyone,
|
||||
NetworkVariableWritePermission.Server
|
||||
);
|
||||
|
||||
private readonly NetworkVariable<NetworkObjectReference> _carriedBy = new(
|
||||
default,
|
||||
NetworkVariableReadPermission.Everyone,
|
||||
NetworkVariableWritePermission.Server
|
||||
);
|
||||
|
||||
public bool IsCarried => _isCarried.Value;
|
||||
|
||||
public override void OnNetworkSpawn()
|
||||
{
|
||||
_isCarried.OnValueChanged += (_, current) => ApplyCarriedState(current);
|
||||
ApplyCarriedState(_isCarried.Value);
|
||||
}
|
||||
|
||||
/// <summary>Server: Träger übernimmt das Objekt. False, wenn schon getragen.</summary>
|
||||
public bool TryPickUp(NetworkObject carrier)
|
||||
{
|
||||
if (!IsServer || _isCarried.Value) return false;
|
||||
|
||||
_carriedBy.Value = carrier;
|
||||
_isCarried.Value = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Server: Objekt an fester Position/Rotation ablegen.</summary>
|
||||
public void Drop(Vector3 position, Quaternion rotation)
|
||||
{
|
||||
if (!IsServer || !_isCarried.Value) return;
|
||||
|
||||
transform.SetPositionAndRotation(position, rotation);
|
||||
_isCarried.Value = false;
|
||||
_carriedBy.Value = default;
|
||||
}
|
||||
|
||||
private void ApplyCarriedState(bool carried)
|
||||
{
|
||||
foreach (var collider in _collidersToDisableWhileCarried)
|
||||
if (collider != null) collider.enabled = !carried;
|
||||
|
||||
if (_networkTransform != null) _networkTransform.enabled = !carried;
|
||||
|
||||
// Sonst kämpft die Physik gegen das kosmetische Tragen (LateUpdate unten).
|
||||
if (_rigidbody != null)
|
||||
{
|
||||
_rigidbody.isKinematic = carried;
|
||||
if (!carried) _rigidbody.linearVelocity = Vector3.zero;
|
||||
}
|
||||
}
|
||||
|
||||
private void LateUpdate()
|
||||
{
|
||||
if (!_isCarried.Value) return;
|
||||
if (!_carriedBy.Value.TryGet(out var carrier)) return;
|
||||
if (!carrier.TryGetComponent<PlayerCarry>(out var carry) || carry.HandSocket == null) return;
|
||||
|
||||
transform.SetPositionAndRotation(carry.HandSocket.position, carry.HandSocket.rotation);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b73b32288703e1c4995e220e80249f35
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace AfterHours.Interaction
|
||||
{
|
||||
/// <summary>
|
||||
/// Alles, was per Klick-halten Stück für Stück ausgeschüttet werden kann
|
||||
/// (z. B. Lieferkarton, später Lagerregal-Karton). TryTakeOne() läuft
|
||||
/// serverseitig und muss selbst validieren, genau wie IPourTarget.TryAddOne().
|
||||
/// </summary>
|
||||
public interface IPourSource
|
||||
{
|
||||
bool IsOpen { get; }
|
||||
int ProductId { get; }
|
||||
bool IsEmpty { get; }
|
||||
bool TryTakeOne();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7117f9f519059394889cc6edda07b9e1
|
||||
@@ -0,0 +1,19 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace AfterHours.Interaction
|
||||
{
|
||||
/// <summary>
|
||||
/// Alles, was per Klick-halten Stück für Stück befüllt werden kann
|
||||
/// (z. B. ShelfSlot, später Lagerregal). TryAddOne() läuft serverseitig
|
||||
/// und muss selbst validieren, genau wie IInteractable.Interact().
|
||||
/// </summary>
|
||||
public interface IPourTarget
|
||||
{
|
||||
Transform Transform { get; }
|
||||
int ProductId { get; }
|
||||
int Quantity { get; }
|
||||
bool IsEmpty { get; }
|
||||
bool IsFull { get; }
|
||||
bool TryAddOne(int productId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 024acb3d9dadf024b9739a835debdbaa
|
||||
@@ -1,3 +1,4 @@
|
||||
using AfterHours.Networking;
|
||||
using Unity.Netcode;
|
||||
using UnityEngine;
|
||||
|
||||
@@ -19,6 +20,8 @@ namespace AfterHours.Interaction
|
||||
private const float ServerReachTolerance = 1.5f;
|
||||
|
||||
private IInteractable _current;
|
||||
private IPourTarget _currentPourTarget;
|
||||
private IPourTarget _pouringTarget;
|
||||
public string CurrentPrompt { get; private set; }
|
||||
|
||||
private void Update()
|
||||
@@ -27,24 +30,38 @@ namespace AfterHours.Interaction
|
||||
|
||||
RefreshTarget();
|
||||
|
||||
if (_current != null && Input.GetKeyDown(KeyCode.E))
|
||||
if (Input.GetKeyDown(KeyCode.E))
|
||||
{
|
||||
var target = ((MonoBehaviour)_current).GetComponent<NetworkObject>();
|
||||
if (target != null) RequestInteractRpc(target);
|
||||
if (_current != null)
|
||||
{
|
||||
var target = ((MonoBehaviour)_current).GetComponent<NetworkObject>();
|
||||
if (target != null) RequestInteractRpc(target);
|
||||
}
|
||||
else if (TryGetComponent<PlayerCarry>(out var carry) && carry.IsCarrying)
|
||||
{
|
||||
carry.RequestDropRpc();
|
||||
}
|
||||
}
|
||||
|
||||
HandlePourInput();
|
||||
}
|
||||
|
||||
private void RefreshTarget()
|
||||
{
|
||||
var previous = _current;
|
||||
_current = null;
|
||||
_currentPourTarget = null;
|
||||
CurrentPrompt = null;
|
||||
|
||||
if (Physics.Raycast(_eyes.position, _eyes.forward, out var hit, _reach, _interactableMask)
|
||||
&& hit.collider.TryGetComponent<IInteractable>(out var interactable))
|
||||
if (Physics.Raycast(_eyes.position, _eyes.forward, out var hit, _reach, _interactableMask))
|
||||
{
|
||||
_current = interactable;
|
||||
CurrentPrompt = interactable.GetPrompt(OwnerClientId);
|
||||
if (hit.collider.TryGetComponent<IInteractable>(out var interactable))
|
||||
{
|
||||
_current = interactable;
|
||||
CurrentPrompt = interactable.GetPrompt(OwnerClientId);
|
||||
}
|
||||
|
||||
hit.collider.TryGetComponent<IPourTarget>(out _currentPourTarget);
|
||||
}
|
||||
|
||||
// TODO: entfernen, sobald ein echter Prompt im UI angezeigt wird.
|
||||
@@ -56,21 +73,62 @@ namespace AfterHours.Interaction
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Maustaste halten, um ein IPourTarget im Blick Stück für Stück zu befüllen.</summary>
|
||||
private void HandlePourInput()
|
||||
{
|
||||
if (Input.GetMouseButtonDown(0) && _currentPourTarget != null)
|
||||
{
|
||||
var targetNo = ((MonoBehaviour)_currentPourTarget).GetComponent<NetworkObject>();
|
||||
if (targetNo != null)
|
||||
{
|
||||
_pouringTarget = _currentPourTarget;
|
||||
RequestPourStartRpc(targetNo);
|
||||
}
|
||||
}
|
||||
|
||||
if (_pouringTarget != null && (Input.GetMouseButtonUp(0) || _currentPourTarget != _pouringTarget))
|
||||
{
|
||||
_pouringTarget = null;
|
||||
RequestPourStopRpc();
|
||||
}
|
||||
}
|
||||
|
||||
[Rpc(SendTo.Server)]
|
||||
private void RequestInteractRpc(NetworkObjectReference targetRef)
|
||||
{
|
||||
if (!targetRef.TryGet(out var target)) return;
|
||||
|
||||
// Server-Validierung: ist der Spieler überhaupt in Reichweite?
|
||||
float distance = Vector3.Distance(transform.position, target.transform.position);
|
||||
if (distance > _reach + ServerReachTolerance)
|
||||
if (!IsInReach(target.transform.position))
|
||||
{
|
||||
Debug.LogWarning($"[PlayerInteractor] Client {OwnerClientId} zu weit entfernt ({distance:F1} m).");
|
||||
Debug.LogWarning($"[PlayerInteractor] Client {OwnerClientId} zu weit entfernt.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.TryGetComponent<IInteractable>(out var interactable))
|
||||
interactable.Interact(NetworkObject);
|
||||
}
|
||||
|
||||
[Rpc(SendTo.Server)]
|
||||
private void RequestPourStartRpc(NetworkObjectReference targetRef)
|
||||
{
|
||||
if (!targetRef.TryGet(out var target) || !target.TryGetComponent<IPourTarget>(out var pourTarget)) return;
|
||||
if (!IsInReach(target.transform.position)) return;
|
||||
|
||||
if (TryGetComponent<PlayerCarry>(out var carry)) carry.BeginPour(pourTarget);
|
||||
}
|
||||
|
||||
[Rpc(SendTo.Server)]
|
||||
private void RequestPourStopRpc()
|
||||
{
|
||||
if (TryGetComponent<PlayerCarry>(out var carry)) carry.StopPour();
|
||||
}
|
||||
|
||||
/// <summary>Server-Validierung: ist der Spieler überhaupt in Reichweite?</summary>
|
||||
private bool IsInReach(Vector3 point)
|
||||
{
|
||||
float distance = Vector3.Distance(transform.position, point);
|
||||
return distance <= _reach + ServerReachTolerance;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user