Static helper that renders a copy of the local player's body in first person, showing only bottoms, footwear and skin. It rebuilds the copy's pose from the real body each frame, offsets it behind the camera, and reapplies the character's clothing when appearance, sex or clothing version changes. Updated by the HUD each frame.
namespace HorizonRP;
/// <summary>
/// Les jambes à la première personne : en baissant les yeux, on voit ses jambes, son bas et ses
/// chaussures (Julien, 29/10/2026). Le contrôleur de s&box cache tout le corps à la première
/// personne (HideBodyInFirstPerson) : on n'y touche pas, et on montre à la place une copie du
/// corps calée sur ses os (BoneMergeTarget), dont la tête, le torse et les mains sont vidés par
/// leurs groupes de corps, habillée seulement du bas et des chaussures du personnage.
///
/// Chez le joueur seulement : un objet hors réseau, hors du joueur (il n'hérite donc pas de
/// l'étiquette « viewer » que la caméra exclut), sans ombre (le vrai corps en projette déjà une).
/// Mis à jour par le HUD à chaque image, comme DayNight.
/// </summary>
public static class FirstPersonLegs
{
/// <summary>Les catégories de vêtements qui se voient en baissant les yeux.</summary>
static readonly HashSet<string> LegCategories = new( StringComparer.OrdinalIgnoreCase )
{
"Bottoms", "Trousers", "Jeans", "Shorts", "Skirt", "Footwear", "Shoes", "Boots", "Sandals", "Socks",
};
/// <summary>Les groupes de corps vidés sur la copie.</summary>
static readonly string[] HiddenParts = { "Head", "Chest", "Hands" };
/// <summary>Pour un essai : montrer les jambes en toute circonstance (troisième personne, création).</summary>
public static bool DebugShow { get; set; }
/// <summary>Pour un essai : l'apparence à montrer à la place de celle du personnage.</summary>
public static string DebugAppearance { get; set; }
static string AppearanceOf( RoleplayPlayer me ) => DebugAppearance ?? me?.AppearanceJson;
/// <summary>Le recul de la copie derrière le vrai corps (unités), réglable à chaud.</summary>
public static float BackOffset { get; set; } = 18f;
/// <summary>L'étiquette de la copie : les autres caméras l'excluent.</summary>
public const string Tag = "jambes_1p";
static GameObject holder;
static SkinnedModelRenderer legs;
static Dresser dresser;
static string dressedJson;
static string dressedSex;
static int dressedVersion = -1;
static bool dressing;
public static void Update( Scene scene )
{
var me = RoleplayPlayer.Local;
var controller = me?.GetComponent<PlayerController>();
var body = controller?.Renderer;
var wanted = body.IsValid() && !string.IsNullOrWhiteSpace( AppearanceOf( me ) )
&& (DebugShow || (!me.NeedsCharacter && !CharacterCreator.Framing && !controller.ThirdPerson
// Assis (véhicule, chaise), le recul mettrait les jambes dans le dossier.
&& me.GetComponentInParent<BaseChair>() is null));
if ( !wanted )
{
if ( holder.IsValid() && holder.Enabled )
holder.Enabled = false;
return;
}
if ( !holder.IsValid() || holder.Scene != scene )
Build( scene );
holder.Enabled = true;
// Reculées sous la caméra : le bassin passe derrière le regard et les pieds apparaissent
// devant (sinon, en baissant les yeux, on voyait le haut du pantalon par en dessus). Le
// calage sur les os (BoneMergeTarget) recopie leurs positions dans le monde et annulait ce
// recul : la pose est donc recopiée os par os, décalée.
// L'habillage de s&box (Dresser) recale lui-même la copie sur le corps : on le défait.
if ( legs.BoneMergeTarget is not null )
legs.BoneMergeTarget = null;
if ( legs.UseAnimGraph )
legs.UseAnimGraph = false;
var offset = body.WorldRotation.Backward * BackOffset;
holder.WorldTransform = body.WorldTransform.WithPosition( body.WorldPosition + offset );
CopyPose( body );
if ( !dressing && (dressedJson != AppearanceOf( me ) || dressedSex != me.Sex
|| dressedVersion != WorkshopPackages.ClothingVersion) )
_ = Dress( me );
}
static void Build( Scene scene )
{
holder = new GameObject( true, "Jambes à la première personne" );
holder.Flags |= GameObjectFlags.NotNetworked | GameObjectFlags.NotSaved;
// Pour la caméra du jeu seulement : reculée, la copie se verrait derrière le joueur dans
// toute autre caméra (le selfie du téléphone, une caméra d'essai).
holder.Tags.Add( Tag );
legs = holder.Components.Create<SkinnedModelRenderer>();
legs.RenderType = ModelRenderer.ShadowRenderType.Off;
legs.UseAnimGraph = false;
dresser = holder.Components.Create<Dresser>();
dresser.BodyTarget = legs;
dresser.Source = Dresser.ClothingSource.Manual;
dressedJson = null;
}
/// <summary>
/// La pose du vrai corps, os par os (même squelette, homme ou femme), dans le repère du modèle :
/// SetBoneTransform les attend là, et le recul de l'objet décale alors toute la copie.
/// </summary>
static void CopyPose( SkinnedModelRenderer body )
{
var bones = legs.Model?.Bones;
var source = body.Model?.Bones;
if ( bones is null || source is null )
return;
foreach ( var bone in bones.AllBones )
{
var match = source.GetBone( bone.Name );
if ( match is null || !body.TryGetBoneTransform( in match, out var t ) )
continue;
legs.SetBoneTransform( in bone, body.WorldTransform.ToLocal( t ) );
}
}
static async Task Dress( RoleplayPlayer me )
{
dressing = true;
try
{
dressedJson = AppearanceOf( me );
dressedSex = me.Sex;
dressedVersion = WorkshopPackages.ClothingVersion;
// La peau (qui fixe le corps et son teint) et ce qui habille les jambes, rien d'autre.
var container = ClothingContainer.CreateFromJson( dressedJson );
container.Clothing.RemoveAll( x => x.Clothing is null
|| (!x.Clothing.HasHumanSkin && !LegCategories.Contains( x.Clothing.Category.ToString() )) );
CharacterRules.EnsureSkin( container, me.Sex );
legs.Model = Model.Load( RoleplayPlayer.BodyModelFor( me.Sex ) );
await TintedSkin.DressAsync( dresser, container );
if ( !legs.IsValid() )
return;
// Après l'habillage, qui pose lui-même les groupes de corps.
foreach ( var part in HiddenParts )
{
var choices = legs.Model?.Parts.Get( part )?.Choices;
var empty = choices?.Select( ( x, i ) => (x, i) ).FirstOrDefault( c => string.IsNullOrEmpty( c.x.Name ), (null, -1) ).i ?? -1;
if ( empty >= 0 )
legs.SetBodyGroup( part, empty );
}
// Chaque vêtement porte aussi l'étiquette : une caméra n'exclut pas les enfants d'un objet
// étiqueté (le jean restait visible dans le selfie).
foreach ( var renderer in holder.GetComponentsInChildren<ModelRenderer>( true ) )
{
renderer.RenderType = ModelRenderer.ShadowRenderType.Off;
renderer.GameObject.Tags.Add( Tag );
}
}
catch ( Exception e )
{
Log.Warning( $"[Horizon] Jambes à la première personne : {e.Message}" );
}
finally
{
dressing = false;
}
}
}