Files
Emberwild/Assets/GAME/Script/Player/PlayerStats.cs
T
2026-06-22 16:18:34 +02:00

472 lines
17 KiB
C#

using UnityEngine;
using UnityEngine.Events;
using FishNet.Object;
using FishNet.Object.Synchronizing;
using Ashwild.Inventory;
using Ashwild.Network;
namespace Ashwild.Player
{
/// <summary>
/// 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.
/// </summary>
[DisallowMultipleComponent]
public class PlayerStats : NetworkBehaviour
{
#region Singleton
/// <summary>
/// The local player's stats — set only on the owning client (mirrors PlayerInventory.Instance).
/// </summary>
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<float> onHealthChanged;
public UnityEvent<float> onHungerChanged;
public UnityEvent<float> onThirstChanged;
public UnityEvent onDeath;
#endregion
#region Networked State
/// <summary>
/// Replicated current health, written by the server from the owner's snapshot so other
/// clients can read this player's health without simulating it.
/// </summary>
private readonly SyncVar<float> netHealth = new SyncVar<float>();
/// <summary>
/// Replicated current hunger (see netHealth).
/// </summary>
private readonly SyncVar<float> netHunger = new SyncVar<float>();
/// <summary>
/// Replicated current thirst (see netHealth).
/// </summary>
private readonly SyncVar<float> netThirst = new SyncVar<float>();
/// <summary>
/// Replicated death flag (see netHealth).
/// </summary>
private readonly SyncVar<bool> netDead = new SyncVar<bool>();
#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
/// <summary>
/// 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.
/// </summary>
private void Awake()
{
currentHealth = maxHealth;
currentHunger = maxHunger;
currentThirst = maxThirst;
}
/// <summary>
/// Subscribes to the sprint state used to scale drain rates.
/// </summary>
private void OnEnable() { PlayerEvents.SprintChanged += OnSprintChanged; }
/// <summary>
/// Unsubscribes — mirrors OnEnable exactly.
/// </summary>
private void OnDisable() { PlayerEvents.SprintChanged -= OnSprintChanged; }
/// <summary>
/// Runs the survival simulation. Only the owning copy is enabled (PlayerNetworkController
/// disables it on remote players), so this drives one player's stats locally.
/// </summary>
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
/// <summary>
/// Registers SyncVar hooks and claims the singleton on the owning client only.
/// </summary>
public override void OnStartNetwork()
{
base.OnStartNetwork();
netHealth.OnChange += OnNetHealthChanged;
netHunger.OnChange += OnNetHungerChanged;
netThirst.OnChange += OnNetThirstChanged;
netDead.OnChange += OnNetDeadChanged;
if (base.Owner.IsLocalClient) Instance = this;
}
/// <summary>
/// Seeds the authoritative snapshot to full so late joiners see correct bars immediately.
/// </summary>
public override void OnStartServer()
{
base.OnStartServer();
netHealth.Value = maxHealth;
netHunger.Value = maxHunger;
netThirst.Value = maxThirst;
netDead.Value = false;
}
/// <summary>
/// Unregisters hooks and clears the singleton — mirrors OnStartNetwork exactly.
/// </summary>
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
/// <summary>
/// Pushes the current snapshot to other clients on a fixed cadence (owner only).
/// </summary>
private void ReplicateThrottled(float dt)
{
if (!base.IsOwner) return;
replicationTimer += dt;
if (replicationTimer >= replicationInterval) ReplicateNow();
}
/// <summary>
/// Pushes the current snapshot immediately (owner only) — used for discrete events
/// like damage, death and revive that should not wait for the throttle.
/// </summary>
private void ReplicateNow()
{
if (!base.IsOwner) return;
replicationTimer = 0f;
PushStatsServerRpc(currentHealth, currentHunger, currentThirst, isDead);
}
/// <summary>
/// Server-side: stores the owner's snapshot into the SyncVars so all observers receive it.
/// </summary>
[ServerRpc]
private void PushStatsServerRpc(float health, float hunger, float thirst, bool dead)
{
netHealth.Value = health;
netHunger.Value = hunger;
netThirst.Value = thirst;
netDead.Value = dead;
}
/// <summary>
/// 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.
/// </summary>
private void OnNetHealthChanged(float prev, float next, bool asServer)
{
if (base.IsOwner) return;
currentHealth = next;
onHealthChanged?.Invoke(next);
}
/// <summary>
/// On non-owning copies, mirrors replicated hunger into local state.
/// </summary>
private void OnNetHungerChanged(float prev, float next, bool asServer)
{
if (base.IsOwner) return;
currentHunger = next;
onHungerChanged?.Invoke(next);
}
/// <summary>
/// On non-owning copies, mirrors replicated thirst into local state.
/// </summary>
private void OnNetThirstChanged(float prev, float next, bool asServer)
{
if (base.IsOwner) return;
currentThirst = next;
onThirstChanged?.Invoke(next);
}
/// <summary>
/// On non-owning copies, mirrors replicated death state and fires the instance death event.
/// </summary>
private void OnNetDeadChanged(bool prev, bool next, bool asServer)
{
if (base.IsOwner) return;
isDead = next;
if (next) onDeath?.Invoke();
}
#endregion
#region Event Handlers
/// <summary>
/// Caches the local player's sprint state to scale hunger/thirst drain.
/// </summary>
private void OnSprintChanged(bool sprinting) => isSprinting = sprinting;
#endregion
#region Simulation
/// <summary>
/// Drains hunger over time, faster while sprinting; broadcasts the new value.
/// </summary>
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);
}
/// <summary>
/// Drains thirst over time, faster while sprinting; broadcasts the new value.
/// </summary>
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);
}
/// <summary>
/// Applies starvation and dehydration damage when hunger or thirst hit zero.
/// </summary>
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);
}
}
/// <summary>
/// Regenerates health once enough time has passed since the last hit and hunger is high enough.
/// </summary>
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
/// <summary>
/// Applies generic damage to the local player.
/// </summary>
public void TakeDamage(float amount) => TakeDamage(amount, DamageType.Generic);
/// <summary>
/// Applies typed damage, broadcasts it, replicates the new state and triggers death at zero.
/// </summary>
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();
}
/// <summary>
/// Heals the local player, broadcasts the new value and replicates it.
/// </summary>
public void Heal(float amount)
{
if (isDead || amount <= 0f) return;
currentHealth = Mathf.Min(maxHealth, currentHealth + amount);
onHealthChanged?.Invoke(currentHealth);
PlayerEvents.RaiseHealthChanged(currentHealth, maxHealth);
ReplicateNow();
}
/// <summary>
/// Restores hunger, broadcasts the new value and replicates it.
/// </summary>
public void Feed(float amount)
{
currentHunger = Mathf.Min(maxHunger, currentHunger + amount);
onHungerChanged?.Invoke(currentHunger);
PlayerEvents.RaiseHungerChanged(currentHunger, maxHunger);
ReplicateNow();
}
/// <summary>
/// Restores thirst, broadcasts the new value and replicates it.
/// </summary>
public void Drink(float amount)
{
currentThirst = Mathf.Min(maxThirst, currentThirst + amount);
onThirstChanged?.Invoke(currentThirst);
PlayerEvents.RaiseThirstChanged(currentThirst, maxThirst);
ReplicateNow();
}
/// <summary>
/// Computes and applies drop-distance fall damage; returns the damage dealt (0 if safe).
/// </summary>
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;
}
/// <summary>
/// Resets all stats to full, clears death and replicates the fresh state.
/// </summary>
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
/// <summary>
/// Marks the local player dead, fires events and replicates the death immediately.
/// </summary>
private void Die()
{
isDead = true;
onDeath?.Invoke();
PlayerEvents.RaiseDied();
ReplicateNow();
}
#endregion
}
}