Part of the soccer match simulation (MatchSimulation partial class). Chooses short-lived action plans per player (carry, pass or shoot) with bounded randomness, plans carry targets and dribble detours around defenders, keeps the carrier away from the touchlines, and assigns first/second/third defender roles for coordinated defending. Also holds counters for selected/interrupted plans and boundary corrections.
using System;
using System.Linq;
namespace Socer;
// Short, interruptible intentions. Only selection consumes randomness; movement and rendering do not.
public sealed partial class MatchSimulation
{
public int PlansSelected,PlansInterrupted,BoundaryCorrections;
readonly int[] coverPlayer={-1,-1},screenPlayer={-1,-1},defenceOwner={-2,-2};
readonly float[] defenceUntil=new float[2];
static Point InsidePitch(Point p,float margin=1.2f)=>new(Math.Clamp(p.X,margin,105-margin),Math.Clamp(p.Y,margin,68-margin));
float BoundaryMargin(Footballer p)=>Math.Min(Math.Min(p.Position.X,105-p.Position.X),Math.Min(p.Position.Y,68-p.Position.Y));
float CarryLane(Footballer p,Point target)
{
var delta=target-p.Position;float length=delta.Length;var axis=delta.Unit;float lane=8;
foreach(var rival in ActivePlayers.Where(q=>q.Team!=p.Team))
{
var seen=Observed(p,rival);if(!seen.HasValue)continue;
var offset=seen.Value-p.Position;float along=Point.Dot(offset,axis);
if(along<-.3f || along>length+.4f)continue;
lane=Math.Min(lane,DistanceToSegment(seen.Value,p.Position,target));
}
return lane;
}
Point AvoidCarrierObstacle(Footballer p,Point target)
{
if(Owner!=p.Id || p.Keeper || RehearsingStrategy || WindupPlayer==p.Id ||
p.ShotSetupUntil>0 || Seconds<p.KickSetupUntil || Seconds<p.ShieldUntil)return target;
if(p.CarryAvoidUntil>Seconds && (p.CarryAvoidTarget-p.Position).Length>.7f && CarryLane(p,p.CarryAvoidTarget)>1.05f)
return p.CarryAvoidTarget;
var forward=(target-p.Position).Unit;
if(forward.Length<.1f)return target;
float lookahead=Math.Clamp(2.5f+p.Velocity.Length*.55f,3,6);
var ahead=InsidePitch(p.Position+forward*lookahead);
if(CarryLane(p,ahead)>1.8f){p.CarryAvoidUntil=0;return target;}
var side=new Point(-forward.Y,forward.X);Point best=p.Position;float value=-100;
foreach(float angle in new[]{-.5f,.5f,-.9f,.9f,-1.3f,1.3f})
{
var aim=InsidePitch(p.Position+(forward*MathF.Cos(angle)+side*MathF.Sin(angle))*3.5f);
float lane=CarryLane(p,aim);
float score=Math.Min(lane,2.3f)*4+Math.Min(PerceivedSpace(p,aim),3)+Point.Dot(aim-p.Position,forward)*1.4f-Math.Abs(angle)*1.2f;
if(lane<1.25f)score-=15;
if(score>value){value=score;best=aim;}
}
p.CarryAvoidTarget=value>0?best:p.Position;
p.CarryAvoidUntil=Seconds+.7f;
p.DecisionReason=value>0?"Обходит защитника через свободное плечо":"Проход закрыт · ищет передачу";
return p.CarryAvoidTarget;
}
Point PlanCarryPoint(Footballer p)
{
var local=World(p.Position,p.Team);
bool wide=local.X<94 && Math.Abs(local.Y-34)>17;
var forward=wide?new Point(Direction(p.Team),0):(World(new Point(99,34),p.Team)-p.Position).Unit;
var side=new Point(-forward.Y,forward.X);
float reach=OpenCarryDistance(p)>7?11.5f:4;
Point best=InsidePitch(p.Position+forward*reach);float value=float.MinValue;
foreach(float offset in new[]{0f,-2.8f,2.8f,-5f,5f})
{
var at=InsidePitch(p.Position+forward*reach+side*offset);
float lane=CarryLane(p,at),space=PerceivedSpace(p,at);
float score=Math.Min(space,6)+Math.Min(lane,3)*2+(at.X-p.Position.X)*Direction(p.Team)*.7f-Math.Abs(offset)*.25f;
score-=Math.Max(0,1-(at-p.Position).Length)*8;
score-=Math.Max(0,1.7f-lane)*10;
if(score>value){value=score;best=at;}
}
return best;
}
BallDecision SelectActionPlan(Footballer p,float pressure)
{
var now=EvaluateBallDecision(p,pressure);
if(RehearsingStrategy)return now;
bool clear=ClearFinishingChance(p);
if(p.PlanUntil>Seconds && !clear)
{
bool valid=p.PlanChoice==BallChoice.Carry?(p.PlanTarget-p.Position).Length>.7f && PerceivedLane(p,p.PlanTarget)>.65f:
p.PlanChoice==BallChoice.Pass && p.PlanReceiver>=0 && !Players[p.PlanReceiver].SentOff &&
SeesPartner(p,Players[p.PlanReceiver]) && IsOnside(Players[p.PlanReceiver],p.Position) &&
PassSafety(p,Players[p.PlanReceiver],PassMeetingPoint(p,Players[p.PlanReceiver]))>.4f;
if(valid && now.Shot<Math.Max(now.Carry,now.Pass)+8)
return now with{Choice=p.PlanChoice,Receiver=p.PlanReceiver,Reason=p.PlanReason};
PlansInterrupted++;p.KickSetupUntil=0;
}
var choice=now.Choice;
// Bounded, personality-dependent error is sampled once per intention, never every frame.
float uncertainty=(1-p.Profile.Composure)*3+(1-p.Profile.Vision)*2;
float carry=now.Carry+(Next()-.5f)*uncertainty;
float pass=now.Pass+(Next()-.5f)*uncertainty;
float shotValue=now.Shot+(Next()-.5f)*uncertainty+p.Frustration*2;
if(!clear)choice=shotValue>Math.Max(carry,pass)?BallChoice.Shoot:pass>carry+1?BallChoice.Pass:BallChoice.Carry;
p.PlanChoice=choice;p.PlanReceiver=now.Receiver;p.PlanTarget=choice==BallChoice.Carry?PlanCarryPoint(p):FinishingWindow(p).Target;
p.PlanUntil=Seconds+(choice==BallChoice.Pass?.85f:.65f)*DecisionTempo(p.Team);
p.PlanReason=choice==BallChoice.Shoot?(now.Shot<now.Carry?"Рискует ударить под давлением":"Выбрал завершение атаки"):
choice==BallChoice.Pass?"Нашёл адресата · готовит передачу":"Выбрал свободный коридор для продвижения";
PlansSelected++;return now with{Choice=choice,Reason=p.PlanReason};
}
Point SafeCarrierMovement(Footballer p,Point target)
{
if(Owner!=p.Id || p.Keeper || WindupPlayer==p.Id || RehearsingStrategy)return target;
// Recover the actual loose touch first; an inward safety waypoint must not
// strand the owner two metres away from a stationary ball beside the line.
if(p.ChasingTouch && (Ball.X<2.4f || Ball.X>102.6f || Ball.Y<2.4f || Ball.Y>65.6f) && Ball.X>=0 && Ball.X<=105 && Ball.Y>=0 && Ball.Y<=68)
return InsidePitch(Ball,.15f);
var predicted=p.Position+p.Velocity*(.25f+p.Velocity.Length/14);
if(predicted.X<1.1f || predicted.X>103.9f || predicted.Y<1.1f || predicted.Y>66.9f)
{
p.DecisionReason="Берегёт мяч у линии · тормозит и уходит внутрь";
p.Action="Разворачивается от линии";
return InsidePitch(target,2.4f);
}
return InsidePitch(target,.85f);
}
Point LimitDribbleImpulse(Point velocity)
{
// Rolling drag is 2.4/s. Bound the *intentional* touch's stopping point,
// not the ball position: tackles, passes and rebounds may still go out.
const float drag=2.4f;
var safe=new Point(Math.Clamp(velocity.X,(.2f-Ball.X)*drag,(104.8f-Ball.X)*drag),
Math.Clamp(velocity.Y,(.2f-Ball.Y)*drag,(67.8f-Ball.Y)*drag));
if((safe-velocity).Length>.01f)BoundaryCorrections++;
return safe;
}
Point CoordinatedDefence(Footballer p,Point target)
{
if(Owner<0 || Players[Owner].Team==p.Team || Players[Owner].Keeper || p.Keeper ||
p.DirectOrder!=DirectOrderKind.None || DuelDefender==p.Id || Seconds<p.FallUntil || p.BlockUntil>Seconds)return target;
int team=p.Team;var carrier=Players[Owner];
// A dangerous receiver must not be abandoned for generic ball-side cover.
if(p.AutoMarkId>=0 && p.AutoMarkId!=Owner && !Players[p.AutoMarkId].SentOff &&
Observed(p,Players[p.AutoMarkId]) is Point marked && (marked-World(new Point(0,34),team)).Length<32)
return target;
bool Invalid(int id)=>id<0 || Players[id].SentOff || Seconds<Players[id].FallUntil || id==chasers[team];
if(Seconds>=defenceUntil[team] || defenceOwner[team]!=Owner || Invalid(coverPlayer[team]) || Invalid(screenPlayer[team]))
{
var goal=World(new Point(0,34),team);var anchor=carrier.Position+(goal-carrier.Position).Unit*4;
var available=ActivePlayers.Where(q=>q.Team==team && !q.Keeper && q.Id!=chasers[team] && q.DirectOrder==DirectOrderKind.None && Seconds>=q.FallUntil)
.OrderBy(q=>(q.Position-anchor).Length-q.Profile.Defence*2).ToArray();
coverPlayer[team]=available.FirstOrDefault()?.Id??-1;
screenPlayer[team]=available.Skip(1).FirstOrDefault()?.Id??-1;
defenceUntil[team]=Seconds+Math.Clamp(1.2f-TeamAwareness(team)*.85f,.42f,.85f);
defenceOwner[team]=Owner;
}
if(p.Id==chasers[team]){p.DecisionReason="Первый защитник · сдержать и выбрать момент отбора";return target;}
if((carrier.Position-p.Position).Length>25)return target;
if(p.Id==coverPlayer[team])
{
var goal=World(new Point(0,34),team);
p.Action="Страхует прорыв";p.DecisionReason="Второй защитник · между мячом и воротами";
var anticipated=carrier.Position+carrier.Velocity*(.08f+p.Profile.Defence*.18f);
return InsidePitch(anticipated+(goal-anticipated).Unit*4+new Point(0,carrier.Position.Y<34?1.8f:-1.8f));
}
if(p.Id==screenPlayer[team])
{
var receiver=ActivePlayers.Where(q=>q.Team==carrier.Team && !q.Keeper && q.Id!=carrier.Id && Observed(p,q).HasValue)
.OrderBy(q=>(Observed(p,q).Value-carrier.Position).Length*.65f+
(Observed(p,q).Value-World(new Point(0,34),team)).Length*.18f).FirstOrDefault();
if(receiver!=null)
{
var predicted=Observed(p,receiver).Value+receiver.Velocity*(.08f+p.Profile.Interceptions*.17f);
p.Action="Перекрывает передачу";p.DecisionReason="Третий защитник · закрыть видимого адресата";
return InsidePitch(carrier.Position+(predicted-carrier.Position)*.55f);
}
}
return target;
}
}