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:<plaque> », 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();
}
}