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