ShotAccuracy.cs

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.

Rough Code
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);
    }
}