GoalCelebration.cs

Part of the football MatchSimulation. Runs the goal celebration: picks up to four nearby non-keeper teammates, assigns them gathering spots around the scorer, and moves everyone with acceleration limits, braking and obstacle avoidance. Also holds the goal clock's recovery handling for diving keepers and injured or acrobatic players.

using System;
using System.Linq;

namespace Socer;

public sealed partial class MatchSimulation
{
    public const float GoalCelebrationDuration=6.8f;
    public Point GoalCelebrationOrigin;
    public float GoalCelebrationFacing;
    public int GoalCelebrationVariant;
    public float[] GoalCelebrationArrivedAt=Enumerable.Repeat(-1f,22).ToArray();
    public float[] GoalCelebrationBodyReadyAt=new float[22];
    readonly float[] goalCelebrationStartsAt=new float[22];
    readonly int[] goalCelebrationAvoidSides=new int[22*22];
    readonly Point[] goalCelebrationWaypoints=new Point[22];
    readonly bool[] goalCelebrationDetours=new bool[22];
    readonly Point[] goalCelebrationTargets=new Point[4];
    Point goalCelebrationDestination;
    Point goalCelebrationGatherDirection;
    int goalCelebrationPreparedScorer=-1;

    void BeginGoalCelebration(Footballer scorer)
    {
        var participants=ActivePlayers.Where(p=>p.Team==scorer.Team && !p.Keeper && p.Id!=scorer.Id && GoalCelebrationReady(p))
            .OrderBy(p=>(p.Position-scorer.Position).Length).ThenBy(p=>p.Id).Take(4).Select(p=>p.Id).ToArray();
        PrepareGoalCelebration(scorer,participants);
        GoalHoldRemaining=GoalCelebrationDuration;
    }

    void PrepareGoalCelebration(Footballer scorer,int[] participants)
    {
        ResetGoalCelebration();
        GoalCelebrants=participants;
        goalCelebrationPreparedScorer=scorer.Id;
        GoalCelebrationOrigin=scorer.Position;
        foreach(var player in Players)
            if((uint)player.Id<GoalCelebrationBodyReadyAt.Length)
                GoalCelebrationBodyReadyAt[player.Id]=player.IsAcrobaticCommitted(Seconds)?-1:0;
        // Independent of football RNG: celebrations never consume a decision
        // roll or change subsequent match results by shifting the random stream.
        uint variation=unchecked((uint)Seed*2654435761u+(uint)(Goals[0]+Goals[1])*2246822519u+(uint)scorer.Id*3266489917u);
        GoalCelebrationVariant=(int)(variation%6);
        var teammate=participants.Length>0?Players[participants[0]].Position-scorer.Position:new Point(-Direction(scorer.Team),0);
        if(teammate.Length<.1f)teammate=new Point(-Direction(scorer.Team),0);
        goalCelebrationGatherDirection=teammate.Unit;
        // Wheel away from the goal, towards the nearby team, then brake into
        // the gathering. This is actual root motion, never a renderer offset.
        var away=new Point(-Direction(scorer.Team),0);
        var run=(goalCelebrationGatherDirection*.7f+away*.3f).Unit;
        var destination=scorer.Position+run*(2.3f+(GoalCelebrationVariant%2)*.15f);
        // Keep the gathering ring inside the pitch even when the finish was
        // close to a touchline; clamping every slot to the edge would merge it.
        goalCelebrationDestination=new Point(Math.Clamp(destination.X,1.8f,103.2f),Math.Clamp(destination.Y,1.8f,66.2f));
        GoalCelebrationFacing=MathF.Atan2(goalCelebrationGatherDirection.Y,goalCelebrationGatherDirection.X);
        AssignGoalCelebrationPlaces(participants);
        for(int slot=0;slot<participants.Length;slot++)
            goalCelebrationStartsAt[participants[slot]]=.18f+slot*.17f;
    }

    void ResetGoalCelebration()
    {
        GoalCelebrationElapsed=0;GoalCelebrants=Array.Empty<int>();
        GoalCelebrationOrigin=default;GoalCelebrationFacing=0;GoalCelebrationVariant=0;
        goalCelebrationPreparedScorer=-1;goalCelebrationDestination=default;goalCelebrationGatherDirection=default;
        if(GoalCelebrationArrivedAt==null || GoalCelebrationArrivedAt.Length!=22)GoalCelebrationArrivedAt=new float[22];
        Array.Fill(GoalCelebrationArrivedAt,-1f);
        if(GoalCelebrationBodyReadyAt==null || GoalCelebrationBodyReadyAt.Length!=22)GoalCelebrationBodyReadyAt=new float[22];
        Array.Clear(GoalCelebrationBodyReadyAt);
        Array.Clear(goalCelebrationStartsAt);Array.Clear(goalCelebrationAvoidSides);
        Array.Clear(goalCelebrationWaypoints);Array.Clear(goalCelebrationDetours);
        Array.Clear(goalCelebrationTargets);
    }

