LegendaryVisuals.cs

Static helper that produces the short wake-beam strokes for each legendary ability in the Socer match renderer. Also holds the ability durations, an age-based fade envelope, the caption suppression rule against duplicate event badges, and ball segment colours.

using System;

namespace Socer;

// Small, deterministic shapes shared by the live renderer and replay. Every
// signature uses at most eight existing wake beams, with no particles or lights.
public static class LegendaryVisuals
{
    public const int MaximumStrokes=8;
    public readonly record struct Stroke(Point A,float ZA,Point B,float ZB,float Width,string Color);

    // Seconds from the football contact (LaserFinish) or activation. Short
    // accents leave the player's silhouette readable during recovery.
    public static float Duration(LegendaryAbility ability)=>ability switch
    {
        LegendaryAbility.LaserFinish=>.36f,LegendaryAbility.RoyalReception=>.42f,
        LegendaryAbility.NotToday=>.62f,LegendaryAbility.SkySiu=>.65f,
        LegendaryAbility.ChessPass=>.78f,LegendaryAbility.OrderSeven=>1.1f,
        LegendaryAbility.EntryDenied=>.72f,_=>.92f
    };
    public static float Envelope(LegendaryAbility ability,float age)
    {
        if(!float.IsFinite(age) || age<0 || ability==LegendaryAbility.None)return 0;
        float duration=Duration(ability);
        if(age>=duration)return 0;
        float Ease(float t){t=Math.Clamp(t,0,1);return t*t*(3-2*t);}
        return Ease(age/.045f)*(1-Ease((age-duration*.35f)/(duration*.65f)));
    }

    public static bool ShowCaption(MatchEffect effect,MatchSimulation match)
    {
        if(effect.Kind=="goal")return false;
        // An action can emit both its football event and a legendary activation.
        // Keep both in telemetry, but show the single localized signature badge.
        // Incidents and failures still need their own visible explanation.
        if(effect.Player<0 || effect.Kind is not ("shot" or "header" or "dribble" or "pass" or "skill" or "save" or "intercept" or "block"))return true;
        foreach(var other in match.Effects)
            if(other.Kind=="legendary" && other.Player==effect.Player && other.Age<other.Duration)return false;
        return true;
    }

    public static string BallColor(LegendaryAbility ability,int segment)=>ability switch
    {
        LegendaryAbility.LaserFinish=>segment%3==0?"#fff4a8":"#ffd84d",
        LegendaryAbility.RoyalReception=>segment%3==0?"#fff5d5":"#eabe69",
        LegendaryAbility.SkySiu=>segment%3==0?"#ffffff":"#e76b73",
        LegendaryAbility.ChessPass=>segment%3==0?"#f8f1df":"#aecfc5",
        LegendaryAbility.OrderSeven=>segment%3==0?"#e4ce82":"#d59862",
        _=>"#9dedf1"
    };

