SkillMoves.cs

The skill move library and simulation logic for a football match. Defines 20 skill moves and picks, starts and advances them for players, including nutmeg attempts and their resolution against a defender.

Rough Code
using System;
using System.Linq;

namespace Socer;

// Original procedural interpretations of familiar football skills. A move changes
// the ball only when a boot can reach it; difficulty affects selection and success.
public enum SkillMoveKind
{
    BodyFeint, Stepover, Scissors, BallRoll, DragBack, CruyffTurn,
    Roulette, Elastico, ReverseElastico, HeelToHeel, RainbowFlick,
    LaCroqueta, FakeShot, McGeadySpin, ScoopTurn,
    Nutmeg, InsideChop, OutsideCut, StopAndGo, SoleVTurn
}

public sealed record SkillMoveSpec(SkillMoveKind Kind,string Name,float MinimumSkill,float Duration,float Weight);

public static class SkillMoves
{
    static SkillMoveSpec[] cached;
    public static SkillMoveSpec[] All=>cached?.Length==(int)SkillMoveKind.SoleVTurn+1?cached:cached=CreateAll();
    static SkillMoveSpec[] CreateAll()=>new SkillMoveSpec[]{
        new(SkillMoveKind.BodyFeint,"Body feint",.20f,.56f,1.5f),
        new(SkillMoveKind.Stepover,"Stepover",.29f,.70f,1.5f),
        new(SkillMoveKind.Scissors,"Scissors",.32f,.85f,3.2f),
        new(SkillMoveKind.BallRoll,"Ball roll",.27f,.64f,1.5f),
        new(SkillMoveKind.DragBack,"Drag back",.36f,.76f,1.0f),
        new(SkillMoveKind.CruyffTurn,"Cruyff turn",.47f,.74f,.9f),
        new(SkillMoveKind.Roulette,"Roulette",.56f,.88f,.85f),
        new(SkillMoveKind.Elastico,"Elastico",.71f,.70f,.75f),
        new(SkillMoveKind.ReverseElastico,"Reverse elastico",.75f,.70f,.65f),
        new(SkillMoveKind.HeelToHeel,"Heel to heel",.54f,.70f,.85f),
        new(SkillMoveKind.RainbowFlick,"Rainbow flick",.78f,.96f,.50f),
        new(SkillMoveKind.LaCroqueta,"La croqueta",.58f,.70f,.90f),
        new(SkillMoveKind.FakeShot,"Fake shot",.42f,.66f,1.1f),
        new(SkillMoveKind.McGeadySpin,"McGeady spin",.74f,.88f,.55f),
        new(SkillMoveKind.ScoopTurn,"Scoop turn",.68f,.76f,.65f),
        new(SkillMoveKind.Nutmeg,"Nutmeg",.59f,.84f,1.6f),
        new(SkillMoveKind.InsideChop,"Inside chop",.34f,.58f,1.15f),
        new(SkillMoveKind.OutsideCut,"Outside cut",.31f,.54f,1.2f),
        new(SkillMoveKind.StopAndGo,"Stop and go",.41f,.72f,.98f),
        new(SkillMoveKind.SoleVTurn,"Sole V turn",.55f,.72f,.75f)
    };
    public static SkillMoveSpec Get(SkillMoveKind kind)=>All[(int)kind];
}

public sealed partial class MatchSimulation
{
    public int[] SkillMoveStarts=new int[SkillMoves.All.Length];
    public int[] SkillMoveExits=new int[SkillMoves.All.Length];
    public int NutmegOpportunities,NutmegAttempts,NutmegsCompleted,NutmegsStopped;