    void AdvanceGoalCelebration(float dt,float clockAdvanced=-1)
    {
        if(GoalScorerId<0 || GoalScorerId>=Players.Count)return;
        var scorer=Players[GoalScorerId];
        if(goalCelebrationPreparedScorer!=GoalScorerId)
        {
            // Old test fixtures and hotloaded matches may already hold a goal
            // without having called the new begin method. Preserve that ticket.
            float elapsed=GoalCelebrationElapsed;
            var participants=(GoalCelebrants??Array.Empty<int>()).Where(id=>id>=0 && id<Players.Count && id!=scorer.Id &&
                Players[id].Team==scorer.Team && !Players[id].Keeper && GoalCelebrationReady(Players[id])).Distinct().Take(4).ToArray();
            if(participants.Length==0)participants=ActivePlayers.Where(p=>p.Team==scorer.Team && !p.Keeper && p.Id!=scorer.Id && GoalCelebrationReady(p))
                .OrderBy(p=>(p.Position-scorer.Position).Length).ThenBy(p=>p.Id).Take(4).Select(p=>p.Id).ToArray();
            PrepareGoalCelebration(scorer,participants);GoalCelebrationElapsed=elapsed;
        }
        float frozen=clockAdvanced>=0?Math.Clamp(dt-clockAdvanced,0,dt):
            Math.Max(0,GoalCelebrationElapsed+dt-1)-Math.Max(0,GoalCelebrationElapsed-1);
        GoalCelebrationElapsed+=dt;
        AdvanceGoalCelebrationRecovery(dt,frozen);
        foreach(var player in Players)
            if((uint)player.Id<GoalCelebrationBodyReadyAt.Length && GoalCelebrationBodyReadyAt[player.Id]<0 && GoalCelebrationReady(player))
                GoalCelebrationBodyReadyAt[player.Id]=GoalCelebrationElapsed;
        bool authorReady=GoalCelebrationReady(scorer);
        if(authorReady)MoveGoalCelebrant(scorer,goalCelebrationDestination,dt,3.5f);
        else scorer.Velocity=new Point();
        scorer.Action=authorReady?"Празднует гол":"Поднимается после гола";
        var scorerLook=scorer.Velocity.Length>.8f?scorer.Position+scorer.Velocity:
            scorer.Position+goalCelebrationGatherDirection;
        if(authorReady)TurnGoalCelebrant(scorer,scorerLook,dt);
        if(authorReady && (scorer.Position-goalCelebrationDestination).Length<.11f && scorer.Velocity.Length<.35f && GoalCelebrationArrivedAt[scorer.Id]<0)
            GoalCelebrationArrivedAt[scorer.Id]=GoalCelebrationElapsed;

        for(int slot=0;slot<GoalCelebrants.Length;slot++)
        {
            var player=Players[GoalCelebrants[slot]];
            if(!GoalCelebrationReady(player)){player.Velocity=new Point();continue;}
            var target=goalCelebrationTargets[slot];
            bool waiting=GoalCelebrationElapsed<goalCelebrationStartsAt[player.Id];
            MoveGoalCelebrant(player,waiting?player.Position:target,dt,waiting?0:5.6f);
            float remaining=(target-player.Position).Length;
            TurnGoalCelebrant(player,remaining<1.1f && player.Velocity.Length<1.5f?scorer.Position:player.Position+player.Velocity,dt);
            if(authorReady && !waiting && remaining<.15f && player.Velocity.Length<.55f && scorer.Velocity.Length<.65f && GoalCelebrationArrivedAt[player.Id]<0)
                GoalCelebrationArrivedAt[player.Id]=GoalCelebrationElapsed;
            player.Action=GoalCelebrationArrivedAt[player.Id]>=0 && slot<2?"Обнимает автора гола":
                GoalCelebrationArrivedAt[player.Id]>=0?"Празднует гол":"Бежит праздновать";
        }
        foreach(var player in ActivePlayers)
        {
            if(player.Id==scorer.Id || GoalCelebrants.Contains(player.Id))continue;
            if(player.IsAcrobaticCommitted(Seconds))
            {player.Velocity=new Point();player.Action="Поднимается после акробатического удара";continue;}
            // Distant teammates and the keeper support the group in place.
            // The pose layer gives them applause rather than a shared jump.
            player.Velocity*=MathF.Exp(-dt*8);
            if(player.Team==scorer.Team)
            {
                TurnGoalCelebrant(player,scorer.Position,dt);player.Action="Празднует гол";
            }
            else player.Action=player.Keeper?"Разочарован после гола":"Опустил голову";
        }
    }

