Files
2026-08-10 19:23:04 +02:00

228 lines
7.7 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}
}
}