BallFlight.cs

Partial MatchSimulation that handles ball flight, crosses and goalkeeper behaviour. Advances ball height, spin and trail, shapes driven, curled, chipped and crossed strikes, decides crosses and cutbacks, distributes from the keeper, and poses or moves keeper gloves during blocks and dives.

Rough Code
using System;
using System.Collections.Generic;
using System.Linq;

namespace Socer;

public enum StrikeKind { Driven, Power, Curl, Chip, Cross, Header }
public struct FlightMark { public Point Position; public float Height; }

public sealed partial class MatchSimulation
{
    public float BallHeight, VerticalSpeed, BallSpin, GoalHoldRemaining;
    public StrikeKind Strike;
    public List<FlightMark> FlightTrail { get; }=new();
    public int Crosses, CurledShots, Chips, KeeperAttempts, KeeperMisses;
    public int GoalScorerId=-1;
    public string GoalMinute="";
    public float LastShotSpeed;
    public int LoftedPasses, SkippingPasses, DrivenCrosses, Cutbacks;
    public float GoalCelebrationElapsed;
    public float GoalNetMotionRemaining;
    void AdvanceGoalNet(float dt)
    {
        Ball+=BallVelocity*dt;
        BallVelocity*=MathF.Exp(-dt*3);
        BallHeight+=VerticalSpeed*dt-4.905f*dt*dt;
        VerticalSpeed-=9.81f*dt;
        if(BallHeight<.11f){BallHeight=.11f;VerticalSpeed=Math.Max(0,-VerticalSpeed*.22f);}
        if(Ball.X< -1.9f || Ball.X>106.9f){Ball.X=Math.Clamp(Ball.X,-1.9f,106.9f);BallVelocity.X*=-.12f;}
        if(Ball.Y<30.35f || Ball.Y>37.65f){Ball.Y=Math.Clamp(Ball.Y,30.35f,37.65f);BallVelocity.Y*=-.12f;}
        if(BallHeight>2.35f){BallHeight=2.35f;VerticalSpeed=-Math.Abs(VerticalSpeed)*.15f;}
    }
    public int[] GoalCelebrants=Array.Empty<int>();
    int pendingGoalTeam;
    float trailClock;

    void ResetFlight()
    {
        BallHeight=0; VerticalSpeed=0; BallSpin=0; BallUniqueSkill=UniqueSkill.None; BallSkillGrade=0; BallLegendaryAbility=LegendaryAbility.None; FlightTrail.Clear(); trailClock=0;
    }

    // All flight values are metres and seconds. Curvature rotates horizontal velocity;
    // gravity is independent of match playback speed.
    void AdvanceFlight(float dt)
    {
        float angle=BallSpin*dt, c=MathF.Cos(angle), s=MathF.Sin(angle);
        BallVelocity=new Point(BallVelocity.X*c-BallVelocity.Y*s,BallVelocity.X*s+BallVelocity.Y*c);
        BallSpin*=MathF.Exp(-dt*.4f);
        Ball+=BallVelocity*dt;
        if(BallHeight>0 || VerticalSpeed>0)
        {
            BallHeight+=VerticalSpeed*dt-4.905f*dt*dt;
            VerticalSpeed-=9.81f*dt;
            if(BallHeight<=0) { RegisterShotContact("ОТСКОК");BallHeight=0; VerticalSpeed=-VerticalSpeed*.32f; if(VerticalSpeed<.7f) VerticalSpeed=0; BallVelocity*=.83f; }
        }
        BallVelocity*=MathF.Exp(-dt*(BallHeight>.05f?.08f:.28f));
        trailClock+=dt;
        if(trailClock>=.035f) { trailClock=0; FlightTrail.Add(new FlightMark {Position=Ball,Height=BallHeight}); if(FlightTrail.Count>12) FlightTrail.RemoveAt(0); }
    }

