Weapon holder component that wires the SWB weapon system to the roleplay player and controller. Syncs the active weapon, follows the hand bone on every machine, pulls reserve ammo from the inventory, reports clip changes, and drives weapon sounds, view model state and glove materials.
using SWB.Shared;
namespace HorizonRP;
/// <summary>
/// Ce que simple-weapon-base (SWB) attend du joueur qui tient l'arme
/// (<see cref="IPlayerBase"/>), branché sur notre PlayerController et notre
/// RoleplayPlayer au lieu du PlayerBase de leur démo. Posé sur le prefab du joueur.
///
/// L'arme vit chez son propriétaire, comme dans SWB : c'est sa machine qui vise,
/// tire et recharge. Tout ce qui compte passe par l'hôte (<see cref="WeaponSystem"/>) :
/// les munitions sont prises dans l'inventaire par lui, et un tir qui touche n'est
/// qu'une demande qu'il revérifie avant d'ôter de la vie.
/// </summary>
[Title( "Horizon RP – Porteur d'arme" )]
[Icon( "my_location" )]
public sealed class WeaponHolder : Component, IPlayerBase
{
/// <summary>Hôte : le numéro de série de l'arme sortie, ou null.</summary>
[Sync( SyncFlags.FromHost )] public string ActiveSerial { get; set; }
[Sync( SyncFlags.FromHost )] public string ActiveWeaponId { get; set; }
[Sync( SyncFlags.FromHost )] public GameObject ActiveObject { get; set; }
public static WeaponHolder Local => RoleplayPlayer.Local?.GetComponent<WeaponHolder>();
RoleplayPlayer Player => GetComponent<RoleplayPlayer>();
PlayerController Controller => GetComponent<PlayerController>();
public SWB.Base.Weapon ActiveWeapon => ActiveObject?.GetComponent<SWB.Base.Weapon>( true );
public WeaponEntry ActiveEntry => WeaponSystem.Current?.Get( ActiveWeaponId );
// IPlayerBase
Guid IPlayerBase.Id => GameObject.Id;
public CameraComponent ViewModelCamera { get; set; }
public CameraComponent Camera => Scene.Camera;
public bool IsFirstPerson => Controller is { } c && !c.ThirdPerson;
public Vector3 Velocity => Controller?.Velocity ?? Vector3.Zero;
public bool IsCrouching => Controller?.IsDucking ?? false;
public bool IsRunning => !IsProxy && Controller is { } c && c.IsOnGround && Input.Down( "Run" ) && c.Velocity.WithZ( 0 ).Length > 160;
public bool IsOnGround => Controller?.IsOnGround ?? true;
public bool IsClimbingLadder => Controller?.IsClimbing ?? false;
public bool IsAlive => Player.IsValid() && Player.Health > 0;
public bool IsBot { get; set; }
public Angles EyeAngles => Controller?.EyeAngles ?? WorldRotation.Angles();
public Vector3 EyePos => Controller?.EyePosition ?? WorldPosition + Vector3.Up * 64;
public float InputSensitivity { get; set; } = 1f;
// Pas d'initialiseur : le générateur de code de s&box en ferait un attribut, qui exige une constante.
public float FieldOfView { get; set; }
protected override void OnAwake() => FieldOfView = Screen.CreateVerticalFieldOfView( Preferences.FieldOfView );
HoldTypes holdType;
public HoldTypes HoldType { set => holdType = value; }
/// <summary>Les balles de réserve : l'objet de munitions de l'arme sortie, dans l'inventaire.</summary>
public int AmmoCount( string type )
{
if ( !Player.IsValid() || string.IsNullOrEmpty( type ) )
return 0;
var slots = Networking.IsHost ? Player.Inventory : Player.PublishedInventory;
return slots.Where( x => x.Item == type ).Sum( x => x.Count ) - pendingAmmo;
}
/// <summary>
/// Le rechargement prend des balles : on les compte tout de suite (l'arme recharge
/// sans attendre), et l'hôte les ôte de l'inventaire. Tant que l'inventaire publié
/// ne les a pas retirées, elles ne comptent plus comme réserve.
/// </summary>
public int TakeAmmo( string type, int amount )
{
var taken = Math.Clamp( AmmoCount( type ), 0, amount );
if ( taken > 0 )
{
pendingAmmo += taken;
pendingSince = 0;
WeaponSystem.Current?.RequestTakeAmmo( taken );
}
return taken;
}
int pendingAmmo;
RealTimeSince pendingSince;
string lastInventory;
public void TriggerAnimation( Animations animation )
{
Controller?.Renderer?.Set( animation == Animations.Reload ? "b_reload" : "b_attack", true );
if ( animation == Animations.Reload )
PlayReloadSounds();
}
/// <summary>
/// Les sons du rechargement, aux temps où ils tombent (fraction de la durée) : chargeur
/// sorti, chargeur mis, culasse. Empruntés aux armes de la démo SWB, par catégorie.
/// </summary>
static (float At, string Sound)[] ReloadSoundsOf( string category ) => category switch
{
"Pistolet" => new[] { (0.15f, "colt/sounds/colt.clipout"), (0.55f, "colt/sounds/colt.clipin"), (0.8f, "colt/sounds/clot.slide") },
"Pistolet-mitrailleur" => new[] { (0.2f, "veresk/sounds/veresk.clipout"), (0.55f, "veresk/sounds/veresk.clipin"), (0.8f, "veresk/sounds/veresk.boltfull") },
"Fusil de précision" => new[] { (0.2f, "l96a1/sounds/l96a1.clipout"), (0.5f, "l96a1/sounds/l96a1.clipin"), (0.62f, "l96a1/sounds/l96a1.cliplock") },
"Fusil à pompe" => new[] { (0.2f, "remington/sounds/remington.insert"), (0.35f, "remington/sounds/remington.insert"), (0.5f, "remington/sounds/remington.insert"), (0.65f, "remington/sounds/remington.insert"), (0.85f, "remington/sounds/remington.pumpfull") },
_ => new[] { (0.2f, "scarh/sounds/scar.magout"), (0.55f, "scarh/sounds/scar.magin"), (0.8f, "scarh/sounds/scar.boltpull") },
};
/// <summary>
/// Les modèles de Facepunch (prefabs hz_*) n'ont aucun son dans leurs animations,
/// contrairement à ceux de SWB, que le moteur joue d'eux-mêmes : on les joue ici, sur
/// chaque machine (SWB appelle TriggerAnimation partout), tant que l'arme reste en main.
/// </summary>
async void PlayReloadSounds()
{
// Les armes FA:S (hz_fas2_*) portent leurs sons dans leurs animations.
if ( ActiveEntry is not { } entry || entry.Prefab?.StartsWith( "prefabs/weapons/hz_" ) != true || entry.Prefab.StartsWith( "prefabs/weapons/hz_fas2_" ) )
return;
var serial = ActiveSerial;
var duration = Math.Max( 0.5f, entry.ReloadTime );
var elapsed = 0f;
foreach ( var (at, path) in ReloadSoundsOf( entry.Category ) )
{
var wait = at * duration - elapsed;
if ( wait > 0 )
await GameTask.DelaySeconds( wait );
elapsed = at * duration;
if ( !this.IsValid() || ActiveSerial != serial )
return;
var sound = ResourceLibrary.Get<SoundEvent>( $"weapons/swb/{path}.sound" );
if ( sound is null )
continue;
var handle = Sound.Play( sound, ActiveObject.IsValid() ? ActiveObject.WorldPosition : WorldPosition );
// Comme SWB pour les sons d'animation entendus chez les autres : plus bas, plus loin.
if ( handle is not null && IsProxy )
{
handle.Volume = 0.5f;
handle.Distance = 7500f;
}
}
}
public void ApplyEyeAnglesOffset( Angles offset )
{
if ( Controller is { } c )
c.EyeAngles = (c.EyeAngles + offset).WithPitch( Math.Clamp( (c.EyeAngles + offset).pitch, -89f, 89f ) );
}
/// <summary>
/// SWB rattache l'arme à l'os de la main. Pas l'arme elle-même : c'est un objet en réseau, à
/// son tireur, et l'os est un objet local, propre à chaque machine. Le tireur envoyait aux
/// autres une position relative à un parent qu'ils n'ont pas : sur le serveur dédié, l'arme
/// était invisible dans sa main et flottait au-dessus de sa tête, et ses tirs se jouaient à
/// sa position, très loin — un « petit bruit de silencieux » (Julien, 30/10/2026). Elle suit
/// donc l'os sur chaque machine (<see cref="FollowHand"/>), sans changer de parent, posée juste
/// avant le rendu : ce que le réseau envoie de sa position ne compte plus. Le reste
/// (accessoires, objets locaux) se rattache comme avant.
/// </summary>
public void ParentToBone( GameObject @object, string boneName, bool deleteOnFail = true, Action<GameObject> onFail = null )
{
if ( @object.IsValid() && @object.Components.Get<SWB.Base.Weapon>( true ) is not null )
{
handBone = boneName;
FollowHand();
return;
}
ModelUtil.ParentToBone( @object, Controller?.Renderer, boneName, deleteOnFail: deleteOnFail, onFail: onFail );
}
/// <summary>L'os que suit l'arme sortie (« hold_R »), donné par SWB.</summary>
string handBone = "hold_R";
/// <summary>
/// Sur chaque machine, à chaque image : l'arme sortie se pose sur l'os de la main de cette
/// copie du corps, avec la prise réglée pour son modèle (WeaponRules.GripOf).
/// </summary>
void FollowHand()
{
if ( ActiveObject is not { } weapon || !weapon.IsValid() || Controller?.Renderer is not { } body )
return;
if ( !body.TryGetBoneTransform( handBone, out var hand ) )
return;
var model = weapon.Components.Get<SWB.Base.Weapon>( true )?.WorldModelRenderer?.Model;
var local = model is not null && WeaponRules.GripOf( model.ResourcePath ) is { } grip
? new global::Transform( grip.Position, grip.Angles.ToRotation(), grip.Scale )
: new global::Transform( Vector3.Zero, Rotation.Identity, 1f );
weapon.WorldTransform = hand.ToWorld( local );
}
protected override void OnPreRender() => FollowHand();
/// <summary>Pas de tremblement d'écran : la caméra appartient au PlayerController.</summary>
public void ShakeScreen( ScreenShake screenShake ) { }
/// <summary>
/// SWB appelle ceci sur la machine du tireur, quand sa balle nous touche. Rien
/// n'est retiré ici : la demande part à l'hôte, qui décide.
/// </summary>
public void OnDamage( in Sandbox.DamageInfo info )
{
if ( info.Attacker is not { } attacker || attacker == GameObject || attacker.IsProxy )
return;
var headshot = info is SWB.Shared.DamageInfo swb && swb.Tags is not null && swb.Tags.Has( "head" );
WeaponSystem.Current?.RequestHit( GameObject.Id, info.Position, headshot );
}
// Chaque image
float baseSensitivity = -1;
protected override void OnUpdate()
{
var armed = ActiveObject.IsValid();
// Sur toutes les machines : le corps tient l'arme.
if ( Controller?.Renderer is { } body )
{
// Une clé de véhicule en main : sa pose est posée par VehicleKeySystem.
if ( armed || string.IsNullOrEmpty( Player?.HeldKey ) )
body.Set( "holdtype", armed ? (int)holdType : 0 );
}
if ( armed )
{
ApplyDefaultParts();
FollowHand();
}
if ( IsProxy )
return;
// La réserve en attente est soldée dès que l'inventaire publié a changé (ou au bout d'une seconde).
if ( pendingAmmo > 0 && (Player.InventoryJson != lastInventory || pendingSince > 1f) )
pendingAmmo = 0;
lastInventory = Player.InventoryJson;
// A (q physique) : l'arme en main tombe au sol, avec sa série et ses balles.
if ( armed && Input.Pressed( "Drop" ) && !UiLock.IsLocked )
WeaponSystem.Current?.RequestDropActive();
if ( Controller is not { } c )
return;
// Visée et lunette : SWB change la sensibilité et le champ de vision.
if ( baseSensitivity < 0 )
baseSensitivity = c.LookSensitivity;
c.LookSensitivity = armed ? baseSensitivity * InputSensitivity : baseSensitivity;
// Le champ de vision : celui que SWB calcule, avec ou sans arme. Sans arme, la
// caméra prenait les préférences brutes, nettement plus larges : sortir une arme
// faisait un saut. On garde donc toujours la valeur de SWB, et sans arme celle
// qu'il donne au repos (sans visée).
c.UseFovFromPreferences = false;
if ( !armed )
{
InputSensitivity = 1f;
FieldOfView = Screen.CreateVerticalFieldOfView( Preferences.FieldOfView );
}
// Pendant la création du personnage, ou au salon du garage de service, la caméra cadre elle-même (champ de portrait).
if ( Scene.Camera is { } camera && !CharacterCreator.Framing && !PoliceShowroom.Framing )
camera.FieldOfView = FieldOfView;
if ( armed )
{
DriveViewModel();
ApplyBareHands();
}
// Les balles du chargeur partent à l'hôte quand elles changent : il les range avec l'arme.
if ( ActiveWeapon is { } weapon && weapon.Primary is { } primary && primary.Ammo != lastClip && sinceClip > 0.5f )
{
lastClip = primary.Ammo;
sinceClip = 0;
WeaponSystem.Current?.ReportClip( ActiveSerial, primary.Ammo );
}
}
int lastClip = -1;
RealTimeSince sinceClip;
/// <summary>
/// Les armes de Facepunch (bras humains de s&box) visent, courent et montrent
/// un chargeur vide par leur propre graphe d'animation : on lui passe l'état de SWB.
/// Les modèles de la démo SWB, eux, n'ont pas ces paramètres et ne sont pas touchés.
/// </summary>
void DriveViewModel()
{
if ( ActiveWeapon is not { } weapon || weapon.ViewModelRenderer is not { } vm || vm.Model?.AnimGraph is not { } graph )
return;
if ( !graph.TryGetParameterIndex( "ironsights", out _ ) )
return;
vm.Set( "ironsights", weapon.IsAiming ? 1 : 0 );
vm.Set( "b_sprint", IsRunning );
vm.Set( "b_grounded", IsOnGround );
vm.Set( "b_empty", weapon.Primary?.Ammo == 0 );
vm.Set( "move_bob", Math.Clamp( Velocity.WithZ( 0 ).Length / 300f, 0, 1 ) );
}
// Les mains des armes SWB
/// <summary>Le modèle de vue déjà rhabillé, pour ne le faire qu'une fois par arme sortie.</summary>
SkinnedModelRenderer bareHands;
/// <summary>
/// Les modèles de vue de la démo SWB ont des mains gantées (matériau « gloves »),
/// modelées en gants. Faute de mieux, on leur met la peau du personnage : une texture
/// par peau réaliste (human01 à human11, Assets/weapons/swb/hands/textures/peau_*.vmat).
/// Les armes de Facepunch ont les vrais bras humains et ne sont pas touchées.
/// </summary>
void ApplyBareHands()
{
var renderer = ActiveWeapon?.ViewModelRenderer;
if ( !renderer.IsValid() || renderer == bareHands || renderer.Model is null )
return;
bareHands = renderer;
var material = Material.Load( $"weapons/swb/hands/textures/peau_{SkinName()}.vmat" );
if ( material is null )
return;
var materials = renderer.Model.Materials.ToList();
for ( var i = 0; i < materials.Count; i++ )
{
if ( materials[i]?.Name?.Contains( "gloves", StringComparison.OrdinalIgnoreCase ) == true )
renderer.Materials.SetOverride( i, material );
}
}
/// <summary>« human03 » : la peau réaliste que porte le personnage (human01 par défaut).</summary>
string SkinName()
{
var json = Player?.AppearanceJson;
if ( !string.IsNullOrEmpty( json ) )
{
var container = ClothingContainer.CreateFromJson( json );
var skin = container?.Clothing.FirstOrDefault( x => x.Clothing?.HasHumanSkin == true )?.Clothing;
var name = System.IO.Path.GetFileNameWithoutExtension( skin?.ResourcePath ?? "" );
if ( name.StartsWith( "human" ) )
return name;
}
return "human01";
}
/// <summary>
/// Sur toutes les machines : un modèle venu d'un autre jeu (Rust) est recalé dans la
/// main, une fois que SWB l'a accroché à l'os (il le pose pile sur l'os, sans réglage).
/// </summary>
/// <summary>Les modèles déjà réglés, pour ne le faire qu'une fois par arme sortie.</summary>
SkinnedModelRenderer partsDoneView, partsDoneWorld;
/// <summary>
/// Une pièce que le modèle de Facepunch montre d'origine (la lunette du M700) se
/// cache tant qu'elle n'est pas montée : SWB n'applique le choix « par défaut »
/// d'un accessoire qu'en le démontant.
/// </summary>
void ApplyDefaultParts()
{
if ( ActiveWeapon is not { } weapon )
return;
foreach ( var renderer in new[] { weapon.ViewModelRenderer, weapon.WorldModelRenderer } )
{
if ( !renderer.IsValid() || renderer == partsDoneView || renderer == partsDoneWorld )
continue;
// Un groupe que porte un accessoire monté n'est pas touché : le point rouge et la
// lunette FA:S partagent le groupe des viseurs, et remettre le choix par défaut du
// premier effaçait la seconde, montée (rien sur l'arme, mais la visée de la lunette).
var portes = weapon.Attachments.Where( x => x.Equipped ).Select( x => x.BodyGroup )
.Concat( weapon.Attachments.Where( x => x.Equipped ).OfType<IFas2Extras>().SelectMany( x => x.ExtraGroups.Select( g => g.Group ) ) )
.ToHashSet();
foreach ( var part in weapon.Attachments.Where( x => !x.Enable && !x.Equipped && !string.IsNullOrEmpty( x.BodyGroup ) && !portes.Contains( x.BodyGroup ) ) )
renderer.SetBodyGroup( part.BodyGroup, part.BodyGroupDefault );
if ( renderer == weapon.ViewModelRenderer )
partsDoneView = renderer;
else
partsDoneWorld = renderer;
}
}
}