UI/WeaponMenu.razor

The weapon menu UI panel, opened with the weapon menu key. Shows a rotatable 3D weapon preview, stats, mounted and owned attachments with compatibility, and sends mount or unmount requests to WeaponSystem.

@using Sandbox;
@using Sandbox.UI;
@namespace HorizonRP
@inherits PanelComponent

@*
	Le menu de l'arme (touche W en AZERTY), l'arme en main. À gauche, l'arme en 3D :
	clic maintenu pour la faire tourner, molette pour s'approcher. À droite, ses
	valeurs, les accessoires montés (démonter), et ceux de l'inventaire, chacun dit
	compatible ou non avec cette arme. Monter et démonter sont des demandes à l'hôte
	(WeaponSystem.RequestAttachment), qui prend et rend les objets.
*@

<root>
	@if ( Open && Weapon is { } weapon && Entry is { } entry )
	{
		<div class="wm-backdrop" onmouseup=@(() => dragging = false)>
			<div class="wm">
				<div class="wm-view" onmousedown=@StartDrag onmouseup=@(() => dragging = false)>
					<ScenePanel @ref="View" class="wm-scene"></ScenePanel>
					<div class="wm-view-keys">
						<span><b>Clic maintenu</b> tourner</span>
						<span><b>Molette</b> zoom</span>
					</div>
					<div class="wm-zoom">
						<div class="wm-zoom-btn" onclick=@(() => ZoomBy( -0.15f ))><i>zoom_in</i></div>
						<div class="wm-zoom-btn" onclick=@(() => ZoomBy( 0.15f ))><i>zoom_out</i></div>
						<div class="wm-zoom-btn" onclick=@ResetView><i>restart_alt</i></div>
					</div>
				</div>

				<div class="wm-side">
					<div class="wm-head">
						<div class="wm-title"><i>my_location</i> @entry.Name</div>
						<div class="wm-sub">@($"{(entry.Category)} · série {(Data?.Serial ?? "—")}")</div>
						<div class="wm-close" onclick=@(() => SetOpen( false ))><i>close</i></div>
					</div>

					<div class="wm-stats">
						<div class="wm-stat"><div class="wm-stat-label">Dégâts</div><div class="wm-stat-value">@(entry.Bullets > 1 ? $"{entry.Damage} × {entry.Bullets}" : $"{entry.Damage}")</div></div>
						<div class="wm-stat"><div class="wm-stat-label">Cadence</div><div class="wm-stat-value">@($"{(entry.Rpm)}/min")</div></div>
						<div class="wm-stat"><div class="wm-stat-label">Chargeur</div><div class="wm-stat-value">@($"{(weapon.Primary?.Ammo ?? 0)} / {(entry.ClipSize)}")</div></div>
						<div class="wm-stat"><div class="wm-stat-label">Réserve</div><div class="wm-stat-value">@Reserve</div></div>
					</div>

					<div class="wm-section">Monté sur l'arme</div>
					<div class="wm-rows">
						@if ( !Mounted.Any() )
						{
							<div class="wm-empty">Aucun accessoire.</div>
						}
						@foreach ( var (cls, acc) in Mounted )
						{
							<div class="wm-row">
								<div class="wm-icon" style="background-image: url( @acc.Icon )"></div>
								<div class="wm-row-main">
									<div class="wm-row-title">@acc.Name</div>
									<div class="wm-row-sub">@acc.Category</div>
								</div>
								<div class="wm-btn" onclick=@(() => WeaponSystem.Current?.RequestAttachment( Data.Serial, cls, false ))><i>remove_circle</i> Démonter</div>
							</div>
						}
					</div>

					<div class="wm-section">Dans votre inventaire</div>
					<div class="wm-rows">
						@if ( !Owned.Any() )
						{
							<div class="wm-empty">Aucun accessoire sur vous. Ils s'achèteront à l'Ammu-Nation.</div>
						}
						@foreach ( var (acc, count, cls) in Owned )
						{
							<div class="wm-row @(cls is null ? "off" : "")">
								<div class="wm-icon" style="background-image: url( @acc.Icon )"></div>
								<div class="wm-row-main">
									<div class="wm-row-title">@($"{(acc.Name)} ×{(count)}")</div>
									<div class="wm-badge @(cls is null ? "no" : "yes")">@(cls is null ? "Incompatible avec cette arme" : "Compatible")</div>
								</div>
								@if ( cls is not null )
								{
									<div class="wm-btn primary" onclick=@(() => WeaponSystem.Current?.RequestAttachment( Data.Serial, cls, true ))><i>add_circle</i> Monter</div>
								}
							</div>
						}
					</div>

					<div class="wm-section">Ce qui va sur cette arme</div>
					<div class="wm-chips">
						@if ( !Accepted.Any() )
						{
							<div class="wm-empty">Aucun accessoire pour ce modèle.</div>
						}
						@foreach ( var acc in Accepted )
						{
							<div class="wm-chip">@acc.Name</div>
						}
					</div>

					<div class="wm-foot">
						<span><b>@HorizonHud.KeyName( "WeaponMenu" )</b> fermer</span>
						<span><b>Échap</b> fermer</span>
					</div>
				</div>
			</div>
		</div>
	}
