Weapon attachments for a FA:S 2.0 weapon pack built on the SWB base. Defines suppressor, muzzle brake, flash hider, magazine, sight, scope, grip and tactical light/laser attachment classes, plus a Fas2Extras helper that sets extra body groups on the view and world models. Fas2Tactical draws a synced laser beam, dot and spotlight and toggles them with the Flashlight key.
using SWB.Base;
using SWB.Base.Attachments;
namespace HorizonRP;
// Les accessoires des armes du pack FA:S 2.0 (Joni, importées par outils/fas2) : leurs
// modèles les portent en groupes de corps, comme ceux de Facepunch. Une classe par sorte
// d'accessoire (c'est la classe que l'inventaire retient, WeaponRules.Accessories), pour
// tout le pack : le prefab de chaque arme dit quel choix de quel groupe l'affiche
// (fas2_prefab.py le relève dans les « AttachmentElements » de la fiche ArcCW).
// Ne jamais surcharger BodyGroup / BodyGroupChoice / BodyGroupDefault ici : le moteur écrit
// la valeur du prefab dans la propriété de la base, et la surcharge rendait la sienne (tous
// les viseurs sur « sights=2 », même dans le groupe « optique » du M4A1 ; le chargeur « très
// grande capacité » sur le choix de l'étendu). Le prefab donne toujours les trois.
/// <summary>Silencieux de fusil (et de pistolet-mitrailleur, de mitrailleuse).</summary>
[Title( "Silencieux (FA:S)" )]
public class Fas2Suppressor : SilencerAttachment
{
public override string Name => "Silencieux de fusil";
public override string IconPath => "weapons/fas2/icons/rendus/silencieux_fusil.png";
public override PrefabScene MuzzleFlashParticle { get; set; } = SceneUtility.GetPrefabScene( ResourceLibrary.Get<PrefabFile>( "prefabs/particles/muzzle/muzzleflash.prefab" ) );
}
[Title( "Silencieux de pistolet (FA:S)" )]
public class Fas2PistolSuppressor : Fas2Suppressor
{
public override string Name => "Silencieux de pistolet";
}
[Title( "Silencieux de fusil à pompe (FA:S)" )]
public class Fas2ShotgunSuppressor : Fas2Suppressor
{
public override string Name => "Silencieux de fusil à pompe";
}
[Title( "Silencieux de précision (FA:S)" )]
public class Fas2SniperSuppressor : Fas2Suppressor
{
public override string Name => "Silencieux de précision";
}
/// <summary>Frein de bouche : moins de recul.</summary>
[Title( "Frein de bouche (FA:S)" )]
public class Fas2MuzzleBrake : Attachment
{
public override string Name => "Frein de bouche";
public override AttachmentCategory Category => AttachmentCategory.Muzzle;
public override string Description => "Renvoie les gaz sur les côtés : l'arme relève moins.";
public override string[] Positives => new[] { "Recul réduit de 15 %" };
public override string[] Negatives => new string[0];
public override string IconPath => "weapons/fas2/icons/rendus/frein_bouche.png";
public override StatsModifier StatsModifier { get; set; } = new() { Recoil = -0.15f };
// Rien d'autre que le groupe de corps et les valeurs : SWB applique les deux.
public override void OnEquip() { }
public override void OnUnequip() { }
}
/// <summary>Cache-flamme : moins de dispersion en tir soutenu.</summary>
[Title( "Cache-flamme (FA:S)" )]
public class Fas2FlashHider : Attachment
{
public override string Name => "Cache-flamme";
public override AttachmentCategory Category => AttachmentCategory.Muzzle;
public override string Description => "Disperse la flamme du départ du coup.";
public override string[] Positives => new[] { "Recul réduit de 5 %", "Dispersion réduite de 5 %" };
public override string[] Negatives => new string[0];
public override string IconPath => "weapons/fas2/icons/rendus/cache_flamme.png";
public override StatsModifier StatsModifier { get; set; } = new() { Recoil = -0.05f, Spread = -0.05f };
// Rien d'autre que le groupe de corps et les valeurs : SWB applique les deux.
public override void OnEquip() { }
public override void OnUnequip() { }
}
/// <summary>
/// Chargeur étendu : le groupe de corps du chargeur, et sa contenance (<see cref="Capacity"/>,
/// relevée dans la fiche ArcCW). L'hôte la reprend pour borner les balles du chargeur
/// (<see cref="WeaponSystem.ClipOf"/>).
/// </summary>
[Title( "Chargeur étendu (FA:S)" )]
public class Fas2Magazine : Attachment
{
public override string Name => "Chargeur étendu";
public override AttachmentCategory Category => AttachmentCategory.Magazine;
public override string Description => "Plus de balles avant de recharger.";
public override string[] Positives => new[] { $"{Capacity} balles" };
public override string[] Negatives => new string[0];
public override string IconPath => "weapons/fas2/icons/rendus/chargeur_etendu.png";
[Property] public int Capacity { get; set; } = 45;
int oldClipSize = -1;
public override void OnEquip()
{
if ( Weapon?.Primary is { } primary )
{
oldClipSize = primary.ClipSize;
primary.ClipSize = Math.Max( primary.ClipSize, Capacity );
}
}
public override void OnUnequip()
{
if ( Weapon?.Primary is { } primary && oldClipSize > 0 )
{
primary.ClipSize = oldClipSize;
primary.Ammo = Math.Min( primary.Ammo, oldClipSize );
}
}
}
/// <summary>Le plus grand chargeur que l'arme accepte, quand elle en a plusieurs (le 60 coups de l'AK-74).</summary>
[Title( "Chargeur très grande capacité (FA:S)" )]
public class Fas2MagazineMax : Fas2Magazine
{
public override string Name => "Chargeur très grande capacité";
public override string IconPath => "weapons/fas2/icons/rendus/chargeur_max.png";
}
// ------------------------------------------------------------------ viseurs et lunettes
/// <summary>
/// Viseur point rouge : un choix du groupe des viseurs (l'optique, et la monture quand elle
/// est sur le dessus, posées par outils/fas2/fas2_viseurs.py) et la visée à travers lui.
/// Le point est dessiné par le verre de la démo SWB (shader swb_sam_red_dot). Le CompM4 ;
/// les autres modèles du pack en dérivent, une classe chacun.
/// </summary>
[Title( "Aimpoint CompM4 (FA:S)" )]
public class Fas2RedDot : SightAttachment, IFas2Extras
{
public override string Name => "Viseur point rouge";
public override string IconPath => "weapons/fas2/icons/rendus/point_rouge.png";
/// <summary>Les organes de visée d'origine que l'optique retire (la poignée de transport de la M4A1).</summary>
[Property] public string Extras { get; set; } = "";
public IEnumerable<(string Group, int Choice)> ExtraGroups => Fas2Extras.Parse( Extras );
public override void OnEquip() { base.OnEquip(); Fas2Extras.Apply( Weapon, this, true ); }
public override void OnUnequip() { base.OnUnequip(); Fas2Extras.Apply( Weapon, this, false ); }
}
[Title( "EOTech 553 (FA:S)" )]
public class Fas2EoTech : Fas2RedDot
{
public override string Name => "Viseur holographique EOTech";
public override string IconPath => "weapons/fas2/icons/rendus/eotech.png";
}
[Title( "C-More Railway (FA:S)" )]
public class Fas2CMore : Fas2RedDot
{
public override string Name => "Viseur C-More";
public override string IconPath => "weapons/fas2/icons/rendus/cmore.png";
}
[Title( "Sightmark (FA:S)" )]
public class Fas2Sightmark : Fas2RedDot
{
public override string Name => "Viseur Sightmark";
public override string IconPath => "weapons/fas2/icons/rendus/sightmark.png";
}
[Title( "Docter Sight (FA:S)" )]
public class Fas2Docter : Fas2RedDot
{
public override string Name => "Viseur de pistolet Docter";
public override string IconPath => "weapons/fas2/icons/rendus/docter.png";
}
[Title( "Delta Point (FA:S)" )]
public class Fas2DeltaPoint : Fas2RedDot
{
public override string Name => "Viseur de pistolet Delta Point";
public override string IconPath => "weapons/fas2/icons/rendus/delta_point.png";
}
/// <summary>Lunette : même principe, avec l'image de lunette de SWB quand on vise. L'ACOG ; les autres en dérivent.</summary>
[Title( "ACOG (FA:S)" )]
public class Fas2Scope : Scope2DAttachment, IFas2Extras
{
public override string Name => "Lunette ACOG";
public override string IconPath => "weapons/fas2/icons/rendus/lunette.png";
/// <summary>Les organes de visée d'origine que la lunette retire (la poignée de transport de la M4A1).</summary>
[Property] public string Extras { get; set; } = "";
public IEnumerable<(string Group, int Choice)> ExtraGroups => Fas2Extras.Parse( Extras );
public override void OnEquip() { base.OnEquip(); Fas2Extras.Apply( Weapon, this, true ); }
public override void OnUnequip() { base.OnUnequip(); Fas2Extras.Apply( Weapon, this, false ); }
}
[Title( "ELCAN C79 (FA:S)" )]
public class Fas2Elcan : Fas2Scope
{
public override string Name => "Lunette ELCAN";
public override string IconPath => "weapons/fas2/icons/rendus/elcan.png";
}
[Title( "PK-A (FA:S)" )]
public class Fas2Pka : Fas2Scope
{
public override string Name => "Viseur PK-A";
public override string IconPath => "weapons/fas2/icons/rendus/pka.png";
}
[Title( "PS-320 Valday (FA:S)" )]
public class Fas2Ps320 : Fas2Scope
{
public override string Name => "Lunette PS-320";
public override string IconPath => "weapons/fas2/icons/rendus/ps320.png";
}
[Title( "PSO-1 (FA:S)" )]
public class Fas2Pso1 : Fas2Scope
{
public override string Name => "Lunette PSO-1";
public override string IconPath => "weapons/fas2/icons/rendus/pso1.png";
}
[Title( "Leupold (FA:S)" )]
public class Fas2Leupold : Fas2Scope
{
public override string Name => "Lunette de précision Leupold";
public override string IconPath => "weapons/fas2/icons/rendus/leupold.png";
}
/// <summary>La lunette faite pour une seule arme (G36K, SG551), à sa place d'origine.</summary>
[Title( "Lunette intégrée (FA:S)" )]
public class Fas2IntegratedScope : Fas2Scope
{
public override string Name => "Lunette intégrée";
public override string IconPath => "weapons/fas2/icons/rendus/lunette_integree.png";
}
// ------------------------------------------------------------------ pièces des groupes de corps
/// <summary>
/// Une pièce que l'arme montre par ses groupes de corps : le principal (<c>BodyGroup</c>), et
/// d'autres que la fiche ArcCW active avec elle (<see cref="Extras"/>, « groupe=choix;… » :
/// la poignée avant pose aussi le garde-main à rails). Ses effets viennent des
/// multiplicateurs de la fiche, recopiés dans le prefab (<see cref="StatsModifier"/>).
/// </summary>
public abstract class Fas2Piece : Attachment, IFas2Extras
{
[Property] public string Extras { get; set; } = "";
// Déclaré ici pour être lu dans le prefab : celui de SWB n'est pas une [Property].
[Property] public override StatsModifier StatsModifier { get; set; } = new();
public override string[] Positives => Effets( true );
public override string[] Negatives => Effets( false );
string[] Effets( bool bons )
{
var s = StatsModifier ?? new();
var res = new List<string>();
void Ligne( float v, string moins, string plus, bool moinsEstBon )
{
if ( MathF.Abs( v ) < 0.001f ) return;
var bon = (v < 0) == moinsEstBon;
if ( bon == bons ) res.Add( $"{(v < 0 ? moins : plus)} de {MathF.Abs( v ) * 100:0} %" );
}
Ligne( s.Recoil, "Recul réduit", "Recul accru", true );
Ligne( s.Spread, "Dispersion réduite", "Dispersion accrue", true );
Ligne( s.Mobility, "Mobilité réduite", "Mobilité accrue", false );
return res.ToArray();
}
/// <summary>Les groupes de <see cref="Extras"/>.</summary>
public IEnumerable<(string Group, int Choice)> ExtraGroups => Fas2Extras.Parse( Extras );
public override void OnEquip() => Fas2Extras.Apply( Weapon, this, true );
public override void OnUnequip() => Fas2Extras.Apply( Weapon, this, false );
}
/// <summary>Une pièce FA:S qui montre d'autres groupes de corps que le sien (« groupe=choix;… »).</summary>
public interface IFas2Extras
{
string Extras { get; }
IEnumerable<(string Group, int Choice)> ExtraGroups { get; }
}
/// <summary>Les groupes de corps « en plus » des pièces FA:S, posés sur les deux modèles de l'arme.</summary>
public static class Fas2Extras
{
public static IEnumerable<(string Group, int Choice)> Parse( string extras )
{
foreach ( var part in (extras ?? "").Split( ';', StringSplitOptions.RemoveEmptyEntries ) )
{
var kv = part.Split( '=' );
if ( kv.Length == 2 && int.TryParse( kv[1], out var choice ) )
yield return (kv[0].Trim(), choice);
}
}
/// <summary>
/// Pose (ou retire) les groupes en plus d'une pièce. En la retirant, un groupe qu'une
/// autre pièce montée montre aussi reste à son choix (la poignée et le laser posent tous
/// deux le garde-main à rails).
/// </summary>
public static void Apply<T>( Weapon weapon, T piece, bool on ) where T : Attachment, IFas2Extras
{
if ( !weapon.IsValid() ) return;
var others = weapon.Attachments.Where( x => x != piece && x.Equipped ).OfType<IFas2Extras>().SelectMany( x => x.ExtraGroups ).ToList();
foreach ( var (group, choice) in piece.ExtraGroups )
{
var value = on ? choice : others.Where( x => x.Group == group ).Select( x => x.Choice ).DefaultIfEmpty( 0 ).First();
if ( !piece.IsProxy && weapon.ViewModelRenderer.IsValid() )
weapon.ViewModelRenderer.SetBodyGroup( group, value );
if ( weapon.WorldModelRenderer.IsValid() )
weapon.WorldModelRenderer.SetBodyGroup( group, value );
}
}
}
[Title( "Poignée avant (FA:S)" )]
public class Fas2Foregrip : Fas2Piece
{
public override string Name => "Poignée avant";
public override AttachmentCategory Category => AttachmentCategory.Grip;
public override string Description => "Une prise de plus sous le garde-main : l'arme se tient mieux.";
public override string IconPath => "weapons/fas2/icons/rendus/poignee.png";
}
[Title( "Crosse (FA:S)" )]
public class Fas2Stock : Fas2Piece
{
public override string Name => "Crosse";
public override AttachmentCategory Category => AttachmentCategory.Stock;
public override string Description => "Une crosse dépliée ou un appui-joue : l'arme s'épaule mieux.";
public override string IconPath => "weapons/fas2/icons/rendus/crosse.png";
}
[Title( "Choke (FA:S)" )]
public class Fas2Choke : Fas2Piece
{
public override string Name => "Choke";
public override AttachmentCategory Category => AttachmentCategory.Muzzle;
public override string Description => "Resserre la gerbe des plombs.";
public override string IconPath => "weapons/fas2/icons/rendus/choke.png";
}
[Title( "Compensateur (FA:S)" )]
public class Fas2Compensator : Fas2Piece
{
public override string Name => "Compensateur";
public override AttachmentCategory Category => AttachmentCategory.Muzzle;
public override string Description => "Renvoie les gaz vers le haut : le canon relève moins.";
public override string IconPath => "weapons/fas2/icons/rendus/compensateur.png";
}
[Title( "Cache-rail (FA:S)" )]
public class Fas2DustCover : Fas2Piece
{
public override string Name => "Cache-rail";
public override AttachmentCategory Category => AttachmentCategory.Rail;
public override string Description => "Habille le garde-main.";
public override string IconPath => "weapons/fas2/icons/rendus/cache_rail.png";
}
// ------------------------------------------------------------------ laser et lampe
/// <summary>
/// Laser et lampe (modèles posés dans le groupe « tactique », ou garde-main à lampe du MP5) :
/// la touche Lampe (<c>Flashlight</c>, F) les allume et les éteint. L'état est synchronisé,
/// chaque machine dessine elle-même faisceau, point et lumière (rien de tout cela n'est en
/// réseau). Le laser part de la bouche et va où vise le joueur (là où partent les balles) ;
/// en première personne, on ne voit que le point. Le laser de SWB est commenté dans sa base.
/// </summary>
public abstract class Fas2Tactical : Fas2Piece
{
public override AttachmentCategory Category => AttachmentCategory.Tactical;
public override string Description => "Touche Lampe pour l'allumer ou l'éteindre.";
[Property] public bool HasLaser { get; set; }
[Property] public bool HasLight { get; set; }
[Property] public Color LaserColor { get; set; } = Color.Red;
[Sync] public bool Lit { get; set; }
public override string[] Positives => (HasLaser ? new[] { "Point laser sur la cible" } : new string[0])
.Concat( HasLight ? new[] { "Lampe" } : new string[0] ).ToArray();
public override string[] Negatives => new[] { "Se voit de loin, allumé" };
GameObject fx;
LineRenderer beam, spot;
PointLight dot;
SpotLight torch;
protected override void OnUpdate()
{
var weapon = Weapon;
var active = Equipped && weapon.IsValid() && weapon.GameObject.Active && weapon.Owner is { IsAlive: true };
if ( active && !IsProxy && Input.Pressed( "Flashlight" ) && !WeaponSystem.InputBlockedNow )
Lit = !Lit;
if ( !active || !Lit )
{
Hide();
return;
}
Show( weapon );
}
protected override void OnDisabled() => Hide();
protected override void OnDestroy() => Hide();
void Hide()
{
if ( fx.IsValid() )
fx.Destroy();
fx = null;
}
void Show( Weapon weapon )
{
if ( !fx.IsValid() )
Build();
var owner = weapon.Owner;
var eye = owner.EyePos;
var dir = owner.EyeAngles.ToRotation();
var muzzle = weapon.GetMuzzleTransform()?.Position ?? eye;
var tr = Scene.Trace.Ray( eye, eye + dir.Forward * 4000 )
.IgnoreGameObjectHierarchy( owner.GameObject )
.WithoutTags( "trigger" )
.Run();
var end = tr.EndPosition;
if ( beam.IsValid() )
{
// En première personne, le faisceau sortirait du modèle de vue, devant l'œil.
beam.Enabled = IsProxy || !owner.IsFirstPerson;
beam.VectorPoints = new List<Vector3> { muzzle, end };
}
if ( spot.IsValid() )
{
// Le point : un trait très court tourné vers la caméra, bouts arrondis, qui garde
// la même taille à l'écran quelle que soit la distance (une lumière seule ne se
// voyait pas de jour, à plus de quelques mètres).
// Décollé de la surface d'une demi-taille, sinon le sol en coupe la moitié ; les
// deux bouts presque confondus font un disque.
var cam = Scene.Camera;
var size = cam is null ? 1f : Math.Clamp( (cam.WorldPosition - end).Length * 0.009f, 0.6f, 14f );
var pos = end + tr.Normal * (size * 0.5f + 0.2f);
var right = cam?.WorldRotation.Right ?? Vector3.Right;
spot.Enabled = tr.Hit;
spot.Width = size;
spot.VectorPoints = new List<Vector3> { pos - right * size * 0.02f, pos + right * size * 0.02f };
}
if ( dot.IsValid() )
{
dot.Enabled = tr.Hit;
dot.WorldPosition = end + tr.Normal * 1.5f;
}
if ( torch.IsValid() )
{
torch.WorldPosition = muzzle;
torch.WorldRotation = dir;
}
}
void Build()
{
fx = new GameObject( false, "Laser / lampe" ) { NetworkMode = NetworkMode.Never };
if ( HasLaser )
{
var b = new GameObject( fx, true, "Faisceau" );
beam = b.Components.Create<LineRenderer>();
beam.UseVectorPoints = true;
beam.Color = LaserColor.WithAlpha( 0.35f );
beam.Width = 0.12f;
beam.Additive = true;
beam.Opaque = false;
beam.CastShadows = false;
beam.Lighting = false;
var p = new GameObject( fx, true, "Point" );
spot = p.Components.Create<LineRenderer>();
spot.UseVectorPoints = true;
spot.Color = LaserColor * 3f;
spot.StartCap = SceneLineObject.CapStyle.Rounded;
spot.EndCap = SceneLineObject.CapStyle.Rounded;
spot.Additive = true;
spot.Opaque = false;
spot.CastShadows = false;
spot.Lighting = false;
// Et une petite lueur autour, qui se voit la nuit.
var d = new GameObject( fx, true, "Lueur" );
dot = d.Components.Create<PointLight>();
dot.LightColor = LaserColor * 4f;
dot.Radius = 12f;
dot.Shadows = false;
}
if ( HasLight )
{
var l = new GameObject( fx, true, "Lampe" );
torch = l.Components.Create<SpotLight>();
torch.LightColor = new Color( 1f, 0.96f, 0.88f ) * 2.5f;
torch.Radius = 768f;
torch.ConeOuter = 30f;
torch.ConeInner = 18f;
torch.Shadows = true;
}
fx.Enabled = true;
}
}
[Title( "AN/PEQ-15 (FA:S)" )]
public class Fas2Peq15 : Fas2Tactical
{
public override string Name => "Laser vert AN/PEQ-15";
public override string IconPath => "weapons/fas2/icons/rendus/laser_vert.png";
}
[Title( "AN/PEQ-2 (FA:S)" )]
public class Fas2Peq2 : Fas2Tactical
{
public override string Name => "Laser AN/PEQ-2";
public override string IconPath => "weapons/fas2/icons/rendus/laser.png";
}
[Title( "Lampe INFORCE (FA:S)" )]
public class Fas2Light : Fas2Tactical
{
public override string Name => "Lampe tactique";
public override string IconPath => "weapons/fas2/icons/rendus/lampe.png";
}
[Title( "INSIGHT X2 (FA:S)" )]
public class Fas2Insight : Fas2Tactical
{
public override string Name => "Laser et lampe de pistolet";
public override string IconPath => "weapons/fas2/icons/rendus/laser_pistolet.png";
}
/// <summary>Le garde-main à lampe du MP5A5 : il remplace celui à poignée, d'où la même catégorie.</summary>
[Title( "Garde-main à lampe (FA:S)" )]
public class Fas2HandguardLight : Fas2Tactical
{
public override string Name => "Lampe tactique";
public override AttachmentCategory Category => AttachmentCategory.Grip;
public override string IconPath => "weapons/fas2/icons/rendus/lampe.png";
}