InteractiveSetPieces.cs

Partial of MatchSimulation handling interactive set pieces: penalties, corners, free kicks and other restarts where the coach picks the play. Pauses the match clock while waiting, tracks delay warnings and bookings, and turns the submitted shot/pass/cross target into ball velocity, spin and height.

Rough Code
using System;
using System.Linq;

namespace Socer;
public enum SetPiecePlay { Shot, GroundPass, Cross }

public sealed partial class MatchSimulation
{
    public static MatchSimulation InteractiveRestartPreview(RestartKind kind,int team=0,bool secondHalf=false)
    {
        var m=new MatchSimulation(823){SecondHalf=secondHalf,Seconds=secondHalf?200:40,InteractiveSetPieces=true};
        var spot=m.World(kind==RestartKind.Penalty?new Point(94,34):kind==RestartKind.Corner?new Point(104.3f,.7f):new Point(80,29),team);
        m.Restart(team,spot,"Стандарт",kind:kind);m.ArrangeSetPiece(12);m.RestartRemaining=.2f;
        return m;
    }
    public bool InteractiveSetPieces;
    public bool ManualRestartWaiting {get;private set;}
    public float ManualRestartWait {get;private set;}
    public int RestartSequence {get;private set;}
    public int DelayWarnings {get;private set;}
    public bool ManualRestartWarned=>ManualRestartWait>=10;
    public Point SetPieceSpot=>restartPoint;
    public bool ManualShotAllowed=>SetPiece is RestartKind.Penalty or RestartKind.FreeKick;
    public bool DangerousManualRestart
    {
        get
        {
            if(SetPiece==RestartKind.Penalty)return true;
            if(SetPiece!=RestartKind.FreeKick)return false;
            return DangerousFreeKick;
        }
    }
    bool manualRestartResolved,manualCommand,manualShot;
    SetPiecePlay manualPlay;
    Point manualTarget;
    float manualHeight,manualPower;

