Vehicles/VehicleKeys.cs

Static helpers for vehicle keys. Builds the cle:<plate> inventory item, gives and holds it, and runs the per-frame system that poses a held key, cash, phone, hammer, cardboard box or prop in the citizen's hand with IK, first-person arm, finger curl, sway and remote-lock input.

namespace HorizonRP;

/// <summary>
/// La clé électronique d'un véhicule : un objet d'inventaire « cle:&lt;plaque&gt; », qui
/// n'ouvre que le véhicule de cette plaque. On la donne, on la jette, on la vole : elle
/// suit l'objet, pas le joueur (même un propriétaire sans sa clé reste dehors).
///
/// On la prend en main par sa poche (1 à 5) ; en main, le clic gauche verrouille ou
/// déverrouille à distance, la main avancée, comme une vraie télécommande (~25 m). Dans le
/// véhicule, c'est la touche L, sans clé (le bouton de la portière).
///
/// Modèle : « Electronic Car Key » de neeb17 (Sketchfab, CC-BY-4.0), converti en OBJ par
/// outils/cle/gltf_vers_obj.py.
/// </summary>
public static class VehicleKeys
{
	public const string Prefix = "cle:";
	public const string ModelPath = "models/horizon/cle/cle_voiture.vmdl";

	/// <summary>Portée de la télécommande : ~25 m (un pouce = 2,54 cm).</summary>
	public const float Range = 1000f;

	/// <summary>Durée du bras tendu quand on appuie.</summary>
	public const float PressTime = 0.6f;

	public static string IdOf( string plate ) => $"{Prefix}{plate}";

	/// <summary>La plaque d'une clé, ou null si l'objet n'en est pas une.</summary>
	public static string PlateOf( string itemId ) =>
		itemId is not null && itemId.StartsWith( Prefix ) && itemId.Length > Prefix.Length ? itemId[Prefix.Length..] : null;

	public static ItemDefinition Get( string id ) => PlateOf( id ) is { } plate
		? new ItemDefinition( id, $"Clé de voiture · {plate}", $"Clé électronique du véhicule immatriculé {plate}. Elle n'ouvre que lui. En main : clic gauche pour verrouiller ou déverrouiller à distance.",
			"key", "Clés", 0.05f, 1, 0, ModelPath )
		: null;

	/// <summary>Hôte : donne la clé d'une plaque au joueur, s'il ne l'a pas déjà.</summary>
	public static void Give( RoleplayPlayer player, string plate )
	{
		var id = IdOf( plate );
		if ( player is null || player.Inventory.Any( x => x.Item == id ) )
			return;

		if ( player.AddItem( Get( id ), 1 ) == 0 )
			player.Notify( $"Plus de place pour la clé du véhicule {plate} : libérez une poche et revenez au garage.", "erreur" );
		else
			player.Notify( $"Clé du véhicule {plate} ajoutée à vos poches.", "info" );
	}

	/// <summary>Hôte : prend la clé en main, ou la range si c'est déjà elle (l'arme sortie est rangée).</summary>
	public static void ToggleHold( RoleplayPlayer player, InventorySlot slot )
	{
		if ( player.HeldKey == slot.Item )
		{
			player.HeldKey = null;
			return;
		}

		WeaponSystem.Current?.Holster( player );
		player.HeldKey = slot.Item;
	}
}

/// <summary>
/// La clé tenue en main — ou la liasse de billets (Money/Cash.cs, <see cref="Cash.HoldId"/>), qui
/// se tient de la même façon —, sur chaque machine. Une seule pose, la même partout : la main droite
/// devant soi en bas à droite, paume vers le haut, la clé couchée dedans ; à l'appui, la main
/// avance. Elle est posée par IK sur le vrai corps (ce que voient les autres, et soi en
/// troisième personne) et, en première personne, sur une copie du bras tenue comme une vue
/// d'arme. Chez le joueur lui-même : le clic gauche.
/// Appelée par VehicleManager à chaque image (un GameObjectSystem ajouté pendant une
/// partie ne naît qu'au chargement suivant de la scène).
/// </summary>
public static class VehicleKeySystem
{
	/// <summary>Les appuis vus récemment, par joueur (pour la main qui avance).</summary>
	static readonly Dictionary<Guid, RealTimeSince> presses = new();

	public static void Pressed( Guid playerObject ) => presses[playerObject] = 0;

	static readonly Dictionary<Guid, GameObject> shown = new();
	/// <summary>Le modèle affiché pour chacun : passer de la clé aux billets le change.</summary>
	static readonly Dictionary<Guid, string> shownModel = new();

	static bool IsCash( string held ) => held == Cash.HoldId;

	/// <summary>Le téléphone sorti passe devant ce que le joueur tenait (clé, billets), le temps qu'il le range.</summary>
	public const string PhoneHoldId = "telephone";
	public const string PhoneModel = "models/horizon/telephone/telephone.vmdl";
	static bool IsPhone( string held ) => held == PhoneHoldId;
	/// <summary>Le marteau du staff (le physgun) : devant la clé ou les billets, derrière le téléphone.</summary>
	public const string HammerHoldId = "marteau";
	public const string HammerModel = "models/horizon/marteau/marteau.vmdl";
	static bool IsHammer( string held ) => held == HammerHoldId;
	static string HeldOf( RoleplayPlayer player ) => player.PhoneOut ? PhoneHoldId : player.StaffHammer ? HammerHoldId
		: DebugHeld is not null && player == RoleplayPlayer.Local ? DebugHeld : player.HeldKey;

	/// <summary>Essais : ce que le joueur local tient à l'écran (« prop:12 »…), sans rien dans ses poches.</summary>
	public static string DebugHeld { get; set; }

	static string ModelOf( string held ) => IsPhone( held ) ? PhoneModel : IsHammer( held ) ? HammerModel : IsCash( held ) ? Cash.ModelPath
		: IsCarton( held ) ? CartonModel : IsProp( held ) ? PropModel( held ) ?? CartonModel : VehicleKeys.ModelPath;

	/// <summary>
	/// Un prop pris en main (sa poche, 1 à 5) : un carton porté à deux mains devant soi — ce qu'on
	/// va poser est dedans —, sauf le digicode et le terminal de paiement, tenus pour de vrai comme
	/// la clé (Julien, 30/10/2026). Le clic gauche le pose (PropMenu).
	/// </summary>
	public const string CartonModel = "models/citizen_props/cardboardbox01.vmdl";
	static readonly string[] RealProps = { "models/horizon/props/digicode/digicode.vmdl", "models/horizon/props/terminal_cb/terminal_cb.vmdl" };
	static string PropModel( string held ) => PropItems.EntryOf( held ) is var id && id > 0 ? PropSystem.Current?.Get( id )?.Model : null;
	static bool IsProp( string held ) => PropItems.EntryOf( held ) > 0;
	static bool IsCarton( string held ) => IsProp( held ) && !RealProps.Contains( PropModel( held ) );

