PenaltyShootout.cs

Partial of MatchSimulation that runs a penalty shootout. Picks takers and keepers per team, drives the run-up, shot flight, keeper dive and goal/save/miss resolution, tracks attempts and scores, and decides the winner including sudden death.

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

namespace Socer;

public enum PenaltyOutcome { Goal, Saved, Wide, High, Post }
public sealed record PenaltyKickResult(int Team,int Player,PenaltyOutcome Outcome,float TargetY,float Height)
{
    public bool Scored=>Outcome==PenaltyOutcome.Goal;
}

public sealed partial class MatchSimulation
{
    public bool ShootoutRequired;
    public bool ShootoutActive {get;private set;}
    public bool ShootoutComplete {get;private set;}
    public int[] ShootoutScores {get;}=new int[2];
    public int[] ShootoutAttempts {get;}=new int[2];
    public List<PenaltyKickResult> ShootoutKicks {get;}=new();
    public int ShootoutWinner {get;private set;}=-1;
    public int ShootoutTaker {get;private set;}=-1;
    public int ShootoutKeeper {get;private set;}=-1;
    public int ShootoutTeam {get;private set;}
    public int ShootoutPhase {get;private set;}
    public bool ShootoutSuddenDeath=>ShootoutAttempts.Min()>=5;
    string shootoutMatchClock,shootoutMatchClockLabel;
    readonly List<int>[] shootoutEligible={new(),new()};
    readonly int[] shootoutKeepers={-1,-1};
    int shootoutFirstTeam,shootoutTurns;
    float shootoutPhaseAge,shootoutFlightAge,shootoutKeeperDelay;
    Point shootoutRunOrigin,shootoutAim;
    float shootoutAimHeight;
    bool shootoutKeeperStarted;

    public bool BeginShootout()
    {
        if(ShootoutActive || ShootoutComplete || !ShootoutRequired || Goals[0]!=Goals[1])return false;
        foreach(int team in new[]{0,1})
        {
            var eligible=ActivePlayers.Where(p=>p.Team==team).ToArray();
            if(eligible.Length==0)return false;
            shootoutKeepers[team]=eligible.OrderByDescending(p=>p.Keeper || p.EmergencyKeeper).ThenByDescending(p=>p.Profile.Goalkeeping).First().Id;
            shootoutEligible[team].Clear();
            shootoutEligible[team].AddRange(eligible.OrderByDescending(p=>p.Id==shootoutKeepers[team]).ThenByDescending(PenaltyQuality).Select(p=>p.Id));
        }
        int count=Math.Min(shootoutEligible[0].Count,shootoutEligible[1].Count);
        foreach(var list in shootoutEligible)
        {
            if(list.Count>count)list.RemoveRange(count,list.Count-count);
            // The goalkeeper remains eligible, but the better penalty takers
            // go first. Everybody takes one before the order cycles.
            list.Sort((a,b)=>PenaltyQuality(Players[b]).CompareTo(PenaltyQuality(Players[a])));
        }
        shootoutMatchClock=BroadcastClock;shootoutMatchClockLabel=ClockLabel;
        CancelWindup();ResetFlight();ClearOffside();ResetManualRestart();InteractiveSetPieces=false;
        Owner=Passer=-1;shot=false;RestartRemaining=IncidentRemaining=GoalHoldRemaining=0;
        PeriodWalkRemaining=0;ShootoutActive=true;ShootoutPhase=1;shootoutPhaseAge=0;
        shootoutFirstTeam=Next()<.5f?0:1;shootoutTurns=0;
        Ball=new Point(94,34);BallVelocity=new();BallHeight=0;
        RefereePosition=new Point(97.5f,27.7f);
        RefereeActor.Position=RefereeActor.VisualBefore=RefereePosition;RefereeActor.Velocity=RefereeActor.VisualVelocity=RefereeActor.VisualAcceleration=new();RefereeActor.VisualSpeed=0;
        RefereeActor.Facing=MathF.Atan2(34-RefereePosition.Y,105-RefereePosition.X);RefereeActor.Action="Судья наблюдает серию";
        foreach(var p in ActivePlayers){p.Velocity=new();p.DiveRemaining=0;p.RunUntil=p.FeintUntil=0;p.DirectOrder=DirectOrderKind.None;p.LastKickAt=-10;}
        Status="Серия пенальти · команды готовятся";AddEvent(Status);
        return true;
    }

