224 results

@namespace Sandbox
@using Sandbox.UI
@inherits Sandbox.UI.Panel

<root>

    @if ( Developer )
    {
        <div class="developer-badge">🪲</div>
    }

    @if ( !string.IsNullOrWhiteSpace( Title ) && !HideText )
    {
        <div class="title">@Title</div>
    }

</root>

@code
{
    [Parameter] public string Ident { get; set; }
    [Parameter] public string Icon { get; set; }
    [Parameter] public string Title { get; set; }
    [Parameter] public bool HideText { get; set; }
    [Parameter] public string Metadata { get; set; }
    [Parameter] public bool Developer { get; set; }

    string Type;
    string Path;
    string Source;

    /// <summary>
    /// We want to drag from this panel
    /// </summary>
    public override bool WantsDrag => true;

    protected override void OnParametersSet()
    {
        base.OnParametersSet();

        (Type, Path, Source) = SpawnlistItem.ParseIdent( Ident );

        if ( string.IsNullOrWhiteSpace( Icon ) )
        {
            Icon = $"thumb:{Path}";
        }

        Style.SetBackgroundImage(Icon);
    }

    protected override int BuildHash()
    {
        return HashCode.Combine( Ident, Icon, Title, HideText, Developer );
    }

    protected override async void OnDragStart( DragEvent e )
    {
        var dragData = new DragData
        {
            Type = Type,
            Path = Path,
            Icon = Icon ?? $"thumb:{Path}",
            Title = Title,
            Source = this
        };

        DragHandler.StartDragging( dragData );

        if ( Type == "dupe" )
        {
            dragData.Data = await LoadDupeJson( Path, Source );
        }
    }

    protected override void OnDragEnd( DragEvent e )
    {
        DragHandler.StopDragging();
    }

    protected override void OnClick( MousePanelEvent e )
    {
        GameManager.Spawn( Ident, Metadata );
    }

    protected override async void OnRightClick( MousePanelEvent e )
    {
        var menu = MenuPanel.Open( this );

        // Let the spawner populate its own context menu options
        var spawner = ISpawner.Create( Type, Path, metadata: Metadata );
        if ( spawner is not null && await spawner.Loading )
        {
            spawner.PopulateContextMenu( menu, Ident, Metadata );
        }

        // If inside a spawnlist, offer removal
        var spawnlistView = Ancestors.OfType<SpawnlistView>().FirstOrDefault();
        if ( spawnlistView?.Entry is not null )
        {
            menu.AddOption( "delete", "Remove from List", () =>
            {
                var data = SpawnlistData.Load( spawnlistView.Entry );
                var idx = data.Items.FindIndex( x => x.Ident == Ident );
                if ( idx >= 0 )
                {
                    SpawnlistData.RemoveItem( spawnlistView.Entry, idx );
                    spawnlistView.RefreshCache();
                }
            } );
            menu.AddSpacer();
        }

        SpawnlistData.PopulateContextMenu( menu, new SpawnlistItem
        {
            Ident = Ident,
            Title = Title,
            Icon = Icon,
        }, spawnlistView?.Entry );

        e.StopPropagation();
    }

    async Task<string> LoadDupeJson( string id, string source )
    {
        if ( !ulong.TryParse( id, out var fileId ) )
            return null;

        if ( source == "workshop" )
        {
            var query = new Storage.Query { FileIds = [fileId] };
            var result = await query.Run();
            var item = result.Items?.FirstOrDefault();
            if ( item is null ) return null;

            var installed = await item.Install();
            return installed?.Files.ReadAllText( "/dupe.json" );
        }

        var entry = Storage.GetAll( "dupe" ).FirstOrDefault( x => x.Id.ToString() == fileId.ToString() );
        if ( entry is null ) return null;

        return await entry.Files.ReadAllTextAsync( "/dupe.json" );
    }
}
@using Sandbox;
@using Sandbox.UI;
@inherits Panel
@namespace Sandbox
@attribute [SpawnMenuHost.SpawnMenuMode]
@attribute [Icon( "🎨" )]
@attribute [Title( "#spawnmenu.mode.effects" )]
@attribute [Order( -75 )]

<root>
    <EffectsList />
    <EffectsProperties />
</root>
@using Sandbox;
@using Sandbox.UI;
@inherits UtilityPage
@namespace Sandbox
@attribute [Icon( "🔧" )]
@attribute [Title( "#spawnmenu.utility.utilities" )]
@attribute [Group( "#spawnmenu.utility.group.utilities" )]
@attribute [Order( 0 )]

<root class="page" style="flex-direction: column;">
    <div class="control-row" @onclick=@(() => ConsoleSystem.Run( "kill" ))>
        <div class="left"><label>🚿 </label><label>#spawnmenu.utility.kill_me</label></div>
    </div>
</root>
@using Sandbox;
@using Sandbox.UI;
@inherits Panel
@namespace Sandbox

<root class="menu-header-bar">

    @if ( Left != null )
    {
        <div class="left">@Left</div>
    }

    <div class="body">@Body</div>

@if ( Right != null )
    {
        <div class="right">@Right</div>
    }

</root>

@code
{

    [Parameter] public RenderFragment Left { get; set; }
    [Parameter] public RenderFragment Body { get; set; }
    [Parameter] public RenderFragment Right { get; set; }

}

@using Sandbox;
@using Sandbox.UI;
@inherits UtilityPage
@namespace Sandbox
@attribute [Icon( "🔫" )]
@attribute [Title( "#spawnmenu.utility.weapons" )]
@attribute [Group( "#spawnmenu.utility.group.world" )]
@attribute [Order( 2 )]

<root class="page" style="flex-direction: column;">
    @if ( Connection.Local.HasPermission( "admin" ) )
    {
        <div class="control-row" Tooltip=@UnlimitedAmmoDescription @onclick=@ToggleUnlimitedAmmo>
            <div class="left"><label>@(WeaponConVars.UnlimitedAmmo ? "🟢" : "⚪") </label><label>#spawnmenu.utility.unlimited_ammo</label></div>
            <div style="flex-grow: 1;"></div>
            <label class="right">@(WeaponConVars.UnlimitedAmmo ? "#spawnmenu.common.on" : "#spawnmenu.common.off")</label>
        </div>
        <div class="control-row" Tooltip=@InfiniteReservesDescription @onclick=@ToggleInfiniteReserves>
            <div class="left"><label>@(WeaponConVars.InfiniteReserves ? "🟢" : "⚪") </label><label>#spawnmenu.utility.infinite_reserves</label></div>
            <div style="flex-grow: 1;"></div>
            <label class="right">@(WeaponConVars.InfiniteReserves ? "#spawnmenu.common.on" : "#spawnmenu.common.off")</label>
        </div>
    }
    else
    {
        <div class="control-row">
            <div class="left"><label>⚠️ </label><label>#spawnmenu.common.host_only</label></div>
        </div>
    }
</root>

@code
{
    public override bool IsPageVisible() => Connection.Local.HasPermission( "admin" );

    static readonly string UnlimitedAmmoDescription = "#spawnmenu.utility.unlimited_ammo_description";
    static readonly string InfiniteReservesDescription = "#spawnmenu.utility.infinite_reserves_description";

    void ToggleUnlimitedAmmo() => GameManager.SetConVar( "sb.weapon.unlimitedammo", !WeaponConVars.UnlimitedAmmo );
    void ToggleInfiniteReserves() => GameManager.SetConVar( "sb.weapon.infinitereserves", !WeaponConVars.InfiniteReserves );
}
@using Sandbox;
@using Sandbox.UI;
@namespace Sandbox
@inherits Panel

<root>

    <div class="presets-toggle" @onclick=@OnToggleClick>
        <i>expand_less</i>
    </div>

</root>

@code
{
    public PlayerInventory Inventory { get; set; }

    void OnToggleClick()
    {
        var menu = MenuPanel.Open( this );

        var presets = PlayerLoadout.GetLoadoutPresets();
        foreach ( var preset in presets )
        {
            var name = preset.Name;
            var json = preset.LoadoutJson;
            menu.AddSubmenu( "bookmark", name, sub =>
            {
                sub.AddOption( "play_arrow", "Load", () => OnLoadPreset( json ) );
                sub.AddOption( "save", "Overwrite with current", () => OnOverwritePreset( name ) );
                sub.AddOption( "close", "Delete", () => OnDeletePreset( name ) );
            } );
        }

        if ( presets.Any() )
        {
            menu.AddSpacer();
        }

        menu.AddOption( "refresh", "Reset to Default", ResetToDefault );
        menu.AddOption( "add", "New Preset", OnSaveNew );
        menu.StateHasChanged();
    }

    void OnLoadPreset( string json )
    {
        var loadout = GetLoadout();
        if ( !loadout.IsValid() ) return;
        loadout.SwitchToPreset( json );
    }

    void OnOverwritePreset( string name )
    {
        var json = LocalData.Get<string>( "hotbar" );
        if ( string.IsNullOrEmpty( json ) ) return;

        PlayerLoadout.SaveLoadoutPreset( name, json );
    }

    void OnDeletePreset( string name )
    {
        PlayerLoadout.DeleteLoadoutPreset( name );
    }

    void ResetToDefault()
    {
        var loadout = GetLoadout();
        if ( !loadout.IsValid() ) return;
        loadout.ResetToDefault();
    }

    void OnSaveNew()
    {
        var popup = new StringQueryPopup
        {
            Title = "New Inventoy Preset",
            Placeholder = "Enter a name...",
            ConfirmLabel = "Save",
            OnConfirm = OnSaveConfirmed,
            Parent = FindRootPanel()
        };
    }

    void OnSaveConfirmed( string name )
    {
        var json = LocalData.Get<string>( "hotbar" );
        if ( string.IsNullOrEmpty( json ) ) return;

        PlayerLoadout.SaveLoadoutPreset( name, json );
    }

    PlayerLoadout GetLoadout()
    {
        return Inventory?.GetComponent<PlayerLoadout>();
    }
}
@using Sandbox;
@using Sandbox.UI;

@inherits Panel
@namespace Sandbox

@if ( LastResult is null )
{
    // loading
    return;
}

<SpawnMenuContent>

    <Header>
        <SpawnMenuToolbar>
            <Left>
                <TextEntry Placeholder="#spawnmenu.common.search" class="filter menu-input" Value:bind=@Filter />
            </Left>

            <Right>
                <DropDown Value:bind=@SortOrder />
            </Right>
        </SpawnMenuToolbar>
    </Header>

    <Body>
<VirtualGrid Items=@( LastResult.Packages ) ItemSize=@(120)>
    <Item Context="item">
        @if (item is Package entry)
        {
            <SpawnMenuIcon Ident=@($"prop:{entry.FullIdent}") Title="@entry.Title"></SpawnMenuIcon>
        }
    </Item>
</VirtualGrid>
    </Body>

</SpawnMenuContent>

@code
{
    public string Category { get; set; } = "";

    private string Filter
    {
        get;
        set { field = value; Rebuild(); }
    }

    public PackageSortMode SortOrder
    {
        get;
        set { field = value; Rebuild(); }
    }

    protected override void OnParametersSet()
    {
        SortOrder = PackageSortMode.Popular;
        Rebuild();
    }

    Package.FindResult LastResult;

    async void Rebuild()
    {
        var query = $"sort:{SortOrder.ToIdentifier()} type:model";
        if ( !string.IsNullOrEmpty( Filter ) ) query += $" {Filter} ";
        if ( !string.IsNullOrEmpty( Category ) ) query += $" category:{Category}";

        LastResult = await Package.FindAsync( query );
        StateHasChanged();
    }
}
@using Sandbox;
@using Sandbox.UI;
@inherits PanelComponent

<root>
    <div class="body">
        <div class="title">
            <label class="big">sandbox</label>

            @if ( !string.IsNullOrEmpty( _mapTitle ) )
            {
                <label class="small">Playing on @(_mapTitle)</label>
            }
        </div>

        <div class="table">
            <div class="col">
                <div class="header row">
                    <div class="avatar-spacer"></div>
                    <div class="name"></div>
                    <div class="stat">Kills</div>
                    <div class="stat">Deaths</div>
                    <div class="stat">Ping</div>
                    <div class="mute-spacer"></div>
                </div>

                @foreach ( var entry in Scene.GetAll<PlayerData>().OrderByDescending( x => x.Kills ).ThenBy( x => x.DisplayName ) )
                {
                    <ScoreboardRow Entry="@entry" />
                }
            </div>
        </div>
    </div>
</root>

@code
{
    string _mapTitle = null;

    protected override void OnTreeFirstBuilt()
    {
        base.OnTreeFirstBuilt();
        FetchMap();
    }

    async void FetchMap()
    {
        var ident = Networking.MapName;

        var pkg = await Package.FetchAsync( ident, false );
        if ( pkg is not null )
        {
            _mapTitle = pkg.Title;
        }
    }

    protected override void OnUpdate()
    {
	    var host = Game.ActiveScene.Get<SpawnMenuHost>();
	    if ( host?.Panel?.HasClass( "open" ) ?? false )
	    {
		    Input.Clear( "Score" );
	    }

	    SetClass( "visible", Input.Down( "Score" ) );
    }

    /// <summary>
    /// update every second
    /// </summary>
    protected override int BuildHash() => System.HashCode.Combine( RealTime.Now.CeilToInt() );
}
@using Sandbox;
@using Sandbox.UI;
@inherits Panel
@namespace Sandbox

<root>
    <div class="menu-icon-toggle-group">
        <IconPanel Tooltip="Compact" class=@( Size == 60 ? "active" : "" ) Text="view_compact" @onclick=@( () => ChangeSize( 60 ) )></IconPanel>
        <IconPanel Tooltip="Medium" class=@( Size == 120 ? "active" : "" ) Text="view_module" @onclick=@( () => ChangeSize( 120 ) )></IconPanel>
        <IconPanel Tooltip="Large" class=@( Size == 200 ? "active" : "" ) Text="grid_view" @onclick=@( () => ChangeSize( 200 ) )></IconPanel>
    </div>
</root>


@code
{
	public int Size { get; set; } = 120;

	protected override int BuildHash() => HashCode.Combine( Size );

	void ChangeSize( int size )
	{
		Size = size;
	}
}

@using System
@using HitShapes
@using Sandbox.UI

@inherits PanelComponent

<root @onmouseout=@OnUnhover
      @onmousemove=@OnHover
      @onclick=@OnClicked>
    @for ( int i = 0; i < 8; i++ )
    {
        int slot = i;
        <div class=@SlotClass(slot) style=@SlotStyle(slot)>
            <div class="num">@slot</div>
            <div class="cnt">@_clicks[slot]</div>
        </div>
    }
</root>

@code
{
    SlotDispatcher _dispatcher;
    int[] _clicks = new int[8];

    protected override void OnEnabled()
    {
        base.OnEnabled();
        _dispatcher = new SlotDispatcher( HitShape.Radial( 8 ) )
        {
            OnSlotEnter = SlotChanged,
            OnSlotLeave = SlotChanged,
            OnSlotClick = ( slot, e ) => { _clicks[slot]++; StateHasChanged(); },
        };
    }

    void SlotChanged( int slot ) => StateHasChanged();

    void OnHover( PanelEvent e ) => _dispatcher?.HandleMouseMove( (MousePanelEvent)e, Panel );
    void OnUnhover( PanelEvent e ) => _dispatcher?.HandleMouseLeave( (MousePanelEvent)e );
    void OnClicked( PanelEvent e ) => _dispatcher?.HandleClick( (MousePanelEvent)e, Panel );

    string SlotClass( int slot ) => "slot" + (_dispatcher?.CurrentSlot == slot ? " hot" : "");

    string SlotStyle( int slot )
    {
        float theta = slot * MathF.Tau / 8f - MathF.PI / 2f;
        float lx = 200f + MathF.Cos( theta ) * 140f - 30f;
        float ly = 200f + MathF.Sin( theta ) * 140f - 30f;
        return $"left: {lx}px; top: {ly}px";
    }
}
@using Sandbox;
@using Sandbox.UI;
@inherits PanelComponent
@namespace Sandbox

@*
    The floating name tag above an avatar. StreamPlayer.Setup() points its Viewer at the right viewer,
    and we render their display name in their chat colour.
*@

<root>
    <div class="username" style="@NameStyle">
        @Viewer.DisplayName
    </div>
</root>

@code
{
    /// <summary>
    /// The viewer this tag is labelling. Set by StreamPlayer when the avatar is spawned.
    /// </summary>
    public Streamer.Viewer Viewer { get; set; }

    /// <summary>
    /// Colour the name with the viewer's chat colour, if they have one set.
    /// </summary>
    string NameStyle => Viewer.Color.HasValue ? $"color: {Viewer.Color.Value.Hex}" : null;

    /// <summary>
    /// PanelComponent rebuilds the markup whenever this hash changes - so include anything we display.
    /// </summary>
    protected override int BuildHash() => System.HashCode.Combine( Viewer.DisplayName, Viewer.Color );
}
@namespace Sandbox
@using Sandbox;
@using Sandbox.UI;
@using System;
@inherits Panel
@attribute [StyleSheet("DifficultyPanel.razor.scss")]

@{
	var isClient = Networking.IsClient;
	int diff = DontChangeGameDifficulty ? DifficultyToDisplay : Manager.Instance.Difficulty;
	// var canDecreaseDifficulty = diff > Manager.MinDifficulty;
	var isAtMinDifficulty = diff <= Manager.MinDifficulty - (DontChangeGameDifficulty ? 1 : 0);
	var canIncreaseDifficulty = DontChangeGameDifficulty || ( diff < Manager.MaxDifficulty && Manager.Instance.HasBeatenDifficulty(diff) );
	var isAtMaxDifficulty = diff >= Manager.MaxDifficulty;
	var difficultyDesc = Manager.GetDescriptionForDifficulty( diff );
}

<root>
	<div class="top-row">
		@if( isAtMinDifficulty)
		{
			<div class="difficulty_decrease disabled_button" style="opacity:0;"> </div>
		}
		else
		{
			<div class="difficulty_decrease @(isClient && !DontChangeGameDifficulty ? "disabled_button" : "")" onclick="@(() => ButtonLeft())">
				@if(Input.UsingController && (!isClient || DontChangeGameDifficulty)) { <InputHint class="inputbutton" Button="Slot1" /> }
			</div>
		}

		<div class="middle">
			<div class="difficulty_label" style="color:@(Manager.GetDifficultyLabelColor(diff).Rgba);">@($"{Manager.GetNameForDifficulty(diff)}")</div>
			@if ( !string.IsNullOrEmpty( difficultyDesc ) )
			{
				<label class="description">@difficultyDesc</label>
			}
		</div>

		@if(isAtMaxDifficulty)
		{
			<div class="difficulty_increase disabled_button" style="opacity:0;"></div>
		}
		else
		{
			if(canIncreaseDifficulty)
			{
				<div class="difficulty_increase @(isClient && !DontChangeGameDifficulty ? "disabled_button" : "")" onclick="@(() => ButtonRight())">
					@if(Input.UsingController && (!isClient || DontChangeGameDifficulty)) { <InputHint class="inputbutton" Button="Slot3" /> }
				</div>
			}
			else
			{
				<div class="difficulty_locked" Tooltip=@($"Beat {Manager.GetNameForDifficulty(diff)} first")></div>
			}
		}
	</div>

</root>

@code
{
	public bool DontChangeGameDifficulty { get; set; }
	public static int DifficultyToDisplay { get; set; } = -1; // -1 equals all difficulties combined

	public override void Tick()
	{
		base.Tick();

		if(!Input.UsingController)
			return;

		if(Networking.IsClient && !DontChangeGameDifficulty)
			return;

		int diff = DontChangeGameDifficulty ? DifficultyToDisplay : Manager.Instance.Difficulty;
		var isAtMinDifficulty = diff <= Manager.MinDifficulty - (DontChangeGameDifficulty ? 1 : 0);
		var canIncreaseDifficulty = DontChangeGameDifficulty || ( diff < Manager.MaxDifficulty && Manager.Instance.HasBeatenDifficulty(diff) );
		var isAtMaxDifficulty = diff >= Manager.MaxDifficulty;

		if(Input.Pressed("Slot1") && !isAtMinDifficulty) { Manager.Instance.PlaySfxUI("click", pitch: 1.15f, volume: 0.75f); ButtonLeft(); }
		else if(Input.Pressed("Slot3") && !isAtMaxDifficulty) { Manager.Instance.PlaySfxUI("click", pitch: 1.15f, volume: 0.75f); ButtonRight(); }
	}

	protected override int BuildHash()
	{
		return HashCode.Combine(
			Manager.Instance.Difficulty,
			DifficultyToDisplay,
			Input.UsingController
		);
	}

	void ButtonLeft()
	{
		GameSettingsSystem.Save();

		ButtonLeftAsync();
	}

	async void ButtonLeftAsync()
	{
		if(DontChangeGameDifficulty)
		{
			if(DifficultyToDisplay == Manager.MinDifficulty)
				DifficultyToDisplay = -1;
			else
				DifficultyToDisplay = Math.Max(DifficultyToDisplay - 1, Manager.MinDifficulty);
		}
		else
		{
			Manager.Instance.FadeRpc(fadeIn: false);

			await Task.Frame();

			var difficulty = Math.Max(Manager.Instance.Difficulty - 1, Manager.MinDifficulty);
			Manager.Instance.SetDifficulty(difficulty);
		}
	}

	void ButtonRight()
	{
		ButtonRightAsync();

		GameSettingsSystem.Save();
	}

	async void ButtonRightAsync()
	{
		if(DontChangeGameDifficulty)
		{
			DifficultyToDisplay = Math.Min(DifficultyToDisplay + 1, Manager.MaxDifficulty);
		}
		else
		{
			Manager.Instance.FadeRpc(fadeIn: false);

			await Task.Frame();

			var difficulty = Math.Min(Manager.Instance.Difficulty + 1, Manager.MaxDifficulty);
			Manager.Instance.SetDifficulty(difficulty);
		}
	}
}
@namespace Sandbox
@using Sandbox;
@using Sandbox.UI;
@using System;
@using System.Linq;
@using System.Collections.Generic;
@inherits Panel
@attribute [StyleSheet("LoadoutPanel.razor.scss")]

<root>
	<div class="hide_button" onclick=@(() => Close())></div>
	<div class="title_label">Loadout</div>

	<div class="tabs">
		@for(int t = 0; t < TabNames.Length; t++)
		{
			var tabIndex = t;
			<div class="tab @(_selectedTab == tabIndex ? "active" : "")" onclick=@(() => SelectTab(tabIndex))>
				@TabNames[tabIndex]
			</div>
		}
	</div>

	<div class="panel_body">
		@if(_selectedTab == 2)
		{
			var equippedGems = ProgressManager.GetEquippedGems();
			var ownedGems = ProgressManager.GetOwnedItemsByCategory(ShopItemCategory.Gem);
			var maxGemSlots = ProgressManager.GetSelectedGunSocketCount();
			var gemsFull = equippedGems.Count >= maxGemSlots;

			@if(ownedGems.Count > 0)
			{
				<div class="items_grid">
					@foreach(var gem in ownedGems)
					{
						var isEquipped = equippedGems.Contains(gem.Id);
						var gemCapture = gem;
						<LoadoutItemPanel Item=@gem IsSelected=@isEquipped IsSlotFull=@(!isEquipped && gemsFull) OnClick=@(() => OnSelectGem(gemCapture)) />
					}
				</div>
			}
			else
			{
				<div class="coming_soon">Buy gems in the Shop to equip them here.</div>
			}
		}
		else
		{
			var items = GetItemsForTab(_selectedTab);

			@if(_selectedTab == 1)
			{
				var selectedCharmIds = ProgressManager.GetSelectedCharmIds();
				var maxCharmSlots = ProgressManager.GetSelectedGunCharmSlotCount();
				var charmsFull = selectedCharmIds.Count >= maxCharmSlots;

				<div class="items_grid">
					@foreach(var item in items)
					{
						var isSelected = selectedCharmIds.Contains(item.Id);
						var itemCapture = item;
						<LoadoutItemPanel Item=@item IsSelected=@isSelected IsSlotFull=@(!isSelected && charmsFull && maxCharmSlots > 1) OnClick=@(() => OnSelectItem(itemCapture)) />
					}
				</div>
			}
			else
			{
				var selectedGunId = ProgressManager.GetSelectedGunId();

				<div class="items_grid">
					@foreach(var item in items)
					{
						var isSelected = item.Id == selectedGunId;
						var itemCapture = item;
						<LoadoutItemPanel Item=@item IsSelected=@isSelected OnClick=@(() => OnSelectItem(itemCapture)) />
					}
				</div>
			}
		}
	</div>