	// Le carton (cardboardbox01 : 32 × 32 × 33 pouces, origine dessous) ramené à ~40 cm, porté
	// contre le ventre, les deux mains dessous, paumes vers le haut (on porte un carton par le
	// fond). Repère du corps (avant, gauche, haut), au sol. Plus loin du corps, l'IK tirait les
	// bras tendus et pliait le buste en avant (premier essai).
	public static float CartonScale { get; set; } = 0.5f;
	public static Vector3 CartonBody { get; set; } = new( 9, 0, 39 );
	public static float CartonHalf => 16f * CartonScale;
	/// <summary>Les mains sous le fond : en arrière du milieu (côté ventre), rentrées sous les flancs, juste sous le fond.</summary>
	public static Vector3 CartonHand { get; set; } = new( -2, -2.2f, -0.6f );
	/// <summary>Le carton à la première personne, repère de la caméra : en bas de l'image, sous le regard.</summary>
	public static Vector3 CartonFp { get; set; } = new( 20, 0, -27 );
	// Les mains sous le fond, doigts vers l'avant et un peu vers l'intérieur, paume vers le haut
	// (le dos de la main, Z de l'os, vers le bas). Les axes de « hand_L » sont ceux de « hand_R »
	// en miroir : réglés à part.
	public static Vector3 CartonFingersRight { get; set; } = new( 1, 0.25f, 0 );
	public static Vector3 CartonFingersLeft { get; set; } = new( 1, -0.25f, 0 );
	public static Vector3 CartonBackRight { get; set; } = new( 0, 0, -1 );
	public static Vector3 CartonBackLeft { get; set; } = new( 0, 0, -1 );

	/// <summary>Le carton et les deux mains, repère donné (le corps à plat, ou la caméra).</summary>
	static (Transform Box, Transform Right, Transform Left) CartonFrame( Vector3 center, Rotation frame )
	{
		var box = new Transform( center, frame, CartonScale );
		var mid = center + frame * Vector3.Up * CartonHand.z;
		var right = new Transform( mid + frame * new Vector3( CartonHand.x, -(CartonHalf + CartonHand.y), 0 ),
			frame * Rotation.LookAt( CartonFingersRight.Normal, CartonBackRight.Normal ) );
		var left = new Transform( mid + frame * new Vector3( CartonHand.x, CartonHalf + CartonHand.y, 0 ),
			frame * Rotation.LookAt( CartonFingersLeft.Normal, CartonBackLeft.Normal ) );
		return (box, right, left);
	}

	// La main qui tient quelque chose suit la marche : l'IK la clouait à un point fixe devant le regard,
	// et elle restait immobile pendant que tout le corps courait (vidéo de Julien, 30/10/2026). Un
	// balancement d'avant en arrière, un rebond à chaque pas, à proportion de la vitesse ; le même
	// pour la clé, les billets, le téléphone, le marteau et le carton (deux mains, moins ample).
	static readonly Dictionary<Guid, (Vector3 Last, float Phase, float Speed)> gaits = new();
	/// <summary>Le décalage de la main du joueur en cours de pose (monde), posé par <see cref="Gait"/>.</summary>
	static Vector3 gait;
	/// <summary>Le balancement d'avant en arrière et le rebond, à la course (pouces).</summary>
	public static float GaitSwing { get; set; } = 5f;
	public static float GaitBounce { get; set; } = 2.2f;

	static Vector3 Gait( RoleplayPlayer player, PlayerController controller )
	{
		var id = player.GameObject.Id;
		var dt = MathF.Max( Time.Delta, 0.0001f );
		var here = player.WorldPosition;
		var state = gaits.TryGetValue( id, out var g ) ? g : (Last: here, Phase: 0f, Speed: 0f);
		// La vitesse à plat, lissée, mesurée ici : celle du contrôleur ne suit pas chez les autres.
		var measured = (here - state.Last).WithZ( 0 ).Length / dt;
		if ( measured > 1000 )
			measured = 0;   // un déplacement d'un coup (téléportation)
		var speed = MathX.Lerp( state.Speed, measured, MathF.Min( 1, dt * 8 ) );
		var amount = controller.IsOnGround ? Math.Clamp( speed / 220f, 0, 1.2f ) : 0;
		// Un pas fait ~45 pouces en courant : une période de balancement tous les deux pas.
		var phase = (state.Phase + dt * MathF.PI * speed / 45f) % (MathF.PI * 2);
		gaits[id] = (here, phase, speed);
		if ( amount < 0.05f )
			return Vector3.Zero;
		var yaw = Rotation.FromYaw( controller.EyeAngles.yaw );
		return yaw * new Vector3( MathF.Sin( phase ) * GaitSwing * amount, 0, -MathF.Abs( MathF.Cos( phase ) ) * GaitBounce * amount );
	}

	static void PoseCarton( SkinnedModelRenderer body, PlayerController controller )
	{
		body.Set( "holdtype", 4 );
		body.Set( "holdtype_handedness", 0 );   // les deux mains
		body.Set( "holdtype_pose_hand", 0.15f );
		var yaw = Rotation.FromYaw( controller.EyeAngles.yaw );
		var (_, right, left) = CartonFrame( body.WorldPosition + yaw * CartonBody + gait * 0.6f, yaw );
		body.SetIk( "hand_right", right );
		body.SetIk( "hand_left", left );
	}

	/// <summary>
	/// Le digicode et le terminal tenus comme la clé (PlaceBetweenThumbAndIndex : X du modèle vers
	/// le haut, Z le long des doigts), puis recalés dans ce repère. Le digicode (face avant +X,
	/// hauteur selon Z, origine en bas) dépassait des doigts de toute sa hauteur : reculé. Le
	/// terminal est couché selon Z, long selon Y : tourné pour avoir l'écran vers le haut et sa
	/// longueur le long des doigts, et descendu sur eux (son origine est dessous).
	/// </summary>
	public static Vector3 KeypadInHand { get; set; } = new( 0, 0, -2.4f );
	public static Vector3 TerminalInHand { get; set; } = new( -0.45f, 0, 0.6f );

	static void PlaceRealProp( GameObject item, string held )
	{
		var model = PropModel( held ) ?? "";
		var t = item.WorldTransform;
		if ( model.Contains( "/terminal_cb/" ) )
			item.WorldTransform = t.ToWorld( new Transform( TerminalInHand, Rotation.LookAt( Vector3.Left, Vector3.Forward ) ) );
		else if ( model.Contains( "/digicode/" ) )
			item.WorldTransform = t.ToWorld( new Transform( KeypadInHand ) );
	}

	static void PlaceCarton( GameObject box, SkinnedModelRenderer body, PlayerController controller )
	{
		var yaw = Rotation.FromYaw( controller.EyeAngles.yaw );
		box.WorldTransform = CartonFrame( body.WorldPosition + yaw * CartonBody + gait * 0.6f, yaw ).Box;
	}
	static readonly Dictionary<Guid, int> releasing = new();
	/// <summary>Les joueurs dont la main a été posée en tenue de clé : eux seuls sont à relâcher.</summary>
	static readonly HashSet<Guid> posed = new();
	static RealTimeSince sinceClick = 10;

	/// <summary>Essais : garde la main avancée.</summary>
	public static bool DebugPress { get; set; }