    static float PenaltyQuality(Footballer p)=>p.Profile.Penalties*.60f+p.Profile.Finishing*.12f+p.Profile.Composure*.28f;
    public IReadOnlyList<int> ShootoutEligible(int team)=>shootoutEligible[Math.Clamp(team,0,1)];
    void MoveShootoutPlayer(Footballer p,Point target,float dt,float speed=3)
    {
        var delta=target-p.Position;
        p.Velocity=delta.Length>.04f?delta.Unit*Math.Min(speed,delta.Length*3):new Point();
        p.Position+=p.Velocity*dt;
        if(delta.Length>.04f)UpdateFacing(p,target,dt);
    }
    void ArrangeShootout(float dt)
    {
        foreach(var p in ActivePlayers)
        {
            if(p.Id==ShootoutTaker || p.Id==ShootoutKeeper)continue;
            var target=p.Id==shootoutKeepers[p.Team]?new Point(88,28):new Point(52+(p.Number%4)*1.4f,29+p.Team*6+(p.Number/4)*1.2f);
            MoveShootoutPlayer(p,target,dt,4.5f);p.Action="Ожидает серию пенальти";
        }
    }
    void PrepareShootoutKick()
    {
        ShootoutTeam=(shootoutFirstTeam+shootoutTurns)%2;
        ShootoutTaker=shootoutEligible[ShootoutTeam][ShootoutAttempts[ShootoutTeam]%shootoutEligible[ShootoutTeam].Count];
        ShootoutKeeper=shootoutKeepers[1-ShootoutTeam];
        var taker=Players[ShootoutTaker];var keeper=Players[ShootoutKeeper];
        Ball=new Point(94,34);BallHeight=0;BallVelocity=new();VerticalSpeed=0;Owner=-1;LastTouch=ShootoutTeam;
        // Prefer a compact angled run-up; no dribbling or rebound is permitted.
        taker.Velocity=new();taker.LastKickAt=-10;taker.Action="Готовит пенальти";
        keeper.Velocity=new();keeper.DiveRemaining=0;keeper.DiveEndedAt=-10;keeper.Action="На линии ворот";
        shootoutPhaseAge=0;ShootoutPhase=2;
        WindupPlayer=-1;ShotCueRemaining=0;Strike=StrikeKind.Driven;
        Status=$"Пенальти · {taker.Profile.Name} · {ShootoutScores[0]} : {ShootoutScores[1]}";
    }
    void ReleaseShootoutKick()
    {
        var taker=Players[ShootoutTaker];var keeper=Players[ShootoutKeeper];
        float quality=Math.Clamp(PenaltyQuality(taker),.1f,1),fatigue=Math.Max(0,.55f-taker.Stamina);
        float error=(Next()+Next()+Next()-1.5f)*(.22f+(1-quality)*1.65f+fatigue*.35f);
        float side=Next()<.5f?-1:1;
        shootoutAim=new Point(105,34+side*(1.5f+Next()*1.7f)+error);
        shootoutAimHeight=.15f+Next()*(.95f+(1-quality)*.65f);
        if(Next()<.045f+(1-quality)*.11f){if(Next()<.55f)shootoutAim.Y=34+side*(3.75f+Next()*1.0f);else shootoutAimHeight=2.5f+Next()*.8f;}
        float speed=23+taker.Profile.ShotPower*9+Next()*3;
        BallVelocity=(shootoutAim-Ball).Unit*speed;
        float flight=11/Math.Max(10,BallVelocity.X);
        VerticalSpeed=(shootoutAimHeight+.5f*9.81f*flight*flight)/flight;
        taker.Position=new Point(93.65f,34);taker.Velocity=new();taker.LastKickAt=Seconds;taker.LastKickFacing=0;taker.LastKickWasShot=true;taker.LastKickKind=StrikeKind.Driven;taker.Action="Пробивает пенальти";
        taker.Stamina=Math.Max(.05f,taker.Stamina-.025f);
        WindupPlayer=-1;WindupRemaining=0;Strike=StrikeKind.Driven;ShotTarget=shootoutAim;ShotSequence++;
        float read=.32f+keeper.Profile.GkReflexes*.15f+keeper.Profile.GkPositioning*.12f-taker.Profile.Composure*.06f;
        float guess=Next()<read?side:Next()<.72f?-side:0;
        keeper.DiveOrigin=keeper.Position;keeper.DiveTarget=new Point(105,34+guess*(1.5f+keeper.Profile.GkDiving*.9f));
        keeper.DiveHeight=.25f+Next()*.85f;keeper.DiveAge=0;keeper.DiveRemaining=0;keeper.DiveConnected=false;
        shootoutKeeperDelay=.04f+(1-keeper.Profile.GkReflexes)*.12f;shootoutKeeperStarted=false;
        shootoutFlightAge=0;shootoutPhaseAge=0;ShootoutPhase=4;
    }
    void CompleteShootoutKick(PenaltyOutcome outcome)
    {
        ShootoutAttempts[ShootoutTeam]++;
        if(outcome==PenaltyOutcome.Goal)ShootoutScores[ShootoutTeam]++;
        ShootoutKicks.Add(new(ShootoutTeam,ShootoutTaker,outcome,shootoutAim.Y,shootoutAimHeight));
        string result=outcome switch{PenaltyOutcome.Goal=>"ГОЛ",PenaltyOutcome.Saved=>"СЕЙВ",PenaltyOutcome.Post=>"ШТАНГА",PenaltyOutcome.High=>"ВЫШЕ",_=>"МИМО"};
        Status=$"Пенальти · {result} · {ShootoutScores[0]} : {ShootoutScores[1]}";AddEvent(Status);
        Players[ShootoutTaker].Action=outcome==PenaltyOutcome.Goal?"Празднует гол":"Опустил голову";
        ShotContactSequence=ShotSequence;ShotContactKind=result;
        shootoutPhaseAge=0;ShootoutPhase=5;
    }
    bool ShootoutDecided()
    {
        if(ShootoutAttempts.Min()<5)
        {
            if(ShootoutScores[0]>ShootoutScores[1]+Math.Max(0,5-ShootoutAttempts[1]))ShootoutWinner=0;
            else if(ShootoutScores[1]>ShootoutScores[0]+Math.Max(0,5-ShootoutAttempts[0]))ShootoutWinner=1;
        }
        else if(ShootoutAttempts[0]==ShootoutAttempts[1] && ShootoutScores[0]!=ShootoutScores[1])ShootoutWinner=ShootoutScores[0]>ShootoutScores[1]?0:1;
        return ShootoutWinner>=0;
    }
    void AdvanceShootout(float dt)
    {
        Seconds+=dt;shootoutPhaseAge+=dt;ArrangeShootout(dt);
        foreach(var effect in Effects)effect.Age+=dt;
        Effects.RemoveAll(e=>e.Age>e.Duration);
        var keeper=ShootoutKeeper>=0?Players[ShootoutKeeper]:null;
        if(keeper!=null && keeper.DiveRemaining>0)
        {
            keeper.DiveAge+=dt;keeper.DiveRemaining=Math.Max(0,keeper.DiveRemaining-dt);
            float t=Math.Clamp(keeper.DiveAge/.38f,0,1);t=t*t*(3-2*t);
            keeper.Position=keeper.DiveOrigin+(keeper.DiveTarget-keeper.DiveOrigin)*t;
            if(keeper.DiveRemaining==0)keeper.DiveEndedAt=Seconds;
        }
        if(ShootoutPhase==1)
        {
            bool centered=ActivePlayers.Where(p=>p.Id!=shootoutKeepers[p.Team]).All(p=>(p.Position-new Point(52+(p.Number%4)*1.4f,29+p.Team*6+(p.Number/4)*1.2f)).Length<1.2f);
            if(shootoutPhaseAge>=3.5f && centered)PrepareShootoutKick();return;
        }
        if(ShootoutPhase==2)
        {
            var taker=Players[ShootoutTaker];var spot=new Point(90.8f,34+.5f*taker.PreferredFoot);
            MoveShootoutPlayer(taker,spot,dt,4.5f);MoveShootoutPlayer(keeper,new Point(105,34),dt,4.2f);
            if(shootoutPhaseAge>=1.4f && (taker.Position-spot).Length<.1f && (keeper.Position-new Point(105,34)).Length<.1f)
            {taker.Facing=0;keeper.Facing=MathF.PI;shootoutRunOrigin=taker.Position;ShootoutPhase=3;shootoutPhaseAge=0;WindupPlayer=-1;WindupRemaining=0;}
            return;
        }
        if(ShootoutPhase==3)
        {
            var p=Players[ShootoutTaker];
            // Ordinary running feet lead into the final shooting plant. Keeping
            // the leg cocked throughout the approach made the run-up hop.
            if(shootoutPhaseAge>=.75f)
            {if(WindupPlayer!=ShootoutTaker)p.WindupStartedAt=Seconds;WindupPlayer=ShootoutTaker;WindupRemaining=Math.Max(0,1.05f-shootoutPhaseAge);}
            float t=Math.Clamp(shootoutPhaseAge/1.05f,0,1);t=t*t*(3-2*t);
            var before=p.Position;p.Position=shootoutRunOrigin+(new Point(93.65f,34)-shootoutRunOrigin)*t;
            p.Velocity=(p.Position-before)*(1/dt);p.Facing=0;p.Action="Разбег перед пенальти";
            if(shootoutPhaseAge>=1.05f)ReleaseShootoutKick();return;
        }
        if(ShootoutPhase==4)
        {
            shootoutFlightAge+=dt;
            if(!shootoutKeeperStarted && shootoutFlightAge>=shootoutKeeperDelay)
            {shootoutKeeperStarted=true;keeper.DiveRemaining=.95f;keeper.DiveAge=0;keeper.Action="Прыжок на пенальти";}
            var previous=Ball;Ball+=BallVelocity*dt;BallHeight=Math.Max(0,BallHeight+VerticalSpeed*dt-.5f*9.81f*dt*dt);VerticalSpeed-=9.81f*dt;
            if(previous.X<104.6f && Ball.X>=104.6f && BallHeight<2.2f && Math.Abs(Ball.Y-keeper.Position.Y)<.38f+keeper.Profile.GkDiving*.25f && Math.Abs(BallHeight-keeper.DiveHeight)<.65f+keeper.Profile.GkReflexes*.55f)
            {BallVelocity=new Point(-5,Next()*7-3.5f);VerticalSpeed=2;keeper.DiveConnected=true;CompleteShootoutKick(PenaltyOutcome.Saved);return;}
            if(Ball.X>=105)
            {
                PenaltyOutcome outcome=BallHeight>2.44f?PenaltyOutcome.High:Math.Abs(Ball.Y-34)>3.66f?PenaltyOutcome.Wide:Math.Abs(Math.Abs(Ball.Y-34)-3.66f)<.07f?PenaltyOutcome.Post:PenaltyOutcome.Goal;
                if(outcome==PenaltyOutcome.Goal)BallVelocity*=.18f;else if(outcome==PenaltyOutcome.Post)BallVelocity=new Point(-4,3*Next()-1.5f);
                CompleteShootoutKick(outcome);return;
            }
            return;
        }
        if(ShootoutPhase==5)
        {
            Ball+=BallVelocity*dt;BallVelocity*=MathF.Exp(-dt*3);BallHeight=Math.Max(0,BallHeight+VerticalSpeed*dt);VerticalSpeed-=9.81f*dt;
            if(shootoutPhaseAge<1.7f)return;
            if(ShootoutDecided())
            {ShootoutActive=false;ShootoutComplete=true;ShootoutPhase=6;finalWhistleComplete=true;BallVelocity=new();Status=$"Серия пенальти · {TeamName(ShootoutWinner)} победили {ShootoutScores[0]} : {ShootoutScores[1]}";AddEvent(Status);return;}
            shootoutTurns++;PrepareShootoutKick();
        }
    }
}