    void ShapeFlight(Point target, StrikeKind kind,float arrivalHeight=-1)
    {
        Strike=kind;
        int actor=shot?lastShooter:Passer;
        if(actor>=0) Players[actor].LastKickKind=kind;
        float distance=(target-Ball).Length;
        float duration=Math.Max(.15f,distance/Math.Max(1,BallSpeed));
        BallHeight=.12f;
        if(kind==StrikeKind.Cross || kind==StrikeKind.Chip)
        {
            // Choose time in air, then compensate horizontal drag to land at the target.
            duration=kind==StrikeKind.Cross?Math.Clamp(distance/17,1.25f,2.5f):Math.Clamp(distance/15,1.1f,2.2f);
            BallVelocity=(target-Ball)*(.08f/(1-MathF.Exp(-.08f*duration)));
            VerticalSpeed=4.905f*duration+Math.Max(0,arrivalHeight)/duration;
        }
        else if(kind==StrikeKind.Driven && arrivalHeight<=0) { BallHeight=0; VerticalSpeed=0; }
        else
        {
            // Match vertical aim to the flight time including horizontal air drag.
            duration=-MathF.Log(Math.Max(.05f,1-.08f*distance/Math.Max(1,BallSpeed)))/.08f;
            float height=arrivalHeight<0?(kind==StrikeKind.Power?1.0f:.65f):arrivalHeight;
            VerticalSpeed=(height-BallHeight)/Math.Max(.15f,duration)+4.905f*duration;
        }
        if(kind==StrikeKind.Curl || kind==StrikeKind.Cross)
        {
            BallSpin=(target.Y>=Ball.Y?1:-1)*(kind==StrikeKind.Curl?.48f:.22f);
            // Aim beside the destination first, so the visible arc bends back towards it.
            float correction=-BallSpin*duration*.45f;
            float c=MathF.Cos(correction), s=MathF.Sin(correction);
            BallVelocity=new Point(BallVelocity.X*c-BallVelocity.Y*s,BallVelocity.X*s+BallVelocity.Y*c);
        }
    }

    float GroundPassSpeed(Footballer p,float distance,bool driven=false)=>
        Math.Clamp(8+distance*.48f+p.Profile.Passing*3+(driven?4:0),10,driven?29:26);

    bool TryCross(Footballer p)
    {
        float localX=World(p.Position,p.Team).X;
        if(p.Keeper || p.Held<.65f || localX<76 || Math.Abs(p.Position.Y-34)<12 || ClearFinishingChance(p)) return false;
        var receiver=ActivePlayers.Where(q=>q.Team==p.Team && q.Id!=p.Id && !q.Keeper &&
            World(q.Position,p.Team).X>(localX>90?79:83) && Math.Abs(q.Position.Y-34)<14 &&
            SeesPartner(p,q) && IsOnside(q,p.Position) && (q.Position-p.Position).Length>6)
            .OrderByDescending(q=>PerceivedSpace(p,q.Position)*1.2f+q.Profile.Finishing*2+
                (localX>90 && World(q.Position,p.Team).X<localX-2.5f &&
                    PerceivedLane(p,q.Position)>1.05f?5:0)-
                (q.Position-World(new Point(93,34),p.Team)).Length*.25f).FirstOrDefault();
        if(receiver==null) return false;
        var target=PassMeetingPoint(p,receiver);
        float lane=PerceivedLane(p,target),space=PerceivedSpace(p,target);
        bool cutback=localX>90 && World(target,p.Team).X<localX-2.5f && lane>1.05f && space>1;
        bool driven=localX>82 && lane>1.3f && space>1.2f;
        bool aerialTarget=!cutback && receiver.Profile.HeadingAccuracy>.8f &&
            receiver.Profile.Jumping>.7f && localX<94 &&
            (receiver.Position-p.Position).Length>14 && space>1.4f && (localX<89 || lane<1.5f);
        // A hopeful early ball into a marked forward is usually inferior to
        // advancing down the flank and synchronising the box runs.
        if(!cutback && !driven && space<1.6f)return false;
        if(localX<82 && space<2.5f && CarrierPressure(p)>2.3f)return false;
        if(localX<84 && !aerialTarget && CarrierPressure(p)>2)return false;
        bool aerial=(!cutback && !driven) || aerialTarget;
        if(aerial)target=Clamp(receiver.Position+receiver.Velocity*.75f);
        if(!PrepareKickFacing(p,target))return true;
        if(!BallAtFoot(p))return true;
        float deliveryDistance=(target-p.Position).Length;
        float technique=p.Profile.Crossing*.7f+p.Profile.Passing*.3f;
        float error=(1-technique)*(.5f+deliveryDistance*.045f)+Math.Max(0,2-CarrierPressure(p))*.16f;
        target=Clamp(target+new Point((Next()-.5f)*error,(Next()-.5f)*error));
        Kick(p,target,GroundPassSpeed(p,(target-p.Position).Length,true),false);
        string label;
        if(!aerial)
        {DrivenCrosses++;if(cutback)Cutbacks++;label=cutback?"ПАС НАЗАД ПОД УДАР":"ПРОСТРЕЛ";}
        else
        {ShapeFlight(target,StrikeKind.Cross,1.75f+receiver.Profile.Jumping*.25f);Crosses++;LoftedPasses++;label="НАВЕС";}
        PassReceiver=receiver.Id;IntendedPassTarget=target;
        Passes[p.Team]++; Emit("pass",p,label,1.8f); Say(p,"В штрафную!",true);
        AddEvent($"{TeamName(p.Team)} · {label.ToLowerInvariant()} #{p.Number} → #{receiver.Number}");
        return true;
    }