	// La pose, dans le repère du regard (avant, gauche, haut). Axes de l'os « hand_R » : X vers
	// les phalanges, Y côté index, Z le dos de la main (mesurés sur les doigts du citoyen).
	// Le poignet : sous les yeux pour la vue de première personne (en bas à droite de l'image),
	// devant la poitrine sur le corps que voient les autres. Même main, même mouvement.
	public static Vector3 Wrist { get; set; } = new( 12, -8, -11 );
	public static Vector3 WristPressed { get; set; } = new( 20, -6, -5 );
	public static Vector3 BodyWrist { get; set; } = new( 14, -7, -22 );
	public static Vector3 BodyWristPressed { get; set; } = new( 23, -6, -14 );
	// Tenue de télécommande : poignet tourné sur le côté, dos de la main vers la droite, pouce
	// en haut, doigts repliés dessous, la clé pointée vers l'avant (photo de Julien).
	public static Vector3 Fingers { get; set; } = new( 0.85f, 0.35f, 0.15f );
	public static Vector3 BackOfHand { get; set; } = new( 0.45f, -0.675f, 0.225f );
	/// <summary>La fermeture de la main (paramètre « holdtype_pose_hand » du citoyen).</summary>
	public static float Grip { get; set; } = 0.35f;
	// La clé à plat dans la paume, repère de « hand_R » (la paume regarde -Z) : boutons vers
	// le haut, hors de la paume, la longueur le long des doigts, qui se referment dessus.
	public static Vector3 KeyPosition { get; set; } = new( 2.7f, 0.3f, -1.3f );
	public static Vector3 KeyThickness { get; set; } = new( 0, 0, -1 );
	public static Vector3 KeyLength { get; set; } = new( 1, 0, 0 );
	/// <summary>
	/// Le pied de la copie de première personne, dans le repère du regard : l'épaule droite
	/// tombe derrière, à droite et plus bas que l'œil, le torse derrière la caméra (invisible,
	/// même tête baissée), et seul l'avant-bras droit entre dans l'image par le coin bas droit.
	/// </summary>
	public static Vector3 FpRoot { get; set; } = new( -4, -4, -70 );

	public static void Update()
	{
		var alive = new HashSet<Guid>();
		foreach ( var player in RoleplayPlayer.All )
		{
			if ( !player.IsValid() )
				continue;
			var controller = player.GetComponent<PlayerController>();
			var body = controller?.Renderer;
			// Les os du corps en objets : la clé, les billets et le téléphone s'accrochent à « hand_R »,
			// les doigts et la tête se replient par eux. Le prefab ne les crée pas : chez l'hôte de
			// l'éditeur quelque chose les créait, chez les autres joueurs rien n'apparaissait en main
			// (premier essai à deux, 29/10/2026).
			if ( body.IsValid() && !body.CreateBoneObjects )
				body.CreateBoneObjects = true;
			var seated = player.GameObject.GetComponentInParent<Vehicle>() is not null;
			// Les billets à 0 $ : rien dans la main, comme « Mains » (une liasse avec un solde nul
			// faisait bizarre).
			var held = HeldOf( player );
			var holding = !string.IsNullOrEmpty( held ) && !seated && body.IsValid() && !player.OnTheGround
				&& !(IsCash( held ) && player.Wallet <= 0);
			var pressing = DebugPress || presses.TryGetValue( player.GameObject.Id, out var since ) && since < VehicleKeys.PressTime;

			if ( holding )
			{
				alive.Add( player.GameObject.Id );
				posed.Add( player.GameObject.Id );
				gait = Gait( player, controller );
				Show( player, body );
				if ( IsPhone( held ) )
					PosePhone( body, controller, player.PhoneSelfie, player == RoleplayPlayer.Local );
				else if ( IsHammer( held ) )
					PoseHammer( body, controller );
				else if ( IsCarton( held ) )
					PoseCarton( body, controller );
				else
					Pose( body, controller, pressing, BodyWrist, BodyWristPressed, IsCash( held ) );
				if ( shown.TryGetValue( player.GameObject.Id, out var key ) && key.IsValid() )
				{
					if ( IsPhone( held ) )
						PlacePhone( key );
					else if ( IsHammer( held ) )
						PlaceHammer( key );
					else if ( IsCarton( held ) )
						PlaceCarton( key, body, controller );
					else if ( !PlaceBetweenThumbAndIndex( key, body, Vector3.Up, IsCash( held ) ) )
						PlaceKey( key );
					else if ( IsProp( held ) )
						PlaceRealProp( key, held );
				}
			}
			else if ( body.IsValid() && posed.Contains( player.GameObject.Id ) )
			{
				// Lâchée (rangée, jetée, donnée, mise au coffre) : la pose est relâchée une
				// trentaine d'images, paramètres de la tenue compris. Le relâchement se décidait
				// sur le modèle de clé affiché : s'il n'avait pas pu se créer (os de la main pas
				// encore là), rien n'était relâché et la main restait figée en l'air.
				body.ClearIk( "hand_right" );
				body.ClearIk( "hand_left" );
				body.Set( "holdtype_handedness", 0 );
				body.Set( "holdtype_pose_hand", 0f );
				releasing[player.GameObject.Id] = releasing.GetValueOrDefault( player.GameObject.Id ) + 1;
				if ( releasing[player.GameObject.Id] > 30 )
				{
					releasing.Remove( player.GameObject.Id );
					posed.Remove( player.GameObject.Id );
				}
			}
			if ( holding )
				releasing.Remove( player.GameObject.Id );
			if ( body.IsValid() )
			{
				// Les doigts repliés : autour du téléphone, ou en poing sur le manche du marteau.
				if ( holding && IsHammer( held ) )
					CurlFingers( player, body, true, HammerCurl, HammerThumbCurl, HammerThumbAxis );
				else
					CurlFingers( player, body, holding && IsPhone( held ), PhoneBodyCurl, PhoneThumbCurl, PhoneThumbAxis );
				// En selfie, il regarde l'objectif, devant lui : la tête reste droite.
				HeadDown( player, body, holding && IsPhone( held ) && !player.PhoneSelfie );
			}

			if ( player == RoleplayPlayer.Local )
			{
				var push = DebugPress ? 1f : presses.TryGetValue( player.GameObject.Id, out var t ) && t < VehicleKeys.PressTime ? MathF.Sin( MathF.PI * t / VehicleKeys.PressTime ) : 0f;
				// Le téléphone ne se montre pas à la première personne (ni main ni téléphone : Julien) ;
				// les autres, et la troisième personne, le voient en main.
				FirstPerson( player, controller, body, holding && !IsPhone( held ), push );
				if ( holding && !IsPhone( held ) && !IsHammer( held ) )
					LocalInput( player );
			}
		}

		// Clés rangées, joueurs partis : leur modèle disparaît.
		foreach ( var id in shown.Keys.Where( x => !alive.Contains( x ) ).ToList() )
		{
			if ( shown[id].IsValid() )
				shown[id].Destroy();
			shown.Remove( id );
			shownModel.Remove( id );
		}
	}

	// Le marteau : la main avancée, le poing serré sur le manche, la tête en haut. Repère
	// de « hand_R » : X les doigts, Y le côté de l'index, Z le dos de la main ; repère du modèle :
	// le manche selon X (tête vers +X), la tête étalée selon Z, l'origine à la prise.
	public static Vector3 HammerInPalm { get; set; } = new( 1.8f, 0.2f, -1.9f );
	// Mesurés dans la pose du marteau (PoseHammer) : le manche vers le haut du monde penché de 15°
	// vers l'avant (on ne tient jamais un marteau parfaitement droit : Julien), la face de frappe
	// vers l'avant du joueur.
	public static Vector3 HammerHandle { get; set; } = new( 0.414f, 0.910f, 0 );
	public static Vector3 HammerFace { get; set; } = new( 0.986f, -0.164f, 0 );
	/// <summary>Taille réelle (30 cm) : agrandi à 1,5, il paraissait démesuré (Julien).</summary>
	public static float HammerScale { get; set; } = 1f;

