- 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>
172 lines
6.3 KiB
C#
172 lines
6.3 KiB
C#
using AfterHours.Data;
|
||
using AfterHours.Interaction;
|
||
using AfterHours.Networking;
|
||
using Unity.Netcode;
|
||
using UnityEngine;
|
||
|
||
namespace AfterHours.Shop
|
||
{
|
||
/// <summary>
|
||
/// 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, IPourTarget
|
||
{
|
||
[SerializeField] private ProductCatalog _catalog;
|
||
[SerializeField] private int _capacityUnits = 12;
|
||
|
||
[Tooltip("Ist dieses Fach vom Schaufenster aus einsehbar? Setzt der Level-Designer.")]
|
||
[SerializeField] private bool _exposedToStreet;
|
||
|
||
[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);
|
||
|
||
public int ProductId => _productId.Value;
|
||
public int Quantity => _quantity.Value;
|
||
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()
|
||
{
|
||
_quantity.OnValueChanged += (_, _) => RefreshVisuals();
|
||
_productId.OnValueChanged += (_, _) => RefreshVisuals();
|
||
RefreshVisuals();
|
||
|
||
if (IsServer) ShelfRegistry.Instance.Register(this);
|
||
}
|
||
|
||
public override void OnNetworkDespawn()
|
||
{
|
||
if (IsServer) ShelfRegistry.Instance.Unregister(this);
|
||
}
|
||
|
||
public string GetPrompt(ulong clientId)
|
||
{
|
||
if (IsEmpty) return "Regal befüllen";
|
||
|
||
var product = _catalog.Get(_productId.Value);
|
||
string name = product != null ? product.DisplayName : "?";
|
||
return $"{name} — {_quantity.Value}/{Capacity()} — {_price.Value:0.00} €";
|
||
}
|
||
|
||
/// <summary>Server: Spieler füllt aus der Hand auf.</summary>
|
||
public void Interact(NetworkObject actor)
|
||
{
|
||
if (!IsServer) return;
|
||
|
||
// TODO: Debug.Log-Zeilen entfernen, sobald Regal-Befüllen zuverlässig läuft.
|
||
if (!actor.TryGetComponent<PlayerHands>(out var hands) || hands.IsEmpty)
|
||
{
|
||
Debug.Log("[ShelfSlot] Abbruch: keine PlayerHands am Actor oder Hände leer.");
|
||
return;
|
||
}
|
||
|
||
int incomingProductId = hands.ProductId;
|
||
|
||
// Fach ist mit anderer Ware belegt -> nichts tun.
|
||
if (!IsEmpty && _productId.Value != incomingProductId)
|
||
{
|
||
Debug.Log($"[ShelfSlot] Abbruch: Fach belegt mit ProductId {_productId.Value}, Hand trägt {incomingProductId}.");
|
||
return;
|
||
}
|
||
|
||
int free = Capacity() - _quantity.Value;
|
||
if (free <= 0)
|
||
{
|
||
Debug.Log($"[ShelfSlot] Abbruch: Fach voll ({_quantity.Value}/{Capacity()}).");
|
||
return;
|
||
}
|
||
|
||
int moved = hands.TryGive(Mathf.Min(free, hands.Quantity));
|
||
if (moved <= 0)
|
||
{
|
||
Debug.Log("[ShelfSlot] Abbruch: TryGive() lieferte 0 Stück.");
|
||
return;
|
||
}
|
||
|
||
if (IsEmpty)
|
||
{
|
||
_productId.Value = incomingProductId;
|
||
var def = _catalog.Get(_productId.Value);
|
||
if (def != null && _price.Value <= 0f) _price.Value = def.RecommendedPrice;
|
||
}
|
||
|
||
_quantity.Value += moved;
|
||
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()
|
||
{
|
||
if (!IsServer || IsEmpty) return false;
|
||
|
||
_quantity.Value--;
|
||
if (_quantity.Value == 0) _productId.Value = -1;
|
||
return true;
|
||
}
|
||
|
||
/// <summary>Server: Preis setzen (vom Preis-UI aus).</summary>
|
||
public void SetPrice(float price)
|
||
{
|
||
if (!IsServer) return;
|
||
_price.Value = Mathf.Max(0.01f, price);
|
||
}
|
||
|
||
/// <summary>Kapazität in Stück, abhängig von der Produktgröße.</summary>
|
||
private int Capacity()
|
||
{
|
||
var product = _catalog.Get(_productId.Value);
|
||
int size = product != null ? (int)product.ShelfSize : 1;
|
||
return Mathf.Max(1, _capacityUnits / size);
|
||
}
|
||
|
||
private void RefreshVisuals()
|
||
{
|
||
var product = _catalog != null ? _catalog.Get(_productId.Value) : null;
|
||
GameObject prefab = product != null ? product.PackagePrefab : null;
|
||
_packageDisplay.SetTarget(prefab, _quantity.Value, Capacity());
|
||
}
|
||
}
|
||
}
|