This commit is contained in:
2026-08-10 19:23:04 +02:00
commit 404d15a56c
134 changed files with 5374 additions and 0 deletions
+102
View File
@@ -0,0 +1,102 @@
using System.Collections.Generic;
using AfterHours.Core;
using AfterHours.Data;
using AfterHours.Interaction;
using Unity.Netcode;
using UnityEngine;
namespace AfterHours.Shop
{
/// <summary>
/// Kasse. Kunden reihen sich ein; ein Spieler scannt Position für Position
/// und schließt ab. Sämtliche Geldbewegung passiert hier auf dem Server.
/// </summary>
public sealed class CashRegister : NetworkBehaviour, IInteractable
{
[SerializeField] private ProductCatalog _catalog;
[SerializeField] private Transform _queueStart;
[SerializeField] private float _queueSpacing = 0.8f;
/// <summary>Server-only. Clients sehen die Schlange über die NPC-Positionen.</summary>
private readonly List<Customers.CustomerAgent> _queue = new();
private readonly NetworkVariable<float> _pendingTotal = new(0f);
private readonly NetworkVariable<int> _scannedItems = new(0);
public float PendingTotal => _pendingTotal.Value;
public int QueueLength => _queue.Count;
public string GetPrompt(ulong clientId)
{
if (_queue.Count == 0) return "Kasse (frei)";
return _scannedItems.Value > 0
? $"Kassieren — {_pendingTotal.Value:0.00} €"
: "Artikel scannen";
}
/// <summary>Server: ein Klick = ein Artikel scannen, oder abschließen wenn fertig.</summary>
public void Interact(NetworkObject actor)
{
if (!IsServer || _queue.Count == 0) return;
var customer = _queue[0];
if (customer == null) { _queue.RemoveAt(0); return; }
if (_scannedItems.Value < customer.BasketCount)
{
ScanNext(customer);
return;
}
Finalize(customer);
}
private void ScanNext(Customers.CustomerAgent customer)
{
var line = customer.BasketAt(_scannedItems.Value);
_pendingTotal.Value += line.Price;
_scannedItems.Value++;
}
private void Finalize(Customers.CustomerAgent customer)
{
ShopEconomy.Instance.Earn(_pendingTotal.Value);
ShopEconomy.Instance.RegisterSale(customer.Comfort);
customer.OnPaid();
_queue.RemoveAt(0);
_pendingTotal.Value = 0f;
_scannedItems.Value = 0;
ReflowQueue();
}
/// <summary>Server: Kunde stellt sich an. Gibt die Zielposition zurück.</summary>
public Vector3 Enqueue(Customers.CustomerAgent customer)
{
if (!IsServer) return _queueStart.position;
if (!_queue.Contains(customer)) _queue.Add(customer);
return SlotPosition(_queue.IndexOf(customer));
}
/// <summary>Server: Kunde verlässt die Schlange (z. B. weil er aufgibt).</summary>
public void Remove(Customers.CustomerAgent customer)
{
if (!IsServer) return;
if (_queue.Remove(customer)) ReflowQueue();
}
private void ReflowQueue()
{
for (int i = 0; i < _queue.Count; i++)
if (_queue[i] != null) _queue[i].MoveTo(SlotPosition(i));
}
private Vector3 SlotPosition(int index)
{
return _queueStart.position + _queueStart.forward * (-_queueSpacing * index);
}
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 87517e4a128619c469c629b6248f1daa
+22
View File
@@ -0,0 +1,22 @@
using System.Collections.Generic;
namespace AfterHours.Shop
{
/// <summary>
/// Serverseitige Liste aller aktiven Regalfächer. Damit Kunden ein
/// passendes Fach finden können, ohne FindObjectOfType im Frame-Loop
/// aufzurufen (siehe CLAUDE.md §5).
/// </summary>
public sealed class ShelfRegistry
{
public static readonly ShelfRegistry Instance = new();
private readonly List<ShelfSlot> _slots = new();
public IReadOnlyList<ShelfSlot> Slots => _slots;
public void Register(ShelfSlot slot) => _slots.Add(slot);
public void Unregister(ShelfSlot slot) => _slots.Remove(slot);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 36930243258ac6f4f816f2cfc254a52f
+112
View File
@@ -0,0 +1,112 @@
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
{
[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;
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 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;
if (!actor.TryGetComponent<PlayerHands>(out var hands) || hands.IsEmpty) return;
// Fach ist mit anderer Ware belegt -> nichts tun.
if (!IsEmpty && _productId.Value != hands.ProductId) return;
int free = Capacity() - _quantity.Value;
if (free <= 0) return;
int moved = hands.TryGive(Mathf.Min(free, hands.Quantity));
if (moved <= 0) return;
if (IsEmpty)
{
_productId.Value = hands.ProductId >= 0 ? hands.ProductId : _productId.Value;
var def = _catalog.Get(_productId.Value);
if (def != null && _price.Value <= 0f) _price.Value = def.RecommendedPrice;
}
_quantity.Value += moved;
}
/// <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()
{
// TODO: Verpackungs-Prefabs nach Menge ein-/ausblenden (Object Pool).
// Läuft auf allen Peers, rein kosmetisch.
}
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2bf1c5efe2d53984e90da832661184d8
+49
View File
@@ -0,0 +1,49 @@
using AfterHours.Data;
using AfterHours.Interaction;
using AfterHours.Networking;
using Unity.Netcode;
using UnityEngine;
namespace AfterHours.Shop
{
/// <summary>
/// Lagerkiste im Backroom: hält Nachschub einer einzigen Produktsorte.
/// Spieler nehmen daraus in die Hände auf und tragen die Ware zum Regal
/// (siehe ShelfSlot.Interact).
/// </summary>
public sealed class StorageCrate : NetworkBehaviour, IInteractable
{
[SerializeField] private ProductCatalog _catalog;
[SerializeField] private int _productId = -1;
[SerializeField] private int _startingQuantity = 200;
private readonly NetworkVariable<int> _quantity = new(0);
public int ProductId => _productId;
public int Quantity => _quantity.Value;
public bool IsEmpty => _quantity.Value <= 0;
public override void OnNetworkSpawn()
{
if (IsServer) _quantity.Value = _startingQuantity;
}
public string GetPrompt(ulong clientId)
{
var product = _catalog.Get(_productId);
string name = product != null ? product.DisplayName : "?";
return IsEmpty ? $"{name} — Lager leer" : $"{name} nehmen — {_quantity.Value} im Lager";
}
/// <summary>Server: Spieler nimmt Ware in die Hände auf.</summary>
public void Interact(NetworkObject actor)
{
if (!IsServer || IsEmpty) return;
if (!actor.TryGetComponent<PlayerHands>(out var hands)) return;
int want = Mathf.Min(_quantity.Value, PlayerHands.MaxCarry);
int taken = hands.TryTake(_productId, want);
_quantity.Value -= taken;
}
}
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2b8651b0bcbd9a442bc9688879ce2605