FootballEmotionAnimation.cs

Extends the MatchSimulation partial class with a 22-slot emotion cue board: events (misses, woodwork, blocks, saves, cards, fouls, substitutions) queue prioritized, cooled-down emotion cues per footballer, and UpdateFootballEmotions advances their pending/active/cooldown state. Also adds ReplayFigure.ApplyFootballEmotion, which poses the replay body parts (crouch, pitch, yaw, roll, arm FK, hand contact, claps) for each emotion without touching the simulation.

using System;
using System.Collections.Generic;
using NVector=System.Numerics.Vector3;
using NQuaternion=System.Numerics.Quaternion;

namespace Socer;

public enum FootballEmotion
{
    None,NearMiss,Woodwork,BlockedShot,GreatSave,DefensiveApplause,
    FoulAppeal,BookedRestraint,Encourage,SubstitutionApplause
}

public readonly record struct FootballEmotionCue(FootballEmotion Kind,float Started,float Duration,
    float Intensity,int Variant,Point Attention,int Source)
{
    public bool Active=>Kind!=FootballEmotion.None && Started>=0;
}

public sealed partial class MatchSimulation
{
    sealed class EmotionTrack
    {
        public FootballEmotionCue Cue;
        public float PendingUntil,CooldownUntil;
        public int Fouls;
        public Footballer Player;
    }
    readonly EmotionTrack[] footballEmotionTracks=new EmotionTrack[22];
    float footballEmotionPreviousSeconds=-1;
    int footballEmotionSerial,footballEmotionVariation;
    public float FootballEmotionClock {get;private set;}

    public FootballEmotionCue FootballEmotionFor(int id)=>(uint)id<22 && footballEmotionTracks[id]!=null?
        footballEmotionTracks[id].Cue:default;

    public void QueueFootballEmotion(int id,FootballEmotion kind,Point attention,float intensity=1,int source=0)
    {
        if((uint)id>=22 || id>=Players.Count || kind==FootballEmotion.None)return;
        var player=Players[id];
        if(!player.InPlay || player.InjuryRemovedFromField)return;
        var track=footballEmotionTracks[id]??=new EmotionTrack{Player=player,Fouls=player.FoulsCommitted};
        float remaining=track.Cue.Active?track.Cue.Started+track.Cue.Duration-FootballEmotionClock:track.PendingUntil-FootballEmotionClock;
        if(remaining>0 && EmotionPriority(track.Cue.Kind)>EmotionPriority(kind))return;
        if(FootballEmotionClock<track.CooldownUntil && EmotionPriority(kind)<65)return;
        uint variation=unchecked(PlayerDialogue.StableHash(player.Profile.CatalogId)+(uint)player.Id*2654435761u+(uint)source*2246822519u);
        var temperament=PlayerDialogue.Temperament(player);
        float expression=temperament==PlayerTemperament.Fiery?1.08f:temperament==PlayerTemperament.Calm?.72f:.90f;
        expression*=Math.Clamp(.75f+player.Frustration*.25f+player.Confidence*.15f,.75f,1.10f);
        track.Cue=new(kind,-1,EmotionDuration(kind),Math.Clamp(intensity*expression,.45f,1.1f),(int)(variation%3),attention,source);
        track.PendingUntil=FootballEmotionClock+(kind==FootballEmotion.GreatSave?5:3.2f);
    }

    public void NotifyFootballSubstitution(int team,int incomingSlot)
    {
        if((uint)incomingSlot>=Players.Count)return;
        var incoming=Players[incomingSlot];
        footballEmotionTracks[incomingSlot]=new EmotionTrack{Player=incoming,Fouls=incoming.FoulsCommitted};
        QueueFootballEmotion(incomingSlot,FootballEmotion.SubstitutionApplause,RefereePosition,.8f,++footballEmotionVariation);
        int first=FindEmotionPartner(incoming,team,-1);
        if(first>=0)QueueFootballEmotion(first,FootballEmotion.SubstitutionApplause,incoming.Position,.85f,footballEmotionVariation);
    }