    bool TrySkillMove(Footballer p)
    {
        if(Owner!=p.Id || p.Keeper || RehearsingStrategy || Strategy is {Done:false} || !CanFieldCall ||
            WindupPlayer>=0 || p.ShotSetupUntil>0 || Seconds<p.KickSetupUntil || Seconds<p.FeintReadyAt ||
            Seconds<p.FeintUntil || Seconds<p.ReceiveUntil || Seconds<p.ChestUntil || Seconds<p.FallUntil ||
            p.Held<.35f || p.Stamina<.25f || !BallAtFoot(p) || ClearFinishingChance(p) ||
            p.Position.X<5 || p.Position.X>100 || p.Position.Y<2 || p.Position.Y>66)return false;
        var axis=p.Velocity.Length>1?p.Velocity.Unit:new Point(MathF.Cos(p.Facing),MathF.Sin(p.Facing));
        if(axis.Length<.1f)axis=new Point(Direction(p.Team),0);
        var rival=ActivePlayers.Where(q=>q.Team!=p.Team && !q.Keeper && Seconds>=q.FallUntil &&
            (q.Position-p.Position).Length is >1.35f and <4.6f &&
            Point.Dot((q.Position-p.Position).Unit,axis)>.15f)
            .OrderBy(q=>(q.Position-p.Position).Length).FirstOrDefault();
        if(rival==null)return false;
        p.FeintReadyAt=Seconds+2.2f;
        bool rareCut=SkillReady(p,UniqueSkill.PhantomCut) || SkillReady(p,UniqueSkill.Counterstep);
        if(Next()>.27f+p.Skill*.36f+(p.Confidence-.6f)*.12f+(PlayerPlayStyles.Has(p,PlayStyle.Technical)?.09f:0)+
            (rareCut?.16f:0))return false;
        var lateral=new Point(-axis.Y,axis.X);
        int side=Point.Dot(rival.Position-p.Position,lateral)>0?-1:1;
        var cue=side<0?AttackCue.DribbleLeft:AttackCue.DribbleRight;
        float known=OpponentMemory.Recognition(rival.Id,p.Id,cue,Seconds);
        float alternative=OpponentMemory.Recognition(rival.Id,p.Id,
            side<0?AttackCue.DribbleRight:AttackCue.DribbleLeft,Seconds);
        if(known>alternative+.18f && p.Profile.Vision>.55f)
        {side=-side;AdaptiveVariations++;}
        // Both exits are checked; a skill is not chosen if it simply runs out of play.
        var exit=(axis*.7f+lateral*side*.9f).Unit;
        if(CarryLane(p,p.Position+exit*3)<.9f){side=-side;exit=(axis*.7f+lateral*side*.9f).Unit;}
        if(CarryLane(p,p.Position+exit*3)<.65f)return false;
        int repertoire=Math.Clamp(p.Profile.SkillMoves+(PlayerPlayStyles.Has(p,PlayStyle.Trickster)?1:0),1,5);
        bool throughLane=NutmegLane(p,rival,axis);
        if(throughLane)NutmegOpportunities++;
        var eligible=SkillMoves.All.Where(s=>p.Skill>=s.MinimumSkill &&
            repertoire >= (s.MinimumSkill<.35f?1:s.MinimumSkill<.53f?2:s.MinimumSkill<.69f?3:s.MinimumSkill<.77f?4:5) &&
            (s.Kind is not SkillMoveKind.RainbowFlick and not SkillMoveKind.ScoopTurn || p.Stamina>.42f) &&
            (s.Kind is not SkillMoveKind.CruyffTurn and not SkillMoveKind.DragBack ||
                (p.Position-new Point(52.5f,34)).Length>9) &&
            (s.Kind!=SkillMoveKind.Nutmeg || throughLane)).ToArray();
        if(eligible.Length==0)return false;
        float total=eligible.Sum(s=>s.Weight),roll=Next()*total;
        var selected=eligible[^1];
        foreach(var move in eligible){roll-=move.Weight;if(roll<0){selected=move;break;}}
        if(FrequentScissors && p.Profile.SkillMoves>=1 && (rival.Position-p.Position).Length<4.5f &&
            Point.Dot((rival.Position-p.Position).Unit,axis)>.35f && Next()<.38f)
            selected=SkillMoves.Get(SkillMoveKind.Scissors);
        if(throughLane && repertoire>=3 && p.Skill>=.59f && Next()<.38f)
            selected=SkillMoves.Get(SkillMoveKind.Nutmeg);
        return BeginSkillMove(p,rival,axis,side,exit,selected);
    }

