FootballMotion.cs

Part of a procedural football replay renderer. Rotates upper-body parts around the chest, samples an analytic per-foot step curve for walk/sprint cycles, blends kick strike poses, predicts an incoming header and the ball target for each skill move.

using System;
using System.Collections.Generic;

namespace Socer;

public static partial class ReplayFigure
{
    // Turn the shoulder girdle in the thorax frame. World-Z yaw after a lean
    // tilted the shoulder line through the shirt during roulette/spin moves.
    static void RotateFootballUpperBody(List<Part> parts,float turn)
    {
        var frame=AnatomicalFrame(parts);
        for(int i=7;i<parts.Count;i++)
        {
            var p=parts[i];
            var a=frame.Chest+RotateArm(AnatomicalA(p)-frame.Chest,frame.Up,-turn);
            var b=frame.Chest+RotateArm(AnatomicalB(p)-frame.Chest,frame.Up,-turn);
            parts[i]=p with{A=AnatomicalGround(a),ZA=a.Z,B=AnatomicalGround(b),ZB=b.Z};
        }
    }

    public readonly record struct FootballStep(float Offset,float Lift,float ToePitch,float Stance);

    // In-place motion: the match owns root travel. In stance the foot travels
    // backwards by the distance the simulation advances per cycle. A shorter
    // sprint contact gives a flight phase without extending anatomical legs.
    // These analytic curves are deterministic when a replay samples out of order.
    public static FootballStep SampleFootballStep(float phase,float speed,float activity,float sprint,
        bool active,bool carrying,float braking)
    {
        phase=(phase%1+1)%1;
        float cycleDistance=Math.Clamp(1.3f+speed*.28f,1.3f,3.2f);
        if((active || speed>1.8f) && speed>1.1f)cycleDistance*=.8f;
        cycleDistance*=1-braking*.22f;
        float stance=.54f-.22f*sprint;
        float reach=cycleDistance*stance*.5f*activity;
        float recovery=Math.Clamp((phase-stance)/(1-stance),0,1);
        float swing=phase<stance?0:MathF.Sin(recovery*MathF.PI);
        float offset=phase<stance?reach-cycleDistance*phase*activity:
            -reach+reach*2*Smooth(recovery);
        // Fold the heel early, then extend into the next contact. A dribbler
        // recovers lower, leaving room for the next deliberate ball touch.
        float lift=MathF.Pow(Math.Max(0,swing),1.15f)*(.13f+sprint*(carrying?.23f:.31f))*activity;
        float toePitch=.24f*swing*(1-2*recovery)*activity;
        return new(offset,lift,toePitch,stance);
    }

    public static float StrikeBlend(Footballer p,MatchSimulation m)
    {
        if(m.KeeperKickPlayer==p.Id)return Smooth((m.Seconds-m.KeeperKickAt)/.08f);
        float punt=m.Seconds-p.LastPuntAt;
        if(punt>=0 && punt<.65f)return 1-Smooth((punt-.46f)/.19f);
        if(m.WindupPlayer==p.Id)return Smooth((m.Seconds-p.WindupStartedAt)/.07f);
        if(p.LastKickKind==StrikeKind.Header)return 0;
        float age=m.Seconds-p.LastKickAt;
        float length=p.LastKickWasShot?.5f:p.LastKickKind==StrikeKind.Cross?.45f:.3f;
        if(age<0 || age>=length)return 0;
        return 1-Smooth((age-(length-.16f))/.16f);
    }

    // Predict an incoming head-height crossing before the real contact event.
    // Preserve a missed jump through its landing if the ball is redirected; a
    // renderer must not cancel an airborne pose simply because possession changed.
    internal static void AdvanceHeaderPreparation(Footballer p,MatchSimulation m)
    {
        if(p.Keeper || !p.InPlay || m.Owner>=0 || m.RestartRemaining>0 || m.GoalHoldRemaining>0 ||
            m.WindupPlayer==p.Id || m.Seconds<p.FallUntil || m.Seconds<p.AerialReadyAt ||
            m.Seconds-p.HeaderAt<.7f || m.Seconds-p.LastKickAt<.35f || m.BallHeight<1.35f)return;
        var forward=new Point(MathF.Cos(p.Facing),MathF.Sin(p.Facing));
        var relative=m.BallVelocity-p.Velocity;
        float speed2=Point.Dot(relative,relative);
        float time=speed2>1?Point.Dot(p.Position+forward*.18f-m.Ball,relative)/speed2:-1;
        if(time<0 || time>.30f)return;
        var meeting=m.Ball+m.BallVelocity*time;
        var root=p.Position+p.Velocity*time;
        float gap=(meeting-root-forward*.18f).Length;
        float contactRadius=.26f+p.Profile.HeadingAccuracy*.15f;
        if(gap>contactRadius)return;
        // The swept football event occurs as the ball enters the head's
        // reachable disc, before its centre reaches the closest point. Using
        // closest-approach time predicts a much lower late descending ball.
        time=Math.Max(0,time-MathF.Sqrt(Math.Max(0,contactRadius*contactRadius-gap*gap)/speed2));
        meeting=m.Ball+m.BallVelocity*time;
        float height=m.BallHeight+m.VerticalSpeed*time-4.905f*time*time;
        if(height<1.5f || height>2.55f)return;
        // Freeze the final short approach so a late turn cannot continually
        // postpone the peak. The real event supplies the precise contact point.
        if(p.VisualHeaderAt>m.Seconds-.2f && p.VisualHeaderAt<m.Seconds+.06f)return;
        p.VisualHeaderAt=m.Seconds+time;p.VisualHeaderPoint=meeting;
        p.VisualHeaderHeight=height;p.VisualHeaderLift=Math.Clamp(height-1.72f,0,.40f);
    }

