CharacterEquipment.cs
using Sandbox;

namespace PaintballBaddies;

/// <summary>Reusable skinned protective gear for Citizen-compatible player or AI bodies.</summary>
public sealed class CharacterEquipment : Component
{
    private readonly List<GameObject> pieces = new();
    public SkinnedModelRenderer Outfit { get; private set; }
    public SkinnedModelRenderer Gloves { get; private set; }
    public SkinnedModelRenderer Boots { get; private set; }
    public SkinnedModelRenderer Helmet { get; private set; }
    private SkinnedModelRenderer face,hair;
    private static readonly Material[] faceMaterials=new Material[2];
    private int faceIndex=-1;
    private BaddieWardrobe wardrobe;
    public bool CompleteCharacterReady => wardrobe.IsValid() && wardrobe.Ready;
    public SkinnedModelRenderer VisibleOutfit => CompleteCharacterReady ? wardrobe.Visual : Outfit;
    private static readonly string[] HairPaths={
        "models/citizen_clothes/hair/hair_longcurly/models/hair_longcurly_m_human.vmdl",
        "models/citizen_clothes/hair/hair_longcurly/models/hair_longcurly_m_human.vmdl",
        "models/citizen_clothes/hair/hair_tied_braids/models/hair_tied_braids_m_human.vmdl",
        "models/citizen_clothes/hair/hair_longbob/models/hair_longbob_m_human.vmdl",
        "models/citizen_clothes/hair/hair_messy_bun/models/messy_bun_f_human.vmdl",
        "models/citizen_clothes/hair/hair_longcurly/models/hair_longcurly_m_human.vmdl"};
    private static readonly Color[] HairTints={new(.18f,.13f,.11f),new(.8f,.32f,.13f),new(.09f,.065f,.055f),new(.10f,.09f,.10f),new(1,.86f,.63f),new(.18f,.11f,.075f)};
    protected override void OnUpdate()
    {
        var body=Components.Get<SkinnedModelRenderer>();
        if(body.IsValid() && Components.Get<CharacterHitboxes>() is null)
            Components.Create<CharacterHitboxes>().Configure(body,body.GameObject.Parent);
        if(!body.IsValid())return;
        // Baddies vs Sausages always shows the full baddie look, including in
        // the menu before a round starts (it used to show the paintball suit).
        bool unhelmeted=true;
        int index=GameObject.Parent.Components.Get<RosterSelection>()?.Selected ?? GameObject.Parent.Components.Get<ArenaOpponent>()?.CharacterIndex ?? 0;
        index=System.Math.Clamp(index,0,5);
        wardrobe ??= Components.GetOrCreate<BaddieWardrobe>();
        wardrobe.Present(body,index,unhelmeted);
        bool complete=unhelmeted && wardrobe.Ready;
        if(Outfit.IsValid())Outfit.Enabled=!unhelmeted || !wardrobe.Ready;
        // Native fitted gloves retain the established articulated gun grip.
        // Hi3D source hands are trimmed beneath the cuff during preparation.
        if(Gloves.IsValid())
        {
            var path=GloveModelFor(body.Model?.ResourcePath);
            if(Gloves.Model?.ResourcePath!=path)Gloves.Model=Model.Load(path);
            Gloves.Enabled=true;
        }
        if(Boots.IsValid())Boots.Enabled=!complete;
        if(unhelmeted && !complete && (!face.IsValid() || faceIndex!=index))
        {
            if(face.IsValid())face.GameObject.Destroy();
            if(hair.IsValid())hair.GameObject.Destroy();
            faceIndex=index;
            face=Attach(body,$"models/characters/baddie_faces/{RosterSelection.Names[index].ToLowerInvariant()}.vmdl","Native female face");
            foreach(var group in new[]{"Chest","Legs","Hands","Feet"})face.SetBodyGroup(group,1);
            // The native female model has two actual head meshes; higher shared slots are empty.
            face.SetBodyGroup("Head",index is 2 or 5 ? 0 : 1);
            bool deep=index is 2 or 5;
            face.MaterialGroup=deep ? "skin_dark" : "skin_light";
            int materialIndex=deep ? 0 : 1;
            if(faceMaterials[materialIndex] is null)
            {
                int headNumber=deep ? 1 : 2;
                var skin=Material.Load($"models/citizen_human/heads/citizen_human_femalehead{headNumber:00}/citizen_human_femalehead{headNumber:00}.vmat").CreateCopy();
                skin.Set("g_tColor",Texture.Load(deep ? "textures/characters/faces/deep-glamour.png" : "textures/characters/faces/light-glamour.png"));
                faceMaterials[materialIndex]=skin;
            }
            face.SetMaterialOverride(faceMaterials[materialIndex],"face");
            // The older gameplay driver's facial bind pose differs from the
            // native human head. Merging its eye/lid bones distorts eyelashes.
            // Follow the head joint while retaining the face's own facial rig.
            face.BoneMergeTarget=null;
            hair=Attach(body,HairPaths[index],"Native female hairstyle");
            hair.BoneMergeTarget=face;
            hair.Tint=HairTints[index];
        }
        if(face.IsValid())face.Enabled=unhelmeted && !complete;
        if(hair.IsValid())hair.Enabled=unhelmeted && !complete;
        if(Helmet.IsValid())Helmet.Enabled=!unhelmeted;
        body.RenderOptions.Game=!unhelmeted || complete;
    }