    // Called once per presentation step, including frozen restart/medical waits. It owns
    // a fixed 22-slot cue board, and never calls Next(), moves a player or changes AI.
    public void UpdateFootballEmotions(float dt)
    {
        if(dt<=0)return;
        if(Seconds<footballEmotionPreviousSeconds)
        {Array.Clear(footballEmotionTracks);FootballEmotionClock=0;footballEmotionSerial=footballEmotionVariation=0;}
        footballEmotionPreviousSeconds=Seconds;
        FootballEmotionClock+=dt;
        for(int i=0;i<Math.Min(22,Players.Count);i++)
        {
            var player=Players[i];var track=footballEmotionTracks[i];
            if(track==null || track.Player!=player)
                footballEmotionTracks[i]=track=new EmotionTrack{Player=player};
            if(player.FoulsCommitted>track.Fouls)
            {
                track.Fouls=player.FoulsCommitted;
                QueueFootballEmotion(i,FootballEmotion.FoulAppeal,RefereePosition,.75f+(1-player.Profile.Composure)*.25f,footballEmotionSerial);
            }
            if(track.Cue.Kind==FootballEmotion.None)continue;
            if(!player.InPlay || player.InjuryRemovedFromField || GoalHoldRemaining>0)
            {track.Cue=default;continue;}
            if(!track.Cue.Active)
            {
                if(FootballEmotionClock>track.PendingUntil){track.Cue=default;continue;}
                if(CanPlayFootballEmotion(player))track.Cue=track.Cue with{Started=FootballEmotionClock};
            }
            else if(FootballEmotionClock>=track.Cue.Started+track.Cue.Duration)
            {track.Cue=default;track.CooldownUntil=FootballEmotionClock+3.5f;}
        }
        foreach(var effect in Effects)
        {
            if(effect.Id<=footballEmotionSerial)continue;
            footballEmotionSerial=effect.Id;
            if(effect.Age>.25f || GoalHoldRemaining>0)continue;
            int actor=effect.Player;
            switch(effect.Kind)
            {
                case "miss" when actor<0 && (uint)lastShooter<Players.Count && Seconds-Players[lastShooter].LastKickAt<4:
                    QueueFootballEmotion(lastShooter,FootballEmotion.NearMiss,effect.Position,1,effect.Id);
                    EncourageEmotionPartner(Players[lastShooter],effect.Id);break;
                case "post" when (uint)lastShooter<Players.Count && Seconds-Players[lastShooter].LastKickAt<4:
                    QueueFootballEmotion(lastShooter,FootballEmotion.Woodwork,effect.Position,1.05f,effect.Id);
                    EncourageEmotionPartner(Players[lastShooter],effect.Id);break;
                case "block" when (uint)actor<Players.Count:
                    if((uint)lastShooter<Players.Count && Players[lastShooter].Team!=Players[actor].Team && Seconds-Players[lastShooter].LastKickAt<3)
                        QueueFootballEmotion(lastShooter,FootballEmotion.BlockedShot,effect.Position,.8f,effect.Id);
                    break;
                case "save" when (uint)actor<Players.Count && effect.Text!="ЗАБРАЛ!":
                    QueueFootballEmotion(actor,FootballEmotion.GreatSave,Ball,.85f,effect.Id);
                    int partner=FindEmotionPartner(Players[actor],Players[actor].Team,-1);
                    if(partner>=0)QueueFootballEmotion(partner,FootballEmotion.DefensiveApplause,Players[actor].Position,.8f,effect.Id);
                    break;
                case "yellow-card" when (uint)actor<Players.Count:
                    QueueFootballEmotion(actor,FootballEmotion.BookedRestraint,RefereePosition,.9f,effect.Id);break;
                case "offside" when (uint)actor<Players.Count:
                    QueueFootballEmotion(actor,FootballEmotion.FoulAppeal,RefereePosition,.65f,effect.Id);break;
            }
        }
    }

