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));
}
}