PlayerBehaviour.cs

Part of the football match simulation. Holds the stable PlayerProfile stat block (attributes, play styles, skill loadout) plus AI helpers on MatchSimulation: chaser selection, off-ball run planning, stamina and sprint bursts, facing/turn lean, and the shot windup, strike-kind selection and release pipeline that kicks the ball and emits effect events.

using System;
using System.Collections.Generic;
using System.Linq;

namespace Socer;

// Stable profile data, separate from mutable match state. Names/assets can be supplied by a licensed roster later.
public sealed class PlayerProfile
{
    float volleyTechnique=-1;
    // Generated/laboratory profiles inherit their finishing technique; real
    // cards supply their separate volleys attribute when applied to a match.
    public float Volleys {get=>volleyTechnique<0?Finishing:volleyTechnique;set=>volleyTechnique=value;}
    public string SlotRole { get; set; }="";
    public float RoleFamiliarity { get; set; }=1;
    public int Overall { get; set; }=75;
    public float Defence { get; set; }=.65f;
    public float Interceptions { get; set; }=.65f;
    public float StandingTackle { get; set; }=.65f;
    public float SlidingTackle { get; set; }=.65f;
    public float Physical { get; set; }=.65f;
    public float HeadingAccuracy { get; set; }=.65f;
    public float Jumping { get; set; }=.65f;
    public float Goalkeeping { get; set; }=.7f;
    public float GkDiving {get;set;}=.7f;
    public float GkHandling {get;set;}=.7f;
    public float GkReflexes {get;set;}=.7f;
    public float GkPositioning {get;set;}=.7f;
    public float GkKicking {get;set;}=.7f;
    public string Id { get; set; }="";
    public string CatalogId {get;set;}="";
    public string CountryCode {get;set;}="";
    public string Edition {get;set;}="";
    public string Name { get; set; }="";
    public string Style { get; set; }="";
    public PlayStyle PlayStyles { get; set; }
    public PlayStyle PlayStylesPlus { get; set; }
    public UniqueSkill UniqueSkill { get; set; }
    public List<SkillInstance> SkillLoadout { get; set; }=new();
    public int SkinTone { get; set; }=-1;
    public int HairTone { get; set; }=-1;
    public float Passing { get; set; }
    public float Finishing { get; set; }
    public float Penalties {get;set;}=.65f;
    public float ShotPower { get; set; }
    public float LongShots { get; set; }
    public float Curve { get; set; }
    public float Crossing { get; set; }
    public float FreeKickAccuracy { get; set; }
    public float LongPassing { get; set; }
    public int SkillMoves { get; set; }=3;
    public float Vision { get; set; }
    public float Endurance { get; set; }
    public float Acceleration { get; set; }
    public float Aggression { get; set; }
    public float Composure { get; set; }=.65f;
    public float OffBall { get; set; }
}

public sealed class MatchEffect
{
    public int Id, Player=-1;
    public string Kind="", Text="";
    public Point Position;
    public float Age, Duration;
}

