Duels.cs

Part of MatchSimulation that runs tackle duels between a defender and the ball carrier. Picks a challenge type (standing tackle, sliding tackle or shoulder-to-shoulder), applies skill and profile modifiers, and resolves success or miss into ball velocity, recovery timers and events.

Rough Code
using System;
using System.Linq;

namespace Socer;

public sealed partial class MatchSimulation
{
    public int DuelDefender=-1, DuelCarrier=-1;
    public Point DuelAim;
    public float DuelRemaining, DuelStartedAt, PickupDelay, DuelClosestContact;
    public bool Sliding,BodyChallenge;
    public float BodyPressureStrength,BodyPressureSeconds,BodyContestContact;
    public int BodyDuelResolutions,BodyDuelEndClose,BodyDuelEndBallReach,BodyDuelEndPossessed;
    public int SlideAttempts,StandingAttempts,BodyAttempts,BodyWins;
    public int DefensiveRecoveryRuns,DefensiveRecoverySprints;
    public int[] StandingTackleVariantStarts=new int[3];
    bool CanBodyChallenge(Footballer d,Footballer c)
    {
        var offset=d.Position-c.Position;
        var forward=c.Velocity.Length>1?c.Velocity.Unit:new Point(MathF.Cos(c.Facing),MathF.Sin(c.Facing));
        return !d.Keeper && offset.Length>.35f && offset.Length<1.12f &&
            Math.Abs(Point.Dot(offset.Unit,forward))<.65f && (Ball-c.Position).Length<1.4f && BallHeight<.7f &&
            Seconds>=d.FallUntil && Seconds>=c.FallUntil;
    }
    bool ShouldSlide(Footballer d,Footballer c)
    {
        float reach=(Ball-d.Position).Length;
        var goal=World(new Point(0,34),d.Team);var toGoal=(goal-c.Position).Unit;
        bool behind=Point.Dot((d.Position-c.Position).Unit,toGoal)<-.35f;
        bool emergency=(goal-c.Position).Length<26 && (WindupPlayer==c.Id ||
            (c.Velocity.Length>2.5f && Point.Dot(c.Velocity.Unit,toGoal)>.35f));
        bool ballFirst=Point.Dot((c.Position-d.Position).Unit,(Ball-d.Position).Unit)<.75f ||
            (Ball-d.Position).Length+.2f<(c.Position-d.Position).Length;
        bool desperateRecovery=behind && AggressionOf(d)>.58f && reach<1.7f;
        return !d.Keeper && emergency && (!behind && ballFirst || desperateRecovery) && BallHeight<.45f && reach>1.05f && reach<2.1f &&
            d.Stamina>.25f && (d.Profile.Defence>.45f || AggressionOf(d)>.8f);
    }
    public int[] TackleAttempts=new int[2], TackleMisses=new int[2];
    public void BeginChallenge(Footballer defender,Footballer carrier)
    {
        if(carrier.FeintVariant==4 && carrier.SkillMove==SkillMoveKind.Nutmeg && carrier.SkillStage==1 &&
            defender.Id==carrier.SkillOpponent)return;
        if(DuelDefender>=0 || carrier.Keeper || defender.TackleCooldown>0 || Seconds-defender.LastSlideAt<2.1f || defender.SentOff || carrier.SentOff) return;
        DuelDefender=defender.Id; DuelCarrier=carrier.Id; DuelAim=Ball; DuelStartedAt=Seconds;
        DuelClosestContact=(defender.Position-carrier.Position).Length;
        BodyChallenge=CanBodyChallenge(defender,carrier);
        Sliding=!BodyChallenge && ShouldSlide(defender,carrier);
        defender.Stamina=Math.Max(.08f,defender.Stamina-(Sliding?.035f:.023f));
        if(defender.Profile.Defence>.45f && SkillReady(defender,UniqueSkill.SteelPress) && Next()<.42f)
            ActivateSkill(defender,UniqueSkill.SteelPress);
        if(BodyChallenge){ActivateSkill(defender,UniqueSkill.ShoulderWall,.9f);ActivateSkill(carrier,UniqueSkill.ShoulderWall,.9f);}
        else if(!Sliding)ActivateSkill(defender,UniqueSkill.TimingTackle,.9f);
        BodyContestContact=0;
        BodyPressureStrength=BodyChallenge?Math.Clamp(.12f+
            (defender.Profile.Physical-carrier.Profile.Physical)*.20f+
            defender.Profile.Defence*.08f-carrier.Profile.Composure*.04f+
            (int)PlayerPlayStyles.Grade(defender,PlayStyle.Enforcer)*.035f-
            (int)PlayerPlayStyles.Grade(carrier,PlayStyle.Enforcer)*.035f+
            (SkillActive(defender,UniqueSkill.ShoulderWall)?.11f*defender.ActiveSkillPower:0)-
            (SkillActive(carrier,UniqueSkill.ShoulderWall)?.06f*carrier.ActiveSkillPower:0)-
            .035f*SkillStrength(carrier,UniqueSkill.PressResistant),.07f,.43f):0;
        if(BodyChallenge)BodyAttempts++;else if(Sliding)SlideAttempts++;else StandingAttempts++;
        if(!BodyChallenge && !Sliding)
        {
            defender.StandingTackleAt=Seconds;
            defender.StandingTackleResolvedAt=-10;
            defender.StandingTackleResult=0;
            defender.StandingTackleAim=Ball;
            var side=new Point(-MathF.Sin(defender.Facing),MathF.Cos(defender.Facing));
            defender.StandingTackleFoot=Point.Dot(Ball-defender.Position,side)<0?-1:1;
            float approach=Point.Dot((carrier.Position-defender.Position).Unit,
                carrier.Velocity.Length>1?carrier.Velocity.Unit:new Point(MathF.Cos(carrier.Facing),MathF.Sin(carrier.Facing)));
            defender.StandingTackleVariant=approach>.35f?2:
                Math.Abs(approach)<.4f?1:0; // front poke, side hook, recovery block
            StandingTackleVariantStarts[defender.StandingTackleVariant]++;
        }
        if(Sliding)
        {
            defender.LastSlideAt=Seconds;defender.SlideDirection=(Ball-defender.Position).Unit;
            defender.SlideSpeed=defender.Velocity.Length;
            defender.SlideVariant=Point.Dot(defender.SlideDirection,carrier.Velocity.Unit)<-.2f?2:Ball.Y>defender.Position.Y?0:1;
        }
        DuelRemaining=BodyChallenge?.6f:Sliding?.3f:.26f;
        defender.TackleCooldown=Sliding?2.5f:BodyChallenge?Math.Clamp(.92f-defender.Profile.Defence*.25f,.65f,.85f):
            Math.Clamp(1.28f-defender.Profile.Defence*.48f,.78f,1.05f);
        // Shoulder pressure is a running contest. Dropping the defender's burst
        // here made him lose half a stride as soon as contact began.
        if(BodyChallenge)defender.BurstRemaining=Math.Max(defender.BurstRemaining,.75f);
        else defender.BurstRemaining=0;
        TackleAttempts[defender.Team]++;
        Say(carrier,"Держу!",false);
    }
    void ResolveChallenge(float dt)
    {
        if(DuelDefender<0) return;
        var defender=Players[DuelDefender];
        var carrier=Players[DuelCarrier];
        DuelClosestContact=Math.Min(DuelClosestContact,(defender.Position-carrier.Position).Length);
        if(BodyChallenge && Owner==DuelCarrier && CanBodyChallenge(defender,carrier))
        {
            var side=(carrier.Position-defender.Position).Unit;
            defender.BodyLeanUntil=carrier.BodyLeanUntil=Seconds+.25f;
            defender.BodyLeanDirection=side;carrier.BodyLeanDirection=side*-1;
            defender.Action="Борется плечом";carrier.Action="Удерживает мяч корпусом";
            // Each frame of legal shoulder contact costs the carrier speed and
            // nudges his line; a turnover still requires a reachable ball.
            BodyPressureSeconds+=dt;
            BodyContestContact+=dt;
            carrier.Velocity+=side*(dt*(1.1f+BodyPressureStrength*3.5f));
            carrier.Position=Clamp(carrier.Position+side*dt*(.18f+BodyPressureStrength*.85f));
            defender.Velocity+=carrier.Velocity*(dt*.18f);
        }
        DuelRemaining-=dt;
        if(DuelRemaining>0 && Owner==DuelCarrier) return;
        if(BodyChallenge)
        {
            BodyDuelResolutions++;
            if(Owner==DuelCarrier)BodyDuelEndPossessed++;
            if((defender.Position-carrier.Position).Length<1.6f)BodyDuelEndClose++;
            if(BodyContestContact>.11f && (defender.Position-carrier.Position).Length<1.65f &&
                (defender.Position-Ball).Length<1.85f && (Ball-carrier.Position).Length<1.6f)BodyDuelEndBallReach++;
        }
        if(Owner==DuelCarrier && TryFoul(defender,carrier)) return;
        var forward=new Point(MathF.Cos(carrier.Facing),MathF.Sin(carrier.Facing));
        float exposed=Point.Dot((defender.Position-carrier.Position).Unit,forward);
        float reach=Sliding?1.6f:BodyChallenge?1.15f:.93f+defender.Profile.StandingTackle*.5f;
        if(!Sliding && !BodyChallenge && SkillActive(defender,UniqueSkill.TimingTackle))reach+=.16f*defender.ActiveSkillPower;
        // A sustained shoulder duel can dislodge a controlled ball without the
        // defender's boot landing at its exact current point. The ball still
        // becomes free and must be recovered after the challenge.
        bool shoulderReach=BodyChallenge && BodyContestContact>.11f &&
            (defender.Position-carrier.Position).Length<1.65f &&
            (defender.Position-Ball).Length<1.85f &&
            (Ball-carrier.Position).Length<1.6f;
        bool inReach=BallHeight<.65f && (BodyChallenge?shoulderReach:
            (defender.Position-Ball).Length<reach && (Ball-DuelAim).Length<2.1f);
        // Enforcer strengthens shoulder challenges and, especially at Plus grade,
        // lets a carrier keep balance while shielding the ball.
        float enforcer=BodyChallenge?
            (int)PlayerPlayStyles.Grade(defender,PlayStyle.Enforcer)*.04f-
            (int)PlayerPlayStyles.Grade(carrier,PlayStyle.Enforcer)*.055f:0;
        float tackleSkill=BodyChallenge?defender.Profile.Physical:Sliding?defender.Profile.SlidingTackle:defender.Profile.StandingTackle;
        float variantBonus=!Sliding && !BodyChallenge?defender.StandingTackleVariant switch
            {1=>.035f,2=>-.055f,_=>0}:0;
        float chance=Math.Clamp(.18f+tackleSkill*.56f+(defender.Profile.Defence-.65f)*.12f+
            defender.Profile.Aggression*.09f-carrier.Skill*.18f+
            (defender.Profile.Physical-carrier.Profile.Physical)*(BodyChallenge?.18f:.08f)+
            (defender.Stamina-carrier.Stamina)*.18f+exposed*.14f+enforcer+variantBonus+
            (SkillActive(defender,UniqueSkill.SteelPress)?.17f*defender.ActiveSkillPower:0)+
            (SkillActive(defender,UniqueSkill.ShoulderWall)?.13f*defender.ActiveSkillPower:0)+
            (SkillActive(defender,UniqueSkill.TimingTackle)?.10f*defender.ActiveSkillPower:0)-
            (BodyChallenge && SkillActive(carrier,UniqueSkill.ShoulderWall)?.08f*carrier.ActiveSkillPower:0)-
            (BodyChallenge?.06f*SkillStrength(carrier,UniqueSkill.PressResistant):0),.12f,.91f);
        if(Owner==DuelCarrier && inReach && Next()<chance)
        {
            if(!BodyChallenge && !Sliding){defender.StandingTackleResolvedAt=Seconds;defender.StandingTackleResult=1;}
            CancelWindup();
            SnapshotOffside(defender,false);
            Owner=-1; Passer=-1; shot=false; LastTouch=defender.Team;
            ResetFlight();
            var poke=(Ball-defender.Position).Unit;
            if(poke.Length<.01f) poke=new Point(Direction(defender.Team),0);
            // A successful challenge is a deflection into space, not an
            // immediate exchange of possession at the old carrier's feet.
            var away=(Ball-carrier.Position).Unit;
            var clearance=new Point(Direction(defender.Team),0);
            poke=(poke*.3f+away*.45f+clearance*.25f).Unit;
            if(!Sliding && !BodyChallenge)
            {
                var side=new Point(-clearance.Y,clearance.X);
                poke=defender.StandingTackleVariant switch
                {
                    1=>(poke*.55f+side*(defender.StandingTackleFoot<0?-1:1)*.45f).Unit,
                    2=>(poke*.48f+clearance*.52f).Unit,
                    _=>poke
                };
            }
            if(poke.Length<.01f)poke=clearance;
            float cleanTouch=!Sliding && !BodyChallenge?Math.Clamp((defender.Profile.StandingTackle-.55f)/.45f,0,1):0;
            BallVelocity=poke*(Sliding?8:6-cleanTouch*1.7f)+
                new Point(-poke.Y,poke.X)*(Next()-.5f)*(2.2f-cleanTouch*1.3f);
            PickupDelay=Sliding?.18f:.21f-cleanTouch*.07f; flightAge=0;
            carrier.Recovery=.4f; defender.Recovery=Sliding?.6f:.26f-tackleSkill*.16f;
            if(BodyChallenge)
            {
                var apart=(carrier.Position-defender.Position).Unit;
                defender.BodyLeanDirection=apart;
                carrier.BodyLeanDirection=apart*-1;
                defender.BodyLeanUntil=carrier.BodyLeanUntil=Seconds+.38f;
                carrier.StumbleUntil=Seconds+.42f;
                carrier.Action="Потерял равновесие под давлением";
                defender.Action="Выдавил плечом";
            }
            bool bodyContact=!BodyChallenge && (carrier.Position-defender.Position).Length<(Sliding?1.12f:.82f);
            if(bodyContact)
            {
                RegisterChallengeImpact(defender,carrier,Sliding);
                if(Sliding)FallDown(carrier,carrier.Velocity*.3f+carrier.Position-defender.Position,.25f);
                else {carrier.StumbleUntil=Seconds+.5f;if(carrier.VisualSpeed>4 && carrier.Stamina<.55f && Next()<.25f)FallDown(carrier,carrier.Velocity,.15f);}
            }
            carrier.Frustration=Math.Min(1,carrier.Frustration+.12f);
            Tackles[defender.Team]++;if(BodyChallenge)BodyWins++;
            Emit("tackle",defender,BodyChallenge?"КОРПУСОМ!":Sliding?"ПОДКАТ!":"ВЫБИЛ!",1.3f);
            AddEvent($"{TeamName(defender.Team)} · выбил мяч #{defender.Number}");
        }
        else
        {
            if(!BodyChallenge && !Sliding){defender.StandingTackleResolvedAt=Seconds;defender.StandingTackleResult=-1;}
            defender.Recovery=Sliding?1.2f*(1.1f-tackleSkill*.4f):
                BodyChallenge?Math.Clamp(.40f-tackleSkill*.20f,.19f,.33f):
                Math.Clamp(.60f-tackleSkill*.34f,.25f,.48f);
            TackleMisses[defender.Team]++;
            defender.Frustration=Math.Min(1,defender.Frustration+.09f);
            Emit("miss",defender,"НЕ ДОСТАЛ",1);
        }
        DuelDefender=-1; DuelCarrier=-1; DuelRemaining=0;DuelClosestContact=float.MaxValue;
    }
}