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
+41 -3
View File
@@ -10,7 +10,7 @@ namespace AfterHours.Shop
/// Ein Regalfach: hält genau eine Produktsorte. Auffüllen geht über
/// Interact(), Entnahme durch Kunden serverseitig über TakeOne().
/// </summary>
public sealed class ShelfSlot : NetworkBehaviour, IInteractable
public sealed class ShelfSlot : NetworkBehaviour, IInteractable, IPourTarget
{
[SerializeField] private ProductCatalog _catalog;
[SerializeField] private int _capacityUnits = 12;
@@ -20,6 +20,11 @@ namespace AfterHours.Shop
[SerializeField] private Transform _displayRoot;
[Header("Anzeige-Raster (sauber in Reihen eingeräumt)")]
[SerializeField] private int _displayColumns = 4;
[SerializeField] private int _displayRows = 3;
[SerializeField] private Vector3 _displaySpacing = new(0.1f, 0.1f, 0.1f);
private readonly NetworkVariable<int> _productId = new(-1);
private readonly NetworkVariable<int> _quantity = new(0);
private readonly NetworkVariable<float> _price = new(0f);
@@ -29,6 +34,20 @@ namespace AfterHours.Shop
public float Price => _price.Value;
public bool ExposedToStreet => _exposedToStreet;
public bool IsEmpty => _quantity.Value <= 0;
public bool IsFull => _quantity.Value >= Capacity();
// Explizite Implementierung, weil "transform" (Component) den Namen schon belegt.
Transform IPourTarget.Transform => transform;
private PackageDisplay _packageDisplay;
private void Awake() => _packageDisplay = new PackageDisplay(_displayRoot, _displayColumns, _displayRows, _displaySpacing);
/// <summary>Weltposition des Rasterplatzes mit gegebenem Index Ziel fürs Flug-Visual (siehe PlayerCarry).</summary>
public Vector3 GetSlotWorldPosition(int index)
{
return _displayRoot != null ? _displayRoot.TransformPoint(_packageDisplay.SlotLocalPosition(index)) : transform.position;
}
public override void OnNetworkSpawn()
{
@@ -99,6 +118,24 @@ namespace AfterHours.Shop
Debug.Log($"[ShelfSlot] Eingeräumt: {moved} Stück, jetzt {_quantity.Value}/{Capacity()}.");
}
/// <summary>Server: ein Stück aus einem IPourSource einräumen (siehe PlayerCarry).</summary>
public bool TryAddOne(int productId)
{
if (!IsServer) return false;
if (!IsEmpty && _productId.Value != productId) return false;
if (_quantity.Value >= Capacity()) return false;
if (IsEmpty)
{
_productId.Value = productId;
var def = _catalog.Get(productId);
if (def != null && _price.Value <= 0f) _price.Value = def.RecommendedPrice;
}
_quantity.Value++;
return true;
}
/// <summary>Server: Kunde entnimmt ein Stück.</summary>
public bool TakeOne()
{
@@ -126,8 +163,9 @@ namespace AfterHours.Shop
private void RefreshVisuals()
{
// TODO: Verpackungs-Prefabs nach Menge ein-/ausblenden (Object Pool).
// Läuft auf allen Peers, rein kosmetisch.
var product = _catalog != null ? _catalog.Get(_productId.Value) : null;
GameObject prefab = product != null ? product.PackagePrefab : null;
_packageDisplay.SetTarget(prefab, _quantity.Value, Capacity());
}
}
}