The host-side weapon system for Horizon RP. It syncs a weapon and accessory catalog from the backend, draws and holsters inventory weapons with per-serial ammo and attachments, validates player and vehicle hit RPCs, drops weapons when a player goes down, and mounts the weapon packages at startup.
using SWB.Base;
namespace HorizonRP;
/// <summary>
/// Les armes, façon FiveM. Une arme est un objet d'inventaire (« arme:colt ») qui
/// garde sa série, ses balles et ses accessoires ; on la sort et on la range par
/// Utiliser ou une case rapide. L'hôte la fait apparaître dans la main du joueur
/// (prefab de simple-weapon-base, aux valeurs du catalogue) et la donne à sa
/// machine, qui vise et tire.
///
/// L'hôte tient le catalogue (lu dans l'API, relu quand sa version change, réglable
/// depuis le panneau et le menu admin) et revérifie tout ce qui compte : les balles
/// prises dans l'inventaire, et chaque tir qui touche (arme sortie, cadence,
/// distance, ligne de vue). Les dégâts sont ceux du catalogue, jamais ceux que le
/// tireur annonce.
/// </summary>
[Title( "Horizon RP – Armes" )]
[Icon( "my_location" )]
public sealed partial class WeaponSystem : Component, Component.INetworkListener
{
public static WeaponSystem Current { get; private set; }
[Sync( SyncFlags.FromHost )] public string CatalogJson { get; set; }
/// <summary>Les réglages des accessoires (nom, prix, vente), lus avec le catalogue des armes.</summary>
[Sync( SyncFlags.FromHost )] public string AccessoriesJson { get; set; }
List<WeaponEntry> catalog = new();
List<AccessoryEntry> accessories = new();
List<AccessoryEntry> publishedAccessories = new();
string parsedAccessories;
string version;
bool loading;
List<WeaponEntry> published = new();
string parsedJson;
protected override void OnAwake()
{
Current = this;
Weapon.InputBlocked = IsInputBlocked;
}
/// <summary>
/// SWB lit le tir directement au clavier : on le coupe quand un menu est ouvert,
/// pendant une pose de prop ou d'objet, et avec le physgun en main. Une méthode
/// nommée et pas une lambda : le rechargement à chaud de l'éditeur ne sait pas
/// toujours remplacer une lambda, et l'ancienne lève alors une exception à chaque
/// appel (la molette de l'inventaire ne marchait plus).
/// </summary>
static bool IsInputBlocked( SWB.Shared.IPlayerBase _ )
{
var scene = Game.ActiveScene;
return UiLock.IsLocked || StaffTool.Equipped
|| (scene is not null && (scene.GetAllComponents<PropMenu>().Any( x => x.IsPlacing )
|| scene.GetAllComponents<AdminMenu>().Any( x => x.Building )));
}
/// <summary>Le même blocage, pour les touches que le jeu lit lui-même (la lampe des armes FA:S).</summary>
public static bool InputBlockedNow => IsInputBlocked( null );
protected override void OnDestroy()
{
if ( Current == this )
Current = null;
}
/// <summary>
/// Les paquets des armes de Facepunch et des bras humains (aussi dans PackageReferences
/// du .sbproj). Chaque machine les monte au démarrage : un modèle d'arme qui n'est pas
/// monté quand l'arme sort reste une boîte orange.
/// </summary>
static readonly string[] Packages =
{
"facepunch.v_first_person_arms_human",
"facepunch.v_usp", "facepunch.w_usp", "facepunch.v_mp5", "facepunch.w_mp5",
"facepunch.v_m4a1", "facepunch.w_m4a1", "facepunch.v_m700", "facepunch.w_m700",
"facepunch.v_spaghellim4", "facepunch.w_spaghellim4",
// Rust (Facepunch) : modèle à la troisième personne seulement, essai du 28/10/2026.
"rust.w_ak47",
};
protected override void OnStart()
{
if ( !Scene.IsEditor )
{
_ = MountPackages();
_ = WorkshopPackages.MountAll();
}
}
static async Task MountPackages()
{
foreach ( var ident in Packages )
{
try
{
var package = await Package.Fetch( ident, false );
if ( package is not null )
await package.MountAsync();
}
catch ( Exception e )
{
Log.Warning( $"[Horizon] Paquet d'arme {ident} : {e.Message}" );
}
}
}
public IReadOnlyList<WeaponEntry> Catalog
{
get
{
if ( Networking.IsHost )
return catalog;
if ( parsedJson != CatalogJson )
{
parsedJson = CatalogJson;
published = string.IsNullOrEmpty( CatalogJson ) ? new() : Json.Deserialize<List<WeaponEntry>>( CatalogJson ) ?? new();
}
return published;
}
}
public WeaponEntry Get( string id ) => id is null ? null : Catalog.FirstOrDefault( x => x.Id == id );
/// <summary>Vide sans base : les accessoires gardent alors les valeurs du code.</summary>
public IReadOnlyList<AccessoryEntry> AccessorySettings
{
get
{
if ( Networking.IsHost )
return accessories;
if ( parsedAccessories != AccessoriesJson )
{
parsedAccessories = AccessoriesJson;
publishedAccessories = string.IsNullOrEmpty( AccessoriesJson ) ? new() : Json.Deserialize<List<AccessoryEntry>>( AccessoriesJson ) ?? new();
}
return publishedAccessories;
}
}
protected override void OnUpdate()
{
// Reposé à chaque image, sur toutes les machines : après un rechargement à chaud,
// le délégué gardé par SWB peut pointer sur l'ancienne version du code.
Weapon.InputBlocked = IsInputBlocked;
if ( Scene.IsEditor || !Networking.IsHost || !Networking.IsActive )
return;
if ( version is null && !loading )
_ = LoadCatalog();
EnsureSettings();
}
/// <summary>Les réglages de SWB (une seule fois par partie) : rechargement automatique, sans aide à la visée ni menu Q.</summary>
void EnsureSettings()
{
if ( WeaponSettings.Instance.IsValid() )
return;
var go = new GameObject( true, "Horizon RP – réglages des armes" );
var settings = go.Components.Create<WeaponSettings>();
settings.Customization = false;
settings.AimAssist = false;
settings.AutoReload = true;
go.NetworkSpawn();
}
// Catalogue (hôte)
async Task LoadCatalog()
{
loading = true;
try
{
if ( !Backend.IsOnline )
{
catalog = WeaponRules.Defaults();
version = "local";
}
else
{
var response = await Backend.LoadWeapons();
if ( response?.Weapons is null )
{
Log.Warning( "[Horizon] Catalogue des armes indisponible (API injoignable)" );
return;
}
catalog = response.Weapons;
accessories = response.Accessories ?? new();
version = response.Version ?? "";
}
CatalogJson = Json.Serialize( catalog );
AccessoriesJson = Json.Serialize( accessories );
Log.Info( $"[Horizon] {catalog.Count} arme(s) au catalogue" );
}
finally
{
loading = false;
}
}
/// <summary>Hôte, à chaque échange avec l'API : un catalogue changé ailleurs (panneau web) est relu.</summary>
public void CheckVersion( string apiVersion )
{
if ( !string.IsNullOrEmpty( apiVersion ) && version is not null && version != "local" && apiVersion != version && !loading )
_ = LoadCatalog();
}
/// <summary>Le menu admin en jeu règle une arme : l'API l'enregistre, puis le catalogue est relu.</summary>
[Rpc.Host]
public void StaffSaveWeapon( string json )
{
var staff = Caller();
if ( staff is null || !staff.IsStaff )
return;
var edited = Json.Deserialize<WeaponEntry>( json ?? "" );
if ( edited is null || Get( edited.Id ) is null )
return;
_ = SaveWeapon( staff, edited );
}
async Task SaveWeapon( RoleplayPlayer staff, WeaponEntry edited )
{
if ( !Backend.IsOnline )
{
// Sans base : le réglage vaut pour cette partie seulement.
catalog.RemoveAll( x => x.Id == edited.Id );
catalog.Add( edited );
CatalogJson = Json.Serialize( catalog );
staff.Notify( $"{edited.Name} réglée (partie locale, non enregistrée).", "info" );
return;
}
var response = await Backend.SaveWeapon( edited, $"{staff.RpName} ({staff.SteamId})" );
if ( response?.Ok != true )
{
staff.Notify( response?.Error ?? "API injoignable : arme non enregistrée.", "erreur" );
return;
}
staff.Notify( $"{edited.Name} enregistrée au catalogue.", "info" );
await LoadCatalog();
}
/// <summary>Le menu admin en jeu règle un accessoire (nom, prix, vente) : l'API l'enregistre, puis le catalogue est relu.</summary>
[Rpc.Host]
public void StaffSaveAccessory( string json )
{
var staff = Caller();
if ( staff is null || !staff.IsStaff )
return;
var edited = Json.Deserialize<AccessoryEntry>( json ?? "" );
if ( edited is null || WeaponRules.Accessory( edited.Id ) is null )
return;
edited.Price = Math.Clamp( edited.Price, 0, 1000000 );
_ = SaveAccessory( staff, edited );
}
async Task SaveAccessory( RoleplayPlayer staff, AccessoryEntry edited )
{
if ( !Backend.IsOnline )
{
accessories.RemoveAll( x => x.Id == edited.Id );
accessories.Add( edited );
AccessoriesJson = Json.Serialize( accessories );
staff.Notify( $"{edited.Name} réglé (partie locale, non enregistrée).", "info" );
return;
}
var response = await Backend.SaveAccessory( edited, $"{staff.RpName} ({staff.SteamId})" );
if ( response?.Ok != true )
{
staff.Notify( response?.Error ?? "API injoignable : accessoire non enregistré.", "erreur" );
return;
}
staff.Notify( $"{edited.Name} enregistré au catalogue.", "info" );
await LoadCatalog();
}
// Sortir et ranger (hôte)
static WeaponHolder HolderOf( RoleplayPlayer player ) => player?.GetComponent<WeaponHolder>();
/// <summary>Ce que l'hôte sait de l'arme sortie de chaque joueur : les balles qu'elle peut avoir au plus.</summary>
sealed class Drawn
{
public int MaxClip;
public int Clip;
public RealTimeSince Window;
public int HitsInWindow;
}
readonly Dictionary<ulong, Drawn> drawn = new();
public void ToggleWeapon( RoleplayPlayer player, InventorySlot slot )
{
var holder = HolderOf( player );
if ( holder is null || player.NeedsCharacter || player.OnTheGround )
return;
var data = WeaponData.Read( slot.Data );
var wasThis = data is not null && holder.ActiveSerial == data.Serial;
Holster( player );
// Une arme sortie range la clé de véhicule tenue en main.
player.HeldKey = null;
if ( !wasThis )
Draw( player, slot );
}
void Draw( RoleplayPlayer player, InventorySlot slot )
{
var holder = HolderOf( player );
var entry = Get( WeaponItems.WeaponOf( slot.Item ) );
if ( entry is null || !entry.Active )
{
player.Notify( "Cette arme n'est plus en service.", "erreur" );
return;
}
// Menotté ou en prison (Police/) : pas d'arme.
if ( player.Frozen || player.Restrained || player.Cuffed || player.Jailed )
{
player.Notify( player.Jailed ? "Pas d'arme en prison." : "Impossible de sortir une arme maintenant.", "erreur" );
return;
}
var data = WeaponData.Read( slot.Data ) ?? new WeaponData { Serial = WeaponData.NewSerial() };
data.Ammo = Math.Clamp( data.Ammo, 0, ClipOf( entry, data ) );
// Une grenade (ou une mine) est elle-même ce qu'on lance : sortie, elle est prête, le premier
// clic la lance. Sans ça, il fallait cliquer une fois pour « recharger » (Julien). Les
// suivantes, s'il y en a dans l'inventaire, se rechargent seules après le lancer.
if ( Explosives.Get( entry.Id ) is { Mode: "lance" or "mine" } && data.Ammo <= 0 )
data.Ammo = 1;
slot.Data = data.Write();
var prefab = GameObject.GetPrefab( entry.Prefab );
if ( prefab is null )
{
player.Notify( $"Arme introuvable ({entry.Prefab}).", "erreur" );
Log.Warning( $"[Horizon] Prefab d'arme introuvable : {entry.Prefab}" );
return;
}
var go = prefab.Clone( new CloneConfig( player.WorldTransform, player.GameObject, false, $"Arme – {entry.Name}" ) );
Apply( go, entry, data );
// Chaque machine la pose elle-même sur la main de sa copie du corps, juste avant le rendu
// (WeaponHolder.FollowHand) : ce que le réseau envoie de sa position ne compte pas. Surtout
// pas de go.Network.Flags ici : avant NetworkSpawn, l'arme n'a pas d'accès réseau à elle et
// ce sont les drapeaux du JOUEUR qui changeaient — NoTransformSync figeait sa position chez
// tous les autres (essai à deux du 30/10/2026).
go.NetworkSpawn( false, player.Network.Owner );
holder.ActiveSerial = data.Serial;
holder.ActiveWeaponId = entry.Id;
holder.ActiveObject = go;
drawn[player.SteamId] = new Drawn { MaxClip = data.Ammo, Clip = data.Ammo };
go.GetComponent<Weapon>( true )?.OnCarryStart();
HorizonGame.Current?.AddLog( "arme_sortie", player.SteamId, $"{entry.Name} ({data.Serial})" );
}
/// <summary>Les valeurs du catalogue, posées avant la mise en réseau : la machine du joueur les reçoit avec l'arme.</summary>
static void Apply( GameObject go, WeaponEntry entry, WeaponData data )
{
var weapon = go.GetComponent<Weapon>( true );
if ( weapon is null )
return;
weapon.DisplayName = entry.Name;
weapon.ReloadTime = entry.ReloadTime;
weapon.Mobility = entry.Mobility;
if ( weapon.Primary is { } primary )
{
primary.Damage = entry.Damage;
primary.RPM = entry.Rpm;
primary.ClipSize = entry.ClipSize;
primary.Bullets = Math.Max( 1, entry.Bullets );
primary.Recoil = entry.Recoil;
primary.Spread = entry.Spread;
primary.AmmoType = entry.Ammo;
primary.Ammo = data.Ammo;
primary.InfiniteAmmo = InfiniteAmmoType.disabled;
}
// Les accessoires montés sur cette arme, et eux seuls.
foreach ( var attachment in go.Components.GetAll<SWB.Base.Attachments.Attachment>( FindMode.EverythingInSelfAndDescendants ) )
attachment.Enable = data.Att.Contains( attachment.GetType().Name );
}
/// <summary>Range l'arme sortie et garde ses balles dans la case de l'inventaire.</summary>
public void Holster( RoleplayPlayer player )
{
var holder = HolderOf( player );
if ( holder is null || holder.ActiveSerial is null )
return;
var slot = player.Inventory.FirstOrDefault( x => WeaponData.Read( x.Data )?.Serial == holder.ActiveSerial );
if ( slot is not null && drawn.TryGetValue( player.SteamId, out var state ) )
{
var data = WeaponData.Read( slot.Data );
data.Ammo = Math.Clamp( state.Clip, 0, state.MaxClip );
slot.Data = data.Write();
player.PublishInventory();
}
if ( holder.ActiveObject.IsValid() )
holder.ActiveObject.Destroy();
holder.ActiveObject = null;
holder.ActiveSerial = null;
holder.ActiveWeaponId = null;
drawn.Remove( player.SteamId );
}
/// <summary>Avant de jeter, donner ou retirer une arme : si c'est celle qui est sortie, on la range.</summary>
public void HolsterIfActive( RoleplayPlayer player, InventorySlot slot )
{
var serial = WeaponData.Read( slot?.Data )?.Serial;
if ( serial is not null && HolderOf( player )?.ActiveSerial == serial )
Holster( player );
}
// Demandes de la machine du tireur (hôte)
RoleplayPlayer Caller() =>
RoleplayPlayer.All.FirstOrDefault( x => Rpc.Caller is not null && x.Network.OwnerId == Rpc.Caller.Id );
/// <summary>Le rechargement : l'hôte ôte les balles de l'inventaire, et le chargeur peut en contenir d'autant plus.</summary>
[Rpc.Host]
public void RequestTakeAmmo( int amount )
{
var player = Caller();
var holder = HolderOf( player );
var entry = holder?.ActiveEntry;
if ( entry is null || !drawn.TryGetValue( player.SteamId, out var state ) )
return;
var clip = ClipOf( entry, WeaponData.Read( player.Inventory.FirstOrDefault( x => WeaponData.Read( x.Data )?.Serial == holder.ActiveSerial )?.Data ) );
amount = Math.Clamp( amount, 0, clip );
var left = amount;
foreach ( var slot in player.Inventory.Where( x => x.Item == entry.Ammo ).OrderByDescending( x => x.Slot ).ToList() )
{
var take = Math.Min( left, slot.Count );
player.RemoveFrom( slot, take );
left -= take;
if ( left == 0 )
break;
}
state.MaxClip = Math.Min( clip, state.MaxClip + (amount - left) );
}
/// <summary>Les balles du chargeur, que la machine du tireur annonce : jamais plus que ce qu'il a pu y mettre.</summary>
[Rpc.Host]
public void ReportClip( string serial, int clip )
{
var player = Caller();
if ( player is null || HolderOf( player )?.ActiveSerial != serial || !drawn.TryGetValue( player.SteamId, out var state ) )
return;
// Un chargeur qui se vide ne se remplit pas sans passer par RequestTakeAmmo.
state.Clip = Math.Clamp( clip, 0, state.MaxClip );
state.MaxClip = state.Clip;
}
/// <summary>
/// Une balle a touché un joueur, d'après la machine du tireur. L'hôte vérifie que
/// l'arme est sortie, que la cadence est possible, que la cible est à portée et en
/// vue, puis ôte les dégâts du catalogue (et le bonus de la tête).
/// </summary>
[Rpc.Host( NetFlags.Unreliable )]
public void RequestHit( Guid targetId, Vector3 hitPosition, bool headshot )
{
var shooter = Caller();
var holder = HolderOf( shooter );
var entry = holder?.ActiveEntry;
var target = Scene.Directory.FindByGuid( targetId )?.GetComponent<RoleplayPlayer>();
if ( entry is null || target is null || target == shooter || target.Health <= 0 || !drawn.TryGetValue( shooter.SteamId, out var state ) )
return;
// La cadence : pas plus de touches qu'en tirant à fond, avec de la marge.
if ( state.Window > 1f )
{
state.Window = 0;
state.HitsInWindow = 0;
}
var allowed = entry.Rpm / 60f * Math.Max( 1, entry.Bullets ) * 1.5f + 2;
if ( ++state.HitsInWindow > allowed )
return;
// La touche doit être sur la cible, et la cible à portée.
if ( hitPosition.Distance( target.WorldPosition ) > 140 || shooter.WorldPosition.Distance( target.WorldPosition ) > 12000 )
return;
// Et en vue : rien du décor entre l'œil du tireur et l'impact.
var eye = shooter.GetComponent<PlayerController>()?.EyePosition ?? shooter.WorldPosition + Vector3.Up * 64;
var toHit = hitPosition - eye;
var sightTrace = Scene.Trace.Ray( eye, eye + toHit * 0.95f )
.IgnoreGameObjectHierarchy( shooter.GameObject )
.IgnoreGameObjectHierarchy( target.GameObject )
.WithoutTags( "player", "trigger" );
// À bord, la boîte de collision de son véhicule ne l'abrite pas (on tire par la vitre).
if ( target.GameObject.GetComponentInParent<Vehicle>() is { } aboard )
sightTrace = sightTrace.IgnoreGameObject( aboard.GameObject );
// Les vitres de la map ne cachent pas la cible : la balle les traverse (World/MapGlass.cs).
if ( MapGlass.ThroughGlass( sightTrace, eye, eye + toHit * 0.95f ).Hit )
return;
// La vitre traversée pour l'atteindre vole en éclats.
target.GameObject.GetComponentInParent<Vehicle>()?.BreakGlassOnLine( eye, hitPosition );
// Mode dieu (staff) : touché, mais aucun dégât.
if ( target.GodMode )
return;
var damage = entry.Damage * (headshot ? entry.Headshot : 1f);
target.Health = MathF.Max( 0, target.Health - damage );
if ( target.Health <= 0 )
{
// Le détail d'un meurtre : l'arme et son numéro de série, la distance, la tête ou non.
var meters = shooter.WorldPosition.Distance( target.WorldPosition ) * 0.0254f;
var serial = string.IsNullOrEmpty( holder.ActiveSerial ) ? "" : $" (n° {holder.ActiveSerial})";
var where = headshot ? ", tir à la tête" : "";
var inCar = target.GameObject.GetComponentInParent<Vehicle>() is { } car ? $", à bord de {car.Plate}" : "";
HorizonGame.Current?.AddLog( "abattu", target.SteamId,
$"{target.RpName} abattu par {shooter.RpName} ({shooter.SteamId}) au {entry.Name}{serial}, à {meters:0} m{where}{inCar} ({damage:0} de dégâts au dernier tir)" );
HorizonGame.Current?.Down( target, $"abattu au {entry.Name}{serial} à {meters:0} m{where}", shooter );
}
}
/// <summary>
/// Une balle a touché un véhicule, d'après la machine du tireur : la pièce (caisse, vitre, roue)
/// et le point, dans le repère de la caisse. Mêmes contrôles que pour un joueur (arme sortie,
/// cadence, portée, vue). Les dégâts suivent la munition de l'arme (Ballistics).
///
/// L'hôte refaisait le tir sur sa propre copie et exigeait le même point à 12 pouces près : une
/// voiture qui roule n'est pas au même endroit chez lui (la latence), et sur le serveur dédié
/// aucune balle ne passait — « aucun dégât » (Julien, 30/10/2026). Le tireur, qui voit ce qu'il
/// vise, dit ce qu'il a touché ; l'hôte vérifie que le point tient dans le véhicule et qu'il le voit.
/// </summary>
[Rpc.Host( NetFlags.Unreliable )]
public void RequestVehicleHit( Guid vehicleId, Vector3 localPoint, Vector3 localNormal, int part, int index, int piece )
{
var shooter = Caller();
var holder = HolderOf( shooter );
var entry = holder?.ActiveEntry;
var vehicle = Scene.Directory.FindByGuid( vehicleId )?.GetComponent<Vehicle>();
if ( entry is null || vehicle is null || !vehicle.Ready || !drawn.TryGetValue( shooter.SteamId, out var state ) )
return;
if ( state.Window > 1f )
{
state.Window = 0;
state.HitsInWindow = 0;
}
var allowed = entry.Rpm / 60f * Math.Max( 1, entry.Bullets ) * 1.5f + 2;
if ( ++state.HitsInWindow > allowed )
return;
// Le point doit tenir dans le véhicule (roues comprises, sous la caisse) et la pièce exister.
if ( vehicle.Rig is not { } rig || !vehicle.BodyFrame( out var frame ) )
return;
var bounds = rig.BodyBounds;
if ( localPoint.x < bounds.Mins.x - 30 || localPoint.x > bounds.Maxs.x + 30 || localPoint.y < bounds.Mins.y - 30
|| localPoint.y > bounds.Maxs.y + 30 || localPoint.z < bounds.Mins.z - 40 || localPoint.z > bounds.Maxs.z + 30 )
return;
if ( part is < 0 or > (int)Vehicle.ShotPart.Wheel || part == (int)Vehicle.ShotPart.Wheel && (index < 0 || index >= rig.Wheels.Length) )
return;
var point = frame.PointToWorld( localPoint );
var normal = frame.Rotation * localNormal.Normal;
var eye = shooter.GetComponent<PlayerController>()?.EyePosition ?? shooter.WorldPosition + Vector3.Up * 64;
if ( eye.Distance( point ) > 12000 || shooter.GameObject.GetComponentInParent<Vehicle>() == vehicle )
return;
var dir = (point - eye).Normal;
var sight = MapGlass.ThroughGlass( Scene.Trace.Ray( eye, point - dir * 2 )
.IgnoreGameObjectHierarchy( shooter.GameObject )
.IgnoreGameObjectHierarchy( vehicle.GameObject )
.WithoutTags( "player", "trigger" ), eye, point - dir * 2 );
if ( sight.Hit )
return;
var round = Ballistics.Of( entry.Ammo );
var energy = round.Energy / Math.Max( 1, entry.Bullets );
switch ( (Vehicle.ShotPart)part )
{
case Vehicle.ShotPart.Wheel:
vehicle.Puncture( index );
break;
case Vehicle.ShotPart.Glass:
vehicle.BreakGlass( index, piece );
vehicle.LoseHealth( energy / (Vehicle.BodyDamageDivisor * 2.5f), 0 );
break;
default:
vehicle.TakeBullet( point, normal, round, energy );
break;
}
}
/// <summary>
/// Hôte : le joueur tombe inconscient. L'arme sortie tombe près du corps, avec son numéro et ses
/// balles, et se ramasse (Julien, 30/10/2026) ; une arme de service se range seulement : elle se
/// rend au vestiaire, jamais au sol.
/// </summary>
public void DropOnDown( RoleplayPlayer player )
{
var serial = HolderOf( player )?.ActiveSerial;
Holster( player );
if ( string.IsNullOrEmpty( serial ) )
return;
var slot = player.Inventory.FirstOrDefault( x => WeaponData.Read( x.Data )?.Serial == serial );
if ( slot?.Definition is not { } item || PoliceService.IsServiceSlot( slot ) )
return;
var data = slot.Data;
player.RemoveFrom( slot, 1 );
DroppedItem.Spawn( item, 1, HorizonGame.NearBody( Scene, player ), data );
HorizonGame.Current?.AddLog( "arme_lachee", player.SteamId, $"{item.Name} ({serial}) tombé au sol (inconscient)" );
}
/// <summary>Lâcher l'arme en main (touche A) : elle tombe devant le joueur, avec ses données.</summary>
[Rpc.Host]
public void RequestDropActive()
{
var player = Caller();
var serial = HolderOf( player )?.ActiveSerial;
if ( serial is null )
return;
// Une arme de service ne se lâche pas (Police/PoliceService.Service.cs).
if ( PoliceService.IsServiceSlot( player.Inventory.FirstOrDefault( x => WeaponData.Read( x.Data )?.Serial == serial ) ) )
{
player.Notify( "Arme de service : elle ne se lâche pas. Rendez-la au vestiaire.", "erreur" );
return;
}
Holster( player );
var slot = player.Inventory.FirstOrDefault( x => WeaponData.Read( x.Data )?.Serial == serial );
if ( slot?.Definition is not { } item )
return;
var data = slot.Data;
player.RemoveFrom( slot, 1 );
var start = player.WorldPosition + player.WorldRotation.Forward.WithZ( 0 ).Normal * 50 + Vector3.Up * 40;
var ground = Scene.Trace.Ray( start, start + Vector3.Down * 200 ).IgnoreGameObjectHierarchy( player.GameObject ).Run();
DroppedItem.Spawn( item, 1, ground.Hit ? ground.EndPosition : player.WorldPosition, data );
player.Notify( $"{item.Name} lâché au sol.", "info" );
HorizonGame.Current?.AddLog( "arme_lachee", player.SteamId, $"{item.Name} ({serial}) lâché au sol" );
}
// Accessoires (menu de l'arme, touche W)
/// <summary>Les composants d'accessoire que porte une arme (classes SWB), lus dans son prefab.</summary>
/// <summary>
/// Les balles que le chargeur de cette arme peut tenir : celles du catalogue, ou celles
/// d'un chargeur grande capacité monté (<see cref="Fas2Magazine"/>, lu dans le prefab).
/// </summary>
public static int ClipOf( WeaponEntry entry, WeaponData data )
{
var clip = entry.ClipSize;
if ( data?.Att is { Count: > 0 } att )
{
foreach ( var mag in AttachmentsOf( entry ).OfType<Fas2Magazine>().Where( x => att.Contains( x.GetType().Name ) ) )
clip = Math.Max( clip, mag.Capacity );
}
return clip;
}
public static IEnumerable<SWB.Base.Attachments.Attachment> AttachmentsOf( WeaponEntry entry ) =>
GameObject.GetPrefab( entry?.Prefab )?.Components.GetAll<SWB.Base.Attachments.Attachment>( FindMode.EverythingInSelfAndDescendants )
?? Enumerable.Empty<SWB.Base.Attachments.Attachment>();
/// <summary>
/// Monte (ou démonte) un accessoire sur l'arme de cette série. Monter prend l'objet
/// dans l'inventaire et rend celui qui occupait la même place (deux viseurs ne vont
/// pas ensemble) ; démonter le rend. L'arme sortie est ressortie pour l'afficher.
/// </summary>
[Rpc.Host]
public void RequestAttachment( string serial, string cls, bool mount )
{
var player = Caller();
var slot = player?.Inventory.FirstOrDefault( x => WeaponData.Read( x.Data )?.Serial == serial );
var entry = Get( WeaponItems.WeaponOf( slot?.Item ) );
var data = WeaponData.Read( slot?.Data );
if ( entry is null || data is null )
return;
var attachment = AttachmentsOf( entry ).FirstOrDefault( x => x.GetType().Name == cls );
var accessory = WeaponRules.AccessoryForClass( cls );
if ( attachment is null || accessory is null )
{
player.Notify( "Cet accessoire ne va pas sur cette arme.", "erreur" );
return;
}
var itemId = WeaponItems.AccessoryItemId( accessory.Id );
var item = Items.Get( itemId );
if ( mount )
{
if ( data.Att.Contains( cls ) )
return;
var owned = player.Inventory.Where( x => x.Item == itemId ).OrderByDescending( x => x.Slot ).FirstOrDefault();
if ( owned is null )
{
player.Notify( $"Vous n'avez pas de {accessory.Name}.", "erreur" );
return;
}
player.RemoveFrom( owned, 1 );
// Une seule pièce par place (viseur, bouche…) : l'ancienne revient dans l'inventaire.
foreach ( var other in data.Att.ToList() )
{
var otherAtt = AttachmentsOf( entry ).FirstOrDefault( x => x.GetType().Name == other );
if ( otherAtt is null || otherAtt.Category != attachment.Category )
continue;
data.Att.Remove( other );
if ( WeaponRules.AccessoryForClass( other ) is { } back )
player.AddItem( Items.Get( WeaponItems.AccessoryItemId( back.Id ) ), 1 );
}
data.Att.Add( cls );
player.Notify( $"{accessory.Name} monté sur {entry.Name}.", "info" );
}
else
{
if ( !data.Att.Remove( cls ) )
return;
if ( player.AddItem( item, 1 ) == 0 )
{
data.Att.Add( cls );
player.Notify( "Plus de place dans l'inventaire pour l'accessoire.", "erreur" );
return;
}
player.Notify( $"{accessory.Name} démonté.", "info" );
}
slot.Data = data.Write();
player.PublishInventory();
HorizonGame.Current?.AddLog( "accessoire", player.SteamId, $"{accessory.Name} {(mount ? "monté sur" : "démonté de")} {entry.Name} ({serial})" );
// L'arme sortie se ressort avec ses nouvelles pièces (les balles restent).
if ( HolderOf( player )?.ActiveSerial == serial )
{
Holster( player );
Draw( player, player.Inventory.First( x => WeaponData.Read( x.Data )?.Serial == serial ) );
}
}
// Connexions
/// <summary>L'arme d'un joueur qui part : HorizonGame la range (et garde ses balles) avant de sauvegarder.</summary>
public void OnDisconnected( Connection channel )
{
if ( Networking.IsHost )
drawn.Remove( channel.SteamId );
}
}