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