This commit is contained in:
2026-08-10 19:23:04 +02:00
commit 404d15a56c
134 changed files with 5374 additions and 0 deletions
+227
View File
@@ -0,0 +1,227 @@
using System.Collections.Generic;
using AfterHours.Core;
using AfterHours.Data;
using AfterHours.Economy;
using AfterHours.Shop;
using Unity.Netcode;
using UnityEngine;
using UnityEngine.AI;
namespace AfterHours.Customers
{
/// <summary>
/// Ein Kunde. Die gesamte Entscheidungslogik läuft AUSSCHLIESSLICH auf dem
/// Server; Clients bekommen nur Transform-Sync und den sichtbaren Zustand
/// (für Sprechblasen / Emotes).
///
/// Performance: Der Server tickt die Kunden bewusst nicht jeden Frame,
/// sondern in Intervallen (TickInterval). 30 NPCs à 60 fps wären
/// verschwendete CPU-Zeit.
/// </summary>
[RequireComponent(typeof(NavMeshAgent))]
public sealed class CustomerAgent : NetworkBehaviour
{
private const float TickInterval = 0.25f;
private const float ArrivalDistance = 0.6f;
[SerializeField] private ProductCatalog _catalog;
[SerializeField] private float _personalSpaceRadius = 1.6f;
[SerializeField] private LayerMask _customerMask;
private readonly NetworkVariable<CustomerState> _state = new(CustomerState.Entering);
private readonly NetworkVariable<float> _visibleComfort = new(1f);
private NavMeshAgent _agent;
private CashRegister _register;
private ShelfSlot _targetSlot;
private readonly List<BasketLine> _basket = new();
private readonly Collider[] _neighbourBuffer = new Collider[8];
private float _tickTimer;
private float _baseTolerance = 0.5f;
private float _comfort = 1f;
private int _wantedItems = 1;
private ProductCategory _wantedCategory;
public CustomerState State => _state.Value;
public float Comfort => _comfort;
public int BasketCount => _basket.Count;
public BasketLine BasketAt(int index) => _basket[index];
private void Awake() => _agent = GetComponent<NavMeshAgent>();
public override void OnNetworkSpawn()
{
// Clients simulieren nichts NavMesh nur auf dem Server.
_agent.enabled = IsServer;
}
/// <summary>Server: Initialisierung durch den Spawner.</summary>
public void Initialize(float tolerance, int wantedItems, ProductCategory wantedCategory, CashRegister register)
{
if (!IsServer) return;
_baseTolerance = Mathf.Clamp01(tolerance);
_wantedItems = Mathf.Max(1, wantedItems);
_wantedCategory = wantedCategory;
_register = register;
_comfort = 1f;
_state.Value = CustomerState.Browsing;
}
private void Update()
{
if (!IsServer) return;
_tickTimer -= Time.deltaTime;
if (_tickTimer > 0f) return;
float dt = TickInterval - _tickTimer;
_tickTimer = TickInterval;
UpdateComfort(dt);
UpdateBehaviour();
}
private void UpdateComfort(float deltaTime)
{
float discretion = 0.3f;
if (_targetSlot != null && _catalog.TryGet(_targetSlot.ProductId, out var product))
discretion = product.DiscretionDemand;
float target = ComfortModel.EvaluateTarget(
_baseTolerance,
discretion,
CountNearbyCustomers(),
staffNearbyUnrequested: IsStaffCrowding(),
shelfExposedToStreet: _targetSlot != null && _targetSlot.ExposedToStreet);
_comfort = ComfortModel.Step(_comfort, target, deltaTime);
_visibleComfort.Value = _comfort;
if (ComfortModel.ShouldAbandon(_comfort) && _state.Value != CustomerState.Leaving)
Abandon();
}
private void UpdateBehaviour()
{
switch (_state.Value)
{
case CustomerState.Browsing:
if (_basket.Count >= _wantedItems) { GoToCheckout(); break; }
if (_targetSlot == null)
{
_targetSlot = FindShelfToVisit();
if (_targetSlot != null) MoveTo(_targetSlot.transform.position);
}
else if (HasArrivedAtTarget())
{
TryTakeFromTarget();
}
break;
case CustomerState.Queueing:
// Positionierung übernimmt CashRegister.ReflowQueue().
break;
case CustomerState.Leaving:
if (!_agent.pathPending && _agent.remainingDistance < 0.4f) Despawn();
break;
}
}
/// <summary>Nächstes passendes Regal: richtige Kategorie, vorrätig, Preis noch akzeptabel.</summary>
private ShelfSlot FindShelfToVisit()
{
ShelfSlot best = null;
float bestSqrDistance = float.MaxValue;
foreach (var slot in ShelfRegistry.Instance.Slots)
{
if (slot == null || slot.IsEmpty) continue;
if (!_catalog.TryGet(slot.ProductId, out var product)) continue;
if (product.Category != _wantedCategory) continue;
float probability = PricingService.PurchaseProbability(slot.Price, product.RecommendedPrice);
if (probability <= 0f || Random.value > probability) continue;
float sqrDistance = (slot.transform.position - transform.position).sqrMagnitude;
if (sqrDistance < bestSqrDistance)
{
bestSqrDistance = sqrDistance;
best = slot;
}
}
return best;
}
private bool HasArrivedAtTarget()
{
return !_agent.pathPending && _agent.remainingDistance <= ArrivalDistance;
}
private void TryTakeFromTarget()
{
int productId = _targetSlot.ProductId;
float price = _targetSlot.Price;
if (_targetSlot.TakeOne())
_basket.Add(new BasketLine(productId, price));
_targetSlot = null;
}
private int CountNearbyCustomers()
{
int hits = Physics.OverlapSphereNonAlloc(
transform.position, _personalSpaceRadius, _neighbourBuffer, _customerMask);
return Mathf.Max(0, hits - 1); // sich selbst abziehen
}
private bool IsStaffCrowding()
{
// TODO: Spieler in Nähe prüfen, sofern der Kunde keine Beratung wollte.
return false;
}
private void GoToCheckout()
{
_state.Value = CustomerState.Queueing;
MoveTo(_register.Enqueue(this));
}
private void Abandon()
{
_state.Value = CustomerState.Leaving;
_basket.Clear();
_register?.Remove(this);
ShopEconomy.Instance.RegisterAbandonment();
// TODO: zurückgelegte Ware am Regal wieder gutschreiben (oder als "Chaos" liegen lassen).
MoveTo(CustomerSpawner.Instance.ExitPoint.position);
}
/// <summary>Server: Kasse meldet erfolgreichen Abschluss.</summary>
public void OnPaid()
{
if (!IsServer) return;
_state.Value = CustomerState.Leaving;
MoveTo(CustomerSpawner.Instance.ExitPoint.position);
}
public void MoveTo(Vector3 destination)
{
if (!IsServer || !_agent.enabled) return;
_agent.SetDestination(destination);
}
private void Despawn()
{
if (!IsServer) return;
CustomerSpawner.Instance?.NotifyCustomerGone();
NetworkObject.Despawn();
}
}
}