FootballMatchEffects.cs

Static helper that computes one segment of the ball flight trail for football match visuals. Given the trail marks, current ball position and speed, it decides whether the segment is drawn and returns its endpoints, width, color and opacity, with colors keyed to unique skills and legendary abilities.

using System;
using System.Collections.Generic;

namespace Socer;

// Timing brief: physical contact peaks on contact, glints end at .18 s, grass
// settles by .60 s. Ordinary ball wake spans .10–.25 s of actual sampled flight.
// No camera-facing rings, random clocks or match-state changes are involved.
public static class FootballMatchEffects
{
    public readonly record struct FlightStroke(bool Visible,Point A,float ZA,Point B,float ZB,
        float Width,string Color,float Opacity);
    public static FlightStroke Flight(IReadOnlyList<FlightMark> trail,int index,Point ball,float height,
        float speed,bool free,UniqueSkill skill,int grade,LegendaryAbility legendary)
    {
        if(!free || speed<=9 || index<0 || index>=trail.Count)return default;
        bool special=skill!=UniqueSkill.None || legendary!=LegendaryAbility.None;
        int visible=special?11:speed>23?7:speed>15?5:3;
        int first=Math.Max(0,trail.Count-visible);
        if(index<first)return default;
        var a=trail[index];
        var b=index+1<trail.Count?trail[index+1]:new FlightMark{Position=ball,Height=height};
        float horizontal=(b.Position-a.Position).Length,dz=b.Height-a.Height;
        float length=MathF.Sqrt(horizontal*horizontal+dz*dz);
        // A restart, camera preview or seek must never draw a bridge across the pitch.
        if(length<.018f || length>3.2f)return default;
        float freshness=(index-first+1f)/Math.Max(1,trail.Count-first);
        float taper=freshness*freshness*(3-2*freshness);
        float width=(.008f+(special?.092f:.050f)*taper)*Math.Clamp(speed/23,.55f,1.12f);
        string color=skill switch
        {
            UniqueSkill.CurveComet=>freshness>.65f?"#ffe6a0":"#b68cd2",
            UniqueSkill.PowerDrive or UniqueSkill.AerialFinisher=>"#efc687",
            UniqueSkill.Sniper=>"#b8d2e8",
            UniqueSkill.ThreadNeedle=>"#a4d7b7",
            UniqueSkill.LongDistributor or UniqueSkill.KeeperDistributor=>"#a7dce0",
            _=>"#e0e6c8"
        };
        if(special && grade==2)width*=1.08f;
        if(legendary!=LegendaryAbility.None)color=LegendaryVisuals.BallColor(legendary,index);
        float opacity=(special?.28f:.12f)+(special?.46f:.36f)*taper;
        return new(true,a.Position,Math.Max(.11f,a.Height),b.Position,Math.Max(.11f,b.Height),width,color,opacity);
    }
}