A replay buffer for goal celebrations. It records 30 Hz snapshots of the match (ball, players, poses, skill state, trail and dust effects) into a 330 frame ring buffer, cuts an 11 second clip ending at the goal, and plays it back at variable speed with binary-search frame sampling. Also contains GoalReplayCamera, a 2D projection helper that frames the scorer facing the goal.
using System;
using System.Collections.Generic;
namespace Socer;
// Presentation-only ring buffer. A replay never steps or restores the match.
public sealed class GoalReplay
{
public sealed class Actor
{
public int Id, Team, Number, Count;
public string CatalogId="";
public string Edition="";
public bool Visible,Keeper;
public Point Position,Velocity,LegendaryTargetA,LegendaryRunTarget;
public float Facing,UniqueSkillActiveUntil,LegendaryActiveUntil,LegendaryActivatedAt,LegendaryRunUntil,LastKickAt;
public float AcrobaticAt,AcrobaticUntil;
public Point AcrobaticVelocity;
public int KickFoot;
public UniqueSkill ActiveUniqueSkill;
public int ActiveSkillGrade;
public LegendaryAbility ActiveLegendaryAbility;
public ReplayFigure.Part[] Parts=new ReplayFigure.Part[24];
}
public sealed class Frame
{
public float Time, BallHeight, SimulationTime, BallSpeed,GoalPresentationExtra;
public bool BallFree;
public UniqueSkill BallUniqueSkill;
public int BallSkillGrade;
public LegendaryAbility BallLegendaryAbility;
public readonly List<FlightMark> Trail=new(12);
public readonly List<SprintDust.Puff> Dust=new(48);
public Point Ball;
public Actor[] Actors=new Actor[22];
public Frame(){for(int i=0;i<Actors.Length;i++)Actors[i]=new Actor();}
}
const int Capacity=330; // Eleven seconds: shot build-up plus the team gathering.
readonly Frame[] frames=new Frame[Capacity];
readonly List<ReplayFigure.Part> scratch=new(24);
readonly SprintDust replayDust=new();
int head,count;
float nextCapture,captureClock;
Frame[] clip=Array.Empty<Frame>();
public bool Playing { get; private set; }
public int ScorerId { get; private set; }=-1;
public int ScorerNumber { get; private set; }
public int ScoringTeam { get; private set; }
public int AttackDirection { get; private set; }
public float CelebrationFacing {get;private set;}
public float PlaybackTime { get; private set; }
public int SeekRevision { get; private set; }
public float KickTime { get; private set; }
public float GoalTime { get; private set; }
public Point GoalImpactPoint { get; private set; }
public float GoalImpactHeight { get; private set; }
public float GoalImpactSpeed { get; private set; }
float endHold;
public void Reset()
{
Playing=false;clip=Array.Empty<Frame>();head=count=0;nextCapture=captureClock=0;ScorerId=-1;
GoalImpactPoint=default;GoalImpactHeight=GoalImpactSpeed=0;
}
public void Stop(){Playing=false;clip=Array.Empty<Frame>();}
public void Seek(float time){if(Playing){PlaybackTime=Math.Clamp(time,clip[0].Time,clip[^1].Time);SeekRevision++;}}
public void Capture(MatchSimulation match,float stepSeconds=0)
{
if(Playing)return;
captureClock+=Math.Max(0,stepSeconds);
if(captureClock+.0001f<nextCapture)return;
nextCapture=captureClock+1f/30f;
// Editor hotload can retain the write cursor while rebuilding the frame array.
if((uint)head>=(uint)frames.Length){head=0;count=0;}
var frame=frames[head]??=new Frame();
frame.Time=captureClock;frame.Ball=match.Ball;frame.BallHeight=match.BallHeight;
frame.SimulationTime=match.Seconds;frame.BallSpeed=match.BallSpeed;frame.BallFree=match.Owner<0;
frame.GoalPresentationExtra=match.GoalHoldRemaining>0?Math.Max(0,match.GoalCelebrationElapsed-1):0;
frame.BallUniqueSkill=match.BallUniqueSkill;frame.BallSkillGrade=match.BallSkillGrade;
frame.BallLegendaryAbility=match.BallLegendaryAbility;
// Reuse each ring slot's effect buffers rather than allocating at 30 Hz.
frame.Trail.Clear();frame.Trail.AddRange(match.FlightTrail);
var puffs=replayDust.Update(match);frame.Dust.Clear();
for(int i=0;i<puffs.Count;i++)frame.Dust.Add(puffs[i]);
for(int i=0;i<match.Players.Count && i<frame.Actors.Length;i++)
{
var player=match.Players[i];var actor=frame.Actors[i];
actor.Id=player.Id;actor.Team=player.Team;actor.Number=player.Number;
actor.Keeper=player.Keeper;
actor.CatalogId=player.Profile.CatalogId;
actor.Edition=player.Profile.Edition;
actor.Visible=(!player.SentOff || match.Seconds<player.ImpactUntil) && !player.InjuryRemovedFromField;actor.Position=player.Position;
actor.Velocity=player.Velocity;actor.Facing=player.Facing;
actor.LastKickAt=player.LastKickAt;actor.KickFoot=player.KickFoot;
actor.AcrobaticAt=player.AcrobaticAt;actor.AcrobaticUntil=player.AcrobaticUntil;
actor.AcrobaticVelocity=player.AcrobaticVelocity;
actor.ActiveUniqueSkill=player.ActiveUniqueSkill;actor.ActiveSkillGrade=player.ActiveSkillGrade;
actor.UniqueSkillActiveUntil=player.UniqueSkillActiveUntil;
actor.ActiveLegendaryAbility=player.ActiveLegendaryAbility;
actor.LegendaryActiveUntil=player.LegendaryActiveUntil;actor.LegendaryActivatedAt=player.LegendaryActivatedAt;
actor.LegendaryTargetA=player.LegendaryTargetA;
actor.LegendaryRunTarget=player.LegendaryRunTarget;actor.LegendaryRunUntil=player.LegendaryRunUntil;
if(!actor.Visible){actor.Count=0;continue;}
ReplayFigure.Pose(player,match,scratch);
if(actor.Parts.Length<scratch.Count)actor.Parts=new ReplayFigure.Part[scratch.Count];
actor.Count=scratch.Count;
for(int j=0;j<actor.Count;j++)actor.Parts[j]=scratch[j];
}
head=(head+1)%Capacity;count=Math.Min(Capacity,count+1);
}
public void MarkGoal(MatchSimulation match)
{
GoalTime=captureClock;ScorerId=match.GoalScorerId;
GoalImpactPoint=match.Ball;GoalImpactHeight=match.BallHeight;
GoalImpactSpeed=match.BallSpeed/.58f;
if(ScorerId<0 || ScorerId>=match.Players.Count)return;
ScorerNumber=match.Players[ScorerId].Number;ScoringTeam=match.Players[ScorerId].Team;
AttackDirection=match.Direction(ScoringTeam);
CelebrationFacing=match.GoalCelebrationFacing;
KickTime=GoalTime-Math.Max(0,match.Seconds-match.Players[ScorerId].LastKickAt);
}
public bool Start()
{
if(count<3 || ScorerId<0)return false;
float earliest=Math.Max(GoalTime-5.5f,Math.Min(GoalTime-2.8f,KickTime-.9f));
var selected=new List<Frame>(Capacity);
for(int i=0;i<count;i++)
{
var frame=frames[(head-count+i+Capacity)%Capacity];
if(frame!=null && frame.Time>=earliest-.04f)selected.Add(frame);
}
if(selected.Count<3)return false;
clip=selected.ToArray();PlaybackTime=clip[0].Time;endHold=0;Playing=true;
return true;
}
public void Advance(float realDelta)
{
if(!Playing)return;
float rate=PlaybackTime<KickTime-.15f?.85f:PlaybackTime<GoalTime-.12f?.48f:.7f;
PlaybackTime=Math.Min(clip[^1].Time,PlaybackTime+Math.Clamp(realDelta,0,.1f)*rate);
if(PlaybackTime>=clip[^1].Time)
{
endHold+=Math.Clamp(realDelta,0,.1f);
if(endHold>.55f)Stop();
}
}
public bool Sample(out Frame first,out Frame second,out float blend)
{
first=second=null;blend=0;
if(!Playing || clip.Length==0)return false;
int lo=0,hi=clip.Length-1;
while(lo<hi)
{
int mid=(lo+hi+1)/2;
if(clip[mid].Time<=PlaybackTime)lo=mid;else hi=mid-1;
}
first=clip[lo];second=clip[Math.Min(lo+1,clip.Length-1)];
blend=Math.Clamp((PlaybackTime-first.Time)/Math.Max(.001f,second.Time-first.Time),0,1);
return true;
}
public static Point Mix(Point a,Point b,float t)=>a+(b-a)*t;
}
public readonly struct GoalReplayCamera
{
readonly Point origin,forward,right;
readonly float width,height,focal,horizon;
public GoalReplayCamera(Point scorer,int attackDirection,float width,float height,float celebration=0)
{
celebration=Math.Clamp(celebration,0,1);
this.width=width;this.height=height;focal=height*(1.17f-.17f*celebration);
horizon=height*(.23f+.14f*celebration);
Point goal=new(attackDirection>0?105:0,34);
var goalward=(goal-scorer).Unit;
if(goalward.Length<.01f)goalward=new Point(attackDirection,0);
var sidestep=new Point(-goalward.Y,goalward.X);
origin=scorer-goalward*(2.6f+3f*celebration)+sidestep*(3.2f*celebration);
forward=(scorer+goalward*(5+3*celebration)-origin).Unit;
right=new Point(-forward.Y,forward.X);
}
public float Depth(Point p)=>Point.Dot(p-origin,forward)+1.2f;
public float Scale(Point p)=>focal/Math.Max(.7f,Depth(p));
public Point Project(Point p,float elevation=0)
{
float scale=Scale(p);
return new(width*.5f+Point.Dot(p-origin,right)*scale,horizon+(1.65f-elevation)*scale);
}
}