fighter/fighter.cs
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Sandbox;
public enum FighterStatus
{
Active,
Staggered,
KnockedDown,
Dead
}
public sealed class Fighter : Component
{
[Property, Group( "1. Condition Physique" )] public float MaxHealth { get; set; } = 100f;
[Property, Group( "1. Condition Physique" )] public float CurrentHealth { get; set; } = 100f;
[Property, Group( "1. Condition Physique" )] public float HealthRegenRate { get; set; } = 1.5f;
[Property, Group( "1. Condition Physique" )] public float HealthPalierStep { get; set; } = 25f;
[Property, Group( "2. Équilibre" )] public float MaxBalance { get; set; } = 100f;
[Property, Group( "2. Équilibre" )] public float CurrentBalance { get; set; } = 100f;
[Property, Group( "2. Équilibre" )] public float BaseBalanceRegenRate { get; set; } = 35f;
[Property, Group( "2. Équilibre" )] public float BalanceRegenDelay { get; set; } = 1.2f;
[Property, Group( "2. Équilibre" )] public float MinHealthBalanceRegenMult { get; set; } = 0.25f;
[Property, Group( "3. Adrénaline" )] public float MaxAdrenaline { get; set; } = 100f;
[Property, Group( "3. Adrénaline" )] public float CurrentAdrenaline { get; set; } = 0f;
[Property, Group( "3. Adrénaline" )] public float AdrenalineDecayRate { get; set; } = 2.5f;
[Property, Group( "3. Adrénaline" )] public float AdrenalineDecayDelay { get; set; } = 4.0f;
[Property, Group( "3. Adrénaline" )] public float LowHealthAdrenalineBonus { get; set; } = 1.5f;
[Property, Group( "3. Adrénaline" )] public float MaxAdrenalineBalanceBoost { get; set; } = 2.0f;
[Property, Group( "États & Vulnérabilité" )] public float PerfectParryWindow { get; set; } = 0.20f;
[Property, Group( "États & Vulnérabilité" )] public float StaggerDuration { get; set; } = 3.0f;
[Property, Group( "États & Vulnérabilité" )] public float KnockdownLieDuration { get; set; } = 1.4f;
[Property, Group( "États & Vulnérabilité" )] public float GetUpDuration { get; set; } = 1.1f;
public bool IsParrying { get; private set; }
public FighterStatus Status { get; private set; } = FighterStatus.Active;
public bool IsDead => Status == FighterStatus.Dead;
public float HealthRatio => Math.Clamp( CurrentHealth / MaxHealth, 0f, 1f );
public float BalanceRatio => Math.Clamp( CurrentBalance / MaxBalance, 0f, 1f );
public float AdrenalineRatio => Math.Clamp( CurrentAdrenaline / MaxAdrenaline, 0f, 1f );
private TimeSince _timeSinceDamageTaken = 10f;
private TimeSince _timeSinceBalanceDamaged = 10f;
private TimeSince _timeSinceCombatActivity = 10f;
private TimeSince _timeSinceParryStarted = 10f;
private TimeSince _timeSinceStagger = 10f;
private readonly List<SkinnedModelRenderer> _cachedRenderers = new();
private NavMeshAgent _agent;
protected override void OnAwake()
{
_agent = Components.Get<NavMeshAgent>();
RefreshRenderers();
}
protected override void OnStart()
{
RefreshRenderers();
}
public void RefreshRenderers()
{
_cachedRenderers.Clear();
_cachedRenderers.AddRange( Components.GetAll<SkinnedModelRenderer>( FindMode.EverythingInSelfAndDescendants ) );
}
protected override void OnUpdate()
{
if ( IsDead ) return;
UpdateStagger();
UpdateBalanceRegen();
UpdateHealthRegen();
UpdateAdrenalineDecay();
}
public void ApplyDamage( DamageContext ctx )
{
if ( IsDead ) return;
_timeSinceCombatActivity = 0f;
var combat = Components.Get<FighterCombat>();
if ( IsParrying && _timeSinceParryStarted <= PerfectParryWindow )
{
CurrentBalance = MaxBalance;
AddAdrenaline( 20f );
combat?.NotifyParrySuccess( ctx.HitPosition );
if ( ctx.Attacker != null && ctx.Attacker.IsValid )
{
ctx.Attacker.TakeBalanceDamage( ctx.Attacker.MaxBalance );
ctx.Attacker.Components.Get<FighterMovement>()?.ApplyKnockback( -ctx.KnockbackForce * 0.5f );
}
return;
}
if ( IsParrying )
{
bool hasShield = combat != null && combat.HasShieldEquipped;
float healthDmg = hasShield ? 0f : (ctx.HealthDamage * 0.15f);
float balanceDmg = hasShield ? (ctx.BalanceDamage * 0.15f) : (ctx.BalanceDamage * 0.40f);
CurrentHealth = MathF.Max( 0f, CurrentHealth - healthDmg );
TakeBalanceDamage( balanceDmg );
AddAdrenaline( 5f );
combat?.NotifyBlockSuccess( ctx.HitPosition, hasShield );
Components.Get<FighterMovement>()?.ApplyKnockback( ctx.KnockbackForce * (hasShield ? 0.10f : 0.25f) );
return;
}
var finalHealthDmg = ctx.HealthDamage;
var finalBalanceDmg = ctx.BalanceDamage;
if ( ctx.Zone == HitZone.Head )
{
finalHealthDmg *= 2.0f;
finalBalanceDmg *= 0.5f;
}
else if ( ctx.Zone == HitZone.Legs )
{
finalHealthDmg *= 0.5f;
finalBalanceDmg *= 2.0f;
}
CurrentHealth = MathF.Max( 0f, CurrentHealth - finalHealthDmg );
_timeSinceDamageTaken = 0f;
AddAdrenaline( finalHealthDmg * 0.6f );
var balanceBefore = CurrentBalance;
var remainingBalance = MathF.Max( 0f, CurrentBalance - finalBalanceDmg );
bool balanceBroken = (balanceBefore > 0f && remainingBalance <= 0f) || CurrentBalance <= 0f;
CurrentBalance = remainingBalance;
_timeSinceBalanceDamaged = 0f;
bool shouldKnockdown = ctx.ForceKnockdown ||
(ctx.CausesKnockdown && (balanceBroken || Status == FighterStatus.Staggered || CurrentBalance <= 15f));
if ( ctx.ForceKnockdown )
CurrentBalance = 0f;
if ( CurrentHealth <= 0f )
{
Die( ctx, diedOnGround: shouldKnockdown || Status == FighterStatus.KnockedDown );
return;
}
if ( shouldKnockdown )
{
TriggerKnockdown( ctx );
return;
}
if ( balanceBroken && Status == FighterStatus.Active )
{
TriggerStagger();
}
Components.Get<FighterMovement>()?.ApplyKnockback( ctx.KnockbackForce );
if ( Status == FighterStatus.Active )
{
TriggerHitReaction( ctx.Zone, ctx.Direction );
}
// Notification d'agression reçue (pour le suivi de l'épargne)
CombatEvents.EmitFighterDamaged( this, ctx );
}
private async void TriggerKnockdown( DamageContext ctx )
{
bool attackerIsPlayer = ctx.Attacker != null && ctx.Attacker.Components.Get<PlayerController>() != null;
if ( attackerIsPlayer && ctx.Attacker != this )
{
CombatEvents.EmitPlayerKnockdownEnemy( this, ctx );
}
CombatEvents.EmitFighterKnockedDown( this, ctx );
Status = FighterStatus.KnockedDown;
CurrentBalance = 0f;
var combat = Components.Get<FighterCombat>();
combat?.CancelAttackAnticipation();
combat?.DropAllWeapons();
if ( _agent != null && _agent.IsValid )
_agent.Enabled = false;
int landingMode = ctx.Direction == HitDirection.Back ? 1 : 0;
var pushDir = landingMode == 1 ? WorldRotation.Forward : -WorldRotation.Forward;
Components.Get<FighterMovement>()?.ApplyKnockback( pushDir * 380f );
SendAnimParameter( "knockdown_landing", landingMode );
SendAnimParameter( "in_knockdown", true );
SendAnimParameter( "knockdown", true );
await Task.Delay( 60 );
if ( !IsValid || IsDead ) return;
SendAnimParameter( "knockdown", false );
await Task.Delay( (int)(KnockdownLieDuration * 1000f) );
if ( !IsValid || IsDead ) return;
EjectImpaledWeapons();
SendAnimParameter( "get_up", true );
await Task.Delay( 60 );
if ( !IsValid || IsDead ) return;
SendAnimParameter( "get_up", false );
await Task.Delay( (int)(GetUpDuration * 1000f) );
if ( !IsValid || IsDead ) return;
Status = FighterStatus.Active;
CurrentBalance = MaxBalance * 0.35f;
SendAnimParameter( "in_knockdown", false );
if ( _agent != null && _agent.IsValid )
_agent.Enabled = true;
CombatEvents.EmitFighterRecoveredFromStaggerOrKnockdown( this );
}
private void EjectImpaledWeapons()
{
foreach ( var weapon in Scene.GetAllComponents<Weapon>() )
{
if ( weapon.IsValid && weapon.CurrentState == WeaponPhysicsState.Impaled && weapon.ImpaledVictim == this )
{
weapon.Dislodge();
}
}
}
private void Die( DamageContext ctx, bool diedOnGround )
{
var combat = Components.Get<FighterCombat>();
bool victimWasUnarmed = combat == null || (combat.MainWeapon == null && combat.ShieldItem == null);
bool attackerIsPlayer = ctx.Attacker != null && ctx.Attacker.Components.Get<PlayerController>() != null;
if ( attackerIsPlayer && ctx.Attacker != this )
{
CombatEvents.EmitPlayerKillEnemy( this, ctx, victimWasUnarmed );
}
CombatEvents.EmitFighterKilled( this, ctx );
Status = FighterStatus.Dead;
combat?.CancelAttackAnticipation();
combat?.DropAllWeapons();
if ( _agent != null && _agent.IsValid )
_agent.Enabled = false;
SendAnimParameter( "death_type", (int)ctx.Type );
SendAnimParameter( "death_zone", (int)ctx.Zone );
SendAnimParameter( "death_heavy", ctx.IsHeavy );
SendAnimParameter( "death_direction", (int)ctx.Direction );
SendAnimParameter( "is_dead", true );
if ( diedOnGround )
{
int landingMode = ctx.Direction == HitDirection.Back ? 1 : 0;
var pushDir = landingMode == 1 ? WorldRotation.Forward : -WorldRotation.Forward;
Components.Get<FighterMovement>()?.ApplyKnockback( pushDir * 320f );
SendAnimParameter( "knockdown_landing", landingMode );
SendAnimParameter( "in_knockdown", true );
SendAnimParameter( "knockdown", true );
}
else
{
SendAnimParameter( "in_knockdown", false );
SendAnimParameter( "death", true );
}
if ( Components.Get<PlayerController>() != null )
{
GameManager.Instance?.HandlePlayerKnockedDownOrKilled( this );
}
}
private async void TriggerHitReaction( HitZone zone, HitDirection direction )
{
SendAnimParameter( "hit_zone", (int)zone );
SendAnimParameter( "hit_direction", (int)direction );
SendAnimParameter( "hit_flinch", true );
await Task.Delay( 60 );
if ( IsValid && !IsDead )
SendAnimParameter( "hit_flinch", false );
}
private void SendAnimParameter( string param, object val )
{
for ( int i = 0; i < _cachedRenderers.Count; i++ )
{
var rend = _cachedRenderers[i];
if ( !rend.IsValid ) continue;
if ( val is bool b ) rend.Set( param, b );
else if ( val is int num ) rend.Set( param, num );
else if ( val is float f ) rend.Set( param, f );
}
}
public void TakeBalanceDamage( float amount )
{
CurrentBalance = MathF.Max( 0f, CurrentBalance - amount );
_timeSinceBalanceDamaged = 0f;
if ( CurrentBalance <= 0f && Status == FighterStatus.Active )
{
TriggerStagger();
}
}
public void AddAdrenaline( float baseAmount )
{
var lowHealthMult = 1.0f + ((1.0f - HealthRatio) * LowHealthAdrenalineBonus);
CurrentAdrenaline = Math.Clamp( CurrentAdrenaline + (baseAmount * lowHealthMult), 0f, MaxAdrenaline );
_timeSinceCombatActivity = 0f;
}
public void SetParry( bool active )
{
if ( active && !IsParrying )
_timeSinceParryStarted = 0f;
IsParrying = active;
}
private void UpdateBalanceRegen()
{
if ( Status != FighterStatus.Active ) return;
if ( _timeSinceBalanceDamaged < BalanceRegenDelay ) return;
var healthEfficiency = MathX.Lerp( MinHealthBalanceRegenMult, 1.0f, HealthRatio );
var adrenalineBoost = 1.0f + (AdrenalineRatio * MaxAdrenalineBalanceBoost);
var finalRate = BaseBalanceRegenRate * healthEfficiency * adrenalineBoost;
CurrentBalance = Math.Clamp( CurrentBalance + (finalRate * Time.Delta), 0f, MaxBalance );
}
private void UpdateHealthRegen()
{
if ( _timeSinceDamageTaken < 6.0f ) return;
var maxPalier = MathF.Ceiling( CurrentHealth / HealthPalierStep ) * HealthPalierStep;
maxPalier = Math.Clamp( maxPalier, 0f, MaxHealth );
if ( CurrentHealth < maxPalier )
{
CurrentHealth = MathF.Min( maxPalier, CurrentHealth + (HealthRegenRate * Time.Delta) );
}
}
private void UpdateAdrenalineDecay()
{
if ( _timeSinceCombatActivity < AdrenalineDecayDelay ) return;
CurrentAdrenaline = MathF.Max( 0f, CurrentAdrenaline - (AdrenalineDecayRate * Time.Delta) );
}
private void TriggerStagger()
{
Status = FighterStatus.Staggered;
_timeSinceStagger = 0f;
Components.Get<FighterCombat>()?.CancelAttackAnticipation();
SendAnimParameter( "is_staggered", true );
Log.Info( $"{GameObject.Name} est Staggered (Garde brisée) !" );
}
private void UpdateStagger()
{
if ( Status != FighterStatus.Staggered ) return;
if ( _timeSinceStagger >= StaggerDuration )
{
Status = FighterStatus.Active;
CurrentBalance = MaxBalance * 0.35f;
SendAnimParameter( "is_staggered", false );
CombatEvents.EmitFighterRecoveredFromStaggerOrKnockdown( this );
}
}
}