Ball decision logic for the soccer match simulation. Picks carry, pass or shoot by scoring finishing lanes, pressure, open space and teammate pass value.
using System;
using System.Linq;
namespace Socer;
public enum BallChoice { Carry, Pass, Shoot }
public readonly record struct BallDecision(BallChoice Choice,int Receiver,float Carry,float Pass,float Shot,string Reason);
public sealed partial class MatchSimulation
{
(Point Target,float Lane) FinishingWindow(Footballer p,float? contactTechnique=null)
{
Point best=World(new Point(105,34),p.Team);float bestValue=-100,bestLane=0;
float technique=Math.Clamp(contactTechnique??p.Profile.Finishing,0,1);
// The reliable finishing corridor comes from technique, not OVR. Less
// precise contacts favour an inboard corner; excellent technique can
// address the space beside the post without gaining impossible reach.
float edge=1.8f+technique*1.42f;
foreach(float y in new[]{34-edge,34-edge*.72f,32.6f,34f,35.4f,34+edge*.72f,34+edge})
{
var target=World(new Point(105,y),p.Team);var direction=(target-p.Position).Unit;
float distance=(target-p.Position).Length,lane=5,keeperCover=0;
foreach(var q in ActivePlayers.Where(q=>q.Team!=p.Team))
{
var seen=Observed(p,q);if(!seen.HasValue)continue;
float along=Point.Dot(seen.Value-p.Position,direction);
// A pursuer behind the striker pressures him, but cannot close every shot lane.
if(along<.35f || along>distance+.3f)continue;
float clearance=DistanceToSegment(seen.Value,p.Position,target);
if(q.Keeper)
{
// Judge the keeper where the ball crosses his line, not by his
// distance to the shooter. A displaced keeper exposes the far corner.
float atKeeper=distance>.1f?Math.Clamp(along/distance,0,1):0;
float flight=Math.Clamp(distance/29,.1f,.8f);
float read=p.Profile.Composure*.55f+p.Profile.Vision*.45f;
float keeperY=seen.Value.Y+Math.Clamp(q.Velocity.Y*flight*read,-.55f,.55f);
float lateral=Math.Abs(keeperY-(p.Position.Y+(target.Y-p.Position.Y)*atKeeper));
keeperCover=Math.Max(0,2.15f-lateral)*1.65f-Math.Min(lateral,5)*.14f;
}
else lane=Math.Min(lane,clearance);
}
float value=Math.Min(lane,2.5f)-keeperCover-Math.Abs(y-34)*.075f-Math.Max(0,.6f-lane)*8;
if(value>bestValue){bestValue=value;best=target;bestLane=lane;}
}
return(best,bestLane);
}
// Training can reproduce an authored route; live routines yield to a clear finish.
bool ClearFinishingChance(Footballer p)
{
var goal=World(new Point(105,34),p.Team);
float distance=(goal-p.Position).Length;
if(distance>23 || Math.Abs(p.Position.Y-34)>10 || World(p.Position,p.Team).X>104)return false;
var direction=(goal-p.Position).Unit;
if(FinishingWindow(p).Lane<.85f)return false;
if(distance<16)return true;
var keeper=ActivePlayers.FirstOrDefault(q=>q.Team!=p.Team && q.Keeper);
var known=keeper==null?null:Observed(p,keeper);
return known.HasValue && (Point.Dot(known.Value-p.Position,direction)<0 || DistanceToSegment(known.Value,p.Position,goal)>4);
}
Point PassMeetingPoint(Footballer p,Footballer q)
{
float distance=(q.Position-p.Position).Length;
bool run=q.RunUntil>Seconds || (q.FootballRunUntil>Seconds && q.FootballRun is FootballRun.Behind or FootballRun.Overlap) || q.BoxRole is 1 or 2;
float time=distance/GroundPassSpeed(p,distance);
float synergy=run?PassingRunLink(p,q):0;
// A linked passer aims where the partner will arrive, with a bounded lead.
return Clamp(q.Position+q.Velocity*Math.Clamp(time*(run?.85f+synergy*.07f:.45f),0,run?1.15f:.65f));
}
Point ReceptionMeeting(Footballer p)
{
var at=Ball;var velocity=BallVelocity;float height=BallHeight,vertical=VerticalSpeed,spin=BallSpin;
float pace=Math.Max(1,EffectivePace(p));var best=at;float bestMiss=float.MaxValue;
const float dt=.06f;
for(float time=dt;time<=4;time+=dt)
{
float angle=spin*dt,c=MathF.Cos(angle),s=MathF.Sin(angle);
velocity=new Point(velocity.X*c-velocity.Y*s,velocity.X*s+velocity.Y*c);spin*=MathF.Exp(-dt*.4f);
at+=velocity*dt;
if(height>0 || vertical>0)
{
height+=vertical*dt-4.905f*dt*dt;vertical-=9.81f*dt;
if(height<=0){height=0;vertical=-vertical*.32f;if(vertical<.7f)vertical=0;velocity*=.83f;}
}
velocity*=MathF.Exp(-dt*(height>.05f?.08f:.28f));
if(at.X<0 || at.X>105 || at.Y<0 || at.Y>68)break;
if(height>1.5f || (height>.85f && vertical>0))continue;
var delta=at-p.Position;
float turnDelay=.10f+Math.Max(0,pace-Point.Dot(p.Velocity,delta.Unit))/(4+p.Profile.Acceleration*5)*.35f;
float miss=delta.Length-Math.Max(0,time-turnDelay)*pace;
if(miss<bestMiss){bestMiss=miss;best=at;}
if(miss<=.45f)return at;
}
return Clamp(best);
}
float PassSafety(Footballer p,Footballer q,Point target)
{
var delta=target-p.Position;float distance=delta.Length;
float speed=GroundPassSpeed(p,distance);
float margin=6;
foreach(var rival in ActivePlayers.Where(r=>r.Team!=p.Team))
{
var observed=Observed(p,rival);if(!observed.HasValue)continue;
for(int sample=1;sample<=4;sample++)
{
float t=sample*.25f;
float arrival=distance*t/speed;
var intercept=p.Position+delta*t;
// Reaction and acceleration matter: compare arrival envelopes, not just
// whether somebody happens to stand on the drawn line right now.
float reach=.55f+Math.Max(0,arrival-.22f)*rival.Pace*.65f;
margin=Math.Min(margin,(observed.Value-intercept).Length-reach);
}
}
return Math.Clamp(.5f+margin*.22f,.02f,1);
}
float OpenCarryDistance(Footballer p)
{
var forward=(World(new Point(100,34),p.Team)-p.Position).Unit;
float open=12;
foreach(var q in ActivePlayers.Where(q=>q.Team!=p.Team && !q.Keeper))
{
var seen=Observed(p,q);if(!seen.HasValue)continue;
var delta=seen.Value-p.Position;float along=Point.Dot(delta,forward);
if(along>0 && along<open && (delta-forward*along).Length<2.8f)open=Math.Max(0,along-1.5f);
}
return open;
}
BallDecision EvaluateBallDecision(Footballer p,float pressure)
{
var goal=World(new Point(105,34),p.Team);float distance=(goal-p.Position).Length;
float lane=FinishingWindow(p).Lane,open=OpenCarryDistance(p);
float danger=Math.Clamp((3.4f-pressure)/3.4f,0,1);
float longQuality=p.Profile.LongShots*.55f+p.Profile.ShotPower*.3f+p.Profile.Composure*.15f;
float carry=3+Math.Min(pressure,6)*1.1f+open*.9f+(distance<38 && Math.Abs(p.Position.Y-34)<20?3:0);
float shot=distance<40?25-distance*.85f+(distance>22?longQuality:p.Profile.Finishing)*5-
Math.Max(0,2-lane)*7-Math.Abs(p.Position.Y-34)*.22f:-100;
if(distance<13 && lane>1.5f)shot+=9;
if(distance<18 && lane>1 && Seconds-p.LastControlAt<1.2f)shot+=2.2f*SkillStrength(p,UniqueSkill.Poacher);
if(distance<23 && lane>1 && Math.Abs(p.Position.Y-34)<12)shot+=10;
if(distance>=18 && distance<34 && lane>1.2f && Math.Abs(p.Position.Y-34)<14)
shot+=longQuality*6+AttackUrgency(p.Team)*9;
// A good lane at the edge of the box is temporary. If a defender blocks
// further progress, finish before he closes it instead of recycling a
// safe sideways pass. An unchallenged carrier still advances closer.
if(distance is >15 and <23 && Math.Abs(p.Position.Y-34)<12 && lane>1.05f &&
open<5.5f && pressure<4.5f)
shot+=6+p.Profile.Finishing*3;
// Keep progressing into a better shooting window when there is an open
// route. Distant attempts remain possible for a specialist under pressure
// or when chasing the score late in the match.
if(distance>21 && pressure>3.2f && open>4.5f)
shot-=11+Math.Min(open,10)*.8f;
if(distance>24 && (longQuality<.68f || lane<1.15f))shot-=15;
if(distance>29 && danger<.2f && AttackUrgency(p.Team)<.8f)shot-=30;
if(distance>18 && pressure>2.8f && open>5 && AttackUrgency(p.Team)<.65f)carry+=5;
float stale=p.Held>1.5f && p.AttackProgressAt>0?Math.Clamp(Seconds-p.AttackProgressAt-1,0,3):0;
carry-=stale*2.5f;
if(distance<38 && Math.Abs(p.Position.Y-34)<18 && lane>.5f)
shot+=stale*(1.5f+p.Profile.Finishing)+Math.Max(0,3-pressure)*(.6f+p.Frustration);
if(BoundaryMargin(p)<1.6f)carry-=4;
if(ClearFinishingChance(p))shot=70;
var best=ActivePlayers.Where(q=>q.Team==p.Team && q.Id!=p.Id && !q.Keeper)
.Select(q=>new{Player=q,Value=PassValue(p,q,pressure)})
.OrderByDescending(q=>q.Value).FirstOrDefault();
float pass=best?.Value??-100;
// A small advantage is not enough to give away a clean run towards goal.
var choice=shot>Math.Max(carry,pass)?BallChoice.Shoot:pass>carry+1?BallChoice.Pass:BallChoice.Carry;
string reason=choice==BallChoice.Shoot?"Есть коридор для удара":choice==BallChoice.Pass?"Свободный адресат и безопасная линия":open>6?"Свободный путь к воротам":"Продвигается с мячом";
return new(choice,best?.Player.Id??-1,carry,pass,shot,reason);
}
bool PrepareKickFacing(Footballer p,Point target)
{
if(p.Keeper || Owner!=p.Id)return true;
var aim=(target-p.Position).Unit;
if(Point.Dot(new Point(MathF.Cos(p.Facing),MathF.Sin(p.Facing)),aim)>=.68f)
{p.KickSetupUntil=0;return true;}
p.KickAim=target;p.KickSetupUntil=Seconds+1.8f;p.Decision=.09f;
p.Action="Разворачивается для передачи";return false;
}
}