Staff wardrobe page for the admin menu. Lists all Clothing resources with filters and search, previews outfits on a 3D character via ScenePanel and Preview3D, and lets staff hide clothing or edit per-grade police uniforms through AdminActions.
@using Sandbox;
@using Sandbox.UI;
@namespace HorizonRP
@inherits Panel
@*
La garde-robe du staff (menu admin, onglets Garde-robe et Tenues de service) : tous les
vêtements du jeu, rangés par catégorie, avec à part ceux que nous avons importés
(models/horizon/…, la tenue UCPD par exemple). À droite, son propre personnage en 3D, comme à
la création : chaque vêtement s'y essaie, rien n'est porté pour de vrai.
Garde-robe : masquer un vêtement le retire de la création de personnage (réglage
« garde_robe_masques »). Tenues de service : les pièces de la tenue de chaque grade de la police
(réglage « tenues_service », PoliceUniform.PiecesOf), essayées avant d'être enregistrées.
Les deux passent par l'hôte (AdminActions), qui revérifie que l'on est staff.
*@
<root>
<div class="wd-list">
@if ( Mode == "tenues" )
{
<div class="section-label">Grade</div>
<div class="chips">
@foreach ( var g in PoliceRules.Grades )
{
var rank = g.Rank;
<div class="chip @(Grade == rank ? "active" : "") @(ServerSettings.UniformOf( PoliceUniform.Service, rank ) is null ? "" : "set")" onclick=@(() => SelectGrade( rank ))>@g.Name</div>
}
</div>
<div class="fcard">
<div class="fcard-title">
<i>local_police</i> @($"Tenue : {(PoliceRules.GradeOf( Grade ).Name)}")
<span class="wd-state">@(Changed ? "modifiée, pas encore enregistrée" : ServerSettings.UniformOf( PoliceUniform.Service, Grade ) is null ? "tenue d'origine" : "réglée")</span>
</div>
<div class="rows">
@if ( !Draft.Any() )
{
<div class="hint">Aucune pièce : l'agent garderait ses vêtements civils. Ajoutez-en depuis la liste ci-dessous.</div>
}
@foreach ( var path in Draft.ToList() )
{
var p = path;
var c = ResourceLibrary.Get<Clothing>( p );
<div class="row">
<div class="wd-pic" style="@PicStyle( c )">@if ( PicStyle( c ) == "" ) { <i>checkroom</i> }</div>
<div class="row-main">
<div class="row-title">@(c is null ? "Introuvable" : Label( c )) <span>@($"· {(GroupOf( c ))}")</span></div>
<div class="row-sub">@p</div>
</div>
<div class="btn danger" onclick=@(() => RemovePiece( p ))><i>remove_circle</i> Retirer</div>
</div>
}
</div>
<div class="wd-actions">
<div class="place @(Changed ? "" : "disabled")" onclick=@SaveUniform><i>save</i> Enregistrer pour ce grade</div>
<div class="btn @(Changed ? "" : "disabled")" onclick=@(() => SelectGrade( Grade ))><i>undo</i> Annuler</div>
<div class="btn" onclick=@ResetUniform><i>restart_alt</i> Tenue d'origine</div>
</div>
<div class="hint">L'agent la reçoit à sa prochaine prise de service, son visage, sa coiffure et ses lunettes gardés. Les chemises UCPD prennent les galons de leur grade (chemise_g0 à g8).</div>
</div>
<div class="section-label">Ajouter une pièce</div>
}
<div class="wd-search">
<TextEntry class="field" Placeholder="Rechercher : nom, chemin…" Value=@Query OnTextEdited=@((string v) => { Query = v ?? ""; })></TextEntry>
</div>
<div class="chips">
@foreach ( var (id, label) in Filters )
{
var f = id;
<div class="chip @(Filter == f ? "active" : "")" onclick=@(() => Filter = f)>@($"{label} · {(Count( f ))}")</div>
}
</div>
<div class="rows">
@{
var shown = Shown().ToList();
}
@if ( !shown.Any() )
{
<div class="empty">Aucun vêtement.</div>
}
@foreach ( var item in shown.Take( MaxRows ) )
{
var c = item;
var worn = Mode == "tenues" ? Draft.Contains( c.ResourcePath, StringComparer.OrdinalIgnoreCase ) : Container?.Has( c ) == true;
var hidden = ServerSettings.HiddenClothing.Contains( c.ResourcePath ?? "" );
<div class="row @(worn ? "worn" : "")">
<div class="wd-pic" style="@PicStyle( c )">@if ( PicStyle( c ) == "" ) { <i>checkroom</i> }</div>
<div class="row-main">
<div class="row-title">@Label( c ) <span>@($"· {(GroupOf( c ))}")</span></div>
<div class="row-sub">@c.ResourcePath</div>
<div class="wd-tags">
<div class="wd-tag @OriginOf( c )">@OriginLabel( c )</div>
@if ( UniformUse( c ) is { } use )
{
<div class="wd-tag service">@use</div>
}
@if ( hidden )
{
<div class="wd-tag hidden">masqué à la création</div>
}
@if ( c.SteamItemDefinitionId.HasValue )
{
<div class="wd-tag hidden">payant Steam : pas vu des autres</div>
}
</div>
</div>
@if ( Mode == "tenues" )
{
<div class="btn @(worn ? "danger" : "primary")" onclick=@(() => { if ( worn ) RemovePiece( c.ResourcePath ); else AddPiece( c ); })><i>@(worn ? "remove_circle" : "add_circle")</i> @(worn ? "Retirer" : "Ajouter")</div>
}
else
{
<div class="btn @(worn ? "active" : "")" onclick=@(() => TryOn( c ))><i>@(worn ? "remove_circle" : "checkroom")</i> @(worn ? "Retirer" : "Essayer")</div>
@if ( !PoliceUniform.IsUniform( c ) )
{
<div class="btn @(hidden ? "" : "danger")" onclick=@(() => AdminActions.RequestClothingHidden( c.ResourcePath, !hidden ))><i>@(hidden ? "visibility" : "visibility_off")</i> @(hidden ? "Proposer" : "Masquer")</div>
}
}
</div>
}
@if ( shown.Count > MaxRows )
{
<div class="hint">@($"{(shown.Count - MaxRows)} autre(s) : affinez la recherche.")</div>
}
</div>
</div>
<div class="wd-view" onmousedown=@StartDrag onmouseup=@(() => dragging = false)>
<ScenePanel @ref="View" class="wd-scene"></ScenePanel>
<div class="wd-view-top">
<div class="chip @(HeadOnly ? "" : "active")" onclick=@(() => SetFrame( false ))><i>accessibility_new</i> Corps</div>
<div class="chip @(HeadOnly ? "active" : "")" onclick=@(() => SetFrame( true ))><i>face</i> Tête</div>
</div>
<div class="wd-view-keys">
<span><b>Clic maintenu</b> tourner</span>
<span><b>Molette</b> zoom</span>
</div>
@if ( Mode != "tenues" )
{
<div class="wd-view-bottom">
<div class="btn" onclick=@ResetLook><i>restart_alt</i> Ma tenue</div>
<div class="btn" onclick=@Undress><i>layers_clear</i> Tout retirer</div>
</div>
}
</div>
</root>
@code
{
/// <summary>« garderobe » ou « tenues » : l'onglet du menu admin.</summary>
public string Mode { get; set; } = "garderobe";
RoleplayPlayer Me => RoleplayPlayer.Local;
string Query { get; set; } = "";
string Filter { get; set; } = "tout";
/// <summary>Au-delà, la liste ralentirait le menu : on affine par la recherche.</summary>
const int MaxRows = 80;
// ------------------------------------------------------------------ la liste
static List<Clothing> all;
static int allVersion = -1;
/// <summary>Tous les vêtements chargés (s&box, Workshop monté, importés), relus quand les paquets arrivent.</summary>
static List<Clothing> All
{
get
{
if ( all is null || allVersion != WorkshopPackages.ClothingVersion )
{
allVersion = WorkshopPackages.ClothingVersion;
all = ResourceLibrary.GetAll<Clothing>().Where( x => !string.IsNullOrEmpty( x.ResourcePath ) ).OrderBy( x => Label( x ) ).ToList();
}
return all;
}
}
static readonly (string Id, string Label)[] Groups =
{
("hauts", "Hauts"), ("bas", "Bas"), ("chaussures", "Chaussures"), ("couvre-chefs", "Couvre-chefs"),
("accessoires", "Accessoires"), ("coiffures", "Coiffures"), ("visage", "Visage"), ("corps", "Corps"), ("autres", "Autres"),
};
IEnumerable<(string Id, string Label)> Filters =>
new[] { ("tout", "Tout"), ("importes", "Importés"), ("service", "Tenues de service"), ("masques", "Masqués") }
.Concat( Groups.Where( g => All.Any( c => GroupId( c ) == g.Id ) ) );
/// <summary>La famille d'un vêtement, d'après sa catégorie s&box.</summary>
static string GroupId( Clothing c )
{
if ( c is null )
return "autres";
if ( c.HasHumanSkin )
return "corps";
var k = c.Category.ToString().ToLowerInvariant();
if ( k.StartsWith( "hair" ) ) return "coiffures";
if ( k.Contains( "facial" ) || k.Contains( "beard" ) || k.Contains( "eyebrow" ) || k.Contains( "eyelash" ) || k.Contains( "makeup" ) || k.Contains( "eyes" ) || k.Contains( "complexion" ) ) return "visage";
if ( k.Contains( "hat" ) || k.Contains( "helmet" ) || k.Contains( "cap" ) || k.Contains( "headwear" ) ) return "couvre-chefs";
if ( k.Contains( "shoe" ) || k.Contains( "boot" ) || k.Contains( "sock" ) || k.Contains( "foot" ) ) return "chaussures";
if ( k.Contains( "trouser" ) || k.Contains( "short" ) || k.Contains( "skirt" ) || k.Contains( "pant" ) || k.Contains( "leg" ) ) return "bas";
if ( k.Contains( "shirt" ) || k.Contains( "jacket" ) || k.Contains( "coat" ) || k.Contains( "vest" ) || k.Contains( "jumper" ) || k.Contains( "hoodie" ) || k.Contains( "top" ) || k.Contains( "dress" ) || k.Contains( "suit" ) || k.Contains( "outfit" ) ) return "hauts";
if ( k.Contains( "glove" ) || k.Contains( "glass" ) || k.Contains( "eyewear" ) || k.Contains( "neck" ) || k.Contains( "watch" ) || k.Contains( "mask" ) || k.Contains( "back" ) || k.Contains( "bag" ) || k.Contains( "earring" ) ) return "accessoires";
if ( k.Contains( "skin" ) || k.Contains( "body" ) || k.Contains( "underwear" ) ) return "corps";
return "autres";
}
static string GroupOf( Clothing c ) => c is null ? "–" : $"{(Groups.FirstOrDefault( g => g.Id == GroupId( c ) ).Label)} ({c.Category})";
/// <summary>
/// D'où vient le vêtement : nos imports (models/horizon/…, et les variantes teintables de
/// clothing/horizon/…), la base de s&box, ou le Workshop.
/// </summary>
static string OriginOf( Clothing c )
{
var p = c?.ResourcePath ?? "";
return p.Contains( "horizon/", StringComparison.OrdinalIgnoreCase ) ? "importe"
: p.StartsWith( "models/citizen", StringComparison.OrdinalIgnoreCase ) ? "sbox" : "workshop";
}
static string OriginLabel( Clothing c ) => OriginOf( c ) switch { "importe" => "importé", "sbox" => "s&box", _ => "Workshop" };
/// <summary>« Tenue de service : 3 grades » si une tenue (réglée ou d'origine) le porte, sinon null.</summary>
static string UniformUse( Clothing c )
{
var p = c?.ResourcePath;
if ( p is null )
return null;
var grades = PoliceRules.Grades.Count( g => PoliceUniform.PiecesOf( g.Rank ).Contains( p, StringComparer.OrdinalIgnoreCase ) );
return grades == 0 ? null : grades == PoliceRules.Grades.Length ? "tenue police : tous les grades" : $"tenue police : {grades} grade(s)";
}
static string Label( Clothing c ) => c is null ? "" : string.IsNullOrWhiteSpace( c.Title ) ? System.IO.Path.GetFileNameWithoutExtension( c.ResourcePath ) : c.Title;
bool Matches( Clothing c, string filter ) => filter switch
{
"tout" => true,
"importes" => OriginOf( c ) == "importe",
"service" => UniformUse( c ) is not null || PoliceUniform.IsUniform( c ),
"masques" => ServerSettings.HiddenClothing.Contains( c.ResourcePath ?? "" ),
_ => GroupId( c ) == filter,
};
int Count( string filter ) => All.Count( c => Matches( c, filter ) );
IEnumerable<Clothing> Shown()
{
var q = (Query ?? "").Trim();
return All.Where( c => Matches( c, Filter ) && (q.Length == 0
|| Label( c ).Contains( q, StringComparison.OrdinalIgnoreCase ) || c.ResourcePath.Contains( q, StringComparison.OrdinalIgnoreCase )) );
}
/// <summary>La vignette du vêtement (la sienne, ou celle rendue par le jeu), ou rien : une icône la remplace.</summary>
static string PicStyle( Clothing c )
{
var icon = CharacterRules.IconOf( c );
return string.IsNullOrEmpty( icon ) ? "" : $"background-image: url( {icon} )";
}
// ------------------------------------------------------------------ l'essayage (Garde-robe)
/// <summary>Ce que porte le personnage de l'aperçu (une copie : rien n'est porté pour de vrai).</summary>
ClothingContainer Container { get; set; }
void ResetLook()
{
Container = !string.IsNullOrWhiteSpace( Me?.AppearanceJson ) ? ClothingContainer.CreateFromJson( Me.AppearanceJson ) : new ClothingContainer();
}
/// <summary>Ne garde que le corps et le visage (tous les vêtements ôtés).</summary>
void Undress()
{
Container ??= new ClothingContainer();
Container.Clothing.RemoveAll( x => x.Clothing is null || !PoliceUniform.IsKept( x.Clothing ) );
}
void TryOn( Clothing item )
{
Container ??= new ClothingContainer();
if ( Container.Has( item ) )
Container.Clothing.RemoveAll( x => x.Clothing == item );
else
{
// Ce qui ne se porte pas avec (deux pantalons, deux coiffures…) s'en va, comme à la création.
Container.Clothing.RemoveAll( x => x.Clothing is not null && !x.Clothing.CanBeWornWith( item ) );
Container.Add( item );
}
}
// ------------------------------------------------------------------ les tenues de service
int Grade { get; set; }
/// <summary>Les pièces affichées pour le grade choisi (copie modifiable, pas encore enregistrée).</summary>
List<string> Draft { get; set; } = new();
bool Changed { get; set; }
void SelectGrade( int rank )
{
Grade = rank;
Draft = PoliceUniform.PiecesOf( rank ).ToList();
Changed = false;
}
void AddPiece( Clothing item )
{
// Une pièce qui ne se porte pas avec une autre (deux chemises) la remplace.
Draft.RemoveAll( p => ResourceLibrary.Get<Clothing>( p ) is { } other && !other.CanBeWornWith( item ) );
Draft.Add( item.ResourcePath );
Changed = true;
}
void RemovePiece( string path )
{
Draft.RemoveAll( p => string.Equals( p, path, StringComparison.OrdinalIgnoreCase ) );
Changed = true;
}
void SaveUniform()
{
if ( !Changed )
return;
AdminActions.RequestUniformSave( PoliceUniform.Service, Grade, Json.Serialize( Draft ) );
Changed = false;
}
void ResetUniform()
{
AdminActions.RequestUniformSave( PoliceUniform.Service, Grade, "[]" );
Draft = PoliceUniform.DefaultPiecesOf( Grade ).ToList();
Changed = false;
}
/// <summary>Ce que montre l'aperçu : son essayage (Garde-robe) ou la tenue du grade sur son corps (Tenues).</summary>
string ShownJson => Mode == "tenues"
? PoliceUniform.Dress( Me?.AppearanceJson, Draft ) ?? Me?.AppearanceJson
: Container?.Serialize() ?? Me?.AppearanceJson;
// ------------------------------------------------------------------ l'aperçu 3D
ScenePanel View { get; set; }
readonly Preview3D preview = new() { TurnSpeed = 0, BodyYaw = 0f, BodyDistance = 118f, BodyHeight = 36f, HeadDistance = 42f, HeadHeight = 64f };
bool dragging;
Vector2 lastMouse;
float viewYaw;
bool HeadOnly { get; set; }
string lastMode;
void StartDrag()
{
dragging = true;
lastMouse = Mouse.Position;
}
void SetFrame( bool head )
{
HeadOnly = head;
preview.Zoom = 1f;
}
public override void Tick()
{
base.Tick();
if ( Me is null )
return;
// À l'ouverture d'un onglet : on repart de sa propre tenue, et du grade de l'agent.
if ( lastMode != Mode )
{
lastMode = Mode;
ResetLook();
SelectGrade( Math.Clamp( Me.PoliceGrade, 0, PoliceRules.Grades.Length - 1 ) );
}
if ( View is null )
return;
preview.Attach( View );
preview.ShowCharacter( ShownJson, Me.Sex ?? "M", HeadOnly );
if ( dragging )
{
var delta = Mouse.Position - lastMouse;
lastMouse = Mouse.Position;
viewYaw += delta.x * 0.45f;
}
if ( Input.MouseWheel.y != 0 && OverView( Mouse.Position ) )
preview.Zoom = Math.Clamp( preview.Zoom - Input.MouseWheel.y * 0.08f, 0.35f, 1.6f );
// L'aperçu remet sa rotation de départ à chaque nouvelle tenue : on garde la nôtre.
preview.Yaw = viewYaw;
preview.Tick();
}
bool OverView( Vector2 mouse ) => View is not null && View.Box.Rect.IsInside( mouse );
protected override int BuildHash() => HashCode.Combine(
HashCode.Combine( Mode, Query, Filter, Grade, Changed, HeadOnly ),
HashCode.Combine( Container?.Serialize(), string.Join( "|", Draft ), ServerSettings.Version, WorkshopPackages.ClothingVersion ) );
}