228 lines
7.7 KiB
C#
228 lines
7.7 KiB
C#
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();
|
||
}
|
||
}
|
||
}
|