A ScenePanel dressing-room preview for club cosmetics. Builds its own Scene with a camera and studio lighting, drives the shared ReplayFigure rig and FootballWorldPanel part models to pose a Footballer, and frames the camera on the player's head using the authored head model bounds. Also shows the selected ball and colourises kit parts through ClubCosmetics.
using Sandbox;
using Sandbox.UI;
using Socer;
using System;
using System.Collections.Generic;
// The dressing room renders the same ReplayFigure rig and geometry as the live pitch.
public sealed class ClubCosmeticPreview : ScenePanel
{
public PlayerCard Card { get; set; }
public int PlayerSlot { get; set; }=-1;
public string KitId { get; set; }="north";
public string BallId { get; set; }="classic";
public float FixedFacing {get;set;}=float.NaN;
public bool Closeup {get;set;}
public bool Clay {get;set;}
// Review the actual broadcast LOD at close range, including its silhouette.
public bool ForceLowDetail {get;set;}
public string IdentityOverride {get;set;}="";
const float Units=39.37f;
readonly Dictionary<string,Color> colors=new();
readonly List<ReplayFigure.Part> pose=new(24);
ModelRenderer[] actor;
ModelRenderer ball,ballTrim,ballPanels;
MatchSimulation poseMatch;
Footballer previewPlayer;
bool ready;
PlayerDetailRenderer details;
CameraComponent sceneCamera;
string shownBall;
string framingIdentity;
bool framingLegendary;
int framingGeneration;
float headTopOffset=.27f;
static float Linear(float value)=>value<=.04045f?value/12.92f:MathF.Pow((value+.055f)/1.055f,2.4f);
Color Ink(string hex)
{
if(colors.TryGetValue(hex,out var color))return color;
var source=Color.Parse(hex)??Color.White;
return colors[hex]=new Color(Linear(source.r),Linear(source.g),Linear(source.b),source.a);
}
ModelRenderer Create(Model model,string name)
{
var renderer=new GameObject(RenderScene,true,name).Components.Create<ModelRenderer>();
renderer.Model=model;
return renderer;
}
void Floor(Model model,string name,Vector3 centre,Vector3 scale,string tint)
{
var renderer=Create(model,name);
renderer.WorldTransform=new Transform(centre,Rotation.Identity,scale);
renderer.Tint=Ink(tint);
}
void Initialize()
{
RenderScene=new Scene();
var camera=new GameObject(RenderScene,true,"Dressing room camera").Components.Create<CameraComponent>();
sceneCamera=camera;
camera.IsMainCamera=true;camera.FieldOfView=43;camera.FovAxis=CameraComponent.Axis.Horizontal;
camera.BackgroundColor=new Color(.07f,.12f,.15f);
camera.WorldPosition=new Vector3(105,-155,96);
camera.WorldRotation=Rotation.LookAt(new Vector3(7,0,39)-camera.WorldPosition);
new GameObject(RenderScene,true,"Ambient fill").Components.Create<AmbientLight>().Color=new Color(.28f,.32f,.36f);
var sun=new GameObject(RenderScene,true,"Studio light").Components.Create<DirectionalLight>();
sun.WorldRotation=Rotation.From(new Angles(48,25,0));sun.LightColor=new Color(1.45f,1.42f,1.35f);
var box=FootballWorldPanel.MakeBox();
var sphere=FootballWorldPanel.MakeSphere();
var torso=FootballWorldPanel.MakeBody(true);
var limb=FootballWorldPanel.MakeBody(false);
Floor(box,"Turf",new Vector3(0,0,-4),new Vector3(170,145,4),"#244d43");
Floor(box,"Display plinth",new Vector3(0,0,-1),new Vector3(86,75,3),"#193039");
actor=new ModelRenderer[24];
for(int i=0;i<actor.Length;i++)
actor[i]=Create(i==6?torso:i is 0 or 1 or 3 or 4 or 7 or 8 or 10 or 11?limb:sphere,"Match player limb");
ball=Create(FootballWorldPanel.MakeSmoothSphere(),"Match ball");
ballTrim=Create(FootballWorldPanel.MakeBallPanels(.51f,.503f),"Flat ball trim");
ballPanels=Create(FootballWorldPanel.MakeBallPanels(.43f,.506f),"Flat ball panels");
poseMatch=new MatchSimulation(77){Owner=-1,Seconds=10};
previewPlayer=new Footballer {Team=0,Number=9,Position=new Point(0,0),Home=new Point(0,0),Stamina=1};
ready=true;
}
void PlacePart(ModelRenderer renderer,int slot,ReplayFigure.Part part,Point shoulderAxis,float shoulderHeight,KitDesign kit,Vector3 palmNormal)
{
var start=FootballWorldPanel.WorldPoint(part.A,part.ZA);
var end=FootballWorldPanel.WorldPoint(part.B,part.ZB);
var delta=end-start;
var direction=delta.Length>.001f?delta.Normal:Vector3.Forward;
var rotation=delta.Length>.001f?Rotation.LookAt(direction,Math.Abs(direction.z)>.98f?Vector3.Forward:Vector3.Up):Rotation.Identity;
renderer.WorldTransform=new Transform((start+end)*.5f,rotation,
new Vector3(Math.Max(part.Width*Units,delta.Length+part.Width*Units*.45f),part.Width*Units,part.Width*Units));
string color=part.Color switch {"#46c7ed"=>kit.Shirt,"#193d60"=>kit.Shorts,"#8fe5f2"=>kit.Socks,_=>part.Color};
renderer.Tint=Ink(color);renderer.Enabled=true;
FootballWorldPanel.FitPoseBody(renderer,slot,pose,previewPlayer.Keeper);
}
public override void OnDeleted()
{
RenderScene?.Destroy();
base.OnDeleted();
}
public override void Tick()
{
base.Tick();
if(!ready)Initialize();
var kit=ClubCosmetics.Kit(KitId);
previewPlayer.Id=PlayerSlot>=0?PlayerSlot:Math.Abs(Card?.Id??0)%11;
previewPlayer.Number=Card?.Position=="ВР"?1:PlayerSlot>=0?PlayerSlot+1:9;
previewPlayer.Profile.CatalogId=string.IsNullOrEmpty(IdentityOverride)?Card?.CatalogId??"":IdentityOverride;
previewPlayer.Profile.Edition=Card?.Edition??"";
previewPlayer.Profile.SkinTone=Card?.PortraitSkin??-1;
previewPlayer.Profile.HairTone=Card?.PortraitHair??-1;
previewPlayer.Facing=float.IsNaN(FixedFacing)?.96f+MathF.Sin(Time.Now*.45f)*.34f:FixedFacing;
poseMatch.Seconds=10+Time.Now;
ReplayFigure.Pose(previewPlayer,poseMatch,pose);
var shoulderAxis=pose.Count>10?pose[10].A-pose[7].A:new Point(0,1);
float shoulderHeight=pose.Count>10?pose[10].ZA-pose[7].ZA:0;
var palmNormal=previewPlayer.Keeper?FootballWorldPanel.PoseFront(pose):default;
for(int i=0;i<actor.Length;i++)
{
actor[i].Enabled=i<pose.Count;
if(i<pose.Count)PlacePart(actor[i],i,pose[i],shoulderAxis,shoulderHeight,kit,palmNormal);
}
details??=new PlayerDetailRenderer(RenderScene);
details.Update(previewPlayer.Profile.CatalogId,previewPlayer.Profile.Edition,previewPlayer.Number,true,actor,Clay,kit.Trim,ForceLowDetail);
FramePlayer();
if(shownBall!=BallId)
{
shownBall=BallId;
var design=ClubCosmetics.Ball(BallId);
ball.Tint=Ink(design.Base);ballTrim.Tint=Ink(design.Trim);ballPanels.Tint=Ink(design.Panels);
}
var ballTransform=new Transform(new Vector3(31,-8,5),Rotation.From(new Angles(Time.Now*42,Time.Now*22,0)),new Vector3(9));
ball.WorldTransform=ballTransform;ballTrim.WorldTransform=ballTransform;ballPanels.WorldTransform=ballTransform;
}
void FramePlayer()
{
string id=previewPlayer.Profile.CatalogId;
bool legendary=previewPlayer.Profile.Edition==CardRatings.Legendary;
if(framingIdentity!=id || framingLegendary!=legendary || framingGeneration!=PlayerHeadProfiles.Generation)
{
framingIdentity=id;framingLegendary=legendary;framingGeneration=PlayerHeadProfiles.Generation;
Model headModel=null;float scale=.23f;
if(PlayerAppearance.HasAuthoredHead(id))headModel=FootballFigureAssets.AuthoredHead(id,false,legendary);
else if(PlayerAppearance.HasModel(id)){headModel=FootballFigureAssets.Head(id);scale=.17f;}
else if(PlayerHeadProfiles.Find(id) is { } profile)headModel=FootballFigureAssets.ModularHead(profile,true);
headTopOffset=headModel!=null && !headModel.IsError?Math.Max(.18f,headModel.Bounds.Maxs.z*scale):.27f;
if(id==PlayerAppearance.MessiId && legendary)
{
var antennae=FootballFigureAssets.LegendaryCostume("messi_alien","antennae");
if(antennae!=null && !antennae.IsError)headTopOffset=Math.Max(headTopOffset,antennae.Bounds.Maxs.z*.17f);
}
}
// Frame from the actual authored head bound, including crown/antennae.
// Fixing the vertical view also prevents a wide panel from reducing
// vertical FOV and chopping the top of the hairstyle.
float headZ=pose.Count>13?pose[13].ZA:1.72f;
float top=(headZ-.025f+headTopOffset+.055f)*Units;
float bottom=(Closeup?.98f:-.08f)*Units;
float centre=(top+bottom)*.5f;
var cameraOffset=Closeup?new Vector3(35,-51,12):float.IsNaN(FixedFacing)?new Vector3(105,-155,57):new Vector3(72,-105,43);
var target=new Vector3(0,0,centre);
sceneCamera.WorldPosition=target+cameraOffset;
sceneCamera.WorldRotation=Rotation.LookAt(target-sceneCamera.WorldPosition);
float height=(top-bottom)*1.13f;
float vertical=2*MathF.Atan(height/(2*cameraOffset.Length))*180/MathF.PI;
float aspect=Math.Max(.25f,Box.Rect.Width/Math.Max(1,Box.Rect.Height));
sceneCamera.FieldOfView=Screen.CreateVerticalFieldOfView(Math.Clamp(vertical,15,65),aspect);
}
}