This commit is contained in:
2026-08-10 19:23:04 +02:00
commit 404d15a56c
134 changed files with 5374 additions and 0 deletions
+15
View File
@@ -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
+75
View File
@@ -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
+227
View File
@@ -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