ClubCosmeticPreview.cs

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