SkillLinkPlay.cs

Part of MatchSimulation for a soccer game. Plans linked off-ball runs and passes between nearby teammates, validates the route and passing lane, adjusts reception timing and facing, and tracks link run, pass and reception counters per team.

Rough Code
using System;
namespace Socer;

public sealed partial class MatchSimulation
{
    readonly float[] linkedRunUntil=new float[22],nextLinkedRun=new float[22],nextLinkedValidation=new float[22];
    readonly int[] linkedRunPasser=new int[22];
    readonly Point[] linkedRunTarget=new Point[22];
    public int[] SkillLinkRuns=new int[2],SkillLinkPasses=new int[2],SkillLinkReceptions=new int[2];
    public int[] SkillLinkCombinations=new int[2],SkillLinkCompletions=new int[2];
    public float[] SkillLinkProgress=new float[2];
    int linkedPasser=-1,linkedReceiver=-1;
    float linkedPassUntil;
    Point linkedPassOrigin;

    float PassingRunLink(Footballer passer,Footballer runner)
    {
        if(!SkillNeighbors(passer,runner) || passer.Profile.RoleFamiliarity<.7f ||
            runner.Profile.RoleFamiliarity<.7f || Seconds<passer.FallUntil || Seconds<runner.FallUntil)return 0;
        float delivery=Math.Max(SkillStrength(passer,UniqueSkill.ThreadNeedle),
            Math.Max(SkillStrength(passer,UniqueSkill.LongDistributor),SkillStrength(passer,UniqueSkill.KeeperDistributor)));
        float movement=Math.Max(SkillStrength(runner,UniqueSkill.SpaceHunter),SkillStrength(runner,UniqueSkill.OutletRunner));
        return Math.Min(delivery,movement);
    }
    float WallRunLink(Footballer passer,Footballer runner)
    {
        if(!SkillNeighbors(passer,runner) || passer.Profile.RoleFamiliarity<.7f || runner.Profile.RoleFamiliarity<.7f)return 0;
        return Math.Min(SkillStrength(passer,UniqueSkill.WallPass),Math.Max(SkillStrength(runner,UniqueSkill.SpaceHunter),
            Math.Max(SkillStrength(runner,UniqueSkill.Poacher),SkillStrength(runner,UniqueSkill.OutletRunner))));
    }