    bool BeginSkillMove(Footballer p,Footballer rival,Point axis,int side,Point exit,SkillMoveSpec selected)
    {
        if(selected.Kind==SkillMoveKind.Scissors)
        {
            p.FeintReadyAt=Seconds; // The existing two-foot scissors has its own cadence and contacts.
            bool started=TryScissorsFeint(p);
            if(started)SkillMoveStarts[(int)SkillMoveKind.Scissors]++;
            return started;
        }
        if(selected.Kind==SkillMoveKind.Nutmeg)NutmegAttempts++;
        p.SkillMove=selected.Kind;p.SkillStage=0;p.SkillOpponent=rival.Id;
        p.SkillAxis=axis;p.SkillSide=side;p.FeintDirection=exit;
        p.FeintVariant=4;p.FeintStartedAt=Seconds;p.FeintUntil=Seconds+selected.Duration;
        p.CarryAvoidUntil=0;p.PlanUntil=0;p.ShieldUntil=0;
        p.Stamina=Math.Max(.08f,p.Stamina-(selected.MinimumSkill>.7f?.024f:.014f));
        if(!LegendaryActive(p,LegendaryAbility.OtherGravity) && Next()<.85f)
        {
            if(!ActivateSkill(p,UniqueSkill.PhantomCut,selected.Duration+.65f))
                ActivateSkill(p,UniqueSkill.Counterstep,selected.Duration+.65f);
        }
        p.Action=selected.Name;DribbleAttempts++;DribbleAttemptsByTeam[p.Team]++;SkillMoveStarts[(int)selected.Kind]++;
        Emit("dribble",p,selected.Name.ToUpperInvariant(),selected.Duration);
        AddEvent($"{TeamName(p.Team)} · {selected.Name.ToLowerInvariant()} #{p.Number}");
        return true;
    }

    Point SkillExit(Footballer p)
    {
        var f=p.SkillAxis;var s=new Point(-f.Y,f.X)*p.SkillSide;
        return p.SkillMove switch
        {
            SkillMoveKind.DragBack => (f*-.38f+s*.92f).Unit,
            SkillMoveKind.CruyffTurn => (f*-.75f+s*.65f).Unit,
            SkillMoveKind.Roulette => (f*-.42f+s*.9f).Unit,
            SkillMoveKind.McGeadySpin => (f*-.2f+s*.98f).Unit,
            SkillMoveKind.ScoopTurn => (f*.15f+s*.99f).Unit,
            SkillMoveKind.HeelToHeel or SkillMoveKind.RainbowFlick => f,
            SkillMoveKind.LaCroqueta or SkillMoveKind.BallRoll => (f*.45f+s*.9f).Unit,
            SkillMoveKind.Nutmeg => (f*.72f+s*.68f).Unit,
            SkillMoveKind.InsideChop or SkillMoveKind.SoleVTurn => (f*-.18f+s*.98f).Unit,
            SkillMoveKind.OutsideCut => (f*.52f+s*.85f).Unit,
            SkillMoveKind.StopAndGo => f,
            _ => p.FeintDirection
        };
    }