	/// <summary>
	/// Le poignet, repère du regard (avant, gauche, haut) : à hauteur de la taille, près du corps.
	/// Pose de référence d'un marteau porté : le haut du bras vertical le long du corps, le coude
	/// plié à ~90° vers l'arrière, l'avant-bras vers l'avant. Plus loin ou plus bas, l'IK tendait
	/// le bras ou pliait le coude à l'envers (Julien, 29/10/2026).
	/// </summary>
	public static Vector3 HammerWrist { get; set; } = new( 8, -9, -22 );
	/// <summary>La fermeture du poing sur le manche.</summary>
	public static float HammerGrip { get; set; } = 0.9f;
	/// <summary>Le poignet à la première personne (repère du regard) : le marteau entre par la droite de l'image.</summary>
	public static Vector3 HammerFpWrist { get; set; } = new( 15, -11, -10 );
	/// <summary>Décalage du marteau vers la droite de l'image, à la première personne (pouces).</summary>
	public static float HammerFpShift { get; set; } = 0.8f;
	/// <summary>L'axe du pouce de la copie de première personne (celui que Julien a choisi en photo).</summary>
	public static Vector3 HammerFpThumbAxis { get; set; } = new( 0, 0, -1 );
	// La main dans le prolongement de l'avant-bras (presque horizontal, vers l'avant), le
	// pouce en haut, le dos de la main vers l'extérieur : le poignet reste droit. Celle de la
	// clé (télécommande tenue sur le côté) le cassait (Julien).
	public static Vector3 HammerFingers { get; set; } = new( 0.986f, 0, 0.164f );
	public static Vector3 HammerBackOfHand { get; set; } = new( 0, -1, 0 );

	// Bras le long du corps, la main restait ouverte (la fermeture ne s'applique qu'avec une
	// position de main imposée) : comme la clé, la main est placée, et le poing se ferme.
	static void PoseHammer( SkinnedModelRenderer body, PlayerController controller )
	{
		body.Set( "holdtype", 4 );             // tenir un objet
		body.Set( "holdtype_handedness", 1 );  // main droite seule
		body.Set( "holdtype_pose_hand", HammerGrip );
		var eye = Rotation.FromYaw( controller.EyeAngles.yaw );
		var hand = Rotation.LookAt( HammerFingers.Normal, HammerBackOfHand.Normal );
		body.SetIk( "hand_right", new Transform( controller.EyePosition + eye * HammerWrist + gait, eye * hand ) );
	}

	static void PlaceHammer( GameObject hammer )
	{
		hammer.LocalPosition = HammerInPalm;
		// La tête s'étale selon Z dans le modèle : Z vers l'avant du joueur, la face de frappe devant.
		hammer.LocalRotation = Rotation.LookAt( HammerHandle.Normal, HammerFace.Normal );
		hammer.LocalScale = HammerScale;
	}

	/// <summary>La pose de la main, la même sur le corps et sur la copie de première personne.</summary>
	static void Pose( SkinnedModelRenderer renderer, PlayerController controller, bool pressing, Vector3 wrist, Vector3 wristPressed, bool cash = false )
	{
		renderer.Set( "holdtype", 4 );             // tenir un objet
		renderer.Set( "holdtype_handedness", 1 );  // main droite seule
		renderer.Set( "holdtype_pose_hand", Grip );

		var eye = controller.EyeAngles.ToRotation();
		var hand = Rotation.LookAt( Fingers.Normal, BackOfHand.Normal );
		renderer.SetIk( "hand_right", new Transform( controller.EyePosition + eye * (pressing ? wristPressed : wrist) + gait, eye * hand ) );
	}

	// Le téléphone : tenu debout devant la poitrine, l'écran vers les yeux, la paume derrière lui, les
	// doigts en travers sur sa tranche (photo de Julien) ; repère du regard : avant, gauche, haut ;
	// axes de « hand_R » : X les doigts, Y le côté de l'index, Z le dos de la main.
	public static Vector3 PhoneBodyWrist { get; set; } = new( 15, 1, -13 );
	/// <summary>De combien la tête se baisse vers le téléphone tenu (degrés).</summary>
	public static float PhoneHeadPitch { get; set; } = -22f;
	public static Vector3 PhoneSelfieWrist { get; set; } = new( 21, -6, -3 );
	public static Vector3 PhoneFingers { get; set; } = new( 0.1f, 0.97f, 0.2f );
	public static Vector3 PhoneBackOfHand { get; set; } = new( 0.8f, -0.05f, -0.6f );
	public static Vector3 PhoneFpWrist { get; set; } = new( 13, -7, -13 );
	public static float PhoneGrip { get; set; } = 0.6f;
	public static float PhoneCurl { get; set; } = 28f;
	/// <summary>Le téléphone dans la paume (repère de « hand_R ») : sa longueur le long des doigts, l'écran côté paume.</summary>
	public static Vector3 PhoneInPalm { get; set; } = new( 2.2f, 1.2f, -1.7f );
	static readonly Dictionary<Guid, (float Pitch, GameObject Head, Rotation Initial)> headsDown = new();

	/// <summary>
	/// Le joueur regarde son téléphone : la tête se baisse vers lui (les autres le voient), sans
	/// toucher à son regard à lui, donc à sa caméra. La visée de la tête par l'animation ne tenait
	/// pas (le contrôleur la repose sur le regard à chaque image) : l'os de la tête est pris en main,
	/// comme les doigts, puis rendu à l'animation quand le téléphone est rangé.
	/// </summary>
	static void HeadDown( RoleplayPlayer player, SkinnedModelRenderer body, bool on )
	{
		var id = player.GameObject.Id;
		if ( headsDown.TryGetValue( id, out var done ) && (!on || done.Pitch != PhoneHeadPitch) )
		{
			if ( done.Head.IsValid() )
			{
				done.Head.LocalRotation = done.Initial;
				done.Head.Flags &= ~GameObjectFlags.ProceduralBone;
			}
			headsDown.Remove( id );
		}
		if ( !on || headsDown.ContainsKey( id ) || body.GetBoneObject( "head" ) is not { } head || !head.IsValid() )
			return;
		var initial = head.LocalRotation;
		head.Flags |= GameObjectFlags.ProceduralBone;
		head.WorldRotation = Rotation.FromAxis( body.WorldRotation.Right, PhoneHeadPitch ) * head.WorldRotation;
		headsDown[id] = (PhoneHeadPitch, head, initial);
	}

	/// <summary>Le sens du téléphone dans la paume : sa longueur selon le côté de l'index (debout quand les doigts sont en travers).</summary>
	public static Vector3 PhoneLength { get; set; } = new( 0, 1, 0 );