</root>

@code
{
	static readonly string[] TabNames = { "Guns", "Charms", "Gems" };
	static readonly ShopItemCategory[] TabCategories = { ShopItemCategory.Gun, ShopItemCategory.Charm, ShopItemCategory.Gem };

	static int _selectedTab = 0;
	static bool _initialTabSet = false;

	protected override void OnAfterTreeRender(bool firstTime)
	{
		base.OnAfterTreeRender(firstTime);
		if(firstTime)
		{
			GunPreviewController.Show();
			if(!_initialTabSet)
			{
				_selectedTab = BestStartingTab();
				_initialTabSet = true;
			}
		}
	}

	int BestStartingTab()
	{
		for(int i = 0; i < TabCategories.Length; i++)
			if(GetItemsForTab(i).Count > 0)
				return i;
		return 0;
	}

	List<ShopItemDef> GetItemsForTab(int tab)
	{
		if(tab < 0 || tab >= TabCategories.Length)
			return new List<ShopItemDef>();
		return ProgressManager.GetOwnedItemsByCategory(TabCategories[tab]);
	}

	void OnSelectItem(ShopItemDef item)
	{
		if(_selectedTab == 0)
		{
			var currentGunId = ProgressManager.GetSelectedGunId();
			var deselecting = currentGunId == item.Id;
			var targetDef = deselecting ? ProgressManager.DefaultGun : item;

			// Unequip gems that exceed the target gun's socket count
			var newSocketCount = targetDef.GemSocketCount > 0 ? targetDef.GemSocketCount : 3;
			var equippedGems = ProgressManager.GetEquippedGems();
			while(equippedGems.Count > newSocketCount)
			{
				ProgressManager.UnequipGem(equippedGems[equippedGems.Count - 1]);
				equippedGems = ProgressManager.GetEquippedGems();
			}

			// Trim selected charms to the target gun's charm slot count
			var newCharmSlots = targetDef.CharmSlotCount > 0 ? targetDef.CharmSlotCount : 1;
			var selectedCharms = ProgressManager.GetSelectedCharmIds();
			if(selectedCharms.Count > newCharmSlots)
				ProgressManager.SetSelectedCharmIds(selectedCharms.Take(newCharmSlots).ToList());

			ProgressManager.SetSelectedGunId(deselecting ? ProgressManager.DefaultGun.Id : item.Id);

			// Broadcast gun swap so all clients see the new model
			var player = Manager.Instance.LocalPlayer;
			if(player.IsValid())
			{
				var gunPrefab = ProgressManager.GetPrefabPath(targetDef.Id) ?? "prefabs/guns/gun_default.prefab";
				var charmIds = ProgressManager.GetSelectedCharmIds();
				var charm0 = charmIds.Count > 0 ? ProgressManager.GetPrefabPath(charmIds[0]) ?? "" : "";
				var charm1 = charmIds.Count > 1 ? ProgressManager.GetPrefabPath(charmIds[1]) ?? "" : "";
				var (g0, g1, g2, g3) = GetEquippedGemPrefabs();
				player.SwapGunRpc(gunPrefab, charm0, g0, g1, g2, g3, charm1);
			}
			GunPreviewController.Refresh();
		}
		else if(_selectedTab == 1)
		{
			var selectedIds = ProgressManager.GetSelectedCharmIds();
			var maxSlots = ProgressManager.GetSelectedGunCharmSlotCount();

			if(selectedIds.Contains(item.Id))
			{
				// Deselect
				ProgressManager.SetSelectedCharmIds(selectedIds.Where(id => id != item.Id).ToList());
			}
			else if(maxSlots == 1)
			{
				// Single-slot gun: replace
				ProgressManager.SetSelectedCharmIds(new List<string> { item.Id });
			}
			else if(selectedIds.Count < maxSlots)
			{
				// Multi-slot gun with a free slot: add
				ProgressManager.SetSelectedCharmIds(selectedIds.Concat(new[] { item.Id }).ToList());
			}
			else
			{
				// All slots full: do nothing, player must deselect first
				return;
			}

			// Broadcast charm swap so all clients see the new model
			var player = Manager.Instance.LocalPlayer;
			if(player.IsValid())
			{
				var ids = ProgressManager.GetSelectedCharmIds();
				var charm0 = ids.Count > 0 ? ProgressManager.GetPrefabPath(ids[0]) ?? "" : "";
				var charm1 = ids.Count > 1 ? ProgressManager.GetPrefabPath(ids[1]) ?? "" : "";
				player.SwapCharmRpc(charm0, charm1);
			}
			GunPreviewController.Refresh();
		}
		StateHasChanged();
	}

	void OnSelectGem(ShopItemDef gem)
	{
		var equipped = ProgressManager.GetEquippedGems();
		var maxSlots = ProgressManager.GetSelectedGunSocketCount();
		if(equipped.Contains(gem.Id))
			ProgressManager.UnequipGem(gem.Id);
		else if(equipped.Count < maxSlots)
			ProgressManager.EquipGem(gem.Id, maxSlots);

		BroadcastGemSwap();
		GunPreviewController.Refresh();
		StateHasChanged();
	}


	void BroadcastGemSwap()
	{
		var player = Manager.Instance.LocalPlayer;
		if(!player.IsValid()) return;
		var (g0, g1, g2, g3) = GetEquippedGemPrefabs();
		player.SwapGemsRpc(g0, g1, g2, g3);
	}

	static (string, string, string, string) GetEquippedGemPrefabs()
	{
		var equipped = ProgressManager.GetEquippedGems();
		string Get(int i) => i < equipped.Count ? (ProgressManager.GetPrefabPath(equipped[i]) ?? "") : "";
		return (Get(0), Get(1), Get(2), Get(3));
	}

	void SelectTab(int tabIndex)
	{
		if(_selectedTab == tabIndex) return;
		_selectedTab = tabIndex;
		StateHasChanged();
	}

	void Close()
	{
		GunPreviewController.Hide();
		Manager.Instance.ShowLoadoutPanel = false;
	}

	protected override int BuildHash()
	{
		return System.HashCode.Combine(
			Manager.Instance.ShowLoadoutPanel,
			_selectedTab,
			ProgressManager.StateVersion
		);
	}
}
@namespace Sandbox
@using Sandbox;
@using Sandbox.UI;
@using System;
@using System.Text.Json;
@inherits Panel
@attribute [StyleSheet("LobbyNametagPanel.razor.scss")]

<root>
	<LeaderboardPlayerIcon style="width: 42px; height: 42px;" PlayerInfo=@PlayerInfo />
	<div class="name">@PlayerInfo.displayName</div>
	@if(Networking.IsHost || PlayerInfo.steamId == Game.SteamId)
	{
		<i class="remove-btn" onclick=@KickPlayer>disabled_by_default</i>
	}
</root>

@code
{
	const int MAX_NAME_LENGTH = 13;

	public PlayerInfo PlayerInfo { get; set; }

	void KickPlayer()
	{
		Manager.Instance?.RequestKickPlayer(PlayerInfo.steamId);
	}
}
@namespace Sandbox
@using Sandbox;
@using Sandbox.UI;
@using System;
@inherits Panel
@attribute [StyleSheet("PlayerPerks.razor.scss")]

@{
	var player = Manager.Instance.SelectedPlayer.IsValid()
		? Manager.Instance.SelectedPlayer
		: Manager.Instance.LocalPlayer;

	if (!player.IsValid())
		return;

	var height = Manager.Instance.Players.Count > 1 ? 880f : 950f;
}

<root style="height: @(height)px; max-height: @(height)px;">
	@if( player.IsProxy )
	{
		if (player.SyncPerks.Count == 0)
			return;

		int index = 0;
		<div class="itemlist" style="opacity:@(1f);">
			@foreach (var pair in player.SyncPerks)
			{
				var rot = Utils.Map((player.PerkRandomRotationSeed + index * 3f) % 10, 0, 9, -5f, 5f);
				<PerkIconStatic style="width: 50px; height: 50px; transform: rotate(@(rot)deg);" PerkType=@(PerkManager.IdentityToType(pair.Key)) Level=@(pair.Value) ProgressLevel=@(pair.Value) Banished=@false />

				index++;
			}
		</div>
	}
	else
	{
		if (player.Perks.Count == 0)
			return;

		int index = 0;
		<div class="itemlist @(Manager.Instance.IsGameOver ? "" : "itemlist_hover")">
			@foreach (var (_, perk) in player.Perks)
			{
				var rot = Utils.Map((player.PerkRandomRotationSeed + index * 3f) % 10, 0, 9, -5f, 5f);
				<PerkIcon style="width: 50px; height: 50px;" Perk=@perk Angle=@rot Banished=@false />

				index++;
			}
		</div>
	}
</root>

@code
{
	protected override int BuildHash()
	{
		var player = Manager.Instance.SelectedPlayer.IsValid()
			? Manager.Instance.SelectedPlayer
			: Manager.Instance.LocalPlayer;

		var perkHash = player.IsValid()
			? player.PerkHash
			: 0;

		var perkCount = player.IsValid()
			? player.SyncPerks.Count
			: 0;

		return HashCode.Combine(
			player, 
			// player.Id // todo: this instead?
			perkCount,
			perkHash,
			Manager.Instance.IsGameOver
			// Time.Now
		);
	}
}
@using Sandbox;
@using Sandbox.UI;
@using Sandbox.Mounting;
@inherits Panel
@namespace Sandbox

<root>

<Button class="menu-action primary wide" Icon="💾" Text="#spawnmenu.dupes.save_button" Disabled=@( !CanSaveDupe() ) @onclick=@MakeSave></Button>

</root>

@using Sandbox;
@using Sandbox.UI;
@inherits Panel
@namespace Sandbox

@{
    var data = GetData();
}

<SpawnMenuContent>

    <Header>
        <SpawnMenuToolbar>
            <Left>
                <h2>@data.Name</h2>
            </Left>
            <Right>
                @{
                    var workshopId = Entry.GetMeta( "_workshopId", 0u );
                    var isPublished = workshopId > 0;
                }
                <div class="menu-icon-toggle-group">
                    @if ( isPublished )
                    {
                        <IconPanel Tooltip="#spawnmenu.spawnlist.copy_url" Text="link" @onclick=@( () => Clipboard.SetText( $"https://steamcommunity.com/sharedfiles/filedetails/?id={workshopId}" ) ) />
                        <IconPanel Tooltip="#spawnmenu.spawnlist.sync" Text="sync" @onclick=@( () => OnPublish() ) />
                    }
                    else
                    {
                        <IconPanel Tooltip="#spawnmenu.spawnlist.publish" Text="cloud_upload" @onclick=@( () => OnPublish() ) />
                    }
                    @if ( !Entry.Files.IsReadOnly )
                    {
                        <IconPanel Tooltip="#spawnmenu.spawnlist.delete" Text="delete" @onclick=@( () => OnDelete() ) />
                    }
                    else
                    {
                        <IconPanel Tooltip="#spawnmenu.spawnlist.remove" Text="delete" @onclick=@( () => OnUninstall() ) />
                    }
                </div>
            </Right>
        </SpawnMenuToolbar>
    </Header>

    <Body>
        @if ( data.Items.Count == 0 )
        {
            <div class="empty-state">
                <p>#spawnmenu.spawnlist.empty_title</p>
                <p>#spawnmenu.spawnlist.empty_instructions</p>
            </div>
        }
        else
        {
            <VirtualGrid [email protected] ItemSize=@(120)>
                <Item Context="item">
                    @if ( item is SpawnlistItem spawnItem )
                    {
                        <SpawnMenuIcon Ident="@spawnItem.Ident" Title="@spawnItem.Title" Icon="@spawnItem.Icon" />
                    }
                </Item>
            </VirtualGrid>
        }
    </Body>

</SpawnMenuContent>

@code
{
    public Storage.Entry Entry { get; set; }

    SpawnlistData _cachedData;
    RealTimeSince _lastRefresh;

    SpawnlistData GetData()
    {
        if ( _cachedData == null )
            RefreshCache();

        return _cachedData;
    }

    public void RefreshCache()
    {
        _cachedData = SpawnlistData.Load( Entry );
        _lastRefresh = 0;
    }

    public override void Tick()
    {
        base.Tick();

        // Periodically check for changes from other tabs
        if ( _lastRefresh > 1f )
        {
            var fresh = SpawnlistData.Load( Entry );
            if ( fresh.Items.Count != _cachedData?.Items?.Count )
            {
                _cachedData = fresh;
                StateHasChanged();
            }
            _lastRefresh = 0;
        }
    }

    protected override int BuildHash() => HashCode.Combine( _cachedData?.Items?.Count );

    void OnPublish()
    {
        if ( Entry is null ) return;
        SpawnlistData.Publish( Entry );
    }

    void OnDelete()
    {
        if ( Entry is null ) return;
        var page = Ancestors.OfType<SpawnlistsPage>().FirstOrDefault();
        page?.Collection.Delete( Entry );
    }

    void OnUninstall()
    {
        if ( Entry is null ) return;
        var workshopId = Entry.GetMeta( "_workshopId", 0ul );
        var page = Ancestors.OfType<SpawnlistsPage>().FirstOrDefault();
        page?.Collection.Uninstall( workshopId );
    }
}

@using Sandbox;
@using Sandbox.UI;
@namespace Sandbox
@inherits Panel

<root>

    <div class="menu-segmented-group">
    @{

        var activeMode = SpawnMenuHost.GetActiveMode();


        foreach ( var mode in Game.TypeLibrary.GetTypesWithAttribute<SpawnMenuHost.SpawnMenuMode>().OrderBy( x => x.Type.Order ) )
        {
            if ( !mode.Attribute.CheckCondition() ) continue;

            var activeClass = mode.Type.TargetType == activeMode?.GetType() ? "active" : "";

            <div class="menu-mode-button @activeClass" @onclick="@( () => SpawnMenuHost.SwitchMode( mode.Type.Name ) )">
                <div class="icon">@mode.Type.Icon</div>
                <div class="title">@mode.Type.Title</div>                
            </div>
        }
    }
    </div>

</root>

@code
{
    protected override int BuildHash() => HashCode.Combine( SpawnMenuHost.GetActiveMode(), Game.TypeLibrary.GetTypesWithAttribute<SpawnMenuHost.SpawnMenuMode>() );
}

@namespace Sandbox
@using Sandbox;
@using Sandbox.UI;
@using System;
@inherits Panel
@attribute [StyleSheet("InfoPanel.razor.scss")]

