FootballFigureAssets.cs

Static helper that loads and caches the football figure ModelDoc assets (body parts, heads, hair, beards, costume parts) and builds per-player Material palettes via 8x8 texel Bitmaps. Keyed caches are invalidated when PlayerHeadProfiles.Generation or HeadRevision changes.

using Sandbox;
using System;
using System.Collections.Generic;

namespace Socer;

// Native ModelDoc assets exported from editable Blender meshes. Shared by every football view.
public static class FootballFigureAssets
{
    public const int Revision=1;
    public const int HeadRevision=5;
    [SkipHotload] static readonly Dictionary<string,Model> Models=new();
    [SkipHotload] static readonly Dictionary<(Color Skin,Color Fabric,Color Trim),Material> Palettes=new();
    [SkipHotload] static readonly Dictionary<string,Color> LinearColors=new();
    [SkipHotload] static readonly Dictionary<string,Material> HeadPalettes=new();
    [SkipHotload] static int headGeneration;
    static Model Load(string name)
    {
        if(!Models.TryGetValue(name,out var model) || model.IsError)
        {
            model=Model.Load($"models/football/{name}.vmdl");
            if(!model.IsError)Models[name]=model;
        }
        return model;
    }
    public static Model Part(int slot)=>slot switch
    {
        0 or 3=>Load("thigh"),1 or 4=>Load("calf"),2 or 5=>Load("boot"),6=>Load("torso"),
        7 or 10=>Load("upper_arm"),8 or 11=>Load("forearm"),9 or 12=>Load("hand"),_=>null
    };
    public static Model Head(string id,bool low=false)=>Load((id==PlayerAppearance.MessiId?"messi":"haaland")+"_head"+(low?"_low":""));
    public static Model MbappeOfficerTorso=>Load("mbappe_officer_torso");
    public static Model LegendaryCostume(string costume,string part="torso")=>Load($"legend_{costume}_{part}_v1");
    public static Model AuthoredHead(string id,bool low,bool legendary=false)=>Load("legend_"+(id switch
    {
        PlayerAppearance.ZlatanId=>legendary?"zlatan_crowned":"zlatan",
        PlayerAppearance.PirloId=>"pirlo",PlayerAppearance.MaldiniId=>"maldini",
        PlayerAppearance.BuffonId=>"buffon",_=>"messi_alien"
    })+"_head"+(low?"_low":"")+"_v1");
    static void RefreshHeads()
    {
        if(headGeneration==PlayerHeadProfiles.Generation+HeadRevision*1000)return;
        headGeneration=PlayerHeadProfiles.Generation+HeadRevision*1000;
        foreach(var key in new List<string>(Models.Keys))if(key.StartsWith("modular_head_"))Models.Remove(key);
        HeadPalettes.Clear();
    }
    public static Model ModularHead(PlayerHeadProfile p,bool low)
    {
        RefreshHeads();
        var model=Load(low?$"modular_head_far_{p.HeadShape}_{p.HairStyle}_{p.BeardStyle}_v{HeadRevision}":$"modular_head_shape_{p.HeadShape}_v{HeadRevision}");
        if(!model.IsError)return model;
        // A stale editor profile or interrupted import must never display the
        // engine's enormous ERROR mesh. All generic clean combinations exist.
        model=Load($"modular_head_far_{Math.Clamp(p.HeadShape,0,5)}_{p.HairStyle}_0_v{HeadRevision}");
        return !model.IsError?model:Head(PlayerAppearance.HaalandId,true);
    }
    public static Model ModularHair(PlayerHeadProfile p)=>p.HairStyle==0?null:Load($"modular_head_hair_{p.HairStyle}_v{HeadRevision}");
    public static Model ModularBeard(PlayerHeadProfile p)=>p.BeardStyle==0?null:Load($"modular_head_beard_{p.HeadShape}_{p.BeardStyle}_v{HeadRevision}");
    public static Material HeadPalette(string id,PlayerHeadProfile p,bool clay=false,bool crowned=false)
    {
        RefreshHeads();
        string key=clay?"clay":p.Skin+":"+p.Hair+":"+p.Eyes+":"+(id=="PRO-52"?"blond":id==PlayerAppearance.BuffonId?"silver":crowned?"crown":"normal");
        if(HeadPalettes.TryGetValue(key,out var material))return material;
        var skin=Color.Parse(p.Skin)??Color.White;
        var hair=Color.Parse(p.Hair)??Color.Black;
        var iris=Color.Parse(p.Eyes)??Color.Black;
        var lip=new Color(skin.r*.72f,skin.g*.63f,skin.b*.64f);
        var shade=skin*.68f;
        var accent=id=="PRO-52"?Color.Parse("#c3a268").Value:new Color(Math.Min(1,hair.r*1.19f+.018f),Math.Min(1,hair.g*1.19f+.018f),Math.Min(1,hair.b*1.19f+.018f));
        if(id==PlayerAppearance.BuffonId)accent=Color.Parse("#a4a39b").Value;
        if(crowned)accent=Color.Parse("#e8c975").Value;
        var colors=new[]{skin,hair,iris,new Color(.83f,.84f,.79f),lip,shade,accent,new Color(.045f,.038f,.032f)};
        using var pixels=new Bitmap(64,8);
        for(int i=0;i<8;i++)for(int x=0;x<8;x++)for(int y=0;y<8;y++)pixels.SetPixel(i*8+x,y,clay?Color.White:colors[i]);
        material=Material.Load($"models/football/modular_head_shape_0_v{HeadRevision}.vmat").CreateCopy("football-head-palette-"+headGeneration+"-"+HeadPalettes.Count);
        material.Set("g_tColor",pixels.ToTexture());material.Set("g_flModelTintAmount",clay?1f:0f);
        return HeadPalettes[key]=material;
    }
    static float Srgb(float x)=>x<=.0031308f?12.92f*x:1.055f*MathF.Pow(x,1/2.4f)-.055f;
    public static Material Palette(Color skin,Color fabric,Color trim)
    {
        var key=(skin,fabric,trim);
        if(Palettes.TryGetValue(key,out var shader))return shader;
        using var pixels=new Bitmap(24,8);
        var colors=new[]{skin,fabric,trim};
        for(int i=0;i<3;i++)
        {
            var c=colors[i];var srgb=new Color(Srgb(c.r),Srgb(c.g),Srgb(c.b));
            for(int x=0;x<8;x++)for(int y=0;y<8;y++)pixels.SetPixel(i*8+x,y,srgb);
        }
        shader=Material.Load("models/football/torso.vmat").CreateCopy("football-uniform-"+Palettes.Count);
        shader.Set("g_tColor",pixels.ToTexture());shader.Set("g_flModelTintAmount",0f);
        return Palettes[key]=shader;
    }
    public static Color Linear(string hex)
    {
        if(LinearColors.TryGetValue(hex,out var result))return result;
        var c=Color.Parse(hex)??Color.White;
        float F(float x)=>x<=.04045f?x/12.92f:MathF.Pow((x+.055f)/1.055f,2.4f);
        return LinearColors[hex]=new Color(F(c.r),F(c.g),F(c.b));
    }
    public static Material Clay=>Palette(Color.White,Color.White,Color.White);
}