Police/PoliceUniform.cs

Static helper for the Horizon RP police uniform. Dress builds a ClothingContainer JSON from a civilian appearance, keeps only skin, face and hair categories, then adds the uniform clothing pieces for a rank. Apply walks the dressed body's child renderers named after the shirt and swaps in the per-grade material, leaving body-skin materials untouched.

namespace HorizonRP;

/// <summary>
/// La tenue de service de l'UCPD, façon LAPD : chemise marine à cravate (écusson d'Union aux
/// manches ; plaque argent et chevrons jusqu'au sergent, plaque or et insigne de col pour les
/// officiers), pantalon et ceinturon, casquette à insigne UCPD, rangers de s&box. Les pièces
/// (models/horizon/police/tenue/) viennent du modèle « Police 3D Model » d'Aayush.Bhattarai
/// (Sketchfab, CC Attribution 4.0 : à créditer), ajustées au corps s&box par
/// outils/police/tenue_sketchfab.py, textures par outils/police/marques_ucpd.py.
///
/// Les neuf chemises (une par grade) partagent un modèle : on reconnaît le grade au nom de l'objet
/// porté (« Clothing - ucpd_chemise_g4 ») et on lui pose le matériau du grade, à chaque habillage
/// (<see cref="TintedSkin.DressAsync"/>, que passent tous les corps du jeu).
/// </summary>
public static class PoliceUniform
{
	/// <summary>Vrai depuis le 30/10/2026 (tenue Sketchfab) : la prise de service habille l'agent.</summary>
	public const bool Enabled = true;

	const string Folder = "models/horizon/police/tenue/";
	const string Prefix = "Clothing - ucpd_chemise_g";

	/// <summary>Ce que l'uniforme laisse de la tenue civile : le corps, le visage, la coiffure, les lunettes.</summary>
	static readonly HashSet<string> Kept = new( StringComparer.OrdinalIgnoreCase )
	{
		"Facial", "FacialHair", "Beard", "Eyebrows", "Eyelashes", "Eyes", "Makeup", "MakeupLips", "MakeupEyes",
		"Eyewear", "GlassesEye", "GlassesSun",
	};

	/// <summary>Gardé sous l'uniforme. Toutes les coiffures (« HairShort », « HairLong »…), pas seulement « Hair ».</summary>
	public static bool IsKept( Clothing clothing )
	{
		var category = clothing.Category.ToString();
		return clothing.HasHumanSkin || category.StartsWith( "Hair", StringComparison.OrdinalIgnoreCase ) || Kept.Contains( category );
	}

	/// <summary>La tenue d'origine d'un grade (chemise du grade, pantalon, casquette, rangers).</summary>
	public static string[] DefaultPiecesOf( int rank ) => new[]
	{
		$"{Folder}ucpd_chemise_g{Math.Clamp( rank, 0, PoliceRules.Grades.Length - 1 )}.clothing",
		$"{Folder}ucpd_pantalon.clothing",
		$"{Folder}ucpd_casquette.clothing",
		"models/citizen_clothes/shoes/boots/black_boots.clothing",
	};

	/// <summary>
	/// Les pièces de l'uniforme pour un grade : celles réglées dans le menu admin (onglet Tenues de
	/// service, réglage « tenues_service »), sinon la tenue d'origine.
	/// </summary>
	public static IReadOnlyList<string> PiecesOf( int rank ) =>
		(IReadOnlyList<string>)ServerSettings.UniformOf( Service, Math.Clamp( rank, 0, PoliceRules.Grades.Length - 1 ) ) ?? DefaultPiecesOf( rank );

	/// <summary>Le service dont la tenue est réglable (TENUES_SERVICES d'api.php).</summary>
	public const string Service = "police";

	/// <summary>
	/// La tenue de service d'un agent (JSON ClothingContainer) : son corps et sa tête tels qu'il les
	/// a faits, l'uniforme de son grade par-dessus. Null si une pièce manque (paquet non monté…).
	/// </summary>
	public static string For( string civilianJson, int rank ) => Dress( civilianJson, PiecesOf( rank ) );

	/// <summary>
	/// Des pièces passées par-dessus le corps et la tête d'une apparence (ce que garde l'uniforme :
	/// <see cref="IsKept"/>). Sert à la prise de service et à l'aperçu de l'onglet Tenues de service.
	/// </summary>
	public static string Dress( string civilianJson, IEnumerable<string> pieces )
	{
		if ( string.IsNullOrEmpty( civilianJson ) )
			return null;
		ClothingContainer container;
		try
		{
			container = ClothingContainer.CreateFromJson( civilianJson );
		}
		catch
		{
			return null;
		}
		container.Clothing.RemoveAll( x => x.Clothing is null || !IsKept( x.Clothing ) );
		foreach ( var path in pieces )
		{
			var piece = ResourceLibrary.Get<Clothing>( path );
			if ( piece is null )
			{
				Log.Warning( $"[Horizon] Uniforme : pièce introuvable {path}" );
				return null;
			}
			container.Add( piece );
		}
		container.Normalize();
		return container.Serialize();
	}

	/// <summary>Une pièce de l'uniforme (copie de vêtement) : pas proposée dans le créateur de personnage.</summary>
	public static bool IsUniform( Clothing clothing ) => clothing?.ResourcePath?.StartsWith( Folder, StringComparison.OrdinalIgnoreCase ) == true;

	/// <summary>Cette tenue (JSON) est-elle l'uniforme ? Lisible partout : CivilianAppearance ne vit que chez l'hôte.</summary>
	public static bool IsWorn( string appearanceJson ) =>
		appearanceJson is not null && (appearanceJson.Contains( "police/tenue/ucpd_", StringComparison.OrdinalIgnoreCase )
			|| ServerSettings.Uniforms.TryGetValue( Service, out var grades )
				&& grades.Values.Any( pieces => pieces.Count > 0 && pieces.All( p => appearanceJson.Contains( p, StringComparison.OrdinalIgnoreCase ) ) ));

	static readonly Dictionary<string, Material> materials = new();

	/// <summary>Pose sur la chemise portée par ce corps le matériau de son grade (pantalon et casquette ont le leur).</summary>
	public static void Apply( Dresser dresser )
	{
		var body = dresser.IsValid() ? dresser.BodyTarget : null;
		if ( !body.IsValid() )
			return;

		foreach ( var child in body.GameObject.Children )
		{
			if ( !child.Name.StartsWith( Prefix, StringComparison.Ordinal ) )
				continue;
			var cloth = child.GetComponent<SkinnedModelRenderer>();
			if ( !cloth.IsValid() )
				continue;

			// « ucpd_chemise_g4 » → chemise_g4.vmat.
			var name = "chemise_g" + child.Name[Prefix.Length..];
			if ( !materials.TryGetValue( name, out var material ) )
			{
				material = Material.Load( $"{Folder}{name}.vmat" );
				materials[name] = material;
			}
			if ( material is null )
				continue;

			// Seul le tissu change : la peau que certains vêtements portent (cou, bras) reste la sienne.
			for ( var i = 0; i < cloth.Materials.Count; i++ )
			{
				if ( cloth.Materials.GetOriginal( i )?.ResourcePath?.Contains( "/bodies/" ) == true )
					continue;
				cloth.Materials.SetOverride( i, material );
			}
			cloth.Materials.Apply();
		}
	}
}