UI/CharacterCreator.razor

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