Part of the football match simulation. Holds the stable PlayerProfile stat block (attributes, play styles, skill loadout) plus AI helpers on MatchSimulation: chaser selection, off-ball run planning, stamina and sprint bursts, facing/turn lean, and the shot windup, strike-kind selection and release pipeline that kicks the ball and emits effect events.
using System;
using System.Collections.Generic;
using System.Linq;
namespace Socer;
// Stable profile data, separate from mutable match state. Names/assets can be supplied by a licensed roster later.
public sealed class PlayerProfile
{
float volleyTechnique=-1;
// Generated/laboratory profiles inherit their finishing technique; real
// cards supply their separate volleys attribute when applied to a match.
public float Volleys {get=>volleyTechnique<0?Finishing:volleyTechnique;set=>volleyTechnique=value;}
public string SlotRole { get; set; }="";
public float RoleFamiliarity { get; set; }=1;
public int Overall { get; set; }=75;
public float Defence { get; set; }=.65f;
public float Interceptions { get; set; }=.65f;
public float StandingTackle { get; set; }=.65f;
public float SlidingTackle { get; set; }=.65f;
public float Physical { get; set; }=.65f;
public float HeadingAccuracy { get; set; }=.65f;
public float Jumping { get; set; }=.65f;
public float Goalkeeping { get; set; }=.7f;
public float GkDiving {get;set;}=.7f;
public float GkHandling {get;set;}=.7f;
public float GkReflexes {get;set;}=.7f;
public float GkPositioning {get;set;}=.7f;
public float GkKicking {get;set;}=.7f;
public string Id { get; set; }="";
public string CatalogId {get;set;}="";
public string CountryCode {get;set;}="";
public string Edition {get;set;}="";
public string Name { get; set; }="";
public string Style { get; set; }="";
public PlayStyle PlayStyles { get; set; }
public PlayStyle PlayStylesPlus { get; set; }
public UniqueSkill UniqueSkill { get; set; }
public List<SkillInstance> SkillLoadout { get; set; }=new();
public int SkinTone { get; set; }=-1;
public int HairTone { get; set; }=-1;
public float Passing { get; set; }
public float Finishing { get; set; }
public float Penalties {get;set;}=.65f;
public float ShotPower { get; set; }
public float LongShots { get; set; }
public float Curve { get; set; }
public float Crossing { get; set; }
public float FreeKickAccuracy { get; set; }
public float LongPassing { get; set; }
public int SkillMoves { get; set; }=3;
public float Vision { get; set; }
public float Endurance { get; set; }
public float Acceleration { get; set; }
public float Aggression { get; set; }
public float Composure { get; set; }=.65f;
public float OffBall { get; set; }
}
public sealed class MatchEffect
{
public int Id, Player=-1;
public string Kind="", Text="";
public Point Position;
public float Age, Duration;
}
public sealed partial class MatchSimulation
{
public List<MatchEffect> Effects { get; }=new();
public int[] ThroughBalls=new int[2];
public int WindupPlayer=-1;
public float WindupRemaining, ShotCueRemaining;
public bool PowerShot;
public Point ShotTarget;
public int ShotPreparations, CancelledShots;
int effectId;
float nextSpeech;
readonly int[] chasers={-1,-1}, secondChasers={-1,-1};
public bool ShotInFlight => shot && Owner<0;
public float Danger
{
get
{
if(RestartRemaining>0 || Finished) return 0;
if(WindupPlayer>=0 || ShotInFlight) return 1;
float endDistance=Math.Min(Ball.X,105-Ball.X);
return Math.Clamp((34-endDistance)/17,0,1);
}
}
PlayerProfile CreateProfile(Footballer p)
{
string style=p.Keeper?"Вратарь":p.Number<=5?"Опорный":p.Number is 6 or 9?"Бегунок":p.Number<=8?"Диспетчер":"Завершитель";
return new PlayerProfile {
Id=$"demo-{p.Team}-{p.Number}", Name=$"{TeamName(p.Team)} #{p.Number}", Style=style,
Passing=.55f+Next()*.3f+(style=="Диспетчер"?.12f:0),
Vision=.5f+Next()*.3f+(style=="Диспетчер"?.18f:0),
Finishing=.45f+Next()*.3f+(style=="Завершитель"?.22f:0),
ShotPower=.5f+Next()*.35f,
LongShots=.45f+Next()*.38f, Curve=.4f+Next()*.4f,
Crossing=.56f+(p.Id%6)*.045f, FreeKickAccuracy=.51f+(p.Id%7)*.05f,
LongPassing=.45f+Next()*.38f, SkillMoves=p.Keeper?1:2+(p.Number%3),
Endurance=.5f+Next()*.45f, Acceleration=.5f+Next()*.3f+(style=="Бегунок"?.17f:0),
Physical=.5f+Next()*.35f,
HeadingAccuracy=(p.Number is 3 or 4 ? .67f : p.Number>=10 ? .73f : .48f)+((p.Id*37+Seed%17)%20)*.01f,
Jumping=(p.Number is 3 or 4 ? .68f : p.Number>=10 ? .66f : .48f)+((p.Id*29+Seed%13)%21)*.01f,
Aggression=.4f+Next()*.5f, Composure=.25f+(p.Id*17+Seed%37)%65/100f, OffBall=.5f+Next()*.3f+(style is "Бегунок" or "Завершитель"?.18f:0)
};
}
void UpdateChasers()
{
for(int t=0;t<2;t++)
{
var order=ActivePlayers.Where(p=>p.Team==t&&!p.Keeper && Seconds>=p.FallUntil && !p.IsAcrobaticCommitted(Seconds)).OrderBy(p=>(p.Position-Ball).Length/(.7f+p.Stamina*.3f)).ToArray();
if(order.Length==0){chasers[t]=-1;secondChasers[t]=-1;continue;}
if(order.Length==1){chasers[t]=order[0].Id;secondChasers[t]=-1;continue;}
var candidate=order[0];
// A new closer player takes over only with a meaningful advantage; avoid chaser flicker.
if(chasers[t]<0 || Players[chasers[t]].SentOff || Seconds<Players[chasers[t]].FallUntil || Players[chasers[t]].IsAcrobaticCommitted(Seconds) || (Players[chasers[t]].Position-Ball).Length>(candidate.Position-Ball).Length+2.5f) chasers[t]=candidate.Id;
secondChasers[t]=order.First(p=>p.Id!=chasers[t]).Id;
}
}
Point PlanRun(Footballer p,Point target,int possessionTeam)
{
if(p.Keeper || Owner==p.Id) return target;
if(possessionTeam!=p.Team) { p.RunUntil=0; return target; }
int dir=Direction(p.Team);
if(p.RunUntil>Seconds)
{
p.Action="Открывание";
// Hold the line until the pass is released.
float offsideLine=ActivePlayers.Where(q=>q.Team!=p.Team).Select(q=>q.Position.X*dir).OrderByDescending(x=>x).Skip(1).First();
var run=p.RunTarget;
if(Owner>=0) run.X=dir*Math.Min(run.X*dir,Math.Max(offsideLine-1.5f,Ball.X*dir));
return run;
}
if(Owner>=0 && AttackUnit(p) && p.BurstCooldown<=0 && p.Stamina>.28f &&
Players[Owner].Held>.4f && (p.Position-Ball).Length is > 8 and < 32 &&
ActivePlayers.Count(q=>q.Team==p.Team&&q.RunUntil>Seconds)<2 &&
p.Decision<=0)
{
p.Decision=.8f+Next()*.6f;
if(Next()<p.Profile.OffBall*(Approach[p.Team]==2?.67f:Approach[p.Team]==0?.3f:.45f))
{
float side=p.Position.Y<34?-1:1;
float cut=p.Profile.Style=="Бегунок"?side*5:-side*4;
var run=Clamp(p.Position+new Point(dir*(10+p.Profile.OffBall*8),cut));
float space=ActivePlayers.Where(q=>q.Team!=p.Team).Min(q=>(q.Position-run).Length);
if(space>3)
{
p.RunTarget=run; p.RunUntil=Seconds+3;
p.Action="Открывание"; Say(p,p.Number>=10?"Вразрез!":"Мне!",false);
return run;
}
}
}
return target;
}
public static float EffectivePace(Footballer p) => p.Pace*(.58f+.42f*p.Stamina)*(p.Sprinting?1.48f:1)*
(p.ActiveUniqueSkill==UniqueSkill.RecoveryBurst && p.ActiveSkillRemaining>0 && p.Sprinting?1+.075f*p.ActiveSkillPower:1);
public void Recover(Footballer p,float dt)
{
p.BurstRemaining=0;
p.BurstCooldown=Math.Max(0,p.BurstCooldown-dt);
p.BurstCharge=Math.Min(1,p.BurstCharge+dt*.12f);
p.Stamina=Math.Min(1,p.Stamina+dt*(.0035f+.003f*p.Profile.Endurance+.001f*p.Profile.Physical));
}
void UpdateEffort(Footballer p,Point target,float dt,int possessionTeam)
{
float distance=(target-p.Position).Length;
bool recoverySprint=Owner>=0 && Players[Owner].Team!=p.Team && !p.Keeper &&
p.Id==chasers[p.Team] && p.DefensiveRecoveryUntil>Seconds && distance>1.25f;
bool shoulderPursuit=BodyChallenge && DuelDefender==p.Id && Owner==DuelCarrier;
bool urgent=UrgentLooseBall(p) || p.KeeperRushing || p.AerialChasing || recoverySprint;
bool secondBall=Owner<0 && BallSpeed>3 && p.SecondBallUntil>Seconds;
bool recoverMark=possessionTeam!=p.Team && p.AutoMarkId>=0 && !Players[p.AutoMarkId].SentOff &&
Observed(p,Players[p.AutoMarkId]) is Point marked && (marked-World(new Point(0,34),p.Team)).Length<32 && distance>1.8f;
// The moving ball is a nearby contact target, not the end of a carrier's run.
bool carryBurst=Owner==p.Id && !p.Keeper && WindupPlayer!=p.Id && p.ShotSetupUntil<=0 &&
Seconds>=p.KickSetupUntil && Seconds>=p.ShieldUntil && Seconds>=p.ReceiveUntil &&
World(p.Position,p.Team).X>35 &&
(OpenCarryDistance(p)>6 || (p.CarryAvoidUntil>Seconds && CarryLane(p,p.CarryAvoidTarget)>1.7f && (p.CarryAvoidTarget-p.Position).Length>2)) && CarrierPressure(p)>1.5f;
bool intent=shoulderPursuit || recoverySprint || carryBurst || recoverMark || LinkedCoverRecovery(p) || (p.DirectOrderUntil>Seconds && distance>2 &&
(p.DirectOrder==DirectOrderKind.AttackRun || (p.DirectOrder==DirectOrderKind.MarkPlayer && possessionTeam!=p.Team))) || (urgent?distance>1.1f:!p.Keeper && WindupPlayer!=p.Id && distance>2.5f &&
(secondBall || (p.DirectOrder==DirectOrderKind.PressPlayer && Owner>=0 && Players[Owner].Team!=p.Team) || p.RunUntil>Seconds || (p.FootballRunUntil>Seconds && p.FootballRun is FootballRun.Behind or FootballRun.Overlap or FootballRun.Decoy) || p.BoxRole is 1 or 2 ||
(counterPressUntil[p.Team]>Seconds && p.Id==secondChasers[p.Team]) ||
(Owner==p.Id && World(p.Position,p.Team).X>48) ||
(p.Id==chasers[p.Team] && possessionTeam!=p.Team && (p.Position-Ball).Length<23)));
if(BroadcastPlanActive[p.Team] && TeamTempo[p.Team]>.7f && !p.Keeper && distance>4 &&
possessionTeam==p.Team && Owner!=p.Id && !BackLine(p) && WindupPlayer!=p.Id)intent=true;
if(recoverySprint && p.BurstRemaining<=0 && p.DefensiveRecoveryBurstReadyAt<=Seconds &&
p.BurstCharge>.16f && p.Stamina>.14f)
{
ActivateSkill(p,UniqueSkill.RecoveryBurst,1.7f);
p.BurstRemaining=.78f+p.Profile.Defence*.65f;
p.BurstCooldown=Math.Max(p.BurstCooldown,2.4f);
p.DefensiveRecoveryBurstReadyAt=Seconds+1.9f;
p.Sprints++;DefensiveRecoverySprints++;
}
else if(intent && p.BurstRemaining<=0 && (p.BurstCooldown<=0 || (urgent && p.BurstCooldown<2)) && p.BurstCharge>(urgent?.25f:.75f) && p.Stamina>(urgent?.15f:.24f))
{
p.BurstRemaining=1.3f+p.Profile.Endurance*1.1f;
p.BurstCooldown=urgent?3:5.5f*(BroadcastPlanActive[p.Team]?1.3f-TeamTempo[p.Team]*.6f:1); p.Sprints++;
}
bool sprint=p.Sprinting && intent;
if(!intent) p.BurstRemaining=0;
if(sprint)
{
p.BurstRemaining=Math.Max(0,p.BurstRemaining-dt);
p.BurstCharge=Math.Max(0,p.BurstCharge-dt*.34f);
float economy=1-.08f*SkillStrength(p,UniqueSkill.Conditioning)-
(SkillActive(p,UniqueSkill.RecoveryBurst)?.10f*p.ActiveSkillPower:0);
p.Stamina=Math.Max(.08f,p.Stamina-dt*(.055f-.022f*p.Profile.Endurance-.008f*p.Profile.Physical)*PlanEffort(p.Team)*economy);
if(p.KeeperRushing || urgent || p.Action is "Позиция" or "Прессинг" or "Ведение")p.Action=p.KeeperRushing?"Рывок вратаря":urgent?"Рывок за мячом":"Рывок";
}
else
{
p.BurstCharge=Math.Min(1,p.BurstCharge+dt*.12f);
float drain=p.Velocity.Length>3 ? .0025f*(1.25f-p.Profile.Endurance*.5f) :
-(.0035f+.003f*p.Profile.Endurance+.001f*p.Profile.Physical);
p.Stamina=Math.Clamp(p.Stamina-dt*drain*(drain>0?PlanEffort(p.Team):1),.08f,1);
}
}
void UpdateFacing(Footballer p,Point target,float dt)
{
if(p.IsAcrobaticCommitted(Seconds))return;
int receptionShot=FirstTimePhase(p);
bool strategyFinish=receptionShot>=0 && Owner!=p.Id && (Ball-p.Position).Length<14;
bool openFinish=!RehearsingStrategy && Owner<0 && PassReceiver==p.Id && LastTouch==p.Team && BallHeight<1 &&
(Ball-p.Position).Length<14 && (World(new Point(105,34),p.Team)-p.Position).Length<25 &&
FinishingWindow(p).Lane>1.05f;
bool readyToFinish=strategyFinish || openFinish;
bool keepingGoal=p.Keeper && Owner!=p.Id && KeeperKickPlayer!=p.Id;
bool committedSave=p.Keeper && KeeperKickPlayer!=p.Id && (p.KeeperBlockUntil>0 && Seconds<p.KeeperBlockUntil+.22f ||
p.DiveRemaining>0 || Seconds-p.DiveEndedAt<HumanFigure.DiveRecoverySeconds(p,this));
Point look=KeeperKickPlayer==p.Id?KeeperKickTarget-p.Position:WindupPlayer==p.Id?ShotTarget-p.Position:
Owner==p.Id && p.ShotSetupUntil>0?FinishingWindow(p).Target-p.Position:
strategyFinish?World(new Point(106,31.5f+Strategy.Plan.Phases[receptionShot].Corner*2.5f),p.Team)-p.Position:
openFinish?FinishingWindow(p).Target-p.Position:
Owner==p.Id && Seconds<p.KickSetupUntil?p.KickAim-p.Position:
Owner==p.Id && p.Velocity.Length>1?p.Velocity.Unit*.65f+(target-p.Position).Unit*.35f:
Owner==p.Id?target-p.Position:
(Ball-p.Position).Length<22 || p.Keeper?Ball-p.Position:target-p.Position;
if(committedSave)
look=p.KeeperSaveFacing.Length>.05f?p.KeeperSaveFacing:new Point(Direction(p.Team),0);
if(!keepingGoal && !committedSave && !readyToFinish && Owner!=p.Id && Seconds<p.ReceiveReadyUntil)look=(Ball-p.Position).Unit*.65f+p.ReceiveDirection*.65f;
if(!keepingGoal && !committedSave && !readyToFinish && LinkedReceptionFacing(p,out var linkedLook))look=linkedLook;
if(look.Length<.05f)
{
p.TurnLean*=MathF.Exp(-dt*8);
return;
}
bool receivingSideways=!RehearsingStrategy && Owner!=p.Id && Owner>=0 && Players[Owner].Team==p.Team && (Ball-p.Position).Length<22 && p.Velocity.Length<4.5f;
// Keepers retreat and shuffle while watching the shot. An outfielder's
// running-direction override turned that defensive movement into a side-on block.
if(!keepingGoal && !committedSave && !readyToFinish && !receivingSideways && WindupPlayer!=p.Id && Seconds>=p.KickSetupUntil && p.Velocity.Length>2 && look.Length>.1f && Point.Dot(p.Velocity.Unit,look.Unit)<-.3f)
look=p.Velocity.Unit*.8f+look.Unit*.2f;
float angle=MathF.Atan2(look.Y,look.X);
float difference=MathF.Atan2(MathF.Sin(angle-p.Facing),MathF.Cos(angle-p.Facing));
float turn=(3.8f+p.Profile.Acceleration*1.2f+p.Stamina*.4f)*
(1-Math.Clamp((p.Velocity.Length-3)/8,0,.32f));
float rotation=Math.Clamp(difference,-dt*turn,dt*turn);
p.Facing+=rotation;
float leanTarget=Math.Clamp(rotation/Math.Max(dt,.001f)/turn,-1,1)*
Math.Clamp(p.Velocity.Length/4,0,1);
p.TurnLean+=(leanTarget-p.TurnLean)*(1-MathF.Exp(-dt*8));
}
void Emit(string kind,Footballer p,string text,float duration)
{
if(kind=="tackle")lastDialogueTackler[p.Team]=p.Id;
EmitAt(kind,p.Position,text,duration);
Effects[^1].Player=p.Id;
}
void EmitAt(string kind,Point at,string text,float duration)
{
Effects.Add(new MatchEffect {Id=++effectId,Kind=kind,Position=at,Text=text,Duration=duration});
if(Effects.Count>8) Effects.RemoveAt(0);
}
public void BeginShot(Footballer p,float distance)
{
penaltyShot=false;
p.KickSetupUntil=0;p.ShotSetupUntil=0;
PowerShot=distance>(PlayerPlayStyles.Has(p,PlayStyle.PowerShot)?16:20) &&
p.Profile.ShotPower>.67f && p.Profile.LongShots>.58f;
var keeper=Players.First(q=>q.Team!=p.Team && q.Keeper);
bool keeperOut=Math.Abs(keeper.Position.X-(Direction(p.Team)>0?105:0))>6;
float touch=p.Profile.Finishing*.72f+p.Profile.Composure*.28f;
Strike=distance<24 && touch>.55f && keeperOut &&
Next()<.35f+touch*.35f?StrikeKind.Chip:
Math.Abs(p.Position.Y-34)>5 && distance>13 && p.Profile.Curve>.44f && Next()<
p.Profile.Curve*.6f+touch*.2f+(PlayerPlayStyles.Has(p,PlayStyle.FinesseShot)?.12f:0)?StrikeKind.Curl:
PowerShot && Next()<.20f+p.Profile.LongShots*.45f+(PlayerPlayStyles.Has(p,PlayStyle.PowerShot)?.12f:0)?StrikeKind.Power:StrikeKind.Driven;
PowerShot=Strike==StrikeKind.Power;
float balance=ShotBalance(p);
float accuracy=distance>22?p.Profile.Finishing*.38f+p.Profile.LongShots*.62f:p.Profile.Finishing;
float spread=.16f+distance*.013f+(1-accuracy)*3.2f+(1-p.Profile.Composure)*1.5f+(1-p.Stamina)*1.3f+(1-balance)*3;
if(!p.RushedShot)spread*=1-.12f*SkillStrength(p,UniqueSkill.Sniper);
var window=FinishingWindow(p);
// Extend the chosen goal-line ray; moving X beyond the line must not shift the aim inward.
var goalRay=window.Target-p.Position;
float beyond=Math.Abs(goalRay.X)>.1f?(Math.Abs(goalRay.X)+1)/Math.Abs(goalRay.X):1;
ShotTarget=p.Position+goalRay*beyond;
ShotTarget.Y+=(Next()-.5f)*spread;
WindupPlayer=p.Id; WindupRemaining=PowerShot?.48f:.28f;
p.RunningShot=!PowerShot && p.Velocity.Length>3.2f && distance<29 &&
Point.Dot(new Point(MathF.Cos(p.Facing),MathF.Sin(p.Facing)),(ShotTarget-p.Position).Unit)>.8f;
if(p.RunningShot)WindupRemaining=.18f;
bool laser=distance is >=8 and <=24 && !p.RushedShot && balance>.62f && window.Lane>1.1f &&
ActivateLegendary(p,LegendaryAbility.LaserFinish);
if(laser)
{
// A planted preparation can be closed down. The chosen corner and
// the ball still use the ordinary physical shot pipeline.
Strike=StrikeKind.Driven;PowerShot=false;p.RunningShot=false;WindupRemaining=.42f;
ShotTarget=p.Position+goalRay*beyond+(ShotTarget-(p.Position+goalRay*beyond))*.24f;
p.LegendaryTargetA=window.Target;
}
p.WindupStartedAt=Seconds;
p.Action="Замах";
ShotPreparations++;
p.BurstRemaining=0;
Say(p,PowerShot?"Бью!":"Сейчас!",true);
}
void CancelWindup()
{
manualShot=false;penaltyShot=false;preparedFreeKickShot=false;
if(WindupPlayer>=0) CancelledShots++;
WindupPlayer=-1; WindupRemaining=0;
}
void ReleaseShot(Footballer p)
{
// Prepared shots wait for the body; a genuine first-time strike has no windup.
if(WindupPlayer==p.Id && !PrepareKickFacing(p,ShotTarget))
{WindupRemaining=.06f;p.Action="Доворачивается под удар";return;}
WindupPlayer=-1; WindupRemaining=0;
float distance=(ShotTarget-p.Position).Length;
bool comet=distance is >10 and <28 && p.Profile.Curve>.38f &&
SkillReady(p,UniqueSkill.CurveComet) && Next()<.48f &&
ActivateSkill(p,UniqueSkill.CurveComet);
if(comet){Strike=StrikeKind.Curl;PowerShot=false;}
bool cannon=!comet && Strike==StrikeKind.Power && distance>17 &&
ActivateSkill(p,UniqueSkill.PowerDrive);
float sniperPower=SkillStrength(p,UniqueSkill.Sniper);
bool sniper=!comet && !cannon && distance<28 && sniperPower>0 && !p.RushedShot;
if(World(p.Position,p.Team).X<88.5f)LongRangeShots++;
if(p.Velocity.Length>3.2f)MovingShots++;
float effort=(Strike==StrikeKind.Power?35:Strike==StrikeKind.Curl?27:25)+p.Profile.ShotPower*4;
float quality=.78f+(preparedFreeKickShot?p.Profile.FreeKickAccuracy:p.Profile.Finishing)*.16f+p.Stamina*.10f;
float speed=Math.Clamp(effort*quality*(.94f+Next()*.12f)*(Strike==StrikeKind.Power && PlayerPlayStyles.Has(p,PlayStyle.PowerShot)?1.08f:1),20,42);
if(cannon)speed=Math.Min(44,speed*(1+.10f*p.ActiveSkillPower));
ApplyShotExecutionError(p);
if(IsMediaLeague)ApplyMediaShotError(p);
if(comet)ShotTarget.Y=Math.Clamp(ShotTarget.Y,31.9f,36.1f);
if(sniper)
{
var precise=FinishingWindow(p).Target;
float line=Direction(p.Team)>0?105:0;
float beyond=Math.Abs(ShotTarget.X-p.Position.X)/Math.Max(.1f,Math.Abs(line-p.Position.X));
float ideal=p.Position.Y+(precise.Y-p.Position.Y)*beyond;
ShotTarget.Y+=(ideal-ShotTarget.Y)*(.12f*sniperPower);
}
preparedFreeKickShot=false;
penaltyShot=false;
Kick(p,ShotTarget,speed,true);
ShapeFlight(ShotTarget,Strike,ShotArrivalHeight);
if(LegendaryActive(p,LegendaryAbility.LaserFinish))BallLegendaryAbility=LegendaryAbility.LaserFinish;
if(comet){BallSpin*=1+.55f*p.ActiveSkillPower;BallUniqueSkill=UniqueSkill.CurveComet;BallSkillGrade=p.ActiveSkillGrade;}
else if(cannon || sniper){BallUniqueSkill=cannon?UniqueSkill.PowerDrive:UniqueSkill.Sniper;BallSkillGrade=cannon?p.ActiveSkillGrade:UniqueSkills.Grade(p.Profile,UniqueSkill.Sniper);}
if(manualShot)ShapeManualShot();
LastShotSpeed=MathF.Sqrt(BallSpeed*BallSpeed+VerticalSpeed*VerticalSpeed);
// Give every shot a visible slow-motion flight, including close finishes and chips.
ShotCueRemaining=Math.Clamp((ShotTarget-Ball).Length/Math.Max(1,BallSpeed)+.35f,.55f,2.5f);
if(Strike==StrikeKind.Curl) CurledShots++;
if(Strike==StrikeKind.Chip) Chips++;
Shots[p.Team]++;
if(secondChanceTeam==p.Team && Seconds<secondChanceUntil)
{ReboundShots++;secondChanceTeam=-1;secondChanceUntil=0;}
string label=Strike==StrikeKind.Chip?"ПАРАШЮТ":Strike==StrikeKind.Curl?"КРУЧЁНЫЙ":PowerShot?"МОЩНЫЙ УДАР":"НИЗОМ!";
Emit("shot",p,label,1.6f);
AddEvent($"{TeamName(p.Team)} · {label.ToLowerInvariant()} #{p.Number}!");
}
}