Part of MatchSimulation that runs tackle duels between a defender and the ball carrier. Picks a challenge type (standing tackle, sliding tackle or shoulder-to-shoulder), applies skill and profile modifiers, and resolves success or miss into ball velocity, recovery timers and events.
using System;
using System.Linq;
namespace Socer;
public sealed partial class MatchSimulation
{
public int DuelDefender=-1, DuelCarrier=-1;
public Point DuelAim;
public float DuelRemaining, DuelStartedAt, PickupDelay, DuelClosestContact;
public bool Sliding,BodyChallenge;
public float BodyPressureStrength,BodyPressureSeconds,BodyContestContact;
public int BodyDuelResolutions,BodyDuelEndClose,BodyDuelEndBallReach,BodyDuelEndPossessed;
public int SlideAttempts,StandingAttempts,BodyAttempts,BodyWins;
public int DefensiveRecoveryRuns,DefensiveRecoverySprints;
public int[] StandingTackleVariantStarts=new int[3];
bool CanBodyChallenge(Footballer d,Footballer c)
{
var offset=d.Position-c.Position;
var forward=c.Velocity.Length>1?c.Velocity.Unit:new Point(MathF.Cos(c.Facing),MathF.Sin(c.Facing));
return !d.Keeper && offset.Length>.35f && offset.Length<1.12f &&
Math.Abs(Point.Dot(offset.Unit,forward))<.65f && (Ball-c.Position).Length<1.4f && BallHeight<.7f &&
Seconds>=d.FallUntil && Seconds>=c.FallUntil;
}
bool ShouldSlide(Footballer d,Footballer c)
{
float reach=(Ball-d.Position).Length;
var goal=World(new Point(0,34),d.Team);var toGoal=(goal-c.Position).Unit;
bool behind=Point.Dot((d.Position-c.Position).Unit,toGoal)<-.35f;
bool emergency=(goal-c.Position).Length<26 && (WindupPlayer==c.Id ||
(c.Velocity.Length>2.5f && Point.Dot(c.Velocity.Unit,toGoal)>.35f));
bool ballFirst=Point.Dot((c.Position-d.Position).Unit,(Ball-d.Position).Unit)<.75f ||
(Ball-d.Position).Length+.2f<(c.Position-d.Position).Length;
bool desperateRecovery=behind && AggressionOf(d)>.58f && reach<1.7f;
return !d.Keeper && emergency && (!behind && ballFirst || desperateRecovery) && BallHeight<.45f && reach>1.05f && reach<2.1f &&
d.Stamina>.25f && (d.Profile.Defence>.45f || AggressionOf(d)>.8f);
}
public int[] TackleAttempts=new int[2], TackleMisses=new int[2];
public void BeginChallenge(Footballer defender,Footballer carrier)
{
if(carrier.FeintVariant==4 && carrier.SkillMove==SkillMoveKind.Nutmeg && carrier.SkillStage==1 &&
defender.Id==carrier.SkillOpponent)return;
if(DuelDefender>=0 || carrier.Keeper || defender.TackleCooldown>0 || Seconds-defender.LastSlideAt<2.1f || defender.SentOff || carrier.SentOff) return;
DuelDefender=defender.Id; DuelCarrier=carrier.Id; DuelAim=Ball; DuelStartedAt=Seconds;
DuelClosestContact=(defender.Position-carrier.Position).Length;
BodyChallenge=CanBodyChallenge(defender,carrier);
Sliding=!BodyChallenge && ShouldSlide(defender,carrier);
defender.Stamina=Math.Max(.08f,defender.Stamina-(Sliding?.035f:.023f));
if(defender.Profile.Defence>.45f && SkillReady(defender,UniqueSkill.SteelPress) && Next()<.42f)
ActivateSkill(defender,UniqueSkill.SteelPress);
if(BodyChallenge){ActivateSkill(defender,UniqueSkill.ShoulderWall,.9f);ActivateSkill(carrier,UniqueSkill.ShoulderWall,.9f);}
else if(!Sliding)ActivateSkill(defender,UniqueSkill.TimingTackle,.9f);
BodyContestContact=0;
BodyPressureStrength=BodyChallenge?Math.Clamp(.12f+
(defender.Profile.Physical-carrier.Profile.Physical)*.20f+
defender.Profile.Defence*.08f-carrier.Profile.Composure*.04f+
(int)PlayerPlayStyles.Grade(defender,PlayStyle.Enforcer)*.035f-
(int)PlayerPlayStyles.Grade(carrier,PlayStyle.Enforcer)*.035f+
(SkillActive(defender,UniqueSkill.ShoulderWall)?.11f*defender.ActiveSkillPower:0)-
(SkillActive(carrier,UniqueSkill.ShoulderWall)?.06f*carrier.ActiveSkillPower:0)-
.035f*SkillStrength(carrier,UniqueSkill.PressResistant),.07f,.43f):0;
if(BodyChallenge)BodyAttempts++;else if(Sliding)SlideAttempts++;else StandingAttempts++;
if(!BodyChallenge && !Sliding)
{
defender.StandingTackleAt=Seconds;
defender.StandingTackleResolvedAt=-10;
defender.StandingTackleResult=0;
defender.StandingTackleAim=Ball;
var side=new Point(-MathF.Sin(defender.Facing),MathF.Cos(defender.Facing));
defender.StandingTackleFoot=Point.Dot(Ball-defender.Position,side)<0?-1:1;
float approach=Point.Dot((carrier.Position-defender.Position).Unit,
carrier.Velocity.Length>1?carrier.Velocity.Unit:new Point(MathF.Cos(carrier.Facing),MathF.Sin(carrier.Facing)));
defender.StandingTackleVariant=approach>.35f?2:
Math.Abs(approach)<.4f?1:0; // front poke, side hook, recovery block
StandingTackleVariantStarts[defender.StandingTackleVariant]++;
}
if(Sliding)
{
defender.LastSlideAt=Seconds;defender.SlideDirection=(Ball-defender.Position).Unit;
defender.SlideSpeed=defender.Velocity.Length;
defender.SlideVariant=Point.Dot(defender.SlideDirection,carrier.Velocity.Unit)<-.2f?2:Ball.Y>defender.Position.Y?0:1;
}
DuelRemaining=BodyChallenge?.6f:Sliding?.3f:.26f;
defender.TackleCooldown=Sliding?2.5f:BodyChallenge?Math.Clamp(.92f-defender.Profile.Defence*.25f,.65f,.85f):
Math.Clamp(1.28f-defender.Profile.Defence*.48f,.78f,1.05f);
// Shoulder pressure is a running contest. Dropping the defender's burst
// here made him lose half a stride as soon as contact began.
if(BodyChallenge)defender.BurstRemaining=Math.Max(defender.BurstRemaining,.75f);
else defender.BurstRemaining=0;
TackleAttempts[defender.Team]++;
Say(carrier,"Держу!",false);
}
void ResolveChallenge(float dt)
{
if(DuelDefender<0) return;
var defender=Players[DuelDefender];
var carrier=Players[DuelCarrier];
DuelClosestContact=Math.Min(DuelClosestContact,(defender.Position-carrier.Position).Length);
if(BodyChallenge && Owner==DuelCarrier && CanBodyChallenge(defender,carrier))
{
var side=(carrier.Position-defender.Position).Unit;
defender.BodyLeanUntil=carrier.BodyLeanUntil=Seconds+.25f;
defender.BodyLeanDirection=side;carrier.BodyLeanDirection=side*-1;
defender.Action="Борется плечом";carrier.Action="Удерживает мяч корпусом";
// Each frame of legal shoulder contact costs the carrier speed and
// nudges his line; a turnover still requires a reachable ball.
BodyPressureSeconds+=dt;
BodyContestContact+=dt;
carrier.Velocity+=side*(dt*(1.1f+BodyPressureStrength*3.5f));
carrier.Position=Clamp(carrier.Position+side*dt*(.18f+BodyPressureStrength*.85f));
defender.Velocity+=carrier.Velocity*(dt*.18f);
}
DuelRemaining-=dt;
if(DuelRemaining>0 && Owner==DuelCarrier) return;
if(BodyChallenge)
{
BodyDuelResolutions++;
if(Owner==DuelCarrier)BodyDuelEndPossessed++;
if((defender.Position-carrier.Position).Length<1.6f)BodyDuelEndClose++;
if(BodyContestContact>.11f && (defender.Position-carrier.Position).Length<1.65f &&
(defender.Position-Ball).Length<1.85f && (Ball-carrier.Position).Length<1.6f)BodyDuelEndBallReach++;
}
if(Owner==DuelCarrier && TryFoul(defender,carrier)) return;
var forward=new Point(MathF.Cos(carrier.Facing),MathF.Sin(carrier.Facing));
float exposed=Point.Dot((defender.Position-carrier.Position).Unit,forward);
float reach=Sliding?1.6f:BodyChallenge?1.15f:.93f+defender.Profile.StandingTackle*.5f;
if(!Sliding && !BodyChallenge && SkillActive(defender,UniqueSkill.TimingTackle))reach+=.16f*defender.ActiveSkillPower;
// A sustained shoulder duel can dislodge a controlled ball without the
// defender's boot landing at its exact current point. The ball still
// becomes free and must be recovered after the challenge.
bool shoulderReach=BodyChallenge && BodyContestContact>.11f &&
(defender.Position-carrier.Position).Length<1.65f &&
(defender.Position-Ball).Length<1.85f &&
(Ball-carrier.Position).Length<1.6f;
bool inReach=BallHeight<.65f && (BodyChallenge?shoulderReach:
(defender.Position-Ball).Length<reach && (Ball-DuelAim).Length<2.1f);
// Enforcer strengthens shoulder challenges and, especially at Plus grade,
// lets a carrier keep balance while shielding the ball.
float enforcer=BodyChallenge?
(int)PlayerPlayStyles.Grade(defender,PlayStyle.Enforcer)*.04f-
(int)PlayerPlayStyles.Grade(carrier,PlayStyle.Enforcer)*.055f:0;
float tackleSkill=BodyChallenge?defender.Profile.Physical:Sliding?defender.Profile.SlidingTackle:defender.Profile.StandingTackle;
float variantBonus=!Sliding && !BodyChallenge?defender.StandingTackleVariant switch
{1=>.035f,2=>-.055f,_=>0}:0;
float chance=Math.Clamp(.18f+tackleSkill*.56f+(defender.Profile.Defence-.65f)*.12f+
defender.Profile.Aggression*.09f-carrier.Skill*.18f+
(defender.Profile.Physical-carrier.Profile.Physical)*(BodyChallenge?.18f:.08f)+
(defender.Stamina-carrier.Stamina)*.18f+exposed*.14f+enforcer+variantBonus+
(SkillActive(defender,UniqueSkill.SteelPress)?.17f*defender.ActiveSkillPower:0)+
(SkillActive(defender,UniqueSkill.ShoulderWall)?.13f*defender.ActiveSkillPower:0)+
(SkillActive(defender,UniqueSkill.TimingTackle)?.10f*defender.ActiveSkillPower:0)-
(BodyChallenge && SkillActive(carrier,UniqueSkill.ShoulderWall)?.08f*carrier.ActiveSkillPower:0)-
(BodyChallenge?.06f*SkillStrength(carrier,UniqueSkill.PressResistant):0),.12f,.91f);
if(Owner==DuelCarrier && inReach && Next()<chance)
{
if(!BodyChallenge && !Sliding){defender.StandingTackleResolvedAt=Seconds;defender.StandingTackleResult=1;}
CancelWindup();
SnapshotOffside(defender,false);
Owner=-1; Passer=-1; shot=false; LastTouch=defender.Team;
ResetFlight();
var poke=(Ball-defender.Position).Unit;
if(poke.Length<.01f) poke=new Point(Direction(defender.Team),0);
// A successful challenge is a deflection into space, not an
// immediate exchange of possession at the old carrier's feet.
var away=(Ball-carrier.Position).Unit;
var clearance=new Point(Direction(defender.Team),0);
poke=(poke*.3f+away*.45f+clearance*.25f).Unit;
if(!Sliding && !BodyChallenge)
{
var side=new Point(-clearance.Y,clearance.X);
poke=defender.StandingTackleVariant switch
{
1=>(poke*.55f+side*(defender.StandingTackleFoot<0?-1:1)*.45f).Unit,
2=>(poke*.48f+clearance*.52f).Unit,
_=>poke
};
}
if(poke.Length<.01f)poke=clearance;
float cleanTouch=!Sliding && !BodyChallenge?Math.Clamp((defender.Profile.StandingTackle-.55f)/.45f,0,1):0;
BallVelocity=poke*(Sliding?8:6-cleanTouch*1.7f)+
new Point(-poke.Y,poke.X)*(Next()-.5f)*(2.2f-cleanTouch*1.3f);
PickupDelay=Sliding?.18f:.21f-cleanTouch*.07f; flightAge=0;
carrier.Recovery=.4f; defender.Recovery=Sliding?.6f:.26f-tackleSkill*.16f;
if(BodyChallenge)
{
var apart=(carrier.Position-defender.Position).Unit;
defender.BodyLeanDirection=apart;
carrier.BodyLeanDirection=apart*-1;
defender.BodyLeanUntil=carrier.BodyLeanUntil=Seconds+.38f;
carrier.StumbleUntil=Seconds+.42f;
carrier.Action="Потерял равновесие под давлением";
defender.Action="Выдавил плечом";
}
bool bodyContact=!BodyChallenge && (carrier.Position-defender.Position).Length<(Sliding?1.12f:.82f);
if(bodyContact)
{
RegisterChallengeImpact(defender,carrier,Sliding);
if(Sliding)FallDown(carrier,carrier.Velocity*.3f+carrier.Position-defender.Position,.25f);
else {carrier.StumbleUntil=Seconds+.5f;if(carrier.VisualSpeed>4 && carrier.Stamina<.55f && Next()<.25f)FallDown(carrier,carrier.Velocity,.15f);}
}
carrier.Frustration=Math.Min(1,carrier.Frustration+.12f);
Tackles[defender.Team]++;if(BodyChallenge)BodyWins++;
Emit("tackle",defender,BodyChallenge?"КОРПУСОМ!":Sliding?"ПОДКАТ!":"ВЫБИЛ!",1.3f);
AddEvent($"{TeamName(defender.Team)} · выбил мяч #{defender.Number}");
}
else
{
if(!BodyChallenge && !Sliding){defender.StandingTackleResolvedAt=Seconds;defender.StandingTackleResult=-1;}
defender.Recovery=Sliding?1.2f*(1.1f-tackleSkill*.4f):
BodyChallenge?Math.Clamp(.40f-tackleSkill*.20f,.19f,.33f):
Math.Clamp(.60f-tackleSkill*.34f,.25f,.48f);
TackleMisses[defender.Team]++;
defender.Frustration=Math.Min(1,defender.Frustration+.09f);
Emit("miss",defender,"НЕ ДОСТАЛ",1);
}
DuelDefender=-1; DuelCarrier=-1; DuelRemaining=0;DuelClosestContact=float.MaxValue;
}
}