fighter/fighter_movement.cs
using System;
using System.Collections.Generic;
using Sandbox;

public sealed class FighterMovement : Component
{
    [Property] public CharacterController Controller { get; set; }
    [Property] public NavMeshAgent Agent { get; set; }
    [Property] public SkinnedModelRenderer AnimRenderer { get; set; }

    [Property, Group( "Vitesse" )] public float WalkSpeed { get; set; } = 190f;
    [Property, Group( "Vitesse" )] public float SprintSpeed { get; set; } = 280f;
    [Property, Group( "Vitesse" )] public float CrouchSpeed { get; set; } = 100f;
    [Property, Group( "Vitesse" )] public float Acceleration { get; set; } = 12f;
    [Property, Group( "Vitesse" )] public float GroundFriction { get; set; } = 5f;
    [Property, Group( "Vitesse" )] public float JumpImpulse { get; set; } = 320f;

    [Property, Group( "Collision Corporelle" )] public float BodyRadius { get; set; } = 24f;
    [Property, Group( "Collision Corporelle" )] public float PushStrength { get; set; } = 15f;

    [Property, Group( "Wall Kick" )] public float WallKickBackwardForce { get; set; } = 340f;
    [Property, Group( "Wall Kick" )] public float WallKickUpwardForce { get; set; } = 160f;
    [Property, Group( "Wall Kick" )] public SoundEvent WallKickSound { get; set; }

    [Property, Group( "Animation Blendspace 2D" )] public float BlendReferenceSpeed { get; set; } = 140f;
    [Property, Group( "Animation Blendspace 2D" )] public float ForwardMultiplier { get; set; } = 1.0f;
    [Property, Group( "Animation Blendspace 2D" )] public float StrafeMultiplier { get; set; } = 1.25f;
    [Property, Group( "Animation Blendspace 2D" )] public float MinMoveThreshold { get; set; } = 6f;
    [Property, Range( 1f, 30f ), Group( "Animation Blendspace 2D" )] public float SmoothingSpeed { get; set; } = 10f;

    [Property, Group( "Audio Pas & Surfaces" )] public float StepDistanceWalk { get; set; } = 120f;
    [Property, Group( "Audio Pas & Surfaces" )] public float StepDistanceSprint { get; set; } = 170f;
    [Property, Group( "Audio Pas & Surfaces" )] public float StepFootWidthOffset { get; set; } = 14f;
    [Property, Group( "Audio Pas & Surfaces" )] public SoundEvent FootstepSand { get; set; }
    [Property, Group( "Audio Pas & Surfaces" )] public SoundEvent FootstepStone { get; set; }
    [Property, Group( "Audio Pas & Surfaces" )] public SoundEvent FootstepWood { get; set; }
    [Property, Group( "Audio Pas & Surfaces" )] public SoundEvent FootstepDefault { get; set; }
    [Property, Group( "Audio Pas & Surfaces" )] public SoundEvent JumpLandSound { get; set; }

    public Vector3 WishVelocity { get; set; }
    public bool WantsSprint { get; set; }
    public bool WantsCrouch { get; set; }

    public bool IsGrounded
    {
        get
        {
            if ( Agent != null && Agent.Enabled ) return true;
            return Controller?.IsOnGround ?? true;
        }
    }

    public bool IsJumping => !IsGrounded;
    public bool IsReboundAirborne { get; private set; } = false;

    private float _defaultHeight;
    private Fighter _fighter;
    private Vector3 _lastFixedPosition;
    private Vector3 _measuredVelocity;

    private float _smoothMoveX;
    private float _smoothMoveY;
    private float _smoothSpeed;

    private float _stepDistanceAccumulator = 0f;
    private bool _stepLeftLeg = false;
    private bool _wasGroundedLastFrame = true;

    private readonly List<SkinnedModelRenderer> _cachedRenderers = new();

    protected override void OnAwake()
    {
        Controller ??= Components.Get<CharacterController>();
        Agent ??= Components.Get<NavMeshAgent>();
        _fighter = Components.Get<Fighter>();
        RefreshRenderers();
    }

    protected override void OnStart()
    {
        if ( Controller != null )
            _defaultHeight = Controller.Height;

        RefreshRenderers();
        _lastFixedPosition = WorldPosition;
    }

    public void RefreshRenderers()
    {
        _cachedRenderers.Clear();
        _cachedRenderers.AddRange( Components.GetAll<SkinnedModelRenderer>( FindMode.EverythingInSelfAndDescendants ) );

        if ( AnimRenderer == null || !AnimRenderer.IsValid )
        {
            foreach ( var rend in _cachedRenderers )
            {
                if ( rend.Model != null && !rend.Model.IsError )
                {
                    AnimRenderer = rend;
                    break;
                }
            }
            AnimRenderer ??= Components.Get<SkinnedModelRenderer>( FindMode.EverythingInSelfAndDescendants );
        }
    }

