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