Defensive support logic for the soccer match simulation. Finds a linked presser, calculates cover positions and pass-lane interceptions, and records support arrivals, regains and contacts.
using System;
namespace Socer;
public sealed partial class MatchSimulation
{
// These are completed support episodes, not frames spent displaying an action.
public int[] SkillCoverArrivals=new int[2],SkillCoverRegains=new int[2],SkillCoverInterceptions=new int[2];
public int[] SkillCoverLaneOccupations=new int[2],SkillCoverPassInterceptions=new int[2];
struct SkillCoverSupport
{
public bool Present,Arrived,Regained,FollowingPass,Contacted,SpecificLane,LaneOccupied;
public int Presser,Source,PossessionTeam,LaneReceiver;
public Point Aim,Meeting,LaneStart,LaneEnd;
public float Started,Until,NextDecision,AtAim,RecoveryUntil,InitialDistance,ContactOutcomeUntil,AtLane;
}
readonly SkillCoverSupport[] skillCoverSupport=new SkillCoverSupport[22];
Footballer FindLinkedCoverPresser(Footballer cover,Footballer carrier,out float synergy)
{
synergy=0;Footballer chosen=null;float best=float.MaxValue;
if(!Observed(cover,carrier).HasValue)return null;
for(int i=0;i<Players.Count;i++)
{
var candidate=Players[i];
if(candidate.Keeper || candidate.Id==cover.Id || !SkillNeighbors(cover,candidate) ||
candidate.Profile.RoleFamiliarity<.7f || Seconds<candidate.FallUntil ||
!SeesPartner(cover,candidate) || !CanObserve(candidate,carrier))continue;
float distance=(candidate.Position-carrier.Position).Length;
if(distance>8 || (candidate.Position-cover.Position).Length>22)continue;
bool duel=DuelDefender==candidate.Id && DuelCarrier==carrier.Id;
bool ordered=candidate.DirectOrderUntil>Seconds && candidate.DirectTargetId==carrier.Id &&
candidate.DirectOrder is DirectOrderKind.PressPlayer or DirectOrderKind.MarkPlayer;
bool chasing=candidate.Id==chasers[cover.Team] ||
candidate.Id==secondChasers[cover.Team] &&
(counterPressUntil[cover.Team]>Seconds || Tactics[cover.Team]==2);
// A skill-equipped neighbour already within challenge range is a real
// pressure participant even while the global chaser is another player.
// A distant marked player or a merely nearby idle teammate is not.
if(!duel && !ordered && !chasing && distance>2.7f)continue;
if(!duel && !ordered && !chasing && carrier.Velocity.Length>2 &&
Point.Dot((candidate.Position-carrier.Position).Unit,carrier.Velocity.Unit)<-.55f)continue;
float power=Math.Max(SkillStrength(candidate,UniqueSkill.SteelPress),SkillStrength(candidate,UniqueSkill.Interceptor));
if(power<=0)continue;
float score=distance-(duel?1.2f:ordered?.8f:chasing?.6f:0)-power*.25f;
if(score<best){best=score;chosen=candidate;synergy=power;}
}
return chosen;
}
bool DangerousCoverMark(Footballer p,out Footballer runner,out Point known)
{
runner=null;known=new();
if(p.AutoMarkId<0 || p.AutoMarkId>=Players.Count || p.AutoMarkId==Owner)return false;
var marked=Players[p.AutoMarkId];
if(marked.SentOff || marked.Team==p.Team || marked.Keeper)return false;
var observed=Observed(p,marked);
if(!observed.HasValue || (observed.Value-World(new Point(0,34),p.Team)).Length>=32)return false;
runner=marked;known=observed.Value;return true;
}
Point LinkedCoverTarget(Footballer p,Point ordinary,Footballer presser,Point source,int sourceId,
int possessionTeam,float cover,float synergy,bool interception=false)
{
ref var support=ref skillCoverSupport[p.Id];
bool changed=!support.Present || support.Presser!=presser.Id || support.Source!=sourceId ||
support.PossessionTeam!=possessionTeam || Seconds>support.Until;
if(changed)
{
support=new SkillCoverSupport{Present=true,Presser=presser.Id,Source=sourceId,
PossessionTeam=possessionTeam,Started=Seconds};
}
if(changed || Seconds>=support.NextDecision)
{
support.NextDecision=Seconds+.25f;
var goal=World(new Point(0,34),p.Team);
var goalward=(goal-source).Unit;
int previousReceiver=support.SpecificLane?support.LaneReceiver:-1;
support.SpecificLane=false;
Point aim;
if(DangerousCoverMark(p,out var marked,out var observed))
{
// A partner's pressure never licenses abandoning an already dangerous runner.
var anticipated=observed+(CanObserve(p,marked)?marked.Velocity:new Point())*
(.15f+.14f*Math.Clamp(p.Profile.Interceptions*synergy,0,1.35f));
// The cover player's existing runner remains their responsibility.
// Orient the support on the runner's goal-side receiving shoulder,
// rather than blending back to a stale generic formation target.
aim=Clamp(anticipated+(goal-anticipated).Unit*1.05f);
}
else
{
var predictedSource=source;
if(sourceId>=0 && sourceId<Players.Count && CanObserve(p,Players[sourceId]))
predictedSource+=Players[sourceId].Velocity*(.12f+.11f*p.Profile.Defence);
aim=interception?presser.Position+(goal-presser.Position).Unit*(3.8f-.5f*cover):
predictedSource+(goal-predictedSource).Unit*(3.5f-cover*.45f)+
new Point(0,source.Y<34?.65f:-.65f);
float best=float.MinValue;
float sourceDepth=World(source,p.Team).X;
// A bounded scan of observed threats; no allocations, random draws or omniscient positions.
for(int i=0;i<Players.Count;i++)
{
var threat=Players[i];
if(threat.Team==p.Team || threat.Keeper || threat.SentOff || threat.Id==sourceId ||
Seconds<threat.FallUntil)continue;
var known=Observed(p,threat);if(!known.HasValue)continue;
var local=World(known.Value,p.Team);
float laneLength=(known.Value-source).Length;
if(local.X>sourceDepth+2 || local.X>56 || Math.Abs(local.Y-34)>23 ||
laneLength<3.5f || laneLength>26)continue;
var anticipated=known.Value+(CanObserve(p,threat)?threat.Velocity:new Point())*
(.15f+.13f*Math.Clamp(p.Profile.Interceptions*synergy,0,1.35f));
var passLine=anticipated-source;
// Close the nearest useful point in the lane. Choosing only
// the ordinary formation target's depth made a cover player
// run along an already reachable passing line as the ball passed.
float fraction=Math.Clamp(Point.Dot(p.Position-source,passLine)/
Math.Max(.01f,Point.Dot(passLine,passLine)),.38f,.82f);
var lane=source+passLine*fraction;
float travel=(lane-p.Position).Length;
if(travel>12)continue;
bool covered=false;
for(int j=0;j<Players.Count;j++)
{
var teammate=Players[j];
if(teammate.Team!=p.Team || teammate.Id==p.Id || teammate.Id==presser.Id ||
teammate.Keeper || teammate.SentOff || Seconds<teammate.FallUntil)continue;
if(teammate.AutoMarkId==threat.Id && (teammate.Position-known.Value).Length<2.3f ||
(teammate.Position-lane).Length<1.8f){covered=true;break;}
ref var teammateSupport=ref skillCoverSupport[teammate.Id];
if(teammateSupport.Present && teammateSupport.SpecificLane && teammateSupport.Until>Seconds &&
teammateSupport.LaneReceiver==threat.Id && (teammateSupport.Aim-lane).Length<2.4f)
{covered=true;break;}
}
if(covered)continue;
float danger=(sourceDepth-local.X)*.20f+(56-local.X)*.10f-
Math.Abs(local.Y-34)*.06f-travel*.24f+(threat.Id==PassReceiver?3:0)+
(threat.Id==previousReceiver?1.1f:0);
if(danger>best)
{
best=danger;aim=lane;support.SpecificLane=true;
support.LaneStart=source;support.LaneEnd=anticipated;support.LaneReceiver=threat.Id;
}
}
// The linked player commits to the uncovered lane instead of crowding the ball presser.
if(!support.SpecificLane)
{
float commitment=Math.Clamp(.57f*cover+.16f*synergy,.50f,.88f);
if(World(source,p.Team).X<36)commitment=Math.Max(commitment,.9f);
aim=ordinary*(1-commitment)+aim*commitment;
}
aim=Clamp(aim);
}
if(!support.SpecificLane || previousReceiver!=support.LaneReceiver)support.AtLane=0;
support.Aim=aim;
if(changed)support.InitialDistance=(aim-p.Position).Length;
}
support.Until=Seconds+.75f;
float distance=(support.Aim-p.Position).Length;
if(distance>2.2f && distance<11 && World(source,p.Team).X<56)
support.RecoveryUntil=Seconds+.35f;
p.Action=interception?"Страхует выход партнёра на перехват":"Закрывает проход за прессингом";
p.DecisionReason="Связка скиллов · партнёр идёт на мяч, подстраховка закрывает оставленную линию";
return support.Aim;
}
internal bool LinkedCoverRecovery(Footballer p)
{
if(p.Id<0 || p.Id>=skillCoverSupport.Length || p.Keeper || p.SentOff ||
Seconds<p.FallUntil || p.DirectOrderUntil>Seconds)return false;
ref var support=ref skillCoverSupport[p.Id];
return support.Present && support.Until>Seconds && support.RecoveryUntil>Seconds &&
(Owner>=0?Players[Owner].Team:lastPossessionTeam>=0?lastPossessionTeam:LastTouch)!=p.Team &&
((support.FollowingPass?support.Meeting:support.Aim)-p.Position).Length is >1.8f and <11 &&
SkillNeighbors(p,Players[support.Presser]);
}
bool FollowLinkedCoverPass(Footballer p,Point ordinary,out Point target)
{
target=ordinary;
if(Owner>=0 || ShotInFlight || p.Id<0 || p.Id>=skillCoverSupport.Length ||
p.Id==PassReceiver && LastTouch==p.Team || Seconds<p.InterceptUntil || BallHeight>1.4f ||
BallSpeed<.5f)return false;
ref var support=ref skillCoverSupport[p.Id];
int possessionTeam=lastPossessionTeam>=0?lastPossessionTeam:LastTouch;
if(!support.Present || Seconds>support.Until || possessionTeam==p.Team ||
possessionTeam!=support.PossessionTeam || !SkillNeighbors(p,Players[support.Presser]))return false;
// First commit only to a nearby opponent pass through the lane already being covered.
// A rebound off our own body remains recoverable during this same bounded episode.
if(!support.FollowingPass && (LastTouch==p.Team || Passer<0 || Passer!=support.Source))return false;
var flight=Ball+BallVelocity*1.1f;
if(DistanceToSegment(p.Position,Ball,flight)>2.5f &&
DistanceToSegment(support.Aim,Ball,flight)>2.5f)return false;
if(!support.FollowingPass)
{
// Recognise the approaching lane before the ball enters immediate contest range.
// Holding the cover episode does not itself override marking or start a chase.
support.FollowingPass=true;
support.Meeting=support.Aim;
support.Until=Seconds+1.6f;
}
if((Ball-p.Position).Length>9)
{
if(DangerousCoverMark(p,out var waitingMark,out var waitingKnown) &&
(waitingMark.Id!=PassReceiver || World(support.Aim,p.Team).X>World(waitingKnown,p.Team).X+3.5f))return false;
if((support.Aim-p.Position).Length>4 || World(support.Aim,p.Team).X<2)return false;
p.Action="Держит линию паса за прессингом";
p.DecisionReason="Связка скиллов · сохраняет перекрытый проход до подхода мяча";
target=support.Aim;return true;
}
float crossingTime=Point.Dot(p.Position-Ball,BallVelocity)/
Math.Max(.01f,Point.Dot(BallVelocity,BallVelocity));
var crossing=Ball+BallVelocity*Math.Clamp(crossingTime,0,.9f);
float lateralTravel=(crossing-p.Position).Length;
float arrive=lateralTravel/Math.Max(1,EffectivePace(p))+.10f;
// Cut across the pass to the closest reachable point in its lane first.
// Chasing a far future meeting point turns a short interception into a race after the receiver.
var meeting=crossingTime is >.04f and <.9f && lateralTravel<3 && arrive<=crossingTime+.08f?
Clamp(crossing):LooseBallMeeting(p);
if((meeting-p.Position).Length>8.5f)
{
float time=Math.Clamp(Point.Dot(p.Position-Ball,BallVelocity)/
Math.Max(.01f,Point.Dot(BallVelocity,BallVelocity)),0,.55f);
meeting=Clamp(Ball+BallVelocity*time);
}
if((meeting-p.Position).Length>8.5f || World(meeting,p.Team).X<2)return false;
if(DangerousCoverMark(p,out var marked,out var known))
{
// Keep the goal-side mark unless this is the marked player's actual pass.
// Then a short step into that pass is the purpose of the cover, not abandoning another runner.
if(marked.Id!=PassReceiver || World(meeting,p.Team).X>World(known,p.Team).X+3.5f)return false;
}
support.Meeting=meeting;
if((meeting-p.Position).Length>1.8f)support.RecoveryUntil=Seconds+.35f;
p.Action="Встречает пас за прессингом";
p.DecisionReason="Связка скиллов · закрытая линия стала передачей, идёт к доступному мячу";
target=meeting;return true;
}
public void ObserveSkillCover(float dt)
{
if(dt<=0)return;
int possessionTeam=Owner>=0?Players[Owner].Team:lastPossessionTeam>=0?lastPossessionTeam:LastTouch;
for(int i=0;i<Players.Count && i<skillCoverSupport.Length;i++)
{
ref var support=ref skillCoverSupport[i];
if(!support.Present)continue;
var p=Players[i];
if(Seconds>support.Until && (!support.Contacted || Seconds>support.ContactOutcomeUntil) ||
p.SentOff || p.Team==possessionTeam || Seconds<p.FallUntil ||
RestartRemaining>0 || GoalHoldRemaining>0 || !SkillNeighbors(p,Players[support.Presser]) ||
p.Profile.RoleFamiliarity<.7f || Players[support.Presser].Profile.RoleFamiliarity<.7f)
{support.Present=false;continue;}
// Keep only the outcome record while a real deflected ball is being recovered.
// This does not prolong movement intent, the recovery sprint, or the skill itself.
if(Seconds>support.Until)continue;
if(!support.Arrived && support.InitialDistance>=1.8f)
{
if((support.Aim-p.Position).Length<1.7f && Seconds-support.Started>=.18f)
support.AtAim+=dt;
else support.AtAim=0;
if(support.AtAim>=.18f){support.Arrived=true;SkillCoverArrivals[p.Team]++;}
}
if(support.SpecificLane && !support.LaneOccupied)
{
var passLine=support.LaneEnd-support.LaneStart;
float fraction=Point.Dot(p.Position-support.LaneStart,passLine)/
Math.Max(.01f,Point.Dot(passLine,passLine));
float physicalReach=.59f+Math.Clamp(p.Profile.Interceptions,0,1)*.36f;
if(fraction is >.22f and <.92f && Seconds-support.Started>=.18f &&
DistanceToSegment(p.Position,support.LaneStart,support.LaneEnd)<=physicalReach)
support.AtLane+=dt;
else support.AtLane=0;
if(support.AtLane>=.18f){support.LaneOccupied=true;SkillCoverLaneOccupations[p.Team]++;}
}
}
}
internal void RecordSkillCoverRecovery(Footballer p,int previousPossessionTeam)
{
if(p.Id<0 || p.Id>=skillCoverSupport.Length || Owner!=p.Id || previousPossessionTeam<0 ||
previousPossessionTeam==p.Team)return;
bool recorded=false;
for(int i=0;i<Players.Count && i<skillCoverSupport.Length;i++)
{
ref var support=ref skillCoverSupport[i];var cover=Players[i];
if(!support.Present || support.Regained ||
Seconds>support.Until && (!support.Contacted || Seconds>support.ContactOutcomeUntil) || cover.Team!=p.Team ||
support.PossessionTeam!=previousPossessionTeam || !SkillNeighbors(cover,Players[support.Presser]) ||
cover.Profile.RoleFamiliarity<.7f || Players[support.Presser].Profile.RoleFamiliarity<.7f)continue;
bool coverRegain=i==p.Id && (support.Arrived || support.Contacted ||
(support.Aim-p.Position).Length<=2.1f ||
support.FollowingPass && (support.Meeting-p.Position).Length<=2.1f);
bool pressRegain=p.Id==support.Presser && (support.Arrived || support.Contacted) &&
(p.Position-cover.Position).Length<12;
if(!coverRegain && !pressRegain)continue;
support.Regained=true;
if(!recorded){SkillCoverRegains[p.Team]++;recorded=true;}
}
}
internal void RecordSkillCoverContact(Footballer p)
{
if(p.Id<0 || p.Id>=skillCoverSupport.Length || p.Team==LastTouch)return;
ref var support=ref skillCoverSupport[p.Id];
if(!support.Present || support.Contacted || Seconds>support.Until ||
LastTouch!=support.PossessionTeam || !SkillNeighbors(p,Players[support.Presser]) ||
(support.Aim-p.Position).Length>2.5f &&
(!support.FollowingPass || (support.Meeting-p.Position).Length>2.5f))return;
support.Contacted=true;
support.ContactOutcomeUntil=Seconds+3.3f;
support.Until=Math.Max(support.Until,Seconds+.75f);
SkillCoverInterceptions[p.Team]++;
if(!ShotInFlight && Passer>=0 && Players[Passer].Team!=p.Team)SkillCoverPassInterceptions[p.Team]++;
}
}