    void ResetManualRestart()
    {
        RestartSequence++;ManualRestartWaiting=false;ManualRestartWait=0;
        manualRestartResolved=manualCommand=manualShot=false;
    }
    bool WaitForManualRestart()
    {
        if(!InteractiveSetPieces || manualRestartResolved || restartTeam!=0 || RestartRemaining>.35f ||
            !DangerousManualRestart)return false;
        // The match clock pauses while the coach chooses a kick. Never freeze
        // the referee in the crouched spraying pose during this thinking time.
        sprayAnimationRemaining=0;RefereeSpraying=false;RefereeActor.Action="Следит за игрой";
        ManualRestartWaiting=true;RestartRemaining=.35f;
        var look=World(new Point(105,34),restartTeam)-restartPoint;
        Players[SetPieceTaker].Facing=MathF.Atan2(look.Y,look.X);return true;
    }
    public void AdvanceManualRestartWait(float realDelta)
    {
        if(!ManualRestartWaiting)return;
        float dt=Math.Clamp(realDelta,0,.1f),before=ManualRestartWait;
        ManualRestartWait+=dt;
        // Thinking time is compensated by the referee; it cannot consume the half.
        if((SecondHalf?SecondHalfAddedMinutes:FirstHalfAddedMinutes)<0)HalfStoppageSeconds+=dt*.25f;
        if(before<10 && ManualRestartWait>=10)
        {
            DelayWarnings++;Signal("НЕ ЗАТЯГИВАЙТЕ!",restartPoint);
            AddEvent("Судья: разыгрывайте, не задерживайте матч!");
        }
        if(ManualRestartWait>=20)
        {
            if(DelayWarnings>=2 && SetPieceTaker>=0 && !Players[SetPieceTaker].SentOff)
                ShowCard(Players[SetPieceTaker],false,"повторная затяжка стандарта");
            AddEvent("Судья требует розыгрыш · команда выполняет стандарт");
            AutoReleaseSetPiece();
        }
        // The football clock and roots remain frozen while the coach thinks,
        // but a foul/card reaction must enter, finish and release in real time.
        // Run after discipline so a delay booking is queued on the same frame.
        UpdateFootballEmotions(dt);
    }
    public void AutoReleaseSetPiece()
    {
        if(!ManualRestartWaiting)return;
        manualRestartResolved=true;ManualRestartWaiting=false;RestartRemaining=.35f;
    }
    public bool SubmitSetPiece(SetPiecePlay play,Point target,float height,float power)
    {
        if(!ManualRestartWaiting || restartTeam!=0 || !float.IsFinite(target.X+target.Y+height+power))return false;
        if(SetPiece==RestartKind.Penalty && play!=SetPiecePlay.Shot)return false;
        if(play==SetPiecePlay.Shot && !ManualShotAllowed)return false;
        if(play!=SetPiecePlay.Shot && (Clamp(target)-restartPoint).Length<2)return false;
        manualPlay=play;manualPower=Math.Clamp(power,.15f,1);manualHeight=Math.Clamp(height,0,2.9f);
        manualTarget=play==SetPiecePlay.Shot?new Point(Direction(0)>0?105:0,Math.Clamp(target.Y,29.6f,38.4f)):Clamp(target);
        manualCommand=true;AutoReleaseSetPiece();return true;
    }
    bool ReleaseManualSetPiece(Footballer taker)
    {
        if(!manualCommand)return false;
        manualCommand=false;
        float skill=manualPlay==SetPiecePlay.Shot?taker.Profile.Finishing:taker.Profile.Passing;
        float error=(1-skill)*.7f+Math.Max(0,manualPower-.8f)*2;
        var target=manualTarget+new Point(0,(Next()-.5f)*error*2);
        if(manualPlay==SetPiecePlay.Shot)
        {
            BeginShot(taker,(target-taker.Position).Length);
            ShotTarget=target;Strike=manualPower>.8f?StrikeKind.Power:StrikeKind.Driven;PowerShot=manualPower>.8f;
            manualShot=true;manualHeight=Math.Max(0,manualHeight+(Next()-.5f)*error*.35f);
        }
        else
        {
            Kick(taker,target,12+manualPower*17,false);
            PassReceiver=ActivePlayers.Where(p=>p.Team==taker.Team && p.Id!=taker.Id && !p.Keeper)
                .OrderBy(p=>(p.Position-target).Length).Select(p=>p.Id).FirstOrDefault(-1);
            IntendedPassTarget=target;Passes[taker.Team]++;
            if(manualPlay==SetPiecePlay.Cross)
            {
                ShapeFlight(target,StrikeKind.Cross,1.5f);
                float duration=Math.Clamp((target-Ball).Length/(10+manualPower*12),.75f,3.1f);
                BallVelocity=(target-Ball)*(.08f/(1-MathF.Exp(-.08f*duration)));
                VerticalSpeed=(1.5f-BallHeight)/duration+4.905f*duration;
                float angle=-BallSpin*duration*.45f;float c=MathF.Cos(angle),s=MathF.Sin(angle);
                BallVelocity=new Point(BallVelocity.X*c-BallVelocity.Y*s,BallVelocity.X*s+BallVelocity.Y*c);Crosses++;
            }
            if(SetPiece==RestartKind.Corner){CornerDeliveries[taker.Team]++;if(manualPlay==SetPiecePlay.GroundPass)ShortCorners++;}
            AddEvent(manualPlay==SetPiecePlay.Cross?"Тренер · подача в выбранную зону":"Тренер · розыгрыш низом");
        }
        return true;
    }
    void ShapeManualShot()
    {
        manualShot=false;BallSpin=0;
        BallVelocity=(ShotTarget-Ball).Unit*(16+manualPower*20);
        float distance=(ShotTarget-Ball).Length;
        float duration=-MathF.Log(Math.Max(.05f,1-.08f*distance/BallSpeed))/.08f;
        BallHeight=.12f;VerticalSpeed=(manualHeight-BallHeight)/Math.Max(.05f,duration)+4.905f*duration;
    }
}