Files
tiny_tackle_heroes/Assets/_Project/Scripts/Player/PlayerHealth.cs
T

335 lines
12 KiB
C#

using System;
using BumpCombat.Combat;
using BumpCombat.Core;
using BumpCombat.Constants;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.InputSystem.LowLevel;
namespace BumpCombat.Player
{
[RequireComponent(typeof(PlayerController), typeof(Animator))]
[RequireComponent(typeof(PlayerStats))]
public sealed class PlayerHealth : MonoBehaviour
{
private static readonly int HurtParameter = Animator.StringToHash("Hurt");
// Nonserialized overrides retain isolated test control; normal gameplay
// reads its values from the centralized PlayerConstants asset.
private float invulnerabilityDuration = -1f;
private float knockbackDuration = -1f;
private float hurtMovementLockDuration = -1f;
private float guardCooldownDuration = -1f;
private float guardDuration = -1f;
private float guardSuccessCooldownRechargePercent = -1f;
private PlayerStats playerStats;
private PlayerController playerController;
private Animator animator;
private DashController dashController;
private float invulnerableUntil;
private float hurtMovementLockedUntil;
private float guardUntil;
private float guardCooldownUntil;
private int nextGuardActivationIdentity;
private int activeGuardActivationIdentity;
private bool activeGuardHasBlockedAttack;
private RunManager subscribedRunManager;
public event Action<float, float> OnHealthChanged;
public event Action<Vector2, float> OnDamaged;
/// <summary>Raised only for positive health actually lost after mitigation and lethal clamping.</summary>
public event Action<float> OnDamageTaken;
public event Action<int> OnGuardAttackBlocked;
public event Action<Vector2> OnGuardImpact;
public event Action OnDied;
public float MaxHealth => playerStats.MaxHealth;
public float CurrentHealth { get; private set; }
public static float DefaultGuardCooldown =>
GameplayConstants.Current.Player.GuardCooldownDuration;
public static float DefaultGuardDuration =>
GameplayConstants.Current.Player.GuardDuration;
public static float DefaultGuardSuccessCooldownRechargePercent =>
GameplayConstants.Current.Player.GuardSuccessCooldownRechargePercent;
public float InvulnerabilityDuration => Mathf.Max(
ResolveOverride(invulnerabilityDuration, GameplayConstants.Current.Player.InvulnerabilityDuration),
ResolveOverride(hurtMovementLockDuration, GameplayConstants.Current.Player.HurtMovementLockDuration)
+ GameplayConstants.Current.Player.HurtRecoveryPadding);
public bool IsHurtMovementLocked => Time.time < hurtMovementLockedUntil;
public float GuardCooldownDuration => ResolveOverride(
guardCooldownDuration,
GameplayConstants.Current.Player.GuardCooldownDuration);
public float GuardDuration => ResolveOverride(
guardDuration,
GameplayConstants.Current.Player.GuardDuration);
public float GuardSuccessCooldownRechargePercent =>
ResolveOverride(
guardSuccessCooldownRechargePercent,
GameplayConstants.Current.Player.GuardSuccessCooldownRechargePercent);
public bool IsGuardAvailable => RunManager.Instance?.IsGuardUnlocked ?? true;
public bool IsGuarding =>
IsGuardAvailable
&& isActiveAndEnabled
&& CurrentHealth > 0f
&& Time.time < guardUntil;
public int ActiveGuardActivationIdentity =>
IsGuarding ? activeGuardActivationIdentity : 0;
public float GuardCooldownRemaining =>
Mathf.Max(0f, guardCooldownUntil - Time.time);
/// <summary>Remaining cooldown as a fraction: 1 immediately after use, 0 when ready.</summary>
public float GuardCooldownNormalized => GuardCooldownDuration > 0f
? Mathf.Clamp01(GuardCooldownRemaining / GuardCooldownDuration)
: 0f;
/// <summary>Guard readiness as a fraction: 0 during cooldown, 1 when ready.</summary>
public float GuardReadinessNormalized => IsGuardAvailable
? 1f - GuardCooldownNormalized
: 0f;
public bool IsInvulnerable =>
IsGuarding
|| Time.time < invulnerableUntil
|| (dashController != null && dashController.IsDashing);
private void Awake()
{
playerStats = GetComponent<PlayerStats>();
playerController = GetComponent<PlayerController>();
animator = GetComponent<Animator>();
dashController = GetComponent<DashController>();
CurrentHealth = MaxHealth;
playerStats.OnStatChanged += HandleStatChanged;
}
private void Start()
{
OnHealthChanged?.Invoke(CurrentHealth, MaxHealth);
RefreshRunManagerSubscription();
}
private void OnEnable()
{
InputSystem.onAfterUpdate += HandleInputSystemUpdate;
}
private void OnDestroy()
{
if (playerStats != null)
{
playerStats.OnStatChanged -= HandleStatChanged;
}
UnsubscribeRunManager();
}
private void OnDisable()
{
InputSystem.onAfterUpdate -= HandleInputSystemUpdate;
// A disabled player cannot remain visibly guarded when re-enabled.
// Keep the cooldown so disabling cannot refresh a spent guard.
guardUntil = 0f;
UnsubscribeRunManager();
}
private void Update()
{
RefreshRunManagerSubscription();
}
private void HandleInputSystemUpdate()
{
InputUpdateType currentUpdateType = InputState.currentUpdateType;
if ((currentUpdateType == InputUpdateType.Dynamic
|| currentUpdateType == InputUpdateType.Fixed
|| currentUpdateType == InputUpdateType.Manual)
&& Keyboard.current?.dKey.wasPressedThisFrame == true)
{
TryActivateGuard();
}
}
public bool TryActivateGuard()
{
if (!isActiveAndEnabled
|| Time.timeScale <= 0f
|| CurrentHealth <= 0f
|| !IsGuardAvailable
|| !RunManager.GameplayInputEnabled
|| IsHurtMovementLocked
|| IsGuarding
|| GuardCooldownRemaining > 0f)
{
return false;
}
float now = Time.time;
nextGuardActivationIdentity = nextGuardActivationIdentity == int.MaxValue
? 1
: nextGuardActivationIdentity + 1;
activeGuardActivationIdentity = nextGuardActivationIdentity;
activeGuardHasBlockedAttack = false;
guardUntil = now + GuardDuration;
guardCooldownUntil = now + GuardCooldownDuration;
return true;
}
public bool TryTakeDamage(float damage, Vector2 knockbackDirection, float knockbackDistance)
{
if (CurrentHealth <= 0f)
{
return false;
}
float damageTaken = Mathf.Max(
0f,
DamageCalculator.FinalizeDamage(
playerStats.CalculateIncomingDamage(damage)));
if (damageTaken <= 0f)
{
return false;
}
if (IsGuarding)
{
if (!activeGuardHasBlockedAttack)
{
activeGuardHasBlockedAttack = true;
float cooldownRecharge = GuardCooldownDuration
* Mathf.Clamp01(
GuardSuccessCooldownRechargePercent / 100f);
guardCooldownUntil = Mathf.Max(
Time.time,
guardCooldownUntil - cooldownRecharge);
}
OnGuardAttackBlocked?.Invoke(activeGuardActivationIdentity);
OnGuardImpact?.Invoke(knockbackDirection.normalized);
return false;
}
if (IsInvulnerable)
{
return false;
}
float healthBefore = CurrentHealth;
CurrentHealth = Mathf.Max(0f, CurrentHealth - damageTaken);
float actualDamageTaken = healthBefore - CurrentHealth;
invulnerableUntil = Time.time + InvulnerabilityDuration;
hurtMovementLockedUntil = Time.time + ResolveOverride(
hurtMovementLockDuration,
GameplayConstants.Current.Player.HurtMovementLockDuration);
playerController.ApplyDamageKnockback(
knockbackDirection,
knockbackDistance,
ResolveOverride(
knockbackDuration,
GameplayConstants.Current.Player.KnockbackDuration));
animator.SetTrigger(HurtParameter);
OnDamaged?.Invoke(knockbackDirection.normalized, knockbackDistance);
OnHealthChanged?.Invoke(CurrentHealth, MaxHealth);
if (actualDamageTaken > 0f)
{
OnDamageTaken?.Invoke(actualDamageTaken);
}
if (CurrentHealth <= 0f)
{
OnDied?.Invoke();
RunManager.Instance?.EndRun();
}
return true;
}
private static float ResolveOverride(float value, float configuredValue)
{
return value >= 0f ? value : configuredValue;
}
public void Heal(float amount)
{
if (amount <= 0f || CurrentHealth <= 0f)
{
return;
}
CurrentHealth = Mathf.Min(MaxHealth, CurrentHealth + amount);
OnHealthChanged?.Invoke(CurrentHealth, MaxHealth);
}
public void GrantInvulnerability(float duration)
{
if (duration <= 0f || CurrentHealth <= 0f)
{
return;
}
invulnerableUntil = Mathf.Max(
invulnerableUntil,
Time.time + duration);
}
private void HandleStatChanged(CharacterStat stat)
{
if (stat != CharacterStat.MaxHealth)
{
return;
}
CurrentHealth = Mathf.Min(CurrentHealth, MaxHealth);
OnHealthChanged?.Invoke(CurrentHealth, MaxHealth);
}
private void HandleRunStarted()
{
guardUntil = 0f;
guardCooldownUntil = 0f;
nextGuardActivationIdentity = 0;
activeGuardActivationIdentity = 0;
activeGuardHasBlockedAttack = false;
}
private void RefreshRunManagerSubscription()
{
RunManager current = RunManager.Instance;
if (subscribedRunManager == current)
{
return;
}
UnsubscribeRunManager();
subscribedRunManager = current;
if (subscribedRunManager != null)
{
subscribedRunManager.OnRunStarted += HandleRunStarted;
subscribedRunManager.OnGuardAvailabilityChanged +=
HandleGuardAvailabilityChanged;
HandleGuardAvailabilityChanged(
subscribedRunManager.IsGuardUnlocked);
}
}
private void HandleGuardAvailabilityChanged(bool isAvailable)
{
if (!isAvailable)
{
guardUntil = 0f;
}
}
private void UnsubscribeRunManager()
{
if (subscribedRunManager == null)
{
return;
}
subscribedRunManager.OnRunStarted -= HandleRunStarted;
subscribedRunManager.OnGuardAvailabilityChanged -=
HandleGuardAvailabilityChanged;
subscribedRunManager = null;
}
}
}