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 OnHealthChanged; public event Action OnDamaged; /// Raised only for positive health actually lost after mitigation and lethal clamping. public event Action OnDamageTaken; public event Action OnGuardAttackBlocked; public event Action 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); /// Remaining cooldown as a fraction: 1 immediately after use, 0 when ready. public float GuardCooldownNormalized => GuardCooldownDuration > 0f ? Mathf.Clamp01(GuardCooldownRemaining / GuardCooldownDuration) : 0f; /// Guard readiness as a fraction: 0 during cooldown, 1 when ready. public float GuardReadinessNormalized => IsGuardAvailable ? 1f - GuardCooldownNormalized : 0f; public bool IsInvulnerable => IsGuarding || Time.time < invulnerableUntil || (dashController != null && dashController.IsDashing); private void Awake() { playerStats = GetComponent(); playerController = GetComponent(); animator = GetComponent(); dashController = GetComponent(); 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; } } }