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 );
}