    protected override void OnFixedUpdate()
    {
        if ( Time.Delta > 0.0001f )
        {
            var deltaMovement = WorldPosition - _lastFixedPosition;
            _measuredVelocity = deltaMovement / Time.Delta;
        }
        _lastFixedPosition = WorldPosition;

        ResolveFighterCollisions();

        if ( Agent != null && Agent.Enabled ) return;
        if ( Controller == null ) return;

        if ( _fighter != null && _fighter.Status != FighterStatus.Active )
        {
            WishVelocity = Vector3.Zero;
        }

        Controller.Height = WantsCrouch ? (_defaultHeight * 0.55f) : _defaultHeight;

        if ( Controller.IsOnGround )
        {
            IsReboundAirborne = false;

            if ( Controller.Velocity.z < 0f )
                Controller.Velocity = Controller.Velocity.WithZ( 0 );

            Controller.ApplyFriction( GroundFriction );

            var speed = WantsCrouch ? CrouchSpeed : (WantsSprint ? SprintSpeed : WalkSpeed);
            Controller.Accelerate( WishVelocity.ClampLength( 1f ) * speed );
        }
        else
        {
            Controller.Velocity += Scene.PhysicsWorld.Gravity * Time.Delta;
            Controller.Accelerate( WishVelocity.ClampLength( 1f ) * (WalkSpeed * 0.25f) );
        }

        Controller.Move();
    }

    private void ResolveFighterCollisions()
    {
        if ( _fighter == null || _fighter.IsDead ) return;

        foreach ( var other in Scene.GetAllComponents<Fighter>() )
        {
            if ( other == _fighter || !other.IsValid || other.IsDead ) continue;

            var delta = WorldPosition.WithZ( 0 ) - other.WorldPosition.WithZ( 0 );
            var dist = delta.Length;
            var targetDist = BodyRadius * 2f;

            if ( dist < targetDist && dist > 0.001f )
            {
                var overlap = targetDist - dist;
                var pushDir = delta / dist;

                if ( Controller != null && Controller.Enabled && (Agent == null || !Agent.Enabled) )
                {
                    Controller.Punch( pushDir * (overlap * PushStrength) );
                }
                else if ( Agent != null && Agent.Enabled )
                {
                    WorldPosition += pushDir * (overlap * 0.5f);
                }
            }
        }
    }

    protected override void OnUpdate()
    {
        UpdateAnimationParameters();
        UpdateProceduralFootsteps();
    }

    private void UpdateAnimationParameters()
    {
        if ( _cachedRenderers.Count == 0 )
            RefreshRenderers();

        Vector3 currentVelocity = GetActiveVelocity();
        var horizontalVel = currentVelocity.WithZ( 0 );
        var speedMagnitude = horizontalVel.Length;

        float targetMoveX = 0f;
        float targetMoveY = 0f;

        if ( speedMagnitude > MinMoveThreshold )
        {
            var localVel = WorldRotation.Inverse * horizontalVel;
            var refSpeed = MathF.Max( 10f, BlendReferenceSpeed );

            targetMoveX = Math.Clamp( (localVel.x / refSpeed) * ForwardMultiplier, -1f, 1f );
            targetMoveY = Math.Clamp( (-localVel.y / refSpeed) * StrafeMultiplier, -1f, 1f );
        }

        var blend = Math.Clamp( SmoothingSpeed * Time.Delta, 0f, 1f );
        _smoothMoveX = MathX.Lerp( _smoothMoveX, targetMoveX, blend );
        _smoothMoveY = MathX.Lerp( _smoothMoveY, targetMoveY, blend );
        _smoothSpeed = MathX.Lerp( _smoothSpeed, speedMagnitude, blend );

        bool isGrounded = IsGrounded;
        bool isJumping = IsJumping;

        for ( int i = 0; i < _cachedRenderers.Count; i++ )
        {
            var rend = _cachedRenderers[i];
            if ( !rend.IsValid ) continue;

            rend.Set( "move_x", _smoothMoveX );
            rend.Set( "move_y", _smoothMoveY );
            rend.Set( "speed", _smoothSpeed );
            rend.Set( "is_grounded", isGrounded );
            rend.Set( "is_jumping", isJumping );
        }
    }

    private Vector3 GetActiveVelocity()
    {
        if ( Agent != null && Agent.Enabled )
        {
            if ( Agent.Velocity.LengthSquared > 1f )
                return Agent.Velocity;

            return _measuredVelocity;
        }

        if ( Controller != null && Controller.Enabled )
            return Controller.Velocity;

        return _measuredVelocity;
    }

