Character creation panel for Horizon RP. Provides sections for identity, body, face, eyes, hair, makeup and clothing, previews appearance locally with a rotating camera and studio lights, and sends the finished identity and appearance to the host.
@using Sandbox;
@using Sandbox.UI;
@namespace HorizonRP
@inherits PanelComponent
@*
Création de personnage, façon FiveM : à la première venue (ou quand le staff
la rouvre), la caméra se place face au personnage et un menu à gauche règle
chaque chose, appliquée en direct : identité, morphologie, visage, yeux,
cheveux, pilosité, maquillage, haut, bas, chaussures, accessoires.
L'aperçu est local ; « Entrer à Union City » envoie le tout à l'hôte, qui
vérifie le nom, retire les vêtements payants et enregistre.
*@
<root>
@if ( IsOpen && Me is not null )
{
@* Relâcher n'importe où (même au-dessus d'un bouton) arrête le glisser. *@
<div class="creator" onmouseup=@(() => orbiting = false)>
@* La scène : appuyer-glisser fait tourner le personnage, la molette zoome. Avant le
panneau et les boutons dans l'arbre, donc dessous : eux restent cliquables. *@
<WheelPanel class="stage" Wheel=@((float y) => StageWheel( y )) onmousedown=@(() => StartOrbit())></WheelPanel>
<div class="panel">
<div class="panel-head">
<div class="logo"></div>
<div class="panel-title">CRÉATION DU PERSONNAGE <span>HORIZON RP</span></div>
</div>
<div class="sections">
@foreach ( var section in Sections )
{
<div class="section @(Section == section.Id ? "active" : "")" onclick=@(() => Select( section.Id ))>
<i>@section.Icon</i> @section.Name
</div>
}
</div>
<div class="options">
@if ( Section == "identite" )
{
<div class="field-label">Prénom</div>
<TextEntry class="field" MaxLength=@(20) Value=@FirstName OnTextEdited=@((string v) => { FirstName = NameInput( v ); })></TextEntry>
<div class="field-label">Nom</div>
<TextEntry class="field" MaxLength=@(20) Value=@LastName OnTextEdited=@((string v) => { LastName = NameInput( v ); })></TextEntry>
<div class="field-label">Date de naissance</div>
<div class="date">
<TextEntry class="field day" MaxLength=@(2) Placeholder="JJ" Value=@BirthDay OnTextEdited=@((string v) => { BirthDay = Digits( v, 2 ); })></TextEntry>
<div class="date-sep">/</div>
<TextEntry class="field month" MaxLength=@(2) Placeholder="MM" Value=@BirthMonth OnTextEdited=@((string v) => { BirthMonth = Digits( v, 2 ); })></TextEntry>
<div class="date-sep">/</div>
<TextEntry class="field year" MaxLength=@(4) Placeholder="AAAA" Value=@BirthYear OnTextEdited=@((string v) => { BirthYear = Digits( v, 4 ); })></TextEntry>
</div>
<div class="field-label">Sexe</div>
<div class="items">
<div class="item @(Sex == "M" ? "on" : "")" onclick=@(() => SetSex( "M" ))><i>male</i> Homme</div>
<div class="item @(Sex == "F" ? "on" : "")" onclick=@(() => SetSex( "F" ))><i>female</i> Femme</div>
</div>
<div class="id-preview">
<IdCard Player=@Me
NameOverride=@($"{(CharacterRules.CleanName( FirstName ) ?? "Prénom")} {(CharacterRules.CleanName( LastName ) ?? "Nom")}")
BirthOverride=@(CharacterRules.ParseBirthDate( BirthInput )?.ToString( "yyyy-MM-dd" ))
SexOverride=@Sex
AppearanceOverride=@PreviewJson></IdCard>
</div>
}
else if ( Section == "visage" )
{
<div class="field-label">@($"Visage ({(Sex == "F" ? "femme" : "homme")}) : traits, teint de peau et yeux")</div>
<div class="tiles">
@foreach ( var (skin, index) in CharacterRules.SkinsOf( Sex ?? "M" ).Select( ( s, i ) => (s, i) ) )
{
<div class="tile @(Container.Has( skin ) ? "on" : "")" onclick=@(() => SetSkin( skin ))>
<div class="pic" style="background-image: url( @CharacterRules.IconOf( skin ) )"></div>
<div class="tile-name">@($"Visage {(index + 1)}")</div>
</div>
}
</div>
<div class="muted">Le sexe se choisit dans Identité ; il change le corps et la liste des visages.</div>
}
else if ( SliderSection( Section ) is { } sliders )
{
@foreach ( var slider in sliders )
{
<div class="slider">
<div class="slider-label">@slider.Name</div>
<div class="step" onclick=@(() => Nudge( slider, -0.05f ))><i>remove</i></div>
<div class="bar"><div class="fill" style="width: @Percent( slider.Get( Container ) * 100 )"></div></div>
<div class="step" onclick=@(() => Nudge( slider, 0.05f ))><i>add</i></div>
</div>
}
@if ( Section == "yeux" )
{
<div class="field-label">Détails</div>
<div class="tiles">
@foreach ( var item in ItemsOf( "yeux" ) )
{
<div class="tile @(Container.Has( item ) ? "on" : "")" onclick=@(() => Toggle( item ))>
<div class="pic" style="background-image: url( @CharacterRules.IconOf( item ) )"></div>
<div class="tile-name">@Label( item )</div>
</div>
}
</div>
}
}
else
{
@foreach ( var worn in ItemsOf( Section ).Where( x => x.AllowTintSelect && Container.Has( x ) ) )
{
var current = TintOf( worn );
<div class="field-label">@($"Couleur — {Label( worn )}")</div>
<div class="swatches">
@foreach ( var t in Swatches( worn ) )
{
<div class="swatch @(MathF.Abs( current - t ) < 0.01f ? "on" : "")" style="background-color: @worn.TintSelection.Evaluate( t ).Hex" onclick=@(() => SetTint( worn, t ))></div>
}
</div>
}
<div class="tiles">
<div class="tile none @(!ItemsOf( Section ).Any( Container.Has ) ? "on" : "")" onclick=@(() => RemoveSection( Section ))>
<div class="pic"><i>block</i></div>
<div class="tile-name">Aucun</div>
</div>
@foreach ( var item in ItemsOf( Section ) )
{
<div class="tile @(Container.Has( item ) ? "on" : "")" onclick=@(() => Toggle( item ))>
<div class="pic" style="background-image: url( @CharacterRules.IconOf( item ) )"></div>
<div class="tile-name">@Label( item )</div>
</div>
}
</div>
}
</div>
<div class="panel-foot">
<div class="btn" onclick=@(() => Randomize())><i>casino</i> Aléatoire</div>
<div class="btn primary" onclick=@(() => Finish())><i>check</i> Entrer à Union City</div>
</div>
</div>
<div class="camera-tools">
<div class="tool" onclick=@(() => Orbit -= 25)><i>rotate_left</i></div>
<div class="tool @(CloseUp ? "on" : "")" onclick=@(() => Frame( true ))><i>face</i> Visage</div>
<div class="tool @(!CloseUp ? "on" : "")" onclick=@(() => Frame( false ))><i>accessibility_new</i> Corps</div>
<div class="tool" onclick=@(() => Zoom = Math.Clamp( Zoom - 0.2f, 0f, 1f ))><i>zoom_out</i></div>
<div class="tool" onclick=@(() => Zoom = Math.Clamp( Zoom + 0.2f, 0f, 1f ))><i>zoom_in</i></div>
<div class="tool" onclick=@(() => Orbit += 25)><i>rotate_right</i></div>
</div>
</div>
}
</root>
@code
{
sealed record SectionInfo( string Id, string Name, string Icon );
sealed record Slider( string Name, Func<ClothingContainer, float> Get, Action<ClothingContainer, float> Set );
static readonly SectionInfo[] Sections =
{
new( "identite", "Identité", "badge" ),
new( "morphologie", "Morphologie", "accessibility_new" ),
new( "visage", "Visage", "face" ),
new( "yeux", "Yeux", "visibility" ),
new( "cheveux", "Cheveux", "content_cut" ),
new( "pilosite", "Pilosité", "face_6" ),
new( "maquillage", "Maquillage", "brush" ),
new( "haut", "Haut", "checkroom" ),
new( "bas", "Bas", "straighten" ),
new( "chaussures", "Chaussures", "directions_walk" ),
new( "accessoires", "Accessoires", "watch" ),
};
/// <summary>Les catégories de vêtements de s&box rangées dans chaque section.</summary>
static readonly Dictionary<string, string[]> SectionCategories = new()
{
["yeux"] = new[] { "Eyes", "Eyebrows", "Eyelashes" },
["cheveux"] = new[] { "Hair", "HairShort", "HairMedium", "HairLong", "HairUpdo", "HairSpecial" },
["pilosite"] = new[] { "Facial", "FacialHairMustache", "FacialHairBeard", "FacialHairStubble", "FacialHairSideburns", "FacialHairGoatee" },
["maquillage"] = new[] { "MakeupLips", "MakeupEyeshadow", "MakeupEyeliner", "MakeupHighlighter", "MakeupBlush", "MakeupSpecial", "ComplexionFreckles", "ComplexionScars", "ComplexionAcne" },
["haut"] = new[] { "Tops", "TShirt", "Sweatshirt", "Hoodie", "Shirt", "Vest", "Knitwear", "Jacket", "Cardigan", "Coat", "Gilet", "Fullbody", "Dress", "Suit", "Uniform", "Costume" },
["bas"] = new[] { "Bottoms", "Shorts", "Trousers", "Jeans", "Skirt" },
["chaussures"] = new[] { "Footwear", "Socks", "Heels", "Sandals", "Shoes", "Trainers", "Boots", "Slippers" },
["accessoires"] = new[] { "Hat", "Headwear", "HatBeanie", "HatFormal", "HatCostume", "HatUniform", "HatSpecial", "Eyewear", "GlassesEye", "GlassesSun", "GlassesSpecial", "Gloves", "Wristwear", "WristWatch", "WristBand", "WristJewel", "NecklaceChain", "NecklacePendant", "NecklaceSpecial", "EarringStud", "EarringDangle", "Ring", "Piercing", "PierceNose", "PierceEyebrow", "HeadBand" },
};
static readonly Slider[] Morphology =
{
new( "Taille", c => c.Height, ( c, v ) => c.Height = v ),
new( "Âge", c => c.Age, ( c, v ) => c.Age = v ),
new( "Teint", c => c.Tint, ( c, v ) => c.Tint = v ),
};
// Cou, poitrine, crâne, nez, menton et regard n'agissent que sur l'ancien corps
// Citizen : sur le corps réaliste, ce sont les visages (peaux) qui les portent.
static readonly Slider[] Eyes =
{
new( "Couleur des yeux", c => c.EyeColor, ( c, v ) => c.EyeColor = v ),
};
public bool IsOpen { get; private set; }
string Section { get; set; } = "identite";
string FirstName { get; set; } = "";
string LastName { get; set; } = "";
// La date en trois cases (JJ / MM / AAAA), les barres sont fixes : le champ unique qui les
// posait tout seul se laissait déborder et effacer (premier essai à deux, 29/10/2026).
string BirthDay { get; set; } = "";
string BirthMonth { get; set; } = "";
string BirthYear { get; set; } = "";
/// <summary>« 14/07/1998 », tel que CharacterRules le lit (jour et mois sur deux chiffres).</summary>
string BirthInput
{
get => BirthDay.Length + BirthMonth.Length + BirthYear.Length == 0 ? ""
: $"{BirthDay.PadLeft( 2, '0' )}/{BirthMonth.PadLeft( 2, '0' )}/{BirthYear}";
set
{
var parts = (value ?? "").Split( '/' );
BirthDay = parts.ElementAtOrDefault( 0 ) ?? "";
BirthMonth = parts.ElementAtOrDefault( 1 ) ?? "";
BirthYear = parts.ElementAtOrDefault( 2 ) ?? "";
}
}
string Sex { get; set; }
bool CloseUp { get; set; } = true;
float Orbit { get; set; }
ClothingContainer Container { get; set; } = new();
int version;
RoleplayPlayer Me => RoleplayPlayer.Local;
PlayerController Controller => Me?.GetComponent<PlayerController>();
static List<Clothing> freeClothing;
static int freeClothingVersion = -1;
/// <summary>Les vêtements gratuits livrés avec s&box (les payants ne se vérifient pas pour les autres).</summary>
/// L'original d'un vêtement qui a sa variante teintable n'est pas montré : la variante le remplace.
/// Relue quand les paquets du Workshop arrivent (WorkshopPackages.ClothingVersion) et quand le staff
/// masque ou réaffiche un vêtement (garde-robe du menu admin : ServerSettings.HiddenClothing).
static List<Clothing> FreeClothing
{
get
{
var version = HashCode.Combine( WorkshopPackages.ClothingVersion, ServerSettings.Raw( ServerSettings.HiddenClothingKey ) );
if ( freeClothing is null || freeClothingVersion != version )
{
freeClothingVersion = version;
var hidden = ServerSettings.HiddenClothing;
freeClothing = ResourceLibrary.GetAll<Clothing>()
.Where( x => !x.SteamItemDefinitionId.HasValue && !CharacterRules.Variants.ContainsKey( x ) && !PoliceUniform.IsUniform( x )
&& !hidden.Contains( x.ResourcePath ?? "" ) )
.OrderBy( x => x.Title )
.ToList();
}
return freeClothing;
}
}
/// <summary>
/// Les vêtements du Workshop (PackageReferences du .sbproj) dont l'auteur a mal choisi
/// la catégorie : leur section, d'après leur chemin.
/// </summary>
static readonly Dictionary<string, string> SectionOverrides = new( StringComparer.OrdinalIgnoreCase )
{
["vacationshirt/2/vacation shirt cream navy stripe.clothing"] = "haut",
};
static IEnumerable<Clothing> ItemsOf( string section ) =>
SectionCategories.TryGetValue( section, out var categories )
? FreeClothing.Where( x => SectionOverrides.TryGetValue( x.ResourcePath ?? "", out var forced )
? forced == section
: categories.Contains( x.Category.ToString() ) )
: Enumerable.Empty<Clothing>();
static Slider[] SliderSection( string section ) => section switch
{
"morphologie" => Morphology,
"yeux" => Eyes,
_ => null,
};
static string Label( Clothing item ) => string.IsNullOrWhiteSpace( item.Title ) ? System.IO.Path.GetFileNameWithoutExtension( item.ResourcePath ) : item.Title;
// Culture invariante : en français « 50,5% » n'est pas du CSS, et la barre prenait toute la largeur.
static string Percent( float value ) => FormattableString.Invariant( $"{Math.Clamp( value, 0, 100 ):0.#}%" );
protected override void OnUpdate()
{
var me = Me;
if ( me is null )
return;
if ( me.NeedsCharacter && !IsOpen )
Open();
else if ( !me.NeedsCharacter && IsOpen )
Close();
if ( IsOpen )
{
UpdateCameraInput();
PlaceCamera();
}
}
void Open()
{
IsOpen = true;
var me = Me;
// On part de l'apparence enregistrée, sinon de l'avatar Steam du joueur.
Container = !string.IsNullOrWhiteSpace( me.AppearanceJson )
? ClothingContainer.CreateFromJson( me.AppearanceJson )
: ClothingContainer.CreateFromLocalUser();
Container.Clothing.RemoveAll( x => x.Clothing is null || x.Clothing.SteamItemDefinitionId.HasValue );
CharacterRules.UseVariants( Container );
freeClothing = null;
// Un personnage déjà nommé garde son nom ; un nouveau part de « John Doe »
// (jamais du pseudo Steam, qui n'est pas un nom de personnage).
var parts = (me.RpName ?? "").Split( ' ', 2 );
var first = CharacterRules.CleanName( parts.ElementAtOrDefault( 0 ) );
var last = CharacterRules.CleanName( parts.ElementAtOrDefault( 1 ) );
var named = !string.IsNullOrWhiteSpace( me.AppearanceJson ) && first is not null && last is not null;
FirstName = named ? first : DefaultFirstName;
LastName = named ? last : DefaultLastName;
// Homme par défaut. Un nouveau personnage part de la tenue de base de son
// sexe plutôt que de l'avatar Steam (qui pouvait mettre une robe à un homme).
Sex = me.Sex ?? "M";
if ( string.IsNullOrWhiteSpace( me.AppearanceJson ) )
CharacterRules.DressDefault( Container, Sex );
BirthInput = me.BirthDate is { Length: 10 } b ? $"{b[8..10]}/{b[5..7]}/{b[..4]}" : "";
me.PreviewingAppearance = true;
Preview();
UiLock.Set( "personnage", true );
if ( Controller.IsValid() )
{
// En 1re personne, le moteur cache le corps du joueur : il doit se voir ici.
wasThirdPerson = Controller.ThirdPerson;
Controller.ThirdPerson = true;
Controller.UseCameraControls = false;
// Pas de physique pendant la création : ceux qui créent en même temps au
// même endroit ne se poussent pas, et personne ne tombe.
if ( Controller.Body.IsValid() )
{
Controller.Body.MotionEnabled = false;
Controller.Body.Velocity = Vector3.Zero;
}
}
Orbit = 0;
Zoom = 0;
// Le corps porte l'étiquette « viewer », que la caméra ne dessine pas en 1re personne.
Scene.Camera?.RenderExcludeTags.Remove( "viewer" );
}
bool wasThirdPerson;
/// <summary>Le créateur tient la caméra : WeaponHolder ne lui impose plus son champ de vision.</summary>
public static bool Framing { get; private set; }
// Cadrage de la création, réglable à chaud.
public static float PortraitFov { get; set; } = 60f;
public static float HeadAbove { get; set; } = 3f;
public static float HeadShown { get; set; } = 26f;
const string DefaultFirstName = "John";
const string DefaultLastName = "Doe";
void Close()
{
IsOpen = false;
var me = Me;
// Le personnage est créé (l'hôte a levé NeedsCharacter) : petit son d'entrée en ville.
if ( me is not null && !me.NeedsCharacter )
Sounds.Play( "bienvenue", 0.6f );
if ( me is not null )
{
me.PreviewingAppearance = false;
me.ResetAppliedAppearance();
}
UiLock.Set( "personnage", false );
if ( Controller.IsValid() )
{
Controller.ThirdPerson = wasThirdPerson;
Controller.UseCameraControls = true;
if ( Controller.Body.IsValid() )
Controller.Body.MotionEnabled = true;
}
if ( !wasThirdPerson )
Scene.Camera?.RenderExcludeTags.Add( "viewer" );
// WeaponHolder reprend la main sur le champ de vision dès l'image suivante.
Framing = false;
studio?.Destroy();
studio = null;
}
// Éclairage de studio : la nuit, le personnage restait dans le noir pendant sa création (Julien,
// 29/10/2026). Trois lampes locales (jamais en réseau), qui suivent la caméra : principale
// devant à gauche, d'appoint à droite, et une en contre-jour qui détache la silhouette.
// Réglables à chaud (statiques) ; ombres coupées, rien ne doit tomber sur le visage.
public static float StudioKey { get; set; } = 2.4f;
public static float StudioFill { get; set; } = 0.9f;
public static float StudioRim { get; set; } = 1.2f;
public static float StudioRadius { get; set; } = 220f;
GameObject studio;
PointLight keyLight, fillLight, rimLight;
void PlaceStudio( Vector3 target, Rotation facing )
{
if ( !studio.IsValid() )
{
studio = new GameObject( true, "Studio de création" );
studio.Flags |= GameObjectFlags.NotNetworked | GameObjectFlags.NotSaved;
keyLight = Lamp( "Principale" );
fillLight = Lamp( "Appoint" );
rimLight = Lamp( "Contre-jour" );
}
var warm = new Color( 1f, 0.93f, 0.85f );
keyLight.LightColor = warm * StudioKey;
fillLight.LightColor = new Color( 0.85f, 0.9f, 1f ) * StudioFill;
rimLight.LightColor = warm * StudioRim;
foreach ( var l in new[] { keyLight, fillLight, rimLight } )
l.Radius = StudioRadius;
// « facing » regarde du personnage vers la caméra.
keyLight.WorldPosition = target + facing.Forward * 70f + facing.Left * 45f + Vector3.Up * 35f;
fillLight.WorldPosition = target + facing.Forward * 60f + facing.Right * 55f + Vector3.Up * 5f;
rimLight.WorldPosition = target - facing.Forward * 55f + facing.Right * 20f + Vector3.Up * 45f;
}
PointLight Lamp( string name )
{
var go = new GameObject( studio, true, name );
var l = go.Components.Create<PointLight>();
l.Shadows = false;
return l;
}
protected override void OnDisabled()
{
if ( IsOpen )
Close();
}
/// <summary>
/// La caméra face au personnage : gros plan du visage ou corps entier. Le
/// personnage est placé au milieu de la partie libre de l'écran, à droite du
/// panneau (qui occupe un peu plus du tiers gauche).
/// </summary>
void PlaceCamera()
{
var camera = Scene.Camera;
var me = Me;
if ( camera is null || me is null )
return;
// Le corps porte l'étiquette « viewer », que la caméra ne dessine pas en 1re personne.
camera.RenderExcludeTags.Remove( "viewer" );
// Le personnage regarde toujours le même côté de la ruelle.
if ( Controller.IsValid() )
{
Controller.EyeAngles = new Angles( 0, CharacterRules.CreationYaw, 0 );
if ( Controller.Renderer.IsValid() )
Controller.Renderer.WorldRotation = Rotation.FromYaw( CharacterRules.CreationYaw );
}
// Un champ de portrait, le temps de la création : celui du jeu (121° en horizontal sur
// un écran 16:9) rendait le personnage minuscule et le rejetait au bord (29/10/2026).
Framing = true;
camera.FieldOfView = PortraitFov;
var aspect = Screen.Width / MathF.Max( 1f, Screen.Height );
var halfVertical = MathF.Atan( MathF.Tan( PortraitFov.DegreeToRadian() / 2f ) / aspect );
// Cadrage par la hauteur montrée : le buste (du haut du crâne à mi-poitrine) ou le corps
// entier. Zoom : de 0 (cadrage de départ) à 1 (au plus près).
// La tête : son os, au-dessus duquel on remonte au milieu du visage (la hauteur change
// avec la taille choisie en Morphologie). Le corps : de la tête aux pieds.
var head = Controller.IsValid() && Controller.Renderer.IsValid()
&& Controller.Renderer.TryGetBoneTransform( "head", out var bone )
? bone.Position.z - me.WorldPosition.z + HeadAbove
: 64f;
// En plan large, les pieds restent au-dessus de la barre d'outils du bas.
var height = CloseUp ? head : head * 0.47f + Zoom * 14f;
var shown = (CloseUp ? HeadShown : head * 1.65f) * (1f - Zoom * 0.5f);
var distance = shown / 2f / MathF.Tan( halfVertical );
var target = me.WorldPosition + Vector3.Up * height;
var yaw = CharacterRules.CreationYaw + Orbit;
var facing = Rotation.FromYaw( yaw );
// Décalage latéral : le personnage au milieu de la partie libre, à droite du panneau
// (son bord droit est à 490 px d'une interface de 1080 de haut). En demi-largeurs
// d'écran, ce décalage vaut exactement la part de l'écran que le panneau occupe.
// La caméra regarde le personnage de face, sa gauche est donc la droite du
// personnage : se décaler vers elle pousse le personnage à droite de l'écran.
var panel = 490f * Screen.Height / 1080f / MathF.Max( 1f, Screen.Width );
var halfWidth = distance * MathF.Tan( PortraitFov.DegreeToRadian() / 2f );
var shift = facing.Right * (halfWidth * panel);
var position = target + facing.Forward * distance + shift;
camera.WorldPosition = position;
// Les lampes visent le personnage lui-même (à mi-hauteur en plan large), pas le cadrage.
PlaceStudio( me.WorldPosition + Vector3.Up * (CloseUp ? head : head * 0.65f), Rotation.FromYaw( CharacterRules.CreationYaw ) );
camera.WorldRotation = Rotation.LookAt( target + shift - position, Vector3.Up );
}
/// <summary>Le sexe change le corps et remet la tenue de départ de ce sexe, tout de suite à l'écran.</summary>
void SetSex( string sex )
{
if ( Sex == sex )
return;
Sex = sex;
CharacterRules.DressDefault( Container, sex );
Preview();
}
void SetSkin( Clothing skin )
{
Container.Clothing.RemoveAll( x => x.Clothing?.HasHumanSkin == true );
Container.Add( skin );
Preview();
}
// Zoom à la molette et rotation en faisant glisser, sur la partie droite de l'écran
float Zoom { get; set; }
// L'interface garde la souris pour elle (Input.MouseWheel et « attack1 » restaient à zéro sous
// le créateur, seuls les boutons du bas marchaient) : la scène est un panneau qui la reçoit.
bool orbiting;
Vector2 orbitLast;
void StartOrbit()
{
orbiting = true;
orbitLast = Mouse.Position;
}
void StageWheel( float y ) => Zoom = Math.Clamp( Zoom - y * 0.12f, 0f, 1f );
void UpdateCameraInput()
{
if ( !orbiting )
return;
Orbit += (Mouse.Position.x - orbitLast.x) * 0.4f;
orbitLast = Mouse.Position;
}
/// <summary>Gros plan sur la tête ou plan sur le corps, en repartant du cadrage de départ.</summary>
void Frame( bool closeUp )
{
if ( CloseUp != closeUp )
Zoom = 0;
CloseUp = closeUp;
}
void Select( string section )
{
Section = section;
// La tête pour l'identité et tout ce qui se voit sur le visage ; le corps pour le reste.
Frame( section is "identite" or "visage" or "yeux" or "cheveux" or "pilosite" or "maquillage" );
}
// Les champs se corrigent pendant la frappe : pas de consigne à lire.
/// <summary>Un prénom ou un nom : lettres, tiret, apostrophe et espace, 20 au plus.</summary>
static string NameInput( string value )
{
var kept = new string( (value ?? "").Where( c => char.IsLetter( c ) || c is '-' or '\'' or ' ' ).ToArray() ).TrimStart();
return kept.Length > 20 ? kept[..20] : kept;
}
/// <summary>Les chiffres seuls, au plus <paramref name="max"/>.</summary>
static string Digits( string value, int max ) => new( (value ?? "").Where( char.IsDigit ).Take( max ).ToArray() );
/// <summary>L'apparence en cours, pour la photo de la carte d'identité (mise à jour à chaque retouche).</summary>
string PreviewJson { get; set; }
void Preview()
{
Me?.ApplyAppearance( Container, Sex ?? "M" );
PreviewJson = Container.Serialize();
version++;
}
void Nudge( Slider slider, float delta )
{
slider.Set( Container, Math.Clamp( slider.Get( Container ) + delta, 0, 1 ) );
Preview();
}
void Toggle( Clothing item )
{
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.
Container.Clothing.RemoveAll( x => x.Clothing is not null && !x.Clothing.CanBeWornWith( item ) );
Container.Add( item );
}
Preview();
}
// La couleur d'un vêtement teintable (cheveux, barbes, et nos variantes de
// vêtements clairs) : un point du dégradé que l'objet déclare (TintSelection).
float TintOf( Clothing item ) => Container.FindEntry( item )?.Tint ?? item.TintDefault;
/// <summary>
/// Les pastilles : les couleurs posées du dégradé, et le milieu entre deux
/// quand il y en a peu (les cheveux de s&box n'en posent que six, du blanc au noir).
/// </summary>
static List<float> Swatches( Clothing item )
{
var stops = item.TintSelection.Colors.Select( x => x.Time ).OrderBy( x => x ).ToList();
if ( stops.Count < 2 )
return new List<float> { 0, 0.25f, 0.5f, 0.75f, 1 };
if ( stops.Count >= 8 )
return stops;
var list = new List<float>();
for ( var i = 0; i < stops.Count; i++ )
{
list.Add( stops[i] );
if ( i + 1 < stops.Count )
list.Add( (stops[i] + stops[i + 1]) / 2f );
}
return list;
}
void SetTint( Clothing item, float tint )
{
if ( Container.FindEntry( item ) is not { } entry )
return;
entry.Tint = Math.Clamp( tint, 0, 1 );
Preview();
}
void RemoveSection( string section )
{
var items = ItemsOf( section ).ToHashSet();
Container.Clothing.RemoveAll( x => x.Clothing is not null && items.Contains( x.Clothing ) );
Preview();
}
void Randomize()
{
var random = Random.Shared;
foreach ( var slider in Morphology.Concat( Eyes ) )
slider.Set( Container, random.Float( 0.2f, 0.8f ) );
var faces = CharacterRules.SkinsOf( Sex ?? "M" );
if ( faces.Count > 0 )
SetSkin( random.FromList( faces ) );
foreach ( var section in new[] { "cheveux", "haut", "bas", "chaussures" } )
{
RemoveSection( section );
var choices = ItemsOf( section ).ToList();
if ( choices.Count > 0 )
Toggle( random.FromList( choices ) );
}
foreach ( var entry in Container.Clothing.Where( x => x.Clothing?.AllowTintSelect == true ) )
entry.Tint = random.FromList( Swatches( entry.Clothing ) );
Preview();
}
void Finish()
{
if ( CharacterRules.CleanName( FirstName ) is null || CharacterRules.CleanName( LastName ) is null )
{
HudNotifications.Push( "Prénom et nom : 2 à 20 lettres chacun.", "erreur" );
Section = "identite";
return;
}
var identityError = CharacterRules.ValidateIdentity( BirthInput, Sex, out _, out _ );
if ( identityError is not null )
{
HudNotifications.Push( identityError, "erreur" );
Section = "identite";
return;
}
Me?.RequestSaveCharacter( FirstName, LastName, BirthInput, Sex, Container.Serialize() );
}
protected override int BuildHash() => HashCode.Combine( IsOpen, Section, FirstName, LastName, CloseUp, version, BirthInput, HashCode.Combine( Sex, WorkshopPackages.ClothingVersion ) );
}