initial
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user