Part of the football match simulation. Computes shot execution error from player finishing, range, pressure and skills, redirects headers with a vertical velocity solution, counts wide and over shots and runs the restart after a missed shot. Also has an editor preview that samples a seed until a high or wide shot occurs.
using System;
namespace Socer;
public sealed partial class MatchSimulation
{
public float ShotArrivalHeight { get; private set; }
public int[] ShotsWide { get; } = new int[2];
public int[] ShotsOver { get; } = new int[2];
public float ShotMissFlightRemaining { get; private set; }
int missedShotRestartTeam;
Point missedShotRestartPoint;
bool penaltyShot;
bool UncontestedCloseFinish(Footballer p,Point goal)
{
if((goal-p.Position).Length>15 || Math.Abs(p.Position.Y-34)>11 ||
FinishingWindow(p).Lane<.8f)return false;
return ActivePlayers.Where(q=>q.Team!=p.Team && !q.Keeper)
.All(q=>(q.Position-p.Position).Length>2.5f);
}
void ApplyShotExecutionError(Footballer p)
{
ShotArrivalHeight = Strike == StrikeKind.Power ? 1 : Strike == StrikeKind.Curl ? .65f : 0;
// Rehearsals demonstrate the authored combination. Manual set pieces retain
// the coach's chosen height and their own execution error, including penalties.
if (StrategyWalkthrough || manualShot || penaltyShot) return;
var goal = new Point(Direction(p.Team) > 0 ? 105 : 0, 34);
var offset = goal - p.Position;
float range = Math.Clamp((offset.Length - 12) / 24, 0, 1);
float angle = Math.Clamp(Math.Abs(offset.Y) / Math.Max(8, Math.Abs(offset.X)) * .65f, 0, 1);
float pressure = Math.Clamp((3.5f - CarrierPressure(p)) / 3.5f, 0, 1);
float striking = preparedFreeKickShot ? p.Profile.FreeKickAccuracy*.75f+p.Profile.Curve*.25f :
offset.Length > 22 ? p.Profile.Finishing * .35f + p.Profile.LongShots * .65f : p.Profile.Finishing;
float quality = Math.Clamp(striking * .72f + p.Profile.Composure * .18f + p.Skill * .10f, 0, 1);
if (Strike == StrikeKind.Curl) quality = Math.Clamp(quality * .75f + p.Profile.Curve * .25f, 0, 1);
if(!preparedFreeKickShot && UncontestedCloseFinish(p,goal))
{
// A clear one-on-one can still be saved. A complete miss from this
// range is exceptional even for a weak finisher; poor finishing is
// expressed mainly through placement and pace into the keeper.
float rareMiss=.018f+(1-quality)*.045f+(p.RushedShot?.018f:0)+(1-p.Stamina)*.025f;
if(Next()>=rareMiss)
{
ShotArrivalHeight=Math.Min(ShotArrivalHeight,2.05f);
float toLine=(goal.X-p.Position.X)/Math.Max(.1f,ShotTarget.X-p.Position.X);
if(Direction(p.Team)<0)toLine=(goal.X-p.Position.X)/Math.Min(-.1f,ShotTarget.X-p.Position.X);
float atLine=p.Position.Y+(ShotTarget.Y-p.Position.Y)*toLine;
// Close low-rated players favour a safer central finish. Higher
// technique keeps the originally chosen corner.
float corner=Math.Clamp(.55f+quality*.45f,.55f,1);
float placed=34+(atLine-34)*corner;
float safeRadius=2.05f+quality*1.22f;
float safe=Math.Clamp(placed,34-safeRadius,34+safeRadius);
ShotTarget.Y=p.Position.Y+(safe-p.Position.Y)/Math.Max(.1f,toLine);
return;
}
}
float weakFoot = p.UsedWeakFoot ? (5 - Math.Clamp(p.WeakFootRating, 1, 5)) / 4f : 0;
float difficulty = .04f + (1 - quality) * .20f + range * .13f + angle * .08f +
pressure * .12f + (1 - p.Stamina) * .09f + weakFoot * .12f +
(p.RushedShot ? .05f : 0) + Math.Clamp((p.Velocity.Length - 3) * .018f, 0, .08f);
difficulty += Strike == StrikeKind.Power ? .10f : Strike == StrikeKind.Curl ? .06f : Strike == StrikeKind.Chip ? .04f : 0;
if(!p.RushedShot)difficulty*=1-.12f*SkillStrength(p,UniqueSkill.Sniper);
if(SkillActive(p,UniqueSkill.PowerDrive))difficulty*=1-.16f*p.ActiveSkillPower;
if(LegendaryActive(p,LegendaryAbility.LaserFinish))difficulty*=.62f;
if (Strike == StrikeKind.Power && PlayerPlayStyles.Has(p, PlayStyle.PowerShot))
difficulty *= PlayerPlayStyles.Grade(p,PlayStyle.PowerShot)==PlayStyleGrade.PlayStylePlus?.77f:.88f;
if (Strike == StrikeKind.Curl && PlayerPlayStyles.Has(p, PlayStyle.FinesseShot))
difficulty *= PlayerPlayStyles.Grade(p,PlayStyle.FinesseShot)==PlayStyleGrade.PlayStylePlus?.67f:.78f;
ShotArrivalHeight = Strike == StrikeKind.Driven ? 0 : .20f + Next() * (Strike == StrikeKind.Power ? 1.7f : 1.5f);
if (Next() >= Math.Clamp(difficulty, .025f, .65f)) return;
float highChance = Strike == StrikeKind.Power ? .64f : Strike == StrikeKind.Curl ? .48f : Strike == StrikeKind.Chip ? .65f : .22f;
if (Next() < highChance)
{
// A poor contact lifts the launch angle, rather than moving a ball in mid-air.
ShotArrivalHeight = 3.1f + MathF.Pow(Next(), .75f) *
(Strike == StrikeKind.Power ? 5 : 3.7f) * (.7f + range * .3f);
ShotTarget.Y += (Next() - .5f) * 2.2f;
}
else
{
float side = Math.Abs(ShotTarget.Y - 34) > .5f ? MathF.Sign(ShotTarget.Y - 34) : Next() < .5f ? -1 : 1;
if (Next() < .2f) side = -side;
float goalLineY = 34 + side * (4.25f + MathF.Pow(Next(), .7f) * (2 + range * 7 + (1 - quality) * 3));
float beyond = Math.Abs(ShotTarget.X - p.Position.X) / Math.Max(.1f, Math.Abs(goal.X - p.Position.X));
ShotTarget.Y = p.Position.Y + (goalLineY - p.Position.Y) * beyond;
}
}
(Point Target,float Speed,float Vertical) HeaderFinish(Footballer p,Point contact,float height,bool pressured)
{
bool royal=height is >=1.6f and <=2.3f && (World(new Point(105,34),p.Team)-contact).Length<20 &&
ActivateLegendary(p,LegendaryAbility.RoyalReception);
bool sky=!royal && (World(new Point(105,34),p.Team)-contact).Length<20 &&
ActivateLegendary(p,LegendaryAbility.SkySiu);
bool specialist=SkillActive(p,UniqueSkill.AerialFinisher) || ActivateSkill(p,UniqueSkill.AerialFinisher);
float contactTechnique=royal?p.Profile.Volleys:p.Profile.HeadingAccuracy;
float technique=Math.Clamp(contactTechnique*.72f+p.Profile.Composure*.18f+p.Stamina*.10f,0,1);
var window=FinishingWindow(p,technique);
var ray=window.Target-contact;
float extend=Math.Abs(ray.X)>.1f?(Math.Abs(ray.X)+1)/Math.Abs(ray.X):1;
float distance=ray.Length;
float spread=.2f+distance*.025f+(1-p.Profile.HeadingAccuracy)*4+(pressured?.6f:0);
if(specialist)spread*=1-.28f*p.ActiveSkillPower;
if(royal || sky)spread*=.64f;
var target=contact+ray*extend;
target.Y+=(Next()-.5f)*spread;
// Redirect the incoming ball with the neck/body, then address a low
// arrival at the goal. A fixed downward velocity made every long header
// hit the ground well before its intended corner, independent of skill.
float speed=Math.Clamp(10+p.Profile.HeadingAccuracy*9+p.Profile.Physical*3+BallSpeed*.10f,13,26);
if(specialist)speed=Math.Min(29,speed*(1+.08f*p.ActiveSkillPower));
if(royal || sky)speed=Math.Min(30,speed*(royal?1.16f:1.12f));
float time=-MathF.Log(Math.Max(.05f,1-.08f*(target-contact).Length/speed))/.08f;
float arrival=.2f+(1-p.Profile.HeadingAccuracy)*1.1f+(pressured?.18f:0);
float vertical=(arrival-height)/Math.Max(.15f,time)+4.905f*time;
if(royal || sky)p.LegendaryTargetA=target;
return(target,speed,vertical);
}
void BeginMissedShotFlight(int attacker, float goalX, float crossingHeight)
{
bool high = crossingHeight >= 2.33f;
if (high) ShotsOver[attacker]++; else ShotsWide[attacker]++;
RegisterShotContact(high ? "ВЫШЕ ВОРОТ" : "МИМО ВОРОТ");
EmitAt("miss", Ball, high ? "ВЫШЕ!" : "МИМО!", 1.8f);
AddEvent($"{TeamName(attacker)} · {(high ? "удар выше ворот" : "удар мимо ворот")}");
ShotMissFlightRemaining = .45f;
missedShotRestartTeam = 1 - attacker;
missedShotRestartPoint = new Point(goalX == 0 ? 6 : 99, 34);
}
void AdvanceMissedShotFlight(float dt)
{
AdvanceFlight(dt);
flightAge += dt;
foreach (var p in ActivePlayers)
{
p.Velocity *= MathF.Exp(-dt * 8);
p.Position = Clamp(p.Position + p.Velocity * dt);
if (p.DiveRemaining > 0)
{
p.DiveAge += dt;
p.DiveRemaining = Math.Max(0, p.DiveRemaining - dt);
if (p.DiveRemaining == 0) p.DiveEndedAt = Seconds;
}
}
ShotMissFlightRemaining = Math.Max(0, ShotMissFlightRemaining - dt);
if (ShotMissFlightRemaining == 0)
Restart(missedShotRestartTeam, missedShotRestartPoint,
"Удар от ворот · " + TeamName(missedShotRestartTeam), goalKick: true);
}
// Editor preview selects a genuine sampled strike; it never forces a miss trajectory.
public static MatchSimulation ShotAccuracyPreview(string kind)
{
bool high = kind.EndsWith("high");
var strike = kind.Contains("curl") ? StrikeKind.Curl : StrikeKind.Power;
for (int seed = 8600; seed < 9100; seed++)
{
var m = new MatchSimulation(seed) { Seconds = 30, RestartRemaining = 0, Owner = 9, LastTouch = 0 };
foreach (var q in m.Players) { q.Position = new Point(45, 5 + q.Id * 2); q.Decision = 100; }
var p = m.Players[9]; p.Position = new Point(78, strike == StrikeKind.Curl ? 25 : 34);
p.Facing = MathF.Atan2(34 - p.Position.Y, 105 - p.Position.X); p.LastControlAt = 29;
p.Skill = .55f; p.Profile.Finishing = .55f; p.Profile.Composure = .55f; p.Profile.ShotPower = .8f;
m.Players[11].Position = new Point(103, 34); m.Ball = p.Position + new Point(.3f, 0);
m.BeginShot(p, 27); m.Strike = strike; m.PowerShot = strike == StrikeKind.Power;
m.WindupPlayer = -1; m.ReleaseShot(p);
if (high ? m.ShotArrivalHeight > 3 : m.ShotArrivalHeight < 2 && Math.Abs(m.ShotTarget.Y - 34) > 5)
return m;
}
throw new InvalidOperationException("No sampled shot preview for " + kind);
}
}