    void EncourageEmotionPartner(Footballer player,int source)
    {
        int partner=FindEmotionPartner(player,player.Team,-1);
        if(partner>=0)QueueFootballEmotion(partner,FootballEmotion.Encourage,player.Position,.8f,source);
    }
    int FindEmotionPartner(Footballer source,int team,int excluded)
    {
        int best=-1;float score=float.MaxValue;
        for(int i=0;i<Math.Min(22,Players.Count);i++)
        {
            var p=Players[i];if(p.Id==source.Id || p.Id==excluded || p.Team!=team || !p.InPlay || p.Keeper)continue;
            float distance=(p.Position-source.Position).Length;
            if(distance>22)continue;
            float value=distance-(PlayerDialogue.Temperament(p)==PlayerTemperament.Leader?4:0);
            if(value<score){best=i;score=value;}
        }
        return best;
    }
    public bool CanPlayFootballEmotion(Footballer p)
    {
        if(p.Team>=2 || !p.InPlay || p.InjuryRemovedFromField || MedicalActive || GoalHoldRemaining>0 || p.IsAcrobaticCommitted(Seconds) ||
            Seconds<p.FallUntil || Seconds<p.ImpactUntil || Seconds-p.LastSlideAt<2.1f ||
            p.DiveRemaining>0 || p.Keeper && Seconds-p.DiveEndedAt<HumanFigure.DiveRecoverySeconds(p,this) ||
            WindupPlayer==p.Id || KeeperKickPlayer==p.Id || Seconds-p.LastKickAt<.65f ||
            Seconds-p.LastPuntAt<.8f || Seconds-p.HeaderAt<.7f || Seconds<p.FeintUntil+.20f ||
            Seconds<p.InterceptUntil || Seconds<p.BlockUntil || Seconds<p.StandingTackleAt+.72f ||
            Owner==p.Id || p.VisualSpeed>2.4f || p.Velocity.Length>2.4f)return false;
        return FootballEmotionBallClearance(p)>4;
    }
    public float FootballEmotionBallClearance(Footballer p)
    {
        if(RestartRemaining>0)return 10;
        var relative=Ball-p.Position;
        float speed2=Point.Dot(BallVelocity,BallVelocity);
        float meeting=Math.Clamp(-Point.Dot(relative,BallVelocity)/Math.Max(1,speed2),0,.65f);
        return Math.Min(relative.Length,(relative+BallVelocity*meeting).Length);
    }
    static int EmotionPriority(FootballEmotion kind)=>kind switch
    {FootballEmotion.Woodwork=>80,FootballEmotion.NearMiss=>75,FootballEmotion.GreatSave=>70,
        FootballEmotion.BookedRestraint=>68,FootballEmotion.FoulAppeal=>65,FootballEmotion.BlockedShot=>55,
        FootballEmotion.SubstitutionApplause=>45,FootballEmotion.DefensiveApplause=>40,_=>35};
    public static float EmotionDuration(FootballEmotion kind)=>kind switch
    {FootballEmotion.NearMiss=>2.5f,FootballEmotion.Woodwork=>2.25f,FootballEmotion.BlockedShot=>1.75f,
        FootballEmotion.GreatSave=>1.95f,FootballEmotion.FoulAppeal=>2.15f,FootballEmotion.BookedRestraint=>2.0f,
        FootballEmotion.Encourage=>2.2f,_=>2.45f};
}

