initial
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
namespace AfterHours.Customers
|
||||
{
|
||||
/// <summary>Ein Artikel im Einkaufskorb eines Kunden. Nur serverseitig.</summary>
|
||||
public readonly struct BasketLine
|
||||
{
|
||||
public readonly int ProductId;
|
||||
public readonly float Price;
|
||||
|
||||
public BasketLine(int productId, float price)
|
||||
{
|
||||
ProductId = productId;
|
||||
Price = price;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f271cf0a93651524d826c623bf6c9509
|
||||
@@ -0,0 +1,75 @@
|
||||
using System;
|
||||
|
||||
namespace AfterHours.Customers
|
||||
{
|
||||
/// <summary>
|
||||
/// Kern-Mechanik des Spiels: Wie wohl fühlt sich ein Kunde gerade?
|
||||
/// Fällt der Komfort unter <see cref="AbandonThreshold"/>, legt der Kunde
|
||||
/// die Ware zurück und geht.
|
||||
///
|
||||
/// Engine-frei und damit testbar – siehe Tests/EditMode/ComfortModelTests.cs.
|
||||
/// </summary>
|
||||
public static class ComfortModel
|
||||
{
|
||||
public const float AbandonThreshold = 0.25f;
|
||||
|
||||
/// <summary>Komfortverlust pro anderem Kunden im Nahbereich.</summary>
|
||||
private const float CrowdingPenaltyPerPerson = 0.09f;
|
||||
|
||||
/// <summary>Malus, wenn Personal ungefragt in Reichweite steht.</summary>
|
||||
private const float UnwantedStaffPenalty = 0.18f;
|
||||
|
||||
/// <summary>Malus, wenn das Regal von der Straße aus einsehbar ist.</summary>
|
||||
private const float ExposedShelfPenalty = 0.22f;
|
||||
|
||||
/// <summary>Erholung pro Sekunde, wenn niemand stört.</summary>
|
||||
private const float RecoveryPerSecond = 0.12f;
|
||||
|
||||
/// <summary>
|
||||
/// Zielkomfort für die aktuelle Situation (0..1).
|
||||
/// </summary>
|
||||
/// <param name="baseTolerance">Charaktereigenschaft des Kunden, 0..1. Höher = unerschrockener.</param>
|
||||
/// <param name="discretionDemand">Diskretionsbedarf des betrachteten Produkts, 0..1.</param>
|
||||
/// <param name="nearbyCustomers">Andere Kunden im Nahbereich.</param>
|
||||
/// <param name="staffNearbyUnrequested">Personal steht daneben, ohne dass Beratung gewünscht war.</param>
|
||||
/// <param name="shelfExposedToStreet">Regal ist vom Schaufenster aus einsehbar.</param>
|
||||
public static float EvaluateTarget(
|
||||
float baseTolerance,
|
||||
float discretionDemand,
|
||||
int nearbyCustomers,
|
||||
bool staffNearbyUnrequested,
|
||||
bool shelfExposedToStreet)
|
||||
{
|
||||
if (nearbyCustomers < 0) throw new ArgumentOutOfRangeException(nameof(nearbyCustomers));
|
||||
|
||||
float tolerance = Clamp01(baseTolerance);
|
||||
float sensitivity = Clamp01(discretionDemand);
|
||||
|
||||
// Ein toleranter Kunde bei unkritischer Ware ist praktisch unstörbar.
|
||||
float exposure = sensitivity * (1f - tolerance * 0.6f);
|
||||
|
||||
float penalty = 0f;
|
||||
penalty += nearbyCustomers * CrowdingPenaltyPerPerson;
|
||||
if (staffNearbyUnrequested) penalty += UnwantedStaffPenalty;
|
||||
if (shelfExposedToStreet) penalty += ExposedShelfPenalty;
|
||||
|
||||
return Clamp01(1f - penalty * exposure * 2f);
|
||||
}
|
||||
|
||||
/// <summary>Bewegt den aktuellen Komfort auf den Zielwert zu (Frame-Schritt).</summary>
|
||||
public static float Step(float current, float target, float deltaTime)
|
||||
{
|
||||
if (target >= current)
|
||||
{
|
||||
// Erholung ist träge …
|
||||
return Clamp01(current + RecoveryPerSecond * deltaTime);
|
||||
}
|
||||
// … Unbehagen schlägt sofort durch.
|
||||
return Clamp01(target);
|
||||
}
|
||||
|
||||
public static bool ShouldAbandon(float comfort) => comfort < AbandonThreshold;
|
||||
|
||||
private static float Clamp01(float v) => v < 0f ? 0f : (v > 1f ? 1f : v);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1aac25a7e1b9fc246a75c4b5e9f7b2ed
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0669ca75bd9edac44a5db06778b39410
|
||||
@@ -0,0 +1,85 @@
|
||||
using AfterHours.Core;
|
||||
using AfterHours.Data;
|
||||
using AfterHours.Shop;
|
||||
using Unity.Netcode;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AfterHours.Customers
|
||||
{
|
||||
/// <summary>
|
||||
/// Erzeugt Kunden abhängig von Uhrzeit und Ruf. Läuft nur auf dem Server.
|
||||
/// </summary>
|
||||
public sealed class CustomerSpawner : NetworkBehaviour
|
||||
{
|
||||
public static CustomerSpawner Instance { get; private set; }
|
||||
|
||||
[SerializeField] private GameObject _customerPrefab;
|
||||
[SerializeField] private Transform _entryPoint;
|
||||
[SerializeField] private Transform _exitPoint;
|
||||
[SerializeField] private CashRegister _register;
|
||||
|
||||
[Header("Andrang")]
|
||||
[SerializeField] private int _maxConcurrent = 20;
|
||||
[SerializeField] private float _baseSecondsBetweenSpawns = 8f;
|
||||
|
||||
[Tooltip("Multiplikator über den Tag: x-Achse 0..24 Uhr, y = Andrang.")]
|
||||
[SerializeField] private AnimationCurve _rushCurve = AnimationCurve.Linear(0f, 1f, 24f, 1f);
|
||||
|
||||
public Transform ExitPoint => _exitPoint;
|
||||
|
||||
private float _cooldown;
|
||||
private int _alive;
|
||||
|
||||
private void Awake() => Instance = this;
|
||||
|
||||
public override void OnNetworkDespawn()
|
||||
{
|
||||
if (Instance == this) Instance = null;
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (!IsServer) return;
|
||||
if (GameClock.Instance == null || !GameClock.Instance.IsOpen) return;
|
||||
if (_alive >= _maxConcurrent) return;
|
||||
|
||||
_cooldown -= Time.deltaTime;
|
||||
if (_cooldown > 0f) return;
|
||||
|
||||
Spawn();
|
||||
_cooldown = NextInterval();
|
||||
}
|
||||
|
||||
private float NextInterval()
|
||||
{
|
||||
float rush = Mathf.Max(0.1f, _rushCurve.Evaluate(GameClock.Instance.HourOfDay));
|
||||
float reputation = Mathf.Lerp(1.6f, 0.6f, ShopEconomy.Instance.Reputation);
|
||||
return _baseSecondsBetweenSpawns / rush * reputation * Random.Range(0.7f, 1.3f);
|
||||
}
|
||||
|
||||
private void Spawn()
|
||||
{
|
||||
var instance = Instantiate(_customerPrefab, _entryPoint.position, _entryPoint.rotation);
|
||||
var netObject = instance.GetComponent<NetworkObject>();
|
||||
netObject.Spawn();
|
||||
|
||||
var categories = (ProductCategory[])System.Enum.GetValues(typeof(ProductCategory));
|
||||
|
||||
var agent = instance.GetComponent<CustomerAgent>();
|
||||
agent.Initialize(
|
||||
tolerance: Random.Range(0.15f, 0.95f),
|
||||
wantedItems: Random.Range(1, 4),
|
||||
wantedCategory: categories[Random.Range(0, categories.Length)],
|
||||
register: _register);
|
||||
|
||||
_alive++;
|
||||
}
|
||||
|
||||
/// <summary>Wird von CustomerAgent kurz vor dem Despawn aufgerufen.</summary>
|
||||
public void NotifyCustomerGone()
|
||||
{
|
||||
if (!IsServer) return;
|
||||
_alive = Mathf.Max(0, _alive - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 976ebea99a2a90e4fa658c69f07afef9
|
||||
Reference in New Issue
Block a user