    public float KeeperBlockPoseWeight(Footballer p)
    {
        if(p.KeeperBlockUntil<=0)return 0;
        static float Smooth(float value)
        {value=Math.Clamp(value,0,1);return value*value*(3-2*value);}
        return Smooth((Seconds-p.KeeperBlockStartedAt)/.16f)*
            (1-Smooth((Seconds-p.KeeperBlockUntil)/.22f));
    }

    public float GloveHeight(Footballer p)
    {
        if(p.DiveRemaining>0)return p.DiveHeight;
        bool holding=Owner==p.Id && !GoalKickPossession;
        // A caught ball settles to a chest hold. Reading the already solved ball
        // height here would feed a tucked wrist back into the next pose target.
        float ready=holding?1.12f:1.00f;
        float block=KeeperBlockPoseWeight(p);
        return ready+((p.KeeperBlockKind==2?.52f:1.15f)-ready)*block;
    }

    public Point GlovePosition(Footballer p, int side)
    {
        var forward=new Point(MathF.Cos(p.Facing),MathF.Sin(p.Facing));
        var sideways=new Point(-forward.Y,forward.X);
        if(p.DiveRemaining>0)
        {
            float extension=Math.Clamp((p.DiveAge-.1f)/.25f,0,1);
            // The body arrives at its landing point before the save finishes.
            // A remaining-distance direction then becomes zero (or reverses),
            // pulling both hands back through the torso in the middle of the dive.
            var reachVector=p.DiveTarget-p.DiveOrigin;
            var reach=reachVector.Length<.18f?new Point():reachVector.Unit;
            return p.Position+forward*.25f+reach*(extension*1.0f)+sideways*(side*.17f);
        }
        float block=KeeperBlockPoseWeight(p);
        if(block>0)
        {
            float width=p.KeeperBlockKind==2?.52f:.46f;
            float ahead=p.KeeperBlockKind==2?.27f:.33f;
            return p.Position+forward*(.24f+(ahead-.24f)*block)+
                sideways*(side*(.40f+(width-.40f)*block));
        }
        bool holding=Owner==p.Id && !GoalKickPossession;
        return p.Position+forward*(holding?.42f:.24f)+sideways*(side*(holding?.16f:.40f));
    }

    void FollowKeeperHands(Footballer p)
    {
        if(Owner!=p.Id || !p.Keeper || GoalKickPossession || KeeperKickPlayer==p.Id)return;
        // Only the holding keeper is posed here; the reusable buffer performs
        // no per-frame allocations and shares the actual tuck and fixed-limb IK.
        ReplayFigure.Pose(p,this,keeperHeldBallPose);
        var left=keeperHeldBallPose[9];var right=keeperHeldBallPose[12];
        Ball=(left.A+right.A)*.5f;
        BallHeight=(left.ZA+right.ZA)*.5f;
    }

    void CommitKeeperSaveFacing(Footballer p)
    {
        var approach=Ball-p.Position;
        if(approach.Length<.5f)approach=BallVelocity*-1;
        if(approach.Length<.05f)approach=new Point(Direction(p.Team),0);
        p.KeeperSaveFacing=approach.Unit;
    }

