combat/fighter_combat.cs
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Sandbox;
public sealed class FighterCombat : Component
{
[Property, Group( "Debug" )] public bool ShowDebugTraces { get; set; } = true;
[Property, Group( "Debug" )] public float DebugTraceDuration { get; set; } = 1.5f;
[Property, Group( "Équipement" )] public string WeaponAttachmentName { get; set; } = "weapon_socket";
[Property, Group( "Équipement" )] public string ShieldAttachmentName { get; set; } = "shield_socket";
[Property, Group( "Équipement" )] public SkinnedModelRenderer PreferredRenderer { get; set; }
[Property, Group( "Équipement" )] public Weapon MainWeapon { get; private set; }
[Property, Group( "Équipement" )] public Weapon ShieldItem { get; private set; }
public Weapon CurrentWeapon => MainWeapon ?? ShieldItem;
public bool HasShieldEquipped => ShieldItem != null && ShieldItem.IsValid;
[Property, Group( "Profils de Combat" )] public WeaponComboResource UnarmedProfile { get; set; }
[Property, Group( "Charge & Attaque Lourde" )] public float HeavyChargeThreshold { get; set; } = 0.35f;
[Property, Group( "Charge & Attaque Lourde" )] public float HeavyDamageMultiplier { get; set; } = 1.5f;
[Property, Group( "Charge & Attaque Lourde" )] public float HeavyBalanceMultiplier { get; set; } = 1.75f;
[Property, Group( "Charge & Attaque Lourde" )] public float HeavyKnockbackMultiplier { get; set; } = 1.4f;
// =========================================================================
// AUDIO UNIVERSEL DU COMBATTANT (EFFORT PHYSIQUE / VOCAL)
// =========================================================================
[Property, Group( "Audio Combattant (Effort)" )]
public SoundEvent AttackAnticipationSound { get; set; }
[Property, Group( "Audio Combattant (Effort)" )]
public SoundEvent AttackReleaseSound { get; set; }
// =========================================================================
// AUDIO UNIVERSEL DU KICK (DÉCOR, CHAIR, SWING)
// =========================================================================
[Property, Group( "Audio Kick Universel" )]
[Description( "Son joué lorsque le kick frappe un combattant (impact lourd de botte contre chair)." )]
public SoundEvent KickHitFleshSound { get; set; }
[Property, Group( "Audio Kick Universel" )]
[Description( "Son joué lorsque le kick percute le décor, un mur ou un prop." )]
public SoundEvent KickHitWorldSound { get; set; }
[Property, Group( "Audio Kick Universel" )]
[Description( "Son joué lorsque le kick fend l'air." )]
public SoundEvent KickSwingSound { get; set; }
// =========================================================================
// KICK VOLUMÉTRIQUE (PROFIL UNIQUE ET INVIOLABLE)
// =========================================================================
[Property, Group( "Kick Volumétrique" )]
public AttackProfile DefaultKickProfile { get; set; } = new()
{
Name = "Spartan Kick",
Damage = 10f,
BalanceDamage = 65f,
Range = 95f,
Radius = 22f,
FighterKnockbackForce = 180f,
FighterUpwardLift = 0.0f,
PropPhysicsImpulse = 24000f,
PropUpwardLift = 0.35f,
OriginOffset = new Vector3( 10f, 0f, -20f ),
WindupDelay = 0.10f,
Cooldown = 0.85f,
AnimIndex = 99,
IsKickAnim = true,
AffectWholeBody = true,
FullBodyDuration = 0.85f,
CausesKnockdownOnStagger = true,
Shape = AttackShape.HorizontalSweep,
RayCount = 3,
SweepAngle = 35f
};
[Property, Group( "Timing" )] public float ComboResetWindow { get; set; } = 1.35f;
private bool IsPlayer => _isPlayerCached ??= (Components.Get<PlayerController>() != null);
private bool? _isPlayerCached;
public WeaponComboResource ActiveProfile
{
get
{
if ( !IsPlayer && CurrentWeapon?.BotComboData != null )
return CurrentWeapon.BotComboData;
return CurrentWeapon?.ComboData ?? ShieldItem?.ComboData ?? UnarmedProfile;
}
}
public bool IsAnticipating => _pendingProfile != null || IsThrowAnticipating;
public bool IsThrowAnticipating { get; private set; } = false;
public bool IsExecutingThrow { get; private set; } = false;
public float CurrentChargeTime { get; private set; } = 0f;
public float ChargeRatio => Math.Clamp( CurrentChargeTime / MathF.Max( 0.01f, HeavyChargeThreshold ), 0f, 1f );
public bool IsExecutingStrike => _isExecutingStrike;
private AttackProfile _pendingProfile;
private bool _pendingIsCombo = false;
private int _currentComboIndex = 0;
private TimeSince _timeSinceLastAttack = 10f;
private bool _isExecutingStrike = false;
private Fighter _fighter;
private FighterMovement _movement;
private PlayerController _playerController;
private int _throwActionToken = 0;
private readonly List<SkinnedModelRenderer> _cachedRenderers = new();
// Cache pour éliminer les allocations GC en boucle de combat
private readonly HashSet<Fighter> _hitFightersCache = new();
private readonly HashSet<Weapon> _hitWeaponsCache = new();
private readonly HashSet<PhysicsBody> _hitBodiesCache = new();
protected override void OnAwake()
{
_fighter = Components.Get<Fighter>();
_movement = Components.Get<FighterMovement>();
_playerController = Components.Get<PlayerController>();
RefreshRenderersCache();
}
protected override void OnStart()
{
RefreshRenderersCache();
foreach ( var weapon in Components.GetAll<Weapon>( FindMode.InChildren ) )
{
if ( !weapon.IsEquipped )
EquipWeapon( weapon );
}
UpdateAnimWeaponType();
}
protected override void OnUpdate()
{
SyncWeaponsToAttachments();
UpdateAnticipationTick();
}
protected override void OnPreRender()
{
SyncWeaponsToAttachments();
}
public void RefreshRenderersCache()
{
_cachedRenderers.Clear();
_cachedRenderers.AddRange( Components.GetAll<SkinnedModelRenderer>( FindMode.EverythingInSelfAndDescendants ) );
}
public void NotifyParrySuccess( Vector3 hitPosition )
{
var parrySound = ActiveProfile?.ParrySound ?? ShieldItem?.ComboData?.ParrySound;
if ( parrySound != null )
Sound.Play( parrySound, hitPosition );
}
public void NotifyBlockSuccess( Vector3 hitPosition, bool hasShield )
{
SoundEvent blockSound = null;
if ( hasShield && ShieldItem?.ComboData?.BlockSound != null )
blockSound = ShieldItem.ComboData.BlockSound;
else if ( ActiveProfile?.BlockSound != null )
blockSound = ActiveProfile.BlockSound;
else if ( ActiveProfile?.HitWorldSound != null )
blockSound = ActiveProfile.HitWorldSound;
if ( blockSound != null )
Sound.Play( blockSound, hitPosition );
}
public bool StartPrimaryAttack( Vector3 eyePos, Vector3 aimDir )
{
if ( IsExecutingThrow ) return false;
var combo = ActiveProfile?.PrimaryCombo;
if ( combo == null || combo.Count == 0 ) return false;
if ( _timeSinceLastAttack > ComboResetWindow && !_isExecutingStrike )
{
_currentComboIndex = 0;
}
var profile = combo[_currentComboIndex];
return StartAction( profile, isCombo: true, eyePos, aimDir );
}
public bool StartKick( Vector3 eyePos, Vector3 aimDir )
{
if ( IsExecutingThrow ) return false;
return StartAction( DefaultKickProfile, isCombo: false, eyePos, aimDir );
}
private bool StartAction( AttackProfile profile, bool isCombo, Vector3 eyePos, Vector3 aimDir )
{
if ( profile == null || _fighter == null || _fighter.IsDead || _fighter.Status != FighterStatus.Active )
return false;
if ( IsAnticipating )
return false;
_pendingProfile = profile;
_pendingIsCombo = isCombo;
CurrentChargeTime = 0f;
_timeSinceLastAttack = 0f;
BroadcastAnticipationState( active: true, profile, isHeavy: profile.IsHeavy, ratio: 0f );
if ( profile.IsKickAnim && IsPlayer )
{
CombatEvents.EmitPlayerKickWindup();
}
if ( AttackAnticipationSound != null )
Sound.Play( AttackAnticipationSound, eyePos );
var swingSound = profile.IsKickAnim ? (KickSwingSound ?? profile.SwingSound) : profile.SwingSound;
if ( swingSound != null )
Sound.Play( swingSound, eyePos );
return true;
}
private void UpdateAnticipationTick()
{
if ( !IsAnticipating ) return;
if ( _fighter == null || _fighter.IsDead || _fighter.Status != FighterStatus.Active )
{
CancelAnticipation();
return;
}
CurrentChargeTime += Time.Delta;
_timeSinceLastAttack = 0f;
if ( _pendingProfile != null )
{
bool isHeavyNow = _pendingProfile.IsHeavy || (CurrentChargeTime >= HeavyChargeThreshold);
BroadcastAnticipationState( active: true, _pendingProfile, isHeavyNow, ChargeRatio );
}
else if ( IsThrowAnticipating )
{
BroadcastThrowAnticipation( active: true, ChargeRatio );
}
}
public async void ReleaseAction( Vector3 eyePos, Vector3 aimDir )
{
if ( !IsAnticipating || _pendingProfile == null ) return;
var profile = _pendingProfile;
bool isCombo = _pendingIsCombo;
bool isHeavy = profile.IsHeavy || (CurrentChargeTime >= HeavyChargeThreshold);
if ( profile.IsKickAnim && IsPlayer )
{
CombatEvents.EmitPlayerKickReleased();
}
_pendingProfile = null;
_pendingIsCombo = false;
BroadcastReleaseState( profile, isHeavy );
if ( AttackReleaseSound != null )
Sound.Play( AttackReleaseSound, eyePos );
if ( isCombo )
{
var combo = ActiveProfile?.PrimaryCombo;
if ( combo != null && combo.Count > 0 )
_currentComboIndex = (_currentComboIndex + 1) % combo.Count;
}
while ( _isExecutingStrike && IsValid )
{
await Task.Frame();
}
_timeSinceLastAttack = 0f;
_ = ExecuteStrikeAsync( profile, eyePos, aimDir, isHeavy );
}
public bool StartThrow()
{
if ( CurrentWeapon == null || _fighter == null || _fighter.IsDead || _fighter.Status != FighterStatus.Active )
return false;
if ( IsAnticipating || IsExecutingThrow || _isExecutingStrike )
return false;
IsThrowAnticipating = true;
CurrentChargeTime = 0f;
_timeSinceLastAttack = 0f;
BroadcastThrowAnticipation( active: true, 0f );
if ( AttackAnticipationSound != null )
Sound.Play( AttackAnticipationSound, WorldPosition + Vector3.Up * 50f );
return true;
}
public void ReleaseThrow( Vector3 aimDir, float speed )
{
if ( !IsThrowAnticipating || CurrentWeapon == null ) return;
IsThrowAnticipating = false;
IsExecutingThrow = true;
_throwActionToken++;
int currentToken = _throwActionToken;
Weapon weaponToThrow = MainWeapon ?? ShieldItem;
var profile = weaponToThrow.ComboData?.ThrownProfile ?? new AttackProfile();
BroadcastThrowRelease();
if ( AttackReleaseSound != null )
Sound.Play( AttackReleaseSound, WorldPosition + Vector3.Up * 50f );
_ = ExecuteThrowSequenceAsync( weaponToThrow, profile, aimDir, speed, currentToken );
}
private async Task ExecuteThrowSequenceAsync( Weapon weaponToThrow, AttackProfile profile, Vector3 aimDir, float speed, int token )
{
if ( profile.WindupDelay > 0f )
await Task.Delay( (int)(profile.WindupDelay * 1000f) );
if ( !IsValid || token != _throwActionToken || weaponToThrow == null || !weaponToThrow.IsValid )
{
IsExecutingThrow = false;
return;
}
string socket = (weaponToThrow == MainWeapon || ShieldItem == null) ? WeaponAttachmentName : ShieldAttachmentName;
var attach = ResolveAttachment( socket );
var spawnPos = attach?.Position ?? (_fighter != null ? _fighter.WorldPosition + Vector3.Up * 50f + aimDir * 25f : weaponToThrow.WorldPosition);
var spawnRot = Rotation.LookAt( aimDir );
if ( weaponToThrow == MainWeapon )
MainWeapon = null;
else
ShieldItem = null;
weaponToThrow.Drop( aimDir * speed, isLethalThrow: true, dropPosition: spawnPos, dropRotation: spawnRot );
float remainingRecovery = MathF.Max( 0.05f, profile.Cooldown - profile.WindupDelay );
await Task.Delay( (int)(remainingRecovery * 1000f) );
if ( !IsValid || token != _throwActionToken ) return;
IsExecutingThrow = false;
_currentComboIndex = 0;
RefreshRenderersCache();
BroadcastThrowReset();
UpdateAnimWeaponType();
}
public void CancelAnticipation()
{
if ( _pendingProfile?.IsKickAnim == true && IsPlayer )
{
CombatEvents.EmitPlayerKickReleased();
}
_pendingProfile = null;
_pendingIsCombo = false;
IsThrowAnticipating = false;
CurrentChargeTime = 0f;
_throwActionToken++;
for ( int i = 0; i < _cachedRenderers.Count; i++ )
{
var rend = _cachedRenderers[i];
if ( !rend.IsValid ) continue;
rend.Set( "attack_anticipating", false );
rend.Set( "attack_released", false );
rend.Set( "is_throwing", false );
rend.Set( "charge_ratio", 0f );
rend.Set( "is_kicking", false );
rend.Set( "attack", false );
rend.Set( "kick", false );
rend.Set( "is_full_body_attack", false );
rend.Set( "full_body_weight", 0.0f );
}
}
public void ExecuteSpecificAttack( AttackProfile profile, Vector3 eyePos, Vector3 aimDir )
{
if ( StartAction( profile, isCombo: false, eyePos, aimDir ) )
ReleaseAction( eyePos, aimDir );
}
public void AttackDirect( Vector3 eyePos, Vector3 aimDir )
{
if ( StartPrimaryAttack( eyePos, aimDir ) )
ReleaseAction( eyePos, aimDir );
}
public void KickDirect( Vector3 eyePos, Vector3 aimDir )
{
if ( StartKick( eyePos, aimDir ) )
ReleaseAction( eyePos, aimDir );
}
public void Attack( Vector3 eyePos, Vector3 aimDir ) => AttackDirect( eyePos, aimDir );
public void Kick( Vector3 eyePos, Vector3 aimDir ) => KickDirect( eyePos, aimDir );
public void StartAttackAnticipation( Vector3 eyePos, Vector3 aimDir ) => StartPrimaryAttack( eyePos, aimDir );
public void ReleaseAttack( Vector3 eyePos, Vector3 aimDir ) => ReleaseAction( eyePos, aimDir );
public void CancelAttackAnticipation() => CancelAnticipation();
private async Task ExecuteStrikeAsync( AttackProfile profile, Vector3 eyePos, Vector3 aimDir, bool isHeavy )
{
_isExecutingStrike = true;
BroadcastAttackState( profile, active: true, isHeavy );
if ( profile.WindupDelay > 0f )
await Task.Delay( (int)(profile.WindupDelay * 1000f) );
if ( !IsValid || _fighter == null || _fighter.IsDead || _fighter.Status != FighterStatus.Active )
{
BroadcastAttackState( profile, active: false, isHeavy );
_isExecutingStrike = false;
return;
}
PerformAttackTrace( profile, eyePos, aimDir, isHeavy );
// Priorité à FullBodyDuration pour le kick si renseigné, sinon repli sur le Cooldown
float animDuration = (profile.IsKickAnim && profile.FullBodyDuration > 0f)
? profile.FullBodyDuration
: profile.Cooldown;
var waitTime = MathF.Max( 0.05f, animDuration - profile.WindupDelay );
await Task.Delay( (int)(waitTime * 1000f) );
if ( IsValid )
BroadcastAttackState( profile, active: false, isHeavy );
// Si le cooldown complet dépasse l'animation, on attend la fin de la fenêtre de récupération
if ( profile.Cooldown > animDuration )
{
await Task.Delay( (int)((profile.Cooldown - animDuration) * 1000f) );
}
_isExecutingStrike = false;
_timeSinceLastAttack = 0f;
}
private void PerformAttackTrace( AttackProfile profile, Vector3 eyePos, Vector3 aimDir, bool isHeavy )
{
var aimRotation = Rotation.LookAt( aimDir );
var attackOrigin = ResolveOrigin( profile, eyePos, aimRotation );
_hitFightersCache.Clear();
_hitWeaponsCache.Clear();
_hitBodiesCache.Clear();
bool worldImpactPlayed = false;
bool hitAnything = false;
foreach ( var rayDir in profile.GenerateRays( aimDir ) )
{
var targetEnd = attackOrigin + rayDir * profile.Range;
var allHits = Scene.Trace.Sphere( profile.Radius, attackOrigin, targetEnd )
.UseHitboxes()
.IgnoreGameObjectHierarchy( GameObject )
.RunAll();
if ( ShowDebugTraces )
{
var primaryTrace = Scene.Trace.Sphere( profile.Radius, attackOrigin, targetEnd )
.UseHitboxes()
.IgnoreGameObjectHierarchy( GameObject )
.Run();
DebugOverlay.Trace( primaryTrace, DebugTraceDuration, overlay: true );
}
foreach ( var trace in allHits )
{
if ( !trace.Hit ) continue;
hitAnything = true;
var victim = trace.GameObject?.Components.Get<Fighter>( FindMode.EverythingInSelfAndAncestors )
?? trace.GameObject?.Components.GetInParent<Fighter>();
// 1. Touche un combattant ennemi
if ( victim != null && victim != _fighter && !victim.IsDead )
{
if ( _hitFightersCache.Add( victim ) )
{
var fleshSound = profile.IsKickAnim
? (KickHitFleshSound ?? profile.HitSoundOverride)
: (profile.HitSoundOverride ?? ActiveProfile?.HitFleshSound);
if ( fleshSound != null )
Sound.Play( fleshSound, trace.HitPosition );
if ( profile.HitParticlePrefab != null )
profile.HitParticlePrefab.Clone( trace.HitPosition, Rotation.LookAt( trace.Normal ) );
ApplyHitToVictim( profile, victim, trace, rayDir, isHeavy );
}
continue;
}
// 2. Touche une arme au sol (Kick & Pop)
var weapon = trace.GameObject?.Components.Get<Weapon>( FindMode.EverythingInSelfAndAncestors );
if ( weapon != null && !weapon.IsEquipped && profile.IsKickAnim )
{
if ( _hitWeaponsCache.Add( weapon ) )
weapon.ReceiveKick( attackOrigin, rayDir, _fighter );
continue;
}
// 3. Touche un prop physique ou le décor
var propBody = trace.Body ?? trace.GameObject?.Components.Get<Rigidbody>( FindMode.EverythingInSelfAndAncestors )?.PhysicsBody;
if ( propBody != null && propBody.BodyType == PhysicsBodyType.Dynamic && _hitBodiesCache.Add( propBody ) )
{
if ( trace.GameObject.Components.Get<Fighter>( FindMode.EverythingInSelfAndAncestors ) == null &&
trace.GameObject.Components.Get<Weapon>( FindMode.EverythingInSelfAndAncestors ) == null )
{
var propLaunchDir = (rayDir + Vector3.Up * profile.PropUpwardLift).Normal;
var impulseMult = isHeavy ? 1.6f : 1.0f;
propBody.ApplyImpulseAt( trace.HitPosition, propLaunchDir * (profile.PropPhysicsImpulse * impulseMult) );
if ( !worldImpactPlayed )
{
var worldSound = profile.IsKickAnim
? (KickHitWorldSound ?? ActiveProfile?.HitWorldSound)
: ActiveProfile?.HitWorldSound;
if ( worldSound != null )
{
Sound.Play( worldSound, trace.HitPosition );
worldImpactPlayed = true;
}
}
}
}
else if ( _hitFightersCache.Count == 0 && !worldImpactPlayed && trace.Surface != null )
{
var worldSound = profile.IsKickAnim
? (KickHitWorldSound ?? ActiveProfile?.HitWorldSound)
: ActiveProfile?.HitWorldSound;
if ( worldSound != null )
{
Sound.Play( worldSound, trace.HitPosition );
worldImpactPlayed = true;
}
}
}
}
// =========================================================================
// WALL KICK AÉRIEN
// =========================================================================
if ( profile.IsKickAnim && hitAnything )
{
var movement = _movement ?? Components.Get<FighterMovement>();
if ( movement != null && !movement.IsGrounded )
{
var viewDir = GetCurrentViewDirection( aimDir );
movement.ApplyWallKick( viewDir );
}
}
}
private Vector3 GetCurrentViewDirection( Vector3 fallbackDir )
{
var player = _playerController ?? Components.Get<PlayerController>();
if ( player != null )
{
if ( player.Camera != null && player.Camera.IsValid )
return player.Camera.WorldRotation.Forward;
if ( player.EyePivot != null && player.EyePivot.IsValid )
return player.EyePivot.WorldRotation.Forward;
}
return fallbackDir.LengthSquared > 0.001f ? fallbackDir : WorldRotation.Forward;
}
private void ApplyHitToVictim( AttackProfile profile, Fighter victim, SceneTraceResult trace, Vector3 attackRayDir, bool isHeavy )
{
var rend = trace.GameObject?.Components.Get<SkinnedModelRenderer>( FindMode.EverythingInSelfAndDescendants )
?? victim.Components.GetInChildren<SkinnedModelRenderer>();
var boneName = (rend?.Model != null && trace.Bone >= 0) ? rend.Model.GetBoneName( trace.Bone ) : "Unknown";
var hitZone = profile.IsKickAnim ? HitZone.Legs : CombatGeometry.ResolveHitZone( boneName );
var hitDirection = CombatGeometry.CalculateHitDirection( victim.WorldPosition, victim.WorldRotation, WorldPosition );
var balanceMult = MathX.Lerp( 0.5f, 1.0f, _fighter.BalanceRatio );
var horizontalDir = attackRayDir.WithZ( 0 ).Normal;
var fighterPushDir = (horizontalDir + Vector3.Up * profile.FighterUpwardLift).Normal;
var dmgMult = isHeavy ? HeavyDamageMultiplier : 1.0f;
var balMult = isHeavy ? HeavyBalanceMultiplier : 1.0f;
var knockMult = isHeavy ? HeavyKnockbackMultiplier : 1.0f;
var context = new DamageContext
{
Attacker = _fighter,
HealthDamage = profile.Damage * balanceMult * dmgMult,
BalanceDamage = profile.BalanceDamage * balanceMult * balMult,
KnockbackForce = fighterPushDir * (profile.FighterKnockbackForce * knockMult),
HitPosition = trace.HitPosition,
Zone = hitZone,
Direction = hitDirection,
Type = profile.Type,
IsHeavy = isHeavy,
CausesKnockdown = profile.CausesKnockdownOnStagger || profile.IsKickAnim || isHeavy,
ForceKnockdown = false
};
_fighter.AddAdrenaline( isHeavy ? 14f : 8f );
victim.ApplyDamage( context );
// Événements d'arène spécifiques au joueur (Rebound Strike & Air Knockdown)
if ( IsPlayer )
{
if ( _movement != null && _movement.IsReboundAirborne )
{
_movement.ConsumeReboundAirborne();
CombatEvents.EmitPlayerReboundStrike( _fighter, victim, context );
}
if ( _movement != null && !_movement.IsGrounded && context.CausesKnockdown )
{
CombatEvents.EmitPlayerAirKnockdown( _fighter, victim, context );
}
}
}
private void BroadcastAnticipationState( bool active, AttackProfile profile, bool isHeavy, float ratio )
{
for ( int i = 0; i < _cachedRenderers.Count; i++ )
{
var rend = _cachedRenderers[i];
if ( !rend.IsValid ) continue;
rend.Set( "attack_anticipating", active );
rend.Set( "is_kicking", profile.IsKickAnim );
rend.Set( "is_throwing", false );
rend.Set( "is_heavy", isHeavy );
rend.Set( "charge_ratio", ratio );
if ( profile.AnimIndex > 0 )
rend.Set( "attack_index", profile.AnimIndex );
if ( active )
rend.Set( "attack_released", false );
}
}
private async void BroadcastReleaseState( AttackProfile profile, bool isHeavy )
{
for ( int i = 0; i < _cachedRenderers.Count; i++ )
{
var rend = _cachedRenderers[i];
if ( !rend.IsValid ) continue;
rend.Set( "attack_anticipating", false );
rend.Set( "attack_released", true );
rend.Set( "is_kicking", profile.IsKickAnim );
rend.Set( "is_throwing", false );
rend.Set( "is_heavy", isHeavy );
if ( profile.AnimIndex > 0 )
rend.Set( "attack_index", profile.AnimIndex );
}
await Task.Delay( 40 );
if ( !IsValid ) return;
for ( int i = 0; i < _cachedRenderers.Count; i++ )
{
var rend = _cachedRenderers[i];
if ( rend.IsValid )
rend.Set( "attack_released", false );
}
}
private void BroadcastThrowAnticipation( bool active, float ratio )
{
for ( int i = 0; i < _cachedRenderers.Count; i++ )
{
var rend = _cachedRenderers[i];
if ( !rend.IsValid ) continue;
rend.Set( "is_throwing", true );
rend.Set( "attack_anticipating", active );
rend.Set( "charge_ratio", ratio );
if ( active )
rend.Set( "attack_released", false );
}
}
private async void BroadcastThrowRelease()
{
for ( int i = 0; i < _cachedRenderers.Count; i++ )
{
var rend = _cachedRenderers[i];
if ( !rend.IsValid ) continue;
rend.Set( "attack_anticipating", false );
rend.Set( "attack_released", true );
rend.Set( "is_throwing", true );
}
await Task.Delay( 40 );
if ( !IsValid ) return;
for ( int i = 0; i < _cachedRenderers.Count; i++ )
{
var rend = _cachedRenderers[i];
if ( rend.IsValid )
rend.Set( "attack_released", false );
}
}
private void BroadcastThrowReset()
{
for ( int i = 0; i < _cachedRenderers.Count; i++ )
{
var rend = _cachedRenderers[i];
if ( !rend.IsValid ) continue;
rend.Set( "is_throwing", false );
rend.Set( "attack_anticipating", false );
rend.Set( "attack_released", false );
rend.Set( "charge_ratio", 0f );
}
}
private void BroadcastAttackState( AttackProfile profile, bool active, bool isHeavy )
{
for ( int i = 0; i < _cachedRenderers.Count; i++ )
{
var rend = _cachedRenderers[i];
if ( !rend.IsValid ) continue;
if ( active )
{
rend.Set( "is_full_body_attack", profile.AffectWholeBody );
rend.Set( "full_body_weight", profile.AffectWholeBody ? 1.0f : 0.0f );
rend.Set( "is_heavy", isHeavy );
if ( profile.IsKickAnim )
{
rend.Set( "is_kicking", true );
rend.Set( "kick", true );
}
else if ( profile.AnimIndex > 0 )
{
rend.Set( "attack_index", profile.AnimIndex );
rend.Set( "attack", true );
}
}
else
{
rend.Set( "attack", false );
rend.Set( "kick", false );
rend.Set( "is_kicking", false );
rend.Set( "is_full_body_attack", false );
rend.Set( "full_body_weight", 0.0f );
rend.Set( "attack_released", false );
}
}
}
public void SyncWeaponsToAttachments()
{
if ( MainWeapon != null && MainWeapon.IsValid && MainWeapon.CurrentState == WeaponPhysicsState.Equipped )
AttachWeaponTo( MainWeapon, WeaponAttachmentName );
if ( ShieldItem != null && ShieldItem.IsValid && ShieldItem.CurrentState == WeaponPhysicsState.Equipped )
{
string socketToUse = (MainWeapon == null) ? WeaponAttachmentName : ShieldAttachmentName;
AttachWeaponTo( ShieldItem, socketToUse );
}
}
private void AttachWeaponTo( Weapon weapon, string attachmentName )
{
var attachTransform = ResolveAttachment( attachmentName );
if ( !attachTransform.HasValue ) return;
var attachRot = attachTransform.Value.Rotation;
var attachPos = attachTransform.Value.Position;
var profile = GetWeaponProfile( weapon );
var posOffset = profile?.GripPositionOffset ?? Vector3.Zero;
var rotOffset = profile?.GripAnglesOffset ?? Angles.Zero;
weapon.WorldPosition = attachPos + (attachRot * posOffset);
weapon.WorldRotation = attachRot * Rotation.From( rotOffset );
}
private WeaponComboResource GetWeaponProfile( Weapon weapon )
{
if ( weapon == null || !weapon.IsValid ) return null;
if ( !IsPlayer && weapon.BotComboData != null )
return weapon.BotComboData;
return weapon.ComboData;
}
private Transform? ResolveAttachment( string attachName )
{
if ( PreferredRenderer != null && PreferredRenderer.IsValid )
{
var attach = PreferredRenderer.GetAttachment( attachName );
if ( attach.HasValue ) return attach;
}
for ( int i = 0; i < _cachedRenderers.Count; i++ )
{
var rend = _cachedRenderers[i];
if ( !rend.IsValid ) continue;
var attach = rend.GetAttachment( attachName );
if ( attach.HasValue ) return attach;
}
return null;
}
private Vector3 ResolveOrigin( AttackProfile profile, Vector3 eyePos, Rotation aimRotation )
{
if ( !string.IsNullOrWhiteSpace( profile.SocketAttachment ) )
{
for ( int i = 0; i < _cachedRenderers.Count; i++ )
{
var rend = _cachedRenderers[i];
if ( !rend.IsValid ) continue;
var attach = rend.GetAttachment( profile.SocketAttachment );
if ( attach.HasValue )
return attach.Value.Position;
}
}
return eyePos + (aimRotation * profile.OriginOffset);
}
public void SetParry( bool active )
{
if ( _fighter != null && !_fighter.IsDead )
_fighter.SetParry( active );
}
public void EquipWeapon( Weapon weapon )
{
if ( IsExecutingThrow || IsThrowAnticipating )
{
_throwActionToken++;
IsExecutingThrow = false;
IsThrowAnticipating = false;
BroadcastThrowReset();
}
if ( weapon.Type == WeaponType.Shield )
{
if ( ShieldItem != null ) DropShield();
ShieldItem = weapon;
}
else
{
if ( MainWeapon != null ) DropMainWeapon();
MainWeapon = weapon;
}
RefreshRenderersCache();
var parentObj = PreferredRenderer?.GameObject ?? GameObject;
weapon.EquipTo( _fighter, parentObj );
_currentComboIndex = 0;
UpdateAnimWeaponType();
SyncWeaponsToAttachments();
}
public void DropMainWeapon( Vector3? customVelocity = null )
{
CancelAnticipation();
if ( MainWeapon == null || !MainWeapon.IsValid ) return;
var attach = ResolveAttachment( WeaponAttachmentName );
Vector3 dropPos;
Rotation dropRot;
if ( attach.HasValue )
{
dropPos = attach.Value.Position;
dropRot = attach.Value.Rotation;
}
else if ( _fighter != null && _fighter.IsValid )
{
dropPos = _fighter.WorldPosition + Vector3.Up * 45f + WorldRotation.Forward * 20f;
dropRot = WorldRotation;
}
else
{
dropPos = MainWeapon.WorldPosition;
dropRot = WorldRotation;
}
var vel = customVelocity ?? (WorldRotation.Forward * 140f + Vector3.Up * 80f);
MainWeapon.Drop( vel, isLethalThrow: false, dropPosition: dropPos, dropRotation: dropRot );
MainWeapon = null;
_currentComboIndex = 0;
UpdateAnimWeaponType();
}
public void DropShield( Vector3? customVelocity = null )
{
CancelAnticipation();
if ( ShieldItem == null || !ShieldItem.IsValid ) return;
string socket = (MainWeapon == null) ? WeaponAttachmentName : ShieldAttachmentName;
var attach = ResolveAttachment( socket );
Vector3 dropPos;
Rotation dropRot;
if ( attach.HasValue )
{
dropPos = attach.Value.Position;
dropRot = attach.Value.Rotation;
}
else if ( _fighter != null && _fighter.IsValid )
{
dropPos = _fighter.WorldPosition + Vector3.Up * 45f - WorldRotation.Right * 20f;
dropRot = WorldRotation;
}
else
{
dropPos = ShieldItem.WorldPosition;
dropRot = WorldRotation;
}
var vel = customVelocity ?? (-WorldRotation.Right * 80f + Vector3.Up * 70f);
ShieldItem.Drop( vel, isLethalThrow: false, dropPosition: dropPos, dropRotation: dropRot );
ShieldItem = null;
_currentComboIndex = 0;
UpdateAnimWeaponType();
}
public void DropAllWeapons()
{
CancelAnticipation();
DropMainWeapon( Vector3.Down * 30f );
DropShield( Vector3.Down * 30f );
}
public void UpdateAnimWeaponType( WeaponType? explicitType = null )
{
WeaponType activeType = WeaponType.Unarmed;
if ( explicitType.HasValue )
{
activeType = explicitType.Value;
}
else if ( MainWeapon != null && MainWeapon.IsValid )
{
activeType = MainWeapon.Type;
}
else if ( ShieldItem != null && ShieldItem.IsValid )
{
activeType = ShieldItem.Type;
}
bool hasShield = ShieldItem != null && ShieldItem.IsValid;
for ( int i = 0; i < _cachedRenderers.Count; i++ )
{
var rend = _cachedRenderers[i];
if ( rend.IsValid )
{
rend.Set( "weapon_type", (int)activeType );
rend.Set( "has_shield", hasShield );
}
}
}
}