    public static Stroke Get(LegendaryAbility ability,int segment,Point centre,float facing,float age)
    {
        var forward=new Point(MathF.Cos(facing),MathF.Sin(facing));
        var side=new Point(-forward.Y,forward.X);
        age=Math.Max(0,age);
        Point Local(float x,float y)=>centre+forward*x+side*y;
        Stroke Line(float ax,float ay,float az,float bx,float by,float bz,float width,string ink)
            =>new(Local(ax,ay),az,Local(bx,by),bz,width,ink);
        switch(ability)
        {
            case LegendaryAbility.OtherGravity:
            {
                float a=segment*MathF.PI/4+age*2.1f,b=a+MathF.PI/4;
                float radius=.48f+.03f*MathF.Sin(age*7);
                return Line(MathF.Cos(a)*radius,MathF.Sin(a)*radius*.8f,.14f+.08f*MathF.Sin(a),
                    MathF.Cos(b)*radius,MathF.Sin(b)*radius*.8f,.14f+.08f*MathF.Sin(b),.026f,
                    segment%2==0?"#a8fff2":"#aeb2ff");
            }
            case LegendaryAbility.RoyalReception:
            {
                if(segment<5)
                {
                    float a=-.2f+segment*.52f,b=a+.44f;
                    return Line(MathF.Cos(a)*.20f,MathF.Sin(a)*.25f,.04f+MathF.Sin(a)*.12f,
                        MathF.Cos(b)*.20f,MathF.Sin(b)*.25f,.04f+MathF.Sin(b)*.12f,.028f,"#f2ce80");
                }
                float left=(segment-6)*.12f;
                return Line(.06f,left,.025f,.32f,left*1.7f,.08f,.023f,"#fff4d0");
            }
            case LegendaryAbility.ChessPass:
                return segment switch
                {
                    0=>Line(.28f,0,.055f,1.45f,0,.055f,.026f,"#f5eee0"),
                    1=>Line(1.18f,-.15f,.055f,1.45f,0,.055f,.026f,"#f5eee0"),
                    2=>Line(1.18f,.15f,.055f,1.45f,0,.055f,.026f,"#f5eee0"),
                    _=>Line(1.58f+(segment-3)*.19f,0,.055f,1.69f+(segment-3)*.19f,0,.055f,.021f,"#aecfc5")
                };
            case LegendaryAbility.EntryDenied:
            {
                float a=-MathF.PI/2+segment*MathF.PI/8,b=a+MathF.PI/8;
                return Line(.24f+MathF.Cos(a)*.26f,MathF.Sin(a)*.43f,.065f,
                    .24f+MathF.Cos(b)*.26f,MathF.Sin(b)*.43f,.065f,.028f,segment%2==0?"#e59283":"#f1d8c5");
            }
            case LegendaryAbility.NotToday:
            {
                // The renderer supplies each actual glove as the centre.
                float a=(segment%4)*MathF.PI/2,b=a+MathF.PI/2;
                float radius=.12f+.025f*MathF.Sin(age*14);
                return Line(0,MathF.Cos(a)*radius,MathF.Sin(a)*radius,
                    0,MathF.Cos(b)*radius,MathF.Sin(b)*radius,.024f,"#94f7ff");
            }
            case LegendaryAbility.OrderSeven:
                if(segment==0)return Line(.12f,-.20f,.06f,.32f,-.12f,.06f,.024f,"#edcf86");
                if(segment==1)return Line(.12f,.20f,.06f,.32f,.12f,.06f,.024f,"#edcf86");
                else
                {
                    int run=(segment-2)/3,part=(segment-2)%3;
                    float lane=run==0?-.43f:.43f;
                    return part switch
                    {
                        0=>Line(.30f,lane,.06f,.95f,lane,.06f,.025f,"#d9be82"),
                        1=>Line(.72f,lane-.14f,.06f,.95f,lane,.06f,.025f,"#d9be82"),
                        _=>Line(.72f,lane+.14f,.06f,.95f,lane,.06f,.025f,"#d9be82")
                    };
                }
            case LegendaryAbility.LaserFinish:
            {
                int ray=segment%2;float offset=(ray==0?-1:1)*(.035f+(segment/2)*.015f);
                float start=.04f+segment/2*.16f,end=start+.15f;
                return Line(start,offset,.035f,end,offset*1.5f,.055f,.020f,
                    segment<2?"#fff6ac":"#efd04b");
            }
            case LegendaryAbility.SkySiu:
            {
                float a=segment*MathF.PI/4,b=a+MathF.PI/5;
                float radius=.29f+Math.Clamp(age,0,.65f)*.40f;
                return Line(MathF.Cos(a)*radius,MathF.Sin(a)*radius,.045f,
                    MathF.Cos(b)*radius,MathF.Sin(b)*radius,.045f,.024f,segment%2==0?"#ffd49b":"#fff3dc");
            }
            default:return default;
        }
    }
}