VendorMenu.cs
using Sandbox;
using Sandbox.UI;
using Sandbox.UI.Construct;
using SWB.Base;
using SWB.Player;

// The loadout board itself. Shows the local player's cash and two columns
// of stock (primary / secondary); clicking a line buys and equips it.
//
// Follows the same no-Razor PanelComponent pattern as swb_hud. Put this on
// the same GameObject as the Vendor and assign Vendor; it creates its own
// ScreenPanel, so no extra editor wiring.
public sealed class VendorMenu : PanelComponent
{
	[Property]
	public Vendor Vendor { get; set; }

	Label _cashLabel;
	Panel _primaryColumn;
	Panel _secondaryColumn;
	Panel _gearColumn;

	bool _wasOpen = false;

	protected override void OnStart()
	{
		Components.GetOrCreate<ScreenPanel>();

		Panel.StyleSheet.Load( "/VendorMenu.cs.scss" );

		var header = Panel.Add.Panel( "header" );
		header.Add.Label( "Loadout", "title" );
		_cashLabel = header.Add.Label( "", "cash" );

		// Holding Use again also closes it, but a visible way out matters
		// when the cursor is already up and the menu is covering the
		// screen - nothing on screen tells you the hold-to-close trick.
		var close = header.Add.Label( "✕", "close" );
		close.AddEventListener( "onmousedown", () => Vendor?.Close() );

		var columns = Panel.Add.Panel( "columns" );

		_primaryColumn = columns.Add.Panel( "column" );
		_primaryColumn.Add.Label( "Primary", "columnTitle" );

		_secondaryColumn = columns.Add.Panel( "column" );
		_secondaryColumn.Add.Label( "Secondary", "columnTitle" );

		// Armour isn't a weapon and doesn't belong under either hand, so it
		// gets its own column rather than being squeezed into Secondary.
		_gearColumn = columns.Add.Panel( "column" );
		_gearColumn.Add.Label( "Gear", "columnTitle" );
	}

	protected override void OnUpdate()
	{
		if ( Vendor is null )
			return;

		Panel.SetClass( "show", Vendor.IsOpen );

		// Rebuild only on open so prices/ownership reflect whatever the
		// player walked up with, without churning panels every frame.
		if ( Vendor.IsOpen && !_wasOpen )
			Rebuild();

		// Release the cursor so the menu can be clicked - see MenuInput for
		// why this works on mouse-look rather than on Mouse.Visible.
		if ( Vendor.IsOpen != _wasOpen )
			MenuInput.SetMenuOpen( Vendor.IsOpen );

		_wasOpen = Vendor.IsOpen;

		if ( Vendor.IsOpen )
			UpdateCash();
	}

	void UpdateCash()
	{
		var loadout = LocalLoadout();
		_cashLabel.Text = loadout is null ? "" : $"${loadout.Cash}";
	}

	void Rebuild()
	{
		ClearEntries( _primaryColumn );
		ClearEntries( _secondaryColumn );
		ClearEntries( _gearColumn );

		foreach ( var entry in Vendor.Stock )
		{
			if ( entry is null || !entry.IsValid )
				continue;

			var column = entry.Kind == VendorItemKind.Armor
				? _gearColumn
				: entry.Slot == LoadoutSlot.Primary ? _primaryColumn : _secondaryColumn;

			AddEntry( column, entry );
		}
	}

	void AddEntry( Panel column, VendorEntry entry )
	{
		var isArmor = entry.Kind == VendorItemKind.Armor;

		// Armour has no registry entry to pull a name or icon from, so it
		// leans on the DisplayName set in the editor.
		var weapon = isArmor ? null : WeaponRegistry.Instance?.Get( entry.ClassName );

		var displayName = !string.IsNullOrWhiteSpace( entry.DisplayName )
			? entry.DisplayName
			: weapon?.DisplayName ?? entry.ClassName;

		var row = column.Add.Panel( "entry" );

		if ( !string.IsNullOrWhiteSpace( weapon?.Icon ) )
			row.Add.Image( weapon.Icon, "icon" );

		row.Add.Label( displayName, "name" );
		row.Add.Label( $"${entry.Cost}", "cost" );

		row.AddEventListener( "onmousedown", () =>
		{
			var loadout = LocalLoadout();
			if ( loadout is null )
				return;

			// Two different purchases: armour is a flag, a weapon goes into
			// a slot. Both refuse and change nothing if you can't afford it
			// or already have it, so the result is all we need to check.
			var bought = isArmor
				? loadout.TryBuyArmor( entry.Cost )
				: loadout.TryBuy( entry.ClassName, entry.Cost, entry.Slot );

			if ( !bought )
				return;

			Rebuild();
			UpdateCash();
		} );

		// Reflect current state: what you already have, and what you simply
		// can't afford right now.
		var current = LocalLoadout();

		if ( current is null )
			return;

		var owned = isArmor
			? current.HasArmor
			: (entry.Slot == LoadoutSlot.Primary ? current.Primary : current.Secondary) == entry.ClassName;

		row.SetClass( "equipped", owned );

		// Owning a vest already is a reason to grey the line out too -
		// buying a second one would just take the money.
		row.SetClass( "unaffordable", !owned && !current.CanAfford( entry.Cost ) );
	}

	void ClearEntries( Panel column )
	{
		foreach ( var child in column.Children )
		{
			if ( child.HasClass( "entry" ) )
				child.Delete();
		}
	}

	PlayerLoadout LocalLoadout()
	{
		return PlayerBase.Local?.Components.Get<PlayerLoadout>();
	}
}