GoalReplay.cs

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