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:
2026-08-14 20:28:55 +02:00
co-authored by Claude Sonnet 5
parent 2fd424b2bf
commit a999b0182a
19 changed files with 859 additions and 24 deletions
+68 -10
View File
@@ -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;
}
}
}