    // Plan at the normal thinking cadence, then hold a readable route. Only a
    // visible, safe lane can create an opening; this is not a global speed buff.
    bool TryPlanLinkedRun(Footballer p,Footballer carrier)
    {
        float link=PassingRunLink(carrier,p);
        if(link<=0 || carrier.Keeper || p.Stamina<.3f || p.DirectOrderUntil>Seconds ||
            Seconds<nextLinkedRun[p.Id] || (p.Position-carrier.Position).Length is <6 or >32 ||
            World(carrier.Position,p.Team).X is <30 or >81 || !SeesPartner(carrier,p) ||
            !IsOnside(p,carrier.Position) || !AttackUnit(p))return false;
        var play=combinations[p.Team];
        if(play.Stage>0 && Seconds<play.Until && (play.Starter==p.Id || play.Third==p.Id))return false;
        nextLinkedRun[p.Id]=Seconds+2.4f;
        float side=p.Position.Y<carrier.Position.Y?-1:1,dir=Direction(p.Team);
        Point best=p.Position;float value=float.MinValue;
        for(int option=0;option<4;option++)
        {
            float depth=option<2?10:15,lateral=(option%2==0?4:8)*side;
            var at=OnsideRunTarget(p,carrier.Position+new Point(dir*depth,lateral));
            float gain=(at.X-p.Position.X)*dir;
            if(gain<2 || (at-p.Position).Length>14)continue;
            float space=PerceivedSpace(p,at),lane=PerceivedLane(carrier,at);
            if(space<2.6f || lane<1.35f || PassSafety(carrier,p,at)<.56f)continue;
            bool occupied=false;
            for(int i=0;i<Players.Count;i++)
            {
                var teammate=Players[i];
                if(teammate.Team==p.Team && teammate.Id!=p.Id && teammate.Id!=carrier.Id && !teammate.SentOff &&
                    (linkedRunUntil[i]>Seconds && (linkedRunTarget[i]-at).Length<3 ||
                    (teammate.Position-at).Length<2.4f)){occupied=true;break;}
            }
            if(occupied)continue;
            float score=Math.Min(space,6)*1.1f+Math.Min(lane,4)*1.5f+Math.Min(gain,9)*.28f-(at-p.Position).Length*.12f;
            if(score>value){best=at;value=score;}
        }
        if(value==float.MinValue)return false;
        linkedRunTarget[p.Id]=best;linkedRunPasser[p.Id]=carrier.Id;
        linkedRunUntil[p.Id]=Seconds+2.1f+.25f*link;
        nextLinkedValidation[p.Id]=Seconds+.25f;
        p.FootballRun=FootballRun.Behind;p.FootballRunTarget=best;
        p.FootballRunStarted=Seconds;p.FootballRunUntil=linkedRunUntil[p.Id];
        SkillLinkRuns[p.Team]++;IntelligentRuns++;RunTypes[(int)FootballRun.Behind]++;
        return true;
    }
    Point LinkedAttackMovement(Footballer p,Point target,int possessionTeam)
    {
        if(possessionTeam!=p.Team || Owner<0 || Owner!=linkedRunPasser[p.Id] || Owner==p.Id ||
            Seconds>=linkedRunUntil[p.Id] || p.DirectOrderUntil>Seconds || WindupPlayer==p.Id ||
            DuelDefender==p.Id || Seconds<p.FallUntil || PassingRunLink(Players[Owner],p)<=0)return target;
        if(Seconds>=nextLinkedValidation[p.Id])
        {
            nextLinkedValidation[p.Id]=Seconds+.25f;
            if(PerceivedSpace(p,linkedRunTarget[p.Id])<1.9f ||
                PassSafety(Players[Owner],p,linkedRunTarget[p.Id])<.42f)
            {
                linkedRunUntil[p.Id]=Seconds;p.FootballRun=FootballRun.None;p.FootballRunUntil=Seconds+.5f;
                return target;
            }
        }
        p.Action="Открывается под связанный пас";
        return OnsideRunTarget(p,linkedRunTarget[p.Id]);
    }
    float LinkedPassValue(Footballer p,Footballer q,float gain,float safety,float space)
    {
        if(gain<3 || safety<.56f || space<2.6f ||
            !(q.RunUntil>Seconds || q.FootballRunUntil>Seconds && q.FootballRun is FootballRun.Behind or FootballRun.Overlap))return 0;
        return PassingRunLink(p,q)*2.8f;
    }
    void RecordLinkedPass(Footballer p,Footballer q)
    {
        linkedPasser=linkedReceiver=-1;
        if(PassingRunLink(p,q)<=0 || (q.Position.X-p.Position.X)*Direction(p.Team)<3 ||
            !(q.RunUntil>Seconds || q.FootballRunUntil>Seconds && q.FootballRun is FootballRun.Behind or FootballRun.Overlap))return;
        linkedPasser=p.Id;linkedReceiver=q.Id;linkedPassOrigin=p.Position;linkedPassUntil=Seconds+4;
        SkillLinkPasses[p.Team]++;
    }
    void RecordLinkedReception(Footballer p,int passer)
    {
        if(p.Id==linkedReceiver && passer==linkedPasser && Seconds<linkedPassUntil)
        {
            SkillLinkReceptions[p.Team]++;
            SkillLinkProgress[p.Team]+=Math.Max(0,(p.Position.X-linkedPassOrigin.X)*Direction(p.Team));
        }
        linkedPasser=linkedReceiver=-1;
    }
    void ApplyCombinationReceptionTiming(Footballer p)
    {
        var play=combinations[p.Team];
        if(play.Stage!=1 || play.Link!=p.Id || Seconds>=play.Until || !play.Linked)return;
        // The first reception used to overwrite the timing set at release.
        // Facing, control and shot selection still run normally.
        // At .07 s the receiver had not held the ball for the .12 s required by
        // the return-pass gate. That wasted a decision and reset the slow cadence.
        p.Decision=.14f;
        p.ReceiveUntil=Math.Min(p.ReceiveUntil,Seconds+.12f);
        p.NextDribbleTouch=Math.Min(p.NextDribbleTouch,Seconds+.06f);
        BallVelocity=p.Velocity*.25f;
    }
    bool LinkedReceptionFacing(Footballer p,out Point look)
    {
        look=new();
        // Officials share the same facing animation but are not either team.
        if(p.Team<0 || p.Team>=combinations.Length || p.Id<0 || p.Id>=linkedRunUntil.Length)return false;
        var play=combinations[p.Team];
        if(Owner>=0 || PassReceiver!=p.Id || play.Stage!=1 || !play.Linked || play.Link!=p.Id ||
            Seconds>=play.Until || Passer!=play.Starter || (Ball-p.Position).Length>12)return false;
        // Open the body while the first pass travels. Watching only the incoming
        // ball left the receiver needing a full turn before the return pass.
        var outlet=play.ThirdLinked && play.Third>=0?play.ThirdTarget:play.StarterTarget;
        look=(Ball-p.Position).Unit*.45f+(outlet-p.Position).Unit*.70f;
        return look.Length>.1f;
    }
}