</root>

@code
{
	public bool Open { get; private set; }

	ScenePanel View { get; set; }
	// De profil au départ : le canon vers la gauche de l'écran.
	const float ProfileYaw = 90f;
	readonly Preview3D preview = new() { TurnSpeed = 0, Yaw = ProfileYaw, Zoom = 1.45f };
	bool dragging;
	Vector2 lastMouse;

	void StartDrag()
	{
		dragging = true;
		lastMouse = Mouse.Position;
	}

	void ZoomBy( float amount ) => preview.Zoom = Math.Clamp( preview.Zoom + amount, 0.45f, 1.8f );

	void ResetView()
	{
		preview.Yaw = ProfileYaw;
		preview.Pitch = 0;
		preview.Zoom = 1.45f;
	}

	RoleplayPlayer Me => RoleplayPlayer.Local;
	WeaponHolder Holder => WeaponHolder.Local;
	SWB.Base.Weapon Weapon => Holder?.ActiveWeapon;
	WeaponEntry Entry => Holder?.ActiveEntry;

	/// <summary>La case de l'arme sortie, et ce qu'elle garde (série, accessoires).</summary>
	InventorySlot Slot => Me?.PublishedInventory.FirstOrDefault( x => WeaponData.Read( x.Data )?.Serial == Holder?.ActiveSerial );
	WeaponData Data => WeaponData.Read( Slot?.Data );

	int Reserve => Weapon?.Primary is { } primary ? Holder.AmmoCount( primary.AmmoType ) : 0;

	/// <summary>Les composants d'accessoire de l'arme sortie (classes SWB).</summary>
	IEnumerable<SWB.Base.Attachments.Attachment> Parts => Weapon?.Attachments ?? Enumerable.Empty<SWB.Base.Attachments.Attachment>();

	/// <summary>Les accessoires qui vont sur cette arme (les rails, posés tout seuls, ne comptent pas).</summary>
	IEnumerable<AccessoryInfo> Accepted => Parts.Select( x => WeaponRules.AccessoryForClass( x.GetType().Name ) ).Where( x => x is not null ).Distinct();

	IEnumerable<(string Class, AccessoryInfo Accessory)> Mounted =>
		(Data?.Att ?? new()).Select( cls => (cls, WeaponRules.AccessoryForClass( cls )) ).Where( x => x.Item2 is not null );

	/// <summary>Les accessoires en poche, avec la classe à monter sur cette arme (null : incompatible).</summary>
	IEnumerable<(AccessoryInfo Accessory, int Count, string Class)> Owned => Me is null ? Enumerable.Empty<(AccessoryInfo, int, string)>() :
		Me.PublishedInventory
			.Where( x => WeaponItems.AccessoryOf( x.Item ) is not null )
			.GroupBy( x => WeaponItems.AccessoryOf( x.Item ) )
			.Select( g => (WeaponRules.Accessory( g.Key ), g.Sum( x => x.Count )) )
			.Where( x => x.Item1 is not null )
			.Select( x => (x.Item1, x.Item2, Parts.Select( p => p.GetType().Name ).FirstOrDefault( c => x.Item1.Classes.Contains( c ) && !(Data?.Att.Contains( c ) ?? false) )) )
			.OrderBy( x => x.Item3 is null ).ThenBy( x => x.Item1.Name );

	/// <summary>
	/// Le modèle 3D de l'arme : celui qu'on voit au sol et dans la main des autres. Mais
	/// certains n'ont pas toutes les pièces (l'USP au sol n'a pas de silencieux) : si une
	/// pièce montée n'y existe pas, on montre le modèle de la vue à la première personne,
	/// qui l'a, dans sa pose de repos.
	/// </summary>
	string PreviewModel => MissingOnWorldModel && Weapon?.ViewModel is { } view ? view.ResourcePath : Entry?.Model ?? "";

	bool MissingOnWorldModel => Weapon?.WorldModel is { } world && Parts.Any( p =>
		!string.IsNullOrEmpty( p.BodyGroup ) && Data?.Att.Contains( p.GetType().Name ) == true
		&& !world.BodyParts.Any( b => b.Name == p.BodyGroup ) );

	/// <summary>
	/// Les groupes de corps à montrer sur le modèle 3D pour les pièces montées (ceux que le modèle
	/// montré possède). Un choix par groupe, la pièce montée d'abord : le point rouge et la lunette
	/// FA:S partagent le groupe des viseurs, et le point rouge démonté, venu après, effaçait la lunette.
	/// </summary>
	IReadOnlyList<(string Group, int Choice)> BodyParts =>
		Parts.Where( p => !string.IsNullOrEmpty( p.BodyGroup ) )
			.Select( p => (p.BodyGroup, Monte: Data?.Att.Contains( p.GetType().Name ) == true, p.BodyGroupChoice, p.BodyGroupDefault) )
			// Les groupes « en plus » d'une pièce FA:S montée (le garde-main à rails de la poignée).
			.Concat( Parts.Where( p => Data?.Att.Contains( p.GetType().Name ) == true ).OfType<IFas2Extras>()
				.SelectMany( p => p.ExtraGroups ).Select( x => (BodyGroup: x.Group, Monte: true, BodyGroupChoice: x.Choice, BodyGroupDefault: 0) ) )
			.GroupBy( p => p.BodyGroup )
			.Select( g => g.FirstOrDefault( p => p.Monte ) is { BodyGroup: not null } m ? (g.Key, m.BodyGroupChoice) : (g.Key, g.First().BodyGroupDefault) )
			.ToList();

	protected override void OnUpdate()
	{
		var me = Me;
		if ( me is null || me.NeedsCharacter || Weapon is null )
		{
			if ( Open ) SetOpen( false );
			return;
		}

		if ( Open && Input.EscapePressed )
		{
			Input.EscapePressed = false;
			SetOpen( false );
			return;
		}

		if ( Input.Pressed( "WeaponMenu" ) && (Open || !UiLock.IsLocked) )
			SetOpen( !Open );

		if ( !Open || View is null )
			return;

		preview.Attach( View );
		preview.ShowWeapon( PreviewModel, BodyParts );

		// Clic maintenu : la souris fait tourner l'arme. Le curseur est celui de
		// l'interface, donc on suit sa position (le jeu, lui, ne reçoit plus le clic).
		if ( dragging )
		{
			var delta = Mouse.Position - lastMouse;
			lastMouse = Mouse.Position;
			preview.Yaw += delta.x * 0.45f;
			preview.Pitch = Math.Clamp( preview.Pitch + delta.y * 0.35f, -70f, 70f );
		}
		if ( Input.MouseWheel.y != 0 )
			ZoomBy( -Input.MouseWheel.y * 0.08f );

		preview.Tick();
	}

	protected override void OnDisabled() => UiLock.Set( "arme", false );

	void SetOpen( bool open )
	{
		Open = open;
		dragging = false;
		if ( open )
			ResetView();
		UiLock.Set( "arme", open );
	}

	protected override int BuildHash() => HashCode.Combine( Open, Holder?.ActiveSerial, Me?.InventoryJson, Weapon?.Primary?.Ammo, Entry?.Name );
}