FootballTechnicalArea.cs

Partial of FootballWorldPanel that renders the technical area: coach and substitute rigs built from ModelRenderer parts, posed each frame from the shared ReplayFigure pose, plus TechnicalAreaReport, a diagnostic dump comparing bench kit tints against what the pose should produce.

using Sandbox;
using Socer;
using System;
using System.Collections.Generic;
using System.Linq;

public sealed partial class FootballWorldPanel
{
    readonly List<ModelRenderer[]> sidelineActors=new();
    readonly List<PlayerDetailRenderer> sidelineUniforms=new();
    readonly List<ReplayFigure.Part> sidelinePose=new(24);
    readonly Footballer sidelineFigure=new(){Id=100,Team=2};
    float nextSidelineFrame;
    float lastSidelineClock=-1;
    int lastSidelineCommands=-1,lastSidelineReactions=-1,lastSidelineSubstitutions=-1;
    string lastSidelineKitId;
    int lastSidelineBenchHash;
    bool wasSidelineVisible;
    MatchSimulation sidelineMatch;
    int sidelineFrames,sidelineVisible;

    void EnsureSidelineActors()
    {
        if(sidelineActors.Count>0)return;
        // Two staffs of two people, and at most nine substitutes per team.
        for(int i=0;i<22;i++)
        {
            var parts=new ModelRenderer[24];
            for(int j=0;j<parts.Length;j++)parts[j]=Create(j==6?torso:j is 0 or 1 or 3 or 4 or 7 or 8 or 10 or 11?limb:sphere,i<4?"Technical staff":"Substitute");
            sidelineActors.Add(parts);
        }
    }
    void UpdateTechnicalArea()
    {
        EnsureSidelineActors();
        while(sidelineUniforms.Count<sidelineActors.Count)sidelineUniforms.Add(new PlayerDetailRenderer(RenderScene));
        if(Time.Now<nextSidelineFrame)return;
        nextSidelineFrame=Time.Now+.05f;
        bool visible=!Game.StrategyLabOpen;
        var area=Game.TechnicalArea;
        var cards=Game.BenchCards.ToArray();
        int benchHash=cards.Length;
        foreach(var card in cards)benchHash=HashCode.Combine(benchHash,card.Id,card.CatalogId,card.Edition,card.Position,card.PortraitSkin,card.PortraitHair);
        string kitId=Game.Career?.EquippedKitId;
        if(sidelineMatch==Game.Match && lastSidelineClock==area.Clock && lastSidelineCommands==area.Commands && lastSidelineReactions==area.Reactions && lastSidelineSubstitutions==Game.SubstitutionsUsed && wasSidelineVisible==visible && lastSidelineKitId==kitId && lastSidelineBenchHash==benchHash)return;
        sidelineMatch=Game.Match;
        lastSidelineClock=area.Clock;lastSidelineCommands=area.Commands;lastSidelineReactions=area.Reactions;lastSidelineSubstitutions=Game.SubstitutionsUsed;wasSidelineVisible=visible;
        lastSidelineKitId=kitId;lastSidelineBenchHash=benchHash;sidelineFrames++;sidelineVisible=0;
        if(!visible){foreach(var actor in sidelineActors)foreach(var part in actor)part.Enabled=false;foreach(var detail in sidelineUniforms)detail.Hide();return;}
        for(int i=0;i<22;i++)
        {
            int team=i<4?i/2:(i-4)/9;bool staff=i<4;int seat=staff?i%2:(i-4)%9;
            var coach=area.Coaches[team];
            bool active=staff || team==1 || seat<cards.Length;
            if(!active){foreach(var part in sidelineActors[i])part.Enabled=false;sidelineUniforms[i].Hide();continue;}
            sidelineVisible++;
            var location=staff?(seat==0?coach.Position:new Point(TechnicalAreaPresentation.BenchX(team)+4.4f,-4.2f)):
                new Point(TechnicalAreaPresentation.BenchX(team)+(seat-4)*1.3f,-7.65f);
            var target=staff && seat==0?coach.Target:Game.Match.Ball;
            var direction=(target-location).Unit;if(direction.Length<.1f)direction=new Point(0,1);
            if(!staff)direction=new Point(0,1);
            var figure=staff && seat==0?coach.Figure:sidelineFigure;
            figure.Position=location;figure.Team=team;
            // Keeper is derived from Number. The seated rig must use the same
            // role as the real reserve, rather than the shared dummy's default.
            figure.Number=staff?0:team==0 && cards[seat].Position=="ВР"?1:seat+12;
            if(staff && seat==0)direction=new Point(MathF.Cos(figure.Facing),MathF.Sin(figure.Facing));
            else figure.Facing=MathF.Atan2(direction.Y,direction.X);
            figure.Profile.SkinTone=staff?(team*2+seat)%6:team==0?cards[seat].PortraitSkin:(seat+team)%6;
            figure.Profile.HairTone=staff?(seat==0?1:3):team==0?cards[seat].PortraitHair:seat%5;
            figure.Profile.CatalogId=!staff && team==0?cards[seat].CatalogId:"";
            figure.Profile.Edition=!staff && team==0?cards[seat].Edition:"";
            ReplayFigure.Pose(figure,Game.Match,sidelinePose);
            PoseSidelinePerson(location,direction,staff,seat,coach,area.Clock);
            ReplayFigure.RepairArmAnatomy(sidelinePose);
            for(int j=0;j<sidelineActors[i].Length;j++)
            {
                var renderer=sidelineActors[i][j];renderer.Enabled=j<sidelinePose.Count;
                if(j>=sidelinePose.Count)continue;
                var part=sidelinePose[j];SetPart(renderer,part.A,part.ZA,part.B,part.ZB,part.Width,!staff?TeamKitColor(team*11,part.Color):part.Color);
                FitPoseBody(renderer,j,sidelinePose,figure.Keeper);
            }
            var trim=team==0?ClubCosmetics.Kit(Game.Career.EquippedKitId).Trim:"#ffffff";
            if(staff)sidelineUniforms[i].UpdateUniform(sidelineActors[i],trim,true);
            else sidelineUniforms[i].Update(team==0?cards[seat].CatalogId:"",team==0?cards[seat].Edition:"",figure.Number,true,sidelineActors[i],trim:trim,lowDetail:true);
        }
    }
    void PoseSidelinePerson(Point centre,Point forward,bool staff,int seat,TechnicalAreaPresentation.Coach coach,float clock)
    {
        var side=new Point(-forward.Y,forward.X);bool assistant=staff && seat==1;
        bool headCoach=staff && !assistant;
        var gesture=clock<coach.Until?coach.Gesture:CoachGesture.Watch;
        float age=coach.Age(clock),weight=gesture==CoachGesture.Watch?0:Ease(age/.18f)*Ease((coach.Until-clock)/.4f);
        float breath=MathF.Sin(clock*1.7f+seat)*.009f;
        // One loaded hop, then settle into the raised-arm reaction. Seated
        // reserves keep their boots on the turf while the upper body reacts.
        float bounce=gesture==CoachGesture.Celebrate && staff?
            (FootballSecondaryMotion.StaffJump(age)+FootballSecondaryMotion.StaffLoad(age))*weight*(assistant?.55f:1):0;
        float hipZ=staff?.84f:.55f,chestZ=staff?1.4f:1.13f;
        if(headCoach){hipZ=sidelinePose[6].ZA;chestZ=sidelinePose[6].ZB;}
        // Seating changes geometry only. Keep the exact shirt/shorts/socks
        // palette provided by the shared player pose, including goalkeeper kits.
        string top=staff?(assistant?coach.Team==0?"#245b74":"#784936":"#28333b"):sidelinePose[6].Color;
        string trousers=staff?"#242b32":sidelinePose[0].Color;
        string socks=staff?trousers:sidelinePose[1].Color;
        Point J(float across,float ahead)=>centre+side*across+forward*ahead;
        void Part(int index,Point a,float za,Point b,float zb,string color)
        {var p=sidelinePose[index];sidelinePose[index]=p with{A=a,ZA=za+bounce,B=b,ZB=zb+bounce,Color=color};}
        for(int leg=0;leg<2;leg++)
        {
            if(headCoach)
            {
                // Preserve the shared stance/swing and fixed-length knee IK.
                var coachFoot=sidelinePose[leg*3+1];
                for(int part=leg*3;part<leg*3+3;part++)
                {
                    var p=sidelinePose[part];sidelinePose[part]=p with{Color=part%3==2?"#17282b":trousers,ZA=p.ZA+bounce,ZB=p.ZB+bounce};
                }
                ReplayFigure.PoseSidelineSupportLeg(sidelinePose,leg,coachFoot.B,coachFoot.ZB+Math.Max(0,bounce),forward);
                continue;
            }
            if(!staff)
            {
                ReplayFigure.PoseSeatedLeg(sidelinePose,leg,centre,forward,hipZ,trousers,socks);
                continue;
            }
            float s=leg==0?-1:1;
            var hip=J(s*.13f,0);var knee=J(s*.17f,staff?.02f:.34f);var foot=J(s*.18f,staff?.06f:.40f);
            Part(leg*3,hip,hipZ,knee,staff?.44f:.48f,trousers);Part(leg*3+1,knee,staff?.44f:.48f,foot,.065f,socks);
            Part(leg*3+2,foot,.065f,foot+forward*.23f,.065f,"#17282b");
            ReplayFigure.PoseSidelineSupportLeg(sidelinePose,leg,foot,.065f+Math.Max(0,bounce),forward);
        }
        float lean=gesture==CoachGesture.Frustrated?weight*.09f:0;
        var chest=headCoach?sidelinePose[6].B+forward*lean:J(0,.02f+lean);chestZ+=breath-lean*.3f;
        Part(6,headCoach?sidelinePose[6].A:centre,hipZ,chest,chestZ,top);
        for(int arm=0;arm<2;arm++)
        {
            int index=7+arm*3;float s=arm==0?-1:1;
            if(headCoach && gesture==CoachGesture.Watch)
            {
                // Idle/walking arms come from the same gait as the feet.
                var p=sidelinePose[index];sidelinePose[index]=p with{Color=top};
                continue;
            }
            var shoulder=chest+side*(s*.215f);float shoulderZ=chestZ-.055f;
            // Rotate fixed-length upper/lower arms: no stretching during a command gesture.
            float upper=.12f,lower=.38f;var armDirection=(forward*.55f+side*(s*.65f)).Unit;
            if(!staff){upper=1.0f;lower=-.8f;}
            else if(assistant){upper=.58f;lower=1.25f;armDirection=(forward-side*s*.5f).Unit;}
            float wantedUpper=upper,wantedLower=lower;
            if(gesture==CoachGesture.Celebrate){wantedUpper=2.3f;wantedLower=2.5f;}
            else if(gesture==CoachGesture.Frustrated){wantedUpper=2.25f;wantedLower=2.6f;armDirection=(forward*.20f-side*s*.65f).Unit;}
            else if(gesture==CoachGesture.Applaud){wantedUpper=1.1f;wantedLower=1.45f;armDirection=(forward-side*s*(.75f+MathF.Sin(age*13)*.18f)).Unit;}
            else if(staff && !assistant && gesture==CoachGesture.Point && arm==1){wantedUpper=1.50f;wantedLower=1.6f;armDirection=forward;}
            else if(staff && !assistant && gesture==CoachGesture.Press){wantedUpper=1.45f+MathF.Sin(age*9)*.18f;wantedLower=1.6f;}
            else if(staff && !assistant && gesture==CoachGesture.Hold){wantedUpper=1.10f;wantedLower=1.15f;armDirection=side*s;}
            else if(staff && !assistant && gesture==CoachGesture.Call){wantedUpper=2;wantedLower=2.65f;armDirection=(forward*.25f-side*s*.35f).Unit;}
            float influence=assistant?weight*.5f:weight;
            upper+=(wantedUpper-upper)*influence;lower+=(wantedLower-lower)*influence;
            var elbow=shoulder+armDirection*(MathF.Sin(upper)*.27f);float elbowZ=shoulderZ-MathF.Cos(upper)*.27f;
            var hand=elbow+armDirection*(MathF.Sin(lower)*.25f);float handZ=elbowZ-MathF.Cos(lower)*.25f;
            Part(index,shoulder,shoulderZ,elbow,elbowZ,top);
            string skin=sidelinePose[index+1].Color;Part(index+1,elbow,elbowZ,hand,handZ,skin);Part(index+2,hand,handZ,hand+forward*.025f,handZ,skin);
        }
        var head=chest+forward*(lean*.15f);float headZ=chestZ+.22f;
        Part(13,head,headZ,head,headZ,sidelinePose[13].Color);
        var look=forward;
        if(headCoach)
        {
            var aim=coach.Target-centre;
            float yaw=MathF.Atan2(aim.Y,aim.X)-coach.Figure.Facing;
            yaw=Math.Clamp(MathF.Atan2(MathF.Sin(yaw),MathF.Cos(yaw)),-1.35f,1.35f)+coach.Figure.Facing;
            look=new Point(MathF.Cos(yaw),MathF.Sin(yaw));
        }
        var lookSide=new Point(-look.Y,look.X);
        Part(14,head-look*.015f,headZ+.10f,head-look*.015f,headZ+.10f,staff && !assistant?"#68655d":sidelinePose[14].Color);
        for(int eye=0;eye<2;eye++){var at=head+look*.151f+lookSide*((eye==0?-1:1)*.052f);Part(15+eye,at,headZ+.026f,at,headZ+.026f,"#243038");}
        if(staff)
        {
            // Mouth pulse while calling, plus a light shirt/tie to distinguish the head coach.
            float mouth=clock<coach.SpeechUntil && !assistant?.045f+.015f*MathF.Sin(age*24):.02f;
            var at=head+look*.155f;sidelinePose.Add(new(at,headZ-.065f+bounce,at,headZ-.065f+bounce,mouth,"#563a32"));
            var tie=chest+forward*.21f;sidelinePose.Add(new(tie,chestZ-.08f+bounce,tie,chestZ-.34f+bounce,.045f,assistant?"#9ebac1":"#e7ded0"));
        }
    }
    static float Ease(float t){t=Math.Clamp(t,0,1);return t*t*(3-2*t);}
    public object TechnicalAreaReport()
    {
        var cards=Game.BenchCards.ToArray();
        var expectedPose=new List<ReplayFigure.Part>(24);
        var reserves=new List<object>();
        object Tint(Color color)=>new{R=color.r,G=color.g,B=color.b,A=color.a};
        for(int i=4;i<sidelineActors.Count;i++)
        {
            var rig=sidelineActors[i];
            if(!rig[6].Enabled)continue;
            int team=(i-4)/9,seat=(i-4)%9;
            var card=team==0 && seat<cards.Length?cards[seat]:null;
            var player=new Footballer{Team=team,Number=card?.Position=="ВР"?1:seat+12};
            player.Profile.CatalogId=card?.CatalogId??"";player.Profile.Edition=card?.Edition??"";
            ReplayFigure.Pose(player,Game.Match,expectedPose);
            string shirt=TeamKitColor(team*11,expectedPose[6].Color);
            string shorts=TeamKitColor(team*11,expectedPose[0].Color);
            string socks=TeamKitColor(team*11,expectedPose[1].Color);
            bool customTop=PlayerAppearance.HasOfficerTunic(player.Profile.CatalogId,player.Profile.Edition) ||
                PlayerAppearance.LegendaryCostume(player.Profile.CatalogId,player.Profile.Edition)!=null;
            reserves.Add(new{Team=team,Seat=seat,CardId=card?.Id,CatalogId=card?.CatalogId,
                Keeper=player.Keeper,CustomTop=customTop,
                Expected=new{Shirt=shirt,Shorts=shorts,Socks=socks},
                Actual=new{Shirt=Tint(rig[6].Tint),Shorts=Tint(rig[0].Tint),Socks=Tint(rig[1].Tint)},
                ShirtMatches=customTop?(bool?)null:rig[6].Tint==Ink(shirt),
                ShortsMatches=rig[0].Tint==Ink(shorts),SocksMatches=rig[1].Tint==Ink(socks)});
        }
        return new{People=sidelineVisible,Frames=sidelineFrames,Actors=sidelineActors.Count,
            KitId=lastSidelineKitId,Reserves=reserves.ToArray(),
            TeamUniforms=Enumerable.Range(0,2).Select(team=>
            {
                bool CustomTop(string id,string edition)=>PlayerAppearance.HasOfficerTunic(id,edition) ||
                    PlayerAppearance.LegendaryCostume(id,edition)!=null;
                var fieldPlayer=Game.Match.ActivePlayers.FirstOrDefault(p=>p.Team==team && !p.Keeper &&
                    !CustomTop(p.Profile.CatalogId,p.Profile.Edition));
                var field=fieldPlayer!=null && fieldPlayer.Id<actors.Count?actors[fieldPlayer.Id]:null;
                var seats=Enumerable.Range(4,Math.Max(0,sidelineActors.Count-4)).Where(i=>
                {
                    if((i-4)/9!=team || !sidelineActors[i][6].Enabled)return false;
                    int seat=(i-4)%9;var card=team==0 && seat<cards.Length?cards[seat]:null;
                    return card?.Position!="ВР" && !CustomTop(card?.CatalogId,card?.Edition);
                }).ToArray();
                bool Match(int slot)=>field!=null && seats.Length>0 && seats.All(i=>sidelineActors[i][slot].Tint==field[slot].Tint);
                return new{Team=team,ComparedReserves=seats.Length,HasFieldReference=field!=null,
                    ShirtMatches=Match(6),ShortsMatches=Match(0),SocksMatches=Match(1),
                    MatchFieldColors=field==null?null:new{Shirt=Tint(field[6].Tint),Shorts=Tint(field[0].Tint),Socks=Tint(field[1].Tint)},
                    BenchColors=seats.Select(i=>new{Seat=(i-4)%9,Shirt=Tint(sidelineActors[i][6].Tint),
                        Shorts=Tint(sidelineActors[i][0].Tint),Socks=Tint(sidelineActors[i][1].Tint)}).ToArray()};
            }).ToArray()};
    }
}