Weapons/Fas2Attachments.cs

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.

Networking
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";
}