public static partial class ReplayFigure
{
    public static bool ApplyFootballEmotion(Footballer p,MatchSimulation m,List<Part> parts,Point forward,Point side)
    {
        var cue=m.FootballEmotionFor(p.Id);
        if(!cue.Active || !m.CanPlayFootballEmotion(p) || parts.Count<17)return false;
        float age=m.FootballEmotionClock-cue.Started;
        if(age<0 || age>=cue.Duration)return false;
        float weight=Smooth(age/.22f)*(1-Smooth((age-(cue.Duration-.38f))/.38f));
        weight*=Math.Clamp((2.4f-p.VisualSpeed)/1.1f,0,1);
        weight*=Math.Clamp((m.FootballEmotionBallClearance(p)-4)/3,0,1);
        float presence=weight;
        weight*=Math.Clamp(cue.Intensity,.45f,1);
        float strength=cue.Intensity;
        float lead=cue.Variant==1?-1:1;
        float load=EmotionPulse(age,.02f,.26f,.58f);
        switch(cue.Kind)
        {
            case FootballEmotion.NearMiss:
            {
                float dismay=Smooth((age-.20f)/.38f)*(1-Smooth((age-1.65f)/.45f));
                float bow=EmotionPulse(age,.65f,1.24f,2.25f);
                EmotionBody(parts,forward,side,(.055f*load+.025f*bow)*strength*weight,
                    (-.10f*load+.19f*bow)*weight,0,.025f*lead*bow*weight,0);
                EmotionHead(parts,forward,side,.16f*bow*weight,.10f*lead*EmotionPulse(age,.48f,1.0f,1.85f)*weight);
                for(int arm=0;arm<2;arm++)
                {
                    ApplyArmFk(parts,7+arm*3,new ArmAngles(132,42,-12,112,15),weight*dismay);
                    // A genuine self-contact, reached only after the arms have loaded.
                    var head=parts[13];int sign=arm==0?-1:1;
                    float touch=Smooth((age-.48f)/.25f)*(1-Smooth((age-1.53f)/.40f))*weight;
                    ApplyArmContact(parts,7+arm*3,head.A+side*(sign*.115f)+forward*.035f,head.ZA+.055f,
                        side*sign,-.18f,touch);
                }
                break;
            }
            case FootballEmotion.Woodwork:
            {
                float recoil=EmotionPulse(age,.02f,.26f,.68f);
                float disbelief=EmotionPulse(age,.32f,.82f,1.70f);
                EmotionBody(parts,forward,side,.075f*recoil*weight,-.15f*recoil*weight,
                    lead*.08f*disbelief*weight,0,0);
                EmotionHead(parts,forward,side,-.10f*recoil*weight,
                    MathF.Sin(Math.Clamp((age-.55f)/1.25f,0,1)*MathF.Tau)*.12f*weight);
                for(int arm=0;arm<2;arm++)ApplyArmFk(parts,7+arm*3,
                    new ArmAngles(46+disbelief*45,28+recoil*15,-5,70+disbelief*30,65,-8),weight);
                break;
            }
            case FootballEmotion.BlockedShot:
            {
                float exhale=EmotionPulse(age,.10f,.53f,1.45f);
                EmotionBody(parts,forward,side,.035f*exhale*weight,.15f*exhale*weight,
                    -.045f*lead*exhale*weight,.035f*lead*exhale*weight,0);
                EmotionHead(parts,forward,side,.16f*exhale*weight,.07f*lead*weight);
                ApplyArmFk(parts,lead<0?7:10,new ArmAngles(28,22,0,58,75,-10),exhale*weight);
                ApplyArmFk(parts,lead<0?10:7,new ArmAngles(-4,7,0,18),weight);
                break;
            }
            case FootballEmotion.GreatSave:
            {
                float fist=EmotionPulse(age,.13f,.44f,.95f);
                float direct=Smooth((age-.85f)/.30f)*(1-Smooth((age-1.55f)/.35f));
                EmotionBody(parts,forward,side,.05f*load*weight,-.07f*fist*weight,
                    lead*.12f*direct*weight,0,0);
                EmotionHead(parts,forward,side,-.08f*fist*weight,.15f*lead*direct*weight);
                ApplyArmFk(parts,lead<0?7:10,new ArmAngles(45,16,-10,112,20),fist*weight);
                ApplyArmFk(parts,lead<0?10:7,new ArmAngles(76,12,0,22,-5,-5),direct*weight);
                break;
            }
            case FootballEmotion.DefensiveApplause:
                EmotionBody(parts,forward,side,.018f*load*weight,.055f*EmotionPulse(age,.2f,.65f,1.35f)*weight,0,0,0);
                EmotionHead(parts,forward,side,.10f*EmotionPulse(age,.12f,.33f,.62f)*weight,AttentionYaw(p,forward,cue.Attention)*.35f*weight);
                EmotionClap(parts,forward,side,age-.32f,weight,2,presence);break;
            case FootballEmotion.FoulAppeal:
            {
                float ask=EmotionPulse(age,.05f,.52f,1.75f);
                EmotionBody(parts,forward,side,.028f*ask*weight,-.05f*ask*weight,
                    lead*.08f*ask*weight,lead*.045f*ask*weight,0);
                EmotionHead(parts,forward,side,-.035f*ask*weight,AttentionYaw(p,forward,cue.Attention)*.6f*weight);
                ApplyArmFk(parts,7,new ArmAngles(46,27,-5,88,80,-8),ask*weight);
                ApplyArmFk(parts,10,new ArmAngles(38,19,5,72,80,-8),ask*weight);
                break;
            }
            case FootballEmotion.BookedRestraint:
            {
                float stop=EmotionPulse(age,.15f,.57f,1.50f);
                EmotionBody(parts,forward,side,.04f*load*weight,.06f*stop*weight,
                    -.07f*lead*stop*weight,0,0);
                EmotionHead(parts,forward,side,.10f*EmotionPulse(age,.4f,.8f,1.55f)*weight,AttentionYaw(p,forward,cue.Attention)*.45f*weight);
                ApplyArmFk(parts,lead<0?7:10,new ArmAngles(54,12,0,45,-10,-18),stop*weight);
                ApplyArmFk(parts,lead<0?10:7,new ArmAngles(2,10,0,22),weight);
                break;
            }
            case FootballEmotion.Encourage:
            {
                float beckon=EmotionPulse(age,.10f,.42f,.79f);
                EmotionBody(parts,forward,side,.025f*beckon*weight,.075f*beckon*weight,
                    lead*.055f*beckon*weight,0,0);
                EmotionHead(parts,forward,side,.13f*EmotionPulse(age,.18f,.38f,.65f)*weight,AttentionYaw(p,forward,cue.Attention)*.45f*weight);
                ApplyArmFk(parts,lead<0?7:10,new ArmAngles(53,12,0,55+beckon*45,60,-8),beckon*weight);
                EmotionClap(parts,forward,side,age-.90f,weight*Smooth((age-.75f)/.25f),2,
                    presence*Smooth((age-.75f)/.25f));break;
            }
            case FootballEmotion.SubstitutionApplause:
                EmotionBody(parts,forward,side,.015f*load*weight,.045f*EmotionPulse(age,.2f,.6f,1.1f)*weight,
                    lead*.09f*EmotionPulse(age,.35f,1.0f,1.9f)*weight,0,0);
                EmotionHead(parts,forward,side,.10f*EmotionPulse(age,.15f,.4f,.7f)*weight,AttentionYaw(p,forward,cue.Attention)*.4f*weight);
                EmotionClap(parts,forward,side,age-.23f,weight,3,presence);break;
        }
        return true;
    }

