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.
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;
}
}