    void DistributeKeeper(Footballer p)
    {
        var opponents=ActivePlayers.Where(q=>q.Team!=p.Team).ToArray();
        float line=opponents.Select(q=>q.Position.X*Direction(p.Team)).OrderByDescending(x=>x).Skip(1).First();
        var receivers=ActivePlayers.Where(q=>q.Team==p.Team && !q.Keeper &&
            (GoalKickPossession || World(q.Position,p.Team).X<=52.5f || q.Position.X*Direction(p.Team)<=Math.Max(line,p.Position.X*Direction(p.Team)))).ToArray();
        var shortOption=receivers.Where(q=>(q.Position-p.Position).Length is >=6 and <=28 &&
            opponents.Min(r=>(r.Position-q.Position).Length)>3 &&
            opponents.Min(r=>DistanceToSegment(r.Position,p.Position,q.Position))>2.2f)
            .OrderByDescending(q=>opponents.Min(r=>(r.Position-q.Position).Length)*1.5f+
                World(q.Position,p.Team).X*.18f-(q.Position-p.Position).Length*.15f).FirstOrDefault();
        // Counterattacks favour a long outlet; otherwise use an open short passing lane.
        float nearestPress=opponents.Min(r=>(r.Position-p.Position).Length);
        bool deepReceiver=shortOption!=null && World(shortOption.Position,p.Team).X<26;
        bool longBall=shortOption==null || (nearestPress<13 && deepReceiver) ||
            Next()<(Tactics[p.Team]==0 || Approach[p.Team]==2?.7f:Approach[p.Team]==0?.22f:.42f);
        if(longBall){BeginKeeperKick(p,receivers);return;}
        var receiver=shortOption;
        bool distributor=SkillActive(p,UniqueSkill.KeeperDistributor) || ActivateSkill(p,UniqueSkill.KeeperDistributor);
        var target=receiver!=null?Clamp(receiver.Position+receiver.Velocity*(longBall?.7f:.25f)):World(new Point(43,Next()<.5f?17:51),p.Team);
        if(distributor && receiver!=null)
            target=Clamp(receiver.Position+receiver.Velocity*(.35f+(SkillNeighbors(p,receiver)?
                .10f*Math.Max(SkillStrength(receiver,UniqueSkill.OutletRunner),SkillStrength(receiver,UniqueSkill.SpaceHunter)):0)));
        Kick(p,target,longBall?29:Math.Clamp(12+(target-p.Position).Length*.28f,14,22),false);
        if(distributor){BallUniqueSkill=UniqueSkill.KeeperDistributor;BallSkillGrade=p.ActiveSkillGrade;}
        if(longBall) ShapeFlight(target,StrikeKind.Cross);
        Passes[p.Team]++;
        Say(p,longBall?"Вперёд!":"Ближе!",true);
        AddEvent($"{TeamName(p.Team)} · вратарь: {(longBall?"дальний ввод":"короткий пас")}"+(receiver!=null?$" → #{receiver.Number}":""));
    }