    static float EmotionPulse(float t,float start,float peak,float end)=>
        t<peak?Smooth((t-start)/(peak-start)):1-Smooth((t-peak)/(end-peak));
    static float AttentionYaw(Footballer p,Point forward,Point attention)
    {
        var at=attention-p.Position;
        if(at.Length<.05f)return 0;
        return Math.Clamp(MathF.Atan2(forward.X*at.Y-forward.Y*at.X,Point.Dot(forward,at)),-.65f,.65f);
    }
    // Body rotations are rigid; planted ankles are solved afterwards. No bone
    // grows, and the expression never offsets the football simulation's root.
    static void EmotionBody(List<Part> parts,Point forward,Point side,float crouch,float pitch,float yaw,float roll,float lift,float stance=0)
    {
        var left=parts[1];var right=parts[4];
        if(crouch!=0)for(int i=0;i<parts.Count;i++)
        {var v=parts[i];parts[i]=v with{ZA=v.ZA-crouch,ZB=v.ZB-crouch};}
        var hip=parts[6].A;float hipZ=parts[6].ZA;
        (Point,float) Turn(Point at,float z)
        {
            var d=at-hip;float u=Point.Dot(d,forward),v=Point.Dot(d,side),w=z-hipZ;
            float x=u*MathF.Cos(pitch)+w*MathF.Sin(pitch),a=w*MathF.Cos(pitch)-u*MathF.Sin(pitch);
            float y=v*MathF.Cos(roll)-a*MathF.Sin(roll),b=a*MathF.Cos(roll)+v*MathF.Sin(roll);
            return(hip+forward*(x*MathF.Cos(yaw)-y*MathF.Sin(yaw))+side*(x*MathF.Sin(yaw)+y*MathF.Cos(yaw)),hipZ+b);
        }
        for(int i=6;i<parts.Count;i++)
        {var v=parts[i];var a=Turn(v.A,v.ZA);var b=Turn(v.B,v.ZB);parts[i]=v with{A=a.Item1,ZA=a.Item2,B=b.Item1,ZB=b.Item2};}
        KeeperLeg(parts,0,left.B-side*stance,left.ZB,forward,0,forward,0);
        KeeperLeg(parts,1,right.B+side*stance,right.ZB,forward,0,forward,0);
        if(lift!=0)for(int i=0;i<parts.Count;i++)
        {var v=parts[i];parts[i]=v with{ZA=v.ZA+lift,ZB=v.ZB+lift};}
    }
    static void EmotionHead(List<Part> parts,Point forward,Point side,float pitch,float yaw)
    {
        var head=parts[13];
        (Point,float) Turn(Point at,float z)
        {
            var d=at-head.A;float x=Point.Dot(d,forward),y=Point.Dot(d,side),h=z-head.ZA;
            float u=x*MathF.Cos(pitch)+h*MathF.Sin(pitch),v=h*MathF.Cos(pitch)-x*MathF.Sin(pitch);
            return(head.A+forward*(u*MathF.Cos(yaw)-y*MathF.Sin(yaw))+side*(u*MathF.Sin(yaw)+y*MathF.Cos(yaw)),head.ZA+v);
        }
        for(int i=13;i<parts.Count;i++)
        {var v=parts[i];var a=Turn(v.A,v.ZA);var b=Turn(v.B,v.ZB);parts[i]=v with{A=a.Item1,ZA=a.Item2,B=b.Item1,ZB=b.Item2};}
    }
    static void EmotionClap(List<Part> parts,Point forward,Point side,float age,float weight,int count,float contactPresence=-1)
    {
        if(weight<=0)return;
        float contact=0;
        for(int n=0;n<count;n++)contact=Math.Max(contact,EmotionPulse(age,n*.59f+.08f,n*.59f+.23f,n*.59f+.45f));
        float ready=Smooth((age+.10f)/.22f)*(1-Smooth((age-(count*.59f+.05f))/.35f));
        // Personality changes the load and volume, not whether two palms can
        // meet. Only an actual beat earns full contact; entry/release and ball
        // safety still attenuate both arms together.
        float contactWeight=Math.Max(weight,Math.Max(0,contactPresence)*contact)*ready;
        for(int arm=0;arm<2;arm++)
        {
            int sign=arm==0?-1:1;int i=7+arm*3;
            ApplyArmFk(parts,i,new ArmAngles(40,22,-12,95,0,-5),weight*ready);
            var chest=parts[6];var target=chest.B+forward*.33f+side*(sign*(.23f-.2025f*contact));
            ApplyArmContact(parts,i,target,chest.ZB-.13f,side*(sign*.12f),-1,contactWeight);
            EmotionHandFrame(parts,i,forward*.72f,.694f,side*(-sign),0,contactWeight);
        }
    }
    static void EmotionHandFrame(List<Part> parts,int arm,Point fingers,float fingersZ,Point palm,float palmZ,float weight)
    {
        if(weight<=0)return;
        var frame=AnatomicalFrame(parts);var hand=parts[arm+2];
        var lower=UnitArm(AnatomicalB(parts[arm+1])-AnatomicalA(parts[arm+1]),-frame.Up);
        var wanted=UnitArm(AnatomicalPoint(fingers,fingersZ),lower);
        float bend=ArmAngle(lower,wanted);
        if(bend>38)wanted=UnitArm(BlendFallBoneDirection(lower,wanted,38/bend,1,frame.Forward),lower);
        var normal=UnitArm(ArmPlane(AnatomicalPoint(palm,palmZ),wanted),PalmReference(frame,wanted));
        var oldPalm=AnatomicalPalmNormal(parts,arm);
        var old=ArmHandRotation(AnatomicalB(hand)-AnatomicalA(hand),AnatomicalPoint(oldPalm.direction,oldPalm.z));
        var rotation=NQuaternion.Slerp(old,ArmHandRotation(wanted,normal),Math.Clamp(weight,0,1));
        var direction=NVector.Transform(NVector.UnitX,rotation);
        normal=NVector.Transform(NVector.UnitZ,rotation);
        float roll=SignedArmAngle(PalmReference(frame,direction),normal,direction);
        var tip=AnatomicalA(hand)+direction*.075f;
        parts[arm+2]=hand with{B=AnatomicalGround(tip),ZB=tip.Z,PalmRoll=roll,ArticulatedHand=true};
    }
}