using UnityEngine; using UnityEngine.Events; using FishNet.Object; using FishNet.Object.Synchronizing; using Ashwild.Inventory; using Ashwild.Network; namespace Ashwild.Player { /// /// Owns the player's survival stats (health, hunger, thirst) and death state. /// Client-authoritative, exactly like PlayerInventory: the owning client simulates its own /// drain, damage and regen locally, then replicates a lightweight snapshot through SyncVars so /// other clients can observe this player (team-mate health bars, remote death). Only the owning /// client registers the Instance singleton and drives the static PlayerEvents bus; remote copies /// just mirror the replicated values. /// [DisallowMultipleComponent] public class PlayerStats : NetworkBehaviour { #region Singleton /// /// The local player's stats — set only on the owning client (mirrors PlayerInventory.Instance). /// public static PlayerStats Instance { get; private set; } #endregion #region Serialized Fields [Header("Health")] [SerializeField] private bool enableHealth = true; [SerializeField] private float maxHealth = 100f; [SerializeField] private float healthRegenRate = 0f; [SerializeField] private float healthRegenDelay = 5f; [SerializeField] private float healthRegenMinHunger = 30f; [Header("Hunger")] [SerializeField] private bool enableHunger = true; [SerializeField] private float maxHunger = 100f; [SerializeField] private float hungerDrainRate = 0.15f; [SerializeField] private float hungerSprintMultiplier = 2f; [SerializeField] private float starvationDamageRate = 2f; [Header("Thirst")] [SerializeField] private bool enableThirst = true; [SerializeField] private float maxThirst = 100f; [SerializeField] private float thirstDrainRate = 0.2f; [SerializeField] private float thirstSprintMultiplier = 2f; [SerializeField] private float dehydrationDamageRate = 3f; [Header("Fall Damage (drop-distance based)")] [SerializeField] private bool enableFallDamage = true; [SerializeField, Tooltip("Drop in meters under which no damage is applied. Forward jumps with small vertical drop stay safe.")] private float fallDamageSafeDrop = 3f; [SerializeField] private float fallDamageScale = 0.25f; [SerializeField] private float fallDamageExponent = 2f; [Header("Replication")] [SerializeField, Tooltip("Seconds between replicated stat snapshots sent to other clients. Discrete events (damage, death) replicate immediately regardless of this.")] private float replicationInterval = 0.2f; [Header("Events (consumed by UI)")] public UnityEvent onHealthChanged; public UnityEvent onHungerChanged; public UnityEvent onThirstChanged; public UnityEvent onDeath; #endregion #region Networked State /// /// Replicated current health, written by the server from the owner's snapshot so other /// clients can read this player's health without simulating it. /// private readonly SyncVar netHealth = new SyncVar(); /// /// Replicated current hunger (see netHealth). /// private readonly SyncVar netHunger = new SyncVar(); /// /// Replicated current thirst (see netHealth). /// private readonly SyncVar netThirst = new SyncVar(); /// /// Replicated death flag (see netHealth). /// private readonly SyncVar netDead = new SyncVar(); #endregion #region Local State private float currentHealth; private float currentHunger; private float currentThirst; private float lastDamageTime; private float replicationTimer; private bool isDead; private bool isSprinting; public float Health => currentHealth; public float Hunger => currentHunger; public float Thirst => currentThirst; public float MaxHealth => maxHealth; public float MaxHunger => maxHunger; public float MaxThirst => maxThirst; public float HealthNormalized => currentHealth / maxHealth; public float HungerNormalized => currentHunger / maxHunger; public float ThirstNormalized => currentThirst / maxThirst; public bool IsDead => isDead; #endregion #region Unity Lifecycle /// /// Initialises local values to full so UI shows full bars before the network is up. /// No destructive singleton guard here — ownership decides the Instance in OnStartNetwork. /// private void Awake() { currentHealth = maxHealth; currentHunger = maxHunger; currentThirst = maxThirst; } /// /// Subscribes to the sprint state used to scale drain rates. /// private void OnEnable() { PlayerEvents.SprintChanged += OnSprintChanged; } /// /// Unsubscribes — mirrors OnEnable exactly. /// private void OnDisable() { PlayerEvents.SprintChanged -= OnSprintChanged; } /// /// Runs the survival simulation. Only the owning copy is enabled (PlayerNetworkController /// disables it on remote players), so this drives one player's stats locally. /// private void Update() { // Defense in depth: when networked, only the owner simulates (remotes mirror SyncVars). // When offline (no NetworkObject spawned), IsSpawned is false so single-scene tests still run. if (base.IsSpawned && !base.IsOwner) return; if (isDead) return; float dt = Time.deltaTime; float sprintMult = isSprinting ? 1f : 0f; if (enableHunger) DrainHunger(dt, sprintMult); if (enableThirst) DrainThirst(dt, sprintMult); if (enableHealth) { HandleStarvationAndDehydration(dt); HandleHealthRegen(dt); } ReplicateThrottled(dt); } #endregion #region Network Lifecycle /// /// Registers SyncVar hooks and claims the singleton on the owning client only. /// public override void OnStartNetwork() { base.OnStartNetwork(); netHealth.OnChange += OnNetHealthChanged; netHunger.OnChange += OnNetHungerChanged; netThirst.OnChange += OnNetThirstChanged; netDead.OnChange += OnNetDeadChanged; if (base.Owner.IsLocalClient) Instance = this; } /// /// Seeds the authoritative snapshot to full so late joiners see correct bars immediately. /// public override void OnStartServer() { base.OnStartServer(); netHealth.Value = maxHealth; netHunger.Value = maxHunger; netThirst.Value = maxThirst; netDead.Value = false; } /// /// Unregisters hooks and clears the singleton — mirrors OnStartNetwork exactly. /// public override void OnStopNetwork() { base.OnStopNetwork(); netHealth.OnChange -= OnNetHealthChanged; netHunger.OnChange -= OnNetHungerChanged; netThirst.OnChange -= OnNetThirstChanged; netDead.OnChange -= OnNetDeadChanged; if (Instance == this) Instance = null; } #endregion #region Replication /// /// Pushes the current snapshot to other clients on a fixed cadence (owner only). /// private void ReplicateThrottled(float dt) { if (!base.IsOwner) return; replicationTimer += dt; if (replicationTimer >= replicationInterval) ReplicateNow(); } /// /// Pushes the current snapshot immediately (owner only) — used for discrete events /// like damage, death and revive that should not wait for the throttle. /// private void ReplicateNow() { if (!base.IsOwner) return; replicationTimer = 0f; PushStatsServerRpc(currentHealth, currentHunger, currentThirst, isDead); } /// /// Server-side: stores the owner's snapshot into the SyncVars so all observers receive it. /// [ServerRpc] private void PushStatsServerRpc(float health, float hunger, float thirst, bool dead) { netHealth.Value = health; netHunger.Value = hunger; netThirst.Value = thirst; netDead.Value = dead; } /// /// On non-owning copies, mirrors replicated health into local state and fires the instance /// event (for team-mate UI). The static PlayerEvents bus is never touched here — it belongs /// to the local player only. /// private void OnNetHealthChanged(float prev, float next, bool asServer) { if (base.IsOwner) return; currentHealth = next; onHealthChanged?.Invoke(next); } /// /// On non-owning copies, mirrors replicated hunger into local state. /// private void OnNetHungerChanged(float prev, float next, bool asServer) { if (base.IsOwner) return; currentHunger = next; onHungerChanged?.Invoke(next); } /// /// On non-owning copies, mirrors replicated thirst into local state. /// private void OnNetThirstChanged(float prev, float next, bool asServer) { if (base.IsOwner) return; currentThirst = next; onThirstChanged?.Invoke(next); } /// /// On non-owning copies, mirrors replicated death state and fires the instance death event. /// private void OnNetDeadChanged(bool prev, bool next, bool asServer) { if (base.IsOwner) return; isDead = next; if (next) onDeath?.Invoke(); } #endregion #region Event Handlers /// /// Caches the local player's sprint state to scale hunger/thirst drain. /// private void OnSprintChanged(bool sprinting) => isSprinting = sprinting; #endregion #region Simulation /// /// Drains hunger over time, faster while sprinting; broadcasts the new value. /// private void DrainHunger(float dt, float sprintMult) { float drain = hungerDrainRate + hungerDrainRate * sprintMult * hungerSprintMultiplier; currentHunger = Mathf.Max(0f, currentHunger - drain * dt); onHungerChanged?.Invoke(currentHunger); PlayerEvents.RaiseHungerChanged(currentHunger, maxHunger); } /// /// Drains thirst over time, faster while sprinting; broadcasts the new value. /// private void DrainThirst(float dt, float sprintMult) { float drain = thirstDrainRate + thirstDrainRate * sprintMult * thirstSprintMultiplier; currentThirst = Mathf.Max(0f, currentThirst - drain * dt); onThirstChanged?.Invoke(currentThirst); PlayerEvents.RaiseThirstChanged(currentThirst, maxThirst); } /// /// Applies starvation and dehydration damage when hunger or thirst hit zero. /// private void HandleStarvationAndDehydration(float dt) { if (currentHunger <= 0f) { float dmg = starvationDamageRate * dt; PlayerEvents.RaiseStarvationTick(dmg); TakeDamage(dmg, DamageType.Starvation); } if (currentThirst <= 0f) { float dmg = dehydrationDamageRate * dt; PlayerEvents.RaiseDehydrationTick(dmg); TakeDamage(dmg, DamageType.Dehydration); } } /// /// Regenerates health once enough time has passed since the last hit and hunger is high enough. /// private void HandleHealthRegen(float dt) { if (healthRegenRate <= 0f) return; if (Time.time - lastDamageTime < healthRegenDelay) return; if (currentHunger < healthRegenMinHunger) return; currentHealth = Mathf.Min(maxHealth, currentHealth + healthRegenRate * dt); onHealthChanged?.Invoke(currentHealth); PlayerEvents.RaiseHealthChanged(currentHealth, maxHealth); } #endregion #region Public API /// /// Applies generic damage to the local player. /// public void TakeDamage(float amount) => TakeDamage(amount, DamageType.Generic); /// /// Applies typed damage, broadcasts it, replicates the new state and triggers death at zero. /// private void TakeDamage(float amount, DamageType type) { if (isDead || amount <= 0f) return; currentHealth = Mathf.Max(0f, currentHealth - amount); lastDamageTime = Time.time; onHealthChanged?.Invoke(currentHealth); PlayerEvents.RaiseHealthChanged(currentHealth, maxHealth); PlayerEvents.RaiseDamaged(amount, type); if (currentHealth <= 0f) Die(); else ReplicateNow(); } /// /// Heals the local player, broadcasts the new value and replicates it. /// public void Heal(float amount) { if (isDead || amount <= 0f) return; currentHealth = Mathf.Min(maxHealth, currentHealth + amount); onHealthChanged?.Invoke(currentHealth); PlayerEvents.RaiseHealthChanged(currentHealth, maxHealth); ReplicateNow(); } /// /// Restores hunger, broadcasts the new value and replicates it. /// public void Feed(float amount) { currentHunger = Mathf.Min(maxHunger, currentHunger + amount); onHungerChanged?.Invoke(currentHunger); PlayerEvents.RaiseHungerChanged(currentHunger, maxHunger); ReplicateNow(); } /// /// Restores thirst, broadcasts the new value and replicates it. /// public void Drink(float amount) { currentThirst = Mathf.Min(maxThirst, currentThirst + amount); onThirstChanged?.Invoke(currentThirst); PlayerEvents.RaiseThirstChanged(currentThirst, maxThirst); ReplicateNow(); } /// /// Computes and applies drop-distance fall damage; returns the damage dealt (0 if safe). /// public float ApplyFallDamage(float dropDistance) { if (!enableFallDamage) return 0f; if (dropDistance < fallDamageSafeDrop) return 0f; float excess = dropDistance - fallDamageSafeDrop; float damage = fallDamageScale * Mathf.Pow(excess, fallDamageExponent); TakeDamage(damage, DamageType.Fall); return damage; } /// /// Resets all stats to full, clears death and replicates the fresh state. /// public void Revive() { isDead = false; currentHealth = maxHealth; currentHunger = maxHunger; currentThirst = maxThirst; onHealthChanged?.Invoke(currentHealth); onHungerChanged?.Invoke(currentHunger); onThirstChanged?.Invoke(currentThirst); PlayerEvents.RaiseHealthChanged(currentHealth, maxHealth); PlayerEvents.RaiseHungerChanged(currentHunger, maxHunger); PlayerEvents.RaiseThirstChanged(currentThirst, maxThirst); PlayerEvents.RaiseRespawned(); ReplicateNow(); } #endregion #region Internal Helpers /// /// Marks the local player dead, fires events and replicates the death immediately. /// private void Die() { isDead = true; onDeath?.Invoke(); PlayerEvents.RaiseDied(); ReplicateNow(); } #endregion } }