Part of the MatchSimulation partial class, it handles refereeing: offside detection and interference checks, fouls and cards, scuffles, keeper parry and block deflection, goal frame collisions, and referee movement and free kick spray marks. Also contains static preview builders used by the editor QA to set up isolated rule scenarios.
using System;
using System.Collections.Generic;
using System.Linq;
namespace Socer;
public sealed partial class MatchSimulation
{
public IEnumerable<Footballer> ActivePlayers => Players.Where(p=>p.InPlay);
public Point RefereePosition=new(48,28);
public Footballer RefereeActor {get;}=new(){Id=22,Number=0,Team=2};
public string RefereeSignal="";
public float RefereeSignalUntil, IncidentRemaining, OffsideLineUntil;
public Point SprayMarkPoint {get;private set;}
public Point SprayMarkForward {get;private set;}
public bool SprayMarked {get;private set;}
public bool RefereeSpraying {get;private set;}
float sprayMarkUntil;
float sprayAnimationRemaining;
public bool SprayMarkVisible=>SprayMarked && (RestartRemaining>0 || Seconds<sprayMarkUntil);
public float SprayOpacity=>!SprayMarkVisible?0:RestartRemaining>0?1:Math.Clamp((sprayMarkUntil-Seconds)/3f,0,1);
public Point SprayArcPoint(float progress)
{
float angle=(Math.Clamp(progress,0,1)-.5f)*MathF.PI;
var side=new Point(-SprayMarkForward.Y,SprayMarkForward.X);
return SprayMarkPoint+(SprayMarkForward*MathF.Cos(angle)+side*MathF.Sin(angle))*.42f;
}
public int ShownCard, ShownPlayer=-1, Scuffles;
public int[] Fouls=new int[2], YellowCards=new int[2], RedCards=new int[2], Offsides=new int[2];
public int[] PenaltiesAwarded=new int[2];
public int FoulEligibleChallenges;
// The displayed decision is frozen at the offending touch, independently
// of subsequent passes/restarts. Coordinates are metres in pitch space.
public float OffsideLineX,OffsideAttackerX;
public Point OffsidePassBall;
public int OffsideDecisionTeam=-1;
public const float OffsideTolerance=.05f;
public bool Abandoned;
readonly bool[] offsideCandidates=new bool[22];
readonly bool[] offsidePhase=new bool[2];
readonly float[] offsideLimits=new float[2];
readonly Point[] offsideBalls=new Point[2];
readonly Point[] offsidePositions=new Point[22];
int scuffleA=-1, scuffleB=-1;
Point incidentPoint;
public Point IncidentFocus => incidentPoint;
bool nextKickExempt;
public int ShotSequence, ShotContactSequence;
public string ShotContactKind="";
public float ShotFlightAge => flightAge;
void RegisterShotContact(string kind)
{
if(!shot || ShotSequence<=0 || ShotContactSequence==ShotSequence) return;
ShotContactSequence=ShotSequence; ShotContactKind=kind; ShotCueRemaining=0;
}
float OffsideLimit(int team,Point ball)
{
int dir=Direction(team);float last=float.NegativeInfinity,second=last;
foreach(var p in Players)
{
if(!p.InPlay || p.Team==team)continue;
float x=p.Position.X*dir;
if(x>=last){second=last;last=x;}else if(x>second)second=x;
}
return Math.Max(52.5f*dir,Math.Max(ball.X*dir,second));
}
bool OffsidePosition(Footballer p,float limit)=>p.InPlay && p.Position.X*Direction(p.Team)>limit+OffsideTolerance;
void ClearOffside()
{Array.Clear(offsideCandidates,0,offsideCandidates.Length);Array.Clear(offsidePhase,0,offsidePhase.Length);}
void SnapshotOffside(Footballer passer,bool exempt,bool preserveOpposition=false)
{
if(!preserveOpposition)ClearOffside();
int team=passer.Team;
offsidePhase[team]=!exempt;
offsideLimits[team]=OffsideLimit(team,Ball);offsideBalls[team]=Ball;
foreach(var p in Players)
{
if(p.Team!=team)continue;
offsidePositions[p.Id]=p.Position;
offsideCandidates[p.Id]=!exempt && p.Id!=passer.Id && OffsidePosition(p,offsideLimits[team]);
}
}
bool CheckOffside(Footballer p)
{
if(!p.InPlay || !offsidePhase[p.Team] || !offsideCandidates[p.Id]) return false;
var at=p.Position;Offsides[p.Team]++;
float line=offsideLimits[p.Team]*Direction(p.Team),attacker=offsidePositions[p.Id].X;
var passBall=offsideBalls[p.Team];
Restart(1-p.Team,at,$"Офсайд · {TeamName(p.Team)} #{p.Number}",kind:RestartKind.Indirect);
OffsideLineX=line;OffsideAttackerX=attacker;OffsidePassBall=passBall;OffsideDecisionTeam=p.Team;OffsideLineUntil=Seconds+3;
Signal("ОФСАЙД",at);Emit("offside",p,"ОФСАЙД",2);return true;
}
// Only clear interference: a close challenge for a playable ball, or a
// player screening the keeper at short range during a goal-bound shot.
// Standing offside or near a pass trajectory is not itself an offence.
bool CheckOffsideInterference(Footballer opponent,Point before,float beforeHeight)
{
if(RehearsingStrategy || StrategyGuaranteedPasses)return false;
foreach(var p in Players)
{
if(!p.InPlay || p.Team==opponent.Team || !offsidePhase[p.Team] || !offsideCandidates[p.Id] || Seconds<p.FallUntil)continue;
bool playable=Math.Min(beforeHeight,BallHeight)<2.2f;
bool attempting=p.Id==PassReceiver || p.AerialContestAt>Seconds-.35f ||
(p.Velocity.Length>.5f && Point.Dot(p.Velocity,(Ball-p.Position).Unit)>.35f);
bool challenge=playable && attempting && (p.Position-opponent.Position).Length<1.15f &&
DistanceToSegment(p.Position,before,Ball)<.65f;
bool screen=false;
if(opponent.Keeper && shot && ShotTowardsGoal(opponent.Team) && beforeHeight<1.9f &&
(p.Position-opponent.Position).Length<2.5f)
{
var sight=before-opponent.Position;
float t=Point.Dot(p.Position-opponent.Position,sight)/Math.Max(.001f,Point.Dot(sight,sight));
screen=t>.08f && t<.95f && DistanceToSegment(p.Position,opponent.Position,before)<.3f;
}
if((challenge || screen) && CheckOffside(p))return true;
}
return false;
}
bool ShotTowardsGoal(int defendingTeam)
{
if(!shot || BallSpeed<.1f)return false;
float x=Direction(defendingTeam)>0?0:105;
float t=(x-Ball.X)/(Math.Abs(BallVelocity.X)>.001f?BallVelocity.X:.001f);
if(t<0 || t>3)return false;
float y=Ball.Y+BallVelocity.Y*t,z=BallHeight+VerticalSpeed*t-4.905f*t*t;
return Math.Abs(y-34)<4.5f && z<2.8f;
}
void Signal(string text,Point where)
{
RefereeSignal=text;RefereeSignalUntil=Seconds+3;incidentPoint=where;
if(text!="КРАСНАЯ КАРТОЧКА" && text!="ЖЁЛТАЯ КАРТОЧКА") ShownCard=0;
}
public bool CalmPlayer(int id)
{
if(id<0 || id>=Players.Count || Finished) return false;
var p=Players[id];if(p.Team!=0 || p.SentOff || p.CalmUntil>Seconds) return false;
p.Frustration=Math.Max(0,p.Frustration-.45f);p.CalmUntil=Seconds+20;
SpeakEmotion(p,"Понял, тренер.");return true;
}
float AggressionOf(Footballer p)=>Math.Clamp(p.Profile.Aggression+p.Frustration*.25f-(p.CalmUntil>Seconds?.2f:0),0,1);
void UpdateOfficials(float dt)
{
bool approachingSpray=RestartRemaining>0 && NeedsFreeKickSpray && IncidentRemaining<=0 && !SprayMarked;
sprayAnimationRemaining=Math.Max(0,sprayAnimationRemaining-dt);
var forward=(World(new Point(105,34),restartTeam)-restartPoint).Unit;
var side=new Point(-forward.Y,forward.X);
Point aim=approachingSpray || sprayAnimationRemaining>0?Clamp(restartPoint-side*1.1f-forward*.12f):
RefereeSignalUntil>Seconds?incidentPoint+new Point(-3,-3):Ball+new Point(Ball.X>52.5f?-9:9,Ball.Y>34?-7:7);
aim=Clamp(aim);var delta=aim-RefereePosition;var before=RefereePosition;
float pace=approachingSpray?Math.Min(13f,delta.Length*4):delta.Length>6?6.8f:delta.Length>.3f?Math.Min(2.4f,delta.Length*3):0;
RefereePosition+=delta.Unit*Math.Min(delta.Length,pace*dt);
if(approachingSpray && (aim-RefereePosition).Length<1.5f)
{SprayMarked=true;SprayMarkPoint=restartPoint;SprayMarkForward=forward;sprayMarkUntil=Seconds+3;sprayAnimationRemaining=.85f;}
if(SprayMarked && RestartRemaining>0)sprayMarkUntil=Seconds+3;
RefereeSpraying=sprayAnimationRemaining>0;
RefereeActor.VisualBefore=before;RefereeActor.Position=RefereePosition;AdvanceGait(RefereeActor,dt);
RefereeActor.Velocity=RefereeActor.VisualVelocity;
RefereeActor.Action=RefereeSpraying?"Отмечает место удара":"Следит за игрой";
UpdateFacing(RefereeActor,RefereeSpraying?SprayMarkPoint:pace>0?aim:Ball,dt);
foreach(var p in ActivePlayers)
{
p.Frustration=Math.Max(0,p.Frustration-dt*(.001f+p.Profile.Composure*.002f));
if(p.Frustration>.62f && Seconds>=p.NextOutburstAt)
{
SpeakEmotion(p,p.Frustration>.85f?"Да какого чёрта?!":"Соберитесь, блин!");
p.NextOutburstAt=Seconds+14;
}
}
}
void UpdateIncident(float dt)
{
IncidentRemaining=Math.Max(0,IncidentRemaining-dt);
foreach(var p in ActivePlayers)
{
if(Seconds<p.FallUntil)continue;
if(p.Id!=scuffleA && p.Id!=scuffleB && (p.Position-incidentPoint).Length>7) continue;
var offset=p.Position-incidentPoint;
if(offset.Length<.01f) offset=new Point(p.Team==0?-1:1,0);
float radius=p.Id==scuffleA || p.Id==scuffleB?.7f:2.3f;
var target=incidentPoint+offset.Unit*(radius+MathF.Sin(Seconds*9+p.Id)*.12f);
p.Position=Clamp(p.Position+(target-p.Position).Unit*Math.Min((target-p.Position).Length,dt*2));
p.Velocity=new();p.Action="Потасовка";
}
}
void ShowCard(Footballer p,bool straightRed,string reason)
{
if(p.SentOff) return;
if(!straightRed) {p.Yellows++;YellowCards[p.Team]++;}
bool red=straightRed || p.Yellows>=2;
ShownCard=red?2:1;ShownPlayer=p.Id;
Signal(red?"КРАСНАЯ КАРТОЧКА":"ЖЁЛТАЯ КАРТОЧКА",p.Position);
AddEvent($"{TeamName(p.Team)} #{p.Number} · {(red?(straightRed?"прямая красная":"вторая жёлтая — удаление"):"жёлтая")} · {reason}");
Emit(red?"red-card":"yellow-card",p,red?"УДАЛЁН":"ЖЁЛТАЯ",3);
if(!red) return;
p.SentOff=true;p.Velocity=new();p.BurstRemaining=0;p.RunUntil=0;RedCards[p.Team]++;
if(Owner==p.Id) {Owner=-1;BallVelocity=new();}
if(WindupPlayer==p.Id) CancelWindup();
if(p.Keeper)
{
var replacement=ActivePlayers.Where(q=>q.Team==p.Team).OrderByDescending(q=>q.Profile.Goalkeeping).First();
replacement.EmergencyKeeper=true;replacement.Home=new Point(5,34);
AddEvent($"{TeamName(p.Team)} #{replacement.Number} встаёт в ворота");
}
if(ActivePlayers.Count(q=>q.Team==p.Team)<7)
{
Abandoned=true;Goals[p.Team]=0;Goals[1-p.Team]=Math.Max(3,Goals[1-p.Team]);
AddEvent("Матч прекращён: менее семи игроков · техническое поражение");
}
}
void StartScuffle(Footballer a,Footballer b,bool violent)
{
Scuffles++;IncidentRemaining=2.8f;scuffleA=a.Id;scuffleB=b.Id;incidentPoint=(a.Position+b.Position)*.5f;
SpeakEmotion(a,"Ты охренел?!",b.Id);SpeakEmotion(b,"Отойди от меня!",a.Id);
a.Frustration=Math.Min(1,a.Frustration+.2f);b.Frustration=Math.Min(1,b.Frustration+.2f);
ShowCard(a,violent,violent?"агрессивное поведение":"толчок после свистка");
if(!b.SentOff && AggressionOf(b)>.8f && b.Profile.Composure<.45f) ShowCard(b,false,"ответный толчок");
RefereeSignal="РАЗОШЛИСЬ!";RefereeSignalUntil=Seconds+3;
}
void CommitFoul(Footballer offender,Footballer victim,float severity)
{
RegisterChallengeImpact(offender,victim,Sliding);
var at=victim.Position;Fouls[offender.Team]++;offender.FoulsCommitted++;
offender.Frustration=Math.Min(1,offender.Frustration+.18f);victim.Frustration=Math.Min(1,victim.Frustration+.28f);
bool penalty=World(at,offender.Team).X<16.5f && Math.Abs(at.Y-34)<20.16f;
var attack=World(at,victim.Team);
var goal=World(new Point(105,34),victim.Team);
bool goalBound=victim.Velocity.Length<1 || Point.Dot(victim.Velocity.Unit,(goal-at).Unit)>.35f;
bool openGoal=ActivePlayers.Where(p=>p.Team==offender.Team && !p.Keeper && p.Id!=offender.Id)
.All(p=>World(p.Position,victim.Team).X<attack.X-2 ||
(p.Position-at).Length>20 || Math.Abs(World(p.Position,victim.Team).Y-attack.Y)>10);
bool obviousChance=attack.X>86 && Math.Abs(attack.Y-34)<13 && goalBound && openGoal;
if(penalty)PenaltiesAwarded[victim.Team]++;
var spot=penalty?World(new Point(94,34),victim.Team):at;
Restart(victim.Team,spot,penalty?"ПЕНАЛЬТИ!":"Свисток · нарушение",kind:penalty?RestartKind.Penalty:RestartKind.FreeKick);
FallDown(victim,victim.Position-offender.Position,severity);
Signal(penalty?"ПЕНАЛЬТИ":"ФОЛ",at);if(!victim.InjuryCannotContinue)SpeakEmotion(victim,"Эй! По ногам же!",offender.Id);
if(severity>.9f || obviousChance && !penalty) ShowCard(offender,true,obviousChance?"срыв явного голевого момента":"грубая игра");
else if(severity>.68f || obviousChance || offender.FoulsCommitted%3==0) ShowCard(offender,false,obviousChance?"срыв явного голевого момента":"опасный отбор");
else AddEvent($"{TeamName(offender.Team)} #{offender.Number} · фол");
if(!offender.SentOff && offender.Frustration>.65f && offender.Profile.Composure<.4f && Next()<.2f)
{SpeakEmotion(offender,"Судья, ты издеваешься?!");ShowCard(offender,false,"споры с судьёй");}
if(!victim.SentOff && !victim.InjuryCannotContinue && victim.Frustration>.35f && AggressionOf(victim)>.7f && victim.Profile.Composure<.55f && Next()<.35f)
StartScuffle(victim,offender,victim.Frustration>.85f && victim.Profile.Composure<.3f);
}
bool TryFoul(Footballer defender,Footballer carrier)
{
// A contact made during the duel still counts if the runner subsequently
// escapes. Never whistle a challenge that did not enter leg/body reach.
if(DuelClosestContact>(Sliding?1.65f:BodyChallenge?1.1f:1.16f))return false;
FoulEligibleChallenges++;
float aggression=AggressionOf(defender);
float tackleSkill=BodyChallenge?defender.Profile.Physical:Sliding?defender.Profile.SlidingTackle:defender.Profile.StandingTackle;
var forward=carrier.Velocity.Length>1?carrier.Velocity.Unit:new Point(MathF.Cos(carrier.Facing),MathF.Sin(carrier.Facing));
bool fromBehind=Point.Dot((defender.Position-carrier.Position).Unit,forward)<-.3f;
bool emergency=(World(new Point(0,34),defender.Team)-carrier.Position).Length<30;
float chance=.075f+aggression*.09f+(1-tackleSkill)*.10f+
(1-defender.Profile.Composure)*.06f+defender.Frustration*.07f+
(Sliding?.1f:BodyChallenge?.025f:0)+(fromBehind?.08f:0)+(emergency?.045f:0);
chance=Math.Clamp(chance,.07f,.48f);
if(!Sliding && !BodyChallenge && SkillActive(defender,UniqueSkill.TimingTackle))
chance*=1-.22f*defender.ActiveSkillPower;
if(Next()>=chance) return false;
float severity=.22f+aggression*.22f+defender.Frustration*.2f+(Sliding?.2f:0)+
(fromBehind?.12f:0)+(emergency?.06f:0)+Next()*.3f-defender.Profile.Composure*.12f;
CommitFoul(defender,carrier,severity);return true;
}
bool HitGoalFrame(Point before,float beforeHeight)
{
// Swept sphere against two vertical posts and the horizontal crossbar at each goal.
foreach(float x in new[]{0f,105f})
{
if((Ball.X-x)*(before.X-x)>0 && Math.Abs(Ball.X-x)>.7f && Math.Abs(before.X-x)>.7f) continue;
foreach(float y in new[]{PitchPerspective.PostLeft,PitchPerspective.PostRight})
{
var delta=Ball-before;var offset=before-new Point(x,y);
float aa=Point.Dot(delta,delta),bb=2*Point.Dot(offset,delta),cc=Point.Dot(offset,offset)-.17f*.17f;
float discriminant=bb*bb-4*aa*cc;
if(aa<.000001f || discriminant<0)continue;
float f=cc<=0?0:(-bb-MathF.Sqrt(discriminant))/(2*aa);
if(f<0 || f>1)continue;
var closest=before+delta*f;float height=beforeHeight+(BallHeight-beforeHeight)*f;
if(height>PitchPerspective.CrossbarCentre+PitchPerspective.PostWidth/2) continue;
var normal=(closest-new Point(x,y)).Unit;
if(normal.Length<.01f) normal=new Point(x==0?1:-1,0);
if(Point.Dot(BallVelocity,normal)>=0) continue;
Ball=new Point(x,y)+normal*.2f;BallVelocity=(BallVelocity-normal*(2*Point.Dot(BallVelocity,normal)))*.72f;
RegisterShotContact("ШТАНГА");shot=false;EmitAt("post",Ball,"ШТАНГА!",2);return true;
}
if((before.X-x)*(Ball.X-x)>0 || Math.Abs(Ball.X-before.X)<.001f) continue;
float fraction=(x-before.X)/(Ball.X-before.X);float yy=before.Y+(Ball.Y-before.Y)*fraction;float hh=beforeHeight+(BallHeight-beforeHeight)*fraction;
if(Math.Abs(yy-34)<3.66f && Math.Abs(hh-PitchPerspective.CrossbarCentre)<.18f)
{
Ball=new Point(x+(x==0?.2f:-.2f),yy);BallHeight=Math.Max(0,hh);BallVelocity.X=-BallVelocity.X*.7f;VerticalSpeed=-VerticalSpeed*.6f;
RegisterShotContact("ПЕРЕКЛАДИНА");shot=false;EmitAt("post",Ball,"ПЕРЕКЛАДИНА!",2);return true;
}
}
return false;
}
void DeflectBall(Footballer p,bool keeper)
{
SnapshotOffside(p,false,true);
if(!keeper){ShotBlocks++;p.BlockUntil=Seconds+.4f;p.Action="Заблокировал удар";AddEvent($"{TeamName(p.Team)} · заблокировал удар #{p.Number}");}
RegisterShotContact(keeper?"ОТБИЛ":"БЛОК");
float speed=BallSpeed;
if(keeper)
{
KeeperParries++;
float DangerAt(float side)=>ActivePlayers.Where(q=>q.Team!=p.Team).Select(q=>Observed(p,q)).Where(q=>q.HasValue)
.Select(q=>(q.Value-(p.Position+new Point(Direction(p.Team)*4,side*9))).Length).DefaultIfEmpty(20).Min();
float side=DangerAt(-1)>DangerAt(1)?-1:1;
float handling=Math.Clamp(p.Profile.GkHandling,0,1);
float stretch=Math.Abs(Ball.Y-p.Position.Y);
// A hard shot reaching the end of a dive is pushed around the post;
// a square two-hand contact can be directed farther from attackers.
float tipChance=Math.Clamp(.13f+stretch*.2f+Math.Max(0,speed-20)*.012f+
(BallHeight>1.6f?.1f:0),.14f,.58f);
float throwChance=.09f+(1-handling)*.12f;
float spillChance=Math.Clamp((1-handling)*.42f+Math.Max(0,speed-24)*.012f,0,.28f);
float ownGoalX=Direction(p.Team)>0?0:105;
bool nearGoal=Math.Abs(Ball.X-ownGoalX)<9;
float outcome=Next();
if(nearGoal && outcome<tipChance)
{
KeeperCornerTips++;
// A glancing glove contact can send the shot behind the keeper.
// Aim outside the posts, so the actual goal-line crossing awards a corner.
float outwardSide=Math.Abs(Ball.Y-34)>2?Math.Sign(Ball.Y-34):side;
float targetY=34+outwardSide*(7+Next()*6);
float flightTime=Math.Clamp(Math.Abs(Ball.X-ownGoalX)/6,.28f,.8f);
BallVelocity=new Point((ownGoalX-Ball.X)/flightTime,(targetY-Ball.Y)/flightTime);
}
else if(outcome<tipChance+throwChance)
{
KeeperTouchlineTips++;
// A different contact runs across the touchline for a throw-in.
float outwardSide=Ball.Y<34?-1:1;
float touchlineY=outwardSide<0?0:68;
float flightTime=Math.Clamp(Math.Abs(touchlineY-Ball.Y)/20,.55f,1.6f);
BallVelocity=new Point(Direction(p.Team)*(2+Next()*3),(touchlineY-Ball.Y)/flightTime);
}
else
{
float reboundSpeed=Math.Clamp(speed*.5f,7,16);
if(outcome<tipChance+throwChance+spillChance && Math.Abs(Ball.Y-34)<5 && BallHeight<1.6f)
{
// Imperfect handling leaves a short ball in front of goal.
// A strong keeper more often directs the same contact wide.
float spillSpeed=Math.Clamp(speed*.25f,4,9);
BallVelocity=new Point(Direction(p.Team)*spillSpeed*.8f,side*spillSpeed*.18f);
KeeperSpills++;
}
else
{
BallVelocity=new Point(Direction(p.Team)*reboundSpeed*.42f,side*reboundSpeed*.91f);
SafeParries++;
}
}
BallSpin=0;
p.Confidence=Math.Min(.95f,p.Confidence+.03f);
}
else {var normal=(Ball-p.Position).Unit;BallVelocity=BallVelocity*.3f+normal*(speed*.5f);}
VerticalSpeed=keeper?1.5f:.8f;BallHeight=Math.Max(.12f,BallHeight);BallSpin*=.3f;
LastTouch=p.Team;Passer=-1;Owner=-1;shot=false;PickupDelay=.18f;
// Both sides react to the real rebound. The midfielder waiting outside
// the first finishing line now has a reason to attack a second chance.
if(keeper && BallVelocity.X*Direction(p.Team)>0)
{
PrepareSecondBall(p,Clamp(Ball+BallVelocity*.55f),2.2f);
secondChanceTeam=1-p.Team;secondChanceUntil=Seconds+5;
}
Emit(keeper?"save":"block",p,keeper?"ОТБИЛ!":"БЛОК!",1.8f);
}
// Isolated arrangements for editor QA; never used by normal match AI.
public static MatchSimulation RulesPreview(string kind)
{
if(kind is "keeper-parry-corner" or "keeper-parry-throw")
{
for(int seed=1;seed<=80;seed++)
{
var preview=new MatchSimulation(seed){Seconds=30,RestartRemaining=0,Owner=-1,LastTouch=0};
foreach(var player in preview.Players){player.Position=new Point(45,6+player.Number*4);player.Decision=100;}
var keeper=preview.Players[11];keeper.Position=new Point(102,34);
preview.Ball=new Point(101.6f,34);preview.BallVelocity=new Point(30,0);preview.BallHeight=.7f;
preview.DeflectBall(keeper,true);
if(kind=="keeper-parry-corner" && preview.BallVelocity.X>0 ||
kind=="keeper-parry-throw" && preview.BallVelocity.X<0 && preview.SafeParries==0)
return preview;
}
throw new InvalidOperationException("No goalkeeper rebound preview found");
}
if(kind is "shot-power-high" or "shot-power-wide" or "shot-curl-high" or "shot-curl-wide") return ShotAccuracyPreview(kind);
if(kind.StartsWith("manual-"))return InteractiveRestartPreview(kind switch {"manual-penalty"=>RestartKind.Penalty,"manual-corner"=>RestartKind.Corner,"manual-indirect"=>RestartKind.Indirect,_=>RestartKind.FreeKick});
if(kind=="football-ai")return new MatchSimulation(2028);
if(kind.StartsWith("offside"))return OffsidePreview(kind.Contains("away")?1:0,kind.Contains("half"),kind.Contains("ball"));
var m=new MatchSimulation(2026) {RestartRemaining=0,Seconds=80,Owner=9};
var victim=m.Players[9];var offender=m.Players[13];
victim.Position=new Point(70,34);offender.Position=new Point(70.6f,34);offender.Profile.Composure=.8f;
m.Ball=victim.Position;victim.Held=1;
if(kind=="keeper-punt" || kind=="keeper-long-goal-kick")
{
foreach(var p in m.Players){p.Position=new Point(p.Team==0?48:78,5+p.Number*5);p.Decision=100;}
var keeper=m.Players[0];keeper.Position=new Point(8,34);keeper.Facing=0;
m.TakeBall(keeper);m.GoalKickPossession=kind=="keeper-long-goal-kick";
m.BeginKeeperKick(keeper,m.Players.Where(p=>p.Team==0 && !p.Keeper).ToArray());
}
else if(kind.StartsWith("impact-"))
{
foreach(var p in m.Players){p.Position=new Point(40,4+p.Id*2.5f);p.Decision=100;}
var direction=kind=="impact-back"?new Point(-1,0):kind=="impact-left"?new Point(0,1):kind=="impact-right"?new Point(0,-1):new Point(1,0);
victim.Position=new Point(84,34);victim.Facing=0;victim.Profile.Aggression=.1f;
victim.Velocity=kind=="impact-roll"?new Point(6,0):new Point(1,0);
offender.Position=victim.Position-direction*.72f;offender.Facing=MathF.Atan2(direction.Y,direction.X);offender.Velocity=direction*4;
m.Sliding=kind is "impact-trip" or "impact-roll";m.Ball=victim.Position;
m.CommitFoul(offender,victim,.8f);
}
else if(kind=="loose-race" || kind=="keeper-claim" || kind=="keeper-secured")
{
foreach(var p in m.Players){p.Position=new Point(45,5+p.Id*2);p.Decision=10;}
m.Owner=-1;m.Ball=new Point(96,34);m.BallVelocity=new Point();m.LastTouch=0;
m.Players[11].Position=new Point(102,34);
if(kind=="loose-race"){victim.Position=new Point(87,32);offender.Position=new Point(87,36);m.Players[11].Recovery=1;}
for(int i=0;i<(kind=="keeper-secured"?180:24) && m.Owner<0;i++)m.Step(1f/60);
m.Status=kind=="loose-race"?"Борьба за свободный мяч":m.Owner==11?"Вратарь забрал мяч":"Вратарь выходит за мячом";
}
else if(kind=="header" || kind=="chest" || kind=="block")
{
foreach(var p in m.Players)p.Position=new Point(25,5+p.Id*2);
var actor=kind=="block"?offender:victim;actor.Position=new Point(94,34);actor.Facing=0;
m.Owner=-1;m.LastTouch=0;m.Ball=new Point(93.8f,34);m.BallVelocity=new Point(12,0);
m.BallHeight=kind=="header"?2:kind=="chest"?1.35f:.4f;m.VerticalSpeed=-1;
if(kind=="block"){actor.BlockUntil=m.Seconds+1;m.shot=true;}
m.MoveBall(1f/60);
}
else if(kind=="keeper-left" || kind=="keeper-right" || kind=="keeper-rise")
{
foreach(var p in m.Players) p.Position=new Point(30,5+p.Id*2);
var keeper=m.Players[11];keeper.Position=new Point(99,34);keeper.Facing=MathF.PI;
keeper.DiveOrigin=keeper.Position;keeper.DiveTarget=keeper.Position+new Point(0,kind=="keeper-left"?-3:3);
keeper.DiveAge=.5f;keeper.DiveRemaining=.45f;keeper.DiveHeight=.9f;
if(kind=="keeper-rise"){keeper.DiveAge=.95f;keeper.DiveRemaining=0;keeper.DiveEndedAt=m.Seconds-.3f;}
m.Owner=-1;m.Ball=keeper.Position+new Point(-2,1);m.BallHeight=.9f;m.Status="Проверка позы вратаря";
}
else if(kind=="shot-windup" || kind=="shot-follow")
{
foreach(var p in m.Players) p.Position=new Point(25,5+p.Id*2);
victim.Position=new Point(82,34);victim.Facing=0;victim.Velocity=new Point();m.Ball=victim.Position;
m.Players[11].Position=new Point(103,34);
m.BeginShot(victim,23);m.Strike=StrikeKind.Power;m.PowerShot=true;m.WindupRemaining=.48f;
if(kind=="shot-follow") {m.ReleaseShot(victim);for(int i=0;i<9;i++)m.Step(1f/60);}
else for(int i=0;i<18;i++)m.Step(1f/60);
}
else if(kind=="corner" || kind=="corner-flight")
{
m.Restart(0,new Point(104.3f,.7f),"Угловой · СЕВЕР",kind:RestartKind.Corner);m.ArrangeSetPiece(12);
if(kind=="corner-flight") {m.RestartRemaining=.001f;for(int i=0;i<40;i++) m.Step(1f/60);}
}
else if(kind=="scuffle")
{
m.CommitFoul(offender,victim,.3f);victim.Profile.Aggression=1;victim.Profile.Composure=.2f;victim.Frustration=.9f;
m.StartScuffle(victim,offender,false);m.UpdateIncident(.15f);
}
else
{
if(kind=="second-yellow") m.ShowCard(offender,false,"первое нарушение");
m.CommitFoul(offender,victim,kind=="red"?.95f:.7f);
}
if(kind=="fall")for(int i=0;i<24;i++)m.Step(1f/60);
m.RefereePosition=Clamp(m.Ball+new Point(-4,-4));return m;
}
public static MatchSimulation OffsidePreview(int team=0,bool secondHalf=false,bool ballAhead=false)
{
team=Math.Clamp(team,0,1);
var m=new MatchSimulation(2026){RestartRemaining=0,Seconds=80,SecondHalf=secondHalf};
foreach(var p in m.Players)
{
p.Position=m.World(new Point(p.Team==team?56+p.Number*1.5f:72+p.Number,4+p.Number*5),team);
p.Decision=100;
}
m.Players[(1-team)*11].Position=m.World(new Point(103,34),team);
m.Players[(1-team)*11+2].Position=m.World(new Point(84,20),team);
var passer=m.Players[team*11+9];var receiver=m.Players[team*11+10];
passer.Position=m.World(new Point(ballAhead?90:78,28),team);
receiver.Position=m.World(new Point(96,34),team);m.Ball=passer.Position;m.Owner=passer.Id;
m.Kick(passer,receiver.Position,20,false);m.TakeBall(receiver);
m.RefereePosition=Clamp(m.Ball+new Point(-4,-4));return m;
}
}