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
+86
View File
@@ -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);
}
}
}