    // Called after the ball advances; no position assignment is made here.
    bool AdvanceSkillMoveTouch(Footballer p,float gap)
    {
        if(p.FeintVariant!=4 || Seconds>=p.FeintUntil)return false;
        if(p.SkillMove==SkillMoveKind.Nutmeg)return AdvanceNutmegTouch(p,gap);
        float age=Seconds-p.FeintStartedAt;
        var f=p.SkillAxis;var side=new Point(-f.Y,f.X)*p.SkillSide;
        float firstAt=p.SkillMove switch
        {
            SkillMoveKind.BodyFeint or SkillMoveKind.FakeShot or SkillMoveKind.StopAndGo => .30f,
            SkillMoveKind.Stepover => .34f,
            SkillMoveKind.CruyffTurn => .32f,
            SkillMoveKind.RainbowFlick => .18f,
            _ => .12f
        };
        if(p.SkillStage==0)
        {
            if(age<firstAt || gap>DribbleFootReach)return true;
            Point impulse=p.SkillMove switch
            {
                SkillMoveKind.BallRoll or SkillMoveKind.LaCroqueta => side*1.8f,
                SkillMoveKind.DragBack or SkillMoveKind.Roulette => f*-1.45f,
                SkillMoveKind.Elastico => side*1.7f+f*.5f,
                SkillMoveKind.ReverseElastico => side*-1.7f+f*.5f,
                SkillMoveKind.HeelToHeel => f*-.85f,
                SkillMoveKind.McGeadySpin => side*1.5f,
                SkillMoveKind.RainbowFlick => f*2.0f,
                SkillMoveKind.ScoopTurn => f*.8f+side*.5f,
                SkillMoveKind.InsideChop or SkillMoveKind.SoleVTurn => f*-.8f+side*.65f,
                SkillMoveKind.OutsideCut => f*.9f+side*1.4f,
                SkillMoveKind.StopAndGo => f*.2f,
                _ => f*.35f
            };
            if(LegendaryActive(p,LegendaryAbility.OtherGravity))impulse*=.72f;
            BallVelocity=LimitDribbleImpulse(impulse);p.SkillStage=1;
            p.DribbleFoot=-p.SkillSide;
            if(p.SkillMove==SkillMoveKind.RainbowFlick){p.SkillAirStartedAt=Seconds;BallHeight=.12f;}
            else if(p.SkillMove==SkillMoveKind.ScoopTurn){BallHeight=.12f;p.SkillAirStartedAt=Seconds;}
            SnapshotOffside(p,false);
            p.DribbleContact=Ball;p.DribbleContactOffset=Ball-p.Position;p.LastDribbleTouch=Seconds;p.NextDribbleTouch=Seconds+.15f;DribbleTouches++;
            return true;
        }
        if(p.SkillMove==SkillMoveKind.RainbowFlick)return true;
        if(p.SkillMove==SkillMoveKind.ScoopTurn && p.SkillStage==1)
            BallHeight=MathF.Max(0,.45f*MathF.Sin(Math.Clamp((Seconds-p.SkillAirStartedAt)/.38f,0,1)*MathF.PI));
        if(p.SkillStage==1 && age>firstAt+.20f && gap<=DribbleFootReach)
        {
            var exit=SkillExit(p);
            bool gravity=LegendaryActive(p,LegendaryAbility.OtherGravity);
            BallVelocity=LimitDribbleImpulse(exit*(3.0f+p.Skill*1.8f)*(gravity?.76f:1));
            p.FeintDirection=exit;p.SkillStage=2;p.DribbleFoot=p.SkillSide;
            p.BurstRemaining=Math.Max(p.BurstRemaining,.9f);
            p.RunUntil=Seconds+.9f;p.RunTarget=Clamp(p.Position+exit*4);
            SkillMoveExits[(int)p.SkillMove]++;DribbleTouches++;
            var rival=Players[p.SkillOpponent];
            var cue=exit.Y<0?AttackCue.DribbleLeft:AttackCue.DribbleRight;
            float known=OpponentMemory.Recognition(rival.Id,p.Id,cue,Seconds);
            float chance=Math.Clamp(.43f+(p.Skill-rival.Profile.Defence)*.36f-known*.2f+
                (PlayerPlayStyles.Grade(p,PlayStyle.Technical) switch {PlayStyleGrade.PlayStylePlus=>.13f,PlayStyleGrade.PlayStyle=>.08f,_=>0})+
                (PlayerPlayStyles.Grade(p,PlayStyle.Trickster) switch {PlayStyleGrade.PlayStylePlus=>.09f,PlayStyleGrade.PlayStyle=>.05f,_=>0})-
                SkillMoves.Get(p.SkillMove).MinimumSkill*.08f+
                (SkillActive(p,UniqueSkill.PhantomCut) || SkillActive(p,UniqueSkill.Counterstep)?.20f*p.ActiveSkillPower:0)+
                .045f*SkillStrength(p,UniqueSkill.PressResistant),.12f,.91f);
            if(gravity)chance=Math.Min(.91f,chance+.12f);
            if((rival.Position-p.Position).Length<4 && Next()<chance)
            {
                float beaten=.16f+(1-rival.Profile.Defence)*.18f;
                rival.Recovery=Math.Max(rival.Recovery,beaten);
                rival.StumbleUntil=Seconds+beaten;
                DribblesWon++;DribblesWonByTeam[p.Team]++;
            }
            OpponentMemory.Observe(Players,p,cue,Seconds);
            SnapshotOffside(p,false);
            p.DribbleContact=Ball;p.DribbleContactOffset=Ball-p.Position;p.LastDribbleTouch=Seconds;p.NextDribbleTouch=Seconds+.18f;
        }
        return true;
    }