    protected override void OnPreRender()
    {
        if(!face.IsValid() || !face.Enabled)return;
        var body=Components.Get<SkinnedModelRenderer>();
        if(!body.IsValid() || !body.TryGetBoneTransform("head",out var head))return;
        var bind=face.Model.GetBoneTransform("head");
        var rotation=head.Rotation*bind.Rotation.Inverse;
        face.WorldTransform=new Transform(head.Position-rotation*(bind.Position*body.WorldScale),rotation,body.WorldScale);
        // Keep the neck blended into the animated collar while the face retains
        // its own eye/lid bind pose. Otherwise a rigid head opens the neckline.
        foreach(var bone in face.Model.Bones.AllBones)
            if((bone.Name=="neck_0" || bone.Name.StartsWith("spine_") || bone.Name.StartsWith("clavicle_")) && body.TryGetBoneTransform(bone.Name,out var neck))
                face.SetBoneTransform(bone,face.WorldTransform.ToLocal(neck));
    }

    public void Configure(SkinnedModelRenderer body, string outfit, string gloves, string boots, string helmet = "")
    {
        Clear();
        if (!body.IsValid()) return;
        {
            int selected=GameObject.Parent.Components.Get<RosterSelection>()?.Selected
                ?? GameObject.Parent.Components.Get<ArenaOpponent>()?.CharacterIndex ?? 0;
            wardrobe=Components.GetOrCreate<BaddieWardrobe>();
            wardrobe.Present(body,selected.Clamp(0,5),true);
        }
        // The complete fitted model already contains its clothes, hair and
        // footwear. Do not allocate legacy morph-bearing gear just to disable
        // it on the next frame (especially while a joining client is loading).
        if(!CompleteCharacterReady)
        {
            Outfit = Attach(body, outfit, "Protective suit");
            Boots = Attach(body, boots, "Protective boots");
            Helmet = Attach(body, helmet, "Protective helmet");
        }
        Gloves = Attach(body, GloveModelFor(body.Model?.ResourcePath), "Protective gloves");
        if(Components.Get<CharacterHitboxes>() is null)
            Components.Create<CharacterHitboxes>().Configure(body,body.GameObject.Parent);
    }

    public static string GloveModelFor(string bodyPath)
        => bodyPath?.Contains("baddies_hi3d/")==true && bodyPath.EndsWith("_handfit.vmdl")
        ? bodyPath[..bodyPath.LastIndexOf('/')]+"/fitted_gloves.vmdl"
        : "models/gear/citizen_female_gloves/gloves.vmdl";

    private SkinnedModelRenderer Attach(SkinnedModelRenderer body, string path, string name)
    {
        if (string.IsNullOrWhiteSpace(path)) return null;
        // Configure the final model and bone merge before allocating a render
        // object; don't briefly render the default box or an unmerged suit.
        var piece = new GameObject(GameObject) { Name = name, Enabled = false };
        pieces.Add(piece);
        var renderer = piece.Components.Create<SkinnedModelRenderer>();
        renderer.Model = Model.Load(path);
        renderer.BoneMergeTarget = body;
        renderer.UseAnimGraph = false;
        piece.Enabled = true;
        return renderer;
    }

    public void Clear()
    {
        foreach (var piece in pieces)
            if (piece.IsValid()) piece.Destroy();
        pieces.Clear();
        Outfit = null;
        Gloves = null;
        Boots = null;
        Helmet = null;
        face = hair = null;faceIndex=-1;
    }

    protected override void OnDestroy() => Clear();
}