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();
}
}
}