	/// <summary>
	/// Le cap du selfie du joueur local (PhoneCamera.OrbitYaw/Pitch) : le bras suit le téléphone
	/// qu'on fait tourner autour de soi. Les autres voient le bras tendu droit devant.
	/// </summary>
	public static Rotation SelfieOrbit => Rotation.From( Math.Clamp( PhoneCamera.OrbitPitch, -35, 35 ), Math.Clamp( PhoneCamera.OrbitYaw, -75, 75 ), 0 );

	static void PosePhone( SkinnedModelRenderer renderer, PlayerController controller, bool selfie, bool local )
	{
		renderer.Set( "holdtype", 4 );
		renderer.Set( "holdtype_handedness", 1 );
		renderer.Set( "holdtype_pose_hand", PhoneGrip );
		var frame = controller.EyeAngles.ToRotation() * (selfie && local ? SelfieOrbit : Rotation.Identity);
		var hand = Rotation.LookAt( PhoneFingers.Normal, PhoneBackOfHand.Normal );
		renderer.SetIk( "hand_right", new Transform( controller.EyePosition + frame * (selfie ? PhoneSelfieWrist : PhoneBodyWrist) + gait * (selfie ? 0.4f : 0.7f), frame * hand ) );
	}

	/// <summary>Le repli de chaque phalange autour du téléphone (degrés), et celui du pouce sur l'autre tranche.</summary>
	public static float PhoneBodyCurl { get; set; } = 35f;
	public static float PhoneThumbCurl { get; set; } = 25f;
	public static Vector3 PhoneThumbAxis { get; set; } = new( 1, 0, 0 );

	static readonly Dictionary<Guid, (float Curl, float Thumb, Vector3 Axis, List<(GameObject Bone, Rotation Initial)> Bones)> curled = new();

	/// <summary>
	/// La main du citoyen reste ouverte quoi qu'on demande à l'animation (« holdtype_pose_hand » n'y
	/// change rien) : le téléphone flottait devant des doigts tendus (Julien). Le temps qu'il le tienne,
	/// les os des doigts sont pris en main (ProceduralBone) et repliés autour de lui, comme sur le
	/// bras de première personne ; ils suivent la main, puisqu'un os procédural garde sa place par
	/// rapport à son parent. Rangé, ils sont rendus à l'animation.
	/// </summary>
	/// <summary>Pli de chaque phalange autour du manche du marteau (degrés) : un poing de sa taille.</summary>
	public static float HammerCurl { get; set; } = 55f;
	public static float HammerThumbCurl { get; set; } = 40f;
	/// <summary>L'axe de repli du pouce sur le manche (repère de « hand_R ») : refermé, pas levé.</summary>
	public static Vector3 HammerThumbAxis { get; set; } = new( 0, 0, -1 );

	static void CurlFingers( RoleplayPlayer player, SkinnedModelRenderer body, bool on, float curl, float thumbCurl, Vector3 thumbAxis )
	{
		var id = player.GameObject.Id;
		if ( curled.TryGetValue( id, out var done ) && (!on || done.Curl != curl || done.Thumb != thumbCurl || done.Axis != thumbAxis) )
		{
			foreach ( var (bone, rest) in done.Bones )
			{
				if ( !bone.IsValid() )
					continue;
				bone.LocalRotation = rest;
				bone.Flags &= ~GameObjectFlags.ProceduralBone;
			}
			curled.Remove( id );
		}
		if ( !on || curled.ContainsKey( id ) || body.GetBoneObject( "hand_R" ) is not { } palm || !palm.IsValid() )
			return;

		// Tous figés d'abord (comme le bras de première personne), puis repliés phalange après
		// phalange : chacune ajoute son pli à celui de la précédente.
		var bones = new List<(GameObject Bone, Rotation Initial)>();
		foreach ( var finger in new[] { "index", "middle", "ring", "pinky", "thumb" } )
			for ( var i = 0; i <= 2; i++ )
				if ( body.GetBoneObject( $"finger_{finger}_{i}_R" ) is { } bone && bone.IsValid() )
					bones.Add( (bone, bone.LocalRotation) );
		foreach ( var (bone, _) in bones )
			bone.Flags |= GameObjectFlags.ProceduralBone;
		foreach ( var (bone, _) in bones )
		{
			// Les doigts se referment vers la paume (autour du côté de l'index), le pouce autour de
			// PhoneThumbAxis (axe de « hand_R » ; celui des doigts par défaut). Un pouce couché en
			// travers de l'écran se corrige en remontant le téléphone (PhoneInPalm), pas le pouce.
			var thumb = bone.Name.Contains( "thumb" );
			var axis = thumb ? palm.WorldRotation * thumbAxis.Normal : palm.WorldRotation.Left;
			bone.WorldRotation = Rotation.FromAxis( axis, thumb ? thumbCurl : curl ) * bone.WorldRotation;
		}
		curled[id] = (curl, thumbCurl, thumbAxis, bones);
	}

	/// <summary>Le téléphone tourné dans le plan de son écran (degrés) : bien droit dans la main.</summary>
	public static float PhoneRoll { get; set; } = 10f;

	static void PlacePhone( GameObject phone )
	{
		phone.LocalPosition = PhoneInPalm;
		phone.LocalRotation = Rotation.LookAt( PhoneLength.Normal, Vector3.Down ) * Rotation.FromYaw( PhoneRoll );
	}

	static void PlaceKey( GameObject key )
	{
		key.LocalPosition = KeyPosition;
		key.LocalRotation = Rotation.LookAt( KeyThickness.Normal, KeyLength );
	}

	/// <summary>De combien la clé dépasse en avant de l'index (pouces), et sa demi-épaisseur.</summary>
	public static float KeyAhead { get; set; } = 1.8f;
	public static float KeyHalfThickness { get; set; } = 0.45f;

	/// <summary>
	/// La clé à plat sur les doigts, comme une télécommande (photo de Julien) : boutons vers
	/// le haut (<paramref name="up"/> : le haut de la vue, ou du monde), sa longueur selon
	/// l'index, posée par-dessus la première phalange de l'index et du majeur. Calculée
	/// d'après les os des doigts : à des coordonnées fixes de la main, elle flottait ou
	/// rentrait dans la paume dès que le poignet tournait, et la caler sur le pouce la
	/// mettait sur la tranche.
	/// </summary>
	// La liasse : tenue comme la clé, couchée sur les doigts, sa longueur le long de l'index (ses
	// axes : X la longueur, Z l'épaisseur), mais relevée au-dessus du pouce : à la hauteur de la
	// clé, le pouce la traversait en première personne (capture de Julien). Paume vers le ciel,
	// c'était pire : elle traversait la main ouverte.
	public static float CashAhead { get; set; } = 2.6f;
	public static float CashLift { get; set; } = 0.5f;
	/// <summary>De combien la liasse descend sous le milieu pouce-index (pouces) : le pouce appuie dessus.</summary>
	public static float CashPinch { get; set; } = 0.1f;
	public static float CashTurn { get; set; } = 10f;