    bool GoalCelebrationReady(Footballer player)=>player.InPlay && player.InjurySeverity!=InjurySeverity.Serious &&
        Seconds>=player.FallUntil && Seconds>=player.ImpactUntil && !player.IsAcrobaticCommitted(Seconds);

    void AssignGoalCelebrationPlaces(int[] participants)
    {
        var side=new Point(-goalCelebrationGatherDirection.Y,goalCelebrationGatherDirection.X);
        Point[] places={goalCelebrationDestination+goalCelebrationGatherDirection*.83f,
            goalCelebrationDestination-goalCelebrationGatherDirection*.24f+side*.83f,
            goalCelebrationDestination-goalCelebrationGatherDirection*.25f-side*.86f,
            goalCelebrationDestination-goalCelebrationGatherDirection*.87f};
        float best=float.MaxValue;
        // The nearest two retain their greeting roles. Assign their actual
        // places jointly, rather than forcing runners to cross one another
        // to reach an arbitrary numbered side of the author.
        for(int a=0;a<4;a++)for(int b=0;b<4;b++)for(int c=0;c<4;c++)for(int d=0;d<4;d++)
        {
            if(a==b || a==c || a==d || b==c || b==d || c==d)continue;
            int[] order={a,b,c,d};float cost=0;
            for(int i=0;i<participants.Length;i++)cost+=(Players[participants[i]].Position-places[order[i]]).Length;
            if(cost>=best-.0001f)continue;
            best=cost;
            for(int i=0;i<participants.Length;i++)goalCelebrationTargets[i]=Clamp(places[order[i]]);
        }
    }

    void AdvanceGoalCelebrationRecovery(float dt,float frozen)
    {
        foreach(var player in Players)
        {
            if(frozen>0 && player.AcrobaticUntil>Seconds)
            {
                // The dedicated goal clock keeps the committed body moving
                // through landing/get-up after the football clock stops.
                player.AcrobaticAt-=frozen;player.AcrobaticUntil-=frozen;
            }
            AdvanceAcrobaticRecovery(player,dt);
            if(player.Keeper)
            {
                // Continue the already committed save, without running keeper
                // decisions, miss statistics, or any new football RNG rolls.
                if(player.DiveRemaining>0)
                {
                    player.DiveAge+=dt;player.DiveRemaining=Math.Max(0,player.DiveRemaining-dt);
                    if(player.DiveRemaining==0)player.DiveEndedAt=Seconds;
                }
                else if(frozen>0 && Seconds-player.DiveEndedAt<HumanFigure.DiveRecoverySeconds(player,this))
                    player.DiveEndedAt-=frozen;
            }
            // The goal clock ages only existing recoverable body reactions.
            // Injury duration and treatment clocks keep their normal semantics.
            if(frozen<=0 || player.InjuryCannotContinue || player.InjurySeverity==InjurySeverity.Serious)continue;
            if(player.FallUntil>Seconds){player.FallAt-=frozen;player.FallUntil-=frozen;}
            if(player.ImpactUntil>Seconds){player.ImpactAt-=frozen;player.ImpactUntil-=frozen;}
        }
    }

    Point GoalCelebrationRoute(Footballer player,Point target)
    {
        var path=target-player.Position;float distance=path.Length;
        if(distance<.08f)return target;
        bool finalApproach=distance<.8f && ActivePlayers.All(other=>other.Id==player.Id ||
            GoalCelebrationHasPriority(player,other) || (target-other.Position).Length>.70f);
        if(finalApproach){goalCelebrationDetours[player.Id]=false;return target;}
        if(goalCelebrationDetours[player.Id])
        {
            var waypoint=goalCelebrationWaypoints[player.Id];
            if((waypoint-player.Position).Length>.28f && GoalCelebrationWaypointClear(player,waypoint))return waypoint;
            goalCelebrationDetours[player.Id]=false;
        }
        var along=path.Unit;var side=new Point(-along.Y,along.X);
        Footballer obstacle=null;float nearest=float.MaxValue;
        foreach(var other in ActivePlayers)
        {
            if(other.Id==player.Id || GoalCelebrationHasPriority(player,other))continue;
            var to=other.Position-player.Position;float ahead=Point.Dot(to,along);
            if(ahead<=.025f || ahead>=distance || ahead>3.2f || Math.Abs(Point.Dot(to,side))>=.82f)continue;
            if(ahead<nearest){nearest=ahead;obstacle=other;}
        }
        if(obstacle==null)return target;
        int key=player.Id*22+obstacle.Id;
        int turn=goalCelebrationAvoidSides[key];
        if(turn==0)
        {
            float lateral=Point.Dot(obstacle.Position-player.Position,side);
            turn=lateral>.03f?-1:lateral<-.03f?1:((player.Id+obstacle.Id)%2==0?1:-1);
            goalCelebrationAvoidSides[key]=turn;
        }
        // Go around a body before aiming at the reserved place on its far side.
        // The side is retained, so a small steering correction cannot reverse it.
        var detour=Clamp(obstacle.Position+side*(turn*1.08f));
        // A neighbour can occupy this body's outside route. Never retain
        // an unreachable waypoint inside it; final steering still brakes.
        if(!GoalCelebrationWaypointClear(player,detour))return target;
        goalCelebrationWaypoints[player.Id]=detour;goalCelebrationDetours[player.Id]=true;
        return detour;
    }