public sealed partial class MatchSimulation
{
    public List<MatchEffect> Effects { get; }=new();
    public int[] ThroughBalls=new int[2];
    public int WindupPlayer=-1;
    public float WindupRemaining, ShotCueRemaining;
    public bool PowerShot;
    public Point ShotTarget;
    public int ShotPreparations, CancelledShots;
    int effectId;
    float nextSpeech;
    readonly int[] chasers={-1,-1}, secondChasers={-1,-1};
    public bool ShotInFlight => shot && Owner<0;
    public float Danger
    {
        get
        {
            if(RestartRemaining>0 || Finished) return 0;
            if(WindupPlayer>=0 || ShotInFlight) return 1;
            float endDistance=Math.Min(Ball.X,105-Ball.X);
            return Math.Clamp((34-endDistance)/17,0,1);
        }
    }
    PlayerProfile CreateProfile(Footballer p)
    {
        string style=p.Keeper?"Вратарь":p.Number<=5?"Опорный":p.Number is 6 or 9?"Бегунок":p.Number<=8?"Диспетчер":"Завершитель";
        return new PlayerProfile {
            Id=$"demo-{p.Team}-{p.Number}", Name=$"{TeamName(p.Team)} #{p.Number}", Style=style,
            Passing=.55f+Next()*.3f+(style=="Диспетчер"?.12f:0),
            Vision=.5f+Next()*.3f+(style=="Диспетчер"?.18f:0),
            Finishing=.45f+Next()*.3f+(style=="Завершитель"?.22f:0),
            ShotPower=.5f+Next()*.35f,
            LongShots=.45f+Next()*.38f, Curve=.4f+Next()*.4f,
            Crossing=.56f+(p.Id%6)*.045f, FreeKickAccuracy=.51f+(p.Id%7)*.05f,
            LongPassing=.45f+Next()*.38f, SkillMoves=p.Keeper?1:2+(p.Number%3),
            Endurance=.5f+Next()*.45f, Acceleration=.5f+Next()*.3f+(style=="Бегунок"?.17f:0),
            Physical=.5f+Next()*.35f,
            HeadingAccuracy=(p.Number is 3 or 4 ? .67f : p.Number>=10 ? .73f : .48f)+((p.Id*37+Seed%17)%20)*.01f,
            Jumping=(p.Number is 3 or 4 ? .68f : p.Number>=10 ? .66f : .48f)+((p.Id*29+Seed%13)%21)*.01f,
            Aggression=.4f+Next()*.5f, Composure=.25f+(p.Id*17+Seed%37)%65/100f, OffBall=.5f+Next()*.3f+(style is "Бегунок" or "Завершитель"?.18f:0)
        };
    }
    void UpdateChasers()
    {
        for(int t=0;t<2;t++)
        {
            var order=ActivePlayers.Where(p=>p.Team==t&&!p.Keeper && Seconds>=p.FallUntil && !p.IsAcrobaticCommitted(Seconds)).OrderBy(p=>(p.Position-Ball).Length/(.7f+p.Stamina*.3f)).ToArray();
            if(order.Length==0){chasers[t]=-1;secondChasers[t]=-1;continue;}
            if(order.Length==1){chasers[t]=order[0].Id;secondChasers[t]=-1;continue;}
            var candidate=order[0];
            // A new closer player takes over only with a meaningful advantage; avoid chaser flicker.
            if(chasers[t]<0 || Players[chasers[t]].SentOff || Seconds<Players[chasers[t]].FallUntil || Players[chasers[t]].IsAcrobaticCommitted(Seconds) || (Players[chasers[t]].Position-Ball).Length>(candidate.Position-Ball).Length+2.5f) chasers[t]=candidate.Id;
            secondChasers[t]=order.First(p=>p.Id!=chasers[t]).Id;
        }
    }
    Point PlanRun(Footballer p,Point target,int possessionTeam)
    {
        if(p.Keeper || Owner==p.Id) return target;
        if(possessionTeam!=p.Team) { p.RunUntil=0; return target; }
        int dir=Direction(p.Team);
        if(p.RunUntil>Seconds)
        {
            p.Action="Открывание";
            // Hold the line until the pass is released.
            float offsideLine=ActivePlayers.Where(q=>q.Team!=p.Team).Select(q=>q.Position.X*dir).OrderByDescending(x=>x).Skip(1).First();
            var run=p.RunTarget;
            if(Owner>=0) run.X=dir*Math.Min(run.X*dir,Math.Max(offsideLine-1.5f,Ball.X*dir));
            return run;
        }
        if(Owner>=0 && AttackUnit(p) && p.BurstCooldown<=0 && p.Stamina>.28f &&
            Players[Owner].Held>.4f && (p.Position-Ball).Length is > 8 and < 32 &&
            ActivePlayers.Count(q=>q.Team==p.Team&&q.RunUntil>Seconds)<2 &&
            p.Decision<=0)
        {
            p.Decision=.8f+Next()*.6f;
            if(Next()<p.Profile.OffBall*(Approach[p.Team]==2?.67f:Approach[p.Team]==0?.3f:.45f))
            {
                float side=p.Position.Y<34?-1:1;
                float cut=p.Profile.Style=="Бегунок"?side*5:-side*4;
                var run=Clamp(p.Position+new Point(dir*(10+p.Profile.OffBall*8),cut));
                float space=ActivePlayers.Where(q=>q.Team!=p.Team).Min(q=>(q.Position-run).Length);
                if(space>3)
                {
                    p.RunTarget=run; p.RunUntil=Seconds+3;
                    p.Action="Открывание"; Say(p,p.Number>=10?"Вразрез!":"Мне!",false);
                    return run;
                }
            }
        }
        return target;
    }
    public static float EffectivePace(Footballer p) => p.Pace*(.58f+.42f*p.Stamina)*(p.Sprinting?1.48f:1)*
        (p.ActiveUniqueSkill==UniqueSkill.RecoveryBurst && p.ActiveSkillRemaining>0 && p.Sprinting?1+.075f*p.ActiveSkillPower:1);
    public void Recover(Footballer p,float dt)
    {
        p.BurstRemaining=0;
        p.BurstCooldown=Math.Max(0,p.BurstCooldown-dt);
        p.BurstCharge=Math.Min(1,p.BurstCharge+dt*.12f);
        p.Stamina=Math.Min(1,p.Stamina+dt*(.0035f+.003f*p.Profile.Endurance+.001f*p.Profile.Physical));
    }
    void UpdateEffort(Footballer p,Point target,float dt,int possessionTeam)
    {
        float distance=(target-p.Position).Length;
        bool recoverySprint=Owner>=0 && Players[Owner].Team!=p.Team && !p.Keeper &&
            p.Id==chasers[p.Team] && p.DefensiveRecoveryUntil>Seconds && distance>1.25f;
        bool shoulderPursuit=BodyChallenge && DuelDefender==p.Id && Owner==DuelCarrier;
        bool urgent=UrgentLooseBall(p) || p.KeeperRushing || p.AerialChasing || recoverySprint;
        bool secondBall=Owner<0 && BallSpeed>3 && p.SecondBallUntil>Seconds;
        bool recoverMark=possessionTeam!=p.Team && p.AutoMarkId>=0 && !Players[p.AutoMarkId].SentOff &&
            Observed(p,Players[p.AutoMarkId]) is Point marked && (marked-World(new Point(0,34),p.Team)).Length<32 && distance>1.8f;
        // The moving ball is a nearby contact target, not the end of a carrier's run.
        bool carryBurst=Owner==p.Id && !p.Keeper && WindupPlayer!=p.Id && p.ShotSetupUntil<=0 &&
            Seconds>=p.KickSetupUntil && Seconds>=p.ShieldUntil && Seconds>=p.ReceiveUntil &&
            World(p.Position,p.Team).X>35 &&
            (OpenCarryDistance(p)>6 || (p.CarryAvoidUntil>Seconds && CarryLane(p,p.CarryAvoidTarget)>1.7f && (p.CarryAvoidTarget-p.Position).Length>2)) && CarrierPressure(p)>1.5f;
        bool intent=shoulderPursuit || recoverySprint || carryBurst || recoverMark || LinkedCoverRecovery(p) || (p.DirectOrderUntil>Seconds && distance>2 &&
            (p.DirectOrder==DirectOrderKind.AttackRun || (p.DirectOrder==DirectOrderKind.MarkPlayer && possessionTeam!=p.Team))) || (urgent?distance>1.1f:!p.Keeper && WindupPlayer!=p.Id && distance>2.5f &&
            (secondBall || (p.DirectOrder==DirectOrderKind.PressPlayer && Owner>=0 && Players[Owner].Team!=p.Team) || p.RunUntil>Seconds || (p.FootballRunUntil>Seconds && p.FootballRun is FootballRun.Behind or FootballRun.Overlap or FootballRun.Decoy) || p.BoxRole is 1 or 2 ||
             (counterPressUntil[p.Team]>Seconds && p.Id==secondChasers[p.Team]) ||
             (Owner==p.Id && World(p.Position,p.Team).X>48) ||
             (p.Id==chasers[p.Team] && possessionTeam!=p.Team && (p.Position-Ball).Length<23)));
        if(BroadcastPlanActive[p.Team] && TeamTempo[p.Team]>.7f && !p.Keeper && distance>4 &&
            possessionTeam==p.Team && Owner!=p.Id && !BackLine(p) && WindupPlayer!=p.Id)intent=true;
        if(recoverySprint && p.BurstRemaining<=0 && p.DefensiveRecoveryBurstReadyAt<=Seconds &&
            p.BurstCharge>.16f && p.Stamina>.14f)
        {
            ActivateSkill(p,UniqueSkill.RecoveryBurst,1.7f);
            p.BurstRemaining=.78f+p.Profile.Defence*.65f;
            p.BurstCooldown=Math.Max(p.BurstCooldown,2.4f);
            p.DefensiveRecoveryBurstReadyAt=Seconds+1.9f;
            p.Sprints++;DefensiveRecoverySprints++;
        }
        else if(intent && p.BurstRemaining<=0 && (p.BurstCooldown<=0 || (urgent && p.BurstCooldown<2)) && p.BurstCharge>(urgent?.25f:.75f) && p.Stamina>(urgent?.15f:.24f))
        {
            p.BurstRemaining=1.3f+p.Profile.Endurance*1.1f;
            p.BurstCooldown=urgent?3:5.5f*(BroadcastPlanActive[p.Team]?1.3f-TeamTempo[p.Team]*.6f:1); p.Sprints++;
        }
        bool sprint=p.Sprinting && intent;
        if(!intent) p.BurstRemaining=0;
        if(sprint)
        {
            p.BurstRemaining=Math.Max(0,p.BurstRemaining-dt);
            p.BurstCharge=Math.Max(0,p.BurstCharge-dt*.34f);
            float economy=1-.08f*SkillStrength(p,UniqueSkill.Conditioning)-
                (SkillActive(p,UniqueSkill.RecoveryBurst)?.10f*p.ActiveSkillPower:0);
            p.Stamina=Math.Max(.08f,p.Stamina-dt*(.055f-.022f*p.Profile.Endurance-.008f*p.Profile.Physical)*PlanEffort(p.Team)*economy);
            if(p.KeeperRushing || urgent || p.Action is "Позиция" or "Прессинг" or "Ведение")p.Action=p.KeeperRushing?"Рывок вратаря":urgent?"Рывок за мячом":"Рывок";
        }
        else
        {
            p.BurstCharge=Math.Min(1,p.BurstCharge+dt*.12f);
            float drain=p.Velocity.Length>3 ? .0025f*(1.25f-p.Profile.Endurance*.5f) :
                -(.0035f+.003f*p.Profile.Endurance+.001f*p.Profile.Physical);
            p.Stamina=Math.Clamp(p.Stamina-dt*drain*(drain>0?PlanEffort(p.Team):1),.08f,1);
        }
    }
    void UpdateFacing(Footballer p,Point target,float dt)
    {
        if(p.IsAcrobaticCommitted(Seconds))return;
        int receptionShot=FirstTimePhase(p);
        bool strategyFinish=receptionShot>=0 && Owner!=p.Id && (Ball-p.Position).Length<14;
        bool openFinish=!RehearsingStrategy && Owner<0 && PassReceiver==p.Id && LastTouch==p.Team && BallHeight<1 &&
            (Ball-p.Position).Length<14 && (World(new Point(105,34),p.Team)-p.Position).Length<25 &&
            FinishingWindow(p).Lane>1.05f;
        bool readyToFinish=strategyFinish || openFinish;
        bool keepingGoal=p.Keeper && Owner!=p.Id && KeeperKickPlayer!=p.Id;
        bool committedSave=p.Keeper && KeeperKickPlayer!=p.Id && (p.KeeperBlockUntil>0 && Seconds<p.KeeperBlockUntil+.22f ||
            p.DiveRemaining>0 || Seconds-p.DiveEndedAt<HumanFigure.DiveRecoverySeconds(p,this));
        Point look=KeeperKickPlayer==p.Id?KeeperKickTarget-p.Position:WindupPlayer==p.Id?ShotTarget-p.Position:
            Owner==p.Id && p.ShotSetupUntil>0?FinishingWindow(p).Target-p.Position:
            strategyFinish?World(new Point(106,31.5f+Strategy.Plan.Phases[receptionShot].Corner*2.5f),p.Team)-p.Position:
            openFinish?FinishingWindow(p).Target-p.Position:
            Owner==p.Id && Seconds<p.KickSetupUntil?p.KickAim-p.Position:
            Owner==p.Id && p.Velocity.Length>1?p.Velocity.Unit*.65f+(target-p.Position).Unit*.35f:
            Owner==p.Id?target-p.Position:
            (Ball-p.Position).Length<22 || p.Keeper?Ball-p.Position:target-p.Position;
        if(committedSave)
            look=p.KeeperSaveFacing.Length>.05f?p.KeeperSaveFacing:new Point(Direction(p.Team),0);
        if(!keepingGoal && !committedSave && !readyToFinish && Owner!=p.Id && Seconds<p.ReceiveReadyUntil)look=(Ball-p.Position).Unit*.65f+p.ReceiveDirection*.65f;
        if(!keepingGoal && !committedSave && !readyToFinish && LinkedReceptionFacing(p,out var linkedLook))look=linkedLook;
        if(look.Length<.05f)
        {
            p.TurnLean*=MathF.Exp(-dt*8);
            return;
        }
        bool receivingSideways=!RehearsingStrategy && Owner!=p.Id && Owner>=0 && Players[Owner].Team==p.Team && (Ball-p.Position).Length<22 && p.Velocity.Length<4.5f;
        // Keepers retreat and shuffle while watching the shot. An outfielder's
        // running-direction override turned that defensive movement into a side-on block.
        if(!keepingGoal && !committedSave && !readyToFinish && !receivingSideways && WindupPlayer!=p.Id && Seconds>=p.KickSetupUntil && p.Velocity.Length>2 && look.Length>.1f && Point.Dot(p.Velocity.Unit,look.Unit)<-.3f)
            look=p.Velocity.Unit*.8f+look.Unit*.2f;
        float angle=MathF.Atan2(look.Y,look.X);
        float difference=MathF.Atan2(MathF.Sin(angle-p.Facing),MathF.Cos(angle-p.Facing));
        float turn=(3.8f+p.Profile.Acceleration*1.2f+p.Stamina*.4f)*
            (1-Math.Clamp((p.Velocity.Length-3)/8,0,.32f));
        float rotation=Math.Clamp(difference,-dt*turn,dt*turn);
        p.Facing+=rotation;
        float leanTarget=Math.Clamp(rotation/Math.Max(dt,.001f)/turn,-1,1)*
            Math.Clamp(p.Velocity.Length/4,0,1);
        p.TurnLean+=(leanTarget-p.TurnLean)*(1-MathF.Exp(-dt*8));
    }
    void Emit(string kind,Footballer p,string text,float duration)
    {
        if(kind=="tackle")lastDialogueTackler[p.Team]=p.Id;
        EmitAt(kind,p.Position,text,duration);
        Effects[^1].Player=p.Id;
    }
    void EmitAt(string kind,Point at,string text,float duration)
    {
        Effects.Add(new MatchEffect {Id=++effectId,Kind=kind,Position=at,Text=text,Duration=duration});
        if(Effects.Count>8) Effects.RemoveAt(0);
    }
    public void BeginShot(Footballer p,float distance)
    {
        penaltyShot=false;
        p.KickSetupUntil=0;p.ShotSetupUntil=0;
        PowerShot=distance>(PlayerPlayStyles.Has(p,PlayStyle.PowerShot)?16:20) &&
            p.Profile.ShotPower>.67f && p.Profile.LongShots>.58f;
        var keeper=Players.First(q=>q.Team!=p.Team && q.Keeper);
        bool keeperOut=Math.Abs(keeper.Position.X-(Direction(p.Team)>0?105:0))>6;
        float touch=p.Profile.Finishing*.72f+p.Profile.Composure*.28f;
        Strike=distance<24 && touch>.55f && keeperOut &&
            Next()<.35f+touch*.35f?StrikeKind.Chip:
            Math.Abs(p.Position.Y-34)>5 && distance>13 && p.Profile.Curve>.44f && Next()<
                p.Profile.Curve*.6f+touch*.2f+(PlayerPlayStyles.Has(p,PlayStyle.FinesseShot)?.12f:0)?StrikeKind.Curl:
            PowerShot && Next()<.20f+p.Profile.LongShots*.45f+(PlayerPlayStyles.Has(p,PlayStyle.PowerShot)?.12f:0)?StrikeKind.Power:StrikeKind.Driven;
        PowerShot=Strike==StrikeKind.Power;
        float balance=ShotBalance(p);
        float accuracy=distance>22?p.Profile.Finishing*.38f+p.Profile.LongShots*.62f:p.Profile.Finishing;
        float spread=.16f+distance*.013f+(1-accuracy)*3.2f+(1-p.Profile.Composure)*1.5f+(1-p.Stamina)*1.3f+(1-balance)*3;
        if(!p.RushedShot)spread*=1-.12f*SkillStrength(p,UniqueSkill.Sniper);
        var window=FinishingWindow(p);
        // Extend the chosen goal-line ray; moving X beyond the line must not shift the aim inward.
        var goalRay=window.Target-p.Position;
        float beyond=Math.Abs(goalRay.X)>.1f?(Math.Abs(goalRay.X)+1)/Math.Abs(goalRay.X):1;
        ShotTarget=p.Position+goalRay*beyond;
        ShotTarget.Y+=(Next()-.5f)*spread;
        WindupPlayer=p.Id; WindupRemaining=PowerShot?.48f:.28f;
        p.RunningShot=!PowerShot && p.Velocity.Length>3.2f && distance<29 &&
            Point.Dot(new Point(MathF.Cos(p.Facing),MathF.Sin(p.Facing)),(ShotTarget-p.Position).Unit)>.8f;
        if(p.RunningShot)WindupRemaining=.18f;
        bool laser=distance is >=8 and <=24 && !p.RushedShot && balance>.62f && window.Lane>1.1f &&
            ActivateLegendary(p,LegendaryAbility.LaserFinish);
        if(laser)
        {
            // A planted preparation can be closed down. The chosen corner and
            // the ball still use the ordinary physical shot pipeline.
            Strike=StrikeKind.Driven;PowerShot=false;p.RunningShot=false;WindupRemaining=.42f;
            ShotTarget=p.Position+goalRay*beyond+(ShotTarget-(p.Position+goalRay*beyond))*.24f;
            p.LegendaryTargetA=window.Target;
        }
        p.WindupStartedAt=Seconds;
        p.Action="Замах";
        ShotPreparations++;
        p.BurstRemaining=0;
        Say(p,PowerShot?"Бью!":"Сейчас!",true);
    }
    void CancelWindup()
    {
        manualShot=false;penaltyShot=false;preparedFreeKickShot=false;
        if(WindupPlayer>=0) CancelledShots++;
        WindupPlayer=-1; WindupRemaining=0;
    }
    void ReleaseShot(Footballer p)
    {
        // Prepared shots wait for the body; a genuine first-time strike has no windup.
        if(WindupPlayer==p.Id && !PrepareKickFacing(p,ShotTarget))
        {WindupRemaining=.06f;p.Action="Доворачивается под удар";return;}
        WindupPlayer=-1; WindupRemaining=0;
        float distance=(ShotTarget-p.Position).Length;
        bool comet=distance is >10 and <28 && p.Profile.Curve>.38f &&
            SkillReady(p,UniqueSkill.CurveComet) && Next()<.48f &&
            ActivateSkill(p,UniqueSkill.CurveComet);
        if(comet){Strike=StrikeKind.Curl;PowerShot=false;}
        bool cannon=!comet && Strike==StrikeKind.Power && distance>17 &&
            ActivateSkill(p,UniqueSkill.PowerDrive);
        float sniperPower=SkillStrength(p,UniqueSkill.Sniper);
        bool sniper=!comet && !cannon && distance<28 && sniperPower>0 && !p.RushedShot;
        if(World(p.Position,p.Team).X<88.5f)LongRangeShots++;
        if(p.Velocity.Length>3.2f)MovingShots++;
        float effort=(Strike==StrikeKind.Power?35:Strike==StrikeKind.Curl?27:25)+p.Profile.ShotPower*4;
        float quality=.78f+(preparedFreeKickShot?p.Profile.FreeKickAccuracy:p.Profile.Finishing)*.16f+p.Stamina*.10f;
        float speed=Math.Clamp(effort*quality*(.94f+Next()*.12f)*(Strike==StrikeKind.Power && PlayerPlayStyles.Has(p,PlayStyle.PowerShot)?1.08f:1),20,42);
        if(cannon)speed=Math.Min(44,speed*(1+.10f*p.ActiveSkillPower));
        ApplyShotExecutionError(p);
        if(IsMediaLeague)ApplyMediaShotError(p);
        if(comet)ShotTarget.Y=Math.Clamp(ShotTarget.Y,31.9f,36.1f);
        if(sniper)
        {
            var precise=FinishingWindow(p).Target;
            float line=Direction(p.Team)>0?105:0;
            float beyond=Math.Abs(ShotTarget.X-p.Position.X)/Math.Max(.1f,Math.Abs(line-p.Position.X));
            float ideal=p.Position.Y+(precise.Y-p.Position.Y)*beyond;
            ShotTarget.Y+=(ideal-ShotTarget.Y)*(.12f*sniperPower);
        }
        preparedFreeKickShot=false;
        penaltyShot=false;
        Kick(p,ShotTarget,speed,true);
        ShapeFlight(ShotTarget,Strike,ShotArrivalHeight);
        if(LegendaryActive(p,LegendaryAbility.LaserFinish))BallLegendaryAbility=LegendaryAbility.LaserFinish;
        if(comet){BallSpin*=1+.55f*p.ActiveSkillPower;BallUniqueSkill=UniqueSkill.CurveComet;BallSkillGrade=p.ActiveSkillGrade;}
        else if(cannon || sniper){BallUniqueSkill=cannon?UniqueSkill.PowerDrive:UniqueSkill.Sniper;BallSkillGrade=cannon?p.ActiveSkillGrade:UniqueSkills.Grade(p.Profile,UniqueSkill.Sniper);}
        if(manualShot)ShapeManualShot();
        LastShotSpeed=MathF.Sqrt(BallSpeed*BallSpeed+VerticalSpeed*VerticalSpeed);
        // Give every shot a visible slow-motion flight, including close finishes and chips.
        ShotCueRemaining=Math.Clamp((ShotTarget-Ball).Length/Math.Max(1,BallSpeed)+.35f,.55f,2.5f);
        if(Strike==StrikeKind.Curl) CurledShots++;
        if(Strike==StrikeKind.Chip) Chips++;
        Shots[p.Team]++;
        if(secondChanceTeam==p.Team && Seconds<secondChanceUntil)
        {ReboundShots++;secondChanceTeam=-1;secondChanceUntil=0;}
        string label=Strike==StrikeKind.Chip?"ПАРАШЮТ":Strike==StrikeKind.Curl?"КРУЧЁНЫЙ":PowerShot?"МОЩНЫЙ УДАР":"НИЗОМ!";
        Emit("shot",p,label,1.6f);
        AddEvent($"{TeamName(p.Team)} · {label.ToLowerInvariant()} #{p.Number}!");
    }
}