	static bool PlaceBetweenThumbAndIndex( GameObject key, SkinnedModelRenderer hand, Vector3 up, bool cash = false )
	{
		var i0 = hand.GetBoneObject( "finger_index_0_R" );
		var i1 = hand.GetBoneObject( "finger_index_1_R" );
		var m0 = hand.GetBoneObject( "finger_middle_0_R" );
		var m1 = hand.GetBoneObject( "finger_middle_1_R" );
		if ( !i0.IsValid() || !i1.IsValid() || !m0.IsValid() || !m1.IsValid() )
			return false;

		var along = (i1.WorldPosition - i0.WorldPosition);
		along = (along - up * Vector3.Dot( along, up )).Normal;
		var onFingers = (i0.WorldPosition + i1.WorldPosition + m0.WorldPosition + m1.WorldPosition) * 0.25f;
		if ( cash )
		{
			// Pincée entre l'index (dessous) et le bout du pouce (dessus) : sa hauteur est à
			// mi-chemin des deux. À hauteur fixe, elle flottait au-dessus des doigts (Julien).
			var length = Rotation.FromAxis( up, -CashTurn ) * along;
			var height = onFingers + up * (KeyFingerRadius + CashLift);
			if ( hand.GetBoneObject( "finger_thumb_2_R" ) is { } thumb && thumb.IsValid() )
			{
				var between = Vector3.Dot( thumb.WorldPosition - onFingers, up ) * 0.5f;
				height = onFingers + up * MathF.Max( 0.25f, between - CashPinch );
			}
			key.WorldTransform = new Transform( height + along * CashAhead, Rotation.LookAt( length, up ) );
			return true;
		}
		var center = onFingers + up * (KeyHalfThickness + KeyFingerRadius) + along * KeyAhead;
		// Tournée sur elle-même vers la droite (autour de ses boutons).
		var turned = Rotation.FromAxis( up, -KeyTurn ) * along;
		key.WorldTransform = new Transform( center, Rotation.LookAt( up, turned ) );
		return true;
	}

	/// <summary>De combien la clé est tournée sur elle-même vers la droite (degrés) : son bout pointé vers l'avant, vers la voiture.</summary>
	public static float KeyTurn { get; set; } = 50f;

	/// <summary>L'épaisseur d'un doigt sous la clé (pouces).</summary>
	public static float KeyFingerRadius { get; set; } = 0.55f;

	static GameObject NewKey( GameObject hand, ModelRenderer.ShadowRenderType shadows, string model )
	{
		var go = new GameObject( hand, true, "Clé en main" );
		go.Flags |= GameObjectFlags.NotNetworked | GameObjectFlags.NotSaved;
		var renderer = go.Components.Create<ModelRenderer>();
		renderer.Model = Model.Load( model );
		renderer.RenderType = shadows;
		PlaceKey( go );
		return go;
	}

	static void Show( RoleplayPlayer player, SkinnedModelRenderer body )
	{
		var model = ModelOf( HeldOf( player ) );
		if ( shown.TryGetValue( player.GameObject.Id, out var go ) && go.IsValid() )
		{
			if ( shownModel.GetValueOrDefault( player.GameObject.Id ) == model )
				return;
			go.Destroy();
		}

		var hand = body.GetBoneObject( "hand_R" );
		if ( !hand.IsValid() )
			return;

		shown[player.GameObject.Id] = NewKey( hand, ModelRenderer.ShadowRenderType.On, model );
		shownModel[player.GameObject.Id] = model;
	}

	// ------------------------------------------------------------------ première personne

	static GameObject fpArm;
	static SkinnedModelRenderer fpRenderer;
	static GameObject fpKey;
	static string fpAppearance;
	static string fpModel;
	/// <summary>Ce que tient la copie (clé, billets, téléphone) : sa pose en dépend.</summary>
	static string fpModelHeld;
	static int fpPoseFrames;
	static bool fpFrozen;

	// En première personne, la même tenue de télécommande que sur le corps (poignet tourné
	// sur le côté : replier la main paume vers soi la pliait sur elle-même).
	public static Vector3 FpFingers { get; set; } = new( 0.85f, 0.35f, 0.15f );
	public static Vector3 FpBackOfHand { get; set; } = new( 0.45f, -0.675f, 0.225f );
	/// <summary>Le repli de chaque phalange des quatre doigts, et du pouce (degrés), une fois la pose figée.</summary>
	public static float FpCurl { get; set; } = 16f;
	public static float FpThumbCurl { get; set; } = 0f;

	/// <summary>Durée de l'arrivée du bras en prenant la clé, et de sa sortie en la rangeant (s).</summary>
	public const float DrawTime = 0.35f;
	public const float HolsterTime = 0.22f;
	/// <summary>Où est le bras quand il est hors de l'écran : plus bas, un peu en arrière, basculé vers le bas.</summary>
	public static Vector3 FpHiddenOffset { get; set; } = new( -4, 0, -13 );
	public static float FpHiddenPitch { get; set; } = 18f;
	/// <summary>L'épaule de la copie, dans le repère de la caméra : le bras bascule autour d'elle.</summary>
	public static Vector3 FpShoulder { get; set; } = new( -4, -10, -17 );

	/// <summary>Essais : fige le bras à ce point de son arrivée (0 = en place, 1 = hors de l'écran).</summary>
	public static float? DebugHidden { get; set; }

	static RealTimeSince fpShownSince;
	static RealTimeSince fpLeaveSince;
	static bool fpLeaving;

	/// <summary>
	/// Place le bras sous la caméra : <paramref name="hidden"/> = 0 à sa place, 1 hors de
	/// l'écran (en bas) ; <paramref name="push"/> = l'avancée de l'appui.
	/// </summary>
	static void PlaceArm( float hidden, float push )
	{
		if ( !fpArm.IsValid() )
			return;
		// Basculé autour de l'épaule (la main part vers le bas), puis descendu.
		var tilt = Rotation.FromPitch( FpHiddenPitch * hidden );
		var root = FpRoot + Vector3.Forward * push * FpPush;
		fpArm.LocalPosition = FpShoulder + tilt * (root - FpShoulder) + FpHiddenOffset * hidden + swayOffset;
		fpArm.LocalRotation = swayRotation * tilt;
	}

	// Le bras en mouvement, comme une arme en main : il traîne un peu derrière la vue quand on
	// tourne, et se balance quand on marche. Sans ça, la copie était un bloc collé à l'écran —
	// « figé, seul le bras bouge » (Julien, 30/10/2026).
	static Angles lastView;
	static bool hasLastView;
	static Angles lag;
	static float bobPhase;
	static Vector3 swayOffset;
	static Rotation swayRotation = Rotation.Identity;

	/// <summary>Le retard maximal du bras sur la vue (degrés), et sa vitesse de rattrapage.</summary>
	public static float FpLagMax { get; set; } = 5f;
	public static float FpLagReturn { get; set; } = 9f;
	/// <summary>L'amplitude du balancement à la marche et à la course (pouces).</summary>
	public static float FpBob { get; set; } = 0.45f;