    bool GoalCelebrationWaypointClear(Footballer player,Point waypoint)=>ActivePlayers.All(other=>other.Id==player.Id ||
        GoalCelebrationHasPriority(player,other) || (waypoint-other.Position).Length>.70f);

    bool GoalCelebrationHasPriority(Footballer player,Footballer other)
    {
        int otherSlot=Array.IndexOf(GoalCelebrants,other.Id);
        if(otherSlot<0)return false;
        if(player.Id==GoalScorerId)return true;
        int ownSlot=Array.IndexOf(GoalCelebrants,player.Id);
        return ownSlot>=0 && ownSlot<otherSlot;
    }

    void MoveGoalCelebrant(Footballer player,Point target,float dt,float maximumSpeed)
    {
        if(maximumSpeed>0)target=GoalCelebrationRoute(player,target);
        var delta=target-player.Position;
        float distance=delta.Length;
        // Braking distance, bounded acceleration and body separation avoid
        // the former exponential slide into overlapping fixed destinations.
        float wantedSpeed=Math.Min(maximumSpeed,MathF.Sqrt(2*4.8f*Math.Max(0,distance-.025f)));
        var desired=distance>.015f?delta.Unit*wantedSpeed:new Point();
        bool avoiding=false;
        foreach(var other in ActivePlayers)
        {
            if(other.Id==player.Id)continue;
            bool priority=GoalCelebrationHasPriority(player,other);
            var away=player.Position-other.Position;
            float gap=away.Length;
            if(!priority && gap>.001f && gap<.69f)desired+=away.Unit*((.69f-gap)*6);
            if(gap<=.001f || gap>4.5f)continue;
            var toward=away.Unit*-1;
            float approach=Point.Dot(desired-(priority?new Point():other.Velocity),toward);
            float safeApproach=MathF.Sqrt(2*4.0f*Math.Max(0,gap-.72f));
            if(approach>safeApproach)
            {
                desired-=toward*(approach-safeApproach);avoiding=true;
            }
        }
        if(desired.Length>maximumSpeed && maximumSpeed>0)desired=desired.Unit*maximumSpeed;
        var change=desired-player.Velocity;
        float maximumChange=(avoiding || wantedSpeed<player.Velocity.Length?6.5f:5.0f)*dt;
        if(change.Length>maximumChange)change=change.Unit*maximumChange;
        player.Velocity+=change;
        // Restrict the next displacement, never repair an existing position by
        // teleporting. The earlier braking envelope normally keeps this idle.
        foreach(var other in ActivePlayers)
        {
            if(other.Id==player.Id)continue;
            var away=player.Position-other.Position;float gap=away.Length;
            if(gap<=.001f || gap>=1.0f)continue;
            var toward=away.Unit*-1;
            float permitted=Math.Max(0,(gap-.52f)/Math.Max(dt,.001f));
            float approach=Point.Dot(player.Velocity,toward);
            if(approach>permitted)player.Velocity-=toward*(approach-permitted);
        }
        if(maximumSpeed==0 && player.Velocity.Length<.04f)player.Velocity=new Point();
        player.Position=Clamp(player.Position+player.Velocity*dt);
    }

    static void TurnGoalCelebrant(Footballer player,Point target,float dt)
    {
        var look=target-player.Position;
        if(look.Length<.025f)return;
        float desired=MathF.Atan2(look.Y,look.X);
        float difference=MathF.Atan2(MathF.Sin(desired-player.Facing),MathF.Cos(desired-player.Facing));
        float turn=Math.Clamp(difference,-3.5f*dt,3.5f*dt);
        player.Facing+=turn;
        player.TurnLean+=(Math.Clamp(difference*.35f,-.45f,.45f)-player.TurnLean)*Math.Min(1,dt*7);
    }
}