FootballIntelligence.cs

Part of the soccer match simulation. Handles player perception (who can see whom, remembered positions), first-touch and heavy-touch outcomes, defender duels and challenges, off-ball run planning, shot setup and keeper parry decisions.

Rough Code
using System;
using System.Linq;
namespace Socer;

public enum FootballRun { None, Behind, CheckShort, Decoy, Overlap, BetweenLines }

// Football decisions consume observed positions. Physical contact/rules still use the real world.
public sealed partial class MatchSimulation
{
    readonly Point[,] seenPositions=new Point[22,22];
    readonly float[,] seenTimes=new float[22,22];
    readonly bool[,] seen=new bool[22,22];
    float nextThinking;
    int lastPossessionTeam=-1;
    readonly float[] counterPressUntil=new float[2];
    public int PreparedReceipts,HeavyTouches,ShotAdjustments,WeakFootShots,RushedShots,ContainedDuels,ShieldActions,IntelligentRuns,CounterPresses,BoxRuns,SafeParries;
    public int NearPostRuns,FarPostRuns,CutbackRuns;
    public int[] RunTypes=new int[6];
    public float CounterPressRemaining(int team)=>Math.Max(0,counterPressUntil[team]-Seconds);
    bool CanObserve(Footballer p,Footballer q)
    {
        var d=q.Position-p.Position;
        if(d.Length<5)return true;
        if(d.Length>16+p.Profile.Vision*30)return false;
        return Seconds-p.LastScanAt<.45f || Point.Dot(new Point(MathF.Cos(p.Facing),MathF.Sin(p.Facing)),d.Unit)>-.05f-p.Profile.Vision*.5f;
    }
    Point? Observed(Footballer p,Footballer q)
    {
        if(CanObserve(p,q))return q.Position;
        return seen[p.Id,q.Id] && Seconds-seenTimes[p.Id,q.Id]<1.4f?seenPositions[p.Id,q.Id]:null;
    }
    float PerceivedPressure(Footballer p)=>ActivePlayers.Where(q=>q.Team!=p.Team)
        .Select(q=>Observed(p,q)).Where(q=>q.HasValue).Select(q=>(q.Value-p.Position).Length).DefaultIfEmpty(30).Min();
    // A pursuer behind the carrier is a direct threat only if he can reach
    // challenge range before the carrier's next stride.
    float CarrierPressure(Footballer p)
    {
        var goalward=(World(new Point(105,34),p.Team)-p.Position).Unit;
        var forward=p.Velocity.Length>1.2f && Point.Dot(p.Velocity.Unit,goalward)>.25f?p.Velocity.Unit:goalward;
        float pressure=30;
        foreach(var rival in ActivePlayers.Where(q=>q.Team!=p.Team))
        {
            var seen=Observed(p,rival);if(!seen.HasValue)continue;
            var offset=seen.Value-p.Position;
            float distance=offset.Length;
            if(Point.Dot(offset,forward)<-.55f && distance>1.1f)
            {
                float closing=Point.Dot(rival.Velocity-p.Velocity,forward);
                float timeToChallenge=(distance-1.1f)/Math.Max(.1f,closing);
                if(timeToChallenge>.45f)distance+=4;
            }
            pressure=Math.Min(pressure,distance);
        }
        return pressure;
    }
    float PerceivedSpace(Footballer p,Point at)=>ActivePlayers.Where(q=>q.Team!=p.Team)
        .Select(q=>Observed(p,q)).Where(q=>q.HasValue).Select(q=>(q.Value-at).Length).DefaultIfEmpty(12).Min();
    float PerceivedLane(Footballer p,Point at,bool shotLane=false)=>ActivePlayers.Where(q=>q.Team!=p.Team && (!shotLane || !q.Keeper))
        .Select(q=>Observed(p,q)).Where(q=>q.HasValue).Select(q=>DistanceToSegment(q.Value,p.Position,at)).DefaultIfEmpty(8).Min();
    void UpdateFootballThinking(float dt)
    {
        if(Owner>=0)
        {
            int team=Players[Owner].Team;
            if(lastPossessionTeam>=0 && team!=lastPossessionTeam)
            {
                int lost=lastPossessionTeam;
                if(TacticalCharacters[team]==TacticalCharacter.Counter)
                {counterAttackUntil[team]=Seconds+6;CounterAttacks[team]++;}
                PossessionLosses[lost]++;
                ResetCombinations();
                bool press=Tactics[lost]==2 || Approach[lost]==3 || (lost==0 && CoachOrder==1 && Seconds<CoachUntil);
                counterPressUntil[lost]=press?Seconds+3.2f:0;
                if(press)CounterPresses++;
                foreach(var p in ActivePlayers){p.FootballRun=FootballRun.None;p.BoxRole=0;p.ShotSetupUntil=0;p.ShieldUntil=0;}
            }
            lastPossessionTeam=team;
        }
        foreach(var p in ActivePlayers)
        {
            p.Confidence+=(.6f-p.Confidence)*dt*.012f;
            if(p.Id!=Owner){p.ShotSetupUntil=0;p.PlanUntil=0;}
        }
        if(Seconds<nextThinking)return;
        nextThinking=Seconds+.22f;
        UpdatePassCalls();
        foreach(var p in ActivePlayers)
        {
            ScanPitch(p);
            foreach(var q in ActivePlayers)
                if(q.Team!=p.Team && CanObserve(p,q)){seen[p.Id,q.Id]=true;seenPositions[p.Id,q.Id]=q.Position;seenTimes[p.Id,q.Id]=Seconds;}
            if(!p.Keeper && Owner<0 && LastTouch==p.Team && (Ball-p.Position).Length<28 && BallSpeed>3)
            {
                bool receiving=PassReceiver==p.Id || (BallVelocity.Length>0 && DistanceToSegment(p.Position,Ball,Ball+BallVelocity*.65f)<2.2f);
                if(receiving && Seconds>=p.ReceiveReadyUntil)
                {p.ReceiveReadyUntil=Seconds+1.1f;p.ReceiveDirection=ChooseFirstTouch(p);PreparedReceipts++;p.Action="Открывает корпус под пас";}
            }
            if(Owner>=0 && p.Team==Players[Owner].Team && p.Id!=Owner && !p.Keeper && Seconds>=p.FootballRunUntil)
                PlanFootballRun(p,Players[Owner]);
            else if(Owner>=0 && p.Team==Players[Owner].Team && p.Id!=Owner && !p.Keeper &&
                linkedRunPasser[p.Id]!=Owner && p.RunUntil<=Seconds && p.BoxRole==0 &&
                p.FootballRun is not (FootballRun.Behind or FootballRun.Overlap))
                // A new linked playmaker can trigger a safe opening immediately,
                // rather than waiting for an unrelated short-check route to expire.
                TryPlanLinkedRun(p,Players[Owner]);
        }
    }
    Point ChooseFirstTouch(Footballer p)
    {
        var forward=new Point(Direction(p.Team),0);
        Point best=forward;float value=float.MinValue;
        foreach(var option in new[]{forward,(forward+new Point(0,-.7f)).Unit,(forward+new Point(0,.7f)).Unit,new Point(0,-1),new Point(0,1),forward*-1,p.Velocity.Length>1?p.Velocity.Unit:forward})
        {
            var at=p.Position+option*2.5f;
            float score=PerceivedSpace(p,at)+Point.Dot(option,forward)*1.6f+Point.Dot(option,p.Velocity.Unit)*1.3f-Math.Max(0,1.5f-CarryLane(p,at))*8;
            if(score>value){value=score;best=option;}
        }
        return best;
    }
    bool FirstTouch(Footballer p,int passer,float speed,Point incoming)
    {
        if(p.Keeper || passer<0 || Players[passer].Team!=p.Team || speed<3 || BallHeight>.9f)return false;
        float pressure=PerceivedPressure(p);
        float prepared=Seconds<p.ReceiveReadyUntil?.13f:0;
        var combination=combinations[p.Team];
        bool linkedReturn=combination.Linked && combination.Stage==1 && combination.Link==p.Id &&
            combination.Starter==passer && Seconds<combination.Until;
        if(linkedReturn)prepared=Math.Max(prepared,.18f);
        if(passer>=0 && PassingRunLink(Players[passer],p)>0 &&
            (p.RunUntil>Seconds || p.FootballRunUntil>Seconds && p.FootballRun is FootballRun.Behind or FootballRun.Overlap))
            prepared=Math.Max(prepared,.18f);
        float firstTouch=SkillStrength(p,UniqueSkill.FirstTouch);
        float quality=Math.Min(1,p.Skill*.45f+p.Profile.Composure*.25f+p.Stamina*.17f+prepared+.10f*firstTouch);
        float difficulty=Math.Clamp((speed-16)/38,0,.4f)+Math.Clamp((5-pressure)/5,0,1)*.30f;
        bool shortDelivery=(Players[passer].Position-p.Position).Length<16 && speed<20;
        if(shortDelivery && pressure>2.5f)difficulty*=.35f+(1-quality)*.4f;
        p.ReceiveDirection=ChooseFirstTouch(p);p.ReceiveUntil=Seconds+(.3f+(1-quality)*.25f)*(1-.17f*firstTouch);
        p.ReceiveReadyUntil=0;
        if(difficulty>.12f && Next()<Math.Clamp(difficulty-quality*.20f,.01f,.30f))
        {
            // A heavy touch remains a physically contestable ball, not a scripted turnover.
            Owner=-1;Passer=-1;LastTouch=p.Team;shot=false;ResetFlight();
            float severity=Next();
            var lateral=new Point(-incoming.Unit.Y,incoming.Unit.X);
            Ball=p.ControlPoint;BallVelocity=(p.ReceiveDirection*.35f+incoming.Unit*.5f+lateral*((Next()-.5f)*1.1f)).Unit*(1.2f+severity*(3.5f+(1-quality)*2));
            VerticalSpeed=.35f+severity*1.2f;
            p.RecoveryChaseUntil=Seconds+2.8f;PassReceiver=p.Id;
            PickupDelay=.1f;p.Recovery=.12f;p.Confidence=Math.Max(.15f,p.Confidence-.06f);HeavyTouches++;
            p.Action=severity>.55f?"Далеко отпустил при приёме":"Мяч отскочил от ноги";Say(p,"Неудобно!",false);return true;
        }
        p.Confidence=Math.Min(.95f,p.Confidence+.012f);
        p.ReceiveOnMove=quality>.55f && pressure>2 && PerceivedSpace(p,p.Position+p.ReceiveDirection*2)>2.5f;
        if(p.ReceiveOnMove)p.ReceiveUntil=Seconds+.10f+(1-quality)*.12f;
        if(linkedReturn)p.ReceiveOnMove=false;
        return false;
    }
    bool ShouldChallenge(Footballer defender,Footballer carrier)
    {
        if(defender.Keeper || Seconds<defender.FallUntil || Seconds<carrier.FallUntil)return false;
        float reach=(defender.Position-Ball).Length;
        if(CanBodyChallenge(defender,carrier))return true;
        if(reach<.70f+defender.Profile.StandingTackle*.43f)return true;
        if(ShouldSlide(defender,carrier))return true;
        // A normal dribble touch is not automatically a tackle opportunity.
        bool exposed=(Ball-carrier.Position).Length>.92f-defender.Profile.Defence*.48f || Seconds<carrier.ReceiveUntil;
        bool behind=Point.Dot((defender.Position-carrier.Position).Unit,
            carrier.Velocity.Length>1?carrier.Velocity.Unit:new Point(MathF.Cos(carrier.Facing),MathF.Sin(carrier.Facing)))<-.45f;
        return !behind && reach<.91f+defender.Profile.StandingTackle*.49f && (exposed || WindupPlayer==carrier.Id ||
            (carrier.Velocity.Length<1 && Seconds>=defender.ContainUntil));
    }
    Point DuelMovement(Footballer p,Footballer carrier,Point target)
    {
        float distance=(carrier.Position-p.Position).Length;
        if(p.Id==chasers[p.Team] && distance<18)
        {
            var goal=World(new Point(0,34),p.Team);var goalside=(goal-carrier.Position).Unit;
            var offset=p.Position-carrier.Position;
            if(Point.Dot(offset,goalside)<-.4f && distance>1.1f)
            {
                // Catch the runner's near shoulder. A distant goal-side waypoint
                // made the defender run past the duel without ever making contact.
                if(p.DefensiveRecoveryUntil<=Seconds)DefensiveRecoveryRuns++;
                p.DefensiveRecoveryUntil=Seconds+1.15f;
                var run=carrier.Velocity.Length>1?carrier.Velocity.Unit:goalside;
                var side=new Point(-run.Y,run.X);
                float flank=Point.Dot(offset,side)>=0?1:-1;
                if(Math.Abs(Point.Dot(offset,side))<.15f)flank=carrier.Position.Y<34?1:-1;
                float lead=Math.Clamp(distance*.14f,.27f,.9f);
                float anticipation=.10f+p.Profile.Defence*.14f;
                p.Action="Рывок вдогонку";
                p.DecisionReason="Возвращается к ближнему плечу нападающего";
                return Clamp(carrier.Position+carrier.Velocity*anticipation+goalside*lead+side*flank*.68f);
            }
            if(CanBodyChallenge(p,carrier)) {p.Action="Сопровождает плечом";return carrier.Position+carrier.Velocity*.18f+offset.Unit*.72f;}
            if((goal-carrier.Position).Length<30 && Point.Dot(offset,goalside)>.1f && distance<8)
            {
                p.Action="Закрывает линию удара";
                p.DecisionReason="Сближается со стороны ворот · не открывает удар";
                if(WindupPlayer==carrier.Id)p.BlockUntil=Seconds+.25f;
                return Clamp(carrier.Position+carrier.Velocity*(.05f+p.Profile.Defence*.20f)+
                    goalside*(distance>2?1.25f:.85f));
            }
            if(ShouldChallenge(p,carrier)){p.Action="Выбирает момент отбора";return Ball+goalside*.25f;}
            if(Seconds>=p.ContainUntil+.5f){p.ContainUntil=Seconds+.7f+p.Profile.Defence*.5f;ContainedDuels++;}
            p.Action=distance>3?"Перекрывает путь к воротам":"Сдерживает · вытесняет на фланг";
            float inside=carrier.Position.Y<34?1:-1;
            return Clamp(carrier.Position+carrier.Velocity*(.05f+p.Profile.Defence*.22f)+
                goalside*(distance>3?2.1f:1.25f)+new Point(0,inside*.3f));
        }
        var defenders=ActivePlayers.Where(q=>q.Team==p.Team && !q.Keeper && q.Id!=chasers[p.Team] && Seconds>=q.FallUntil)
            .OrderBy(q=>(q.Position-carrier.Position).Length).Take(2).ToArray();
        if(distance<23 && defenders.Length>0 && p.Id==defenders[0].Id)
        {
            p.Action="Страхует партнёра";
            var goal=World(new Point(0,34),p.Team);
            return carrier.Position+(goal-carrier.Position).Unit*4+new Point(0,carrier.Position.Y<34?2:-2);
        }
        if(distance<25 && defenders.Length>1 && p.Id==defenders[1].Id)
        {
            var receiver=ActivePlayers.Where(q=>q.Team==carrier.Team && q.Id!=carrier.Id && !q.Keeper && Observed(p,q).HasValue)
                .OrderBy(q=>(Observed(p,q).Value-carrier.Position).Length).FirstOrDefault();
            if(receiver!=null){p.Action="Закрывает линию передачи";return carrier.Position+(Observed(p,receiver).Value-carrier.Position).Unit*5;}
        }
        return target;
    }
    void PlanFootballRun(Footballer p,Footballer carrier)
    {
        p.FootballRun=FootballRun.None;p.FootballRunUntil=Seconds+1.4f;
        if(TryPlanLinkedRun(p,carrier))return;
        if(carrier.Keeper || !AttackUnit(p) || p.Stamina<.27f || (p.Position-carrier.Position).Length>32)return;
        var candidates=ActivePlayers.Where(q=>q.Team==p.Team && q.Id!=Owner && AttackUnit(q) && q.Stamina>.27f)
            .OrderBy(q=>(q.Position-carrier.Position).Length-q.Profile.OffBall*5).Take(2).ToArray();
        int slot=Array.FindIndex(candidates,q=>q.Id==p.Id);if(slot<0)return;
        int choice=(p.Id+p.RunSequence+++(Seed&7))%5;
        if(TacticalCharacters[p.Team]==TacticalCharacter.Counter && counterAttackUntil[p.Team]>Seconds)
            choice=(int)(slot==0?FootballRun.Behind:FootballRun.CheckShort)-1;
        // A forward pocket near the box calls for penetration, not a cyclic short check.
        var pocket=OnsideRunTarget(p,carrier.Position+new Point(Direction(p.Team)*13,p.Position.Y<34?-6:6));
        float hunter=SkillStrength(p,UniqueSkill.SpaceHunter);
        if(hunter>0 && World(carrier.Position,p.Team).X>42 && PerceivedSpace(p,pocket)>2.5f &&
            IsOnside(p,carrier.Position))choice=(int)FootballRun.Behind-1;
        if(slot==0 && World(carrier.Position,p.Team).X is >60 and <88 &&
            PerceivedSpace(p,pocket)>3 && (pocket.X-p.Position.X)*Direction(p.Team)>3)
            choice=(int)FootballRun.Behind-1;
        p.FootballRun=(FootballRun)(choice+1);RunTypes[choice+1]++;IntelligentRuns++;
        p.FootballRunStarted=Seconds;p.FootballRunUntil=Seconds+2.2f+p.Profile.OffBall;
        float dir=Direction(p.Team),side=p.Position.Y<carrier.Position.Y?-1:1;
        p.FootballRunTarget=p.FootballRun switch
        {
            FootballRun.Behind=>carrier.Position+new Point(dir*13,side*6),
            FootballRun.CheckShort=>carrier.Position+new Point(-dir*5,side*5),
            FootballRun.Decoy=>p.Position+new Point(dir*9,side*5),
            FootballRun.Overlap=>carrier.Position+new Point(dir*9,carrier.Position.Y<34?-7:7),
            _=>carrier.Position+new Point(dir*7,side*9)
        };
        if(p.FootballRun==FootballRun.BetweenLines)
        {
            var defenders=ActivePlayers.Where(q=>q.Team!=p.Team && !q.Keeper && Observed(p,q).HasValue)
                .Select(q=>Observed(p,q).Value).OrderBy(q=>(q-p.FootballRunTarget).Length).Take(2).ToArray();
            if(defenders.Length==2)p.FootballRunTarget=(defenders[0]+defenders[1])*.5f;
        }
        p.FootballRunTarget=OnsideRunTarget(p,p.FootballRunTarget);
        if(hunter>0 && p.FootballRun==FootballRun.Behind)
        {
            p.FootballRunTarget=OnsideRunTarget(p,p.FootballRunTarget+new Point(Direction(p.Team)*1.8f*hunter,0));
            p.FootballRunUntil+=.4f*hunter;
        }
    }
    Point OnsideRunTarget(Footballer p,Point target)
    {
        int dir=Direction(p.Team);
        var known=ActivePlayers.Where(q=>q.Team!=p.Team && Observed(p,q).HasValue)
            .Select(q=>Observed(p,q).Value.X*dir).OrderByDescending(x=>x).ToArray();
        if(known.Length>=2 && target.X*dir>Math.Max(Ball.X*dir,known[1])-1)
            target.X=(Math.Max(Ball.X*dir,known[1])-1)*dir;
        return Clamp(target);
    }
    Point FootballMovement(Footballer p,Point target,int possessionTeam)
    {
        if(p.Keeper || Seconds<p.FallUntil || DuelDefender==p.Id || WindupPlayer==p.Id)return target;
        if(Owner==p.Id)
        {
            if(p.ShotSetupUntil>Seconds){p.Action="Подстраивает шаг под удар";return p.ShotSetupTarget;}
            if(p.ReceiveUntil>Seconds){p.Action="Первое касание в свободную зону";return p.Position+p.ReceiveDirection*2;}
            if(p.ShieldUntil>Seconds){p.Action="Прикрывает мяч корпусом";return p.Position+p.ShieldDirection*.4f;}
            if(p.AttackEscapeUntil>Seconds){p.Action="Смещается для обострения";return p.AttackEscapeTarget;}
            if(p.HesitateUntil>Seconds){p.Action="Ищет решение";return p.Position+p.Velocity*.1f;}
            if(p.PlanUntil>Seconds && p.PlanChoice==BallChoice.Carry)return p.PlanTarget;
            return target;
        }
        if(Owner>=0 && possessionTeam!=p.Team && !Players[Owner].Keeper)
        {
            if(p.BlockUntil>Seconds)return target;
            target=DuelMovement(p,Players[Owner],target);
            if(counterPressUntil[p.Team]>Seconds && p.Stamina>.4f && (p.Position-Ball).Length<13 && p.Id==secondChasers[p.Team])
            {p.Action="Контрпрессинг после потери";return Ball+new Point(-Direction(p.Team)*1.5f,p.Position.Y<Ball.Y?-2:2);}
            return target;
        }
        if(TryFinalThirdMovement(p,possessionTeam,out var boxTarget))return boxTarget;
        p.BoxRole=0;
        if(Owner>=0 && possessionTeam==p.Team && p.Home.X>30 &&
            World(Ball,p.Team).X is >48 and <98 &&
            Math.Abs(p.Home.Y-34)>17)
        {
            float x=Math.Min(94,Math.Max(World(Ball,p.Team).X+7,World(p.Position,p.Team).X+1));
            float y=p.Home.Y<34?10:58;
            p.Action="Открывается для навеса";
            return OnsideRunTarget(p,World(new Point(x,y),p.Team));
        }
        if(Owner>=0 && possessionTeam==p.Team && StrikerRole(p) &&
            World(Ball,p.Team).X is >43 and <77 &&
            Math.Sign(p.Home.Y-34)==Math.Sign(World(Ball,p.Team).Y-34))
        {
            var local=World(p.Position,p.Team);
            p.Action="Забегает на фланг";
            return OnsideRunTarget(p,World(new Point(Math.Min(85,Math.Max(World(Ball,p.Team).X+11,local.X+2)),p.Home.Y<34?14:54),p.Team));
        }
        if(Owner>=0 && possessionTeam==p.Team && p.Home.X is >30 and <55 &&
            Math.Abs(p.Home.Y-34)<14 &&
            World(Ball,p.Team).X is >25 and <76 &&
            (p.FootballRun==FootballRun.None || Seconds>=p.FootballRunUntil))
        {
            var ball=World(Ball,p.Team);
            bool near=Math.Abs(p.Home.Y-34)<3 && p.Home.X<40 ||
                Math.Sign(p.Home.Y-34)==Math.Sign(ball.Y-34) ||
                Math.Abs(ball.Y-34)<3 && p.Home.Y<34;
            float y=near?Math.Clamp(ball.Y+(p.Home.Y<34?-8:8),10,58):
                Math.Clamp(34+(p.Home.Y<34?-5:5),18,50);
            p.Action=near?"Поддерживает под пас":"Открывается между линиями";
            return OnsideRunTarget(p,World(new Point(ball.X+(near?-6:8),y),p.Team));
        }
        if(Owner>=0 && possessionTeam==p.Team && p.FootballRun!=FootballRun.None && Seconds<p.FootballRunUntil)
        {
            p.Action=p.FootballRun switch{FootballRun.Behind=>"Рывок за спину",FootballRun.CheckShort=>"Отходит за передачей",FootballRun.Decoy=>"Ложное открывание",FootballRun.Overlap=>"Забегание по флангу",_=>"Открывается между линиями"};
            if(p.FootballRun==FootballRun.Decoy && Seconds-p.FootballRunStarted>.8f)
                return OnsideRunTarget(p,p.FootballRunTarget+new Point(-Direction(p.Team)*8,p.Position.Y<34?5:-5));
            return OnsideRunTarget(p,p.FootballRunTarget);
        }
        return target;
    }
    bool SetUpShot(Footballer p,float distance,float pressure)
    {
        if(p.ShotSetupUntil>0)
        {
            if(Seconds<p.ShotSetupUntil)return true;
            p.ShotSetupUntil=0;
            if(FinishingWindow(p).Lane<.45f && distance>12)
            {p.Decision=.12f;p.Action="Отказался от закрытого удара";return false;}
            BeginShot(p,distance);return true;
        }
        var goalward=(new Point(Direction(p.Team)>0?105:0,34)-p.Position).Unit;
        float alignment=Point.Dot(new Point(MathF.Cos(p.Facing),MathF.Sin(p.Facing)),goalward);
        bool awkward=alignment<.88f || Seconds-p.LastControlAt<.65f;
        if(awkward && pressure>2.5f && p.Profile.Composure>.4f)
        {
            p.ShotSetupUntil=Seconds+.22f+(1-p.Skill)*.25f;
            p.ShotSetupTarget=Clamp(p.Position+goalward*.4f+new Point(-goalward.Y,goalward.X)*(p.StrongFoot*.14f));
            p.Decision=.1f;ShotAdjustments++;p.Action="Подстраивает шаг под удар";return true;
        }
        BeginShot(p,distance);return true;
    }
    float ShotBalance(Footballer p)
    {
        float pressure=CarrierPressure(p);
        p.KickFoot=p.StrongFoot;
        var goalward=(new Point(Direction(p.Team)>0?105:0,34)-p.Position).Unit;
        var side=new Point(-goalward.Y,goalward.X);
        float ballSide=Point.Dot(Ball-p.Position,side);
        p.UsedWeakFoot=pressure<2.5f && ballSide*p.StrongFoot<-.08f;
        if(p.UsedWeakFoot){p.KickFoot=-p.StrongFoot;WeakFootShots++;}
        p.RushedShot=pressure<1.7f || (Seconds-p.LastControlAt<.25f && pressure<3);
        if(p.RushedShot)RushedShots++;
        return Math.Clamp(1-(p.UsedWeakFoot?((1-p.Skill)*.35f+.12f)*(6-Math.Clamp(p.WeakFootRating,1,5))/3f:0)-(p.RushedShot?.22f:0)-(1-p.Stamina)*.15f, .45f,1);
    }
    bool KeeperShouldParry(Footballer keeper,float speed,float height)
    {
        if(keeper.KeeperBlockUntil>Seconds && keeper.KeeperBlockKind==2)return true;
        float stretch=Math.Abs(Ball.Y-keeper.Position.Y);
        float pressure=ActivePlayers.Where(q=>q.Team!=keeper.Team).Min(q=>(q.Position-keeper.Position).Length);
        float handling=keeper.Profile.GkHandling*.65f+keeper.Profile.Composure*.2f+keeper.Stamina*.15f;
        bool safe=SkillActive(keeper,UniqueSkill.SafeHands);
        if(!safe && speed>15 && speed<33 && height<2.1f && pressure>1.4f && stretch<1.35f)
            safe=ActivateSkill(keeper,UniqueSkill.SafeHands);
        float grip=safe?keeper.ActiveSkillPower:0;
        return speed>20+handling*10+grip*3 || speed>12+grip*4 && stretch>.95f+grip*.12f ||
            keeper.DiveRemaining>0 && speed>17+grip*4 && stretch>.65f+grip*.1f ||
            speed>15+grip*3 && (height>1.9f || pressure<1.3f);
    }
    void ResetFootballContext()
    {
        ResetCombinations();
        lastPossessionTeam=-1;counterPressUntil[0]=counterPressUntil[1]=0;
        foreach(var p in Players){p.FootballRun=FootballRun.None;p.ReceiveUntil=p.ReceiveReadyUntil=p.ShotSetupUntil=p.ShieldUntil=p.ContainUntil=0;p.BodyLeanUntil=0;p.PlanUntil=0;p.BoxRole=0;}
    }
}