	static void UpdateSway( PlayerController controller, CameraComponent camera )
	{
		var dt = MathF.Max( Time.Delta, 0.0001f );
		var view = camera.WorldRotation.Angles();
		if ( !hasLastView )
		{
			lastView = view;
			hasLastView = true;
		}
		var turnYaw = MathX.DeltaDegrees( lastView.yaw, view.yaw );
		var turnPitch = MathX.DeltaDegrees( lastView.pitch, view.pitch );
		lastView = view;

		// La vue tourne : le bras reste un instant en arrière, puis rattrape.
		var target = new Angles( Math.Clamp( -turnPitch * 0.6f, -FpLagMax, FpLagMax ), Math.Clamp( -turnYaw * 0.6f, -FpLagMax, FpLagMax ), 0 );
		lag = Angles.Lerp( lag, target, MathF.Min( 1, dt * FpLagReturn ) );

		// À pied : un balancement en huit, plus ample en courant ; rien à l'arrêt ni en l'air.
		var speed = controller is { IsOnGround: true } ? controller.Velocity.WithZ( 0 ).Length : 0f;
		var amount = Math.Clamp( speed / 220f, 0, 1.3f );
		bobPhase += dt * (4f + 6f * amount) * (amount > 0.05f ? 1 : 0);
		var bob = new Vector3( 0, MathF.Cos( bobPhase ) * FpBob * amount, -MathF.Abs( MathF.Sin( bobPhase ) ) * FpBob * amount );

		swayRotation = Rotation.From( lag.pitch, lag.yaw, -lag.yaw * 0.5f );
		swayOffset = bob + new Vector3( 0, lag.yaw * 0.12f, lag.pitch * 0.12f );
	}

	/// <summary>De combien le bras avance à l'appui (pouces).</summary>
	public static float FpPush { get; set; } = 7f;

	/// <summary>Ce qui se porte sur la tête : jamais sur la copie (rien à voir de l'intérieur).</summary>
	static readonly string[] HeadWear = { "/hair/", "/hat", "/glasses", "/beard", "/eyebrow", "/facial", "/mask", "/helmet", "/headwear" };

	/// <summary>
	/// En première personne, le moteur cache tout le corps : la main et la clé avec. Clé en main,
	/// on dessine chez soi seulement une copie du personnage habillée comme lui (Dresser : même
	/// peau, même teint, mêmes vêtements), tenue comme une vue d'arme : enfant de la caméra,
	/// l'épaule derrière et sous l'œil. Sa pose est posée par IK le temps de se placer (cachée),
	/// puis figée (os procéduraux) : le bras est un bloc solide qui tourne avec la vue. Viser
	/// la caméra à chaque image laissait la main en retard d'une image, le bras s'étirait.
	/// À l'appui, tout le bras avance puis revient.
	/// </summary>
	static void FirstPerson( RoleplayPlayer player, PlayerController controller, SkinnedModelRenderer body, bool holding, float push )
	{
		var camera = player.Scene.Camera;
		var want = holding && controller is not null && !controller.ThirdPerson && body.IsValid() && camera.IsValid();

		// La clé portée par le vrai corps (caché) resterait visible : seule son ombre reste.
		if ( shown.TryGetValue( player.GameObject.Id, out var own ) && own.IsValid() && own.Components.Get<ModelRenderer>() is { } ownRenderer )
			ownRenderer.RenderType = want ? ModelRenderer.ShadowRenderType.ShadowsOnly : ModelRenderer.ShadowRenderType.On;

		if ( !want )
		{
			// Clé rangée en première personne : le bras redescend hors de l'écran avant de
			// disparaître. Passé en troisième personne : tout de suite (il est sur la caméra).
			var leaving = fpArm.IsValid() && fpFrozen && controller is not null && !controller.ThirdPerson && body.IsValid();
			if ( leaving && !fpLeaving )
			{
				fpLeaving = true;
				fpLeaveSince = 0;
			}
			if ( leaving && fpLeaveSince < HolsterTime )
			{
				var t = fpLeaveSince / HolsterTime;
				PlaceArm( t * t, 0 );   // accélère en partant
				return;
			}
			if ( fpArm.IsValid() )
				fpArm.Destroy();
			fpArm = null;
			fpLeaving = false;
			return;
		}
		fpLeaving = false;

		if ( !fpArm.IsValid() || fpAppearance != player.AppearanceJson || fpArm.Parent != camera.GameObject || fpModel != ModelOf( HeldOf( player ) ) )
			BuildArm( player, body, camera.GameObject );
		UpdateSway( controller, camera );

		// Le bras arrive du bas de l'écran une fois posé (il était caché en se plaçant), et ralentit
		// en arrivant, comme une arme qu'on sort.
		var drawn = fpFrozen ? MathX.Clamp( fpShownSince / DrawTime, 0, 1 ) : 0f;
		var visible = 1 - (1 - drawn) * (1 - drawn) * (1 - drawn);
		PlaceArm( DebugHidden ?? 1 - visible, push );

		if ( fpFrozen )
			return;

		// Le temps de se placer : ni tête, ni jambes, ni pieds (le Dresser repose les groupes en
		// habillant), et la main posée par IK, cible prise dans le repère du bras lui-même.
		foreach ( var part in fpRenderer.Model.Parts.All.Where( x => x.Name is "Head" or "Legs" or "Feet" ) )
		{
			var empty = part.Choices.ToList().FindIndex( x => string.IsNullOrEmpty( x.Name ) );
			if ( empty >= 0 )
				fpRenderer.SetBodyGroup( part.Name, empty );
		}
		foreach ( var wear in fpArm.GetComponentsInChildren<SkinnedModelRenderer>( true ) )
		{
			if ( wear != fpRenderer && HeadWear.Any( (wear.Model?.ResourcePath ?? "").Contains ) )
				wear.Enabled = false;
			wear.RenderType = ModelRenderer.ShadowRenderType.ShadowsOnly;   // caché tant qu'il se place
		}

		var phone = IsPhone( fpModelHeld );
		var hammer = IsHammer( fpModelHeld );
		var carton = IsCarton( fpModelHeld );
		fpRenderer.Set( "holdtype", 4 );
		fpRenderer.Set( "holdtype_handedness", carton ? 0 : 1 );
		fpRenderer.Set( "holdtype_pose_hand", phone ? PhoneGrip : hammer ? HammerGrip : Grip );
		var root = new Transform( FpRoot );
		var arm = fpArm.WorldTransform;
		// Le marteau garde sa tenue du corps (main dans l'axe de l'avant-bras, pouce en haut).
		var hand = phone ? Rotation.LookAt( PhoneFingers.Normal, PhoneBackOfHand.Normal )
			: hammer ? Rotation.LookAt( HammerFingers.Normal, HammerBackOfHand.Normal )
			: Rotation.LookAt( FpFingers.Normal, FpBackOfHand.Normal );
		if ( carton )
		{
			// Les deux mains sur le carton, en bas de l'image (repère de la caméra).
			var (_, right, left) = CartonFrame( CartonFp, Rotation.Identity );
			fpRenderer.SetIk( "hand_right", arm.ToWorld( root.ToLocal( right ) ) );
			fpRenderer.SetIk( "hand_left", arm.ToWorld( root.ToLocal( left ) ) );
		}
		else
		{
			fpRenderer.SetIk( "hand_right", arm.ToWorld( root.ToLocal( new Transform( phone ? PhoneFpWrist : hammer ? HammerFpWrist : Wrist, hand ) ) ) );
			fpRenderer.SetIk( "hand_left", arm.ToWorld( root.ToLocal( new Transform( new Vector3( -40, 0, -20 ), Rotation.Identity ) ) ) );
		}

		// Une vingtaine d'images pour que l'IK et les vêtements se posent, puis on fige.
		if ( ++fpPoseFrames < 25 )
			return;

		foreach ( var bone in fpArm.GetAllObjects( false ).Where( x => x.Flags.HasFlag( GameObjectFlags.Bone ) ) )
			bone.Flags |= GameObjectFlags.ProceduralBone;
		if ( IsHammer( fpModelHeld ) )
			CurlFingers( HammerCurl, HammerThumbCurl, HammerFpThumbAxis );
		else
			CurlFingers( IsPhone( fpModelHeld ) ? PhoneCurl : FpCurl );
		foreach ( var wear in fpArm.GetComponentsInChildren<SkinnedModelRenderer>( true ) )
			wear.RenderType = ModelRenderer.ShadowRenderType.Off;
		// Les os n'existent pas toujours à la création : la clé se pose au plus tard ici.
		if ( !fpKey.IsValid() && fpRenderer.GetBoneObject( "hand_R" ) is { } palm )
			fpKey = NewKey( palm, ModelRenderer.ShadowRenderType.Off, fpModel );
		if ( fpKey.IsValid() )
		{
			if ( IsPhone( fpModelHeld ) )
				PlacePhone( fpKey );
			else if ( IsHammer( fpModelHeld ) )
			{
				PlaceHammer( fpKey );
				// Un peu à droite de l'image, pour que le manche soit bien dans le poing (Julien) :
				// à la première personne seulement, la tenue du corps est validée telle quelle.
				if ( fpKey.Parent is { } grip && camera.IsValid() )
					fpKey.LocalPosition += grip.WorldRotation.Inverse * (camera.WorldRotation.Right * HammerFpShift);
			}
			else if ( IsCarton( fpModelHeld ) )
				fpKey.WorldTransform = fpArm.WorldTransform.ToWorld( new Transform( FpRoot ).ToLocal( CartonFrame( CartonFp, Rotation.Identity ).Box ) );
			else if ( PlaceBetweenThumbAndIndex( fpKey, fpRenderer, fpArm.WorldRotation.Up, IsCash( fpModelHeld ) ) && IsProp( fpModelHeld ) )
				PlaceRealProp( fpKey, fpModelHeld );
			fpKey.Enabled = true;
		}
		fpFrozen = true;
		fpShownSince = 0;
		PlaceArm( 1, 0 );
	}

