Partial MatchSimulation class for a football match. Handles player decision making: pass, shot and carry choices, hesitation, off-ball support and pressure behaviour, with pass statistics tracking.
using System;
using System.Linq;
namespace Socer;
// Decisions run at player reaction cadence. Rendering never consumes match randomness.
public sealed partial class MatchSimulation
{
public int[] ShortPasses=new int[2],LongPasses=new int[2];
public int Hesitations;
public int PassReceiver=-1;
public Point IntendedPassTarget;
public bool SeesPartner(Footballer p,Footballer q)
{
var delta=q.Position-p.Position;
if(delta.Length>18+p.Profile.Vision*32)return false;
if(delta.Length<5 || Seconds-p.LastScanAt<.6f)return true;
var look=new Point(MathF.Cos(p.Facing),MathF.Sin(p.Facing));
return Point.Dot(look,delta.Unit)>-.05f-p.Profile.Vision*.55f;
}
void ScanPitch(Footballer p)
{
if(Seconds<p.NextScanAt)return;
p.LastScanAt=Seconds;
p.NextScanAt=Seconds+1.1f+(1-p.Profile.Vision)*2.5f+
(1-p.Profile.RoleFamiliarity)*2+p.Id%3*.13f;
}
float PassValue(Footballer p,Footballer q,float pressure)
{
var delta=q.Position-p.Position;float distance=delta.Length;
if(distance<2.5f || distance>(Approach[p.Team]==2?48:41) || !SeesPartner(p,q) || !IsOnside(q,p.Position))return -100;
var target=PassMeetingPoint(p,q);
float lane=PerceivedLane(p,target);
float space=PerceivedSpace(p,target);
float safety=PassSafety(p,q,target);
float gain=Math.Clamp(delta.X*Direction(p.Team),-9,15);
float localX=World(p.Position,p.Team).X;
float receiverX=World(target,p.Team).X;
float nearestAtReception=ActivePlayers.Where(r=>r.Team!=p.Team && !r.Keeper)
.Select(r=>Observed(p,r)).Where(seen=>seen.HasValue)
.Select(seen=>(seen.Value-(target+q.Velocity*.2f)).Length)
.DefaultIfEmpty(9).Min();
float risk=Math.Max(0,2.5f-lane)*(5+(1-p.Profile.Passing)*5);
if(distance>16 && space>3 && p.Profile.Passing>.55f)risk*=.35f; // A loft can clear the first line.
float value=2+safety*10+gain*(Approach[p.Team]==2?.45f:.28f)+Math.Min(space,7)*.55f-distance*.15f-risk;
if(safety<.35f)value-=14;
if(safety<.2f)return -100;
value+=AttackUrgency(p.Team)*Math.Max(0,gain)*.3f;
if(space<2)value-=8;
// A lane can be clear while its receiver is immediately trapped. In our
// defensive third, avoid returning the ball towards goal under a press.
if(localX<35 && receiverX<30 && nearestAtReception<4.5f)
value-=Math.Max(0,4.5f-nearestAtReception)*5+5;
if(localX<32 && pressure<4.5f && gain< -2)value-=7+Math.Min(8,-gain*.7f);
if(localX<35 && gain>7 && safety>.48f && nearestAtReception>3)value+=4;
if(distance>24 && gain>10 && safety>.55f)value+=2.5f;
if(gain>5 && PlayerPlayStyles.Has(p,PlayStyle.IncisivePass))value+=2.3f;
if(Math.Abs(target.Y-34)>19 && gain>4 && safety>.55f)value+=2.5f;
float recipientGoal=(World(new Point(105,34),p.Team)-target).Length;
if(recipientGoal<20 && space>3 && safety>.6f)value+=8;
// Recycling is useful when blocked, not a reward for abandoning a clear finish.
if(World(p.Position,p.Team).X>82 && gain< -3 && pressure>2.5f)value-=10;
if(pressure<4 && space>pressure+1)value+=4;
if(q.RunUntil>Seconds)value+=2.5f*p.Profile.Vision;
if((q.FootballRunUntil>Seconds && q.FootballRun is FootballRun.Behind or FootballRun.Overlap || q.BoxRole is 1 or 2) &&
gain>2 && World(target,p.Team).X>80 && safety>.55f && space>2)
value+=6*p.Profile.Vision+2;
if(Approach[p.Team]==1)value+=Math.Abs(q.Position.Y-34)*.1f;
if(Approach[p.Team]==0 && distance>30)value-=2;
return value+LinkedPassValue(p,q,gain,safety,space)+TacticalPassValue(p,q,distance,gain,safety)+PlanPassValue(p,gain,safety,distance)-FamiliarPassRisk(p,q.Position)*6*p.Profile.Vision;
}
void ChooseFootballAction(Footballer p)
{
if(Seconds-p.LastSlideAt<2.1f)return;
if(Seconds<p.ChestUntil || Seconds<p.FeintUntil || Seconds<p.FallUntil || Seconds<p.ReceiveUntil || Seconds<p.HesitateUntil)return;
if(TryFieldPass(p))return;
if(p.Keeper){if(p.Held>=1.1f)DistributeKeeper(p);return;}
if(TryFieldShot(p))return;
float pressure=CarrierPressure(p);
if((p.Position-p.AttackProgressPoint).Length>1.5f)
{p.AttackProgressPoint=p.Position;p.AttackProgressAt=Seconds;}
float attackDistance=(World(new Point(105,34),p.Team)-p.Position).Length;
bool stalled=!RehearsingStrategy && p.Held>1.8f && Seconds-p.AttackProgressAt>1.6f && attackDistance<32 && WindupPlayer<0;
if(stalled && p.ShotSetupUntil<=0)
{
p.KickSetupUntil=0;p.HesitateUntil=0;p.ShieldUntil=0;
p.AttackProgressAt=Seconds;p.AttackProgressPoint=p.Position;
if(attackDistance<=21 && Math.Abs(p.Position.Y-34)<13 && FinishingWindow(p).Lane>.75f)
{p.DecisionReason="Не передерживает · завершает момент";if(SetUpShot(p,attackDistance,pressure))return;}
var forward=new Point(Direction(p.Team),0);
var left=Clamp(p.Position+forward*1.5f+new Point(0,-3));
var right=Clamp(p.Position+forward*1.5f+new Point(0,3));
p.AttackEscapeTarget=PerceivedSpace(p,left)>PerceivedSpace(p,right)?left:right;
p.AttackEscapeUntil=Seconds+1.2f;p.Action="Смещается для обострения";
}
if(p.ShotSetupUntil>0)
{if(SetUpShot(p,(new Point(Direction(p.Team)>0?105:0,34)-p.Position).Length,pressure))return;}
if(p.Held<.2f+(1-p.Profile.Vision)*.18f && !CombinationReceiverReady(p))return;
if(!RehearsingStrategy && ClearFinishingChance(p))
{
p.LastBallDecision=EvaluateBallDecision(p,pressure);p.DecisionReason="Явный голевой момент · завершает атаку";
if(SetUpShot(p,(World(new Point(105,34),p.Team)-p.Position).Length,pressure))return;
}
int combinationThird;
if(TryLegendaryCarry(p))return;
int legendaryReceiver=LegendaryPassChoice(p);
if(legendaryReceiver>=0)
{
p.LastBallDecision=new BallDecision(BallChoice.Pass,legendaryReceiver,0,100,0,"Шах и пас · подготовленное открывание");
p.DecisionReason=p.LastBallDecision.Reason;
ExecutePlayerPass(p,Players[legendaryReceiver],false,pressure);return;
}
int combinationReceiver=ChooseCombinationReceiver(p,pressure,out combinationThird);
bool coordinated=combinationReceiver>=0;
if(!coordinated && WaitingForCombination(p))
{p.Decision=Math.Min(p.Decision,.09f);p.Action="Выжидает открывание третьего";return;}
if(!coordinated && TrySkillMove(p))return;
var evaluation=coordinated
?new BallDecision(BallChoice.Pass,combinationReceiver,0,100,0,"Совместное открывание · быстрый пас")
:SelectActionPlan(p,pressure);
p.LastBallDecision=evaluation;p.DecisionReason=evaluation.Reason;
if(evaluation.Choice==BallChoice.Carry)p.KickSetupUntil=0;
float goalDistance=(World(new Point(105,34),p.Team)-p.Position).Length;
if(!RehearsingStrategy && evaluation.Choice==BallChoice.Shoot && SetUpShot(p,goalDistance,pressure))return;
if(TryStrategyAction(p))return;
if(ClearDanger(p))return;
if(!coordinated && !RehearsingStrategy && TryCross(p))return;
if(evaluation.Choice==BallChoice.Carry && OpenCarryDistance(p)>7 && pressure>3 && goalDistance<40)
{p.Action="Идёт к воротам с мячом";return;}
if(!coordinated && TryAutomaticStrategy(p)){TryStrategyAction(p);return;}
float stress=Math.Clamp((4-pressure)/4,0,1)*(1-p.Profile.Composure)+(1-p.Stamina)*.25f+Math.Max(0,.6f-p.Confidence)*.4f;
// Hesitation is brief and only occurs under pressure; it does not force a turnover.
if(p.Held<1.2f && stress>.35f && Next()<stress*.13f)
{p.Decision+=.18f+(1-p.Profile.Vision)*.2f;p.HesitateUntil=Seconds+p.Decision;Hesitations++;p.Action="Ищет решение";return;}
int dir=Direction(p.Team);
if(evaluation.Choice==BallChoice.Pass && evaluation.Receiver>=0)
{
// Give an attacker a beat to face the defender and try a move before
// releasing an automatic pass at the first reaction tick.
if(!coordinated && !RehearsingStrategy && World(p.Position,p.Team).X>30 && p.Held<.52f*DecisionTempo(p.Team) &&
CarrierPressure(p)<5.2f)
{p.Action="Оценивает защитника";return;}
if(!coordinated && p.Held<.65f*DecisionTempo(p.Team) && pressure>3 && !ClearFinishingChance(p)){p.Action="Оценивает продолжение атаки";return;}
ExecutePlayerPass(p,Players[evaluation.Receiver],coordinated,pressure);
return;
}
if(pressure<3.5f && TryDribble(p))return;
if(pressure<2.8f && p.Profile.Composure>.5f && Seconds>=p.ShieldUntil+.8f)
{
var rival=ActivePlayers.Where(q=>q.Team!=p.Team && Observed(p,q).HasValue).OrderBy(q=>(q.Position-p.Position).Length).FirstOrDefault();
if(rival!=null){p.ShieldDirection=(p.Position-rival.Position).Unit;p.ShieldUntil=Seconds+.5f+.15f*SkillStrength(p,UniqueSkill.PressResistant);ShieldActions++;p.Action="Прикрывает мяч корпусом";return;}
}
p.Action="Ведение";
}
bool ExecutePlayerPass(Footballer p,Footballer q,bool coordinated,float pressure)
{
int dir=Direction(p.Team);
float stress=Math.Clamp((4-pressure)/4,0,1)*(1-p.Profile.Composure)+
(1-p.Stamina)*.25f+Math.Max(0,.6f-p.Confidence)*.4f;
float distance=(q.Position-p.Position).Length;
float speed=GroundPassSpeed(p,distance);
var target=PassMeetingPoint(p,q);
bool chess=LegendaryActive(p,LegendaryAbility.ChessPass) && p.LegendaryTargetPlayerA==q.Id;
if(chess)
{
var run=(q.LegendaryRunTarget-q.Position).Unit;
float travel=distance/Math.Max(1,speed);
target=OnsideRunTarget(q,Clamp(q.Position+run*Math.Min(3.3f,q.Pace*travel*.65f)));
}
if(!PrepareKickFacing(p,target))return false;
float passTechnique=p.Keeper?p.Profile.GkKicking:distance>25?p.Profile.Passing*.35f+p.Profile.LongPassing*.65f:p.Profile.Passing;
float error=.1f+(1-passTechnique)*(.4f+distance*.04f)+stress*.5f;
bool thread=(q.Position.X-p.Position.X)*dir>5 && distance>12 &&
(q.RunUntil>Seconds || q.FootballRun is FootballRun.Behind or FootballRun.Overlap) &&
ActivateSkill(p,UniqueSkill.ThreadNeedle);
bool distributor=!thread && distance>25 && ActivateSkill(p,UniqueSkill.LongDistributor);
if(thread || distributor){error*=1-.32f*p.ActiveSkillPower;speed*=1+.06f*p.ActiveSkillPower;}
float receiveLink=PassingRunLink(p,q);
if(receiveLink>0 && (q.RunUntil>Seconds || q.FootballRunUntil>Seconds && q.FootballRun is FootballRun.Behind or FootballRun.Overlap))
error*=1-.14f*receiveLink;
if(chess){error*=.7f;speed*=1.04f;}
if(coordinated && SkillStrength(p,UniqueSkill.WallPass)>0)error*=1-.12f*SkillStrength(p,UniqueSkill.WallPass);
if(distance<16)error*=.3f+(1-p.Profile.Passing)*.4f;
if(distance<16 && PlayerPlayStyles.Has(p,PlayStyle.TikiTaka))error*=PlayerPlayStyles.Grade(p,PlayStyle.TikiTaka)==PlayStyleGrade.PlayStylePlus?.66f:.78f;
if(distance>=16 && PlayerPlayStyles.Has(p,PlayStyle.IncisivePass) && (q.Position.X-p.Position.X)*dir>5)
error*=PlayerPlayStyles.Grade(p,PlayStyle.IncisivePass)==PlayStyleGrade.PlayStylePlus?.65f:.78f;
if(distance>25 && PlayerPlayStyles.Has(p,PlayStyle.LongBallPass))
error*=PlayerPlayStyles.Grade(p,PlayStyle.LongBallPass)==PlayStyleGrade.PlayStylePlus?.62f:.76f;
target+=new Point((Next()-.5f)*error,(Next()-.5f)*error);
if(IsMediaLeague)ApplyMediaPassError(p,q,ref target,ref speed);
bool loft=distance>16 && PerceivedSpace(p,q.Position)>3 && p.Profile.LongPassing>.48f &&
(PerceivedLane(p,q.Position)<2.2f || (distance>25 && Next()<(PlayerPlayStyles.Grade(p,PlayStyle.LongBallPass) switch
{PlayStyleGrade.PlayStylePlus=>.64f,PlayStyleGrade.PlayStyle=>.56f,_=>.38f})));
Kick(p,target,speed,false);
if(chess)BallLegendaryAbility=LegendaryAbility.ChessPass;
AfterLegendaryPass(p,q);
if(thread || distributor){BallUniqueSkill=thread?UniqueSkill.ThreadNeedle:UniqueSkill.LongDistributor;BallSkillGrade=p.ActiveSkillGrade;}
if(coordinated)CommitCombinationPass(p,q);
if(loft){ShapeFlight(target,StrikeKind.Cross,
distributor && SkillNeighbors(p,q) && SkillStrength(q,UniqueSkill.AerialFinisher)>0 && World(target,p.Team).X>82?1.65f:-1);LoftedPasses++;}
else if(distance>14 && Next()<.3f){VerticalSpeed=1.3f+Next()*.9f;SkippingPasses++;}
PassReceiver=q.Id;IntendedPassTarget=target;Passes[p.Team]++;
RecordLinkedPass(p,q);
if(distance<=16)ShortPasses[p.Team]++;if(distance>25)LongPasses[p.Team]++;
if(q.RunUntil>Seconds && (q.Position.X-p.Position.X)*dir>6)ThroughBalls[p.Team]++;
Status=$"{TeamName(p.Team)} · {(coordinated?"комбинация":"пас")} #{p.Number} → #{q.Number}";
if(!coordinated && distance<16 && Next()<p.Profile.OffBall*.45f)
{p.RunTarget=Clamp(p.Position+new Point(dir*7,(34-p.Position.Y)*.1f));p.RunUntil=Seconds+2;}
return true;
}
Point SupportAndPressure(Footballer p,Point target,int possessionTeam)
{
if(p.Keeper || Owner<0 || WindupPlayer==p.Id || DuelDefender==p.Id)return target;
var carrier=Players[Owner];float distance=(p.Position-carrier.Position).Length;
if(p.Team!=possessionTeam && !carrier.Keeper && p.Id==chasers[p.Team] && distance<18)
{
var goal=World(new Point(0,34),p.Team);
p.Action="Сближается с мячом";
return distance>2.5f?carrier.Position+(goal-carrier.Position).Unit*.65f:Ball;
}
if(p.Id==Owner || p.Team!=possessionTeam || carrier.Keeper || p.RunUntil>Seconds)return target;
var support=ActivePlayers.Where(q=>q.Team==p.Team && !q.Keeper && q.Id!=Owner && q.RunUntil<=Seconds && Seconds>=q.FallUntil)
.OrderBy(q=>(q.Position-carrier.Position).Length).Take(2).ToArray();
int slot=Array.FindIndex(support,q=>q.Id==p.Id);
if(slot<0 || distance>26)return target;
float side=p.Position.Y<carrier.Position.Y?-1:1;
float forward=World(carrier.Position,p.Team).X<35 && slot==0?3:slot==0?-5:7;
var option=Clamp(carrier.Position+new Point(Direction(p.Team)*forward,side*(slot==0?7:11)));
float awareness=TeamAwareness(p.Team);
if(awareness>.5f)
{
Point best=option;float value=float.MinValue;
foreach(float depth in slot==0?new[]{-7f,-4f,1f}:new[]{3f,7f,10f})
foreach(float width in new[]{6f,9f,12f})
{
var at=Clamp(carrier.Position+new Point(Direction(p.Team)*depth,side*width));
float safety=PassSafety(carrier,p,at);
float score=safety*7+Math.Min(PerceivedSpace(p,at),7)*1.2f+
depth*.12f-(p.Position-at).Length*.12f;
if(safety<.37f)score-=8;
if(score>value){value=score;best=at;}
}
float blend=Math.Clamp((awareness-.5f)*2.5f,0,.85f);
option=Clamp(option*(1-blend)+best*blend);
}
if(ActivePlayers.Where(q=>q.Team!=p.Team).Min(q=>(q.Position-option).Length)<2.5f)option=Clamp(option+new Point(-Direction(p.Team)*3,side*2));
p.Action="Открывается под короткий пас";
return option;
}
}