    void UpdateKeepers(float dt)
    {
        foreach(var p in ActivePlayers.Where(p=>p.Keeper))
        {
            if(p.DiveRemaining>0)
            {
                p.DiveAge+=dt; p.DiveRemaining=Math.Max(0,p.DiveRemaining-dt);
                if(p.DiveRemaining==0) p.DiveEndedAt=Seconds;
                if(p.DiveRemaining==0 && !p.DiveConnected) { KeeperMisses++; Emit("miss",p,"НЕ ДОТЯНУЛСЯ",1.1f); }
                continue;
            }
            if(p.KeeperBlockUntil>Seconds)continue;
            if(!ShotInFlight || Owner>=0 || RestartRemaining>0 || p.Team==LastTouch || p.Recovery>0 || BallSpeed<7) continue;
            float shooterDistance=lastShooter>=0?(Players[lastShooter].Position-p.Position).Length:100;
            bool closeShot=shooterDistance<9.5f;
            if(flightAge<(closeShot?.025f:.10f)+(1-p.Profile.GkReflexes)*(closeShot?.10f:.24f))continue;
            if(!PassiveTrainingOpponent(p) && TryKeeperClaim(p,out _))continue;
            float time=(p.Position.X-Ball.X)/(Math.Abs(BallVelocity.X)>.1f?BallVelocity.X:.1f);
            if(time<0 || time>.65f || (Ball-p.Position).Length>18) continue;
            var predicted=Ball+BallVelocity*time;
            float lateral=Math.Abs(predicted.Y-p.Position.Y);
            if(lateral>5.5f) continue;
            float arrivalHeight=Math.Max(0,BallHeight+VerticalSpeed*time-4.905f*time*time);
            if(SkillReady(p,UniqueSkill.ReflexWall) && Next()<.42f)
                ActivateSkill(p,UniqueSkill.ReflexWall);
            // A reachable central strike is met in front of the chest or boots.
            // Its distance from the shooter does not require a sideways fall.
            bool centralBlock=lateral<.6f && arrivalHeight<=1.65f;
            if(time<.38f && (closeShot && lateral<2.8f || centralBlock))
            {
                CommitKeeperSaveFacing(p);
                p.KeeperBlockStartedAt=Seconds;
                p.KeeperBlockUntil=Seconds+.68f;
                p.KeeperBlockKind=BallHeight+VerticalSpeed*time<.75f?2:1;
                p.KeeperBlockTarget=predicted;
                p.Recovery=.50f+(1-p.Profile.GkReflexes)*.25f;
                KeeperAttempts++;
                continue;
            }
            CommitKeeperSaveFacing(p);
            p.DiveOrigin=p.Position;
            p.DiveTarget=new Point(p.Position.X,predicted.Y+(Next()-.5f)*(1-p.Profile.GkPositioning)*1.4f);
            p.DiveHeight=Math.Clamp(BallHeight+VerticalSpeed*time-4.905f*time*time,.2f,2.7f);
            p.DiveAge=0; p.DiveRemaining=.95f; p.DiveConnected=false; p.Recovery=1.2f;
            KeeperAttempts++;
        }
    }

    bool KeeperTouches(Footballer p, Point before, float beforeHeight)
    {
        if(p.KeeperBlockUntil>Seconds)
        {
            float reaction=.045f+(1-p.Profile.GkReflexes)*.11f;
            if(Seconds-p.KeeperBlockStartedAt<reaction)return false;
            var left=GlovePosition(p,-1);var right=GlovePosition(p,1);
            float z=(beforeHeight+BallHeight)*.5f;
            float reach=.43f+p.Profile.GkDiving*.29f+
                (SkillActive(p,UniqueSkill.ReflexWall)?.18f*p.ActiveSkillPower:0);
            if(z<.77f && p.KeeperBlockKind==2)
                return DistanceToSegment(p.Position,before,Ball)<.72f+p.Profile.GkPositioning*.47f;
            return z<1.9f && (DistanceToSegment(left,before,Ball)<reach ||
                DistanceToSegment(right,before,Ball)<reach ||
                (z<1.35f && DistanceToSegment(p.Position,before,Ball)<.58f));
        }
        if(p.DiveRemaining<=0) return BallHeight<1.6f && DistanceToSegment(p.Position,before,Ball)<.65f;
        if(p.DiveAge<.1f+(.7f-p.Profile.GkReflexes)*.20f) return false;
        var a=GlovePosition(p,-1); var b=GlovePosition(p,1);
        float fraction=Math.Clamp(Point.Dot((a+b)*.5f-before,Ball-before)/Math.Max(.0001f,Point.Dot(Ball-before,Ball-before)),0,1);
        float height=beforeHeight+(BallHeight-beforeHeight)*fraction;
        return Math.Abs(height-p.DiveHeight)<.65f && Math.Min(DistanceToSegment(a,before,Ball),DistanceToSegment(b,before,Ball))<
            .6f+(p.Profile.GkDiving-.7f)*.65f+
            (SkillActive(p,UniqueSkill.ReflexWall)?.24f*p.ActiveSkillPower:0)+
            (PlayerPlayStyles.Grade(p,PlayStyle.FarReach) switch
            {PlayStyleGrade.PlayStylePlus=>.17f,PlayStyleGrade.PlayStyle=>.10f,_=>0});
    }
}