<root>
	@{
		// todo: split each ui line into its own component

		var xp_percent = Player.ExperienceCurrent / (float)Player.ExperienceRequired;
		var xp_transition_enabled = Player.GetUiStat( PlayerStat.DisableXpBarTransition ) == 0f;
		var hp_percent = Math.Clamp(Math.Max(Player.Health, 0f) / Player.GetSyncStat(PlayerStat.MaxHp), 0f, 1f);
		// var hp_regen = Player.Stats[PlayerStat.HealthRegen] + (Player.IsMoving ? 0f : Player.Stats[PlayerStat.HealthRegenStill]);
		var hp_regen = Player.GetHpRegenAmount(forDisplay: true);
		var hpBarHidden = Player.GetUiStat( PlayerStat.HpBarHidden ) > 0f;
	}

	<div class="info_bar">
		<div class="bar_container" style=@("mask-image: url(\"/textures/ui/panel/info_panel_xp_mask.png\");")>
			<div class="info_bar_overlay @(xp_transition_enabled ? "xp_transition_on" : "")" style="width:@(xp_percent * 100f)%; background-color: #8888ff77;"></div>

			@if(Manager.Instance.Difficulty >= Manager.Instance.FirstDifficultyWithCurses && Player.AvailableCurseCount > 0)
			{
				var cursePercent = Player.IsBeingShownCurseChoices ? 1f : Utils.Map(Player.CurrLevelsUntilCurseChoice, Player.NumLevelsBetweenCurseChoices, 0, 0f, 1f);
				var colorSpeed = Player.IsBeingShownCurseChoices ? 15f : Utils.Map(Player.CurrLevelsUntilCurseChoice, Player.NumLevelsBetweenCurseChoices, 0, 3f, 15f, EasingType.SineIn);

				var curseColor = Color.Lerp(new Color(0.2f, 0.1f, 0.4f, 0.5f), new Color(0.8f, 0.1f, 0.9f, 0.25f), Utils.Map(Utils.FastSin(RealTime.Now * colorSpeed), -1f, 1f, 0f, 1f));

				<div class="info_bar_overlay" style="width:@(cursePercent * 100f)%; background-color: @curseColor.Rgba;"></div>
			}
		</div>
		
		<div class="info_bar_label">XP</div>

		<div class="data_container">
			<div class="data" style="opacity: 0.3;">@($"LVL {Player.Level}")</div>
		</div>
	</div>

	@{
		var regularMaxDashes = (int)MathF.Round( Player.GetUiStat( PlayerStat.NumDashes ) );
		var maxDashes = regularMaxDashes + Player.NumTempDashesAvailable;
		var totalAvailable = Player.NumDashesAvailable + Player.NumTempDashesAvailable;
 	}

	@if( maxDashes < 9 )
	{
		<div class="info_dash_container" style="gap: @(Utils.Map(maxDashes, 1, 8, 8, 4, EasingType.QuadIn))px;">
			<div class="dash_bar_container")>
				@for(int i = 0; i < maxDashes; i++)
				{
					// Bars i < regularMaxDashes are regular dashes; i >= regularMaxDashes are temporary (from PerkDashTemporary).
					// Temp bars are always shown to the right and colored purple.
					var isTemp = i >= regularMaxDashes;
					float progress;
					bool isRecharging;
					if ( isTemp )
					{
						// Temp dashes are either fully available or gone — no partial recharge animation.
						int tempIndex = i - regularMaxDashes;
						progress = tempIndex < Player.NumTempDashesAvailable ? 1f : 0f;
						isRecharging = false;
					}
					else
					{
						// The bar at NumDashesAvailable is the one currently recharging (partial fill).
						// Bars below it are full; bars above it are empty.
						progress = i == Player.NumDashesAvailable ? Player.DashRechargeProgress : (i < Player.NumDashesAvailable ? 1f : 0f);
						isRecharging = i >= Player.NumDashesAvailable;
					}
					var filledColor   = isTemp ? "#b34dff88" : "#00ff0088";
					var rechargingColor = isTemp ? "#b34dff44" : "#00ff4444";

					<div class="info_bar" style="background-color: #00000077;">
						<div class="info_bar_overlay" style="width:@(progress * 100f)%; background-color: @(isRecharging ? rechargingColor : filledColor);"></div>
					</div>
				}
			</div>

			@if(maxDashes <= 0) 
			{
				<div class="info_bar">
				</div>
			}

			<div class="info_bar_label" style="left: 8px;">
				@("DASH")
			</div>
		</div>
	}
	else
	{
		var dashPercent = (float)totalAvailable / (float)maxDashes;

		<div class="info_bar">
			<div class="info_bar_overlay" style="width:@(dashPercent * 100f)%; background-color: #00ff0055; transition: all 0.2s ease-in-out;"></div>
			<div class="info_bar_label">DASH</div>

			<div class="data_container">
				<div class="data">@maxDashes</div>
				<div class="data">/</div>
				<div class="data">@totalAvailable</div>
			</div>
		</div>
	}

	<div class="info_bar">
		<div class="bar_container" style=@("mask-image: url(\"/textures/ui/panel/info_panel_hp_mask.png\");")>
			@if ( hpBarHidden )
			{
				<div class="info_bar_overlay" style="width: 100%; background-color: #7a0040ff;"></div>
			}
			else
			{
				<div class="info_bar_overlay" style="width:@(hp_percent * 100f)%; background-color: #ffffffff; transition: all 0.4s ease-in-out "></div>
				<div class="info_bar_overlay" style="width:@(hp_percent * 100f)%; background-color: #ff0000ff; transition: all 0.2s ease-in-out;"></div>
			}
		</div>

		<div class="info_bar_label">HP</div>

		<div class="data_container">
			<div class="data">@($"{(int)MathF.Round(Player.GetSyncStat(PlayerStat.MaxHp))}")</div>
			<div class="data">/</div>
			@if ( hpBarHidden )
			{
				<div class="data">?</div>
			}
			else
			{
				<div class="data">@($"{(Math.Max(Player.Health, 0f) > 0f && Math.Max(Player.Health, 0f) < 1f ? (int)Math.Ceiling(Math.Max(Player.Health, 0f)) : (int)Math.Round(Math.Max(Player.Health, 0f)))}")</div>

				@if (Math.Abs(hp_regen) > 0f)
				{
					<div class="data" style="color:@((hp_regen > 0f ? new Color(0f, 1f, 0f) : new Color(1f, 0f, 0f)).Rgba); letter-spacing: 2px; padding-right: 10px;">@($"{(hp_regen > 0f ? "+" : "")}{hp_regen.ToString("0.##")}")</div>
				}
			}
		</div>
	</div>

	@if (Player.Armor > 0)
	{
		<div class="armor_icon" style="transform: scale(@(Utils.Map(Player.RealTimeSinceArmorChanged, 0f, 0.5f, 1.35f, 1f, EasingType.QuadOut)));">
			<label class="armor_text">@($"{MathX.CeilToInt(Player.Armor)}")</label>
		</div>
	}
</root>

@code
{
	public Player Player { get; set; }

	protected override int BuildHash()
	{
		if( Manager.Instance.Difficulty >= Manager.Instance.FirstDifficultyWithCurses )
		{
			return HashCode.Combine(RealTime.Now);
		}

		var hpBarHidden = Player.GetUiStat( PlayerStat.HpBarHidden ) > 0f;

		var hpHash = HashCode.Combine(
			Player.Health,
			Player.GetSyncStat(PlayerStat.MaxHp),
			Player.GetSyncStat( PlayerStat.HealthRegen ),
			Player.GetSyncStat( PlayerStat.HealthRegenStill ),
			Player.IsMoving,
			Player.GetHpRegenAmount( forDisplay: true ),
			hpBarHidden
		);

		var armorHash = HashCode.Combine(
			Player.RealTimeSinceArmorChanged > 0.5f ? 0f : Player.RealTimeSinceArmorChanged.Relative,
			Player.Armor
		);

		var xpHash = HashCode.Combine(
			Player.Level,
			Player.ExperienceCurrent,
			Player.ExperienceRequired
		);
		
		return HashCode.Combine(
			Player.DashRechargeProgress,
			Player.NumDashesAvailable,
			Player.NumTempDashesAvailable,
			Player.GetUiStat( PlayerStat.NumDashes ),
			hpHash,
			armorHash,
			xpHash
		);
	}
}
@using Sandbox;
@using Sandbox.UI;
@inherits Panel
@namespace Sandbox

<root class="tt">

    <div class="icon">@Icon</div>
    <div class="title">@Title</div>
    <div class="description">@Description</div>

</root>

@code
{
    [Parameter] public string Title { get; set; }
    [Parameter] public string Icon { get; set; }
    [Parameter] public string Description { get; set; }
}
@using Sandbox;
@using Sandbox.UI;
@using Sandbox.Mounting;
@inherits Panel
@namespace Sandbox

<root>

	<div class="title">@Item.Title</div>

	<div class="author">

		<div class="avatar" style="background-image: url('@Item.Owner.Avatar');"></div>
		<div class="name">@Item.Owner.Name</div>

	</div>

</root>

@code
{
    public Storage.QueryItem Item { get; set; }

    public override bool WantsDrag => true;

    protected override void OnParametersSet()
    {
        Style.SetBackgroundImage( Item.Preview );
    }

    protected override void OnMouseDown( MousePanelEvent e )
    {
        if ( e.MouseButton == MouseButtons.Right )
        {
            var menu = MenuPanel.Open( this );

            SpawnlistData.PopulateContextMenu( menu, new SpawnlistItem
            {
                Ident = SpawnlistItem.MakeIdent( "dupe", Item.Id.ToString(), "workshop" ),
                Title = Item.Title,
                Icon = Item.Preview,
            } );

            return;
        }

        base.OnMouseDown( e );
    }

    protected override async void OnDragStart( DragEvent e )
    {
        var data = new DragData
        {
            Type = "dupe",
            Icon = Item.Preview,
            Title = Item.Title,
            Source = this
        };

        DragHandler.StartDragging( data );

		var installed = await Item.Install();
		if ( installed is null ) return;

		data.Data = installed.Files.ReadAllText( "/dupe.json" );
	}

    protected override void OnDragEnd(DragEvent e)
    {
        if ( DragHandler.IsDragging )
        {
            DragHandler.StopDragging();
        }
    }
}
@using Sandbox;
@using Sandbox.UI;

@inherits Panel
@namespace Sandbox

@if ( LastResult is null )
{
    // loading
    return;
}

<SpawnMenuContent>

    <Header>
        <SpawnMenuToolbar>
            <Left>
                <TextEntry Placeholder="#spawnmenu.common.search" class="filter menu-input" Value:bind=@Filter />
            </Left>

            <Right>
                <DropDown Value:bind=@SortOrder />
            </Right>
        </SpawnMenuToolbar>
    </Header>

    <Body>
<VirtualGrid Items=@( LastResult.Packages ) ItemSize=@(120)>
    <Item Context="item">
        @if (item is Package entry)
        {
            <SpawnMenuIcon Ident=@($"prop:{entry.FullIdent}") Title="@entry.Title"></SpawnMenuIcon>
        }
    </Item>
</VirtualGrid>
    </Body>

</SpawnMenuContent>

@code
{
    public string Category { get; set; } = "";

    private string Filter
    {
        get;
        set { field = value; Rebuild(); }
    }

    public PackageSortMode SortOrder
    {
        get;
        set { field = value; Rebuild(); }
    }

    protected override void OnParametersSet()
    {
        SortOrder = PackageSortMode.Popular;
        Rebuild();
    }

    Package.FindResult LastResult;

    async void Rebuild()
    {
        var query = $"sort:{SortOrder.ToIdentifier()} type:model";
        if ( !string.IsNullOrEmpty( Filter ) ) query += $" {Filter} ";
        if ( !string.IsNullOrEmpty( Category ) ) query += $" category:{Category}";

        LastResult = await Package.FindAsync( query );
        StateHasChanged();
    }
}
@using Sandbox.UI

@namespace sGBA

@inherits PanelComponent

<root class="@(IsVisible ? "visible" : "") @(_showConfirmDialog ? "confirm-open" : "") @(_showSavingDialog ? "saving-open" : "") @(InNetworkedSession ? "networked" : "")">
	<div class="overlay">
		<div class="menu-container">
			<div class="menu-left @(_inSlotPanel ? "in-slots" : "")">
				@for (int i = 0; i < ActiveMenuItems.Length; i++)
				{
					var idx = i;
					<div class="menu-item @(idx == _selectedIndex ? "selected" : "")" onmouseenter=@(() => SelectItem(idx)) onclick=@(() => ActivateItemWithMouse(idx))>
						@ActiveMenuItems[idx]
					</div>
				}
			</div>
			<div class="menu-divider @(HasSlotPanel ? "visible" : "")"></div>
			<div class="menu-right @(HasSlotPanel ? "visible" : "")">
				@for (int i = 1; i <= GbaSerialize.SlotCount; i++)
				{
					var slot = i;
					<div class="slot @(slot == _highlightedSlot && HasSlotPanel ? "highlighted" : "")" onmouseenter=@(() => SelectSlotWithMouse(slot)) onclick=@(() => ActivateSlotWithMouse(slot))>
						<div class="slot-preview">
							@if (SlotTextures[slot - 1] != null)
							{
								<Image Texture=@SlotTextures[slot - 1] />
							}
							else
							{
								<span>#pause.slot.empty</span>
							}
						</div>
						<div class="slot-info">
							<div class="slot-label"><span>#pause.slot.label</span><span>@slot</span></div>
							@if (SlotTimestamps[slot - 1] != null)
							{
								<div class="slot-timestamp">@SlotTimestamps[slot - 1].Value.ToString("dd/MM/yyyy - HH:mm")</div>
							}
						</div>
					</div>
				}
			</div>
		</div>
	</div>

	<div class="dialog-overlay @(_showConfirmDialog ? "visible" : "")">
		<div class="confirm-box">
			<div class="dialog-message">#pause.confirm.message</div>
			<div class="confirm-buttons">
				<div class="confirm-btn @(_confirmSelection == 0 ? "selected" : "")" onmouseenter=@(() => SelectConfirmWithMouse(0)) onclick=@(() => ConfirmOverwriteWithMouse())>#pause.confirm.yes</div>
				<div class="confirm-btn @(_confirmSelection == 1 ? "selected" : "")" onmouseenter=@(() => SelectConfirmWithMouse(1)) onclick=@(() => CancelOverwriteWithMouse())>#pause.confirm.no</div>
			</div>
		</div>
	</div>

	<div class="dialog-overlay @(_showSavingDialog ? "visible" : "")">
		<div class="dialog-box">
			<div class="dialog-message">#pause.saving</div>
		</div>
	</div>
</root>

@code
{
	private const int MenuContinue = 0;
	private const int MenuLoad = 1;
	private const int MenuCreate = 2;
	private const int MenuControls = 3;
	private const int MenuReset = 4;
	private const int MenuGoToHome = 5;

	private readonly string[] MenuItems =
	[
		"#pause.continue",
		"#pause.load",
		"#pause.create",
		"#pause.controls",
		"#pause.reset",
		"#pause.home"
	];

	private static readonly string[] ClientMenuItems =
	[
		"#pause.continue",
		"#pause.controls",
		"#pause.home"
	];

	private static bool InNetworkedSession => NetworkManager.Current?.IsActive == true;
	private string[] ActiveMenuItems => InNetworkedSession ? ClientMenuItems : MenuItems;

	public bool IsVisible { get; private set; }
	private int _selectedIndex;
	private string SelectedKey => _selectedIndex >= 0 && _selectedIndex < ActiveMenuItems.Length ? ActiveMenuItems[_selectedIndex] : null;
	private bool HasSlotPanel => SelectedKey is "#pause.load" or "#pause.create";
	private bool IsLoadMode => SelectedKey == "#pause.load";

	private bool _inSlotPanel;
	private int _highlightedSlot = 1;

	private readonly Texture[] SlotTextures = new Texture[GbaSerialize.SlotCount];
	private readonly bool[] SlotOccupied = new bool[GbaSerialize.SlotCount];
	private readonly DateTime?[] SlotTimestamps = new DateTime?[GbaSerialize.SlotCount];
	private int _slotVersion;

	private bool _showConfirmDialog;
	private int _confirmSelection;
	private int _pendingOverwriteSlot;
	private bool _showSavingDialog;

	private readonly FocusInput _input = new();

	protected override void OnUpdate()
	{
		if (!EmulatorComponent.Current.IsValid() || !EmulatorComponent.Current.IsReady)
			return;

		if (Input.EscapePressed)
		{
			Input.EscapePressed = false;

			if (IsVisible && !new Game.Overlay().IsOpen)
				Resume();
			else if (!IsVisible)
				Pause();

			return;
		}

		if (!IsVisible) return;
		if (new Game.Overlay().IsOpen) return;

		if (_showConfirmDialog)
		{
			HandleConfirmInput();
			return;
		}

		if (_showSavingDialog) return;

		var nav = _input.TickRepeating();

		if (nav.Up) NavigateUp();
		if (nav.Down) NavigateDown();

		if (nav.Right)
		{
			if (!_inSlotPanel && HasSlotPanel)
				EnterSlotPanel();
		}

		if (nav.Left)
		{
			if (_inSlotPanel)
				ExitSlotPanel();
		}

		if (Input.Pressed("GBA_A"))
		{
			SetGamepadMode();
			Sound.Play("ui.button.press");

			if (_inSlotPanel)
				ActivateSlot(_highlightedSlot);
			else if (HasSlotPanel)
				EnterSlotPanel();
			else
				ActivateItem(_selectedIndex);
		}

		if (Input.Pressed("GBA_B"))
		{
			SetGamepadMode();
			Sound.Play("ui.button.press");

			if (_inSlotPanel)
				ExitSlotPanel();
			else
				Resume();
		}
	}

	private void SetGamepadMode() => _input.ForceGamepadMode();

	private void NavigateUp()
	{
		SetGamepadMode();
		Sound.Play("ui.button.over");

		if (_inSlotPanel)
			_highlightedSlot = _highlightedSlot <= 1 ? GbaSerialize.SlotCount : _highlightedSlot - 1;
		else
			_selectedIndex = _selectedIndex <= 0 ? ActiveMenuItems.Length - 1 : _selectedIndex - 1;
	}

	private void NavigateDown()
	{
		SetGamepadMode();
		Sound.Play("ui.button.over");

		if (_inSlotPanel)
			_highlightedSlot = _highlightedSlot >= GbaSerialize.SlotCount ? 1 : _highlightedSlot + 1;
		else
			_selectedIndex = _selectedIndex >= ActiveMenuItems.Length - 1 ? 0 : _selectedIndex + 1;
	}

	private void EnterSlotPanel()
	{
		SetGamepadMode();
		Sound.Play("ui.button.press");
		_inSlotPanel = true;
		_highlightedSlot = 1;
	}

	private void ExitSlotPanel()
	{
		SetGamepadMode();
		Sound.Play("ui.button.press");
		_inSlotPanel = false;
		_selectedIndex = MenuContinue;
	}

	private void Pause()
	{
		IsVisible = true;
		_selectedIndex = 0;
		_inSlotPanel = false;
		_highlightedSlot = 1;
		_input.Begin(useGamepad: true);
		Sound.Play("ui.popup.message.open");
		EmulatorComponent.Current.SetPaused(true);
		RefreshSlotPreviews();
	}

	private void Resume()
	{
		IsVisible = false;
		_selectedIndex = MenuContinue;
		_inSlotPanel = false;
		_input.End();
		Mouse.Visibility = MouseVisibility.Hidden;
		Sound.Play("ui.popup.message.close");
		EmulatorComponent.Current.SetPaused(false);
	}

	private void SelectItem(int index)
	{
		_input.ForceMouseMode();
		_selectedIndex = index;
		_inSlotPanel = false;
		if (HasSlotPanel)
			_highlightedSlot = 1;
	}

	private void SelectSlotWithMouse(int slot)
	{
		_input.ForceMouseMode();
		_highlightedSlot = slot;
	}

	private void ActivateSlotWithMouse(int slot)
	{
		_input.ForceMouseMode();
		ActivateSlot(slot);
	}

	private void SelectConfirmWithMouse(int index)
	{
		_input.ForceMouseMode();
		_confirmSelection = index;
	}

	private void ConfirmOverwriteWithMouse()
	{
		_input.ForceMouseMode();
		ConfirmOverwrite();
	}

	private void CancelOverwriteWithMouse()
	{
		_input.ForceMouseMode();
		CancelOverwrite();
	}

	private void ActivateItem(int index)
	{
		if (index < 0 || index >= ActiveMenuItems.Length) return;

		switch (ActiveMenuItems[index])
		{
			case "#pause.continue":
				Resume();
				break;
			case "#pause.load":
			case "#pause.create":
				SelectItem(index);
				_inSlotPanel = true;
				break;
			case "#pause.controls":
				Game.Overlay.ShowBinds();
				break;
			case "#pause.reset":
				EmulatorComponent.Current.ResetEmulator();
				Resume();
				break;
			case "#pause.home":
				IsVisible = false;
				_selectedIndex = MenuContinue;
				_inSlotPanel = false;
				Mouse.Visibility = MouseVisibility.Hidden;
				Sound.Play("ui.popup.message.close");
				NetworkManager.Current?.Leave();
				HomeScreen.Current?.Show();
				break;
		}
	}

	private void ActivateItemWithMouse(int index)
	{
		_input.ForceMouseMode();
		ActivateItem(index);
	}

	private void ActivateSlot(int slot)
	{
		if (IsLoadMode)
		{
			if (!SlotOccupied[slot - 1])
				return;

			EmulatorComponent.Current.LoadSuspendPoint(slot);
			Resume();
		}
		else
		{
			if (SlotOccupied[slot - 1])
			{
				_pendingOverwriteSlot = slot;
				_confirmSelection = 0;
				_showConfirmDialog = true;
				Sound.Play("ui.popup.message.open");
				return;
			}

			DoSave(slot);
		}
	}

	private void HandleConfirmInput()
	{
		var nav = _input.TickRepeating();
		if (nav.Up || nav.Down)
		{
			SetGamepadMode();
			Sound.Play("ui.button.over");
			_confirmSelection = _confirmSelection == 0 ? 1 : 0;
		}

		if (Input.Pressed("GBA_A"))
		{
			SetGamepadMode();
			Sound.Play("ui.button.press");

			if (_confirmSelection == 0)
				ConfirmOverwrite();
			else
				CancelOverwrite();
		}

		if (Input.Pressed("GBA_B"))
		{
			SetGamepadMode();
			Sound.Play("ui.button.press");
			CancelOverwrite();
		}
	}

	private void ConfirmOverwrite()
	{
		_showConfirmDialog = false;
		DoSave(_pendingOverwriteSlot);
	}

	private void CancelOverwrite()
	{
		_showConfirmDialog = false;
		Sound.Play("ui.popup.message.close");
	}

	private async void DoSave(int slot)
	{
		_showSavingDialog = true;
		await Task.Delay(100);
		EmulatorComponent.Current.CreateSuspendPoint(slot);
		RefreshSlotPreviews();
		await Task.Delay(500);
		_showSavingDialog = false;
	}

	private void RefreshSlotPreviews()
	{
		var emu = EmulatorComponent.Current;
		if (!emu.IsValid()) return;

		for (int i = 0; i < GbaSerialize.SlotCount; i++)
		{
			var path = emu.GetStatePath(i + 1);

			if (!FileSystem.Data.FileExists(path))
			{
				ClearSlot(i);
				continue;
			}

			var data = FileSystem.Data.ReadAllBytes(path).ToArray();
			SlotOccupied[i] = true;
			SlotTimestamps[i] = GbaSerialize.ReadTimestamp(data);

			var screenshot = GbaSerialize.ReadScreenshot(data);
			if (screenshot == null) continue;

			if (SlotTextures[i] == null)
			{
				SlotTextures[i] = Texture.Create(GbaConstants.ScreenWidth, GbaConstants.ScreenHeight, ImageFormat.RGBA8888)
					.WithDynamicUsage()
					.WithName($"suspend-preview-{i}")
					.Finish();
			}

			SlotTextures[i].Update(screenshot, 0, 0, GbaConstants.ScreenWidth, GbaConstants.ScreenHeight);
		}

		_slotVersion++;
	}

	private void ClearSlot(int index)
	{
		SlotOccupied[index] = false;
		SlotTimestamps[index] = null;
		SlotTextures[index]?.Dispose();
		SlotTextures[index] = null;
	}

	protected override int BuildHash()
	{
		return HashCode.Combine(
			IsVisible, _selectedIndex, _inSlotPanel, _highlightedSlot,
			_input.UseGamepad, _slotVersion, _showConfirmDialog,
			HashCode.Combine(_confirmSelection, _showSavingDialog, InNetworkedSession)
		);
	}

	protected override void OnDestroy()
	{
		for (int i = 0; i < SlotTextures.Length; i++)
		{
			SlotTextures[i]?.Dispose();
			SlotTextures[i] = null;
		}
	}
}
@using System.Threading.Tasks

@namespace sGBA

@inherits PanelComponent

<root class="@RootClass">
	<div class="home-screen">
		<HomeBackdrop Current=@_currentBackdrop Previous=@_previousBackdrop PreviousOpacity=@PreviousBackdropOpacity />

		@if (ToastVisible)
		{
			<HomeToast Title=@_toast.Title Message=@_toast.Message Icon=@_toast.Icon ColorClass=@_toast.ColorClass InlineStyle=@ToastStyle />
		}

		@if (_allSoftwareOpen)
		{
			<div class="all-software-screen">
				<div class="all-software-rule top" />
				<div class="all-software-rule bottom" />

				<div class="all-software-scroll" @ref=AllSoftwareScroll>
					<div class="all-software-grid" style="@AllSoftwareGridStyle">
					@foreach (var gridItem in AllSoftwareGridItems)
					{
						Texture gridCover = null;
						if (gridItem.Game != null)
							_boxArt.TryGetValue(gridItem.Game.Path, out gridCover);

						<AllSoftwareCard @key="gridItem.Key"
							[email protected]
							Cover=@gridCover
							Selected=@IsAllSoftwareSelected(gridItem.Index)
							PositionStyle=@AllSoftwareGridItemStyle(gridItem.Row, gridItem.Column)
							CardLeft=@(gridItem.Column * (AllSoftwareCardWidth + AllSoftwareColumnGap))
							CardWidth=@AllSoftwareCardWidth
							GridWidth=@AllSoftwareGridWidth
							ShowTitleOnTop=@ShouldShowAllSoftwareTitleOnTop(gridItem.Index)
							OnActivate=@(() => ActivateAllSoftwareItemWithMouse(gridItem.Index)) />
					}

					<div class="all-add-row" style="@AllSoftwareAddRowStyle">
						<div class="@AllSoftwareAddButtonClass" onmouseenter=@UseMouseInAllSoftware onclick=@ActivateAllSoftwareAdd>
							<SelectionRing Active=@IsAllSoftwareAddSelected StrokeWidth=@(8f) CornerRadius=@(10f) Gap=@(0f) class="all-add-button-ring" />
							<div class="all-add-button-fill" />
							<div class="all-add-button-label">#addgames.card.label</div>
						</div>
					</div>
					</div>
				</div>

				@if (AllSoftwareShowsScrollbar)
				{
					<div class="all-scrollbar" @ref=AllSoftwareScrollbar onmousedown=@OnAllSoftwareScrollbarMouseDown>
						<div class="all-scrollbar-thumb" @ref=AllSoftwareScrollbarThumb style="@AllSoftwareScrollbarThumbStyle" />
					</div>
				}
			</div>
		}
		else
		{
			<div class="profile-avatar" style="background-image: url( avatar:@LocalSteamId )" />

			@if (!IsHomeViewMoreSelected)
			{
				<HomeLogo Logo=@SelectedLogo Title=@SelectedTitle />
			}

			<HomeCarousel Items=@CarouselItems SelectedIndex=@_selectedIndex NavFocused=@_navFocused ShowSelection=@ShowHomeSelection RenderVersion=@_homeCarouselRenderVersion ArtworkVersion=@_artworkVersion MountedRange=@CarouselMountedRange Suppress=@_suppressHomeCarouselTransitions CoverFor=@((GameEntry g) => _boxArt.GetValueOrDefault(g.Path)) OnActivate=@((int index) => ActivateCarouselItem(index)) />

			<HomeNavPill Items=@NavItems SelectedIndex=@_navSelection Focused=@_navFocused ShowSelection=@(ShowHomeSelection && _input.UseGamepad && _navFocused) OnHover=@((int i) => SelectNavItem(i, useGamepad: false)) OnActivate=@((int i) => ActivateNavItem(i, useGamepad: false)) />
		}

	</div>
</root>

@code
{
	public static HomeScreen Current { get; private set; }

	private List<GameEntry> _games = new();
	private List<GameEntry> _allGames = new();
	private List<GameEntry> _homeItems = new();
	private HashSet<string> _knownPaths = new();
	private float _pollTimer;
	private const float PollInterval = 3f;
	private Dictionary<string, Texture> _boxArt = new();
	private Dictionary<string, Texture> _logos = new();
	private Dictionary<string, Texture> _snaps = new();
	private Dictionary<string, Texture> _titles = new();
	private int _artworkVersion;
	private int _artworkRequestId;
	private int _selectedIndex;
	private int _allSoftwareSelection;
	private bool _homeOrderDirty;
	private bool _suppressHomeCarouselTransitions;
	private int _homeCarouselTransitionSuppressionFrames;
	private int _homeCarouselRenderVersion;
	private float _homeCarouselSettleRemaining;
	private GameEntry _queuedHomeLaunchGame;
	private string _pendingFrozenHomeLaunchPath;
	private int _pendingFrozenHomeLaunchFrames;
	private string _pendingHomeSelectionPath;
	private int _navSelection = 2;
	private bool _navFocused;
	private bool _isVisible = true;
	private bool _allSoftwareOpen;
	private bool _addGamesModalOpen;
	private readonly HomeToastState _toast = new();
	private const int HomeGameLimit = 12;
	private const int CarouselMountedRange = 9;
	private const float BackdropRetainDuration = 0.32f;
	private Texture _currentBackdrop;
	private Texture _previousBackdrop;
	private float _previousBackdropAge;
	private bool _previousBackdropFading;
	private int _backdropVersion;
	private readonly List<PendingTextureDispose> _pendingTextureDisposals = new();
	private const float HomeCarouselSettleDuration = 0.2f;

	private sealed record ArtworkRequest(GameEntry Game, ThumbType Kind, Dictionary<string, Texture> Target);
	private sealed record ArtworkLoad(ArtworkRequest Request, Texture Texture);
	private readonly record struct PendingTextureDispose(string Key, Texture Texture, float Age);

	private readonly NavItem[] NavItems =
	[
		new("group", "orange", "#nav.netplay"),
		new("newspaper", "green", "#nav.news"),
		new("cloud", "blue", "#nav.cloud"),
		new("settings", "gray", "#nav.settings"),
		new("power_settings_new", "gray", "#nav.quit")
	];

	private readonly FocusInput _input = new();

	public bool IsVisible => _isVisible;
	private string RootClass => (IsVisible ? "visible" : "") + (_suppressHomeCarouselTransitions ? " suppress-home-carousel-transitions" : "");
	public bool AllSoftwareOpen => _allSoftwareOpen;
	public bool NavigationFocused => _navFocused;
	public bool HasSelectedGame => SelectedGame != null;
	public bool CanOpenDetails => !_navFocused && SelectedGame != null;
	public bool HasHomeAction => IsHomeViewMoreSelected;
	public string PrimaryActionLabel => _allSoftwareOpen || IsHomeViewMoreSelected ? "#prompt.ok" : "#prompt.start";
	private long LocalSteamId => Connection.Local is null ? 0L : Connection.Local.SteamId;
	private int HomeGameCount => Math.Min(_homeItems.Count, HomeGameLimit);
	private int HomeEntryCount => HomeGameCount + 1;
	private int HomeViewMoreIndex => HomeGameCount;
	private bool IsHomeViewMoreSelected => _selectedIndex == HomeViewMoreIndex;
	private bool AddGamesModalOpen => _addGamesModalOpen || AddGamesModal.Current?.IsVisible == true;
	private bool SettingsModalOpen => SettingsModal.Current?.IsVisible == true;
	private bool ShowHomeSelection => !AddGamesModalOpen && !SettingsModalOpen;
	private bool ToastBlocked => AddGamesModalOpen || SettingsModalOpen || DetailsModal.Current?.IsVisible == true;
	private bool ToastVisible => _toast.IsVisible(ToastBlocked);
	private string ToastStyle => _toast.GetStyle(ToastVisible);
	private GameEntry SelectedGame => _allSoftwareOpen
		? _allSoftwareSelection >= 0 && _allSoftwareSelection < _allGames.Count ? _allGames[_allSoftwareSelection] : null
		: !IsHomeViewMoreSelected && _selectedIndex >= 0 && _selectedIndex < _homeItems.Count ? _homeItems[_selectedIndex] : null;
	private string SelectedTitle => SelectedGame?.DisplayTitle ?? "sGBA";
	private Texture SelectedLogo => SelectedGame != null && _logos.TryGetValue(SelectedGame.Path, out var logo) ? logo : null;
	private Texture SelectedBackdrop => SelectedGame != null && _snaps.TryGetValue(SelectedGame.Path, out var snap) ? snap : SelectedGame != null && _boxArt.TryGetValue(SelectedGame.Path, out var cover) ? cover : null;
	private float PreviousBackdropOpacity => _previousBackdrop == null ? 0f : _previousBackdropFading ? 0f : 1f;

	private IEnumerable<HomeCarouselEntry> CarouselItems
	{
		get
		{
			if (HomeEntryCount == 0)
				yield break;

			int selected = _selectedIndex.Clamp(0, HomeEntryCount - 1);
			int first = Math.Max(0, selected - CarouselMountedRange);
			int last = Math.Min(HomeEntryCount - 1, selected + CarouselMountedRange);
			for (int i = first; i <= last; i++)
			{
				bool isViewMore = i == HomeViewMoreIndex;
				yield return new HomeCarouselEntry(isViewMore ? null : _homeItems[i], i, i - selected, isViewMore, isViewMore ? "view-more" : _homeItems[i].Path);
			}
		}
	}

	protected override void OnTreeFirstBuilt()
	{
		Current = this;
		if (NetworkManager.Current != null)
			NetworkManager.Current.JoinRejected += OnJoinRejected;
		EnsureInputHintsPanel();
		_games = GameEntry.Discover();
		_knownPaths = GameEntry.GetInstalledPaths();
		RebuildGameLists(preserveSelection: false);
		SyncBackdropTexture();
		QueueArtworkRefresh();
	}

	private void QueueArtworkRefresh()
	{
		var requestId = ++_artworkRequestId;
		_ = LoadArtworkAsync(requestId);
	}

	private async Task LoadArtworkAsync(int requestId)
	{
		var visibleTask = LoadVisibleCarouselThumbnailsAsync();
		var selectedTask = LoadSelectedFeatureArtAsync();

		await visibleTask;
		await selectedTask;

		if (requestId != _artworkRequestId)
			return;

		var artworkVersion = _artworkVersion;
		PruneArtworkTextures();
		if (_artworkVersion != artworkVersion)
			StateHasChanged();
	}

	private Task<bool> LoadSelectedFeatureArtAsync()
	{
		var game = SelectedGame;
		if (game == null)
			return Task.FromResult(false);

		return LoadArtworkBatchAsync([
			new ArtworkRequest(game, ThumbType.Snap, _snaps),
			new ArtworkRequest(game, ThumbType.Logo, _logos),
			new ArtworkRequest(game, ThumbType.Title, _titles)
		]);
	}

	private Task<bool> LoadVisibleCarouselThumbnailsAsync()
	{
		var homeRequests = CarouselItems
			.Where(item => item.Game != null)
			.Select(item => new ArtworkRequest(item.Game, ThumbType.BoxArt, _boxArt));

		var allSoftwareRequests = _allSoftwareOpen
			? AllSoftwareVisibleItems.Select(item => new ArtworkRequest(item.Game, ThumbType.BoxArt, _boxArt))
			: [];

		var requests = homeRequests
			.Concat(allSoftwareRequests)
			.ToList();

		return LoadArtworkBatchAsync(requests);
	}

	private async Task<bool> LoadArtworkBatchAsync(IReadOnlyList<ArtworkRequest> requests)
	{
		var pending = requests
			.Where(request => request.Game != null && !request.Target.ContainsKey(request.Game.Path))
			.ToList();

		if (pending.Count == 0)
			return false;

		foreach (var request in pending)
			CancelPendingTextureDispose(GetArtworkKey(request.Kind, request.Game.Path));

		var changed = false;
		var loadTasks = pending.Select(LoadArtworkAsync).ToList();
		while (loadTasks.Count > 0)
		{
			var completedTask = await Task.WhenAny(loadTasks);
			loadTasks.Remove(completedTask);

			if (!ApplyArtworkLoad(await completedTask))
				continue;

			changed = true;
			_artworkVersion++;
			StateHasChanged();
		}

		return changed;
	}

	private static async Task<ArtworkLoad> LoadArtworkAsync(ArtworkRequest request)
	{
		var texture = await Thumbnails.LoadAsync(request.Game, request.Kind);
		return new ArtworkLoad(request, texture);
	}

	private bool ApplyArtworkLoad(ArtworkLoad load)
	{
		var game = load.Request.Game;
		var kind = load.Request.Kind;
		var target = load.Request.Target;
		var texture = load.Texture;

		if (texture == null || game == null)
			return false;

		CancelPendingTextureDispose(GetArtworkKey(kind, game.Path));

		if (target.ContainsKey(game.Path))
		{
			return false;
		}

		if (!ShouldRetainArtwork(game, kind))
		{
			ScheduleTextureDispose(GetArtworkKey(kind, game.Path), texture);
			return false;
		}

		target[game.Path] = texture;
		if (game == SelectedGame)
			SyncBackdropTexture();

		return true;
	}

	private bool ShouldRetainArtwork(GameEntry game, ThumbType kind)
	{
		if (game == null)
			return false;

		if (game == SelectedGame)
			return true;

		return kind == ThumbType.BoxArt && RetainedBoxArtPaths().Contains(game.Path);
	}

	private HashSet<string> RetainedBoxArtPaths()
	{
		var retained = CarouselItems
			.Where(item => IsCarouselOffsetMounted(item.Offset))
			.Where(item => item.Game != null)
			.Select(item => item.Game.Path)
			.ToHashSet();

		if (_allSoftwareOpen)
		{
			foreach (var item in AllSoftwareVisibleItems)
			{
				if (item.Game != null)
					retained.Add(item.Game.Path);
			}
		}

		return retained;
	}

	private HashSet<string> RetainedFeatureArtworkPaths()
	{
		var retained = new HashSet<string>();
		if (SelectedGame != null)
			retained.Add(SelectedGame.Path);
		return retained;
	}

	private void PruneArtworkTextures()
	{
		PruneArtworkDictionary(_boxArt, ThumbType.BoxArt, RetainedBoxArtPaths());
		var retainedFeatureArtwork = RetainedFeatureArtworkPaths();
		PruneArtworkDictionary(_logos, ThumbType.Logo, retainedFeatureArtwork);
		PruneArtworkDictionary(_snaps, ThumbType.Snap, retainedFeatureArtwork);
		PruneArtworkDictionary(_titles, ThumbType.Title, retainedFeatureArtwork);
	}

	private void PruneArtworkDictionary(Dictionary<string, Texture> textures, ThumbType kind, HashSet<string> retainedPaths)
	{
		foreach (var (path, texture) in textures.ToList())
		{
			if (retainedPaths.Contains(path))
				continue;

			if (texture == _currentBackdrop || texture == _previousBackdrop)
				continue;

			textures.Remove(path);
			ScheduleTextureDispose(GetArtworkKey(kind, path), texture);
			_artworkVersion++;
		}
	}

	private static string GetArtworkKey(ThumbType kind, string path)
	{
		return $"{kind}:{path}";
	}

	private void ScheduleTextureDispose(string key, Texture texture)
	{
		if (texture == null)
			return;

		_pendingTextureDisposals.RemoveAll(item => item.Key == key || item.Texture == texture);
		_pendingTextureDisposals.Add(new PendingTextureDispose(key, texture, 0f));
	}

	private void CancelPendingTextureDispose(string key)
	{
		_pendingTextureDisposals.RemoveAll(item => item.Key == key);
	}

	private void UpdatePendingTextureDisposals()
	{
		if (_pendingTextureDisposals.Count == 0)
			return;

		for (int i = _pendingTextureDisposals.Count - 1; i >= 0; i--)
		{
			var pending = _pendingTextureDisposals[i];
			var age = pending.Age + Time.Delta;
			if (age < 0.5f)
			{
				_pendingTextureDisposals[i] = pending with { Age = age };
				continue;
			}

			if (!IsArtworkTextureStillReferenced(pending.Texture))
				Thumbnails.ReleaseTexture(pending.Texture);

			_pendingTextureDisposals.RemoveAt(i);
		}
	}

	private bool IsArtworkTextureStillReferenced(Texture texture)
	{
		if (texture == null)
			return false;

		if (texture == _currentBackdrop || texture == _previousBackdrop)
			return true;

		return _boxArt.ContainsValue(texture) || _logos.ContainsValue(texture) || _snaps.ContainsValue(texture);
	}

	public void Show()
	{
		if (_homeOrderDirty)
		{
			RebuildGameLists(preserveSelection: false);
			SelectPendingHomeGame();
			_homeOrderDirty = false;
			_pendingHomeSelectionPath = null;
			_suppressHomeCarouselTransitions = true;
			_homeCarouselTransitionSuppressionFrames = 2;
			Panel?.SkipTransitions();
			SyncBackdropTexture();
			QueueArtworkRefresh();
		}

		_isVisible = true;
		_navFocused = false;
		_input.Begin(useGamepad: false);
		Sound.Play("ui.popup.message.open");
		EmulatorComponent.Current?.Unload();
		StateHasChanged();
	}

	public void Hide(bool update = true)
	{
		_isVisible = false;
		Mouse.Visibility = MouseVisibility.Hidden;
		if (update)
			StateHasChanged();
	}

	private void ActivateCarouselItem(int index)
	{
		if (IsHomeViewMoreIndex(index))
		{
			SelectCarouselItem(index, _input.UseGamepad);
			OpenAllSoftware();
			return;
		}

		bool shouldLaunch = !_navFocused && index == _selectedIndex;
		SelectCarouselItem(index, _input.UseGamepad);

		if (shouldLaunch && SelectedGame != null)
		{
			if (_homeCarouselSettleRemaining > 0f)
			{
				_queuedHomeLaunchGame = SelectedGame;
				return;
			}

			LaunchGame(SelectedGame);
		}
	}

	private void OpenAddGamesModal(bool useGamepad = false)
	{
		Sound.Play("ui.button.press");
		AddGamesModal addGamesPanel = EnsureAddGamesPanel();
		if (addGamesPanel == null)
			return;

		_addGamesModalOpen = true;
		StateHasChanged();
		addGamesPanel.Open(useGamepad);
	}

	public void OnAddGamesModalClosed(bool useGamepad)
	{
		_addGamesModalOpen = false;
		if (useGamepad)
			_input.ForceGamepadMode();
		else
			_input.ForceMouseMode();

		EnsureAllSoftwareSelectionVisible();
		StateHasChanged();
	}

	private AddGamesModal EnsureAddGamesPanel()
	{
		if (AddGamesModal.Current != null && AddGamesModal.Current.IsValid())
			return AddGamesModal.Current;

		AddGamesModal addGamesPanel = Scene.GetAllComponents<AddGamesModal>().FirstOrDefault(panel => panel.IsValid());
		if (addGamesPanel != null)
			return addGamesPanel;

		return AddComponent<AddGamesModal>();
	}

	private void LaunchGame(GameEntry game)
	{
		var emulator = EmulatorComponent.Current;
		if (!emulator.IsValid())
			return;

		GamePlayHistory.MarkPlayed(game.Path);
		AchievementManager.OnGameLaunched(game.Path);
		_homeOrderDirty = true;
		_suppressHomeCarouselTransitions = true;
		_pendingHomeSelectionPath = game.Path;

		if (_allSoftwareOpen)
		{
			_allSoftwareOpen = false;
			Sound.Play("ui.button.press");
			Hide();
			emulator.Restart(game.Path);
			return;
		}

		Sound.Play("ui.button.press");
		_suppressHomeCarouselTransitions = true;
		_homeCarouselTransitionSuppressionFrames = 4;
		Panel?.SkipTransitions();
		_homeCarouselRenderVersion++;
		_pendingFrozenHomeLaunchPath = game.Path;
		_pendingFrozenHomeLaunchFrames = 1;
		StateHasChanged();
	}

	private void CompleteFrozenHomeLaunch()
	{
		if (string.IsNullOrWhiteSpace(_pendingFrozenHomeLaunchPath))
			return;

		var path = _pendingFrozenHomeLaunchPath;
		_pendingFrozenHomeLaunchPath = null;
		_pendingFrozenHomeLaunchFrames = 0;

		var emulator = EmulatorComponent.Current;
		if (!emulator.IsValid())
			return;

		Hide(update: false);
		emulator.Restart(path);
	}

	private void RebuildGameLists(bool preserveSelection = true)
	{
		bool hadHomeGames = _homeItems.Count > 0;
		var selectedGame = preserveSelection ? SelectedGame : null;
		bool selectedViewMore = preserveSelection && hadHomeGames && IsHomeViewMoreSelected;

		_allGames = [.._games];
		_homeItems = [.._games
			.OrderByDescending(game => GamePlayHistory.LastPlayedAt(game.Path))
			.ThenBy(game => game.DisplayTitle, StringComparer.OrdinalIgnoreCase)];

		if (!preserveSelection)
		{
			_selectedIndex = 0;
		}
		else if (selectedGame != null && !selectedViewMore)
		{
			int homeIndex = _homeItems.IndexOf(selectedGame);
			if (homeIndex >= 0)
				_selectedIndex = homeIndex;
		}
		else if (selectedViewMore)
		{
			_selectedIndex = HomeViewMoreIndex;
		}

		ClampSelectedIndex();
	}

	private void SelectPendingHomeGame()
	{
		if (string.IsNullOrWhiteSpace(_pendingHomeSelectionPath))
			return;

		int homeIndex = _homeItems.FindIndex(game => string.Equals(game.Path, _pendingHomeSelectionPath, StringComparison.OrdinalIgnoreCase));
		_selectedIndex = homeIndex >= 0 && homeIndex < HomeGameCount ? homeIndex : HomeViewMoreIndex;
	}

	private void ClampSelectedIndex()
	{
		if (HomeEntryCount == 0)
		{
			_selectedIndex = 0;
		}
		else
		{
			_selectedIndex = _selectedIndex.Clamp(0, HomeEntryCount - 1);
		}

		_allSoftwareSelection = _allSoftwareSelection.Clamp(0, AllSoftwareAddIndex);
		EnsureAllSoftwareSelectionVisible();
	}

	private void SetGamepadMode()
	{
		_input.ForceGamepadMode();
	}

	private void SyncVisibilityWithEmulator()
	{
		var emu = EmulatorComponent.Current;
		var net = NetworkManager.Current;
		bool linkClient = net != null && net.IsClient && net.Mode == SessionMode.LinkCable;
		bool running = emu.IsValid() && (emu.IsLinked || linkClient || (emu.IsReady && !string.IsNullOrEmpty(emu.RomPath)));
		if (running && _isVisible) Hide();
		else if (!running && !_isVisible) Show();
	}

	private SettingsModal EnsureSettingsPanel()
	{
		if (SettingsModal.Current != null && SettingsModal.Current.IsValid())
			return SettingsModal.Current;

		SettingsModal settingsPanel = Scene.GetAllComponents<SettingsModal>().FirstOrDefault(panel => panel.IsValid());
		if (settingsPanel != null)
			return settingsPanel;

		return AddComponent<SettingsModal>();
	}

	private InputHints EnsureInputHintsPanel()
	{
		if (InputHints.Current != null && InputHints.Current.IsValid())
			return InputHints.Current;

		InputHints inputHintsPanel = Scene.GetAllComponents<InputHints>().FirstOrDefault(panel => panel.IsValid());
		if (inputHintsPanel != null)
			return inputHintsPanel;

		return AddComponent<InputHints>();
	}

	private void OpenSettings(bool useGamepad = false)
	{
		if (useGamepad)
			SetGamepadMode();
		else
			_input.End();

		_navFocused = true;
		_navSelection = 3;
		Sound.Play("ui.button.press");
		SettingsModal settingsPanel = EnsureSettingsPanel();
		settingsPanel?.Show(useGamepad);
	}

	public void FocusHeaderAction(int headerSelection, bool useGamepad)
	{
		_navFocused = true;
		_navSelection = headerSelection.Clamp(0, NavItems.Length - 1);
		if (useGamepad) _input.ForceGamepadMode();
		else _input.End();
		StateHasChanged();
	}

	public void RestoreInputMode(bool useGamepad)
	{
		if (useGamepad) _input.ForceGamepadMode();
		else _input.ForceMouseMode();
		StateHasChanged();
	}

	public void FocusSettingsAction(bool useGamepad)
	{
		FocusHeaderAction(3, useGamepad);
	}

	protected override void OnUpdate()
	{
		SyncVisibilityWithEmulator();
		if (!string.IsNullOrWhiteSpace(_pendingFrozenHomeLaunchPath))
		{
			if (_pendingFrozenHomeLaunchFrames > 0)
			{
				_pendingFrozenHomeLaunchFrames--;
				return;
			}

			CompleteFrozenHomeLaunch();
			return;
		}

		if (_homeCarouselSettleRemaining > 0f)
		{
			_homeCarouselSettleRemaining = MathF.Max(0f, _homeCarouselSettleRemaining - Time.Delta);
			if (_homeCarouselSettleRemaining <= 0f && _queuedHomeLaunchGame != null)
			{
				var queuedLaunch = _queuedHomeLaunchGame;
				_queuedHomeLaunchGame = null;
				LaunchGame(queuedLaunch);
				return;
			}
		}

		if (_isVisible && _suppressHomeCarouselTransitions)
		{
			if (_homeCarouselTransitionSuppressionFrames > 0)
			{
				_homeCarouselTransitionSuppressionFrames--;
			}
			else
			{
				_suppressHomeCarouselTransitions = false;
				StateHasChanged();
			}
		}

		UpdateBackdropTransition();
		UpdatePendingTextureDisposals();
		UpdateToast();

		_pollTimer += Time.Delta;
		if (_pollTimer >= PollInterval)
		{
			_pollTimer = 0f;
			var current = GameEntry.GetInstalledPaths();
			if (!current.SetEquals(_knownPaths))
			{
				_knownPaths = current;
				_games = GameEntry.Discover();
				RebuildGameLists();
				QueueArtworkRefresh();
				StateHasChanged();
			}
		}

		if (!_isVisible) return;
		if (SettingsModal.Current?.IsVisible == true) return;
		if (DetailsModal.Current?.IsVisible == true) return;
		if (AddGamesModal.Current?.IsVisible == true) return;
		if (new Game.Overlay().IsOpen) return;

		UpdateAllSoftwareScrollState();

		var nav = _input.TickRepeating();
		if (nav.Up) { SetGamepadMode(); NavigateUp(); }
		if (nav.Down) { SetGamepadMode(); NavigateDown(); }
		if (nav.Left) { SetGamepadMode(); NavigateLeft(); }
		if (nav.Right) { SetGamepadMode(); NavigateRight(); }

		if (Input.Pressed("GBA_A") || Input.Pressed("GBA_Start"))
		{
			SetGamepadMode();
			if (_allSoftwareOpen) ActivateAllSoftwareItem(_allSoftwareSelection);
			else if (_navFocused) ActivateNavItem(_navSelection, useGamepad: true);
			else ActivateCarouselItem(_selectedIndex);
		}

		if (Input.Pressed("GBA_B") && _allSoftwareOpen)
		{
			SetGamepadMode();
			CloseAllSoftware();
		}

		if (Input.Pressed("GBA_B") && _navFocused)
		{
			SetGamepadMode();
			_navFocused = false;
			Sound.Play("ui.button.over");
		}

		if (Input.Pressed("GBA_Select") && CanOpenDetails)
		{
			SetGamepadMode();
			OpenSelectedDetails();
		}
	}

	private void NavigateUp()
	{
		if (_allSoftwareOpen)
		{
			NavigateAllSoftwareVertical(-1);
			return;
		}

		if (_navFocused)
		{
			_navFocused = false;
			Sound.Play("ui.button.over");
		}
	}

	private void NavigateDown()
	{
		if (_allSoftwareOpen)
		{
			NavigateAllSoftwareVertical(1);
			return;
		}

		if (!_navFocused)
		{
			_navFocused = true;
			Sound.Play("ui.button.over");
		}
	}

	private void NavigateLeft()
	{
		if (_allSoftwareOpen)
		{
			NavigateAllSoftwareHorizontal(-1);
			return;
		}

		if (_navFocused)
		{
			if (_navSelection > 0)
			{
				_navSelection--;
				Sound.Play("ui.button.over");
			}
			return;
		}

		SelectCarouselItem(_selectedIndex - 1, useGamepad: true);
	}

	private void NavigateRight()
	{
		if (_allSoftwareOpen)
		{
			NavigateAllSoftwareHorizontal(1);
			return;
		}

		if (_navFocused)
		{
			if (_navSelection < NavItems.Length - 1)
			{
				_navSelection++;
				Sound.Play("ui.button.over");
			}
			return;
		}

		SelectCarouselItem(_selectedIndex + 1, useGamepad: true);
	}

	private void SelectCarouselItem(int index, bool useGamepad)
	{
		if (HomeEntryCount == 0)
			return;

		var next = index.Clamp(0, HomeEntryCount - 1);
		if (_selectedIndex == next && !_navFocused)
			return;

		_selectedIndex = next;
		_navFocused = false;
		_queuedHomeLaunchGame = null;
		_homeCarouselSettleRemaining = HomeCarouselSettleDuration;
		if (useGamepad)
			SetGamepadMode();

		Sound.Play("ui.button.over");
		QueueArtworkRefresh();
		SyncBackdropTexture();
		StateHasChanged();
	}

	private bool IsHomeViewMoreIndex(int index)
	{
		return index == HomeViewMoreIndex;
	}

	private void UpdateBackdropTransition()
	{
		SyncBackdropTexture();

		if (_previousBackdrop == null)
			return;

		if (_currentBackdrop == null && !_previousBackdropFading)
		{
			_previousBackdropFading = true;
			_backdropVersion++;
			StateHasChanged();
			return;
		}

		_previousBackdropAge += Time.Delta;
		if (_previousBackdropAge < BackdropRetainDuration)
			return;

		_previousBackdrop = null;
		PruneArtworkTextures();
		_backdropVersion++;
		StateHasChanged();
	}

	private void SyncBackdropTexture()
	{
		var next = SelectedBackdrop;
		if (next == _currentBackdrop)
			return;

		if (_currentBackdrop != null)
			_previousBackdrop = _currentBackdrop;

		_currentBackdrop = next;
		_previousBackdropAge = 0f;
		_previousBackdropFading = false;
		_backdropVersion++;
	}

	private void SelectNavItem(int index, bool useGamepad)
	{
		_navSelection = index.Clamp(0, NavItems.Length - 1);
		_navFocused = true;
		if (useGamepad)
			SetGamepadMode();
		else
			_input.ForceMouseMode();
		StateHasChanged();
	}

	private void ActivateNavItem(int index, bool useGamepad)
	{
		SelectNavItem(index, useGamepad);

		switch (index)
		{
			case 0:
				ShowUnavailableToast("#toast.netplay.title", "#toast.netplay.message", "group", NavItems[index].ColorClass);
				break;
			case 1:
				ShowUnavailableToast("#toast.news.title", "#toast.news.message", "newspaper", NavItems[index].ColorClass);
				break;
			case 2:
				ShowUnavailableToast("#toast.cloud.title", "#toast.cloud.message", "cloud", NavItems[index].ColorClass);
				break;
			case 3:
				OpenSettings(useGamepad);
				break;
			case 4:
				Sound.Play("ui.button.press");
				Game.Close();
				break;
		}
	}

	private void ShowUnavailableToast(string title, string message, string icon, string colorClass)
	{
		_toast.Show(title, message, icon, colorClass);
		Sound.Play("ui.button.press");
		StateHasChanged();
	}

	public void ShowToast(string title, string message, string icon = "info", string colorClass = "blue")
	{
		_toast.Show(title, message, icon, colorClass);
		StateHasChanged();
	}

	private void UpdateToast()
	{
		if (!_toast.IsRunning)
			return;

		_toast.Tick(Time.Delta);
		StateHasChanged();
	}

	private void OpenSelectedDetails()
	{
		if (!CanOpenDetails)
			return;

		DetailsModal detailsPanel = EnsureDetailsPanel();
		detailsPanel?.Open(SelectedGame, _input.UseGamepad);
		StateHasChanged();
	}

	public void RefreshControllerHints()
	{
		InputHints.Current?.Refresh();
	}

	private DetailsModal EnsureDetailsPanel()
	{
		if (DetailsModal.Current != null && DetailsModal.Current.IsValid())
			return DetailsModal.Current;

		DetailsModal detailsPanel = Scene.GetAllComponents<DetailsModal>().FirstOrDefault(panel => panel.IsValid());
		if (detailsPanel != null)
			return detailsPanel;

		return AddComponent<DetailsModal>();
	}

	private static bool IsCarouselOffsetMounted(int offset)
	{
		return offset >= -CarouselMountedRange && offset <= CarouselMountedRange;
	}

	protected override int BuildHash()
	{
		if (_isVisible)
			return HashCode.Combine(
				HashCode.Combine(_selectedIndex, _allSoftwareSelection, (int)AllSoftwareScrollY, _allSoftwareOpen),
				HashCode.Combine(AddGamesModalOpen, SettingsModalOpen, _allGames.Count, _homeItems.Count, _suppressHomeCarouselTransitions),
				HashCode.Combine(_homeCarouselRenderVersion, _input.UseGamepad, _artworkVersion, _backdropVersion),
				_allSoftwareTitleDirection,
				HashCode.Combine(_navFocused, _navSelection, _toast.RenderHash));

		return HashCode.Combine(_isVisible);
	}

	private void OnJoinRejected()
	{
		Show();
		ShowToast("#toast.missingrom.title", "#toast.missingrom.message", "warning", "orange");
	}

	protected override void OnDestroy()
	{
		if (NetworkManager.Current != null)
			NetworkManager.Current.JoinRejected -= OnJoinRejected;
		if (Current == this)
			Current = null;
		DisposeArtworkTextures();
		_currentBackdrop = null;
		_previousBackdrop = null;
	}

	private void DisposeArtworkTextures()
	{
		var textures = _boxArt.Values.Concat(_logos.Values).Concat(_snaps.Values).Concat(_titles.Values).Concat(_pendingTextureDisposals.Select(item => item.Texture)).Where(texture => texture != null).ToHashSet();
		foreach (var texture in textures)
			Thumbnails.ReleaseTexture(texture);

		_boxArt.Clear();
		_logos.Clear();
		_snaps.Clear();
		_titles.Clear();
		_pendingTextureDisposals.Clear();
	}
}
@using Sandbox;
@using Sandbox.UI;
@using System.Linq;
@inherits PanelComponent

<root>
    <div class="bg">
        <!-- Blob 1: Cyan - 5 children (рухливі клякси) -->
        <div class="blob-group b1">
            <div class="c c1a"></div>
            <div class="c c1b"></div>
            <div class="c c1c"></div>
            <div class="c c1d"></div>
            <div class="c c1e"></div>
        </div>

        <!-- Blob 2: Purple - 4 children (рухливі клякси) -->
        <div class="blob-group b2">
            <div class="c c2a"></div>
            <div class="c c2b"></div>
            <div class="c c2c"></div>
            <div class="c c2d"></div>
        </div>

        <!-- Blob 3: Green - 3 children (рухливі клякси) -->
        <div class="blob-group b3">
            <div class="c c3a"></div>
            <div class="c c3b"></div>
            <div class="c c3c"></div>
        </div>

        <!-- Blob 4: Pink - 4 children (рухливі клякси) -->
        <div class="blob-group b4">
            <div class="c c4a"></div>
            <div class="c c4b"></div>
            <div class="c c4c"></div>
            <div class="c c4d"></div>
        </div>

        <!-- Blob 5: Deep Blue - 2 children (рухливі клякси) -->
        <div class="blob-group b5">
            <div class="c c5a"></div>
            <div class="c c5b"></div>
        </div>

        <div class="noise"></div>
    </div>

    <div class="layout">
        <div class="left-panel">
            <div class="logo">PAINT<span>SLIME</span></div>
            <div class="tagline">3v3 Multiplayer Paint Arena</div>
        </div>
        <div class="right-panel">
            <div class="btn btn-multi" onclick=@ShowMultiplayer>Multiplayer</div>
            <div class="btn btn-exit" onclick=@QuitGame>Quit</div>
        </div>
    </div>

    <!-- Matchmaking / Lobby Overlay -->
    @if ( Matchmaking.IsValid() && Matchmaking.IsSearching )
    {
        <div class="lobby-overlay">
            <div class="lobby-card">
                <div class="spinner-container">
                    <div class="spinner-ring"></div>
                    <div class="spinner-core"></div>
                </div>
                <h2>MATCHMAKING</h2>
                <div class="status-msg">@Matchmaking.StatusMessage</div>
                
                <div class="player-slots">
                    @for ( int i = 0; i < Matchmaking.RequiredPlayers; i++ )
                    {
                        var slotActive = i < Matchmaking.ConnectedPlayers;
                        var slotReady = i < Matchmaking.ReadyPlayers.Count;
                        <div class="slot @(slotActive ? "active" : "") @(slotReady ? "ready" : "")">
                            <div class="slot-dot"></div>
                        </div>
                    }
                </div>

                <div class="lobby-actions">
                    @{
                        bool amIReady = Connection.Local != null && Matchmaking.ReadyPlayers.Contains( Connection.Local.Id );
                        bool isHost = Sandbox.Networking.IsHost;
                    }

                    <div class="btn-lobby btn-ready @(amIReady ? "is-ready" : "")" onclick=@ToggleReadyStatus>
                        @(amIReady ? "READY!" : "READY")
                    </div>

                    <div class="btn-lobby btn-cancel" onclick=@CancelSearch>Cancel</div>
                </div>
            </div>
        </div>
    }
</root>

@code {
    [Property] public MatchmakingManager Matchmaking { get; set; }

    protected override void OnStart()
    {
        // Шукаємо компонент на сцені, якщо не призначено вручну
        if ( !Matchmaking.IsValid() )
        {
            Matchmaking = Scene.GetAllComponents<MatchmakingManager>().FirstOrDefault();
        }

        // Автоматично створюємо MatchmakingManager, якщо його немає на сцені,
        // щоб пошук працював одразу «з коробки» без додаткових ручних налаштувань
        if ( !Matchmaking.IsValid() )
        {
            Matchmaking = GameObject.Components.Create<MatchmakingManager>();
        }
    }



    void ShowMultiplayer() 
    { 
        if ( Matchmaking.IsValid() )
        {
            Matchmaking.StartMatchmaking();
        }
        else
        {
            Log.Error( "MatchmakingManager not found in the scene! Try adding it manually." );
        }
    }

    void CancelSearch()
    {
        if ( Matchmaking.IsValid() )
        {
            Matchmaking.CancelMatchmaking();
        }
    }

    void ToggleReadyStatus()
    {
        if ( !Matchmaking.IsValid() ) return;
        if ( Connection.Local == null ) return;
        
        Matchmaking.ToggleReady( Connection.Local.Id );
    }

    void ForceStartGame()
    {
        if ( Matchmaking.IsValid() )
        {
            Matchmaking.ForceStartGame();
        }
    }

    void QuitGame() { Game.Close(); }

    protected override int BuildHash()
    {
        var isSearching = Matchmaking.IsValid() && Matchmaking.IsSearching;
        var players = Matchmaking.IsValid() ? Matchmaking.ConnectedPlayers : 0;
        var ready = Matchmaking.IsValid() ? Matchmaking.ReadyPlayers.Count : 0;
        var msg = Matchmaking.IsValid() ? Matchmaking.StatusMessage : "";
        var isHost = Sandbox.Networking.IsHost;
        return System.HashCode.Combine( isSearching, players, msg, isHost, ready );
    }
}
@using Sandbox;
@using Sandbox.UI;
@inherits PanelComponent
@namespace Sandbox

@if ( Player.IsValid() && Player.WantsHideHud )
    return;

@if ( !HasAnyAction )
    return;

<root>
 <div class="panel">

  <div class="name">@Info?.Name</div>

  @if ( !string.IsNullOrEmpty( Info?.Description ) )
  {
   <div class="description">@Info.Description</div>
  }

  <div class="divider"></div>

        <div class="actions">
            @if (!string.IsNullOrEmpty(Info?.PrimaryAction))
            {
                <div class="action">
                    <InputHint Action="attack1" class="key" />
                    <span class="text">@Info.PrimaryAction</span>
                </div>
            }
            @if (!string.IsNullOrEmpty(Info?.SecondaryAction))
            {
                <div class="action">
                    <InputHint Action="attack2" class="key" />
                    <span class="text">@Info.SecondaryAction</span>
                </div>
            }
            @if (!string.IsNullOrEmpty(Info?.ReloadAction))
            {
                <div class="action">
                    <InputHint Action="reload" class="key" />
                    <span class="text">@Info.ReloadAction</span>
                </div>
            }
        </div>
 </div>
</root>

@code
{
	IToolInfo CurrentToolInfo
	{
		get
		{
			var inv = Player?.GetComponent<PlayerInventory>();
			
			if ( !inv.IsValid() || !inv.ActiveWeapon.IsValid() )
				return null;
			
			if ( inv.ActiveWeapon.GetComponentInChildren<IToolInfo>() is { IsValid: true } toolInfo )
				return toolInfo;

			return inv?.ActiveWeapon as IToolInfo;
		}
	}

	Player Player => Player.FindLocalPlayer();

	IToolInfo Info => CurrentToolInfo;

	bool HasAnyAction => !string.IsNullOrEmpty( Info?.PrimaryAction )
	                     || !string.IsNullOrEmpty( Info?.SecondaryAction )
	                     || !string.IsNullOrEmpty( Info?.ReloadAction );

	protected override void OnUpdate()
	{
		SetClass( "visible", CurrentToolInfo is not null );
	}

	protected override int BuildHash() => System.HashCode.Combine( Info?.Name, Info?.Description, Info?.PrimaryAction, Info?.SecondaryAction, Info?.ReloadAction, Player?.WantsHideHud );
}
@using Sandbox;
@using Sandbox.UI;
@using Sandbox.Mounting;
@namespace Sandbox
@inherits Panel

@if ( mount is null )
{
    <root>
        #spawnmenu.mounts.error
    </root>
    return;
}


<SpawnMenuContent>

    <Header>
        <SpawnMenuToolbar>
			<Left>
				@*
				<Button Icon="home" @onclick="@GoHome"></Button>
				<Button Icon="arrow_upward" Disabled=@( currentDir?.Parent == null ) @onclick="@GoUp"></Button>
				*@
			</left>
			<Body>
				@*
				<SpawnMenuPath Path=@CurrentPath></SpawnMenuPath>
				*@

				<label>@( $"{mount.Title}, {GetData().Count()}" ) </label><label>#spawnmenu.common.items</label>
			</Body>
			<Right>
				<SpawnMenuFilter Query:bind="@Filter"></SpawnMenuFilter>
				<SpawnMenuIconOptions Size:bind="@ItemSize"></SpawnMenuIconOptions>
			</Right>
		</SpawnMenuToolbar>
    </Header>

    <Body>
		
        <VirtualGrid Items=@( GetData() ) ItemSize=@ItemSize>
            <Item Context="item">

				@if (item is ResourceFolder dir)
                {
                    <div class="folder" @onclick=@( () => currentDir = dir )>
						<div><i>folder</i></div>
						<div class="name">@dir.Name</div>
					</div>
                }

                @if (item is ResourceLoader loader)
                {
                    var nameWithoutExt = System.IO.Path.GetFileNameWithoutExtension(loader.Name);
                    <SpawnMenuIcon HideText=@(ItemSize <= 60) Ident=@($"mount:{loader.Path}") Title="@nameWithoutExt" [email protected]( mount.Title )></SpawnMenuIcon>
                }

            </Item>
        </VirtualGrid>
    </Body>

</SpawnMenuContent>

@code
{
    public string Ident { get; set; }
    public string Filter { get; set; }

    public static int ItemSize { get; set; } = 120;

	ResourceFolder currentDir;

    BaseGameMount mount;

	protected override int BuildHash() => HashCode.Combine(Ident, ItemSize, Filter);

    protected override async Task OnParametersSetAsync()
    {
        mount = await Sandbox.Mounting.Directory.Mount( Ident );
    }

	void GoHome()
	{
		currentDir = mount.RootFolder;
	}

	void GoUp()
	{
		if ( currentDir.Parent != null )
		{
			currentDir = currentDir.Parent;
		}
	}

	string CurrentPath
	{
		get => currentDir?.Path ?? "/";
	}

    IEnumerable<object> GetData()
    {
		var q = mount.Resources
						.Where( x => x.Type == ResourceType.Model )
						.Where( x => !x.Flags.Contains( Sandbox.Mounting.ResourceFlags.DeveloperOnly ) ); // Hide dev-only junk models

		if ( !string.IsNullOrWhiteSpace( Filter ) )
		{
			q = q.Where( x => x.Path.Contains( Filter, StringComparison.OrdinalIgnoreCase ) );
		}

		return q;
    }

}
@using Sandbox;
@using Sandbox.UI;
@namespace Sandbox
@inherits BasePopup

<root class="popup">

    <div class="inner" onclick:preventDefault=@true>
        
        <div class="popup-header">
            <h2>@Title</h2>
            @if (AllowPackages)
            {
                <div class="sort-buttons">
                    @foreach ( var mode in Enum.GetValues<PackageSortMode>() )
                    {
                        var m = mode;
                        <div class="sort-button @(SortMode == m ? "active" : "")" @onclick=@(() => SetSort(m))>@m.ToString()</div>
                    }
                </div>
            }
        </div>

        <VirtualGrid Items=@(_filteredItems) ItemSize=@(new Vector2( 180, 200 )) class="grid">
            <Item Context="item">
                @if (item is Resource res)
                {
                    bool selected = res.ResourcePath.Equals(_pendingValue ?? CurrentValue, StringComparison.OrdinalIgnoreCase);
                    string itemClass = selected ? "item active" : "item";
                    string thumbUrl = $"thumb:{res.ResourcePath}";

                    <div @[email protected] class="@itemClass" @onclick=@(() => { OnItemClicked(res); })>

                        <div class="icon" style="background-image: url( @thumbUrl )"></div>

                        @if ( res is IDefinitionResource definitionResource )
                        {
                            <div class="title">@definitionResource.Title</div>
                            <div class="desc">@definitionResource.Description</div>
                        }
                        else
                        {
                            <div class="title">@res.ResourceName</div>
                            <div class="desc"></div>
                        }
                        
                    </div>
                }
                else if ( item is Package pkg )
                {
                    bool selected = pkg.FullIdent.Equals(CurrentValue, StringComparison.OrdinalIgnoreCase);
                    string itemClass = selected ? "item active" : "item";

                    <div @[email protected] class="@itemClass" @onclick=@(() => { SelectPackage(pkg); })>

                        <div class="icon" style="background-image: url( @pkg.Thumb )"></div>
                        <div class="title">@pkg.Title</div>
                        <div class="desc">@pkg.Org.Title</div>
                        
                    </div>
                }
            </Item>
        </VirtualGrid>

        <div class="popup-footer">
            <div class="clear-button" @onclick=@ClearSelection>Clear</div>
            <div class="select-button" @onclick=@ConfirmSelection>Select</div>
        </div>

    </div>

</root>

@code
{
    public string Title { get; set; } = "Select Resource";
    public string Extension { get; set; } = "vmdl";
    public string CurrentValue { get; set; }
    public bool AllowPackages { get; set; }
    public Action<string> OnSelectedFile;

    string _pendingValue;
    IResourcePreview _previewingResource;

    public string Category { get; set; }

    PackageSortMode SortMode { get; set; } = PackageSortMode.Popular;

    void SetSort( PackageSortMode mode )
    {
        if ( SortMode == mode ) return;
        SortMode = mode;
        if ( AllowPackages )
            _ = LoadCloudPackages();
    }

    readonly List<Resource> _localResources = new();
    readonly List<Package> _cloudPackages = new();
    List<object> _filteredItems = new();

    protected override void OnParametersSet()
    {
        _localResources.Clear();
        _cloudPackages.Clear();

        _localResources.AddRange( ResourceLibrary.GetAll<Resource>().Where( FilterExtension ).Where( FilterCategory ) );

        RebuildFilteredItems();

        if ( AllowPackages )
        {
            _ = LoadCloudPackages();
        }
    }

    void RebuildFilteredItems()
    {
        var items = new List<object>();
        items.AddRange( _localResources );
        items.AddRange( _cloudPackages );
        _filteredItems = items;
    }

    async Task LoadCloudPackages()
    {
        var query = $"sort:{SortMode.ToIdentifier()} type:{Extension}";

        var result = await Package.FindAsync( query );

        _cloudPackages.Clear();
        if ( result?.Packages != null )
        {
            _cloudPackages.AddRange( result.Packages );
        }

        RebuildFilteredItems();
        StateHasChanged();
    }

    bool FilterExtension(Resource res)
    {
        if (res == null) return false;
        if (string.IsNullOrEmpty(Extension)) return true;

        var ext = System.IO.Path.GetExtension(res.ResourcePath);
        if (string.IsNullOrEmpty(ext)) return false;

        return ext.TrimStart('.').Equals(Extension, StringComparison.OrdinalIgnoreCase);
    }

    bool FilterCategory(Resource res)
    {
        if (string.IsNullOrEmpty(Category)) return true;
        if (res is not SoundDefinition soundDef) return true;

        return string.Equals(soundDef.Category, Category, StringComparison.OrdinalIgnoreCase);
    }

    void OnItemClicked( Resource res )
    {
        StopPreview();

        _pendingValue = res.ResourcePath;

        var gameRes = ResourceLibrary.Get<GameResource>( res.ResourcePath );
        if ( gameRes is IResourcePreview preview )
        {
            _previewingResource = preview;
            preview.OnPreview();
        }

        StateHasChanged();
    }

    void StopPreview()
    {
        _previewingResource?.OnPreviewStop();
        _previewingResource = null;
    }

    public override void OnDeleted()
    {
        StopPreview();
        base.OnDeleted();
    }

    void ClearSelection()
    {
        StopPreview();
        _pendingValue = null;
        OnSelectedFile?.Invoke( null );
        StateHasChanged();
    }

    void ConfirmSelection()
    {
        StopPreview();

        if ( _pendingValue != null )
        {
            OnSelectedFile?.Invoke( _pendingValue );
            Delete();
        }
    }

    protected override void OnMouseDown(MousePanelEvent e)
    {
        base.OnMouseDown(e);

        if (e.Target == this)
        {
            StopPreview();
            Delete();
        }
    }

    void SelectResource(Resource res)
    {
        OnSelectedFile?.Invoke(res.ResourcePath);
        Delete();
    }

    void SelectPackage(Package pkg)
    {
        OnSelectedFile?.Invoke(pkg.FullIdent);
        Delete();
    }

}
@using Sandbox;
@using Sandbox.UI;
@attribute [InspectorEditor(typeof(Dresser))]
@inherits Panel
@namespace Sandbox
@implements IInspectorEditor

<root>
    <div class="body">
        <Label>todo: add / remove clothing resources</Label>
    </div>
    <div class="footer">
            <Button class="menu-action primary" Text="Randomize" Icon="🎲" onclick=@DoRandomize></Button>
            <Button class="menu-action primary" Text="Clear" Icon="🧹" onclick=@DoClear></Button>
    </div>
</root>

@code
{
    public string Title => "👗 Dresser";

    public Dresser Target { get; private set; }

    public bool TrySetTarget(List<GameObject> selection)
    {
        Dresser found = null;
        foreach (var go in selection)
        {
            if ( go.Tags.Has( "player" ) ) continue;
            found = go.GetComponentInChildren<Dresser>();
            if (found != null) break;
        }

        if (found == Target) return Target != null;

        Target = found;
        StateHasChanged();
        return Target != null;
    }

    void DoRandomize() => TryBroadcastRandomize( Target.GameObject );
    void DoClear() => TryBroadcastClear( Target.GameObject );

    [Rpc.Host]
    private static void TryBroadcastRandomize(GameObject go)
    {
        if ( !go.IsValid() ) return;

        var dresser = go.GetComponentInChildren<Dresser>();
        if ( dresser is null ) return;

        dresser.Randomize();

        // Refresh the object to update the clothing items for everyone
        go.Network.Refresh();
    }

    [Rpc.Host]
    private static async void TryBroadcastClear(GameObject go)
    {
        if ( !go.IsValid() ) return;

        var dresser = go.GetComponentInChildren<Dresser>();
        if ( dresser is null ) return;

        dresser.Clothing.Clear();
        dresser.Clear();
        await dresser.Apply();

        // Refresh the object to update the clothing items for everyone
        go.Network.Refresh();
    }
}
@using Sandbox;
@using Sandbox.UI;
@inherits PanelComponent

@* 
    PeachSplatOverlay — full-screen blit overlay driven by PeachOverlayBridge.
    Add this component to the HUD / camera GameObject in the scene (one instance).
    Evening = regular peach, Midnight = giant peach.
*@

<root>
    <div class="peach-overlay @(PeachOverlayBridge.IsGiant ? "midnight" : "evening")" style="opacity: @OpacityStr;">
    </div>
</root>

@code {
    private float _current = 0f;
    public string OpacityStr => _current.ToString( "0.00" );

    protected override void OnUpdate()
    {
        PeachOverlayBridge.Tick( Time.Delta );

        _current = _current.LerpTo( PeachOverlayBridge.Target, Time.Delta * PeachOverlayBridge.Speed * 3f );

        if ( PeachOverlayBridge.Exiting && _current < 0.005f )
        {
            _current = 0f;
            PeachOverlayBridge.OnFullyFaded();
        }

        StateHasChanged();
    }

    protected override int BuildHash()
    {
        return System.HashCode.Combine( PeachOverlayBridge.IsGiant, (int)(_current * 1000) );
    }
}

<style>
    .peach-overlay {
        position: absolute;
        inset: 0;
        pointer-events: none;
    }

    .peach-overlay.evening {
        background: radial-gradient(
            ellipse at center,
            rgba(30, 15, 60, 0) 30%,
            rgba(80, 30, 120, 220) 100%
        );
    }

    .peach-overlay.midnight {
        background: radial-gradient(
            ellipse at center,
            rgba(5, 5, 30, 40) 20%,
            rgba(10, 5, 60, 242) 100%
        );
    }
</style>
@inherits PanelComponent
<root> <div class="ynal2p"> @t </div> </root>

@code
{
[Property] public game Game {get;set;}
string t = "You need at least 2 players.";

protected override void OnUpdate()
{
if (Game.IsActive == true && Game.Players.Count >= 2)
d(this);
}

[Rpc.Broadcast]
public void d(PanelComponent pc)
{pc.Destroy();}
}
@namespace Sandbox
@using Sandbox;
@using Sandbox.UI;
@using System;
@inherits Panel
@attribute [StyleSheet("PerkIcon.razor.scss")]


@{
	var player = Manager.Instance.LocalPlayer;
	var rot = Angle + Perk.IconAngleOffset;
	var scale = Perk.IconScale;
	const float levelUpAnimDuration = 0.5f;
	var levelUpScale = Perk.RealTimeSinceLevelUp < levelUpAnimDuration
		? Utils.Map( Perk.RealTimeSinceLevelUp, 0f, levelUpAnimDuration, 1.75f, 1.0f, EasingType.QuadOut )
		: 1.0f;
}

<root style="transform: rotate(@(rot)deg) scale(@(scale * levelUpScale));">
	@{
		// float opacity = (SS2Game.Current.IsGameOver || SS2Game.Current.SelectedPlayer != null) ? 4f : Utils.Map(Item.TimeSinceLevelUp, 0f, 3f, 4f, 1f);
		float opacity = Utils.Map( Math.Min( Perk.RealTimeSinceLevelUp, Perk.RealTimeSinceLevelDown ), 0f, 3f, 4f, 1f);
		<!-- todo: cache these -->
		var attrib = TypeLibrary.GetType(Perk.GetType()).GetAttribute<PerkAttribute>();
		var rarity = attrib.Rarity;
		var curse = attrib.Curse;
		var highlightColor = PerkManager.GetCardRarityColor(rarity, curse, alpha: 0.7f);
		var rarityBgTint = PerkManager.GetCardRarityColor(rarity, curse);
		var width = Math.Clamp(Perk.Level / (float)Perk.MaxLevel, 0f, 1f) * 100f;
		var maxLevel = PerkManager.GetMaxLevelForRarity(rarity);
	}
	<panel class="rarity" style="opacity: @opacity; background-color: @highlightColor.Rgba; background-image-tint:@rarityBgTint.Rgba;"></panel>

	@if (!curse && maxLevel > 1)
	{
		// @if (Perk.Level > (IsChoice ? 1 : 0))
		// {
			<panel class="progress" style="width:100%; background-color:@(new Color(0f, 0f, 0f, IsChoice ? 0.3f : 0.6f).Rgba); z-index: 1;"></panel>
		// }
	
		<panel class="progress" style="width:@(width)%; background-color:@highlightColor.Rgba; z-index: 2;"></panel>
	}
	
	<panel class="icon" style="background-image: url(@(Perk.GetImagePath( Perk.GetType() ))); background-color:@rarityBgTint.Rgba; opacity: @opacity;" />

	@if (Perk.Level > Perk.MaxLevel)
	{
		<div class="maxed">X</div>
	}

	@if ( !IsChoice && Perk.DisplayCooldown > 0f) // && !Manager.Instance.IsGameOver
	{
		<div class="cooldown" style="width:@(Math.Clamp(Perk.DisplayCooldown, 0f, 1f) * 100f)%; opacity:@(Utils.Map(Parent.Opacity, 0.3f, 1f, 1.3f, 0.75f)); background-color:@(Perk.DisplayCooldownColor.Rgba);"></div>
	}

	@if ( !string.IsNullOrEmpty(Perk.DisplayText)) //&& !Manager.Instance.IsGameOver && SS2Game.Current.SelectedPlayer == null)
	{
		<label class="display_text" style="color:@(Perk.DisplayTextColor.Rgba); opacity:@Perk.DisplayTextOpacity">@Perk.DisplayText</label>
	}

	@if (Banished)
	{
		<div class="banish">X</div>
	}

	@if (Perk.RealTimeSinceHighlight < Perk.HighlightDuration)
	{
		<div class="highlight" style="opacity:@(Utils.Map( Perk.RealTimeSinceHighlight, 0f, Perk.HighlightDuration, Perk.HighlightOpacity, 0f, EasingType.SineOut)); background-color:@(Perk.HighlightColor.Rgba);"></div>
	}

	@if(Perk.RealTimeSinceLevelDown < 3f) 
	{
		<div class="deleveled" style="opacity:@(Utils.Map( Perk.RealTimeSinceLevelDown, 0f, 3f, 4f, 0f, EasingType.QuadOut));"></div>
	}
</root>

@code
{
	public Perk Perk { get; set; }
	public bool NoTips { get; set; } = false;
	public bool Banished;

	public float Angle { get; set; }
	public float Scale { get; set; }

	public bool IsChoice { get; set; }

	protected override void OnMouseOver(MousePanelEvent e)
	{
		if (e.Target != this || NoTips || Perk == null)
			return;

		Manager.Instance.HoveredPerkType = TypeLibrary.GetType( Perk.GetType() );
		Manager.Instance.HoveredPerkPanel = this;
		Manager.Instance.HoveredPerkLevel = Perk.Level;
		Manager.Instance.IsHoveredPerkBanished = Banished;
		Manager.Instance.IsHoveredPerkAChoice = IsChoice;
		Manager.Instance.IsHoveredPerkHidden = false;

		Manager.Instance.HoveredPlayer = null;
		Manager.Instance.HoveredPlayerIcon = null;
	}

	protected override void OnMouseOut(MousePanelEvent e)
	{
		if ( Manager.Instance.HoveredPerkPanel != this ) return;
		Manager.Instance.HoveredPerkType = null;
		Manager.Instance.HoveredPerkPanel = null;
		Manager.Instance.IsHoveredPerkBanished = false;
	}

	protected override void OnAfterTreeRender ( bool firstTime )
	{
		base.OnAfterTreeRender( firstTime );

		if ( !IsVisible && Manager.Instance.HoveredPerkPanel == this )
		{
			Manager.Instance.HoveredPerkType = null;
			Manager.Instance.HoveredPerkPanel = null;
			Manager.Instance.IsHoveredPerkBanished = false;
		}
	}

	protected override int BuildHash()
	{
		var fadeHash = Math.Min(Perk.RealTimeSinceLevelUp, Perk.RealTimeSinceLevelDown) < 3f ? RealTime.Now : 0f;
		var highlightHash = Perk.RealTimeSinceHighlight.Relative < Perk.HighlightDuration ? Perk.RealTimeSinceHighlight.Relative : 0f;
		var cooldownHash = Perk.DisplayCooldown > 0f ? HashCode.Combine(Perk.DisplayCooldown, Parent.Opacity, Perk.DisplayCooldownColor) : 0f;
		var textHash = !string.IsNullOrEmpty(Perk.DisplayText) ? HashCode.Combine(Perk.DisplayText, Perk.DisplayTextColor, Perk.DisplayTextOpacity) : 0f;
		var transformHash = HashCode.Combine(Angle, Perk.IconAngleOffset, Perk.IconScale);
		return HashCode.Combine(
			Perk.Level, 
			fadeHash, 
			textHash,
			Manager.Instance.IsGameOver, 
			cooldownHash, 
			highlightHash,
			transformHash
		);
	}

	protected override void OnClick(MousePanelEvent e)
	{
		// if non-local player is selected, do nothing
		if (Manager.Instance.SelectedPlayer.IsValid())
			return;

		var player = Manager.Instance.LocalPlayer;
		if ( !player.IsValid() )
			return;

		if( player.Stats[PlayerStat.ClickAddPerk] > 0f )
		{
			var perkType = TypeLibrary.GetType( typeof( PerkClickAdd ) );
			if ( player.HasPerk( perkType ) )
			{
				var perk = player.GetPerk( perkType ) as PerkClickAdd;
				if ( perk != null ) 
				{
					perk.Activate(TypeLibrary.GetType(Perk.GetType()));

					Manager.Instance.HoveredPerkType = null;
					Manager.Instance.IsHoveredPerkBanished = false;

					e.StopPropagation();
				}
			}
		}
	}

	protected override void OnRightClick(MousePanelEvent e)
	{
		// if non-local player is selected, do nothing
		if (Manager.Instance.SelectedPlayer.IsValid())
			return;

		var player = Manager.Instance.LocalPlayer;
		if ( !player.IsValid() )
			return;

		if( player.Stats[PlayerStat.ClickRemovePerk] > 0f )
		{
			var perkType = TypeLibrary.GetType( typeof( PerkClickRemove ) );
			if ( player.HasPerk( perkType ) )
			{
				var perk = player.GetPerk( perkType ) as PerkClickRemove;
				if ( perk != null ) 
				{
					perk.Activate(TypeLibrary.GetType(Perk.GetType()));

					Manager.Instance.HoveredPerkType = null;
					Manager.Instance.IsHoveredPerkBanished = false;

					e.StopPropagation();
				}
			}
		}

		// player.LevelDownPerk(TypeLibrary.GetType(Perk.GetType()));
	}
}
@namespace Sandbox
@using Sandbox;
@using Sandbox.UI;
@using System;
@inherits PanelComponent
@attribute [StyleSheet("PerkWorldPanel.razor.scss")]

@if(PerkItem.ShouldHidePanel)
{
	return;
}

<root>
	@{
		var bgColor = PerkManager.GetCardRarityColor(PerkItem.PerkRarity, PerkItem.Curse);
	}
	<panel class="rarity" style="background-color:@(bgColor.Rgba);">
		<panel class="icon" style="background-image: url(@(PerkItem.IconPath)); " />
	</panel>
</root>

@code
{
	[Property] public PerkItem PerkItem { get; set; }

	protected override int BuildHash()
	{
		return HashCode.Combine(Time.Now);
	}
}

@namespace Sandbox
@using Sandbox;
@using Sandbox.UI;
@using System;
@using System.Text.Json;
@inherits Panel
@attribute [StyleSheet("PlayerProfilePanel.razor.scss")]

<root>
	<div class="hide_button" onclick=@(() => HideProfile())></div>

	@if(PlayerStats is null)
	{
		@* <div class="loading">
			<label>@($"Loading...")</label>
		</div> *@

		return;
	}

	<div class="name_container">
		<div class="avatar" style="background-image: url( avatar:@Manager.Instance.PlayerProfileToShow.steamId )"></div>

		@{
			var displayName = Manager.Instance.PlayerProfileToShow.displayName;
		}

		<div class="displayName">@displayName</div>
	</div>

	<div class="main-content">
		@* @if( _isLoadingPerks)
		{
			<div class="loading_perks">@($"Loading perks...")</div>
		}
		else
		{
			if(_perkPickPercents.Count > 0)
			{
				<div class="perks_container">
					<div class="perks_title">@($"Favorite Perks:")</div>

					<div class="perks">
						@{
							foreach(var pair in _perkPickPercents.OrderByDescending(x => x.Value).Take(10))
							{
								var perkType = pair.Key;
								<PerkIconStatic style="height: 100%;" PerkType=@perkType Level=@(1) HideLevel=@true [email protected] />
							}
						}
					</div>
				</div>
			}
		} *@

		@* <div class="stats_title">
			Stats
		</div> *@

		<div class="stats">
			<DifficultyPanel DontChangeGameDifficulty=@true />

			@{
				var numRuns = GetStatSum(StatType.NumRuns);
				var numWins = GetStatSum(StatType.NumVictory);
				// var numLosses = GetStatSum(StatType.NumDefeat);
				// var numResets = Math.Max(numRuns - (numWins + numLosses), 0);

				var winPercent = numRuns > 0 ? (numWins / (float)numRuns) * 100f : 0f;
				
				string winRateString;
				if(winPercent >= 1f) winRateString = $"{winPercent:0.#}%";
				else if(winPercent >= 0.1f) winRateString = $"{winPercent:0.##}%";
				else if(winPercent >= 0.01f) winRateString = $"{winPercent:0.###}%";
				else if(winPercent > 0f) winRateString = $"{winPercent:0.####}%";
				else winRateString = "0%";

				var numKills = GetStatSum(StatType.NumKills);
				var numMinibossKills = GetStatSum(StatType.NumMinibossKills);
				var fastestWinScore = GetStatMax(StatType.LeaderboardRun);
				var hasFastestWin = fastestWinScore > Manager.VICTORY_OFFSET / 2f;
				var fastestWinTime = hasFastestWin ? Manager.VICTORY_OFFSET - fastestWinScore : 0f;
				var fastestWinString = hasFastestWin ? Utils.FormatTime(fastestWinTime) : "...";

				var i = 0;

				PlayerStatsToShow.Clear();
				PlayerStatsToShow.Add(new PlayerProfileStatData("Victories", numWins.ToString("N0"), new Color(1f, 1f, 0.5f), "win", GetFontSizeForNumber(numWins)));
				PlayerStatsToShow.Add(new PlayerProfileStatData("Fastest Victory", fastestWinString, new Color(0.5f, 1f, 0.5f), "clock"));
				PlayerStatsToShow.Add(new PlayerProfileStatData("Attempts", numRuns.ToString("N0"), new Color(0.8f), "num_runs", GetFontSizeForNumber(numRuns)));
				PlayerStatsToShow.Add(new PlayerProfileStatData("Winrate", winRateString, new Color(0.6f, 0.6f, 0.9f, 0.7f), "win_rate"));

				PlayerStatsToShow.Add(new PlayerProfileStatData("Enemy Kills", numKills.ToString("N0"), new Color(0.8f, 0.2f, 0.2f), "kill", GetFontSizeForNumber(numKills)));
				PlayerStatsToShow.Add(new PlayerProfileStatData("Miniboss Kills", numMinibossKills.ToString("N0"), new Color(0.9f, 0.4f, 0.1f), "kill_miniboss", GetFontSizeForNumber(numMinibossKills)));
			}

			<div class="list_container">
				@foreach(var data in PlayerStatsToShow)
				{
					<div class="flat list" style="background-color: @((i % 2 == 0 ? new Color(0, 0, 0, 0.4f) : new Color(0, 0, 0, 0.7f)).Rgba);">
						<div>
							<div class="icon_container">
								<label class="icon" style="background-color:@(data.color.Rgba); mask-image:@($"url(/textures/ui/stats/{data.icon}.png)")"></label>
							</div>

							<label class="bold stat_name" style="font-size:@(data.title.Length > 15 ? Math.Round(Utils.Map(data.title.Length, 15, 36, 14f, 10f, EasingType.SineIn)) : 14)px; color:@(Color.Lerp(data.color, Color.White, (i % 2 == 0 ? 0.5f : 0.4f)).Rgba);">@(data.title)</label>
						</div>

						<div class="values">
							@{
								var color = (data.text == "0" || data.text == "0%" || data.text == "...")
									? Color.Lerp(data.color, new Color(0.5f), 0.75f).WithAlpha(0.5f).Rgba
									: data.color.Rgba;
							}

							<label class="bold stat_value" style="color:@(color); font-size:@(data.fontSize)px;">@(data.text)</label>
						</div>
					</div>

					@{
						i++;
					}
				}
			</div>
		</div>
	</div>
</root>

@code
{
	public struct PlayerProfileStatData
	{
		public string title;
		public string text;
		public Color color;
		public string icon;
		public float fontSize;

		public PlayerProfileStatData(string _title, string _text, Color _color, string _icon, float _fontSize = 16f)
		{
			title = _title;
			text = _text;
			color = _color;
			icon = _icon;
			fontSize = _fontSize;
		}
	}

	public List<PlayerProfileStatData> PlayerStatsToShow { get; set; } = new();

	private bool _isLoadingPerks;
	private long _loadedSteamId;

	public Sandbox.Services.Stats.PlayerStats PlayerStats { get; set; }

	private Dictionary<TypeDescription, float> _perkPickPercents = new();

	protected override void OnAfterTreeRender(bool firstTime)
	{
		base.OnAfterTreeRender(firstTime);

		var currentSteamId = Manager.Instance.PlayerProfileToShow?.steamId ?? 0;
		if (firstTime || currentSteamId != _loadedSteamId)
		{
			_loadedSteamId = currentSteamId;
			Refresh();
		}
	}

	async void Refresh()
	{
		PlayerStats = null;
		StateHasChanged();

		_isLoadingPerks = true;

		PlayerStats = Sandbox.Services.Stats.GetPlayerStats("facepunch.ss2", Manager.Instance.PlayerProfileToShow.steamId);
		await PlayerStats.Refresh();

		_isLoadingPerks = false;

		_perkPickPercents.Clear();

		/*
		foreach(var type in TypeLibrary.GetTypes<Perk>())
		{
			var attrib = type.GetAttribute<PerkAttribute>();
			if(attrib == null)
				continue;

			if(attrib.Disabled)
				continue;

			if(attrib.Curse)
				continue;

			var timesChosen = (int)PlayerStats.Get(Manager.GetPerkStatString(StatType.PerkChosen, type)).Sum;
			var timesIgnored = (int)PlayerStats.Get(Manager.GetPerkStatString(StatType.PerkIgnored, type)).Sum;

			// if( timesChosen + timesIgnored > 0 && (timesChosen > 2 || timesIgnored > 3) )
			if( timesChosen >= 2 )
			{
				var percent = MathX.Clamp(timesChosen / (float)(timesChosen + timesIgnored), 0f, 1f);
				// Log.Info($"{type}: chosen {timesChosen}, ignored {timesIgnored} - percent: {percent}");

				_perkPickPercents[type] = percent;
			}
		}
		*/

		// Log.Info($"Loaded {_perkPickPercents.Count} perks for player profile.");

		// var totalWinTime = 0f;
		// for(int difficulty = 1; difficulty <= Manager.MaxDifficulty; difficulty++)
		// {

		// }

		StateHasChanged();

		// var zombies = stats.Get("zombies_killed");

		// Log.Info($"Garry has killed {zombies.Sum} zombies!");

		// Log.Info($"Refreshed leaderboard with {Leaderboard.Entries.Count()} entries.");
	}

	protected override int BuildHash()
	{
		return HashCode.Combine(
			DifficultyPanel.DifficultyToDisplay,
			Manager.Instance.PlayerProfileToShow
		);
	}

	public void HideProfile()
	{
		Manager.Instance.PlayerProfileToShow = null;
	}

	public int GetStatSum( StatType statType )
	{
		if( DifficultyPanel.DifficultyToDisplay == -1 )
		{
			int total = 0;
			for(int diff = Manager.MinDifficulty; diff <= Manager.MaxDifficulty; diff++)
			{
				total += (int)PlayerStats.Get(Manager.GetStatString(statType, numPlayers: 1, diff)).Sum;
			}
			return total;
		}
		else
		{
			return (int)PlayerStats.Get(Manager.GetStatString(statType, numPlayers: 1, DifficultyPanel.DifficultyToDisplay)).Sum;
		}
	}

	public float GetStatMax( StatType statType )
	{
		if( DifficultyPanel.DifficultyToDisplay == -1 )
		{
			float max = 0f;
			for(int diff = Manager.MinDifficulty; diff <= Manager.MaxDifficulty; diff++)
			{
				var val = (float)PlayerStats.Get(Manager.GetStatString(statType, numPlayers: 1, diff)).Max;
				if(val > max)
				max = val;
			}

			return max;
		}
		else
		{
			return (float)PlayerStats.Get(Manager.GetStatString(statType, numPlayers: 1, DifficultyPanel.DifficultyToDisplay)).Max;
		}
	}

	public static float GetFontSizeForNumber( float number )
	{
		if(number >= 1000000000f) return 10f;
		if(number >= 100000000f) return 11f;
		if(number >= 10000000f) return 12f;
		if(number >= 1000000f) return 14f;
		return 16f;
	}
}
@using Sandbox;
@using Sandbox.UI;
@inherits Panel
@namespace Sandbox

<root>
	@Path
</root>


@code
{
	public string Path { get; set;  }

}
@namespace Facepunch.UI
@inherits PanelComponent

<root>

    @foreach (var voice in VoiceList.Where( x => CanDisplay( x ) ) )
    {
        <div class="item-row">
            <div class="avatar-wrap">
                <div class="speaking-glow" style="opacity: @GetGlowOpacity( voice ); transform: scale( @GetGlowScale( voice ) )"></div>
                <img class="avatar" src="avatar:@voice.Network.Owner.SteamId" />
            </div>
            <label class="name">@voice.Network.Owner.DisplayName</label>
        </div>
    }

</root>

@code
{
    public IEnumerable<Voice> VoiceList => Scene.GetAllComponents<Voice>();

    private Dictionary<ulong, float> _smoothed = new();

    private float GetSmoothed( Voice voice )
    {
        var id = voice.Network.Owner.SteamId;
        _smoothed[id] = _smoothed.GetValueOrDefault( id, 0f ).LerpTo( voice.Amplitude, Time.Delta * 10f );
        return _smoothed[id];
    }

    private bool CanDisplay( Voice voice )
    {
        if ( voice.Network.Owner is null ) return false;
        return voice.LastPlayed < 0.25f;
    }

    private string GetGlowOpacity( Voice voice )
    {
        var s = GetSmoothed( voice );
        return Math.Clamp( s * 4f, 0.2f, 0.9f ).ToString( "0.##" );
    }

    private string GetGlowScale( Voice voice )
    {
        var s = GetSmoothed( voice );
        return Math.Clamp( 1f + s * 0.6f, 1f, 1.6f ).ToString( "0.##" );
    }

    protected override int BuildHash()
    {
        return HashCode.Combine( Time.Now );
    }
}
@namespace Sandbox
@using Sandbox;
@using Sandbox.UI;
@using System;
@inherits Panel
@attribute [StyleSheet("PerkChoicePanel.razor.scss")]

@{
	var player = GetViewedPlayer();
	var isReadOnly = IsReadOnly( player );
	var showViewingLabel = isReadOnly && !Manager.Instance.IsSpectator;

	if ( !player.IsValid() || (!player.IsChoosingLevelUpReward && !showViewingLabel) )
	{
		Manager.Instance.IsHoveringPerkChoicePanel = false;
		return;
	}
}

@{
	var showBanish = player.HasGottenBanish;
	var showSkip = GetStat( player, PlayerStat.SkipPerkNumRerolls ) > 0f && !player.IsBeingShownCurseChoices;
	var showRandom = GetStat( player, PlayerStat.CanChooseRandomPerk ) > 0f;
	int numChoices = player.GetDisplayedPerkChoiceCount();
}

<root style="bottom: @(Manager.Instance.ShowBossHealthbar ? 50 : 10)px;">
	@if ( showViewingLabel )
	{
		<div class="viewing_label">
			<label>VIEWING CHOICES OF</label>
			<div class="viewing_avatar" style="background-image: url( avatar:@player.Network.Owner.SteamId );"></div>
		</div>
	}
	@if ( player.IsChoosingLevelUpReward )
	{
	@if ( player.NumPerkPointsAvailable > (player.IsBeingShownCurseChoices ? 0 : 1) )
	{
		@{
			var left = 33 + (showBanish ? -8 : 0) + (showSkip ? -6 : 0) + (showRandom ? -5 : 0);
		}

		<div class="num_left" style="left:@(left)%;">
			@{ var extraPerks = player.NumPerkPointsAvailable - 1; }
			<label style="color: #ffffffbb; padding-right: 2px;">@($"{extraPerks}")</label>
			<label style="color: #ffffff88; padding-left: 2px;">@($"MORE {(extraPerks > 1 ? "PERKS" : "PERK")}")</label>
		</div>
	}

	<div class="itemselection @(numChoices > 17 ? "scrollable" : "")" style="gap: @(numChoices == 5 ? 6 : 12)px;">
		@{
			bool onlyShowIcons = numChoices > 5;
		}

		@for ( int i = 0; i < player.GetDisplayedPerkChoiceCount(); i++ )
		{
			var perkType = player.GetDisplayedPerkChoice( i );
			var isUnknown = player.GetDisplayedPerkChoiceUnknown( i );
			var index = i;
			<PerkChoice ViewedPlayer=@player PerkType=@perkType IsChoice=@true OnlyShowIcon=@onlyShowIcons Slot=@i IsUnknown=@isUnknown onclick="@(e => PerkChosen( e, perkType, index ))" />
		}

		@if ( GetStat( player, PlayerStat.ChooseTimeLimit ) > 0f )
		{
			<div class="timelimitbar">
				@{
					var timeLimitProgress = Utils.Map( player.RealTimeSinceOfferedChoices, 0f, GetStat( player, PlayerStat.ChooseTimeLimit ), 0f, 1f );
				}
				<div style="width:@((1f - timeLimitProgress) * 100f)%; background-color:@(Color.Lerp(new Color(1f, 0.8f, 0.8f), new Color(1f, 0f, 0f), timeLimitProgress).Rgba);"></div>
			</div>
		}
	</div>
	<div class="lower_row">
		<div class="button_outer">
			@{
				bool canUseArmor = GetStat( player, PlayerStat.ArmorRerollCost ) > 0f && player.Armor >= (int)GetStat( player, PlayerStat.ArmorRerollCost );
				bool isFree = GetStat( player, PlayerStat.FreeRerollTime ) > 0f && player.RealTimeSinceLvlUp < GetStat( player, PlayerStat.FreeRerollTime );
				bool canReroll = player.NumRerollAvailable > 0 || isFree || canUseArmor;
				var autoRerollTimerProgress = player.GetDisplayedAutoRerollTimerProgress();
			}
			<panel class="button_container @((canReroll && !isReadOnly) ? "" : "disabled")" style="background-image: url(@("/textures/ui/panel/reroll_panel.png"));" onclick="@(e => RerollClicked( e ))">
				@{
					string amountText = isFree ? "∞" : (canUseArmor ? $"{(int)GetStat(player, PlayerStat.ArmorRerollCost)}⛊" : $"{player.NumRerollAvailable}");
					int amountFontSize = canUseArmor ? 16 : 20;
				}

				<InputHint class="ctrl" Button="R" style="opacity: @((canReroll && !isReadOnly) ? 1f : 0.08f);"></InputHint>
				<label class="button" style="color:@((canReroll && !isReadOnly) ? "#E8FFF4CC" : "#ffffff99");"></label>
				<label class="amount" style="color:@((canReroll && !isReadOnly) ? "#88B19E" : "#ffffff99"); font-size:@($"{amountFontSize}px");">@amountText</label>

				@if ( isFree )
				{
					@{
						var freeRerollProgress = Utils.Map( player.RealTimeSinceLvlUp, 0f, GetStat( player, PlayerStat.FreeRerollTime ), 0f, 1f );
					}
					<div class="free_reroll_bar">
						<div style="width:@((1f - freeRerollProgress) * 100f)%;"></div>
					</div>
				}
				@if ( autoRerollTimerProgress > 0f && canReroll )
				{
					<div class="autoreroll_bar">
						<div style="width:@((1f - autoRerollTimerProgress) * 100f)%;"></div>
					</div>
				}
			</panel>
		</div>

		@if ( showBanish )
		{
			<div class="button_outer">
				<panel class="button_container @((player.NumBanishAvailable > 0 && !isReadOnly) ? "" : "disabled")" style="background-image: url(@("/textures/ui/panel/banish_panel.png"));" onclick="@(e => BanishClicked( e ))">
					<InputHint class="ctrl" Button="banish" style="opacity: @((player.NumBanishAvailable > 0 && !isReadOnly) ? 1f : 0.08f);"></InputHint>
					<label class="button" style="color:@((player.NumBanishAvailable > 0 && !isReadOnly) ? "#E8FFF4CC" : "#ffffff99");"></label>
					<label class="amount" style="color:@((player.NumBanishAvailable > 0 && !isReadOnly) ? "#88B19E" : "#ffffff99");">@player.NumBanishAvailable</label>
				</panel>
			</div>
		}

		@if ( showSkip )
		{
			<div class="button_outer">
				<panel class="button_container @(isReadOnly ? "disabled" : "")" style="background-image: url(@("/textures/ui/panel/skip_panel.png"));" onclick="@(e => SkipClicked( e ))">
					<label class="button" style="color:@(isReadOnly ? "#ffffff99" : "#E8FFF4CC"); width: 90px;"></label>
					<label class="amount" style="font-size: 16px; color:@(!isReadOnly && (int)GetStat(player, PlayerStat.SkipPerkNumRerolls) > 0 ? "#88B19E" : "#ffffff55");">@($"+{(int)GetStat(player, PlayerStat.SkipPerkNumRerolls)}")</label>
				</panel>
			</div>
		}

		@if ( showRandom )
		{
			<div class="button_outer">
				<panel class="button_container @(isReadOnly ? "disabled" : "")" style="background-image: url(@("/textures/ui/panel/random_panel.png"));" onclick="@(e => RandomClicked( e ))">
					<label class="button" style="color:@(isReadOnly ? "#ffffff99" : "#E8FFF4CC"); width: 120px;"></label>
				</panel>
			</div>
		}
	</div>
	}

</root>

@code {
	private Player GetViewedPlayer()
	{
		var manager = Manager.Instance;
		var player = manager.SelectedPlayer.IsValid()
			? manager.SelectedPlayer
			: manager.LocalPlayer;

		if ( player.IsValid() && player.IsDead )
			return null;

		return player;
	}

	private bool IsReadOnly( Player player )
	{
		return player.IsValid() && player != Manager.Instance.LocalPlayer;
	}

	private Player GetInteractivePlayer()
	{
		var player = GetViewedPlayer();
		return IsReadOnly( player ) ? null : player;
	}

	private float GetStat( Player player, PlayerStat stat )
	{
		return player.IsValid() ? player.GetUiStat( stat ) : 0f;
	}

	protected override void OnMouseOver( MousePanelEvent e )
	{
		Manager.Instance.IsHoveringPerkChoicePanel = true;
	}

	protected override void OnMouseOut( MousePanelEvent e )
	{
		Manager.Instance.IsHoveringPerkChoicePanel = false;
	}

	protected void PerkChosen( PanelEvent e, TypeDescription type, int index )
	{
		e.StopPropagation();

		if ( Manager.Instance.IsGameOver || Manager.Instance.IsPaused )
			return;

		var player = GetInteractivePlayer();
		if ( !player.IsValid() )
			return;

		if ( Manager.Instance.HoveredPerkType != null && Manager.Instance.IsHoveredPerkAChoice )
		{
			Manager.Instance.HoveredPerkType = null;
			Manager.Instance.HoveredPerkViewedPlayer = null;
			Manager.Instance.HoveredPerkChoiceSlot = -1;
		}

		if ( !player.CanInteractWithChoices )
			return;

		if ( player.IsBanishMode )
		{
			player.CanInteractWithChoices = false;
			player.BanishPerkUIChoice( type );
			Manager.Instance.PlaySfxUI( "banish_perk", pitch: Utils.Map( player.NumBanishAvailable, 0, 5, 1f, 1.3f, EasingType.QuadIn ), volume: 0.95f );
		}
		else
		{
			player.CanInteractWithChoices = false;
			player.AddPerkUIChoice( type );
			Manager.Instance.PlaySfxUI( "click", pitch: Utils.Map( player.NumPerkPointsAvailable, 0, 10, 1f, 0.8f, EasingType.QuadIn ), volume: 0.75f );
		}
	}

	protected void RerollClicked( PanelEvent e )
	{
		e.StopPropagation();

		var player = GetInteractivePlayer();
		if ( !player.IsValid() )
			return;

		player.UseReroll();
	}

	protected void BanishClicked( PanelEvent e )
	{
		e.StopPropagation();

		var player = GetInteractivePlayer();
		if ( !player.IsValid() )
			return;

		player.ToggleBanish();
	}

	protected void SkipClicked( PanelEvent e )
	{
		e.StopPropagation();

		var player = GetInteractivePlayer();
		if ( !player.IsValid() )
			return;

		player.RefreshAfterChoosingPerk();

		var perkType = TypeLibrary.GetType( typeof( PerkSkipChoices ) );
		if ( player.HasPerk( perkType ) )
		{
			var skipPerk = player.GetPerk( perkType ) as PerkSkipChoices;
			if ( skipPerk != null )
				skipPerk.OnSkip();
		}
	}

	protected void RandomClicked( PanelEvent e )
	{
		e.StopPropagation();

		var player = GetInteractivePlayer();
		if ( !player.IsValid() )
			return;

		var perkType = TypeLibrary.GetType( typeof( PerkRandomChoice ) );
		if ( player.HasPerk( perkType ) )
		{
			var randomPerk = player.GetPerk( perkType ) as PerkRandomChoice;
			if ( randomPerk != null )
				randomPerk.OnRandom();
		}
	}

	protected override int BuildHash()
	{
		var player = GetViewedPlayer();
		if ( player is null || !player.IsValid() )
			return 0;

		bool canUseArmor = GetStat( player, PlayerStat.ArmorRerollCost ) > 0f && player.Armor >= (int)GetStat( player, PlayerStat.ArmorRerollCost );
		bool isFree = GetStat( player, PlayerStat.FreeRerollTime ) > 0f && player.RealTimeSinceLvlUp < GetStat( player, PlayerStat.FreeRerollTime );

		var timeLimitHash = GetStat( player, PlayerStat.ChooseTimeLimit ) > 0f ? player.RealTimeSinceOfferedChoices.Relative : 0f;
		var freeRerollHash = isFree ? player.RealTimeSinceLvlUp.Relative : 0f;
		var autoRerollTimerHash = player.GetDisplayedAutoRerollTimerProgress();

		var lockBanishHash = HashCode.Combine(
			player.NumBanishAvailable,
			player.HasGottenBanish
		);

		int buttonsHash = HashCode.Combine(
			player.NumRerollAvailable,
			canUseArmor,
			player.Armor,
			isFree,
			(int)GetStat( player, PlayerStat.SkipPerkNumRerolls ),
			(int)GetStat( player, PlayerStat.CanChooseRandomPerk )
		);

		var panelStateHash = HashCode.Combine(
			player.IsChoosingLevelUpReward,
			player.NumPerkPointsAvailable,
			player.GetDisplayedPerkChoiceCount(),
			player.PerkChoiceHash,
			player.IsBeingShownCurseChoices,
			IsReadOnly( player )
		);

		return HashCode.Combine(
			timeLimitHash,
			freeRerollHash,
			lockBanishHash,
			buttonsHash,
			panelStateHash,
			HashCode.Combine(
				autoRerollTimerHash,
				Manager.Instance.ShowBossHealthbar,
				Input.UsingController
			)
		);
	}
}
@namespace Sandbox
@using Sandbox;
@using Sandbox.UI;
@using System;
@inherits Panel
@attribute [StyleSheet("PlayerIcon.razor.scss")]

@{
	var borderColor = Manager.Instance.SelectedPlayer.IsValid()
				? Color.Lerp(Color.White, new Color(0.5f, 0.5f, 1f), Utils.FastSin(Time.Now * 24f))
				: Color.White;
}

<root style="border: @(Player == Manager.Instance.SelectedPlayer ? 1 : 0)px solid @(borderColor.WithAlpha(0.75f).Rgba);">
	<div class="icon" style="opacity:@((Player == Manager.Instance.SelectedPlayer ? 1f : 0.5f) * (Player.IsDead ? 0.1f : 1f) * (IsHovering ? 1f : 0.8f));">
		<img src="avatar:@Player.Network.Owner.SteamId" />
		<div class="hp_bg"></div>
		<div class="hp_delta" style="width:@((Player.Health / Player.GetSyncStat(PlayerStat.MaxHp)) * 100f)%;"></div>
		<div class="hp_fill" style="width:@((Player.Health / Player.GetSyncStat(PlayerStat.MaxHp)) * 100f)%;"></div>
	</div>
	<div class="player_level">@(Player.Level)</div>
</root>

@code
{
	public Player Player { get; set; }
	public bool IsHovering;

	protected override void OnClick(MousePanelEvent e)
	{
		base.OnClick(e);

		if (Manager.Instance.SelectedPlayer == Player)
			Manager.Instance.SelectedPlayer = null;
		else
			Manager.Instance.SelectedPlayer = Player;

		e.StopPropagation();
	}

	protected override void OnMouseOver(MousePanelEvent e)
	{
		if(e.Target != this)
			return;

		Manager.Instance.HoveredPlayerIcon = Player;
		IsHovering = true;
	}

	protected override void OnMouseOut(MousePanelEvent e)
	{
		Manager.Instance.HoveredPlayerIcon = null;
		IsHovering = false;
	}

	protected override int BuildHash()
	{
		return HashCode.Combine(RealTime.Now);
	}
}
@namespace Sandbox
@using Sandbox;
@using Sandbox.UI;
@using System;
@inherits Panel
@attribute [StyleSheet("PlayerTooltip.razor.scss")]

<root>
	@{
		string name = "";
	}

	@if(Player != null )
	{
		@if(ShowIcon) 
		{
			<div class="icon">
				<img src="avatar:@Player.Network.Owner.SteamId" />
				<div class="level">@Player.Level</div>
			</div>
		}

		@{
			name = Player?.Network.Owner?.DisplayName ?? "";
		}
	}
	else
	{
		name = Name ?? "";
	}

	<div class="name">@name</div>
	@if(Player != null && Player.Network.Owner != null)
	{
		<div class="ping">@($"{Player.Network.Owner.Ping:0}ms")</div>
	}
</root>

@code {
	public Player Player { get; set; }
	public bool ShowIcon { get; set; }

	public string Name { get; set; }

	public override void Tick()
	{
		SetClass( "hidden", Input.UsingController );

		var mousePos = Mouse.Position;

		if (mousePos.x > Screen.Width * 0.9f)
		{
			mousePos += new Vector2(-20f, 20f);
			var mousePosRelative = mousePos / Screen.Size;
			Style.Right = Length.Fraction(1f - mousePosRelative.x);
			Style.Top = Length.Fraction(mousePosRelative.y);
		}
		else
		{
			mousePos += new Vector2(20f, 20f);
			var mousePosRelative = mousePos / Screen.Size;
			Style.Left = Length.Fraction(mousePosRelative.x);
			Style.Top = Length.Fraction(mousePosRelative.y);
		}
	}

	protected override int BuildHash()
	{
		return HashCode.Combine(RealTime.Now);
	}
}

@namespace Sandbox
@inherits Panel
@using Sandbox.UI
@implements IRichTextPanel
@attribute [RichTextPanel( @"([+-]?\d+(?:\.\d+)?[%ms]?)[ \t]*(?:→|->)[ \t]*([+-]?\d+(?:\.\d+)?[%ms]?)", UseCaptureGroup = false )]


<root class="richtext-arrow-result">
	<label class="val val1" style="color: @(Color.Hex);">@Text1</label>
	<label>→</label>
	<label class="val val2" style="color: @(Color.Hex);">@Text2</label>
</root>

<style>
	.val1
	{
		opacity: 0.2;
	}
</style>


@code
{
	public virtual Color Color => Color.Green;
	public float? ImageSize { get; set; }

	string Text1;
	string Text2;

	public void ParseRichText(string text)
	{
		// Log.Info( "Parsing arrow result: " + text );
		var split = text.Split( "->");
		if(split.Length == 1)
		{
			split = text.Split( "→" );
		}

		if(split.Length == 2)
		{
			Text1 = split[0];
			Text2 = split[1];

			if ( Text1.StartsWith( "[-]" ) || Text1.StartsWith( "[+]" ) )
			{
				Text1 = Text1[3..];
			}

			if ( Text2.EndsWith( "[/-]" ) || Text2.EndsWith( "[/+]" ) )
			{
				Text2 = Text2[..^4];
			}

			if(Text1.StartsWith("+") && !Text2.StartsWith("-") && !Text2.StartsWith("+"))
			{
				Text2 = "+" + Text2;
			}
		}
		else
		{
			Text1 = text;
			Text2 = "";
		}
	}

	protected override int BuildHash()
	{
		return System.HashCode.Combine( Text1, Text2, Color );
	}
}
@using Sandbox;
@using Sandbox.UI;
@inherits Panel
@namespace Sandbox
@attribute [SpawnMenuHost.SpawnMenuMode]
@attribute [Icon( "🎨" )]
@attribute [Title( "#spawnmenu.mode.effects" )]
@attribute [Order( -75 )]

<root>
    <EffectsList />
    <EffectsProperties />
</root>
@using Sandbox;
@using Sandbox.UI;
@inherits Panel
@namespace Sandbox

<SpawnMenuContent>

    <Header>
        <SpawnMenuToolbar></SpawnMenuToolbar>
    </Header>

    <Body>
        <VirtualGrid Items=@( Entries ) ItemSize=@(120)>
            <Item Context="item">
                @if (item is Entry entry)
                {
                    <SpawnMenuIcon Ident=@($"prop:{entry.Ident}")></SpawnMenuIcon>
                }
            </Item>
        </VirtualGrid>
    </Body>

</SpawnMenuContent>

@code
{
    record class Entry( string Icon, string Ident );

    private readonly List<Entry> Entries = new()
    {
        new("https://cdn.sbox.game/asset/facepunch.oildrumexplosive/thumb.png.fff8e3787c17283", "facepunch.oildrumexplosive"),
        new("https://cdn.sbox.game/asset/facepunch.watermelon/thumb.png.683db0caeab55816", "facepunch.watermelon"),
        new("https://cdn.sbox.game/asset/facepunch.toolchest/thumb.png.e22d621990091e0d", "facepunch.toolchest"),
        new("https://cdn.sbox.game/asset/facepunch.cabinet_a3/thumb.png.1bf467b6816cecf8", "facepunch.cabinet_a3"),
        new("https://cdn.sbox.game/asset/facepunch.wooden_chair_a/thumb.png.e37e2f0a1d36a772", "facepunch.wooden_chair_a"),
        new("https://cdn.sbox.game/asset/facepunch.water_drum_01/thumb.png.34b58345ab151a28", "facepunch.water_drum_01"),
        new("https://cdn.sbox.game/asset/facepunch.metalwheelbarrow/thumb.png.25663142bc944891", "facepunch.metalwheelbarrow"),
        new("https://cdn.sbox.game/asset/facepunch.hoovera/thumb.png.740d47f682e9ebb9", "facepunch.hoovera"),
        new("https://cdn.sbox.game/asset/facepunch.washingmachine/thumb.png.7aca1114cc535187", "facepunch.washingmachine"),
        new("https://cdn.sbox.game/asset/fpopium.crackhead_02/thumb.png.195fcd31c282be9f", "fpopium.crackhead_02"),
        new("https://cdn.sbox.game/asset/fish.moose/thumb.png.56b890737161bf47", "fish.moose"),
        new("https://cdn.sbox.game/asset/starblue.forklifttruck/thumb.png.d684be52bb80e4d8", "starblue.forklifttruck"),
    };

    void Spawn( string ident )
    {
        GameManager.Spawn( $"prop:{ident}" );
    }
}
@using Sandbox;
@using Sandbox.UI;
@inherits Panel

<root class="row player">

	@if ( Entry is not null && Entry.Connection is not null )
	{
		var steamId = Entry.Connection.SteamId;
		bool isMuted = SandboxVoice.IsMuted( steamId );

		<img class="avatar" src="avatar:@steamId" />
		<div class="name">@Entry.DisplayName</div>
		<div class="stat">@Entry.Kills</div>
		<div class="stat">@Entry.Deaths</div>
		<div class="stat">@(Entry.Ping.CeilToInt())</div>
		@if ( !Entry.IsMe )
		{
			<div class="mute-btn @(isMuted ? "muted" : "")" onclick="@( () => SandboxVoice.Mute( steamId ) )">
				<i>@(isMuted ? "volume_off" : "volume_up")</i>
			</div>
		}
		else
		{
			<div class="mute-spacer"></div>
		}
	}

</root>

@code
{
    public PlayerData Entry { get; set; }

    public override void Tick()
    {
        if ( Entry is null ) return;
        SetClass( "me", Entry.IsMe );
        if ( Entry.Connection is not null )
            SetClass( "friend", new Friend( Entry.Connection.SteamId ).IsFriend );
    }

    protected override int BuildHash()
    {
        if (Entry is null) return 0;
        return System.HashCode.Combine( Entry.DisplayName, Entry.Kills, Entry.Deaths, Entry.Ping );
    }

    protected override void OnRightClick( MousePanelEvent e )
    {
        if ( Entry is null || Entry.Connection is null ) return;

        var steamId = Entry.Connection.SteamId;
        var menu = Sandbox.MenuPanel.Open( this );

        menu.AddOption( "content_copy", "Copy Steam ID", () =>
        {
            Clipboard.SetText( steamId.ToString() );
            Notices.AddNotice( "copy_all", Color.Cyan, $"Copied {Entry.Connection.DisplayName}'s SteamID to your clipboard", 5 );
        } );

		if ( !Entry.IsMe )
		{
			bool isMuted = SandboxVoice.IsMuted( steamId );
			menu.AddOption( isMuted ? "volume_up" : "volume_off", isMuted ? "Unmute" : "Mute", () => SandboxVoice.Mute( steamId ) );

			if ( Connection.Local?.HasPermission( "admin" ) == true )
			{
				menu.AddSpacer();
				menu.AddOption( "person_remove", "Kick", () => OpenKickConfirm( Entry ) );
				menu.AddOption( "gavel", "Ban", () => OpenBanConfirm( Entry ) );
			}
		}

		e.StopPropagation();
	}

	void OpenKickConfirm( PlayerData entry )
	{
		var popup = new StringQueryPopup
		{
			Title = "Kick Player",
			Prompt = $"Why do you want to kick {entry.DisplayName}?",
			ConfirmLabel = "Kick",
			OnConfirm = x =>
			{
				GameManager.RpcKickPlayer( entry.Connection, x );
			}
		};
		popup.Parent = FindPopupPanel();
	}

	void OpenBanConfirm( PlayerData entry )
	{
		var popup = new StringQueryPopup
		{
			Title = "Ban Player",
			Prompt = $"Why do you want to ban {entry.DisplayName}?",
			ConfirmLabel = "Ban",
			OnConfirm = x => BanSystem.RpcBanPlayer( entry.Connection, x )
		};
		popup.Parent = FindPopupPanel();
	}
}
@using Sandbox;
@using Sandbox.UI;
@using Sandbox.Mounting;
@inherits Panel
@namespace Sandbox

<root>
    <div class="name">@(Content.GetMeta<string>( "name" ) ?? "Dupe")</div>
</root>

@code
{
    public Storage.Entry Content;

    protected override void OnParametersSet()
    {
        Style.SetBackgroundImage( Content.Thumbnail );
    }

    protected override void OnMouseDown( MousePanelEvent e )
    {
        if ( e.MouseButton == MouseButtons.Right )
        {
            var menu = MenuPanel.Open( this );

            menu.AddOption( "delete", "#spawnmenu.common.delete", () => Content.Delete() );
            menu.AddOption( "publish", "#spawnmenu.common.publish", Publish );

            SpawnlistData.PopulateContextMenu( menu, new SpawnlistItem
            {
                Ident = SpawnlistItem.MakeIdent( "dupe", Content.Id.ToString(), "local" ),
                Title = Content.GetMeta<string>( "name" ) ?? "Dupe",
                Icon = null,
            } );

            return;
        }

        base.OnMouseDown( e );
    }

    void Publish()
    {
        var options = new Modals.WorkshopPublishOptions();
        options.Title = "My Dupe";
        options.AddCategory<DupeCategory>( "Category" );
        options.AddCategory<DupeMovement>( "Movement" );

        Content.Publish(options);
    }
}
@using Sandbox;
@using Sandbox.UI;

@inherits Panel
@namespace Sandbox

<SpawnMenuContent>

    <Header>
        <SpawnMenuToolbar>
            <Left>
                <TextEntry Placeholder="#spawnmenu.common.search" class="filter menu-input" Value:bind=@Filter />
            </Left>
        </SpawnMenuToolbar>
    </Header>

    <Body>
        <VirtualGrid Items=@Entries ItemSize=@(120)>
            <Item Context="item">
                @if ( item is LocalProps.Entry entry )
                {
                    <SpawnMenuIcon Ident=@($"prop:{entry.Path}") [email protected] />
                }
            </Item>
        </VirtualGrid>
    </Body>

</SpawnMenuContent>

@code
{
    public string Category { get; set; } = "";

    private string Filter
    {
        get;
        set { field = value; Rebuild(); }
    }

    private List<LocalProps.Entry> Entries = new();

    protected override void OnParametersSet()
    {
        Rebuild();
    }

    void Rebuild()
    {
        var source = string.IsNullOrEmpty( Category )
            ? LocalProps.All
            : LocalProps.All.Where( x => x.Category == Category ).ToList();

        if ( !string.IsNullOrWhiteSpace( Filter ) )
            source = source.Where( x => x.DisplayName.Contains( Filter, StringComparison.OrdinalIgnoreCase )
                                     || x.Path.Contains( Filter, StringComparison.OrdinalIgnoreCase ) ).ToList();

        Entries = source;
        StateHasChanged();
    }
}
@using Sandbox;
@using Sandbox.UI;
@inherits Panel
@namespace Sandbox

<root></root>

@code
{
    Panel _active;

    public Panel ActivePanel
    {
        get => _active;
        set => SwitchToPanel(value );
    }

    protected override void OnChildAdded(Panel child)
    {
        base.OnChildAdded(child);

        if ( ChildrenCount == 1 )
        {
            SwitchToPanel(child);
        }
        else
        {
            child.AddClass("hidden");
        }
    }

    public void SwitchToPanel( Panel panel )
    {
        _active = panel;

        foreach ( var child in Children )
        {
            child.SetClass("hidden", child != panel);
        }
    }
}
@using Sandbox;
@using Sandbox.UI;
@attribute [InspectorEditor(null)]
@attribute [Order(100)]
@inherits Panel
@namespace Sandbox
@implements IInspectorEditor

<root>
    <div class="body">
            @if (Target == null || Target.Count == 0)
            {
            <div class="empty-state">#spawnmenu.inspect.click_to_inspect</div>
            }
            else
            {
                var totalMass = Target.SelectMany( go => go.GetComponentsInChildren<Rigidbody>() ).Sum( ResolveMass );
                var health = Target.Select( go => go.GetComponent<Prop>() ).FirstOrDefault( p => p.IsValid() );

                <div class="object-info">
                    @if ( totalMass > 0 )
                    {
                        <span>⚖️ @($"{totalMass:0.#} kg")</span>
                    }
                    @if ( health.IsValid() && health.Health != 0 )
                    {
                        <span>❤️ @($"{health.Health:0.#} HP")</span>
                    }
                </div>
                <ControlSheet Target="@Properties"></ControlSheet>

                @if (Renderers.Count > 0)
                {
                    @if ( MaterialGroups.Count > 1 )
                    {
                        var currentGroup = Renderers[0].MaterialGroup ?? MaterialGroups[0];
                        <div class="material-row">
                        <label>#spawnmenu.inspect.skin</label>
                            <div class="material-button" @onclick="@PickMaterialGroup">
                                <label>@currentGroup</label>
                                <label class="material-group-arrow">▾</label>
                            </div>
                        </div>
                    }

                    var accessor = Renderers[0].Materials;
                    @for (int i = 0; i < accessor.Count; i++)
                    {
                        var index = i;
                        var hasOverride = accessor.HasOverride(index);
                        var mat = hasOverride ? accessor.GetOverride(index) : accessor.GetOriginal(index);
                        var name = mat?.ResourceName ?? "Default";

                        <div class="material-row @(hasOverride ? "overridden" : "")">
                        <label>#spawnmenu.inspect.material</label><label> @(index + 1)</label>
                            <div class="material-button" @onclick=@(() => PickMaterial(index))>
                                <div class="material-preview" style="background-image: url( thumb:@(mat?.ResourcePath) )"></div>
                                <label>@name</label>
                            </div>
                            @if (hasOverride)
                            {
                                <div class="material-revert" @onclick=@(() => RevertMaterial(index))>x</div>
                            }
                        </div>
                    }
                }
            }
    </div>
</root>

@code
{
    public string Title => Target?.Count switch
    {
        null or < 2 => "📦 " + Game.Language.GetPhrase( "spawnmenu.inspect.object" ),
        _ => "📦 " + Game.Language.GetPhrase( "spawnmenu.inspect.object" ) + $" (+{Target.Count - 1})"
    };

    public List<GameObject> Target { get; private set; }

    public bool TrySetTarget(List<GameObject> selection)
    {
        var ids = selection.Select(x => x.Id);
        if (!ids.SequenceEqual(Target?.Select(x => x.Id) ?? []))
        {
            Target = selection.Any() ? selection.ToList() : null;
            RebuildFromTarget();
            StateHasChanged();
        }

        // Hide the tab when something is selected but there's nothing to show
        return Target == null || InspectorHasContent();
    }

    // Frozen rigidbodies report Mass = 0 from the live physics body, so fall back
    // to PhysicalProperties.Mass and MassOverride before giving up.
    static float ResolveMass( Rigidbody rb )
    {
        if ( !rb.IsValid() ) return 0f;
        var mo = rb.GetComponent<PhysicalProperties>();
        if ( mo.IsValid() && mo.Mass > 0f ) return mo.Mass;
        if ( rb.MassOverride > 0f ) return rb.MassOverride;
        return rb.Mass;
    }

    bool InspectorHasContent()
    {
        if ( Target == null ) return false;
        if ( Properties.Count > 0 || Renderers.Count > 0 ) return true;

        var totalMass = Target.SelectMany( go => go.GetComponentsInChildren<Rigidbody>() ).Sum( ResolveMass );
        if ( totalMass > 0 ) return true;

        var health = Target.Select( go => go.GetComponent<Prop>() ).FirstOrDefault( p => p.IsValid() );
        if ( health.IsValid() && health.Health != 0 ) return true;

        return false;
    }

    List<SerializedProperty> Properties = new();
    List<ModelRenderer> Renderers = new();
    List<string> MaterialGroups = new();

    protected override int BuildHash()
    {
        var hc = new HashCode();
        foreach ( var go in Target ?? [] )
        {
            hc.Add( go.Id );
            hc.Add( ResolveMass( go.GetComponent<Rigidbody>() ) );
            hc.Add( go.GetComponent<Prop>()?.Health ?? -1f );
            hc.Add( go.GetComponent<ModelRenderer>()?.MaterialGroup );
        }
        return hc.ToHashCode();
    }

    protected override void OnParametersSet()
    {
        base.OnParametersSet();
        RebuildFromTarget();
    }

    void RebuildFromTarget()
    {
        Properties = new();
        Renderers = new();
        MaterialGroups = new();

        if (Target == null) return;

        foreach (var c in Target.SelectMany(x => x.Components.GetAll()).Distinct().GroupBy(x => x is Collider ? typeof(Collider) : x.GetType()))
        {
            CollectProperties(c.ToArray());
        }
    }

    bool HasEditableProperties(Type type, PropertyDescription[] properties)
    {
        if (type.IsAssignableTo(typeof(ModelRenderer))) return true;
        if (type.IsAssignableTo(typeof(Collider))) return true;

        foreach (var prop in properties)
        {
            if (prop.HasAttribute<ClientEditableAttribute>())
                return true;
        }

        return false;
    }

    void CollectProperties(Component[] components)
    {
        var firstComponent = components.First();

        var tl = TypeLibrary.GetType(firstComponent.GetType());
        if (tl is null) return;

        if (!HasEditableProperties(firstComponent.GetType(), tl.Properties)) return;

        var so = new MultiSerializedObject();
        so.OnPropertyChanged = PropertyChanged;

        foreach (var component in components)
            so.Add(TypeLibrary.GetSerializedObject(component));

        so.Rebuild();

        foreach (var prop in tl.Properties)
        {
            if (!prop.HasAttribute<ClientEditableAttribute>()) continue;
            Properties.Add(so.GetProperty(prop.Name));
        }

        if (firstComponent is ModelRenderer mr)
        {
            Renderers.AddRange(components.OfType<ModelRenderer>());

            var model = mr.Model;
            if ( model is not null )
            {
                for ( int i = 0; i < model.MaterialGroupCount; i++ )
                    MaterialGroups.Add( model.GetMaterialGroupName( i ) );
            }

            var prop = mr.GetComponent<Prop>();
            if (prop is not null)
            {
                var propso = TypeLibrary.GetSerializedObject(prop);
                propso.OnPropertyChanged = PropertyChanged;
                Properties.Add(propso.GetProperty(nameof(ModelRenderer.Tint)));
            }
            else
            {
                Properties.Add(so.GetProperty(nameof(ModelRenderer.Tint)));
            }

            Properties.Add(so.GetProperty(nameof(ModelRenderer.RenderType)));
        }

        if (firstComponent is Collider)
            Properties.Add(so.GetProperty(nameof(Collider.Surface)));
    }

    void PropertyChanged(SerializedProperty prop)
    {
        foreach (var c in prop.Parent.Targets)
        {
            if (c is Component component)
                GameManager.ChangeProperty(component, prop.Name, prop.GetValue<object>());
        }
    }

    void PickMaterialGroup()
    {
        var menu = MenuPanel.Open( this );
        var current = Renderers[0].MaterialGroup ?? MaterialGroups.FirstOrDefault();
        foreach ( var group in MaterialGroups )
        {
            var g = group;
            menu.AddOption( current == g ? "check" : "", g, () => SetMaterialGroup( g ) );
        }
    }

    void SetMaterialGroup( string group )
    {
        foreach ( var renderer in Renderers )
            GameManager.ChangeProperty( renderer, nameof( ModelRenderer.MaterialGroup ), group );
    }

    void PickMaterial(int index)
    {
        var accessor = Renderers[0].Materials;
        var mat = accessor.HasOverride(index) ? accessor.GetOverride(index) : accessor.GetOriginal(index);

        var popup = new ResourceSelectPopup();
        popup.Extension = "material";
        popup.CurrentValue = mat?.ResourcePath;
        popup.AllowPackages = true;
        popup.Parent = FindPopupPanel();
        popup.OnSelectedFile = (path) => SetMaterialOverride(index, path);
    }

    void SetMaterialOverride(int index, string path)
    {
        foreach (var renderer in Renderers)
            GameManager.ChangeMaterialOverride(renderer, index, path);
    }

    void RevertMaterial(int index)
    {
        foreach (var renderer in Renderers)
            GameManager.ChangeMaterialOverride(renderer, index, null);
    }
}
@using Sandbox;
@using Sandbox.UI;
@namespace Sandbox
@inherits PanelComponent
@implements Global.IPlayerEvents

@if ( Player.IsValid() && Player.WantsHideHud )
    return;

@if ( !inventory.IsValid() )
    return;

<root>
	@for (int i = 0; i < inventory.MaxSlots; i++)
	{
		<InventorySlot Index=@i Inventory=@inventory Active=@(activeSlot == i)></InventorySlot>
	}
	<HotbarPresetsButton Inventory=@inventory></HotbarPresetsButton>
</root>

@code
{
    [Property, Group("Sound")] public SoundEvent SwitchSound { get; set; }

    Player Player => Player.FindLocalPlayer();

    PlayerInventory inventory;
    int activeSlot = -1;
    BaseCarryable prev;

    protected override int BuildHash() => HashCode.Combine( inventory, activeSlot, Player?.WantsHideHud );

    void Global.IPlayerEvents.OnPlayerPickup(PlayerPickupEvent e)
    {
        StateHasChanged();
    }

    bool IsSpawnMenuOpen()
    {
	    var host = Game.ActiveScene.Get<SpawnMenuHost>();
	    return host?.Panel?.HasClass("open") ?? false;
    }

    protected override void OnUpdate()
    {
        inventory = Game.ActiveScene.GetAllComponents<PlayerInventory>().Where(x => x.Network.IsOwner).FirstOrDefault();
        activeSlot = inventory?.ActiveWeapon?.InventorySlot ?? -1;

        Panel.SetClass( "spawnmenu-open", IsSpawnMenuOpen() );
    }

    public void HandleInput()
    {
        if (inventory is null)
            return;

        MoveSlot(-(int)Input.MouseWheel.y);

        if ( Input.Pressed( "invprev" ) && prev.IsValid() )
        {
            var weapon = prev;
            prev = inventory.ActiveWeapon;
            inventory.SwitchWeapon( weapon );
        }

        if (Input.Pressed("SlotNext")) MoveSlot(1);
        if (Input.Pressed("SlotPrev")) MoveSlot(-1);

        if (Input.Pressed("Slot1")) SelectSlot(0);
        if (Input.Pressed("Slot2")) SelectSlot(1);
        if (Input.Pressed("Slot3")) SelectSlot(2);
        if (Input.Pressed("Slot4")) SelectSlot(3);
        if (Input.Pressed("Slot5")) SelectSlot(4);
        if (Input.Pressed("Slot6")) SelectSlot(5);
        if (Input.Pressed("Slot7")) SelectSlot(6);
        if (Input.Pressed("Slot8")) SelectSlot(7);
        if (Input.Pressed("Slot9")) SelectSlot(8);
    }

    /// <summary>
    /// Pressing a number key directly switches to that slot's weapon.
    /// If the slot is empty or already active, holsters the current weapon.
    /// </summary>
    public void SelectSlot( int slot )
    {
        var weapon = inventory.GetSlot( slot );

        if ( weapon.IsValid() && !weapon.CanSwitch() )
            return;

        prev = inventory.ActiveWeapon;
        inventory.SwitchWeapon( weapon, allowHolster: true );

        Sound.Play( SwitchSound );
    }

    public void MoveSlot( int delta )
    {
        if ( delta == 0 )
            return;

        var weapons = inventory.Weapons.Where( x => x.CanSwitch() ).ToList();

        if ( weapons.Count == 0 )
            return;

        // Find current position in the ordered weapon list
        BaseCarryable current = inventory.ActiveWeapon ?? weapons.FirstOrDefault();

        int currentIndex = weapons.IndexOf( current );
        currentIndex += delta;
        currentIndex %= weapons.Count;
        if ( currentIndex < 0 )
            currentIndex = weapons.Count + currentIndex;

        var target = weapons[currentIndex];

        prev = inventory.ActiveWeapon;
        inventory.SwitchWeapon( target );

        Sound.Play( SwitchSound );
    }
}
@namespace Sandbox
@using Sandbox;
@using Sandbox.UI;
@using System;
@inherits Panel
@attribute [StyleSheet("GameOverScreen.razor.scss")]

<root>
	@if(Manager.Instance.IsBossDead)
	{
		<div class="title_win"></div>
	}
	else
	{
		<div class="title_lose"></div>
	}

	@* <div class="title">@(Manager.Instance.IsBossDead ? "Victory!" : "Game Over")</div> *@
	<div class="content">
		@if(Manager.Instance.PlayerProfileToShow != null)
		{
			<PlayerProfilePanel />
		}
		else
		{
			<div class="col left">
				<GameOverResultPanel />
			</div>
			<div class="col right">
				<LeaderboardPanel [email protected] IsOnMainMenu=@false />
			</div>
		}
	</div>

	<div class="row controls">
		<button class="restart_button @(Input.UsingController && Networking.IsHost ? "controller" : "") @(_restartActivated ? "ctrl-activated" : _restartHeld ? "ctrl-held" : "")" style="opacity:@(Networking.IsHost ? 1f : 0.1f);" onclick=@(() => Restart())>
		</button>
		<button class="@(Networking.IsHost ? "lobby_button" : "leave_button") @(Input.UsingController ? "controller" : "") @(_lobbyActivated ? "ctrl-activated" : _lobbyHeld ? "ctrl-held" : "")" onclick=@(() => ReturnToLobby())>
		</button>
		@* <button class="lobby_button" style="opacity:@(Networking.IsHost ? 1f : 0.1f);" onclick=@(() => ReturnToLobby())></button> *@
	</div>
	@if(AreYouSureAction is not null)
	{
		<div class="are-you-sure" onclick=@(() => { Manager.Instance.PlaySfxUI("click", pitch: 0.75f, volume: 0.6f); AreYouSureAction = null; })>
			<div class="panel">
				<label class="title">@AreYouSureText</label>
				<div class="row">
					<button class="confirm_no_button" style="width: 25%;" onclick=@(() => { AreYouSureAction = null; })>
						@if(Input.UsingController) { <InputHint class="inputbutton" Button="Back" /> }
					</button>
					<button class="confirm_yes_button" style="width: 25%;" onclick=@(() => { AreYouSureAction?.Invoke(); AreYouSureAction = null; })>
						@if(Input.UsingController) { <InputHint class="inputbutton" Button="Dash" /> }
					</button>
				</div>
			</div>
		</div>
	}
</root>

@code
{
	Action AreYouSureAction = null;
	string AreYouSureText = "Are you sure?";
	bool _restartHeld;
	bool _restartActivated;
	bool _lobbyHeld;
	bool _lobbyActivated;

	public void Restart()
	{
		if (!Networking.IsHost)
			return;

		Manager.Instance.Restart();

		Manager.Instance.SetEscMenuOpen(false);
	}

	public void ReturnToLobby()
	{
		if (!Networking.IsHost)
		{
			AreYouSureText = "Quit the party?";
			AreYouSureAction = () =>
			{
				DoClientLeave();
			};

			return;
		}
		
		Manager.Instance.SetGameState(GameState.Lobby);
		Manager.Instance.SetEscMenuOpen(false);
	}

	public override void Tick()
	{
		base.Tick();
		if (!Input.UsingController)
		{
			_restartHeld = false;
			_restartActivated = false;
			_lobbyHeld = false;
			_lobbyActivated = false;
			return;
		}

		if (AreYouSureAction is not null)
		{
			if (Input.Pressed("Dash")) { Manager.Instance.PlaySfxUI("click", pitch: 1.15f, volume: 0.75f); AreYouSureAction?.Invoke(); AreYouSureAction = null; }
			else if (Input.Pressed("Back")) { Manager.Instance.PlaySfxUI("click", pitch: 0.75f, volume: 0.6f); AreYouSureAction = null; }
			return;
		}

		if (_restartActivated)
		{
			_restartActivated = false;
			Restart();
			return;
		}

		if (_lobbyActivated)
		{
			_lobbyActivated = false;
			ReturnToLobby();
			return;
		}

		bool restartDown = Input.Down("R");
		if (_restartHeld && !restartDown) { _restartHeld = false; _restartActivated = true; Manager.Instance.PlaySfxUI("click", pitch: 1.15f, volume: 0.75f); }
		else { if (!_restartHeld && restartDown) Manager.Instance.PlaySfxUI("click", pitch: 0.85f, volume: 0.6f); _restartHeld = restartDown; }

		bool lobbyDown = Input.Down("banish");
		if (_lobbyHeld && !lobbyDown) { _lobbyHeld = false; _lobbyActivated = true; Manager.Instance.PlaySfxUI("click", pitch: 1.15f, volume: 0.75f); }
		else { if (!_lobbyHeld && lobbyDown) Manager.Instance.PlaySfxUI("click", pitch: 0.85f, volume: 0.6f); _lobbyHeld = lobbyDown; }
	}

	protected override int BuildHash()
	{
		int buttonHash = System.HashCode.Combine(
			_restartHeld,
			_restartActivated,
			_lobbyHeld,
			_lobbyActivated
		);

		return System.HashCode.Combine(
			Manager.Instance.IsEscMenuOpen,
			Input.UsingController,
			AreYouSureAction is not null,
			RealTime.Now, // todo:
			buttonHash
		);
	}

	void DoClientLeave()
	{
		if(!Networking.IsHost)
		{
			Networking.Disconnect();
			Scene.LoadFromFile("scenes/game.scene");
		}

		Manager.Instance.SetEscMenuOpen(false);

		DoClientLeaveAsync();
	}

	async void DoClientLeaveAsync()
	{
		Manager.Instance.FadeRpc(fadeIn: false);

		await Task.Frame();

		Manager.Instance.SetGameState(GameState.Lobby);
	}
}
@namespace Sandbox
@using Sandbox;
@using Sandbox.UI;
@using System;
@inherits Panel
@attribute [StyleSheet("LoadoutItemPanel.razor.scss")]

@{
	var isGem = Item.Category == ShopItemCategory.Gem;
	var owned = ProgressManager.IsItemOwned( Item.Id );
	var coins = ProgressManager.GetCoins();
	var canAfford = coins >= Item.Price;
	var isLocked = !ProgressManager.IsItemUnlocked( Item.Id );

	string stateClass;
	if ( IsShopMode )
	{
		if ( isLocked )
			stateClass = "locked";
		else if ( isGem )
		{
			if ( !owned )
				stateClass = canAfford ? "affordable" : "too_expensive";
			else if ( ProgressManager.IsGemMaxed( Item.Id ) )
				stateClass = "gem_maxed";
			else
			{
				var upgCost = ProgressManager.GetGemUpgradeCost( Item.Id );
				stateClass = (upgCost.HasValue && coins >= upgCost) ? "gem_upgradeable" : "gem_upgrade_expensive";
			}
		}
		else
			stateClass = owned ? "owned" : canAfford ? "affordable" : "too_expensive";
	}
	else
	{
		stateClass = IsSelected ? "selected" : IsSlotFull ? "slot_full" : "";
	}

	// Compute description text
	string descText = null;
	if ( isGem && (Item.GemDescription != null || Item.GemUpgradeDescription != null) )
	{
		var displayLevel = ProgressManager.GetGemDisplayLevel( Item.Id );
		if ( !owned )
			descText = Item.GemDescription?.Invoke( 1 );
		else if ( IsShopMode && !ProgressManager.IsGemMaxed( Item.Id ) )
			descText = Item.GemUpgradeDescription?.Invoke( displayLevel + 1 ) ?? Item.GemDescription?.Invoke( displayLevel );
		else
			descText = Item.GemDescription?.Invoke( displayLevel );
	}
	else if ( Item.ItemDescription != null )
	{
		descText = Item.ItemDescription();
	}
}

<root class="@stateClass" onclick=@OnClick>
	@{
		var frameBg = Item.Category switch
		{
			ShopItemCategory.Gun   => "/textures/ui/panel/item_gun.png",
			ShopItemCategory.Charm => "/textures/ui/panel/item_charm.png",
			ShopItemCategory.Gem   => "/textures/ui/panel/item_gem.png",
			_                      => "/textures/ui/panel/panel_02.png",
		};
	}
	<div class="frame" style="background-image: url(@frameBg);"></div>
	<div class="top_row">
		<div class="item_icon" style="background-image:url(@Item.IconPath);"></div>
		<div class="top_text">
			<label class="item_name">@Item.Name</label>
			@if ( IsShopMode )
			{
				<div class="item_price">
					@if ( isGem && owned )
					{
						var upgCost = ProgressManager.GetGemUpgradeCost( Item.Id );
						@if ( upgCost.HasValue )
						{
							<div class="price_coin_icon"></div>
							<label class="price_amount">@upgCost</label>
						}
						else
						{
							<label class="owned_label">Max</label>
						}
					}
					else if ( !owned )
					{
						<div class="price_coin_icon"></div>
						<label class="price_amount">@Item.Price</label>
					}
				</div>
			}
		</div>
	</div>
	<div class="middle">
		@if ( descText != null )
		{
			<RichText class="item_desc" Text=@descText />
		}
		@if ( IsShopMode && isLocked )
		{
			var needed = ProgressManager.GetPurchasesNeeded( Item.Id );
			var catWord = Item.Category == ShopItemCategory.Gun   ? (needed == 1 ? "gun"   : "guns")
			            : Item.Category == ShopItemCategory.Charm ? (needed == 1 ? "charm" : "charms")
			            :                                            (needed == 1 ? "gem"   : "gems");
			<label class="lock_label">Buy @needed more @catWord</label>
		}
	</div>
	@if ( isGem )
	{
		var maxLevel = (Item.UpgradePrices?.Length ?? 0) + 1;
		var gemDL = ProgressManager.GetGemDisplayLevel( Item.Id );
		// In shop mode: show the level being purchased next (matches the "Lv X" price label).
		// In loadout mode: show current level.
		var displayLevel = !IsShopMode ? gemDL
		                 : !owned      ? 1
		                 : ProgressManager.GetGemUpgradeCost( Item.Id ).HasValue ? gemDL + 1 : gemDL;
		<div class="level_indicator">
			<label>@displayLevel</label><label class="level_sep">/</label><label>@maxLevel</label>
		</div>
	}
</root>

@code {
	public ShopItemDef Item { get; set; }
	public Action OnClick { get; set; }
	/// <summary>True when used in the shop — shows price and lock info, computes shop state classes.</summary>
	public bool IsShopMode { get; set; }
	/// <summary>Loadout mode only — highlights the card as the active selection.</summary>
	public bool IsSelected { get; set; }
	/// <summary>Loadout mode only — dims the card when the slot is full and this item is not selected.</summary>
	public bool IsSlotFull { get; set; }

	protected override int BuildHash()
	{
		return System.HashCode.Combine(
			Item.Id,
			IsShopMode,
			IsSelected,
			IsSlotFull,
			ProgressManager.StateVersion
		);
	}
}