    static (Point ground,float height,float turn) FootballSkillTarget(Footballer p,float t,
        Point forward,Point side,int foot)
    {
        var axis=p.SkillAxis.Length>.1f?p.SkillAxis.Unit:forward;
        var across=new Point(-axis.Y,axis.X)*(p.SkillSide==0?1:p.SkillSide);
        float wave=MathF.Sin(t*MathF.PI),first=Smooth(t/.40f),exit=Smooth((t-.40f)/.55f);
        float ahead=.24f,lateral=foot*.14f,height=.07f,turn=0;
        switch(p.SkillMove)
        {
            case SkillMoveKind.BodyFeint:
                ahead=.12f;lateral=foot*.20f;break;
            case SkillMoveKind.Stepover:
            case SkillMoveKind.Scissors:
                ahead=.20f+.19f*wave;lateral=.28f*MathF.Cos(t*MathF.PI);height+=.17f*wave;break;
            case SkillMoveKind.BallRoll:
                lateral=.24f-.48f*Smooth(t/.78f);height=.105f;break;
            case SkillMoveKind.DragBack:
                ahead=.33f-.58f*Smooth((t-.10f)/.7f);height=.10f;break;
            case SkillMoveKind.CruyffTurn:
                ahead=.32f-.58f*Smooth((t-.17f)/.64f);lateral=.14f-.31f*wave;turn=-.95f*wave;break;
            case SkillMoveKind.Roulette:
                ahead=.22f*MathF.Cos(t*MathF.Tau);lateral=.23f*MathF.Sin(t*MathF.Tau);
                height+=.07f*wave;turn=MathF.Tau*Smooth(t);break;
            case SkillMoveKind.Elastico:
            case SkillMoveKind.ReverseElastico:
                lateral=(.29f*first-.56f*exit)*(p.SkillMove==SkillMoveKind.Elastico?1:-1);
                ahead=.25f+.10f*exit;height+=.045f*wave;break;
            case SkillMoveKind.HeelToHeel:
                ahead=.20f-.34f*first+.52f*exit;lateral=foot*.09f;height+=.12f*wave;break;
            case SkillMoveKind.RainbowFlick:
                ahead=.13f-.34f*Smooth(t/.45f)+.40f*Smooth((t-.48f)/.46f);
                lateral=foot*.08f;height+=.38f*MathF.Sin(Math.Clamp(t/.78f,0,1)*MathF.PI);break;
            case SkillMoveKind.LaCroqueta:
                ahead=.22f;lateral=.25f-.52f*Smooth((t-.10f)/.70f);height+=.07f*wave;break;
            case SkillMoveKind.FakeShot:
                ahead=-.38f*first+.53f*exit;height+=.25f*wave;turn=.32f*wave;break;
            case SkillMoveKind.McGeadySpin:
                ahead=.22f-.48f*first+.31f*exit;lateral=.28f*wave;
                height+=.09f*wave;turn=-1.2f*wave;break;
            case SkillMoveKind.ScoopTurn:
                ahead=.26f-.13f*exit;lateral=.30f*Smooth(t/.72f);height+=.23f*wave;turn=.55f*wave;break;
            case SkillMoveKind.Nutmeg:
                ahead=.20f+.30f*Smooth(t/.38f);lateral=-.08f;height+=.055f*wave;break;
            case SkillMoveKind.InsideChop:
                ahead=.28f-.31f*exit;lateral=.26f-.51f*Smooth(t/.67f);height+=.10f*wave;break;
            case SkillMoveKind.OutsideCut:
                ahead=.22f+.15f*exit;lateral=.08f+.29f*Smooth(t/.66f);height+=.075f*wave;break;
            case SkillMoveKind.StopAndGo:
                ahead=.22f-.31f*first+.46f*exit;lateral=foot*.10f;break;
            case SkillMoveKind.SoleVTurn:
                ahead=.31f-.52f*first+.31f*exit;lateral=.06f+.29f*exit;height=.10f;turn=.65f*wave;break;
        }
        return (p.Position+axis*ahead+across*lateral,height,turn*(p.SkillSide==0?1:p.SkillSide));
    }
}