472 lines
17 KiB
C#
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
|
|
}
|
|
}
|