CloseCombat.cs
using Sandbox;
using Sandbox.Citizen;
namespace PaintballBaddies;

/// <summary>Press aim within reach for a gun-butt strike; five rapid presses recover.</summary>
public sealed class CloseCombat : Component
{
    public const float Reach=56;
    public const int RequiredRecoveryPresses=5;
    public const float RecoveryPressGap=.42f;
    public const float AttackDuration=.68f, ImpactTime=.28f, FallDuration=1.30f, GetUpDuration=1.25f;
    public const string ImpactSound="sounds/contact/elbow_impact.sound";
    public bool Downed { get; private set; }
    public bool Recovering => recovery>0;
    public bool Incapacitated => Downed || Recovering;
    public bool Attacking => attackTime>0;
    public bool ReadyToRecover => Downed && downTime>=FallDuration;
    public int RecoveryPresses { get; private set; }
    public int Contacts { get; private set; }
    public int Knockdowns { get; private set; }
    public int ImpactSounds { get; private set; }
    private int receivedContacts,lastReceivedContacts;
    public float FallProgress => (downTime/FallDuration).Clamp(0,1);
    public float GetUpProgress => Recovering ? (1-recovery/GetUpDuration).Clamp(0,1) : 0;
    // Older editor pose probes still compile; production uses the actual fall clip.
    public CitizenAnimationHelper.SittingStyle KnockPose { get; set; }=CitizenAnimationHelper.SittingStyle.Floor4;
    private float cooldown,attackTime,downTime,recovery,knockImmunity,aiDecision,pressGap,aiRecovery;
    private bool impactPending,inputBefore,movementHeld;
    private PaintballCombatant pendingTarget;
    private Vector3 push;
    private Vector3 stepDirection;
    private float stepDistance,stepApplied;
    private Rotation motionFacing;
    private PlayerController player;
    private PaintballCombatant actor;
    private Rotation Facing => player.IsValid() ? Rotation.FromYaw(player.EyeAngles.yaw) : WorldRotation;
    private bool MenuOpen => player.IsValid() && !IsProxy && Scene.GetAllComponents<PaintballControls>().FirstOrDefault()?.Open==true;
    protected override void OnStart(){player=Components.Get<PlayerController>();actor=Components.Get<PaintballCombatant>();}
    protected override void OnUpdate()
    {
        if(MultiplayerSession.Online && !MultiplayerSession.Authority)
        {
            float delta=Time.Delta;
            attackTime=System.MathF.Max(0,attackTime-delta);recovery=System.MathF.Max(0,recovery-delta);
            if(Downed)downTime+=delta;
            if(!IsProxy)
            {
                if(Incapacitated || Attacking)HoldMovement();else RestoreMovement();
                if(!MenuOpen)ObserveAimInput(Input.Pressed("attack2"),Input.Down("attack2"),Input.Released("attack2"),delta);
            }
            return;
        }
        if(!actor.IsValid())actor=Components.Get<PaintballCombatant>();
        if(actor?.Eliminated==true || actor?.AcceptHits==false){ResetState();return;}
        // A network menu gates local input, never authoritative combat clocks.
        // Offline settings already pause the scene itself.
        float dt=Time.Delta;
        cooldown=System.MathF.Max(0,cooldown-dt);knockImmunity=System.MathF.Max(0,knockImmunity-dt);
        if(Downed)
        {
            downTime+=dt;HoldMovement();pressGap+=dt;
            if(RecoveryPresses>0 && pressGap>RecoveryPressGap)RecoveryPresses=0;
            if(player.IsValid() && !IsProxy)ObserveAimInput(Input.Pressed("attack2"),Input.Down("attack2"),Input.Released("attack2"),dt);
            else if(ReadyToRecover && Components.Get<ArenaOpponent>() is {Enabled:true,CombatEnabled:true})
            {
                aiRecovery-=dt;
                if(aiRecovery<=0){RegisterRecoveryPress();aiRecovery=Game.Random.Float(.13f,.23f);}
            }
            return;
        }
        if(Recovering)
        {
            recovery=System.MathF.Max(0,recovery-dt);HoldMovement();
            if(recovery==0){knockImmunity=1.5f;RestoreMovement();}
            return;
        }
        if(Attacking)
        {
            attackTime=System.MathF.Max(0,attackTime-dt);HoldMovement();
            if(impactPending && AttackDuration-attackTime>=ImpactTime)
            {
                impactPending=false;
                if(CanContact(pendingTarget,Reach+10))
                {
                    var victim=pendingTarget.Components.Get<CloseCombat>();
                    if(victim.IsValid() && !victim.Incapacitated)
                    {
                        var shieldHit=ArenaShield.TryAbsorbMelee(pendingTarget,WorldPosition);
                        bool knock=shieldHit!=ArenaShield.MeleeResult.Absorbed && victim.knockImmunity<=0;
                        victim.ReceiveContact(WorldPosition,knock);
                        Contacts++;if(knock)Knockdowns++;
                        string sound=shieldHit==ArenaShield.MeleeResult.None ? ImpactSound : "sounds/contact/shield_bash.sound";
                        if(MultiplayerSession.Online)NetworkEffects.Sound(sound,pendingTarget.WorldPosition+Vector3.Up*42,System.Guid.Empty);
                        else Sound.Play(sound,pendingTarget.WorldPosition+Vector3.Up*42);
                        ImpactSounds++;
                    }
                }
            }
            if(!Attacking)RestoreMovement();
        }
        if(player.IsValid())
        {
            if(IsProxy)return;
            if(Components.Get<PaintballMarker>()?.AcceptInput!=true){return;}
            ObserveAimInput(Input.Pressed("attack2"),Input.Down("attack2"),Input.Released("attack2"),dt);
        }
        else
        {
            aiDecision-=dt;
            if(aiDecision<=0)
            {
                aiDecision=Game.Random.Float(.35f,.6f);
                if(Components.Get<ArenaOpponent>() is {Enabled:true,CombatEnabled:true,UsingCover:false} && Game.Random.Float(0,1)<.45f)TryAttack();
            }
        }
    }
    /// <summary>Action edges count; a held button never produces repeated taps.</summary>
    public void ObserveAimInput(bool justPressed,bool held,bool released,float dt)
    {
        if(MenuOpen || Recovering){return;}
        if(Downed){if(justPressed)RegisterRecoveryPress();return;}
        // Decide on the press, not a tiny release window. At range this leaves
        // the native aim action alone; holding never repeats an elbow.
        if(justPressed && FindContactTarget().IsValid())TryAttack();
    }
    public bool CanContact(PaintballCombatant other)
        => CanContact(other,Reach);
    private bool CanContact(PaintballCombatant other,float reach)
    {
        if(!actor.IsValid())actor=Components.Get<PaintballCombatant>();
        if(!actor.IsValid() || !other.IsValid() || other==actor || other.Team==actor.Team || !other.AcceptHits || other.Eliminated)return false;
        var offset=other.WorldPosition-WorldPosition;
        var facing=Attacking ? motionFacing : Facing;
        if(offset.WithZ(0).Length>reach || System.MathF.Abs(offset.z)>30 || Vector3.Dot(facing.Forward,offset.WithZ(0).Normal)<.5f)return false;
        var obstruction=Scene.Trace.Sphere(2,WorldPosition+Vector3.Up*38,other.WorldPosition+Vector3.Up*38)
            .IgnoreGameObjectHierarchy(GameObject).IgnoreGameObjectHierarchy(other.GameObject).WithoutTags("paintball_debris").Run();
        return !obstruction.Hit;
    }
    private PaintballCombatant FindContactTarget()=>Scene.GetAllComponents<PaintballCombatant>().Where(x=>CanContact(x))
        .Where(x=>x.Components.Get<CloseCombat>()?.Incapacitated!=true)
        .OrderBy(x=>(x.WorldPosition-WorldPosition).Length).FirstOrDefault();
    public bool TryAttack()
    {
        if(MultiplayerSession.Online && !MultiplayerSession.Authority)
        {
            if(IsProxy || MenuOpen || Incapacitated || Attacking)return false;
            Components.Get<NetworkPawn>()?.RequestElbow();return true;
        }
        return TryAttackAuthoritative();
    }
    internal bool TryAttackAuthoritative()
    {
        if(!actor.IsValid())actor=Components.Get<PaintballCombatant>();
        if(MenuOpen || actor?.AcceptHits!=true || cooldown>0 || Incapacitated || Attacking || Components.Get<VaultController>()?.IsVaulting==true || Components.Get<CoverController>()?.Attached==true)return false;
        pendingTarget=FindContactTarget();
        if(!pendingTarget.IsValid())return false;
        cooldown=1.2f;attackTime=AttackDuration;impactPending=true;motionFacing=Facing;
        var offset=(pendingTarget.WorldPosition-WorldPosition).WithZ(0);
        stepDirection=offset.Normal;stepDistance=(offset.Length-30).Clamp(0,26);stepApplied=0;
        HoldMovement();return true;
    }
    public void ReceiveContact(Vector3 source,bool knock)
    {
        if(Incapacitated)return;
        receivedContacts++;
        Components.Get<CoverController>()?.Leave();Components.Get<VaultController>()?.Cancel();
        Components.Get<ArenaOpponent>()?.InterruptForContact();
        attackTime=0;impactPending=false;pendingTarget=null;
        var away=(WorldPosition-source).WithZ(0).Normal;
        var to=WorldPosition+away*(knock ? 20 : 12);
        var clear=Scene.Trace.Sphere(15,WorldPosition+Vector3.Up*25,to+Vector3.Up*25).IgnoreGameObjectHierarchy(GameObject).Run();
        if(!clear.Hit)push=away*(knock ? 150 : 85);
        var voice=Components.Get<CharacterVoice>();
        if(knock)voice?.PlayKnockdown();else voice?.TryPlay();
        if(knock)
        {
            ArenaShield.ReleaseForKnockdown(Components.Get<PaintballCombatant>());
            motionFacing=Facing;Downed=true;downTime=0;RecoveryPresses=0;pressGap=0;aiRecovery=Game.Random.Float(.25f,.55f);
            HoldMovement();if(player.IsValid())player.UpdateDucking(true);
        }
        else RestoreMovement();
    }
    protected override void OnFixedUpdate()
    {
        if(IsProxy)return;
        if(Attacking && stepApplied<stepDistance)
        {
            float desired=stepDistance*Ease((AttackDuration-attackTime)/ImpactTime);
            var stepTarget=WorldPosition+stepDirection*(desired-stepApplied);
            var bounds=new BBox(new Vector3(-14,-14,4),new Vector3(14,14,62));
            var move=Scene.Trace.Box(bounds,WorldPosition,stepTarget).IgnoreGameObjectHierarchy(GameObject).WithoutTags("paintball_debris").Run();
            var ground=Scene.Trace.Ray(stepTarget+Vector3.Up*12,stepTarget-Vector3.Up*16).IgnoreGameObjectHierarchy(GameObject).Run();
            if(!move.Hit && ground.Hit)WorldPosition=stepTarget;
            stepApplied=desired;
        }
        if(push.Length<1)return;
        var to=WorldPosition+push*Time.Delta;
        var trace=Scene.Trace.Sphere(15,WorldPosition+Vector3.Up*22,to+Vector3.Up*22).IgnoreGameObjectHierarchy(GameObject).Run();
        if(!trace.Hit)WorldPosition=to;
        push*=System.MathF.Exp(-7*Time.Delta);
    }
    public bool RegisterRecoveryPress()
    {
        if(MultiplayerSession.Online && !MultiplayerSession.Authority)
        {
            if(IsProxy || MenuOpen || !ReadyToRecover)return false;
            Components.Get<NetworkPawn>()?.RequestRecovery();return true;
        }
        return RegisterRecoveryAuthoritative();
    }
    internal bool RegisterRecoveryAuthoritative()
    {
        if(MenuOpen || !ReadyToRecover || (RecoveryPresses>0 && pressGap<.055f))return false;
        if(pressGap>RecoveryPressGap)RecoveryPresses=0;
        RecoveryPresses++;pressGap=0;
        if(RecoveryPresses>=RequiredRecoveryPresses)GetUp();
        return true;
    }
    public bool GetUp()
    {
        if(!ReadyToRecover || RecoveryPresses<RequiredRecoveryPresses)return false;
        Downed=false;recovery=GetUpDuration;push=Vector3.Zero;return true;
    }
    private void HoldMovement()
    {
        if(!player.IsValid() || IsProxy)return;
        if(!movementHeld){inputBefore=player.UseInputControls;movementHeld=true;}
        player.UseInputControls=false;player.WishVelocity=Vector3.Zero;
        if(player.Body.IsValid())player.Body.Velocity=player.Body.Velocity.WithX(0).WithY(0);
    }
    private void RestoreMovement()
    {
        if(player.IsValid() && movementHeld)
        {
            player.UpdateDucking(false);
            player.UseInputControls=inputBefore && Components.Get<PaintballMarker>()?.AcceptInput!=false;
            player.WishVelocity=Vector3.Zero;
        }
        movementHeld=false;
    }
    public void ResetState()
    {
        RestoreMovement();Downed=false;
        recovery=attackTime=downTime=cooldown=knockImmunity=pressGap=0;RecoveryPresses=0;
        stepDistance=stepApplied=0;
        impactPending=false;pendingTarget=null;push=Vector3.Zero;
    }
    private sealed class ContactSnapshot
    {
        public bool Down {get;set;}
        public float Attack {get;set;}
        public float Fall {get;set;}
        public float Recovery {get;set;}
        public int Presses {get;set;}
        public Vector3 Push {get;set;}
        public Rotation Facing {get;set;}
        public Vector3 Step {get;set;}
        public float Distance {get;set;}
        public int HitSequence {get;set;}
    }
    private string lastNetworkState;
    internal string CaptureNetworkState()=>Json.Serialize(new ContactSnapshot{Down=Downed,Attack=attackTime,Fall=downTime,Recovery=recovery,
        Presses=RecoveryPresses,Push=push,Facing=motionFacing,Step=stepDirection,Distance=stepDistance,HitSequence=receivedContacts});
    internal void ApplyNetworkState(string json)
    {
        if(string.IsNullOrEmpty(json) || json==lastNetworkState)return;
        var state=Json.Deserialize<ContactSnapshot>(json);lastNetworkState=json;
        if(lastReceivedContacts!=state.HitSequence){lastReceivedContacts=state.HitSequence;push=state.Push;}
        if(state.Attack>0 && !Attacking)stepApplied=0;
        if(state.Down && !Downed){push=state.Push;if(!IsProxy){Components.Get<CoverController>()?.Leave();Components.Get<VaultController>()?.Cancel();}}
        Downed=state.Down;attackTime=state.Attack;downTime=state.Fall;recovery=state.Recovery;
        RecoveryPresses=state.Presses;motionFacing=state.Facing;stepDirection=state.Step;stepDistance=state.Distance;
        impactPending=false;
    }
    private static float Ease(float t){t=t.Clamp(0,1);return t*t*(3-2*t);}
    public void ApplyPresentation(SkinnedModelRenderer body,CitizenAnimationHelper animation,GameObject marker)
    {
        animation.Sitting=CitizenAnimationHelper.SittingStyle.None;
        float elapsed=AttackDuration-attackTime;
        // Frame 16 drives the rear of the marker into contact. Keep the legacy
        // graph channel name so existing network snapshots and rigs stay compatible.
        const float strikePhase=16f/30f;
        const float windup=.19f,impactHold=.045f;
        // Longer anticipation, a fast rear-first drive, then a brief contact hold.
        float attackPhase=elapsed<windup ? elapsed/windup*.25f
            : elapsed<ImpactTime ? .25f+(elapsed-windup)/(ImpactTime-windup)*(strikePhase-.25f)
            : elapsed<ImpactTime+impactHold ? strikePhase
            : strikePhase+(elapsed-ImpactTime-impactHold)/(AttackDuration-ImpactTime-impactHold)*(1-strikePhase);
        float recoverPhase=GetUpProgress;
        float fallWeight=Incapacitated ? Ease(downTime/.18f)*(Recovering ? Ease(recovery/.22f) : 1) : 0;
        body.Set("pb_elbow_phase",attackPhase);
        body.Set("pb_elbow_weight",Attacking ? Ease((AttackDuration-attackTime)/.09f)*Ease(attackTime/.12f) : 0);
        body.Set("pb_fall_phase",FallProgress);body.Set("pb_fall_weight",fallWeight);
        body.Set("pb_getup_phase",recoverPhase);body.Set("pb_getup_weight",Recovering ? Ease(recoverPhase/.18f)*Ease(recovery/.16f) : 0);
        marker.Enabled=!Incapacitated;
        if(!Incapacitated && !Attacking)return;
        body.WorldRotation=motionFacing;
        animation.IkLeftHand=animation.IkRightHand=null;animation.HoldType=CitizenAnimationHelper.HoldTypes.None;
        // Native procedural look/turn can otherwise bend the downed player
        // toward a freely moving camera after the full-body fall pose is mixed.
        animation.WithLook(motionFacing.Forward,0,0,0);animation.MoveRotationSpeed=0;
        animation.Handedness=CitizenAnimationHelper.Hand.Both;animation.IsGrounded=true;
        body.Set("pb_grip_weight",0f);body.Set("pb_left_grip",0f);body.Set("pb_shield_carry",0f);
        animation.WithVelocity(Vector3.Zero);animation.WithWishVelocity(Vector3.Zero);animation.DuckLevel=0;
    }
    protected override void OnDisabled()=>ResetState();
    protected override void OnDestroy()=>ResetState();
}