FootballArmCurves.cs

Part of ReplayFigure, the animation for replays. Holds keyframed arm angle tables for instep shots and passes, samples them with a bounded cubic interpolation, and blends the pass and shot poses based on kick type and windup state.

using System;

namespace Socer;

public static partial class ReplayFigure
{
    readonly record struct ArmKey(float Time,ArmAngles Pose);
    // Original angular blocking refined into bounded cubic curves. Negative time
    // is the windup; zero is ball contact; positive time is the follow-through.
    // The support arm loads back/out with the trunk, then folds forwards/inwards.
    // Passes use a smaller balance gesture instead of a maximal-shot tension arc.
    static readonly ArmKey[] InstepSupportArm={
        new(-1,new(7,10,0,26,10)),new(-.72f,new(-14,42,-18,32,12)),
        new(-.28f,new(-34,69,-26,24,16)),new(0,new(7,57,-12,43,18)),
        new(.22f,new(40,31,8,75,25)),new(.55f,new(25,18,5,58,18)),new(1,new(7,10,0,26,10))};
    static readonly ArmKey[] InstepKickSideArm={
        new(-1,new(7,10,0,26,10)),new(-.72f,new(26,18,12,72,12)),
        new(-.28f,new(37,22,20,85,20)),new(0,new(-16,29,-10,51,16)),
        new(.22f,new(-27,21,-12,39,12)),new(.55f,new(9,16,2,43,10)),new(1,new(7,10,0,26,10))};
    static readonly ArmKey[] PassSupportArm={
        new(-1,new(7,10,0,26,10)),new(-.72f,new(-3,18,-5,42,10)),
        new(-.28f,new(-10,31,-10,45,12)),new(0,new(9,28,-5,56,14)),
        new(.22f,new(25,22,3,69,18)),new(.55f,new(14,14,2,47,14)),new(1,new(7,10,0,26,10))};
    static readonly ArmKey[] PassKickSideArm={
        new(-1,new(7,10,0,26,10)),new(-.72f,new(13,12,4,47,10)),
        new(-.28f,new(19,16,8,59,12)),new(0,new(-5,18,-4,42,14)),
        new(.22f,new(-10,15,-5,36,12)),new(.55f,new(8,12,1,35,10)),new(1,new(7,10,0,26,10))};

    static float ArmCubic(float before,float a,float b,float after,float beforeTime,float aTime,float bTime,float afterTime,float t)
    {
        float span=bTime-aTime;
        float ma=(b-before)/Math.Max(.001f,bTime-beforeTime)*span;
        float mb=(after-a)/Math.Max(.001f,afterTime-aTime)*span;
        float t2=t*t,t3=t2*t;
        return Math.Clamp((2*t3-3*t2+1)*a+(t3-2*t2+t)*ma+(-2*t3+3*t2)*b+(t3-t2)*mb,
            Math.Min(a,b),Math.Max(a,b));
    }
    static ArmAngles SampleArmKeys(ArmKey[] keys,float time)
    {
        if(time<=keys[0].Time)return keys[0].Pose;
        if(time>=keys[^1].Time)return keys[^1].Pose;
        int i=0;while(i<keys.Length-2 && time>keys[i+1].Time)i++;
        var p=keys[Math.Max(0,i-1)];var a=keys[i];var b=keys[i+1];var n=keys[Math.Min(keys.Length-1,i+2)];
        float t=(time-a.Time)/(b.Time-a.Time);
        float Curve(float p0,float p1,float p2,float p3)=>ArmCubic(p0,p1,p2,p3,p.Time,a.Time,b.Time,n.Time,t);
        return new(Curve(p.Pose.Flexion,a.Pose.Flexion,b.Pose.Flexion,n.Pose.Flexion),
            Curve(p.Pose.Abduction,a.Pose.Abduction,b.Pose.Abduction,n.Pose.Abduction),
            Curve(p.Pose.HumeralTwist,a.Pose.HumeralTwist,b.Pose.HumeralTwist,n.Pose.HumeralTwist),
            Curve(p.Pose.ElbowFlexion,a.Pose.ElbowFlexion,b.Pose.ElbowFlexion,n.Pose.ElbowFlexion),
            Curve(p.Pose.Pronation,a.Pose.Pronation,b.Pose.Pronation,n.Pose.Pronation),
            Curve(p.Pose.WristFlexion,a.Pose.WristFlexion,b.Pose.WristFlexion,n.Pose.WristFlexion),
            Curve(p.Pose.WristDeviation,a.Pose.WristDeviation,b.Pose.WristDeviation,n.Pose.WristDeviation));
    }
    public static ArmAngles SampleStrikeArm(Footballer p,MatchSimulation m,int arm)
    {
        bool windup=m.WindupPlayer==p.Id;
        bool shot=windup || p.LastKickWasShot;
        var kind=windup?m.Strike:p.LastKickKind;
        float length=shot?.5f:kind==StrikeKind.Cross?.45f:.3f;
        float duration=Math.Max(.01f,m.Seconds-p.WindupStartedAt+m.WindupRemaining);
        float time=windup?-1+Math.Clamp((m.Seconds-p.WindupStartedAt)/duration,0,1):
            Math.Clamp((m.Seconds-p.LastKickAt)/length,0,1);
        bool support=(arm==7?-1:1)!=p.KickFoot;
        var power=SampleArmKeys(support?InstepSupportArm:InstepKickSideArm,time);
        var control=SampleArmKeys(support?PassSupportArm:PassKickSideArm,time);
        float effort=!shot?kind==StrikeKind.Cross?.70f:0:kind switch
        {StrikeKind.Chip=>.36f,StrikeKind.Driven=>.76f,StrikeKind.Curl=>.90f,_=>1};
        return BlendArmAngles(control,power,effort);
    }
}