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 { /// /// 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. /// [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 _state = new(CustomerState.Entering); private readonly NetworkVariable _visibleComfort = new(1f); private NavMeshAgent _agent; private CashRegister _register; private ShelfSlot _targetSlot; private readonly List _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(); public override void OnNetworkSpawn() { // Clients simulieren nichts – NavMesh nur auf dem Server. _agent.enabled = IsServer; } /// Server: Initialisierung durch den Spawner. 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; } } /// Nächstes passendes Regal: richtige Kategorie, vorrätig, Preis noch akzeptabel. 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); } /// Server: Kasse meldet erfolgreichen Abschluss. 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(); } } }