    private void UpdateProceduralFootsteps()
    {
        if ( _fighter != null && (_fighter.IsDead || _fighter.Status != FighterStatus.Active) )
        {
            _stepDistanceAccumulator = 0f;
            return;
        }

        bool isGrounded = IsGrounded;

        if ( !_wasGroundedLastFrame && isGrounded )
        {
            PlayLandingSound();
            _stepDistanceAccumulator = 0f;
        }
        _wasGroundedLastFrame = isGrounded;

        if ( !isGrounded ) return;

        var horizontalSpeed = GetActiveVelocity().WithZ( 0 ).Length;
        if ( horizontalSpeed < MinMoveThreshold )
        {
            _stepDistanceAccumulator = MathF.Max( 0f, _stepDistanceAccumulator - Time.Delta * 40f );
            return;
        }

        float currentStepThreshold = WantsSprint ? StepDistanceSprint : (WantsCrouch ? StepDistanceWalk * 0.75f : StepDistanceWalk);
        _stepDistanceAccumulator += horizontalSpeed * Time.Delta;

        if ( _stepDistanceAccumulator >= currentStepThreshold )
        {
            _stepDistanceAccumulator = 0f;
            _stepLeftLeg = !_stepLeftLeg;
            TriggerFootstep( _stepLeftLeg );
        }
    }

    private void TriggerFootstep( bool leftFoot )
    {
        var sideOffset = leftFoot ? -WorldRotation.Right * StepFootWidthOffset : WorldRotation.Right * StepFootWidthOffset;
        var footOrigin = WorldPosition + sideOffset + Vector3.Up * 25f;

        var tr = Scene.Trace.Ray( footOrigin, footOrigin + Vector3.Down * 60f )
            .IgnoreGameObjectHierarchy( GameObject )
            .Run();

        var hitPos = tr.Hit ? tr.HitPosition : (WorldPosition + sideOffset);
        var surfaceName = tr.Hit ? tr.Surface?.ResourceName : null;

        var soundToPlay = ResolveSurfaceSound( surfaceName );
        if ( soundToPlay == null ) return;

        var snd = Sound.Play( soundToPlay, hitPos );
        if ( Components.Get<PlayerController>() != null && snd != null )
        {
            snd.Volume = WantsCrouch ? 0.45f : 0.85f;
        }
    }

    private void PlayLandingSound()
    {
        var tr = Scene.Trace.Ray( WorldPosition + Vector3.Up * 25f, WorldPosition + Vector3.Down * 60f )
            .IgnoreGameObjectHierarchy( GameObject )
            .Run();

        var hitPos = tr.Hit ? tr.HitPosition : WorldPosition;
        var surfaceName = tr.Hit ? tr.Surface?.ResourceName : null;

        var landSound = JumpLandSound ?? ResolveSurfaceSound( surfaceName );
        if ( landSound != null )
            Sound.Play( landSound, hitPos );
    }

    private SoundEvent ResolveSurfaceSound( string surfaceName )
    {
        if ( string.IsNullOrEmpty( surfaceName ) )
            return FootstepDefault ?? FootstepStone;

        var lower = surfaceName.ToLowerInvariant();
        if ( lower.Contains( "sand" ) || lower.Contains( "dirt" ) || lower.Contains( "gravel" ) )
            return FootstepSand ?? FootstepDefault;
        if ( lower.Contains( "wood" ) || lower.Contains( "plank" ) )
            return FootstepWood ?? FootstepDefault;
        if ( lower.Contains( "stone" ) || lower.Contains( "rock" ) || lower.Contains( "concrete" ) || lower.Contains( "tile" ) )
            return FootstepStone ?? FootstepDefault;

        return FootstepDefault ?? FootstepStone;
    }

    public void Jump()
    {
        if ( Controller != null && Controller.IsOnGround && _fighter?.Status == FighterStatus.Active )
        {
            Controller.Punch( Vector3.Up * JumpImpulse );
            AnimRenderer?.Set( "jump", true );
        }
    }

    public void ApplyWallKick( Vector3 viewForward )
    {
        IsReboundAirborne = true;

        var backDir = -viewForward.Normal;
        var impulse = backDir * WallKickBackwardForce + Vector3.Up * WallKickUpwardForce;

        if ( Controller != null && Controller.Enabled && (Agent == null || !Agent.Enabled) )
        {
            Controller.Punch( impulse );
        }
        else if ( Agent != null && Agent.Enabled )
        {
            WorldPosition += impulse.WithZ( 0 ) * 0.015f;
        }

        if ( WallKickSound != null )
        {
            Sound.Play( WallKickSound, WorldPosition );
        }
    }

    public void ConsumeReboundAirborne()
    {
        IsReboundAirborne = false;
    }

    public void ApplyKnockback( Vector3 force )
    {
        if ( Controller != null && Controller.Enabled && (Agent == null || !Agent.Enabled) )
        {
            Controller.Punch( force );
        }
        else
        {
            WorldPosition += force.WithZ( 0 ) * 0.012f;
        }

        AnimRenderer?.Set( "hit_flinch", true );
    }
}