initial
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "AfterHours.Runtime",
|
||||
"rootNamespace": "AfterHours",
|
||||
"references": [
|
||||
"Unity.Netcode.Runtime",
|
||||
"Unity.Netcode.Components",
|
||||
"Unity.Collections"
|
||||
],
|
||||
"includePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"autoReferenced": true
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 29f53eee638de7147a47939382c45f71
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e2dd05b235706ab4aa022f4c00c54b33
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
using Unity.Netcode;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AfterHours.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Spielzeit und Tageswechsel. Nur der Server zählt hoch; Clients lesen mit.
|
||||
/// </summary>
|
||||
public sealed class GameClock : NetworkBehaviour
|
||||
{
|
||||
public static GameClock Instance { get; private set; }
|
||||
|
||||
[Header("Konfiguration")]
|
||||
[SerializeField] private float _realSecondsPerGameHour = 60f;
|
||||
[SerializeField] private int _openingHour = 10;
|
||||
[SerializeField] private int _closingHour = 22;
|
||||
|
||||
private readonly NetworkVariable<float> _hourOfDay = new(); // 0..24
|
||||
private readonly NetworkVariable<int> _day = new();
|
||||
private readonly NetworkVariable<bool> _isOpen = new();
|
||||
|
||||
public float HourOfDay => _hourOfDay.Value;
|
||||
public int Day => _day.Value;
|
||||
public bool IsOpen => _isOpen.Value;
|
||||
|
||||
/// <summary>Feuert serverseitig beim Tageswechsel. Für Abrechnung, Lieferungen, Miete.</summary>
|
||||
public event Action<int> DayEnded;
|
||||
|
||||
private void Awake() => Instance = this;
|
||||
|
||||
public override void OnNetworkSpawn()
|
||||
{
|
||||
if (!IsServer) return;
|
||||
_day.Value = 1;
|
||||
_hourOfDay.Value = _openingHour;
|
||||
_isOpen.Value = true;
|
||||
}
|
||||
|
||||
public override void OnNetworkDespawn()
|
||||
{
|
||||
if (Instance == this) Instance = null;
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (!IsServer) return;
|
||||
|
||||
_hourOfDay.Value += Time.deltaTime / _realSecondsPerGameHour;
|
||||
_isOpen.Value = _hourOfDay.Value >= _openingHour && _hourOfDay.Value < _closingHour;
|
||||
|
||||
if (_hourOfDay.Value >= 24f)
|
||||
{
|
||||
_hourOfDay.Value -= 24f;
|
||||
int finished = _day.Value;
|
||||
_day.Value = finished + 1;
|
||||
DayEnded?.Invoke(finished);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Springt zum nächsten Ladenöffnungszeitpunkt. Wird vom "Feierabend"-Button genutzt.</summary>
|
||||
public void SkipToOpening()
|
||||
{
|
||||
if (!IsServer) return;
|
||||
// TODO: erst prüfen, ob noch Kunden im Laden sind.
|
||||
_hourOfDay.Value = _openingHour;
|
||||
int finished = _day.Value;
|
||||
_day.Value = finished + 1;
|
||||
DayEnded?.Invoke(finished);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1c6319a5bac9dc247a1e3f9e025bb5c9
|
||||
@@ -0,0 +1,83 @@
|
||||
using Unity.Netcode;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AfterHours.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Einziger Ort, an dem Geld und Ruf verändert werden. Server-authoritativ.
|
||||
/// Clients lesen ausschließlich die NetworkVariables.
|
||||
/// </summary>
|
||||
public sealed class ShopEconomy : NetworkBehaviour
|
||||
{
|
||||
public static ShopEconomy Instance { get; private set; }
|
||||
|
||||
[SerializeField] private float _startingBalance = 2500f;
|
||||
|
||||
private readonly NetworkVariable<float> _balance = new();
|
||||
private readonly NetworkVariable<float> _reputation = new(); // 0..1
|
||||
private readonly NetworkVariable<int> _customersServedToday = new();
|
||||
private readonly NetworkVariable<int> _customersLostToday = new();
|
||||
|
||||
public float Balance => _balance.Value;
|
||||
public float Reputation => _reputation.Value;
|
||||
public int CustomersServedToday => _customersServedToday.Value;
|
||||
public int CustomersLostToday => _customersLostToday.Value;
|
||||
|
||||
private void Awake() => Instance = this;
|
||||
|
||||
public override void OnNetworkSpawn()
|
||||
{
|
||||
if (!IsServer) return;
|
||||
_balance.Value = _startingBalance;
|
||||
_reputation.Value = 0.5f;
|
||||
}
|
||||
|
||||
public override void OnNetworkDespawn()
|
||||
{
|
||||
if (Instance == this) Instance = null;
|
||||
}
|
||||
|
||||
/// <summary>Versucht abzubuchen. Gibt false zurück, wenn das Geld nicht reicht.</summary>
|
||||
public bool TrySpend(float amount)
|
||||
{
|
||||
if (!IsServer) { Debug.LogError("[ShopEconomy] TrySpend auf Client aufgerufen."); return false; }
|
||||
if (amount <= 0f || _balance.Value < amount) return false;
|
||||
|
||||
_balance.Value -= amount;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Earn(float amount)
|
||||
{
|
||||
if (!IsServer) return;
|
||||
if (amount <= 0f) return;
|
||||
_balance.Value += amount;
|
||||
}
|
||||
|
||||
public void RegisterSale(float satisfaction)
|
||||
{
|
||||
if (!IsServer) return;
|
||||
_customersServedToday.Value++;
|
||||
AdjustReputation(Mathf.Lerp(-0.005f, 0.01f, Mathf.Clamp01(satisfaction)));
|
||||
}
|
||||
|
||||
public void RegisterAbandonment()
|
||||
{
|
||||
if (!IsServer) return;
|
||||
_customersLostToday.Value++;
|
||||
AdjustReputation(-0.015f);
|
||||
}
|
||||
|
||||
public void ResetDailyCounters()
|
||||
{
|
||||
if (!IsServer) return;
|
||||
_customersServedToday.Value = 0;
|
||||
_customersLostToday.Value = 0;
|
||||
}
|
||||
|
||||
private void AdjustReputation(float delta)
|
||||
{
|
||||
_reputation.Value = Mathf.Clamp01(_reputation.Value + delta);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 48276ef6a91772a4f9593792f07b29d2
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 755094105c5bfe746a7545d5b87348bd
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 259041785f4f96f42b6ce2138e6ad8d1
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace AfterHours.Data
|
||||
{
|
||||
/// <summary>Grobes Sortiment-Segment. Beeinflusst Kundschaft, Marge und Ruf.</summary>
|
||||
public enum ProductCategory
|
||||
{
|
||||
Wellness, // Massage, Öle, Pflege – niedrige Hemmschwelle, kleine Marge
|
||||
Couples, // Paar-Sortiment – Kern des Ladens
|
||||
Lingerie, // Textil – hohe Marge, braucht Umkleide
|
||||
Novelty, // Scherzartikel – Laufkundschaft, kaum Beratung
|
||||
Specialty // Nische – hohe Marge, hohe Hemmschwelle, braucht Beratung
|
||||
}
|
||||
|
||||
public enum ShelfSize
|
||||
{
|
||||
Small = 1,
|
||||
Medium = 2,
|
||||
Large = 4
|
||||
}
|
||||
|
||||
public enum CustomerState
|
||||
{
|
||||
Entering,
|
||||
Browsing,
|
||||
SeekingAdvice,
|
||||
WaitingForStaff,
|
||||
Queueing,
|
||||
Paying,
|
||||
Leaving,
|
||||
Abandoning // geht ohne Kauf – Komfort unter Schwelle
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a86bac230bc87784ca31703d750385db
|
||||
@@ -0,0 +1,46 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AfterHours.Data
|
||||
{
|
||||
/// <summary>Zentrale Auflösung ProductId -> ProductDefinition.</summary>
|
||||
[CreateAssetMenu(menuName = "AfterHours/Product Catalog", fileName = "ProductCatalog")]
|
||||
public sealed class ProductCatalog : ScriptableObject
|
||||
{
|
||||
[SerializeField] private List<ProductDefinition> _products = new();
|
||||
|
||||
private Dictionary<int, ProductDefinition> _byId;
|
||||
|
||||
public IReadOnlyList<ProductDefinition> All => _products;
|
||||
|
||||
private void EnsureIndexed()
|
||||
{
|
||||
if (_byId != null) return;
|
||||
|
||||
_byId = new Dictionary<int, ProductDefinition>(_products.Count);
|
||||
foreach (var product in _products)
|
||||
{
|
||||
if (product == null) continue;
|
||||
|
||||
if (_byId.ContainsKey(product.ProductId))
|
||||
{
|
||||
Debug.LogError($"[ProductCatalog] Doppelte ProductId {product.ProductId} bei '{product.name}'.");
|
||||
continue;
|
||||
}
|
||||
_byId[product.ProductId] = product;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGet(int productId, out ProductDefinition product)
|
||||
{
|
||||
EnsureIndexed();
|
||||
return _byId.TryGetValue(productId, out product);
|
||||
}
|
||||
|
||||
public ProductDefinition Get(int productId) => TryGet(productId, out var p) ? p : null;
|
||||
|
||||
#if UNITY_EDITOR
|
||||
private void OnValidate() => _byId = null;
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ff37cbc356c13f849995837f2a7dfc89
|
||||
@@ -0,0 +1,45 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace AfterHours.Data
|
||||
{
|
||||
/// <summary>
|
||||
/// Stammdaten eines Artikels. Wird NIE über das Netzwerk übertragen –
|
||||
/// im Netzwerk zirkuliert ausschließlich die ProductId.
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "AfterHours/Product", fileName = "Product_")]
|
||||
public sealed class ProductDefinition : ScriptableObject
|
||||
{
|
||||
[Header("Identität")]
|
||||
[SerializeField] private int _productId = -1;
|
||||
[SerializeField] private string _displayName = "Unbenannt";
|
||||
[SerializeField] private ProductCategory _category = ProductCategory.Wellness;
|
||||
|
||||
[Header("Wirtschaft")]
|
||||
[SerializeField, Min(0.01f)] private float _wholesalePrice = 5f;
|
||||
[SerializeField, Min(0.01f)] private float _recommendedPrice = 12f;
|
||||
|
||||
[Header("Laden")]
|
||||
[SerializeField] private ShelfSize _shelfSize = ShelfSize.Small;
|
||||
|
||||
[Tooltip("0 = greift jeder mit, 1 = Kunde will damit nicht gesehen werden.")]
|
||||
[SerializeField, Range(0f, 1f)] private float _discretionDemand = 0.3f;
|
||||
|
||||
[Tooltip("Wahrscheinlichkeit, dass der Kunde vor dem Kauf Beratung sucht.")]
|
||||
[SerializeField, Range(0f, 1f)] private float _adviceLikelihood = 0.2f;
|
||||
|
||||
[Header("Darstellung (stilisierte Verpackung – siehe CLAUDE.md §7)")]
|
||||
[SerializeField] private GameObject _packagePrefab;
|
||||
[SerializeField] private Sprite _icon;
|
||||
|
||||
public int ProductId => _productId;
|
||||
public string DisplayName => _displayName;
|
||||
public ProductCategory Category => _category;
|
||||
public float WholesalePrice => _wholesalePrice;
|
||||
public float RecommendedPrice => _recommendedPrice;
|
||||
public ShelfSize ShelfSize => _shelfSize;
|
||||
public float DiscretionDemand => _discretionDemand;
|
||||
public float AdviceLikelihood => _adviceLikelihood;
|
||||
public GameObject PackagePrefab => _packagePrefab;
|
||||
public Sprite Icon => _icon;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5e52a7daaf085524caf591a33107a59d
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4f403b2316053614daaf052c45a7b85c
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,49 @@
|
||||
using System;
|
||||
|
||||
namespace AfterHours.Economy
|
||||
{
|
||||
/// <summary>
|
||||
/// Reine Preis-/Nachfragelogik. Engine-frei und damit testbar.
|
||||
/// Keine UnityEngine-Referenzen in dieser Datei.
|
||||
/// </summary>
|
||||
public static class PricingService
|
||||
{
|
||||
/// <summary>Ab diesem Aufschlag über Empfehlung kauft niemand mehr.</summary>
|
||||
public const float RejectionThreshold = 1.75f;
|
||||
|
||||
/// <summary>
|
||||
/// Kaufwahrscheinlichkeit relativ zum empfohlenen Preis.
|
||||
/// 1.0 bei Empfehlungspreis oder darunter, 0.0 ab RejectionThreshold.
|
||||
/// Dazwischen quadratisch abfallend – kleine Aufschläge tun kaum weh,
|
||||
/// große brechen die Nachfrage schnell ein.
|
||||
/// </summary>
|
||||
public static float PurchaseProbability(float askingPrice, float recommendedPrice)
|
||||
{
|
||||
if (recommendedPrice <= 0f) throw new ArgumentOutOfRangeException(nameof(recommendedPrice));
|
||||
if (askingPrice <= 0f) return 1f;
|
||||
|
||||
float ratio = askingPrice / recommendedPrice;
|
||||
if (ratio <= 1f) return 1f;
|
||||
if (ratio >= RejectionThreshold) return 0f;
|
||||
|
||||
float t = (ratio - 1f) / (RejectionThreshold - 1f); // 0..1
|
||||
return 1f - (t * t);
|
||||
}
|
||||
|
||||
/// <summary>Marge in Prozent des Verkaufspreises.</summary>
|
||||
public static float MarginRatio(float askingPrice, float wholesalePrice)
|
||||
{
|
||||
if (askingPrice <= 0f) return 0f;
|
||||
return (askingPrice - wholesalePrice) / askingPrice;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Erwarteter Deckungsbeitrag pro angebotenem Stück.
|
||||
/// Nützlich für den späteren "Preis-Optimum"-Hinweis im Preis-UI.
|
||||
/// </summary>
|
||||
public static float ExpectedProfitPerUnit(float askingPrice, float recommendedPrice, float wholesalePrice)
|
||||
{
|
||||
return PurchaseProbability(askingPrice, recommendedPrice) * (askingPrice - wholesalePrice);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 488bfdde2f57adf4e8d4ce3a8ec065c0
|
||||
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AfterHours.Economy
|
||||
{
|
||||
/// <summary>Eine Position einer Großhandelsbestellung.</summary>
|
||||
[Serializable]
|
||||
public struct OrderLine
|
||||
{
|
||||
public int ProductId;
|
||||
public int Quantity;
|
||||
public float UnitPrice;
|
||||
|
||||
public float LineTotal => Quantity * UnitPrice;
|
||||
}
|
||||
|
||||
/// <summary>Bestellung beim Großhandel. Wird nur serverseitig gehalten.</summary>
|
||||
public sealed class PurchaseOrder
|
||||
{
|
||||
private readonly List<OrderLine> _lines = new();
|
||||
|
||||
public IReadOnlyList<OrderLine> Lines => _lines;
|
||||
|
||||
/// <summary>Spielzeit in Sekunden, zu der die Lieferung eintrifft.</summary>
|
||||
public float DeliveryTime { get; set; }
|
||||
|
||||
public bool Delivered { get; set; }
|
||||
|
||||
public void Add(int productId, int quantity, float unitPrice)
|
||||
{
|
||||
if (quantity <= 0) return;
|
||||
_lines.Add(new OrderLine { ProductId = productId, Quantity = quantity, UnitPrice = unitPrice });
|
||||
}
|
||||
|
||||
public float Total()
|
||||
{
|
||||
float sum = 0f;
|
||||
foreach (var line in _lines) sum += line.LineTotal;
|
||||
return sum;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ca74ad78a2bea0e4d985e96612714923
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1c1ccc133082caf4eb31bcfed0514b62
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,18 @@
|
||||
using Unity.Netcode;
|
||||
|
||||
namespace AfterHours.Interaction
|
||||
{
|
||||
/// <summary>
|
||||
/// Alles, was der Spieler per Blick + Taste bedienen kann.
|
||||
/// WICHTIG: Interact() läuft nur auf dem Server. Clients rufen es niemals
|
||||
/// direkt auf, sondern gehen über PlayerInteractor.RequestInteractRpc.
|
||||
/// </summary>
|
||||
public interface IInteractable
|
||||
{
|
||||
/// <summary>Text für den Blick-Prompt, z. B. "Regal auffüllen".</summary>
|
||||
string GetPrompt(ulong clientId);
|
||||
|
||||
/// <summary>Serverseitige Ausführung. Muss selbst validieren.</summary>
|
||||
void Interact(NetworkObject actor);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 994d75674ec8c0e46a8c40f1bee27330
|
||||
@@ -0,0 +1,69 @@
|
||||
using Unity.Netcode;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AfterHours.Interaction
|
||||
{
|
||||
/// <summary>
|
||||
/// Raycast auf dem Owner-Client für den Prompt, Ausführung auf dem Server.
|
||||
/// Der Server vertraut dem Client die Zielauswahl NICHT blind – er prüft
|
||||
/// Distanz und Sichtbarkeit erneut (siehe CLAUDE.md §3).
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(NetworkObject))]
|
||||
public sealed class PlayerInteractor : NetworkBehaviour
|
||||
{
|
||||
[SerializeField] private Transform _eyes;
|
||||
[SerializeField] private float _reach = 2.5f;
|
||||
[SerializeField] private LayerMask _interactableMask = ~0;
|
||||
|
||||
/// <summary>Toleranz, weil Client und Server nie exakt denselben Frame sehen.</summary>
|
||||
private const float ServerReachTolerance = 1.5f;
|
||||
|
||||
private IInteractable _current;
|
||||
public string CurrentPrompt { get; private set; }
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (!IsOwner) return;
|
||||
|
||||
RefreshTarget();
|
||||
|
||||
if (_current != null && Input.GetKeyDown(KeyCode.E))
|
||||
{
|
||||
var target = ((MonoBehaviour)_current).GetComponent<NetworkObject>();
|
||||
if (target != null) RequestInteractRpc(target);
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshTarget()
|
||||
{
|
||||
_current = null;
|
||||
CurrentPrompt = null;
|
||||
|
||||
if (!Physics.Raycast(_eyes.position, _eyes.forward, out var hit, _reach, _interactableMask))
|
||||
return;
|
||||
|
||||
if (!hit.collider.TryGetComponent<IInteractable>(out var interactable))
|
||||
return;
|
||||
|
||||
_current = interactable;
|
||||
CurrentPrompt = interactable.GetPrompt(OwnerClientId);
|
||||
}
|
||||
|
||||
[Rpc(SendTo.Server)]
|
||||
private void RequestInteractRpc(NetworkObjectReference targetRef)
|
||||
{
|
||||
if (!targetRef.TryGet(out var target)) return;
|
||||
|
||||
// Server-Validierung: ist der Spieler überhaupt in Reichweite?
|
||||
float distance = Vector3.Distance(transform.position, target.transform.position);
|
||||
if (distance > _reach + ServerReachTolerance)
|
||||
{
|
||||
Debug.LogWarning($"[PlayerInteractor] Client {OwnerClientId} zu weit entfernt ({distance:F1} m).");
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.TryGetComponent<IInteractable>(out var interactable))
|
||||
interactable.Interact(NetworkObject);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3f8feeb0d94c7e44b8d05c33089a77ef
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 45f3bfcc1d96be341b8354325b3bb022
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,49 @@
|
||||
using Unity.Netcode;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AfterHours.Networking
|
||||
{
|
||||
/// <summary>
|
||||
/// Einstiegspunkt für Host/Join. Bewusst minimal – Steam-Lobbies kommen
|
||||
/// später über Facepunch.Steamworks dazu (siehe docs/ROADMAP.md).
|
||||
/// </summary>
|
||||
public sealed class NetworkBootstrap : MonoBehaviour
|
||||
{
|
||||
public const int MaxPlayers = 4;
|
||||
|
||||
[SerializeField] private string _gameSceneName = "Shop";
|
||||
|
||||
public void HostGame()
|
||||
{
|
||||
NetworkManager.Singleton.ConnectionApprovalCallback = ApproveConnection;
|
||||
|
||||
if (!NetworkManager.Singleton.StartHost())
|
||||
{
|
||||
Debug.LogError("[NetworkBootstrap] Host konnte nicht gestartet werden.");
|
||||
return;
|
||||
}
|
||||
|
||||
NetworkManager.Singleton.SceneManager.LoadScene(
|
||||
_gameSceneName, UnityEngine.SceneManagement.LoadSceneMode.Single);
|
||||
}
|
||||
|
||||
public void JoinGame()
|
||||
{
|
||||
if (!NetworkManager.Singleton.StartClient())
|
||||
Debug.LogError("[NetworkBootstrap] Client konnte nicht verbinden.");
|
||||
}
|
||||
|
||||
public void Leave() => NetworkManager.Singleton.Shutdown();
|
||||
|
||||
private void ApproveConnection(
|
||||
NetworkManager.ConnectionApprovalRequest request,
|
||||
NetworkManager.ConnectionApprovalResponse response)
|
||||
{
|
||||
bool hasRoom = NetworkManager.Singleton.ConnectedClientsIds.Count < MaxPlayers;
|
||||
|
||||
response.Approved = hasRoom;
|
||||
response.CreatePlayerObject = true;
|
||||
response.Reason = hasRoom ? null : "Der Laden ist voll (max. 4 Angestellte).";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3f7b7e91ebc572543a8324413a34c7a9
|
||||
@@ -0,0 +1,71 @@
|
||||
using Unity.Netcode;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AfterHours.Networking
|
||||
{
|
||||
/// <summary>
|
||||
/// Spielerbewegung mit Client-Prediction: der Owner bewegt sich sofort,
|
||||
/// NetworkTransform repliziert. Bewegung ist der EINZIGE Bereich, in dem
|
||||
/// wir dem Client vertrauen – siehe CLAUDE.md §3.
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(CharacterController))]
|
||||
public sealed class PlayerAvatar : NetworkBehaviour
|
||||
{
|
||||
[SerializeField] private float _walkSpeed = 3.4f;
|
||||
[SerializeField] private float _mouseSensitivity = 2f;
|
||||
[SerializeField] private Transform _cameraRoot;
|
||||
[SerializeField] private float _gravity = -12f;
|
||||
|
||||
private CharacterController _controller;
|
||||
private float _pitch;
|
||||
private float _verticalVelocity;
|
||||
|
||||
private void Awake() => _controller = GetComponent<CharacterController>();
|
||||
|
||||
public override void OnNetworkSpawn()
|
||||
{
|
||||
if (!IsOwner)
|
||||
{
|
||||
if (_cameraRoot != null && _cameraRoot.GetComponentInChildren<Camera>() is { } cam)
|
||||
cam.gameObject.SetActive(false);
|
||||
enabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
Cursor.lockState = CursorLockMode.Locked;
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (!IsOwner) return;
|
||||
|
||||
Look();
|
||||
Move();
|
||||
}
|
||||
|
||||
private void Look()
|
||||
{
|
||||
float mouseX = Input.GetAxis("Mouse X") * _mouseSensitivity;
|
||||
float mouseY = Input.GetAxis("Mouse Y") * _mouseSensitivity;
|
||||
|
||||
transform.Rotate(Vector3.up, mouseX);
|
||||
|
||||
_pitch = Mathf.Clamp(_pitch - mouseY, -85f, 85f);
|
||||
if (_cameraRoot != null)
|
||||
_cameraRoot.localRotation = Quaternion.Euler(_pitch, 0f, 0f);
|
||||
}
|
||||
|
||||
private void Move()
|
||||
{
|
||||
Vector3 input = new(Input.GetAxisRaw("Horizontal"), 0f, Input.GetAxisRaw("Vertical"));
|
||||
Vector3 direction = transform.TransformDirection(Vector3.ClampMagnitude(input, 1f));
|
||||
|
||||
_verticalVelocity = _controller.isGrounded
|
||||
? -1f
|
||||
: _verticalVelocity + _gravity * Time.deltaTime;
|
||||
|
||||
Vector3 velocity = direction * _walkSpeed + Vector3.up * _verticalVelocity;
|
||||
_controller.Move(velocity * Time.deltaTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f88015bc4effd5b429972c480fabe831
|
||||
@@ -0,0 +1,57 @@
|
||||
using Unity.Netcode;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AfterHours.Networking
|
||||
{
|
||||
/// <summary>
|
||||
/// Was der Spieler gerade trägt. Server-authoritativ – der Client darf
|
||||
/// weder Produkt noch Menge selbst setzen.
|
||||
/// </summary>
|
||||
public sealed class PlayerHands : NetworkBehaviour
|
||||
{
|
||||
public const int MaxCarry = 12;
|
||||
|
||||
private readonly NetworkVariable<int> _productId = new(-1);
|
||||
private readonly NetworkVariable<int> _quantity = new(0);
|
||||
|
||||
public int ProductId => _productId.Value;
|
||||
public int Quantity => _quantity.Value;
|
||||
public bool IsEmpty => _quantity.Value <= 0;
|
||||
|
||||
/// <summary>Serverseitig: nimmt so viel wie möglich auf, gibt die tatsächliche Menge zurück.</summary>
|
||||
public int TryTake(int productId, int quantity)
|
||||
{
|
||||
if (!IsServer) return 0;
|
||||
if (quantity <= 0) return 0;
|
||||
|
||||
if (!IsEmpty && _productId.Value != productId)
|
||||
return 0; // Hände sind mit etwas anderem belegt
|
||||
|
||||
int free = MaxCarry - _quantity.Value;
|
||||
int taken = Mathf.Min(free, quantity);
|
||||
if (taken <= 0) return 0;
|
||||
|
||||
_productId.Value = productId;
|
||||
_quantity.Value += taken;
|
||||
return taken;
|
||||
}
|
||||
|
||||
/// <summary>Serverseitig: gibt bis zu <paramref name="quantity"/> ab.</summary>
|
||||
public int TryGive(int quantity)
|
||||
{
|
||||
if (!IsServer || IsEmpty) return 0;
|
||||
|
||||
int given = Mathf.Min(_quantity.Value, quantity);
|
||||
_quantity.Value -= given;
|
||||
if (_quantity.Value == 0) _productId.Value = -1;
|
||||
return given;
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
if (!IsServer) return;
|
||||
_productId.Value = -1;
|
||||
_quantity.Value = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 402647b9bce9a6a4481b75e1004e3bfb
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 54a0671883c35dd42bfd005bee512b3b
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,102 @@
|
||||
using System.Collections.Generic;
|
||||
using AfterHours.Core;
|
||||
using AfterHours.Data;
|
||||
using AfterHours.Interaction;
|
||||
using Unity.Netcode;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AfterHours.Shop
|
||||
{
|
||||
/// <summary>
|
||||
/// Kasse. Kunden reihen sich ein; ein Spieler scannt Position für Position
|
||||
/// und schließt ab. Sämtliche Geldbewegung passiert hier auf dem Server.
|
||||
/// </summary>
|
||||
public sealed class CashRegister : NetworkBehaviour, IInteractable
|
||||
{
|
||||
[SerializeField] private ProductCatalog _catalog;
|
||||
[SerializeField] private Transform _queueStart;
|
||||
[SerializeField] private float _queueSpacing = 0.8f;
|
||||
|
||||
/// <summary>Server-only. Clients sehen die Schlange über die NPC-Positionen.</summary>
|
||||
private readonly List<Customers.CustomerAgent> _queue = new();
|
||||
|
||||
private readonly NetworkVariable<float> _pendingTotal = new(0f);
|
||||
private readonly NetworkVariable<int> _scannedItems = new(0);
|
||||
|
||||
public float PendingTotal => _pendingTotal.Value;
|
||||
public int QueueLength => _queue.Count;
|
||||
|
||||
public string GetPrompt(ulong clientId)
|
||||
{
|
||||
if (_queue.Count == 0) return "Kasse (frei)";
|
||||
return _scannedItems.Value > 0
|
||||
? $"Kassieren — {_pendingTotal.Value:0.00} €"
|
||||
: "Artikel scannen";
|
||||
}
|
||||
|
||||
/// <summary>Server: ein Klick = ein Artikel scannen, oder abschließen wenn fertig.</summary>
|
||||
public void Interact(NetworkObject actor)
|
||||
{
|
||||
if (!IsServer || _queue.Count == 0) return;
|
||||
|
||||
var customer = _queue[0];
|
||||
if (customer == null) { _queue.RemoveAt(0); return; }
|
||||
|
||||
if (_scannedItems.Value < customer.BasketCount)
|
||||
{
|
||||
ScanNext(customer);
|
||||
return;
|
||||
}
|
||||
|
||||
Finalize(customer);
|
||||
}
|
||||
|
||||
private void ScanNext(Customers.CustomerAgent customer)
|
||||
{
|
||||
var line = customer.BasketAt(_scannedItems.Value);
|
||||
_pendingTotal.Value += line.Price;
|
||||
_scannedItems.Value++;
|
||||
}
|
||||
|
||||
private void Finalize(Customers.CustomerAgent customer)
|
||||
{
|
||||
ShopEconomy.Instance.Earn(_pendingTotal.Value);
|
||||
ShopEconomy.Instance.RegisterSale(customer.Comfort);
|
||||
|
||||
customer.OnPaid();
|
||||
_queue.RemoveAt(0);
|
||||
|
||||
_pendingTotal.Value = 0f;
|
||||
_scannedItems.Value = 0;
|
||||
|
||||
ReflowQueue();
|
||||
}
|
||||
|
||||
/// <summary>Server: Kunde stellt sich an. Gibt die Zielposition zurück.</summary>
|
||||
public Vector3 Enqueue(Customers.CustomerAgent customer)
|
||||
{
|
||||
if (!IsServer) return _queueStart.position;
|
||||
|
||||
if (!_queue.Contains(customer)) _queue.Add(customer);
|
||||
return SlotPosition(_queue.IndexOf(customer));
|
||||
}
|
||||
|
||||
/// <summary>Server: Kunde verlässt die Schlange (z. B. weil er aufgibt).</summary>
|
||||
public void Remove(Customers.CustomerAgent customer)
|
||||
{
|
||||
if (!IsServer) return;
|
||||
if (_queue.Remove(customer)) ReflowQueue();
|
||||
}
|
||||
|
||||
private void ReflowQueue()
|
||||
{
|
||||
for (int i = 0; i < _queue.Count; i++)
|
||||
if (_queue[i] != null) _queue[i].MoveTo(SlotPosition(i));
|
||||
}
|
||||
|
||||
private Vector3 SlotPosition(int index)
|
||||
{
|
||||
return _queueStart.position + _queueStart.forward * (-_queueSpacing * index);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 87517e4a128619c469c629b6248f1daa
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AfterHours.Shop
|
||||
{
|
||||
/// <summary>
|
||||
/// Serverseitige Liste aller aktiven Regalfächer. Damit Kunden ein
|
||||
/// passendes Fach finden können, ohne FindObjectOfType im Frame-Loop
|
||||
/// aufzurufen (siehe CLAUDE.md §5).
|
||||
/// </summary>
|
||||
public sealed class ShelfRegistry
|
||||
{
|
||||
public static readonly ShelfRegistry Instance = new();
|
||||
|
||||
private readonly List<ShelfSlot> _slots = new();
|
||||
|
||||
public IReadOnlyList<ShelfSlot> Slots => _slots;
|
||||
|
||||
public void Register(ShelfSlot slot) => _slots.Add(slot);
|
||||
|
||||
public void Unregister(ShelfSlot slot) => _slots.Remove(slot);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 36930243258ac6f4f816f2cfc254a52f
|
||||
@@ -0,0 +1,112 @@
|
||||
using AfterHours.Data;
|
||||
using AfterHours.Interaction;
|
||||
using AfterHours.Networking;
|
||||
using Unity.Netcode;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AfterHours.Shop
|
||||
{
|
||||
/// <summary>
|
||||
/// Ein Regalfach: hält genau eine Produktsorte. Auffüllen geht über
|
||||
/// Interact(), Entnahme durch Kunden serverseitig über TakeOne().
|
||||
/// </summary>
|
||||
public sealed class ShelfSlot : NetworkBehaviour, IInteractable
|
||||
{
|
||||
[SerializeField] private ProductCatalog _catalog;
|
||||
[SerializeField] private int _capacityUnits = 12;
|
||||
|
||||
[Tooltip("Ist dieses Fach vom Schaufenster aus einsehbar? Setzt der Level-Designer.")]
|
||||
[SerializeField] private bool _exposedToStreet;
|
||||
|
||||
[SerializeField] private Transform _displayRoot;
|
||||
|
||||
private readonly NetworkVariable<int> _productId = new(-1);
|
||||
private readonly NetworkVariable<int> _quantity = new(0);
|
||||
private readonly NetworkVariable<float> _price = new(0f);
|
||||
|
||||
public int ProductId => _productId.Value;
|
||||
public int Quantity => _quantity.Value;
|
||||
public float Price => _price.Value;
|
||||
public bool ExposedToStreet => _exposedToStreet;
|
||||
public bool IsEmpty => _quantity.Value <= 0;
|
||||
|
||||
public override void OnNetworkSpawn()
|
||||
{
|
||||
_quantity.OnValueChanged += (_, _) => RefreshVisuals();
|
||||
_productId.OnValueChanged += (_, _) => RefreshVisuals();
|
||||
RefreshVisuals();
|
||||
|
||||
if (IsServer) ShelfRegistry.Instance.Register(this);
|
||||
}
|
||||
|
||||
public override void OnNetworkDespawn()
|
||||
{
|
||||
if (IsServer) ShelfRegistry.Instance.Unregister(this);
|
||||
}
|
||||
|
||||
public string GetPrompt(ulong clientId)
|
||||
{
|
||||
if (IsEmpty) return "Regal befüllen";
|
||||
|
||||
var product = _catalog.Get(_productId.Value);
|
||||
string name = product != null ? product.DisplayName : "?";
|
||||
return $"{name} — {_quantity.Value}/{Capacity()} — {_price.Value:0.00} €";
|
||||
}
|
||||
|
||||
/// <summary>Server: Spieler füllt aus der Hand auf.</summary>
|
||||
public void Interact(NetworkObject actor)
|
||||
{
|
||||
if (!IsServer) return;
|
||||
if (!actor.TryGetComponent<PlayerHands>(out var hands) || hands.IsEmpty) return;
|
||||
|
||||
// Fach ist mit anderer Ware belegt -> nichts tun.
|
||||
if (!IsEmpty && _productId.Value != hands.ProductId) return;
|
||||
|
||||
int free = Capacity() - _quantity.Value;
|
||||
if (free <= 0) return;
|
||||
|
||||
int moved = hands.TryGive(Mathf.Min(free, hands.Quantity));
|
||||
if (moved <= 0) return;
|
||||
|
||||
if (IsEmpty)
|
||||
{
|
||||
_productId.Value = hands.ProductId >= 0 ? hands.ProductId : _productId.Value;
|
||||
var def = _catalog.Get(_productId.Value);
|
||||
if (def != null && _price.Value <= 0f) _price.Value = def.RecommendedPrice;
|
||||
}
|
||||
|
||||
_quantity.Value += moved;
|
||||
}
|
||||
|
||||
/// <summary>Server: Kunde entnimmt ein Stück.</summary>
|
||||
public bool TakeOne()
|
||||
{
|
||||
if (!IsServer || IsEmpty) return false;
|
||||
|
||||
_quantity.Value--;
|
||||
if (_quantity.Value == 0) _productId.Value = -1;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Server: Preis setzen (vom Preis-UI aus).</summary>
|
||||
public void SetPrice(float price)
|
||||
{
|
||||
if (!IsServer) return;
|
||||
_price.Value = Mathf.Max(0.01f, price);
|
||||
}
|
||||
|
||||
/// <summary>Kapazität in Stück, abhängig von der Produktgröße.</summary>
|
||||
private int Capacity()
|
||||
{
|
||||
var product = _catalog.Get(_productId.Value);
|
||||
int size = product != null ? (int)product.ShelfSize : 1;
|
||||
return Mathf.Max(1, _capacityUnits / size);
|
||||
}
|
||||
|
||||
private void RefreshVisuals()
|
||||
{
|
||||
// TODO: Verpackungs-Prefabs nach Menge ein-/ausblenden (Object Pool).
|
||||
// Läuft auf allen Peers, rein kosmetisch.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2bf1c5efe2d53984e90da832661184d8
|
||||
@@ -0,0 +1,49 @@
|
||||
using AfterHours.Data;
|
||||
using AfterHours.Interaction;
|
||||
using AfterHours.Networking;
|
||||
using Unity.Netcode;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AfterHours.Shop
|
||||
{
|
||||
/// <summary>
|
||||
/// Lagerkiste im Backroom: hält Nachschub einer einzigen Produktsorte.
|
||||
/// Spieler nehmen daraus in die Hände auf und tragen die Ware zum Regal
|
||||
/// (siehe ShelfSlot.Interact).
|
||||
/// </summary>
|
||||
public sealed class StorageCrate : NetworkBehaviour, IInteractable
|
||||
{
|
||||
[SerializeField] private ProductCatalog _catalog;
|
||||
[SerializeField] private int _productId = -1;
|
||||
[SerializeField] private int _startingQuantity = 200;
|
||||
|
||||
private readonly NetworkVariable<int> _quantity = new(0);
|
||||
|
||||
public int ProductId => _productId;
|
||||
public int Quantity => _quantity.Value;
|
||||
public bool IsEmpty => _quantity.Value <= 0;
|
||||
|
||||
public override void OnNetworkSpawn()
|
||||
{
|
||||
if (IsServer) _quantity.Value = _startingQuantity;
|
||||
}
|
||||
|
||||
public string GetPrompt(ulong clientId)
|
||||
{
|
||||
var product = _catalog.Get(_productId);
|
||||
string name = product != null ? product.DisplayName : "?";
|
||||
return IsEmpty ? $"{name} — Lager leer" : $"{name} nehmen — {_quantity.Value} im Lager";
|
||||
}
|
||||
|
||||
/// <summary>Server: Spieler nimmt Ware in die Hände auf.</summary>
|
||||
public void Interact(NetworkObject actor)
|
||||
{
|
||||
if (!IsServer || IsEmpty) return;
|
||||
if (!actor.TryGetComponent<PlayerHands>(out var hands)) return;
|
||||
|
||||
int want = Mathf.Min(_quantity.Value, PlayerHands.MaxCarry);
|
||||
int taken = hands.TryTake(_productId, want);
|
||||
_quantity.Value -= taken;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2b8651b0bcbd9a442bc9688879ce2605
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 34870c3bd1615004f9e6f2b4a05a7f28
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5855a87ce97ace049915c17c1ba0c9ca
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "AfterHours.Tests",
|
||||
"rootNamespace": "AfterHours.Tests",
|
||||
"references": [
|
||||
"AfterHours.Runtime",
|
||||
"UnityEngine.TestRunner",
|
||||
"UnityEditor.TestRunner"
|
||||
],
|
||||
"includePlatforms": ["Editor"],
|
||||
"precompiledReferences": ["nunit.framework.dll"],
|
||||
"defineConstraints": ["UNITY_INCLUDE_TESTS"],
|
||||
"autoReferenced": false
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 576e5e4dc99f32e4eaebf1eb76385743
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,52 @@
|
||||
using AfterHours.Customers;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace AfterHours.Tests
|
||||
{
|
||||
public sealed class ComfortModelTests
|
||||
{
|
||||
[Test]
|
||||
public void EmptyStore_LeavesCustomerFullyComfortable()
|
||||
{
|
||||
float comfort = ComfortModel.EvaluateTarget(0.5f, 0.8f, 0, false, false);
|
||||
Assert.AreEqual(1f, comfort, 0.0001f);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CrowdingReducesComfort()
|
||||
{
|
||||
float alone = ComfortModel.EvaluateTarget(0.5f, 0.8f, 0, false, false);
|
||||
float crowded = ComfortModel.EvaluateTarget(0.5f, 0.8f, 4, false, false);
|
||||
Assert.Less(crowded, alone);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ToleranceCushionsTheSameSituation()
|
||||
{
|
||||
float shy = ComfortModel.EvaluateTarget(0.1f, 0.9f, 3, true, false);
|
||||
float bold = ComfortModel.EvaluateTarget(0.9f, 0.9f, 3, true, false);
|
||||
Assert.Greater(bold, shy);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HarmlessProductIsUnaffectedByCrowding()
|
||||
{
|
||||
float comfort = ComfortModel.EvaluateTarget(0.5f, 0f, 6, true, true);
|
||||
Assert.AreEqual(1f, comfort, 0.0001f);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WorstCaseTriggersAbandonment()
|
||||
{
|
||||
float comfort = ComfortModel.EvaluateTarget(0f, 1f, 5, true, true);
|
||||
Assert.IsTrue(ComfortModel.ShouldAbandon(comfort), $"Komfort war {comfort}");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RecoveryIsGradual_DiscomfortIsImmediate()
|
||||
{
|
||||
Assert.AreEqual(0.3f, ComfortModel.Step(1f, 0.3f, 0.25f), 0.0001f); // sofort
|
||||
Assert.Less(ComfortModel.Step(0.3f, 1f, 0.25f), 1f); // träge
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ee90a53056bfe9c42bf72d0e7ee60e8a
|
||||
@@ -0,0 +1,55 @@
|
||||
using AfterHours.Economy;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace AfterHours.Tests
|
||||
{
|
||||
public sealed class PricingServiceTests
|
||||
{
|
||||
[Test]
|
||||
public void AtOrBelowRecommendedPrice_EveryoneBuys()
|
||||
{
|
||||
Assert.AreEqual(1f, PricingService.PurchaseProbability(10f, 12f), 0.0001f);
|
||||
Assert.AreEqual(1f, PricingService.PurchaseProbability(12f, 12f), 0.0001f);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AtRejectionThreshold_NobodyBuys()
|
||||
{
|
||||
float price = 12f * PricingService.RejectionThreshold;
|
||||
Assert.AreEqual(0f, PricingService.PurchaseProbability(price, 12f), 0.0001f);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DemandFallsMonotonically()
|
||||
{
|
||||
float previous = 1f;
|
||||
for (float price = 12f; price < 21f; price += 0.5f)
|
||||
{
|
||||
float current = PricingService.PurchaseProbability(price, 12f);
|
||||
Assert.LessOrEqual(current, previous);
|
||||
previous = current;
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SmallMarkupHurtsLessThanLargeOne()
|
||||
{
|
||||
float small = 1f - PricingService.PurchaseProbability(13f, 12f);
|
||||
float large = 1f - PricingService.PurchaseProbability(19f, 12f);
|
||||
Assert.Less(small, large * 0.5f);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ExpectedProfitPeaksAboveWholesale()
|
||||
{
|
||||
float best = 0f, bestPrice = 0f;
|
||||
for (float price = 5f; price < 21f; price += 0.25f)
|
||||
{
|
||||
float profit = PricingService.ExpectedProfitPerUnit(price, 12f, 5f);
|
||||
if (profit > best) { best = profit; bestPrice = price; }
|
||||
}
|
||||
Assert.Greater(bestPrice, 5f);
|
||||
Assert.Greater(best, 0f);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 53f7a9ae1e6213b4db8d1f200e402334
|
||||
Reference in New Issue
Block a user