	/// <summary>
	/// Les doigts se referment sur la clé : chaque phalange tourne autour de l'axe index →
	/// auriculaire de la main (Y de « hand_R »), vers la paume (-Z). Le citoyen n'a pas de
	/// paramètre qui ferme la main sans arme (« holdtype_pose_hand » ne change rien ici).
	/// </summary>
	static void CurlFingers( float curl, float? thumbCurl = null, Vector3? thumbAxis = null )
	{
		var palm = fpRenderer.GetBoneObject( "hand_R" );
		if ( !palm.IsValid() )
			return;
		foreach ( var finger in new[] { "index", "middle", "ring", "pinky", "thumb" } )
		{
			// Le pouce ne se plie pas comme les doigts : il vient par-dessus la clé, autour de
			// l'axe des doigts (X de la main).
			var thumb = finger == "thumb";
			// Le marteau : le pouce refermé sur le manche, autour de son propre axe (HammerThumbAxis).
			var axis = thumb ? palm.WorldRotation * (thumbAxis ?? Vector3.Forward).Normal : palm.WorldRotation.Left;
			var angle = thumb ? thumbCurl ?? FpThumbCurl : curl;
			for ( var i = 0; i <= 2; i++ )
			{
				if ( fpRenderer.GetBoneObject( $"finger_{finger}_{i}_R" ) is not { } bone )
					continue;
				bone.WorldRotation = Rotation.FromAxis( axis, angle ) * bone.WorldRotation;
			}
		}
	}

	/// <summary>La copie : le corps, habillé par un Dresser comme le personnage, et la clé, sous la caméra.</summary>
	static void BuildArm( RoleplayPlayer player, SkinnedModelRenderer body, GameObject camera )
	{
		if ( fpArm.IsValid() )
			fpArm.Destroy();

		// Sous la caméra, pas sous le corps : le contrôleur cache en première personne tout ce
		// qui est sous le corps.
		fpArm = new GameObject( camera, true, "Bras (1re personne)" );
		fpArm.Flags |= GameObjectFlags.NotNetworked | GameObjectFlags.NotSaved;
		fpArm.LocalPosition = FpRoot;
		fpRenderer = fpArm.Components.Create<SkinnedModelRenderer>();
		fpRenderer.Model = body.Model;
		fpRenderer.CreateBoneObjects = true;
		fpRenderer.RenderType = ModelRenderer.ShadowRenderType.ShadowsOnly;
		fpAppearance = player.AppearanceJson;
		fpModelHeld = HeldOf( player );
		fpModel = ModelOf( fpModelHeld );
		fpPoseFrames = 0;
		fpFrozen = false;

		// La clé dans la paume (os « hand_R », figé avec le reste), comme sur le corps.
		fpKey = fpRenderer.GetBoneObject( "hand_R" ) is { } grip ? NewKey( grip, ModelRenderer.ShadowRenderType.Off, fpModel ) : null;
		if ( fpKey.IsValid() )
			fpKey.Enabled = false;

		if ( string.IsNullOrWhiteSpace( player.AppearanceJson ) )
			return;
		var container = ClothingContainer.CreateFromJson( player.AppearanceJson );
		CharacterRules.EnsureSkin( container, player.Sex );
		var dresser = fpArm.Components.Create<Dresser>();
		dresser.BodyTarget = fpRenderer;
		dresser.Source = Dresser.ClothingSource.Manual;
		dresser.UpdateAppearance( container );
		_ = TintedSkin.DressAsync( dresser, container );
	}

	static bool rightWasDown;

	static void LocalInput( RoleplayPlayer player )
	{
		// Les billets : le clic droit ouvre la fenêtre « donner · poser au sol ». Pas par
		// Input.Pressed ni avec le délai de la télécommande : il fallait parfois cliquer deux
		// fois (Julien). L'état du bouton est comparé d'une image à l'autre, relevé même quand
		// un menu est ouvert, pour qu'un clic qui le referme ne compte pas comme un nouveau.
		var cash = IsCash( player.HeldKey );
		var right = Input.Down( "attack2" );
		var rightPressed = right && !rightWasDown;
		rightWasDown = right;

		if ( UiLock.IsLocked || SWB.Base.Weapon.InputBlocked?.Invoke( null ) == true )
			return;
		if ( cash )
		{
			if ( rightPressed )
				CashMenu.Open();
			return;
		}

		// Un prop en main : le clic gauche le pose (le fantôme de PropMenu ; pendant la pose,
		// c'est PropMenu qui prend les clics).
		if ( IsProp( player.HeldKey ) && DebugHeld is null )
		{
			if ( Input.Pressed( "attack1" ) && player.Scene.GetAllComponents<PropMenu>().FirstOrDefault() is { IsPlacing: false } menu )
				menu.PlaceFromInventory( PropItems.EntryOf( player.HeldKey ) );
			return;
		}

		// La clé : le clic gauche, une pression par 0,8 s (la télécommande).
		if ( !Input.Pressed( "attack1" ) || sinceClick < 0.8f )
			return;
		sinceClick = 0;
		// Le geste part tout de suite chez soi ; l'hôte le renvoie aux autres.
		if ( RoleplayPlayer.Local is { } me )
			Pressed( me.GameObject.Id );
		VehicleManager.Current?.RequestRemoteLock();
	}
}