SetPieces.cs

Partial MatchSimulation covering set-piece restarts in the football sim: picks the taker, decides corner and free-kick plans (short, near/far cross, shot), positions the wall and run-up, and releases the ball for corners, free kicks, penalties, throw-ins and goal kicks. Also holds the per-team counters for corners, free-kick outcomes and wall/duel events.

Rough Code
using System;
using System.Linq;

namespace Socer;
public enum RestartKind { KickOff, GoalKick, ThrowIn, Corner, FreeKick, Indirect, Penalty, DropBall }
public enum PreparedRestart { Short, Shot, NearCross, FarCross }

public sealed partial class MatchSimulation
{
    public RestartKind SetPiece;
    public int SetPieceTaker=-1;
    public int[] Corners=new int[2], CornerDeliveries=new int[2];
    public int ShortCorners;
    public int[] FreeKickShots=new int[2],FreeKickCrosses=new int[2],FreeKickShort=new int[2];
    public int[] CornerNear=new int[2],CornerFar=new int[2],CornerShort=new int[2];
    public PreparedRestart SetPiecePlan {get;private set;}
    Point setPieceZone;
    bool preparedFreeKickShot;
    public int WallPreparations, CornerBoxDuels;
    bool keeperWallCalled;
    bool indirectFlight;
    bool restartKickPending;
    // The spray is a referee's aid for a set wall, not a marker for every restart.
    // Use attacking coordinates so both teams and halves share the same decision.
    public bool DangerousFreeKick
    {
        get
        {
            if(SetPiece is not (RestartKind.FreeKick or RestartKind.Indirect))return false;
            var goal=new Point(105,34)-World(restartPoint,restartTeam);
            return goal.Length<=32 && goal.X>3 && Math.Abs(goal.Y)<=goal.X*1.25f;
        }
    }
    public int SetPieceWallCount
    {
        get
        {
            if(SetPiece is not (RestartKind.FreeKick or RestartKind.Indirect))return 0;
            var goal=new Point(105,34)-World(restartPoint,restartTeam);
            if(goal.X<=3 || goal.Length>=44)return 0;
            if(Math.Abs(goal.Y)>goal.X*1.25f)return goal.Length<37?2:1;
            return goal.Length<27?5:goal.Length<37?4:3;
        }
    }
    public bool NeedsFreeKickSpray
    {
        get
        {
            if(!DangerousFreeKick)return false;
            int count=0,slots=SetPieceWallCount;
            foreach(var p in Players)
                if(p.InPlay && p.Team!=restartTeam && !p.Keeper && p.Number>=2 && p.Number<2+slots && ++count>=3)return true;
            return false;
        }
    }
    public Point SetPieceRunupStart
    {
        get
        {
            if(SetPiece==RestartKind.Corner)
                return Clamp(restartPoint-new Point(Direction(restartTeam)*2.25f,0));
            var goal=World(new Point(105,34),restartTeam);
            var forward=(goal-restartPoint).Unit;
            var side=new Point(-forward.Y,forward.X);
            float length=SetPiece==RestartKind.Penalty?3.1f:2.25f;
            return Clamp(restartPoint-forward*length+side*.48f);
        }
    }
    Footballer ChooseTaker()
    {
        var team=ActivePlayers.Where(p=>p.Team==restartTeam);
        if(goalKickRestart) return team.First(p=>p.Keeper);
        if(SetPiece==RestartKind.Corner) return team.Where(p=>!p.Keeper).OrderByDescending(p=>
            p.Profile.Crossing*.65f+p.Profile.Curve*.20f+p.Profile.Passing*.15f-(p.Position-restartPoint).Length*.006f).First();
        if(SetPiece==RestartKind.Penalty) return team.Where(p=>!p.Keeper).OrderByDescending(p=>p.Profile.Finishing).First();
        if(SetPiece is RestartKind.FreeKick or RestartKind.Indirect)
        {
            float distance=(World(new Point(105,34),restartTeam)-restartPoint).Length;
            return team.Where(p=>!p.Keeper).OrderByDescending(p=>distance<34 && SetPiece==RestartKind.FreeKick?
                p.Profile.FreeKickAccuracy*.65f+p.Profile.Curve*.20f+p.Profile.ShotPower*.15f:
                p.Profile.Crossing*.5f+p.Profile.Passing*.35f+p.Profile.Curve*.15f).First();
        }
        return team.Where(p=>!p.Keeper).OrderBy(p=>(p.Position-restartPoint).Length).First();
    }
    void PrepareSetPiece()
    {
        preparedFreeKickShot=false;SetPiecePlan=PreparedRestart.Short;
        if(SetPiece is not (RestartKind.Corner or RestartKind.FreeKick or RestartKind.Indirect))return;
        var taker=Players[SetPieceTaker];var local=World(restartPoint,restartTeam);
        float distance=(new Point(105,34)-local).Length;
        float angle=Math.Abs(local.Y-34)/Math.Max(5,105-local.X);
        if(SetPiece==RestartKind.Corner)
            SetPiecePlan=Next()<.13f?PreparedRestart.Short:Next()<.52f?PreparedRestart.NearCross:PreparedRestart.FarCross;
        else
        {
            float shotChance=Math.Clamp(.36f+taker.Profile.FreeKickAccuracy*.47f-
                Math.Max(0,distance-24)*.025f-angle*.32f,.08f,.83f);
            SetPiecePlan=SetPiece==RestartKind.FreeKick && distance<35 && angle<1.45f && Next()<shotChance
                ?PreparedRestart.Shot:distance<53 && local.X>47
                    ?Next()<.55f?PreparedRestart.NearCross:PreparedRestart.FarCross:PreparedRestart.Short;
        }
        float near=local.Y<34?31.2f:36.8f;
        float far=local.Y<34?37.0f:31.0f;
        setPieceZone=World(new Point(SetPiece==RestartKind.Corner?96:94,
            SetPiecePlan==PreparedRestart.NearCross?near:far),restartTeam);
    }
    void ArrangeSetPiece(float dt)
    {
        if(SetPiece==RestartKind.GoalKick)
        {
            var keeper=Players[SetPieceTaker];
            var target=RestartRemaining>.82f?SetPieceRunupStart:restartPoint;
            var delta=target-keeper.Position;var motion=delta.Unit*Math.Min(delta.Length,dt*(RestartRemaining<=.82f?5.2f:8f));
            keeper.Position=Clamp(keeper.Position+motion);keeper.Velocity=dt>0?motion*(1/dt):new Point();
            keeper.Action=RestartRemaining<=.82f?"Разбегается к мячу":"Готовится к удару от ворот";
            UpdateFacing(keeper,restartPoint,dt);
            return;
        }
        if(SetPiece is not (RestartKind.Corner or RestartKind.FreeKick or RestartKind.Indirect or RestartKind.Penalty)) return;
        if(!Players[SetPieceTaker].InPlay) SetPieceTaker=ChooseTaker().Id;
        int wallCount=SetPieceWallCount;
        var taker=Players[SetPieceTaker];var attack=ActivePlayers.Where(p=>p.Team==restartTeam && !p.Keeper && p.Id!=taker.Id).OrderByDescending(p=>p.Home.X).ToArray();
        foreach(var p in ActivePlayers)
        {
            if(Seconds<p.FallUntil || Seconds<p.ImpactUntil)continue;
            var target=p.Position;
            bool wall=false;
            bool organizing=p.Keeper && p.Team!=restartTeam && wallCount>0;
            bool heldForManual=InteractiveSetPieces && !manualRestartResolved && restartTeam==0 && DangerousManualRestart;
            if(p.Id==taker.Id)
                target=RestartRemaining>.82f || heldForManual?SetPieceRunupStart:restartPoint;
            else if(SetPiece==RestartKind.Corner)
            {
                if(p.Team==restartTeam)
                {
                    int i=Array.IndexOf(attack,p);
                    var zone=World(setPieceZone,restartTeam);
                    if(i==0) target=SetPiecePlan==PreparedRestart.Short?
                        World(new Point(96,World(restartPoint,restartTeam).Y<34?8:60),restartTeam):
                        World(new Point(92,zone.Y),restartTeam);
                    else if(i is >=1 and <=6) target=World(new Point(i%3==0?95:91,
                        Math.Clamp(zone.Y+(i-3)*2.5f,19,49)),restartTeam);
                    else if(!p.Keeper) target=World(new Point(Math.Min(70,p.Home.X+25),p.Home.Y),restartTeam);
                }
                else if(p.Keeper) target=World(new Point(2,34),p.Team);
                else
                {
                    if(attack.Length>0)
                    {
                        int i=(p.Number-2)%Math.Min(attack.Length,6);
                        var mark=attack[i];
                        target=mark.Position+new Point(-Direction(restartTeam)*.45f,(p.Number%2==0?.34f:-.34f));
                    }
                    else target=World(new Point(9,30+(p.Number%3)*4),p.Team);
                    if(p.Number>8)target=World(new Point(79+(p.Number%3)*3,22+(p.Number-8)*10),restartTeam);
                }
            }
            else if(SetPiece==RestartKind.Penalty)
            {
                target=p.Team!=restartTeam && p.Keeper?World(new Point(.7f,34),p.Team):World(new Point(82-(p.Number%3)*2,8+p.Number*4),restartTeam);
            }
            else if(p.Team==restartTeam && !p.Keeper && p.Id!=taker.Id &&
                (SetPiece is RestartKind.FreeKick or RestartKind.Indirect) &&
                (SetPiecePlan is PreparedRestart.NearCross or PreparedRestart.FarCross))
            {
                int i=Array.IndexOf(attack,p);
                if(i is >=0 and <3)
                {
                    var zone=World(setPieceZone,restartTeam);
                    target=World(new Point(i==0?91:i==1?95:88,
                        Math.Clamp(zone.Y+(i-1)*3,19,49)),restartTeam);
                }
                else if(i==3)target=World(new Point(81,34),restartTeam);
            }
            else if(p.Team!=restartTeam && p.Keeper)
            {
                var local=World(restartPoint,p.Team);
                target=World(new Point(1.1f,Math.Clamp(34+(local.Y-34)*.1f,32,36)),p.Team);
                if(!keeperWallCalled && organizing)
                {keeperWallCalled=true;KeeperWallCalls++;Say(p,"Стенка! Линию держать!",true);}
            }
            else if(p.Team!=restartTeam && !p.Keeper)
            {
                var goal=World(new Point(105,34),restartTeam);var forward=(goal-restartPoint).Unit;
                float distance=(goal-restartPoint).Length;
                int slot=p.Number-2;
                if(slot>=0 && slot<wallCount)
                {
                    target=restartPoint+forward*9.65f+new Point(-forward.Y,forward.X)*((slot-(wallCount-1)*.5f)*.76f);
                    wall=true;WallPreparations++;
                }
                else if(distance<42 && p.Number<=8)
                    target=goal+(restartPoint-goal).Unit*(4+(p.Number%3)*3)+new Point(0,(p.Number%2==0?1:-1)*3);
            }
            if(p.Team!=restartTeam && (target-restartPoint).Length<9.15f && SetPiece!=RestartKind.Penalty)
            {
                var away=(target-restartPoint).Unit;if(away.Length<.01f) away=new Point(-Direction(restartTeam),0);
                target=restartPoint+away*9.2f;
            }
            target=Clamp(target);var d=target-p.Position;
            float pace=p.Id==taker.Id && RestartRemaining<=.82f && !heldForManual
                ?manualRestartResolved?8f:5.2f:wall?12.5f:8f;
            var motion=d.Unit*Math.Min(d.Length,dt*pace);
            p.Position=Clamp(p.Position+motion);p.Velocity=dt>0?motion*(1/dt):new Point();
            bool jostling=SetPiece==RestartKind.Corner && !p.Keeper && p.Id!=taker.Id &&
                World(p.Position,restartTeam).X>84 &&
                ActivePlayers.Any(q=>q.Team!=p.Team && !q.Keeper && (q.Position-p.Position).Length<1.55f);
            if(jostling && p.Team!=restartTeam)CornerBoxDuels++;
            p.Action=wall?"Стенка":organizing?"Руководит стенкой":p.Id==taker.Id && RestartRemaining<=.82f?"Разбегается к мячу":jostling?"Борется за позицию":"Готовится к стандарту";
            UpdateFacing(p,restartPoint,dt);
        }
    }
    void ReleaseSetPiece(Footballer taker)
    {
        nextKickExempt=SetPiece is RestartKind.Corner or RestartKind.ThrowIn or RestartKind.GoalKick or RestartKind.DropBall;
        restartKickPending=SetPiece is RestartKind.ThrowIn or RestartKind.Indirect;
        if(ReleaseManualSetPiece(taker))return;
        if(SetPiece==RestartKind.Corner)
        {
            var receivers=ActivePlayers.Where(p=>p.Team==taker.Team && !p.Keeper && p.Id!=taker.Id).OrderBy(p=>(p.Position-restartPoint).Length).ToArray();
            if(receivers.Length==0)
            {
                // An isolated training duel can finish with only the corner taker
                // and goalkeeper on this side. Keep the restart valid without a
                // phantom teammate or a pass to an inactive player.
                Kick(taker,setPieceZone,22,false);
                ShapeFlight(setPieceZone,StrikeKind.Cross,1.8f);
                PassReceiver=-1;IntendedPassTarget=setPieceZone;
                CornerDeliveries[taker.Team]++;
                if(SetPiecePlan==PreparedRestart.NearCross)CornerNear[taker.Team]++;else CornerFar[taker.Team]++;
                Crosses++;
                Emit("corner",taker,"ПОДАЧА УГЛОВОГО",2);
                return;
            }
            bool shortPass=SetPiecePlan==PreparedRestart.Short;
            var box=receivers.Where(p=>World(p.Position,taker.Team).X>84 && Math.Abs(World(p.Position,taker.Team).Y-34)<18).ToArray();
            Footballer receiver=shortPass?receivers[0]:box.Length>0?
                box.OrderBy(p=>(p.Position-setPieceZone).Length).First():
                receivers.OrderBy(p=>(p.Position-setPieceZone).Length).First();
            var target=shortPass?receiver.Position+receiver.Velocity*.2f:setPieceZone;
            Kick(taker,target,shortPass?15:22,false);
            PassReceiver=receiver.Id;IntendedPassTarget=target;
            if(!shortPass) {ShapeFlight(target,StrikeKind.Cross,1.8f);Crosses++;} else ShortCorners++;
            CornerDeliveries[taker.Team]++;Passes[taker.Team]++;
            if(shortPass)CornerShort[taker.Team]++;
            else if(SetPiecePlan==PreparedRestart.NearCross)CornerNear[taker.Team]++;
            else CornerFar[taker.Team]++;
            Say(taker,shortPass?"Коротко!":SetPiecePlan==PreparedRestart.NearCross?"На ближнюю!":"На дальнюю!",true);
            Emit("corner",taker,shortPass?"РОЗЫГРЫШ УГЛОВОГО":"ПОДАЧА УГЛОВОГО",2);
            AddEvent($"{TeamName(taker.Team)} · угловой: {(shortPass?"короткий пас":SetPiecePlan==PreparedRestart.NearCross?"навес на ближнюю":"навес на дальнюю")}");return;
        }
        if(SetPiece==RestartKind.Penalty || SetPiece==RestartKind.FreeKick && SetPiecePlan==PreparedRestart.Shot)
        {
            BeginShot(taker,(World(new Point(105,34),taker.Team)-taker.Position).Length);
            if(SetPiece==RestartKind.Penalty) {penaltyShot=true;Strike=StrikeKind.Driven;PowerShot=false;ShotTarget=World(new Point(106,34+(Next()-.5f)*6),taker.Team);}
            else
            {
                preparedFreeKickShot=true;FreeKickShots[taker.Team]++;
                var keeper=ActivePlayers.First(p=>p.Team!=taker.Team && p.Keeper);
                float keeperY=World(keeper.Position,taker.Team).Y;
                ShotTarget=World(new Point(106,keeperY<34?36.0f:32.0f),taker.Team);
                Strike=taker.Profile.Curve>.58f?StrikeKind.Curl:StrikeKind.Driven;PowerShot=false;
                taker.Action="Готовит удар со штрафного";
                AddEvent($"{TeamName(taker.Team)} · подготовленный удар со штрафного #{taker.Number}");
            }
            return;
        }
        if(SetPiece is RestartKind.FreeKick or RestartKind.Indirect &&
            SetPiecePlan is PreparedRestart.NearCross or PreparedRestart.FarCross)
        {
            var receiver=ActivePlayers.Where(p=>p.Team==taker.Team && p.Id!=taker.Id && !p.Keeper &&
                World(p.Position,taker.Team).X>80 && Math.Abs(World(p.Position,taker.Team).Y-34)<19)
                .OrderBy(p=>(p.Position-setPieceZone).Length-
                    p.Profile.HeadingAccuracy*3-p.Profile.Jumping*2-p.Profile.OffBall).FirstOrDefault();
            if(receiver!=null)
            {
                var target=setPieceZone+new Point(0,(Next()-.5f)*(1-taker.Profile.Crossing)*3);
                Kick(taker,target,20,false);ShapeFlight(target,StrikeKind.Cross,1.85f);
                PassReceiver=receiver.Id;IntendedPassTarget=target;
                Passes[taker.Team]++;Crosses++;LoftedPasses++;
                FreeKickCrosses[taker.Team]++;
                Say(taker,SetPiecePlan==PreparedRestart.NearCross?"На ближнюю!":"На дальнюю!",true);
                Emit("cross",taker,"НАВЕС СО СТАНДАРТА",1.8f);
                AddEvent($"{TeamName(taker.Team)} · навес со штрафного на {(SetPiecePlan==PreparedRestart.NearCross?"ближнюю":"дальнюю")} зону #{taker.Number}");
                return;
            }
        }
        if(SetPiece is RestartKind.ThrowIn or RestartKind.FreeKick or RestartKind.Indirect)
        {
            var receiver=ActivePlayers.Where(p=>p.Team==taker.Team && p.Id!=taker.Id && !p.Keeper)
                .OrderBy(p=>(p.Position-taker.Position).Length).FirstOrDefault() ??
                ActivePlayers.Where(p=>p.Team==taker.Team && p.Id!=taker.Id)
                .OrderBy(p=>(p.Position-taker.Position).Length).FirstOrDefault();
            if(receiver==null)return;
            Kick(taker,receiver.Position,16,false);Passes[taker.Team]++;
            if(SetPiece is RestartKind.FreeKick or RestartKind.Indirect)FreeKickShort[taker.Team]++;
            PassReceiver=receiver.Id;IntendedPassTarget=receiver.Position;
            if(SetPiece==RestartKind.ThrowIn) {BallHeight=1.6f;VerticalSpeed=1;}
        }
    }
}