FootballSecondaryMotion.cs

Static helper for presentation-only secondary motion in a football game. Computes procedural gait phase, speed and acceleration for a returning player, the ceremony ball deposit and staff jump/load curves, and poses replay figure parts for ball placement and seated sideline leg positions. No networked or persistent state is touched.

using System;
using System.Collections.Generic;

namespace Socer;

// Presentation-only choreography. Root paths never alter football or saves.
public static class FootballSecondaryMotion
{
    public readonly record struct ReturnFrame(Point Position,Point Velocity,Point Acceleration,float GaitPhase);
    static float Ease(float t){t=Math.Clamp(t,0,1);return t*t*(3-2*t);}
    public static ReturnFrame Return(Point from,Point target,float age,float duration,int id,float maxApproach=12)
    {
        duration=Math.Max(.1f,duration);
        var delta=target-from;float distance=Math.Min(delta.Length,maxApproach);
        var direction=delta.Unit;from=target-direction*distance;
        float t=Math.Clamp(age/duration,0,1);
        float speed=distance*6*t*(1-t)/duration;
        float acceleration=t<=0 || t>=1?0:distance*6*(1-2*t)/(duration*duration);
        // Fixed-grid integration is independent of sampling order. Positive
        // interpolated rates cannot reverse the gait near a speed threshold.
        float cycles=0;
        const int samples=64;
        float Rate(float q)
        {
            float v=distance*6*q*(1-q)/duration;
            float a=distance*6*(1-2*q)/(duration*duration);
            float stride=Math.Clamp(1.3f+v*.28f,1.3f,3.2f);
            if(v>1.8f)stride*=.8f;
            stride*=1-Math.Clamp(-a/8,0,1)*.22f;
            return v/stride;
        }
        for(int i=0;i<samples;i++)
        {
            float covered=Math.Clamp(t*samples-i,0,1);
            if(covered<=0)break;
            float a=Rate(i/(float)samples),b=Rate((i+1)/(float)samples);
            cycles+=(a*covered+(b-a)*covered*covered*.5f)*(duration/samples);
        }
        return new(from+direction*(distance*Ease(t)),direction*speed,direction*acceleration,
            (cycles+id*.12f)%1);
    }
    public static float Deposit(float age)=>Ease((age-2.8f)/.9f);
    public static float CeremonyBallHeight(float age)=>1.04f-.93f*Deposit(age);
    public static float StaffJump(float age)
    {
        float t=(age-.24f)/.48f;
        return t<=0 || t>=1?0:.065f*4*t*(1-t);
    }
    public static float StaffLoad(float age)
    {
        float Pulse(float start,float peak,float end)=>age<peak?Ease((age-start)/(peak-start)):
            1-Ease((age-peak)/(end-peak));
        return -.035f*Pulse(.02f,.16f,.25f)-.030f*Pulse(.69f,.82f,1.05f);
    }
}

public static partial class ReplayFigure
{
    public static void PoseCeremonyBallPlacement(List<Part> parts,Point forward,Point ball,float ballZ,float age)
    {
        float crouch=FootballSecondaryMotion.Deposit(age)*(1-Smooth((age-3.95f)/.75f));
        float pitch=1.05f*crouch,lower=.52f*crouch;
        var hip=parts[6].A;float hipZ=parts[6].ZA;
        var left=parts[1];var right=parts[4];
        for(int i=0;i<parts.Count;i++)
        {
            var v=parts[i];parts[i]=v with{ZA=v.ZA-lower,ZB=v.ZB-lower};
        }
        (Point,float) Bend(Point at,float z)
        {
            float along=Point.Dot(at-hip,forward),height=z-(hipZ-lower);
            return (at+forward*(along*(MathF.Cos(pitch)-1)+height*MathF.Sin(pitch)),
                hipZ-lower+height*MathF.Cos(pitch)-along*MathF.Sin(pitch));
        }
        for(int i=6;i<parts.Count;i++)
        {
            var v=parts[i];var a=Bend(v.A,v.ZA);var b=Bend(v.B,v.ZB);
            parts[i]=v with{A=a.Item1,ZA=a.Item2,B=b.Item1,ZB=b.Item2};
        }
        KeeperLeg(parts,0,left.B,left.ZB,forward,0,forward,0);
        KeeperLeg(parts,1,right.B,right.ZB,forward,0,forward,0);
        var side=new Point(-forward.Y,forward.X);
        float release=Smooth((age-3.74f)/.36f);
        for(int arm=0;arm<2;arm++)
        {
            int i=7+arm*3;float sign=arm==0?-1:1;
            var hand=parts[i+1];
            KeeperArm(parts,i,(ball+side*(sign*.095f))*(1-release)+hand.B*release,
                ballZ*(1-release)+hand.ZB*release,side*sign,-.35f,forward,0);
        }
        KeepKeeperAboveGround(parts);
    }

    public static void PoseSeatedLeg(List<Part> parts,int leg,Point centre,Point forward,float hipZ,
        string trousers,string socks)
    {
        float sign=leg==0?-1:1;int i=leg*3;var side=new Point(-forward.Y,forward.X);
        const float kneeZ=.46f,footZ=.065f;
        float thighAhead=MathF.Sqrt(.405f*.405f-(hipZ-kneeZ)*(hipZ-kneeZ)-.04f*.04f);
        float shinAhead=MathF.Sqrt(.405f*.405f-(kneeZ-footZ)*(kneeZ-footZ)-.01f*.01f);
        var hip=centre+side*(sign*.13f);
        var knee=centre+side*(sign*.17f)+forward*thighAhead;
        var foot=centre+side*(sign*.18f)+forward*(thighAhead+shinAhead);
        parts[i]=parts[i] with{A=hip,ZA=hipZ,B=knee,ZB=kneeZ,Color=trousers};
        parts[i+1]=parts[i+1] with{A=knee,ZA=kneeZ,B=foot,ZB=footZ,Color=socks};
        parts[i+2]=parts[i+2] with{A=foot,ZA=footZ,B=foot+forward*.23f,ZB=footZ};
    }
    public static void PoseSidelineSupportLeg(List<Part> parts,int leg,Point foot,float footZ,Point forward)
        =>KeeperLeg(parts,leg,foot,footZ,forward,0,forward,0);
}