    bool NutmegLane(Footballer p,Footballer rival,Point axis)
    {
        var delta=rival.Position-p.Position;
        return delta.Length>1.35f && delta.Length<3.6f &&
            Point.Dot(delta.Unit,axis)>.60f &&
            Math.Abs(Point.Dot(delta,new Point(-axis.Y,axis.X)))<.85f &&
            rival.Velocity.Length<5.2f && BallHeight<.3f;
    }

    // The ball travels through the defender's actual position. The result is
    // resolved when it reaches that position, then the runner must collect it.
    bool AdvanceNutmegTouch(Footballer p,float gap)
    {
        var defender=Players[p.SkillOpponent];
        var axis=p.SkillAxis;
        if(p.SkillStage==0)
        {
            if(Seconds-p.FeintStartedAt<.17f || gap>DribbleFootReach)return true;
            var target=defender.Position+axis*1.15f;
            p.SkillNutmegGate=defender.Position;
            BallVelocity=LimitDribbleImpulse((target-Ball).Unit*(5.4f+p.Skill*2));
            p.SkillStage=1;p.DribbleFoot=p.SkillSide;
            p.DribbleContact=Ball;p.DribbleContactOffset=Ball-p.Position;
            SnapshotOffside(p,false);
            p.LastDribbleTouch=Seconds;p.NextDribbleTouch=Seconds+.22f;DribbleTouches++;
            return true;
        }
        if(p.SkillStage==1 &&
            (Point.Dot(Ball-defender.Position,axis)>-.05f || Point.Dot(Ball-p.SkillNutmegGate,axis)>-.05f))
        {
            var lateral=new Point(-axis.Y,axis.X);
            var relative=Ball-defender.Position;
            bool legsInPath=Math.Abs(Point.Dot(relative,lateral))<.48f &&
                Math.Abs(Point.Dot(relative,axis))<.90f;
            float chance=Math.Clamp(.34f+(p.Skill-defender.Profile.Defence)*.52f+
                (p.Profile.Composure-defender.Profile.Composure)*.15f+
                (PlayerPlayStyles.Has(p,PlayStyle.Trickster)?.10f:0),.12f,.78f);
            if(legsInPath && Next()<chance)
            {
                defender.Recovery=Math.Max(defender.Recovery,.24f+(1-defender.Profile.Defence)*.22f);
                defender.StumbleUntil=Seconds+.28f+(1-defender.Profile.Defence)*.18f;
                defender.NutmeggedUntil=Seconds+.38f;
                defender.Action="Beaten through the legs";
                p.SkillStage=2;p.FeintDirection=SkillExit(p);
                p.RunUntil=Seconds+.9f;p.RunTarget=Clamp(defender.Position+axis*2+p.FeintDirection*.8f);
                p.BurstRemaining=Math.Max(p.BurstRemaining,.9f);
                NutmegsCompleted++;DribblesWon++;DribblesWonByTeam[p.Team]++;
                SkillMoveExits[(int)SkillMoveKind.Nutmeg]++;
                Emit("dribble",p,"NUTMEG!",1.1f);
            }
            else
            {
                NutmegsStopped++;
                p.FeintUntil=Seconds;
                BallVelocity=(Ball-defender.Position).Unit*3;
                if((defender.Position-Ball).Length<.85f && !PassiveTrainingOpponent(defender))
                {Owner=-1;Passer=-1;shot=false;TakeBall(defender);}
            }
        }
        return true;
    }

    bool AdvanceSkillAir(Footballer p)
    {
        if(p.FeintVariant!=4 || p.SkillMove!=SkillMoveKind.RainbowFlick || p.SkillStage!=1)return false;
        float phase=Math.Clamp((Seconds-p.SkillAirStartedAt)/.58f,0,1);
        BallHeight=1.65f*MathF.Sin(phase*MathF.PI);
        if(phase>=1)
        {
            p.SkillStage=2;p.FeintDirection=p.SkillAxis;
            BallHeight=0;SkillMoveExits[(int)p.SkillMove]++;
            p.BurstRemaining=Math.Max(p.BurstRemaining,1.1f);
            p.RunUntil=Seconds+1;p.RunTarget=Clamp(p.Position+p.SkillAxis*4);
        }
        return true;
    }
}