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