One file of a soccer match simulation, split across partials. Defines the Point vector struct, the per-footballer state class, and the MatchSimulation core: kickoff/restart and set-piece setup, per-frame Step loop, player movement and braking, ball flight and interception, keeper saves and dives, goal/corner/throw-in detection, plus event log and Russian commentary strings. Pure C#, deterministic from a seed.
using System;
using System.Collections.Generic;
using System.Linq;
namespace Socer;
// Metres and simulation seconds. Pure C#: the same engine runs in s&box and the verification harness.
public struct Point
{
public float X, Y;
public Point(float x, float y) { X = x; Y = y; }
public float Length => MathF.Sqrt(X * X + Y * Y);
public Point Unit => Length < .001f ? new Point() : this * (1 / Length);
public static Point operator +(Point a, Point b) => new(a.X + b.X, a.Y + b.Y);
public static Point operator -(Point a, Point b) => new(a.X - b.X, a.Y - b.Y);
public static Point operator *(Point a, float f) => new(a.X * f, a.Y * f);
public static float Dot(Point a, Point b) => a.X * b.X + a.Y * b.Y;
}
public sealed class Footballer
{
public int Id, Team, Number;
public Point Position, Velocity, Home;
public float Skill, Pace, Decision, Held, TackleCooldown;
public bool ChasingTouch;
public Point CarryLook,AttackProgressPoint,AttackEscapeTarget;
public float AttackProgressAt,AttackEscapeUntil;
public BallChoice PlanChoice;
public int PlanReceiver=-1;
public Point PlanTarget;
public float PlanUntil;
public string PlanReason="";
public Point KickAim;
public float KickSetupUntil;
public string DecisionReason="";
public BallDecision LastBallDecision;
public PlayerProfile Profile = new();
public float Stamina=1, BurstCharge=1, BurstRemaining, BurstCooldown, Facing;
public float UniqueSkillReadyAt,UniqueSkillActiveUntil,UniqueSkillConsiderAt;
public readonly float[] SkillReadyAt=new float[3];
public float SkillActivationGateUntil;
public float ActiveSkillRemaining;
public UniqueSkill ActiveUniqueSkill;
public int ActiveSkillGrade;
public LegendaryAbility ActiveLegendaryAbility;
public float LegendaryReadyAt,LegendaryActiveUntil,LegendaryActivatedAt=-10,LegendaryRunUntil;
public float AcrobaticAt=-10,AcrobaticUntil;
public Point AcrobaticVelocity;
public bool IsAcrobaticCommitted(float seconds)=>seconds>=AcrobaticAt && seconds<AcrobaticUntil;
public Point LegendaryTargetA,LegendaryTargetB,LegendaryRunTarget;
public int LegendaryTargetPlayerA=-1,LegendaryTargetPlayerB=-1,LegendaryAbilityActivations;
public float ActiveSkillPower=>ActiveSkillGrade==2?1.35f:1;
public float SpeechUntil, SpeechCooldown, RunUntil;
public string Speech="", Action="Позиция";
public string SpeechEnglish="";
public string SpeechCleanRussian="",SpeechCleanEnglish="",SpeechLanguage="";
public PlayerSpeechCue SpeechCue;
public SpeechAudience SpeechAudience;
public SpeechTone SpeechTone;
public int SpeechTargetId=-1,SpeechSerial,SpeechHistoryCursor;
public float LastSpeechAt=-10;
public readonly int[] RecentSpeechIds=Enumerable.Repeat(-1,64).ToArray();
public Point RunTarget;
public int Sprints;
public float Recovery;
public Point DiveOrigin, DiveTarget;
public float DiveRemaining, DiveAge, DiveHeight;
public bool DiveConnected;
public float KeeperBlockStartedAt,KeeperBlockUntil;
public int KeeperBlockKind;
public Point KeeperBlockTarget;
// A save keeps its incoming-ball orientation through impact and recovery.
public Point KeeperSaveFacing;
public int Instruction;
public int Yellows, FoulsCommitted;
public bool SentOff, EmergencyKeeper;
public InjurySeverity InjurySeverity;
public InjuryBodyPart InjuryPart;
public float InjuryStartedAt=-10,InjuryUntil;
public bool InjuryCannotContinue,InjuryRemovedFromField;
public bool InPlay=>!SentOff && !InjuryCannotContinue;
public float Frustration, NextOutburstAt, CalmUntil;
// Presentation timestamps do not participate in football decisions or randomness.
public float LastKickAt=-10, LastSlideAt=-10;
public float StandingTackleAt=-10, StandingTackleResolvedAt=-10;
public Point StandingTackleAim;
public int StandingTackleFoot=1, StandingTackleResult;
public int StandingTackleVariant;
public float GaitPhase, VisualSpeed, TurnLean, WindupStartedAt, LastKickFacing;
public Point VisualBefore, VisualVelocity, VisualAcceleration;
// Presentation heading survives a stopped frame and turns smoothly into a cut.
public Point VisualTravel;
// Predicted aerial presentation cue; never used by football decisions.
public float VisualHeaderAt=-10,VisualHeaderHeight,VisualHeaderLift;
public Point VisualHeaderPoint;
public bool LastKickWasShot;
public StrikeKind LastKickKind;
public float DiveEndedAt=-10, LastControlAt=-10;
public float HeaderLeap,HeaderAt=-10,AerialContestAt=-10,HeaderContactHeight=1.72f,AerialReadyAt,ChestUntil,ChestHeight,FeintUntil,FeintReadyAt,FeintStartedAt,BlockUntil;
public Point HeaderContactPoint;
public Point FeintDirection;
public bool KeeperRushing;
public float ScoopUntil;
public float FallAt=-10,FallUntil;
public Point FallDirection;
public Point FallVelocity;
public float FallSeverity,FallSpeed,ImpactAt=-10,ImpactUntil;
public Point ImpactPoint,ImpactDirection,ImpactVelocity;
public bool ImpactSlide;
public float LastPuntAt=-10;
public float NutmeggedUntil;
public DirectOrderKind DirectOrder;
public int DirectTargetId=-1,AutoMarkId=-1;
public Point RoleMovementTarget;
public float RoleThinkingUntil;
public int RolePossession=-2;
public Point DirectPoint,SecondBallPoint;
public float DirectOrderUntil,SecondBallUntil;
public float LastScanAt=-10,NextScanAt,HesitateUntil,StumbleUntil;
public int FallVariant,SlideVariant,FeintVariant;
public SkillMoveKind SkillMove;
public int SkillStage,SkillOpponent=-1,SkillSide;
public Point SkillAxis;
public Point SkillNutmegGate;
public float SkillAirStartedAt;
public Point ScissorsAxis;
public int ScissorsSide,ScissorsStage,ScissorsOpponent=-1;
public Point SlideDirection;
public float SlideSpeed;
public float RecoveryChaseUntil,PassCallUntil,NextPassCallAt;
public float DefensiveRecoveryUntil,DefensiveRecoveryBurstReadyAt;
public int IgnoredPassCalls,PassCallOwner=-1;
public bool AngryPassCall;
public bool RunningShot;
public Point ControlPoint;
public float LastDribbleTouch=-10,NextDribbleTouch;
public int DribbleFoot=1;
public Point DribbleContact;
public Point DribbleContactOffset;
public float BodyLeanUntil;
public Point BodyLeanDirection;
public bool AerialChasing,ReceiveOnMove,InterceptBody;
public Point CarryAvoidTarget;
public float CarryAvoidUntil;
public float InterceptAt=-10,InterceptUntil,InterceptHeight;
public Point InterceptAim;
public int InterceptFoot=1;
public float Confidence=.6f,ReceiveReadyUntil,ReceiveUntil,ShotSetupUntil,ShieldUntil,ContainUntil;
public Point ReceiveDirection,ShotSetupTarget,ShieldDirection,FootballRunTarget;
public FootballRun FootballRun;
public float FootballRunUntil,FootballRunStarted;
public int RunSequence,BoxRole,KickFoot=1;
public int PreferredFoot, WeakFootRating=3;
public int StrongFoot=>PreferredFoot==0?(Id%5==2?-1:1):PreferredFoot;
public bool UsedWeakFoot,RushedShot;
public bool Sprinting => BurstRemaining>0 && Stamina>.12f && BurstCharge>.03f;
public bool Keeper => Number == 1 || EmergencyKeeper;
}
public sealed partial class MatchSimulation
{
public List<Footballer> Players { get; } = new();
public List<string> Events { get; } = new();
public int[] Goals = new int[2], Shots = new int[2], Passes = new int[2], Completed = new int[2], Saves = new int[2], Tackles = new int[2];
public int[] PossessionLosses = new int[2], WallPassesCompleted = new int[2], ThirdManPassesCompleted = new int[2];
public int[] DribbleAttemptsByTeam = new int[2], DribblesWonByTeam = new int[2];
public float[] Possession = new float[2];
public float[] TeamCohesion = { .5f, .5f };
public int[] Tactics = { 1, 1 }; // 0 counter, 1 balanced, 2 pressing
public int[] Approach = new int[2]; // 0 patient, 1 wide, 2 direct, 3 aggressive press
public static readonly string[] ApproachNames={"Контроль мяча","Игра через фланги","Вертикальные атаки","Агрессивный прессинг"};
public Point Ball, BallVelocity;
public int Owner = -1, LastTouch, Passer = -1, Seed;
public float Seconds, RestartRemaining;
public bool Finished => !MedicalActive && (Abandoned || finalWhistleComplete ||
(!managedMatchClock && SecondHalf && SecondHalfAddedMinutes>=0 && Seconds>=360+Math.Max(0,FirstHalfAddedMinutes)*4+SecondHalfAddedMinutes*4 && (!ShootoutRequired || Goals[0]!=Goals[1] || ShootoutComplete)));
public bool SecondHalf;
public string Status = "Начало матча";
public int Minute => !SecondHalf?Math.Min(45,(int)(Seconds/4)):
Math.Min(90,45+(int)Math.Max(0,(Seconds-180-Math.Max(0,FirstHalfAddedMinutes)*4)/4));
public float BallSpeed => BallVelocity.Length;
Random random;
int restartTeam;
Point restartPoint;
bool kickoff, shot;
bool goalKickRestart;
public bool GoalKickPossession;
float flightAge;
readonly Point[] nextVelocity=new Point[22];
readonly List<ReplayFigure.Part> keeperHeldBallPose=new(24);
int lastShooter=-1;
static Random seedGenerator=new();
public static int CreateSeed(int previous=-1)
{
int seed;
do { seed=seedGenerator.Next(1,int.MaxValue); } while(seed==previous);
return seed;
}
public static int FriendlyOpponent(int seed)=>new Random(unchecked(seed^0x5A10E5)).Next(1,8);
public MatchSimulation() : this(CreateSeed()) { }
public MatchSimulation(int seed)
{
Seed = seed;
random = new Random(seed);
Approach[0]=new Random(unchecked(seed*7919+17)).Next(ApproachNames.Length);
Approach[1]=new Random(unchecked(seed*104729+31)).Next(ApproachNames.Length);
Tactics[1]=Approach[1]==2?0:Approach[1]==3?2:1;
Point[] homes = { new(5,34), new(23,8), new(20,25), new(20,43), new(23,60), new(43,10), new(39,27), new(39,41), new(43,58), new(61,25), new(61,43) };
for (int t = 0; t < 2; t++)
for (int i = 0; i < 11; i++)
{
var p=new Footballer { Id=t*11+i, Team=t, Number=i+1, Home=homes[i], Skill=.65f+Next()*.3f, Pace=5.1f+Next()*1.2f };
p.Profile=CreateProfile(p);
Players.Add(p);
}
Restart(0, new Point(52.5f,34), "Начальный свисток", true);
}
float Next() => (float)random.NextDouble();
public int Direction(int team) => (team == 0 ? 1 : -1) * (SecondHalf ? -1 : 1);
public string HomeName=ClubCareer.Teams[0], AwayName=ClubCareer.Teams[1];
public string TeamName(int team) => team == 0 ? HomeName : AwayName;
public Point World(Point p, int team) => Direction(team) == 1 ? p : new Point(105-p.X,68-p.Y);
public void AddEvent(string message)
{
Status = message;
Events.Insert(0, $"{ClockLabel}′ {message}");
if (Events.Count > 7) Events.RemoveAt(7);
}
void Restart(int team, Point where, string message, bool centre = false, bool goalKick = false, RestartKind kind=RestartKind.ThrowIn)
{
ResetManualRestart();
ResetFootballContext();
RegisterShotContact("ОСТАНОВКА");ClearOffside();OffsideLineUntil=0;nextKickExempt=false;indirectFlight=false;restartKickPending=false;
CancelWindup();
KeeperKickPlayer=-1;
DuelDefender=-1; DuelCarrier=-1; DuelRemaining=0; PickupDelay=0; BodyChallenge=false;
DuelClosestContact=float.MaxValue;
Owner=-1; Passer=-1; shot=false; BallVelocity=new();
ResetFlight(); GoalHoldRemaining=0;ShotMissFlightRemaining=0;ResetGoalCelebration();
foreach(var keeper in ActivePlayers.Where(p=>p.Keeper))
{keeper.DiveRemaining=0;keeper.KeeperBlockUntil=0;keeper.KeeperSaveFacing=new();}
foreach(var player in Players)
{
player.LegendaryRunUntil=0;player.LegendaryActiveUntil=0;
player.AcrobaticAt=-10;player.AcrobaticUntil=0;player.AcrobaticVelocity=new Point();
}
restartTeam=team; restartPoint=where; kickoff=centre;
goalKickRestart=goalKick; GoalKickPossession=false;
SetPiece=centre?RestartKind.KickOff:goalKick?RestartKind.GoalKick:kind;
SprayMarked=false;RefereeSpraying=false;sprayAnimationRemaining=0;sprayMarkUntil=0;SprayMarkForward=new();
keeperWallCalled=false;
SetPieceTaker=ChooseTaker().Id;
PrepareSetPiece();
RestartRemaining=SetPiece==RestartKind.Corner?Math.Max(3,(Players[SetPieceTaker].Position-restartPoint).Length/8+.3f):
SetPiece is RestartKind.FreeKick or RestartKind.Indirect?NeedsFreeKickSpray
?Math.Max(6.2f,(RefereePosition-restartPoint).Length/12f+1.3f)
:Math.Max(1.8f,(Players[SetPieceTaker].Position-SetPieceRunupStart).Length/8f+.65f):
SetPiece==RestartKind.Penalty?3.4f:SetPiece==RestartKind.GoalKick?2.6f:centre?2.5f:1.3f;
if(SetPiece==RestartKind.Corner) Corners[team]++;
Ball=where;
if (centre)
{
foreach(var p in ActivePlayers) { var h=p.Home; h.X=Math.Min(h.X,48); p.Position=World(h,p.Team); p.Velocity=new(); p.Facing=Direction(p.Team)>0?0:MathF.PI; p.RunUntil=0; p.BurstRemaining=0; }
}
AddEvent(message);
}
void TakeBall(Footballer p)
{
if(PassiveTrainingOpponent(p) || p.IsAcrobaticCommitted(Seconds))return;
bool scriptedReceiver=StrategyGuaranteedPasses && Strategy is {Sent:true} script && script.Expected==p.Id;
if(!p.InPlay || MedicalActive || (!scriptedReceiver && Seconds<p.FallUntil) || (!scriptedReceiver && !RehearsingStrategy && CheckOffside(p))) return;
int previousPossessionTeam=Owner>=0?Players[Owner].Team:lastPossessionTeam>=0?lastPossessionTeam:LastTouch;
p.ControlPoint=Ball;
if(p.DirectOrder==DirectOrderKind.AttackRun)p.DirectOrder=DirectOrderKind.None;
p.ReceiveOnMove=false;
p.CarryAvoidUntil=0;
int incomingPasser=Passer;float incomingSpeed=BallSpeed;var incomingVelocity=BallVelocity;
p.LastControlAt=Seconds;p.PlanUntil=0;p.ChasingTouch=false;p.KickSetupUntil=0;p.AttackEscapeUntil=0;p.AttackProgressAt=Seconds;p.AttackProgressPoint=p.Position;
if(Passer>=0 && Players[Passer].Team!=p.Team)
{Players[Passer].Frustration=Math.Min(1,Players[Passer].Frustration+.16f);Players[Passer].Confidence=Math.Max(.15f,Players[Passer].Confidence-.045f);}
RegisterShotContact(p.Keeper?"ПОЙМАЛ":"КАСАНИЕ");SnapshotOffside(p,false);indirectFlight=false;
if (WindupPlayer>=0 && WindupPlayer!=p.Id) CancelWindup();
if(!scriptedReceiver && !RehearsingStrategy && (MediaFirstTouch(p,incomingPasser,incomingSpeed,incomingVelocity) || FirstTouch(p,incomingPasser,incomingSpeed,incomingVelocity)))
{return;}
if (Passer >= 0 && Players[Passer].Team == p.Team && Passer != p.Id) Completed[p.Team]++;
NoteDialoguePass(p,incomingPasser);
if(Passer>=0 && Players[Passer].Team==p.Team)Players[Passer].Confidence=Math.Min(.95f,Players[Passer].Confidence+.018f);
Owner=p.Id; LastTouch=p.Team; Passer=-1; shot=false;
RecordLinkedReception(p,incomingPasser);
RecordSkillCoverRecovery(p,previousPossessionTeam);
CompleteCombinationReception(p,incomingPasser);
GoalKickPossession=false;
ResetFlight(); BallHeight=p.Keeper?1:0;
p.Held=0; p.Decision=.12f+(1-p.Profile.Vision)*.32f+(1-p.Profile.Composure)*.12f; BallVelocity=p.Keeper?new Point():p.Velocity*.35f;
if(IsMediaLeague)mediaPlanUntil[p.Id]=0;
if(p.Keeper || (Ball-p.Position).Length>1.2f)Ball=p.Position;
p.NextDribbleTouch=Seconds+.06f;
if(p.ReceiveOnMove)
{
BallVelocity=LimitDribbleImpulse(p.ReceiveDirection*(2.4f+Math.Min(3,p.Velocity.Length*.55f)));
p.NextDribbleTouch=Seconds+.16f;p.Decision=Math.Min(p.Decision,.12f);
p.DribbleContact=Ball;p.DribbleContactOffset=Ball-p.Position;p.LastDribbleTouch=Seconds;p.Action="Первым касанием на ход";
}
ApplyCombinationReceptionTiming(p);
}
public void Step(float dt)
{
if(dt<=0)return;
if(MedicalActive){AdvanceFrozenAcrobaticRecoveries(dt);AdvanceMedicalTreatment(dt);UpdateFootballEmotions(dt);return;}
if(Finished || ManualRestartWaiting) return;
AdvanceRecoverableInjuries();
managedMatchClock=true;
foreach(var p in ActivePlayers)
{p.VisualBefore=p.Position;p.ActiveSkillRemaining=Math.Max(0,p.ActiveSkillRemaining-dt);}
if(ShootoutActive)
{
AdvanceShootout(dt);
foreach(var p in ActivePlayers)AdvanceGait(p,dt,false,Seconds);
UpdateFootballEmotions(dt);
return;
}
if(PeriodWalkRemaining>0)
{
AdvanceFrozenAcrobaticRecoveries(dt);
AdvancePeriodWalk(dt);
foreach(var p in ActivePlayers) AdvanceGait(p,dt,false,Seconds);
UpdateFootballEmotions(dt);
return;
}
UpdateStrategy();
if(StrategyBoundaryReached)return;
if(firstTimeContact>=0)ReleaseStrategyFirstTime();
foreach(var p in Players)ReplayFigure.AdvanceHeaderPreparation(p,this);
StepCore(dt);
foreach(var p in ActivePlayers) AdvanceGait(p,dt,Owner==p.Id,Seconds);
ObservePlayerDialogue();
UpdateFootballEmotions(dt);
}
bool IsOnside(Footballer receiver,Point ball)
{
return receiver.InPlay && !OffsidePosition(receiver,OffsideLimit(receiver.Team,ball));
}
internal static void AdvanceGait(Footballer p,float dt,bool carrying=false,float seconds=0)
{
var delta=p.Position-p.VisualBefore;
// Ignore kickoff teleports and referee repositioning, never turn them into a running cycle.
bool locomotion=delta.Length<=12*dt+.02f && p.DiveRemaining<=0 && p.Action!="Подкат" && !p.IsAcrobaticCommitted(seconds);
float speed=locomotion?delta.Length/dt:0;
p.VisualSpeed+=(speed-p.VisualSpeed)*(1-MathF.Exp(-dt*12));
var velocity=locomotion?delta*(1/dt):new Point();
var acceleration=(velocity-p.VisualVelocity)*(1/Math.Max(dt,.001f));
if(acceleration.Length>12)acceleration=acceleration.Unit*12;
p.VisualAcceleration=locomotion?p.VisualAcceleration+(acceleration-p.VisualAcceleration)*(1-MathF.Exp(-dt*9)):new Point();
p.VisualVelocity=velocity;
if(locomotion && velocity.Length>.04f)
{
float desired=MathF.Atan2(velocity.Y,velocity.X);
if(p.VisualTravel.Length>.08f)
{
float previous=MathF.Atan2(p.VisualTravel.Y,p.VisualTravel.X);
float turn=MathF.Atan2(MathF.Sin(desired-previous),MathF.Cos(desired-previous));
desired=previous+turn*(1-MathF.Exp(-dt*13));
}
p.VisualTravel=new Point(MathF.Cos(desired),MathF.Sin(desired));
}
else if(delta.Length>3)p.VisualTravel=new Point();
bool active=!p.Keeper && (carrying || p.RunUntil>seconds || p.FootballRunUntil>seconds ||
p.Action is "Прессинг" or "Рывок" or "Рывок за мячом" or "Открывание" or "Ведение");
float stepLength=Math.Clamp(1.3f+speed*.28f,1.3f,3.2f);
if((active || speed>1.8f) && speed>1.1f)stepLength*=.8f;
float braking=Math.Clamp(-Point.Dot(p.VisualAcceleration,velocity.Unit)/8,0,1);
stepLength*=1-braking*.22f;
if(speed>.03f) p.GaitPhase=(p.GaitPhase+delta.Length/stepLength)%1;
}
void StepCore(float dt)
{
if(!CanFieldCall || (Owner>=0?Players[Owner].Team:LastTouch)!=0)FieldAttackUntil=FieldShootUntil=0;
UpdateFieldPass();
if (Finished || dt <= 0) return;
if(GoalHoldRemaining>0)
{
float dialogueExtra=dt;
float clockAdvanced=0;
if(GoalNetMotionRemaining>0)
{
float motion=Math.Min(dt,GoalNetMotionRemaining);
clockAdvanced=motion;
Seconds+=motion;GoalNetMotionRemaining-=motion;
dialogueExtra-=motion;
AdvanceGoalNet(motion);
}
AdvanceGoalDialogue(dialogueExtra);
// Extra presentation time must not inflate the football's added
// time budget beyond the previous five-second goal stoppage.
CountGoalStoppage(Math.Min(dt,Math.Max(0,5f-GoalCelebrationElapsed)));
foreach(var effect in Effects)effect.Age+=dt;
Effects.RemoveAll(effect=>effect.Age>effect.Duration);
AdvanceGoalCelebration(dt,clockAdvanced);
GoalHoldRemaining=Math.Max(0,GoalHoldRemaining-dt);
if(GoalHoldRemaining==0 && !CheckPeriodEnd()) Restart(1-pendingGoalTeam,new Point(52.5f,34),$"ГОООЛ! {TeamName(pendingGoalTeam)} · {Goals[0]} : {Goals[1]}",true);
return;
}
ShotCueRemaining=Math.Max(0,ShotCueRemaining-dt);
PickupDelay=Math.Max(0,PickupDelay-dt);
foreach(var effect in Effects) effect.Age+=dt;
Effects.RemoveAll(e=>e.Age>e.Duration);
CountStoppage(dt);
Seconds+=dt;
AdvanceAcrobaticRecoveries(dt);
AdvanceFalls(dt);
UpdateOfficials(dt);
AnnounceAddedTime();
if (CheckPeriodEnd()) return;
if(IncidentRemaining>0) {UpdateIncident(dt);return;}
if(ShotMissFlightRemaining>0) {AdvanceMissedShotFlight(dt);return;}
if (RestartRemaining > 0)
{
if(SetPieceTaker<0 || !Players[SetPieceTaker].InPlay)SetPieceTaker=ChooseTaker().Id;
ArrangeSetPiece(dt);
foreach(var p in ActivePlayers) Recover(p,dt);
// A fouled taker may still be on the ground. Do not release a
// prepared penalty/pass through the fall pose before they get up.
if(Seconds<Players[SetPieceTaker].FallUntil){RestartRemaining=Math.Max(.35f,RestartRemaining);return;}
if(WaitForManualRestart())return;
RestartRemaining-=dt;
if (RestartRemaining <= 0)
{
var taker=Players[SetPieceTaker];if(!taker.InPlay) taker=ChooseTaker();
taker.Position=restartPoint;
foreach(var p in ActivePlayers.Where(p=>p.Team!=restartTeam))
if ((p.Position-restartPoint).Length < 5) p.Position=Clamp(restartPoint+(p.Position-restartPoint).Unit*5);
TakeBall(taker);
GoalKickPossession=goalKickRestart;
if(GoalKickPossession) { BallHeight=0; Ball=restartPoint; taker.Action="Удар от ворот"; }
ReleaseSetPiece(taker);
}
return;
}
int possessionTeam=Owner >= 0 ? Players[Owner].Team : LastTouch;
UpdateTacticalCharacters();
PrepareInterceptions();
Possession[possessionTeam]+=dt;
UpdateChasers();
UpdateDefensiveCoordination();
UpdateFootballThinking(dt);
UpdateKeepers(dt);
Array.Clear(nextVelocity);
foreach(var p in ActivePlayers)
{
p.Decision-=dt; p.TackleCooldown-=dt;
p.Recovery=Math.Max(0,p.Recovery-dt);
if (Owner==p.Id) p.Held+=dt;
p.SpeechCooldown-=dt; p.BurstCooldown-=dt;
int dir=Direction(p.Team);
p.Action="Позиция";
if(p.IsAcrobaticCommitted(Seconds))
{p.Velocity=new Point();p.Action="Поднимается после акробатического удара";continue;}
if(Seconds<p.FallUntil){p.Action=p.InjurySeverity==InjurySeverity.Minor?"Держится за ушибленное место":p.InjurySeverity==InjurySeverity.Knock?"Приходит в себя после столкновения":"Поднимается после падения";p.Velocity=new();continue;}
if(Seconds<p.ImpactUntil){p.Velocity=new();continue;}
float slideAge=Seconds-p.LastSlideAt;
if(slideAge>=0 && slideAge<2.1f)
{
nextVelocity[p.Id]=slideAge<.65f?p.SlideDirection*Math.Clamp(p.SlideSpeed,2.5f,6)*MathF.Exp(-slideAge*4):new Point();
p.Facing=MathF.Atan2(p.SlideDirection.Y,p.SlideDirection.X);
p.Action=slideAge<.85f?"Подкат":"Встаёт после подката";
continue;
}
bool dummy=PassiveTrainingOpponent(p) && !p.Keeper;
Point target=dummy?StrategyDummyTarget(p):BuildMovementIntent(p,possessionTeam,dt);
if(IsMediaLeague && !dummy)target=MediaMovement(p,target,possessionTeam);
if(!dummy)UpdateEffort(p,target,dt,possessionTeam);
if(p.Team==0 && Seconds<CoachUntil && CoachOrder==3) p.BurstRemaining=0;
float pace=dummy?1.8f:EffectivePace(p);
if(dummy){p.BurstRemaining=0;p.RunUntil=0;p.Action="Training dummy";}
if(p.Recovery>0) { pace*=.4f; p.Action="Возвращается в игру"; }
if(DuelCarrier==p.Id)
{pace*=BodyChallenge?1-BodyPressureStrength:.85f;p.Action=BodyChallenge?"Удерживает мяч под давлением":"Прикрывает мяч";}
if(Owner==p.Id && Seconds<p.ShieldUntil)pace*=.55f;
bool racing=UrgentLooseBall(p) || p.KeeperRushing || (Owner==p.Id && !p.Keeper && (Ball-p.Position).Length>DribbleFootReach);
bool receivingPass=!RehearsingStrategy && Owner<0 && p.Id==PassReceiver && p.Team==LastTouch;
// Arrive in the path, do not sprint through the meeting point and spend
// two seconds turning back after the ball has already passed.
float distanceToTarget=(target-p.Position).Length;
float brakingLimit=MathF.Sqrt(2*(5+p.Profile.Acceleration*4)*distanceToTarget);
var desired=(target-p.Position).Unit * Math.Min(Math.Min(pace,brakingLimit),distanceToTarget*(receivingPass?1.6f:racing?5:2));
if(Owner==p.Id) desired*=p.Sprinting?.96f:.9f;
if(Owner==p.Id && !p.Keeper && WindupPlayer!=p.Id && Seconds>=p.KickSetupUntil)
{
var forward=new Point(MathF.Cos(p.Facing),MathF.Sin(p.Facing));
// Plant and turn before accelerating sideways with the ball.
float facingAlignment=Point.Dot(forward,desired.Unit);
desired*=Math.Clamp((facingAlignment+.8f)/1.8f,.65f,1);
// Do not run past a slowing touch; turn back to recover it physically.
var toBall=Ball-p.Position;
if(toBall.Length>.25f && p.Velocity.Length>1 && Point.Dot(toBall,p.Velocity.Unit)<-.1f)
desired*=.6f;
}
var separation=new Point();
foreach(var q in ActivePlayers)
{
if(q.Id==p.Id) continue;
var d=p.Position-q.Position;
if(d.Length<.8f && d.Length>.01f) separation+=d.Unit*(.8f-d.Length)*4;
}
var change=(desired+separation-p.Velocity);
float alignment=Point.Dot(desired.Unit,p.Velocity.Unit);
// Brake before reversing, then push off. Do not crawl through a long U-turn.
bool reversing=alignment<-.2f && p.Velocity.Length>2 && !receivingPass;
if(reversing)change=separation-p.Velocity;
float acceleration=(4+p.Profile.Acceleration*5)*(reversing?1.15f:1)*dt;
nextVelocity[p.Id]=p.Velocity+change.Unit*Math.Min(change.Length,acceleration);
UpdateFacing(p,target,dt);
}
foreach(var p in ActivePlayers)
{
if(p.IsAcrobaticCommitted(Seconds)){p.Velocity=new Point();continue;}
p.Velocity=nextVelocity[p.Id];
if(p.Keeper && p.DiveRemaining>0)
{
var reach=p.DiveTarget-p.DiveOrigin;
p.Position=Clamp(p.DiveOrigin+reach.Unit*Math.Min(1.5f,reach.Length)*Math.Clamp((p.DiveAge-.1f)/.35f,0,1));
p.Velocity=new(); p.Action=p.DiveConnected?"Поймал мяч":"Тянется к мячу";
}
else p.Position=Clamp(p.Position+p.Velocity*dt);
}
ObserveSkillCover(dt);
if(Owner>=0 && !Players[Owner].Keeper)
{
AdvanceDribble(dt);
if(Owner<0 || RestartRemaining>0 || GoalHoldRemaining>0)return;
}
if(KeeperKickPlayer>=0)
{
if(AdvanceKeeperKick())return;
}
else if(Owner>=0 && Players[Owner].Keeper && !GoalKickPossession)
FollowKeeperHands(Players[Owner]);
ResolveChallenge(dt);
if(RestartRemaining>0 || IncidentRemaining>0 || MedicalActive || Finished) return;
if(Owner>=0)
{
var carrier=Players[Owner];
if(KeeperKickPlayer==carrier.Id)return;
var defender=ActivePlayers.Where(p=>p.Team!=carrier.Team && !p.IsAcrobaticCommitted(Seconds)).OrderBy(p=>(p.Position-carrier.Position).Length).FirstOrDefault();
// A keeper who caught the ball holds it in his hands until distribution.
// Allowing a tackle here produced endless save/steal/shoot loops at the goal line.
if(defender!=null && !carrier.Keeper && DuelDefender<0 && (defender.Position-Ball).Length<1.65f && defender.Recovery<=0 && defender.TackleCooldown<=0 && carrier.Held>.35f)
{
if(!PassiveTrainingOpponent(defender) && ShouldChallenge(defender,carrier))BeginChallenge(defender,carrier);
}
if(WindupPlayer==carrier.Id)
{
WindupRemaining-=dt;
if(WindupRemaining<=0) ReleaseShot(carrier);
return;
}
if(defender!=null && (defender.Position-carrier.Position).Length<3) Say(carrier,"Сзади!",false);
if(carrier.Decision<=0 && (carrier.Keeper || BallAtFoot(carrier))) { carrier.Decision=(.24f+(1-carrier.Profile.Vision)*.2f+(1-carrier.Stamina)*.18f+Next()*.18f)*DecisionTempo(carrier.Team); Decide(carrier); }
}
else MoveBall(dt);
}
void Decide(Footballer p)
{if(!DecideMediaLeague(p))ChooseFootballAction(p);}
void Kick(Footballer p, Point target, float speed, bool isShot,bool preserveOppositionOffside=false)
{
PassReceiver=-1;
if(!isShot)OpponentMemory.Observe(Players,p,AttackMemory.PassCue(p.Position,target),Seconds);
p.LastKickAt=Seconds;p.PlanUntil=0;
p.KickSetupUntil=0;
p.LastKickWasShot=isShot;p.LastKickKind=isShot?Strike:StrikeKind.Driven;
p.LastKickFacing=MathF.Atan2(target.Y-p.Position.Y,target.X-p.Position.X);
if(p.Keeper || (Ball-p.Position).Length>1.1f)Ball=p.Position+(target-p.Position).Unit*.35f;
SnapshotOffside(p,nextKickExempt,preserveOppositionOffside);nextKickExempt=false;
indirectFlight=restartKickPending;restartKickPending=false;
if(isShot) {ShotSequence++;ShotContactKind="";lastShooter=p.Id;}
Owner=-1; LastTouch=p.Team; Passer=isShot?-1:p.Id;
GoalKickPossession=false;
ResetFlight();
BallVelocity=(target-Ball).Unit*speed; shot=isShot; flightAge=0;
}
void MoveBall(float dt)
{
var before=Ball;
float beforeHeight=BallHeight;
if(AdvanceGuaranteedStrategyPass(dt,before,beforeHeight))return;
AdvanceFlight(dt);
flightAge+=dt;
if(HitGoalFrame(before,beforeHeight)) return;
if(TryStrategyFirstTimeContact(before,beforeHeight))return;
if(TryOpenPlayFirstTime(before,beforeHeight))return;
if(!(RehearsingStrategy && !shot) && TryAerialContact(before,beforeHeight))return;
// Swept interception prevents fast shots tunnelling through players.
foreach(var p in ActivePlayers.Where(p=>Seconds>=p.FallUntil && Seconds-p.LastSlideAt>=2.1f && !p.IsAcrobaticCommitted(Seconds)).OrderBy(p=>(p.Position-before).Length))
{
// Training opponents are visual position markers. The keeper may dive,
// but neither gloves nor bodies alter the ball's flight.
if(PassiveTrainingOpponent(p))continue;
if(!RehearsalReceiver(p))continue;
// The first-time finisher waits for actual foot contact, not the wider trapping radius.
if(FirstTimePhase(p)>=0)continue;
if(PickupDelay>0 || ((p.Id==Passer || shot && p.Id==lastShooter) && flightAge<.35f)) continue;
if(p.Keeper)
{
if(CheckOffsideInterference(p,before,beforeHeight))return;
if(!KeeperTouches(p,before,beforeHeight)) continue;
}
else if(p.Team!=LastTouch)
{
if(!TryBodyContact(p,before,beforeHeight,out var contact,out float contactHeight,out var normal))continue;
if(CheckOffsideInterference(p,before,beforeHeight))return;
if(!RehearsingStrategy && CheckOffside(p))return;
Ball=contact;BallHeight=contactHeight;
RecordSkillCoverContact(p);
ReboundInterception(p,normal);return;
}
else if(shot)
{
if(!TryBodyContact(p,before,beforeHeight,out var contact,out float contactHeight,out var normal))continue;
if(!RehearsingStrategy && CheckOffside(p))return;
Ball=contact;BallHeight=contactHeight;ReboundInterception(p,normal);return;
}
else if(Math.Min(beforeHeight,BallHeight)>.85f || DistanceToSegment(p.Position,before,Ball)>.65f) continue;
if(!RehearsingStrategy && CheckOffside(p)) return;
if(shot && !p.Keeper && Next()>(Seconds<p.BlockUntil?.88f:.5f)) continue;
if(shot && p.Keeper)
{
p.DiveConnected=true;
Saves[p.Team]++;
if(KeeperShouldParry(p,BallSpeed,BallHeight))
{OnLegendaryKeeperParry(p);DeflectBall(p,true);AddEvent($"{TeamName(p.Team)} · вратарь отбил мяч!");return;}
AddEvent($"{TeamName(p.Team)} · сейв вратаря!");
Emit("save",p,"СЕЙВ!",2); Say(p,"Мой!",true);
}
else if(shot && !p.Keeper && (Seconds<p.BlockUntil || Next()<.55f)) {DeflectBall(p,false);return;}
else if(p.Team!=LastTouch) { AddEvent($"{TeamName(p.Team)} · перехват #{p.Number}"); Emit("intercept",p,"ПЕРЕХВАТ",1.4f); }
if(p.Keeper) p.DiveConnected=true;
TakeBall(p);
if(p.Keeper)
{
if(p.KeeperRushing){KeeperClaims++;p.ScoopUntil=Seconds+.55f;p.KeeperRushing=false;Emit("save",p,"ЗАБРАЛ!",1.2f);}
FollowKeeperHands(p);
p.Action="Поймал мяч";
}
return;
}
if(Ball.X<0 || Ball.X>105)
{
float goalX=Ball.X<0?0:105;
float fraction=(goalX-before.X)/(Ball.X-before.X);
float crossingY=before.Y+(Ball.Y-before.Y)*fraction;
float crossingHeight=beforeHeight+(BallHeight-beforeHeight)*fraction;
int attacker=Direction(0)==(Ball.X>105?1:-1)?0:1;
if(Math.Abs(crossingY-34)<3.55f && crossingHeight<2.33f && !indirectFlight)
{
RegisterShotContact("ГОЛ");
Goals[attacker]++;
GoalScorerId=shot && lastShooter>=0 && Players[lastShooter].Team==attacker
?lastShooter:ActivePlayers.Where(p=>p.Team==attacker && !p.Keeper).OrderBy(p=>(p.Position-Ball).Length).First().Id;
GoalMinute=InAddedTime?ClockLabel:Math.Max(1,Minute+1).ToString();
BeginGoalCelebration(Players[GoalScorerId]);
CelebrateDialogue(Players[GoalScorerId]);
Players[GoalScorerId].Confidence=Math.Min(.95f,Players[GoalScorerId].Confidence+.12f);
foreach(var loser in ActivePlayers.Where(p=>p.Team!=attacker)) loser.Frustration=Math.Min(1,loser.Frustration+.16f);
EmitAt("goal",Ball,"ГОООЛ!",4);
pendingGoalTeam=attacker;GoalNetMotionRemaining=1.0f;
foreach(var keeper in ActivePlayers.Where(p=>p.Keeper && p.Team!=attacker && p.DiveRemaining>0 && !p.DiveConnected))
{ KeeperMisses++; Emit("miss",keeper,"НЕ ДОТЯНУЛСЯ",1.5f); }
Ball=new Point(goalX==0?-.08f:105.08f,crossingY); BallHeight=Math.Max(0,crossingHeight);
BallVelocity*=.58f; VerticalSpeed*=.35f;
AddEvent($"ГОООЛ! {TeamName(attacker)} · {Goals[0]} : {Goals[1]}");
}
else
{
if(shot && lastShooter>=0) {var shooter=Players[lastShooter];shooter.Frustration=Math.Min(1,shooter.Frustration+.2f);SpeakEmotion(shooter,"Да блин, мимо!");}
if(LastTouch!=attacker) Restart(attacker,new Point(goalX==0?.7f:104.3f,Ball.Y<34?.7f:67.3f),"Угловой · "+TeamName(attacker),kind:RestartKind.Corner);
else if(shot) BeginMissedShotFlight(attacker,goalX,crossingHeight);
else Restart(1-attacker,new Point(goalX==0?6:99,34),"Удар от ворот · "+TeamName(1-attacker),goalKick:true);
}
}
else if(Ball.Y<0 || Ball.Y>68) Restart(1-LastTouch,new Point(Math.Clamp(Ball.X,2,103),Ball.Y<0?1:67),"Аут · "+TeamName(1-LastTouch));
}
public static Point Clamp(Point p) => new(Math.Clamp(p.X, .7f,104.3f),Math.Clamp(p.Y,.7f,67.3f));
public static float DistanceToSegment(Point p, Point a, Point b)
{
var d=b-a;
float f=Math.Clamp(Point.Dot(p-a,d)/Math.Max(.0001f,Point.Dot(d,d)),0,1);
return (p-(a+d*f)).Length;
}
}