452 results

@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;
@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
		);
	}
}
@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;
@inherits Panel
@namespace Sandbox

<root>
    @if (Header != null)
    {
        <div class="header">@Header</div>
    }

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

@code
{

    [Parameter] public RenderFragment Header { get; set; }
    [Parameter] public RenderFragment Body { get; set; }

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

<root>
	@Path
</root>


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

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

<root>

<Button class="menu-action primary wide" Icon="➕" Text="#spawnmenu.spawnlist.new_button" Disabled=@( !CanCreate() ) @onclick=@CreatePopup></Button>

</root>

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

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

@if ( Game.IsValid() && Game.State != GameState.Menu && Game.Level <= 1 )
{
    <root>
        <div class="container">
            <span class="controls-label">CONTROLS</span>
            <div class="column">
                <div class="input">
                    @*There is no mouse/ controller move input hint so we do this.*@
                    @if(Input.UsingController)
                    {
                        <label class="type">Move</label><img src="ui/glyphs/xbox/leftjoystickbutton.svg" class="mouse-glyph" />
                    }
                    else
                    {
                        <label class="type">Move Paddle</label><img src="ui/glyphs/default/mouse.svg" class="mouse-glyph" />
                    }
                </div>
                <div class="input">
                    <label class="type">Launch</label><InputHint Action="attack1" class="key-glyph" Dark=@true />
                </div>
            </div>
        </div>

    </root>
}

@code
{
    /// <summary>
    /// The game this bar reads score, lives, level and mode from.
    /// </summary>
    [Parameter] public BreakoutGame Game { get; set; }

    // Update the ui when the game state changes or when the input method changes.
    protected override int BuildHash() => System.HashCode.Combine(Game?.State, Game?.Mode, Game?.Level, Input.UsingController);
}
@using System
@using Sandbox
@using Sandbox.UI
@namespace Breakout
@inherits Panel

@*
    HelpScreen is the "how to play" page you reach from the menu's HELP button. It shows a guide
    of every block type and what it does. Pressing BACK runs the Back callback, which tells the
    menu to show itself again.
*@

<root>
    <div class="help">
        <button class="back-btn" onclick=@(() => Back?.Invoke())>
            <span class="back-icon">◀</span>
            BACK
        </button>

        <div class="help-body">
            <div class="help-title">HELP</div>
            <div class="help-sub">Here is what each brick does when you hit it.</div>

            <div class="block-grid">
                @foreach ( var block in Blocks )
                {
                    <div class="block-card">
                        <img class="block-icon" src="@block.Icon" />
                        <div class="block-info">
                            <div class="block-name">@block.Name</div>
                            <div class="block-desc">@block.Description</div>
                        </div>
                    </div>
                }
            </div>
        </div>
    </div>
</root>

@code
{
    /// <summary>
    /// Called when the player presses BACK, so the menu can show itself again.
    /// </summary>
    [Parameter] public Action Back { get; set; }

    /// <summary>
    /// One row in the block guide: the icon to show, the block's name, and a short description.
    /// </summary>
    private record BlockGuideEntry( string Icon, string Name, string Description );

    // The list shown on the help page. Add a new line here to document another block type.
    private static readonly BlockGuideEntry[] Blocks =
    {
        new( "textures/blocks/normal.png", "Brick", "Breaks in a single hit." ),
        new( "textures/blocks/armoured.png", "Armoured", "Takes two hits to break." ),
        new( "textures/blocks/unbreakable.png", "Unbreakable", "Can't be broken, so play around it." ),
        new( "textures/blocks/explosion.png", "Explosive", "Blows up nearby bricks when it breaks." ),
        new( "textures/blocks/speed.png", "Fast Ball", "Turns your ball into a faster one." ),
        new( "textures/blocks/big_ball.png", "Big Ball", "Makes your ball big enough to smash armour in one hit." ),
        new( "textures/blocks/fire_ball.png", "Fireball", "Turns your ball into a fireball that burns straight through bricks." ),
        new( "textures/blocks/split.png", "Multi-Ball", "Drops a pickup that splits your ball into more." ),
        new( "textures/blocks/expand.png", "Expand", "Drops a pickup that makes your paddle wider." ),
    };
}
@using Sandbox;
@using Sandbox.UI;
@using Sandbox.UI.Navigation;
@using Machines.Components;
@using Machines.Resources;

@namespace Machines.UI
@inherits Panel

@{
    var flow = LobbyFlow.Current;
    var map = flow?.SelectedMap;

    var total = Connection.All.Count;
    var ready = ReadyCount();
}

<root class="lobby-status @(Visible ? "" : "hidden") @(Input.UsingController ? "controller" : "mouse")" onclick=@GoToPlay>
    @if ( Visible )
    {
        <div class="ls-map">
            <div class="ls-thumb-wrap">
                <image @ref="ThumbImage" class="ls-thumb" />
                @if ( map?.Thumbnail == null )
                {
                    <div class="ls-thumb-empty">NO IMAGE</div>
                }
            </div>
            <div class="ls-map-info">
                <span class="ls-eyebrow">IN LOBBY</span>
                <span class="ls-title">@(map?.Title ?? "No map")</span>
            </div>
        </div>

        <PlayerSlots class="ls-slots" />

        <div class="ls-status">
            <span class="ls-state">@StateLabel( flow )</span>
            <span class="ls-ready">@ready / @System.Math.Max( total, 1 ) READY</span>
        </div>
    }
</root>

@code
{
    public Sandbox.UI.Image ThumbImage { get; set; }

    // Show when a lobby exists, except on /play which shows the full picker.
    private bool Visible =>
        LobbyFlow.Current is not null
        && MainMenuPanel.Instance?.CurrentUrl != "/play";

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

        if ( ThumbImage != null )
            ThumbImage.Texture = LobbyFlow.Current?.SelectedMap?.Thumbnail;

        StackAbovePatchNotes();
    }

    // Sit above the patch-notes card; explicit px so the CSS transition works both ways.
    private void StackAbovePatchNotes()
    {
        var revision = FindRootPanel()?.Descendants.OfType<RevisionCard>().FirstOrDefault();
        var cardHeight = revision is not null && revision.IsVisible
            ? revision.Box.Rect.Height * ScaleFromScreen + 14f
            : 0f;

        // Nudge up on the main menu home page so it clears the home layout.
        var menuOffset = MainMenuPanel.Instance?.CurrentUrl == "/home" ? 32f : 0f;

        Style.Bottom = Length.Pixels( 56f + cardHeight + menuOffset ); // $deadzone-y (+ card + gap)
    }

    // Click navigates back to the map selector.
    private void GoToPlay()
    {
        this.Navigate( "/play" );
    }

    private static string StateLabel( LobbyFlow flow )
    {
        if ( flow == null )
            return "WAITING";

        return flow.State switch
        {
            LobbyState.Countdown => $"STARTING IN {System.Math.Max( 0, (int)System.Math.Ceiling( (float)flow.CountdownEnds ) )}…",
            LobbyState.Launching => "STARTING…",
            _ => "WAITING"
        };
    }

    private static int ReadyCount()
    {
        var flow = LobbyFlow.Current;
        if ( flow == null )
            return 0;

        return Connection.All.Count( c => flow.Ready.TryGetValue( c.Id, out var r ) && r );
    }

    protected override int BuildHash()
    {
        var flow = LobbyFlow.Current;
        var countdownTick = flow?.State == LobbyState.Countdown ? (int)((float)flow.CountdownEnds * 4) : 0;

        return System.HashCode.Combine(
            Visible,
            flow?.SelectedMapIdent,
            flow?.State,
            Connection.All.Count,
            ReadyCount(),
            countdownTick,
            Input.UsingController );
    }
}
@using Sandbox;
@using Sandbox.UI;
@using Machines.Components;
@using Machines.Race;
@using System;
@using System.Collections.Generic;

@namespace Machines.UI
@inherits Panel

@{
    EnsureBaked();

    var hasMap = _texture != null && _maxExtent > 0.001f && _trackRadius > 0.001f;
    var center = new Vector2( 0.5f, 0.5f );           // whole track, centred on the map
    var fitRadius = PanelSize * 0.5f - RimMargin;     // fit the whole track inside the round clip
    var disp = _maxExtent * (fitRadius / _trackRadius); // displayed track-image size in logical px
}

<root class="minimap @(hasMap ? "" : "empty")">
    <div class="clip">
        @if ( hasMap )
        {
            var imgLeft = PanelSize * 0.5f - center.x * disp;
            var imgTop = PanelSize * 0.5f - center.y * disp;

            <image @ref="MapImage" class="map-img"
                   style="width: @(disp)px; height: @(disp)px; left: @(imgLeft)px; top: @(imgTop)px;" />

            @foreach ( var m in GetCheckpointMarkers( center, disp ) )
            {
                <div class="cp-bar @(m.IsFinish ? "finish" : "")"
                     style="left: @(m.X)px; top: @(m.Y)px; width: @(m.LengthPx)px; transform: translate(-50%, -50%) rotate(@(m.AngleDeg)deg);"></div>
            }

            @foreach ( var dot in GetBlipDots( center, disp ) )
            {
                var c = dot.Color;
                var rgb = $"rgb({(int)(c.r * 255)}, {(int)(c.g * 255)}, {(int)(c.b * 255)})";
                <div class="dot @dot.Class"
                     style="left: @(dot.X)px; top: @(dot.Y)px; background-color: @rgb;"></div>
            }
        }
    </div>
</root>

@code
{
    /// <summary>
    /// Diameter of the minimap in logical px.
    /// </summary>
    public float PanelSize { get; set; } = 320;

    /// <summary>
    /// Inset (logical px) from the minimap edge that the track is fit within.
    /// </summary>
    private const float RimMargin = 32f;

    /// <summary>
    /// Resolution of the baked track texture (square).
    /// </summary>
    public int TextureResolution { get; set; } = 1024;

    /// <summary>
    /// Half-width of the drawn road in world units; tune to match the track mesh.
    /// </summary>
    public float RoadHalfWidth { get; set; } = 80f;

    /// <summary>
    /// Outline thickness around the road, in world units.
    /// </summary>
    public float OutlineWorld { get; set; } = 4f;

    private Image MapImage { get; set; }

    private Texture _texture;
    private RacingLine _bakedFor;        // rebake when this changes
    private Vector2 _center;             // world-XY centre of the baked region
    private float _maxExtent;            // world units covered by the baked region
    private float _trackRadius;          // world units from centre to outermost road edge

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

        if ( MapImage != null && _texture != null )
            MapImage.Texture = _texture;
    }

    /// <summary>
    /// Bakes the track texture once the racing line is available, or rebakes on change.
    /// </summary>
    private void EnsureBaked()
    {
        var line = RacingPath.Current?.Optimal;
        if ( line is null || !line.IsValid )
            return;

        if ( _texture != null && _bakedFor == line )
            return;

        Bake( line, RoadHalfWidth );
        _bakedFor = line;
    }

    private void Bake( RacingLine line, float halfWidth )
    {
        var res = TextureResolution;

        // AA feather sized to ~1 displayed pixel for a smooth outline.
        var (_, _, trackRadius) = TrackMapBaker.ComputeBounds( line, halfWidth, OutlineWorld );
        var pxPerWorld = (PanelSize * 0.5f - RimMargin) / trackRadius;
        var aaW = pxPerWorld > 0.0001f ? 1f / pxPerWorld : 0f;

        var bake = TrackMapBaker.Bake( line, halfWidth, OutlineWorld, res, aaW );
        if ( bake is null )
            return;

        _center = bake.Center;
        _maxExtent = bake.MaxExtent;
        _trackRadius = bake.TrackRadius;

        _texture = Texture.Create( res, res )
            .WithFormat( ImageFormat.RGBA8888 )
            .WithData( bake.Rgba )
            .Finish();
    }

    private struct CpMarker
    {
        public float X;
        public float Y;
        public float AngleDeg;
        public float LengthPx;
        public bool IsFinish;
    }

    /// <summary>
    /// Bar across the track at each checkpoint, oriented to the line tangent.
    /// </summary>
    private List<CpMarker> GetCheckpointMarkers( Vector2 center, float disp )
    {
        var list = new List<CpMarker>();
        if ( _maxExtent < 0.001f )
            return list;

        var line = RacingPath.Current?.Optimal;
        var half = PanelSize * 0.5f;
        var pxPerWorld = disp / _maxExtent;
        var roadWidthWorld = RoadHalfWidth * 2f;
        var lengthPx = MathF.Max( roadWidthWorld * pxPerWorld * 1.4f, 14f );

        foreach ( var cp in Scene.GetAll<Checkpoint>() )
        {
            if ( !cp.IsValid() )
                continue;

            var n = WorldToNorm( cp.WorldPosition );
            var sx = half + (n.x - center.x) * disp;
            var sy = half + (n.y - center.y) * disp;

            // Bar is perpendicular to the line tangent.
            float angle = 0f;
            if ( line is not null && line.IsValid )
            {
                var d = line.GetDistanceAtPosition( cp.WorldPosition );
                var tan = line.GetTangentAtDistance( d );
                // North-up map: screenX ~ -worldY, screenY ~ -worldX.
                var tsx = -tan.y;
                var tsy = -tan.x;
                // Perpendicular = bar's long axis.
                angle = MathF.Atan2( tsx, -tsy ) * (180f / MathF.PI);
            }

            list.Add( new CpMarker
            {
                X = sx,
                Y = sy,
                AngleDeg = angle,
                LengthPx = lengthPx,
                IsFinish = cp.IsFinishLine
            } );
        }

        return list;
    }

    private struct BlipDot
    {
        public float X;
        public float Y;
        public Color Color;
        public string Class;
        public int Priority;
    }

    private List<BlipDot> GetBlipDots( Vector2 center, float disp )
    {
        var dots = new List<BlipDot>();
        var half = PanelSize * 0.5f;
        var maxR = half - 8f; // keep dots inside the round clip

        // Off-screen blips clamp to the rim.
        Vector2 Project( Vector3 world )
        {
            var n = WorldToNorm( world );
            var off = new Vector2( (n.x - center.x) * disp, (n.y - center.y) * disp );
            if ( off.Length > maxR )
                off = off.Normal * maxR;
            return new Vector2( half + off.x, half + off.y );
        }

        // GetAllComponents returns only enabled components.
        foreach ( var blip in Scene.GetAllComponents<IMinimapBlip>() )
        {
            if ( blip is not Component c || !c.IsValid() || !blip.ShowOnMinimap )
                continue;

            var p = Project( c.WorldPosition );
            dots.Add( new BlipDot
            {
                X = p.x,
                Y = p.y,
                Color = blip.BlipColor,
                Class = blip.BlipClass,
                Priority = blip.BlipPriority
            } );
        }

        // Draw order: ghost < items < racers < local player.
        dots.Sort( ( x, y ) => x.Priority.CompareTo( y.Priority ) );
        return dots;
    }

    /// <summary>
    /// Normalized [0,1] texel coords for a world position (north-up).
    /// </summary>
    private Vector2 WorldToNorm( Vector3 w )
    {
        // North-up: world +X = screen up, world +Y = screen left.
        var nu = 0.5f - (w.y - _center.y) / _maxExtent;
        var nv = 0.5f - (w.x - _center.x) / _maxExtent;
        return new Vector2( nu, nv );
    }

    protected override int BuildHash()
    {
        return HashCode.Combine( _texture != null, Time.Now );
    }
}
@using Sandbox;
@using Sandbox.UI;
@using Machines.Player;
@using Machines.Components;
@using Machines.GameModes;

@namespace Machines.UI
@inherits Panel

<root class="score-hud">
    @{
        var car = VisibleCar();
        if ( car is not null )
        {
            var score = car.Score;

            <div class="score-stack" style="opacity: @ComboAlpha( score ).ToString( "0.###", System.Globalization.CultureInfo.InvariantCulture );">
                <div class="combo-value">@score.ComboScore</div>

                @foreach ( var entry in score.Entries.OrderByDescending( e => e.LastTime ) )
                {
                    <div class="combo-entry">
                        <span class="entry-source">@entry.Source</span>
                        <span class="entry-amount">+@entry.Amount</span>
                    </div>
                }
            </div>
        }
    }
</root>

@code
{
    // Tail of the combo timeout over which the whole stack fades out (seconds).
    private const float FadeTail = 0.4f;

    /// <summary>
    /// Local car to show, only while a combo is running; null hides the overlay (so it never
    /// shows on start, and disappears once the chain lapses). Mirrors CarBoostHud's gating.
    /// </summary>
    private Car VisibleCar()
    {
        if ( !BaseGameMode.Current.IsValid() || BaseGameMode.Current.State != GameModeState.Playing )
            return null;

        if ( Game.ActiveScene?.Get<SpectatorCamera>()?.IsActive ?? false )
            return null;

        var car = Car.Local;
        if ( !car.IsValid() || car.Autopilot )
            return null;

        if ( !car.Score.IsValid() || !car.Score.IsComboActive )
            return null;

        return car;
    }

    // Full opacity until the last FadeTail seconds of the combo, then ramp to zero.
    private static float ComboAlpha( CarScore score )
    {
        var remaining = CarScore.ComboTimeout - score.SinceLastGain;
        return MathX.Clamp( remaining / FadeTail, 0f, 1f );
    }

    protected override int BuildHash() => HashCode.Combine( Time.Now );
}
@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>

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

</root>

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

@using System
@using Sandbox
@using Sandbox.UI
@namespace BrickJam.UI
@inherits PanelComponent

<root>
	@if ( Loot.IsValid() )
	{
		<div class="container">
			<span>@Loot.FullName</span>
			<div class="row">
				<span class="currency">$</span><span>@($"{Loot.MonetaryValue:n0}")</span>
			</div>
		</div>
	}
</root>

@code {
	private Loot loot;
	private Loot Loot => loot ??= GetComponentInParent<Loot>();

	protected override int BuildHash() => HashCode.Combine( Loot?.FullName, Loot?.MonetaryValue ?? 0 );
}

<style>
	GroundLootPanel {
		transition: transform 0.5s ease-in-out;
		justify-content: center;
		align-items: center;
		width: 100%;
		height: 100%;

		.container {
			padding: 10px;
			padding-right: 30px;
			padding-left: 30px;
			font-size: 32px;
			align-items: center;
			font-family: "alagard";
			flex-direction: column;
			color: white;
			text-shadow: 3px 3px 0px black;
			background: linear-gradient(to left, rgba(black, 0) 0%, rgba(black, 0.5) 20%, rgba(black, 0.5) 80%, rgba(black, 0) 100%);

			.currency {
				padding-right: 4px;
				font-size: 22px;
				top: 5px;
				color: rgba(50, 205, 50, 1);
			}
		}

		&:outro {
			transform: scale(0);
		}

		&:intro {
			transform: scale(0);
		}
	}
</style>
@using System
@using Sandbox
@using Sandbox.UI
@namespace BrickJam.UI
@inherits PanelComponent

<root>
	@{
		var player = Player.Local;

		var isSpectator = player?.Spectating ?? false;
		var isPlayer = player?.IsValid() ?? false;
	}

	<MoneyCounter />
	<Eventlog />
	<PlayerCounter />
	<StunIndicator />
	<QuickPing />
	<SubtitlesList />
	<Lockpicker />
	<BlackScreen />
	
	<div class="input-hints">
		@if ( isSpectator )
		{
			<div class="hint"><inputglyph action="StopFollowing"/>Stop following</div>
			<div class="hint"><inputglyph action="FollowNext"/>Next player</div>
			<div class="hint"><inputglyph action="FollowPrevious"/>Previous player</div>
		}
		else
		{
			<div class="hint"><inputglyph action="inventory"/>Inventory</div>
			<div class="hint"><inputglyph action="chat"/>Chat</div>
			<div class="hint"><inputglyph action="ping"/>Quick Ping</div>
		}
	</div>

	@if ( player.IsValid() )
	{
		<Inventory />
		<Shop />
		<TextInput Ghost="Say something..." class="chat-input" @ref="ChatInput" onsubmit=@OnChatSubmit />

		@if ( !player.SeenTips )
		{
			<TutorialTips />
		}
	}

	@if ( player.IsValid() && player.IsAlive && !LockpickerBus.IsOpen )
	{
		<div class="crosshair">
			<div class="mark">+</div>
			<RadialProgress />
			<InteractionTip />
		</div>
	}

	@if ( player.IsValid() && !player.IsAlive )
	{
		<div class="death">
			<span class="title">YOU'RE DEAD!</span>
		</div>
	}
</root>

@code {
	private TextInput ChatInput { get; set; }

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

		if ( Input.Pressed( "chat" ) )
		{
			ChatInput.Focus();
			ChatInput.AddClass( "visible" );
		}
	}

	private void OnChatSubmit()
	{
		if ( ChatInput is null )
			return;

		var text = ChatInput.Text;
		if ( !string.IsNullOrWhiteSpace( text ) )
			Player.Local?.Say( text );

		ChatInput.Text = "";
		ChatInput.RemoveClass( "visible" );
	}

	protected override int BuildHash() => HashCode.Combine(
		Player.Local,
		Player.Local?.IsAlive ?? false,
		LockpickerBus.IsOpen );
}

<style>
	Hud {
		position: absolute;
		top: 0;
		left: 0;
		width: 100%;
		height: 100%;
		display: flex;
		flex-direction: column;
		align-items: center;
		font-family: "alagard";
		justify-content: flex-start;

		.crosshair {
			position: absolute;
			top: 0; left: 0;
			width: 100%;
			height: 100%;
			flex-direction: column;
			align-items: center;
			justify-content: center;

			.mark {
				color: white;
				font-size: 28px;
				text-shadow: 2px 2px 0px black;
			}
		}

		.input-hints {
			position: absolute;
			bottom: 30px;
			right: 0px;
			align-items: flex-end;
			flex-direction: column;
			z-index: 3;

			.hint {
				color: white;
				height: 32px;
				justify-content: center;
				font-size: 32px;
				padding-left: 50px;
				padding-top: 10px;
				padding-bottom: 40px;
				padding-right: 10px;
				background: linear-gradient(to left, rgba(black, 0.5) 0%, rgba(black, 0.2) 75%, rgba(black, 0) 100%);
				margin-top: 5px;
				text-shadow: 3px 3px 0px black;

				InputGlyph {
					width: 32px;
					aspect-ratio: 1;
					margin-right: 10px;
				}
			}
		}

		.chat-input {
			position: absolute;
			width: 450px;
			min-height: 20px;
			font-size: 24px;
			opacity: 0;
			pointer-events: all;
			background-color: rgba(37, 64, 98, 1);
			box-shadow: 3px 3px 0px 0px rgba(17, 44, 78, 1);
			transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
			transform: translate(-50% 200px);
			text-shadow: 3px 3px 0px black;
			left: 50%;
			top: 50%;
			margin-top: 40px;

			&.visible {
				opacity: 1;
				transform: translate(-50% 0px);
			}
		}

		.death {
			position: absolute;
			width: 100%;
			height: 100%;
			left: 0px;
			top: 0px;
			color: white;
			font-size: 32px;
			z-index: 2;
			backdrop-filter: grayscale(100%);
			justify-content: center;
			transition: opacity 1s ease-in-out;
			opacity: 0;
			text-shadow: 4px 4px 0px black;

			&.visible {
				opacity: 1;
			}

			.container {
				flex-direction: column;
				align-items: center;
			}

			span {
				background: linear-gradient(to left, rgba(black, 0) 0%, rgba(black, 0.4) 20%, rgba(black, 0.4) 80%, rgba(black, 0) 100%);
				padding: 10px;
				padding-left: 40px;
				padding-right: 40px;
			}

			.title {
				color: red;
				font-size: 64px;
				margin-top: 150px;
				margin-bottom: 20px;
			}
		}
	}
</style>
@using System
@using Sandbox
@using Sandbox.UI
@using WorldPanel = Sandbox.WorldPanel
@namespace BrickJam.UI
@inherits PanelComponent

<root>
	@if ( !IsLocal && Player.IsValid() && Player.IsAlive )
	{
		<div class="container">
			<span>@Name</span>
		</div>
	}
</root>

@code {
	private Player player;
	private Player Player => player ??= GetComponentInParent<Player>();

	// Don't draw your own nametag in first person.
	private bool IsLocal => Player == Player.Local;

	private string Name => Player.IsValid() && Player.Network.Owner is { } owner ? owner.DisplayName : "player";

	protected override int BuildHash() => HashCode.Combine( IsLocal, Name, Player?.IsAlive ?? false );
}

<style>
	Nametag {
		transition: transform 0.5s ease-in-out;
		justify-content: center;
		align-items: center;
		width: 100%;
		height: 100%;

		.container {
			padding: 10px;
			padding-right: 30px;
			padding-left: 30px;
			font-size: 32px;
			align-items: center;
			font-family: "alagard";
			flex-direction: column;
			color: white;
			text-shadow: 3px 3px 0px black;
			background: linear-gradient(to left, rgba(black, 0) 0%, rgba(black, 0.5) 20%, rgba(black, 0.5) 80%, rgba(black, 0) 100%);
		}

		&:outro {
			transform: scale(0);
		}

		&:intro {
			transform: scale(0);
		}
	}
</style>
@using System
@using System.Linq
@using System.Collections.Generic
@using Sandbox
@using Sandbox.UI
@namespace BrickJam.UI
@inherits Panel

<root>
	@foreach ( var line in lines )
	{
		<div class="subtitle">
			@if ( !string.IsNullOrEmpty( line.Speaker ) )
			{
				<span class="speaker">@line.Speaker:</span>
			}
			<span class="text">@line.Text</span>
		</div>
	}
</root>

@code {
	private readonly List<(string Speaker, string Text, TimeUntil Expire)> lines = new();

	public SubtitlesList()
	{
		SubtitleBus.OnSubtitle += Add;
	}

	public override void OnDeleted()
	{
		base.OnDeleted();
		SubtitleBus.OnSubtitle -= Add;
	}

	private void Add( string speaker, string text, float duration )
	{
		lines.Add( (speaker, text, duration) );
		if ( lines.Count > 3 )
			lines.RemoveAt( 0 );
	}

	public override void Tick()
	{
		lines.RemoveAll( l => l.Expire );
	}

	protected override int BuildHash() => HashCode.Combine( lines.Count, lines.LastOrDefault().Text );
}

<style>
	SubtitlesList {
		position: absolute;
		left: 0; right: 0;
		bottom: 60px;
		flex-direction: column;
		align-items: center;

		.subtitle {
			flex-direction: row;
			padding: 6px 16px;
			margin-top: 4px;
			background-color: rgba(0,0,0,0.6);
			font-size: 26px;
			text-shadow: 2px 2px 0px black;

			.speaker { color: rgba(255,220,80,1); margin-right: 8px; }
			.text { color: white; }
		}
	}
</style>
@using System;
@using Sandbox;
@using Sandbox.UI;
@inherits PanelComponent
@namespace Sandbox

<root>
    @if(_active)
    {
        <div class="survival-tut-bg"></div>
        <div class="survival-tut-card">

            <div class="tut-step-count">@(_step + 1) / @_steps.Length</div>
            <div class="survival-tut-title">@_steps[_step].Title</div>
            <div class="survival-tut-text">@_steps[_step].Text</div>

            @if(_steps[_step].ShowGridDemo)
            {
                <div class="survival-demo-grid">
                    ...
                </div>
                <div class="survival-demo-caption">@_demoCaption</div>
            }

            <button class="survival-tut-close" onclick=@NextStep>
                @(_step == _steps.Length - 1 ? "▶ Let's Survive!" : "Got it →")
            </button>
        </div>
    }
</root>

@code {
    public static SurvivalTutorial Instance { get; private set; }
    public bool IsActive => _active;

    bool _active = false;
    int _step = 0;

    struct SurvivalTutStep
    {
        public string Title;
        public string Text;
        public bool ShowGridDemo;
    }

    static readonly SurvivalTutStep[] _steps = new SurvivalTutStep[]
    {
        new SurvivalTutStep {
            Title = "Welcome to Survival! ⚡",
            Text  = "No waves here — chests spawn continuously and slide downward. Your goal: last as long as possible.",
            ShowGridDemo = false
        },
        new SurvivalTutStep {
            Title = "It Gets Faster... 🔥",
            Text  = "Chests spawn every couple seconds at first. After about a minute, spawn rate speeds up and more chests appear at once. Stay sharp — it never really stops.",
            ShowGridDemo = false
        },
        new SurvivalTutStep {
            Title = "Block the Slide! 🛡️",
            Text  = "Here's the key trick: a placed bomb blocks a chest from sliding into its column. Use this to buy time and set up bigger chain reactions before detonating.",
            ShowGridDemo = true
        },
        new SurvivalTutStep {
            Title = "Survive as Long as You Can 💀",
            Text  = "One chest reaching the bottom row ends your run. Place smart, chain big, and see how long you can hold on!",
            ShowGridDemo = false
        },
    };

    // ── Mini grid demo animation state ──
    float _demoTimer = 0f;
    int _demoPhase = 0; // 0=chest falling, 1=bomb placed blocks it, 2=hold
    string _demoCaption = "A chest is about to slide down...";

    protected override void OnStart()
    {
        Instance = this;
        _active = false;
    }

    public void Activate()
    {
        _step = 0;
        _active = true;
        _demoPhase = 0;
        _demoTimer = 0f;
        StateHasChanged();
    }

    protected override void OnUpdate()
    {
        if (!_active || !_steps[_step].ShowGridDemo) return;

        _demoTimer += Time.Delta;

        // Simple 3-phase loop: chest falls (0-1.2s) -> bomb blocks it (1.2-2.4s) -> hold (2.4-3.6s) -> reset
        if (_demoTimer < 1.2f)
        {
            _demoPhase = 0;
            _demoCaption = "A chest slides down each cycle...";
        }
        else if (_demoTimer < 2.4f)
        {
            _demoPhase = 1;
            _demoCaption = "...but a placed bomb blocks it from moving further!";
        }
        else if (_demoTimer < 3.6f)
        {
            _demoPhase = 2;
            _demoCaption = "Now you have time to plan your chain reaction.";
        }
        else
        {
            _demoTimer = 0f;
        }
        StateHasChanged();
    }

    // Simple fixed layout: chest at (0,1) falling toward bomb at (2,1)
    string GetDemoCellClass(int r, int c)
    {
        if (c != 1) return "empty";

        if (_demoPhase == 0)
        {
            // chest animates from row 0 toward row 1
            if (r == 0) return "chest";
            if (r == 2) return "bomb";
            return "empty";
        }
        else
        {
            // blocked — chest sits right above the bomb, doesn't pass
            if (r == 1) return "chest";
            if (r == 2) return "bomb";
            return "empty";
        }
    }

    string GetDemoCellIcon(int r, int c)
    {
        var cls = GetDemoCellClass(r, c);
        return cls switch {
            "chest" => "🎁",
            "bomb"  => "💣",
            _ => ""
        };
    }

    void NextStep()
    {
        _step++;
        Sound.Play("UI_Button");

        if (_step >= _steps.Length)
		{
			_active = false;
			ChainReactionGame.Instance?.UnfreezeSurvivalSpawn(); // new public method
			StateHasChanged();
			return;
		}

        _demoTimer = 0f;
        _demoPhase = 0;
        StateHasChanged();
    }

    protected override int BuildHash() =>
        System.HashCode.Combine(_active, _step, _demoPhase);
}
@using Sandbox;
@using Sandbox.UI;
@inherits PanelComponent
@implements ILocalPlayerEvent

<root>
	@for (int i = 0; i < 5; i++)
	{
        @if ( ItemsInSlot(i).Count() < 1 )
        {
            continue;
        }

        <InventorySlot Index=@i Inventory=@inventory Hovered=@hovered Active=@active></InventorySlot>
	}
</root>

@code
{
    [Property] public SoundEvent SwitchSound { get; set; }
    [Property] public SoundEvent SelectSound { get; set; }
    [Property] public SoundEvent CancelSound { get; set; }

    PlayerInventory inventory => Player.Local.IsValid() ? Player.Local.Inventory : null;
    Carryable hovered;
    Carryable active;
    Carryable prev;

    protected override int BuildHash() => HashCode.Combine(inventory, hovered, active);

    IEnumerable<Carryable> ItemsInSlot( int slot )
    {
        return inventory.IsValid() ? inventory.Carryables.Where(x => x.PreferredSlot == slot) : [];
    }

    void ILocalPlayerEvent.OnPickup( BaseInventoryItem weapon )
    {
        StateHasChanged();
    }

    protected override void OnUpdate()
    {
        DoInventoryInput();
	}

	RealTimeSince timeSinceInteraction;

	void DoInventoryInput()
	{
		if (inventory is null)
			return;

		if (GameManager.Current.CurrentGameStage != GameManager.GameStage.Game)
		{
			if (hovered is not null)
			{
				hovered = null;
				active = null;
				prev = null;
			}

			return;
		}

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

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

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

		if (Input.Pressed("Slot1")) IterateSlot(0);
		if (Input.Pressed("Slot2")) IterateSlot(1);
		if (Input.Pressed("Slot3")) IterateSlot(2);
		if (Input.Pressed("Slot4")) IterateSlot(3);
		if (Input.Pressed("Slot5")) IterateSlot(4);

		if (hovered is null)
			return;

		if (Input.Pressed("Attack1"))
		{
			Input.ReleaseAction("Attack1");
			Input.SetAction("Attack1", false);

			prev = inventory.Current;
			inventory.SwitchWeapon(hovered);
			active = hovered;
			hovered = null;
			Sound.Play(SelectSound);
		}

		if (Input.Pressed("Attack2") || timeSinceInteraction > 2f)
		{
			Input.ReleaseAction("Attack2");
			Input.SetAction("Attack2", false);
			active = null;
			hovered = null;
			Sound.Play(CancelSound);
		}
	}

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

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

		if ( weapons.Count == 0 )
			return;

        var currentHover = hovered ?? active ?? inventory.Current ?? weapons.FirstOrDefault();

		int currentIndex = weapons.IndexOf( currentHover );

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

		active = null;
		if ( GamePreferences.FastSwitch )
		{
			inventory.SwitchWeapon( weapons[currentIndex] );
		}
		else
		{
			hovered = weapons[currentIndex];
			timeSinceInteraction = 0;
		}

		Sound.Play( SwitchSound );
	}

	public void IterateSlot( int slot )
	{
		var slotWeapons = inventory.Carryables.Where( x => x.PreferredSlot == slot ).Where( x => x.CanSwitch() ).OrderBy( x => x.SlotOrder ).ToList();
		if ( slotWeapons.Count == 0 )
			return;

		// if inventory wasn't open, and current weapon is in a different slot, currentIndex will be -1 (incremented to 0), and first weapon in target slot will be selected
        var currentHover = hovered ?? active ?? inventory.Current ?? slotWeapons.FirstOrDefault();

		int currentIndex = slotWeapons.IndexOf( currentHover );

		currentIndex += 1;
		currentIndex %= slotWeapons.Count;

		if ( currentIndex < 0 )
			currentIndex = slotWeapons.Count + currentIndex;

		active = null;
		if ( GamePreferences.FastSwitch )
		{
			inventory.SwitchWeapon( slotWeapons[currentIndex] );
		}
		else
		{
			hovered = slotWeapons[currentIndex];
			timeSinceInteraction = 0;
		}

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

@*
	The live readout: score and best, and nothing else. Deliberately small - everything else
	the player needs during a run is in the arena itself, and length lives in the game-over
	summary instead of here because it is interesting after a run and noise during one.

	Refreshes itself off its own Tick(), independently of whichever overlay GameUi has on top
	of it, so a paused or finished run still shows a live-looking score without dragging the
	rest of the interface through a rebuild to do it.
*@

<root class="readout">
	<div class="@ScoreClass">@ScoreText</div>
	<div class="best">
		<span class="best-label">BEST</span>
		<span class="best-value">@BestText</span>
	</div>
</root>

@code
{
	[Parameter] public GameSession Session { get; set; }

	private string ScoreText { get; set; } = "0";
	private string BestText { get; set; } = "0";

	/// <summary>Carries the pop class while the score is counting, built here rather than in
	/// markup so the attribute is never a mix of literal text and an @@expression.</summary>
	private string ScoreClass { get; set; } = "score";

	/// <summary>
	/// The score as it is being shown, which chases the real one rather than jumping to it.
	/// A number that counts up turns a state change into an event the eye can follow, and it
	/// is the difference between the score being a readout and the score being the reward.
	/// Kept as a float so the chase is smooth, and always floored so the player never sees a
	/// number higher than what they have actually earned.
	/// </summary>
	private float shownScore;

	private int lastRealScore;

	/// <summary>Seconds since the score last changed, driving the pop.</summary>
	private float scoreAge = 99f;

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

		var run = Session?.Run;
		if ( run is null ) return;

		TickScore( run.Score );

		ScoreText = ((int)shownScore).ToString();
		BestText = (Session.Records?.BestScore ?? 0).ToString();
	}

	/// <summary>
	/// Advances the displayed score towards the real one and drives the pop.
	/// <para>
	/// A restart drops the target to zero, and that is the one case where counting is wrong -
	/// watching your score tick down to nothing is a strange thing to do to somebody who just
	/// asked to play again, so it snaps instead.
	/// </para>
	/// </summary>
	private void TickScore( int real )
	{
		if ( real != lastRealScore )
		{
			if ( real < lastRealScore ) shownScore = real;

			lastRealScore = real;
			scoreAge = 0f;
		}

		scoreAge += Time.Delta;

		// Rate comes from the size of the gap, so an award of any size lands in about the same
		// time rather than a big one taking noticeably longer.
		var gap = MathF.Abs( real - shownScore );

		if ( gap > 0.01f )
		{
			var rate = MathF.Max( gap, 1f ) / GameConfig.ScoreCountDuration;

			shownScore = shownScore < real
				? MathF.Min( shownScore + rate * Time.Delta, real )
				: MathF.Max( shownScore - rate * Time.Delta, real );
		}
		else
		{
			shownScore = real;
		}

		ScoreClass = scoreAge < GameConfig.ScoreCountDuration ? "score popped" : "score";
	}

	/// <summary>
	/// The counting score is folded in as its whole number, so the panel redraws once per
	/// digit change rather than every frame.
	/// </summary>
	protected override int BuildHash() => System.HashCode.Combine( (int)shownScore, ScoreClass, BestText );
}
@using Sandbox;
@using Sandbox.UI;
@namespace Coilgarden
@inherits Panel
@attribute [StyleSheet( "/UI/GameUi.razor.scss" )]

@*
	The title screen. A keyboard player can skip it entirely by just pressing a direction -
	GameSession takes that as both "start" and the first turn - so the buttons here exist for
	the mouse player and for reaching Settings, not because the game waits on them.
*@

<root class="scrim">
	<div class="card">
		<div class="wordmark">Coilgarden</div>
		<div class="tagline">A small garden. One apple at a time.</div>

		<div class="menu">
			<div [email protected] class="btn primary" onclick=@Play>Play</div>
			<div [email protected] class="btn" onclick=@OpenSettingsClicked>Settings</div>
		</div>

		<div class="prompt">or press a direction to begin</div>

		@if ( HasRecord )
		{
			<div class="records">
				<div class="records-title">Best run</div>
				<div class="record-row"><span>Score</span><span>@BestScoreText</span></div>
				<div class="record-row"><span>Length</span><span>@BestLengthText</span></div>
				<div class="record-row"><span>Runs played</span><span>@RunsText</span></div>
			</div>
		}
	</div>
</root>

@code
{
	[Parameter] public GameSession Session { get; set; }

	[Parameter] public Action SettingsRequested { get; set; }

	private HighScoreData Records => Session?.Records;

	private bool HasRecord => Records is { RunsPlayed: > 0 };

	private string BestScoreText => (Records?.BestScore ?? 0).ToString();

	private string BestLengthText => (Records?.BestLength ?? 0).ToString();

	private string RunsText => (Records?.RunsPlayed ?? 0).ToString();

	private void Play()
	{
		UiSound.Click();
		Session?.StartRun();
	}

	private void OpenSettingsClicked()
	{
		UiSound.Click();
		SettingsRequested?.Invoke();
	}

	protected override int BuildHash() => System.HashCode.Combine(
		Records?.BestScore, Records?.BestLength, Records?.RunsPlayed );
}
@namespace Sunless.Libraries.UI
@using Sandbox
@using Sandbox.UI
@attribute [StyleSheet]
@inherits Panel

<root class="modal-overlay @(IsOpen ? "is-open" : "") @ExtraClass">
	@if ( IsOpen )
	{
		<div class="modal-scrim" @onclick=@HandleScrimClick></div>
		<div class="modal-dialog" style=@DialogStyle>
			@if ( Header is not null || !string.IsNullOrEmpty( Title ) || ShowClose )
			{
				<div class="modal-header">
					@if ( Header is not null )
					{
						@Header
					}
					else
					{
						<div class="modal-title">@Title</div>
					}
					@if ( ShowClose )
					{
						<div class="modal-close" @onclick=@HandleClose>X</div>
					}
				</div>
			}

			@if ( Body is not null )
			{
				<div class="modal-body">@Body</div>
			}

			@if ( Footer is not null )
			{
				<div class="modal-footer">@Footer</div>
			}
		</div>
	}
</root>

@code
{
	public bool IsOpen { get; set; }
	public string Title { get; set; }
	public bool ShowClose { get; set; } = true;
	public bool DismissOnScrim { get; set; } = true;
	public string ExtraClass { get; set; }
	public string DialogStyle { get; set; }
	public Action OnClose { get; set; }

	public RenderFragment Header { get; set; }
	public RenderFragment Body { get; set; }
	public RenderFragment Footer { get; set; }

	void HandleClose()
	{
		try { OnClose?.Invoke(); } catch ( Exception e ) { Log.Warning( $"[Modal] OnClose threw: {e.Message}" ); }
	}

	void HandleScrimClick()
	{
		if ( !DismissOnScrim ) return;
		HandleClose();
	}

	protected override int BuildHash() => HashCode.Combine( IsOpen, Title, ShowClose, ExtraClass, DialogStyle );
}
@using Sandbox;
@using Sandbox.UI;
@using System;
@using System.Linq;
@using System.Collections.Generic;
@inherits PanelComponent
@namespace Sandbox

@*
	IMPORTANT — s&box text rendering quirk.

	Razor compiles a literal containing non-ASCII characters (emoji, ×, ·, —)
	into AddMarkupContent, and plain-ASCII literals into AddContent. The panel
	renderer hoists every AddMarkupContent node ahead of every AddContent node,
	so "@A × @B" comes out as "×AB".

	The rule this file follows: an element holds EITHER pure literal text OR a
	single @expression. Anything composed goes through a helper below.
*@

<root>
@if ( Visible )
{
	<div class="idle-root">

		@* ══════════════════════ AMBIENT BACKDROP ══════════════════════ *@
		<div class="idle-bg"></div>
		<div class="idle-bg-glow @GlowClass()"></div>

		@for ( int i = 0; i < 12; i++ )
		{
			float pLeft = (i * 8.3f) % 100f;
			float pDur  = 10f + (i * 0.9f) % 8f;
			float pDel  = (i * 1.4f) % 12f;
			int   pSz   = (i % 4 == 0) ? 4 : 2;
			string pCol = (i % 3 == 0) ? "rgba(94,234,212,0.45)" : "rgba(96,165,250,0.30)";
			<div class="idle-particle"
				 style="left:@pLeft.ToString("F1")%;width:@(pSz)px;height:@(pSz)px;background-color:@pCol;animation-duration:@pDur.ToString("F1")s;animation-delay:@pDel.ToString("F1")s;"></div>
		}

		@* ══════════════════════════ TOP BAR ══════════════════════════ *@
		<div class="idle-top">
			<button class="idle-back" onclick=@OnBack>⌂</button>

			<div class="idle-brand">
				<div class="idle-brand-title">THE REACTOR</div>
				<div class="idle-brand-sub">chain reactions, forever</div>
			</div>

			<div class="idle-currencies">
				<div class="idle-cur cores">
					<span class="idle-cur-icon">⚛️</span>
					<span class="idle-cur-val">@CoresLabel()</span>
					<span class="idle-cur-lbl">Cores</span>
				</div>
				<div class="idle-cur rate">
					<span class="idle-cur-icon">📈</span>
					<span class="idle-cur-val">@RateLabel()</span>
					<span class="idle-cur-lbl">per sec</span>
				</div>
				<div class="idle-cur flux">
					<span class="idle-cur-icon">🔷</span>
					<span class="idle-cur-val">@FluxLabel()</span>
					<span class="idle-cur-lbl">Flux</span>
				</div>
				<div class="idle-cur frag">
					<span class="idle-cur-icon">💠</span>
					<span class="idle-cur-val">@FragLabel()</span>
					<span class="idle-cur-lbl">Frags</span>
				</div>
			</div>
		</div>

		@* ═══════════════════════════ BODY ════════════════════════════ *@
		@* Hidden behind a modal — the panel renderer draws emoji glyphs above
		   overlay panels, so the board has to actually leave the tree. *@
		@if ( !ModalOpen )
		{
		<div class="idle-body">

			@* ─────────────────────── LEFT COLUMN ─────────────────────── *@
			<div class="idle-col idle-left">

				<div class="idle-card">
					<div class="idle-card-hd">OUTPUT</div>
					<div class="idle-stat-row">
						<span class="idle-stat-lbl">Cores / sec</span>
						<span class="idle-stat-val hot">@RateLabel()</span>
					</div>
					<div class="idle-stat-row">
						<span class="idle-stat-lbl">Global multiplier</span>
						<span class="idle-stat-val">@GlobalMultLabel()</span>
					</div>
					<div class="idle-stat-row">
						<span class="idle-stat-lbl">Chain exponent</span>
						<span class="idle-stat-val">@ExponentLabel()</span>
					</div>
					<div class="idle-stat-row">
						<span class="idle-stat-lbl">Last chain</span>
						<span class="idle-stat-val">@LastChainLabel()</span>
					</div>
					<div class="idle-stat-row">
						<span class="idle-stat-lbl">Best chain</span>
						<span class="idle-stat-val">@BestChainLabel()</span>
					</div>
					<div class="idle-stat-row">
						<span class="idle-stat-lbl">Board</span>
						<span class="idle-stat-val">@BoardLabel()</span>
					</div>
					<div class="idle-stat-row">
						<span class="idle-stat-lbl">Feed line</span>
						<span class="idle-stat-val @FeedClass()">@FeedLabel()</span>
					</div>
				</div>

				@if ( ShowBoosts )
				{
					<div class="idle-card">
						<div class="idle-card-hd">ACTIVE</div>
						@if ( R.SurgeActive )
						{
							<div class="idle-boost surge">
								<span class="idle-boost-icon">⚡</span>
								<span class="idle-boost-lbl">@SurgeBoostLabel()</span>
								<span class="idle-boost-time">@SecondsLabel( R.SurgeTimeLeft )</span>
							</div>
						}
						@foreach ( var boost in R.Boosts )
						{
							var b = boost;
							<div class="idle-boost">
								<span class="idle-boost-icon">@b.Icon</span>
								<span class="idle-boost-lbl">@BoostLabel( b )</span>
								<span class="idle-boost-time">@SecondsLabel( b.SecondsLeft )</span>
							</div>
						}
					</div>
				}

				<div class="idle-card">
					<div class="idle-card-hd">CONDENSER</div>
					<div class="idle-bar-track">
						<div class="idle-bar-fill frag" style="width:@Pct( R.FragmentProgressFraction )%;"></div>
					</div>
					<div class="idle-card-note">@CondenserLabel()</div>
					<div class="idle-stat-row">
						<span class="idle-stat-lbl">Today</span>
						<span class="idle-stat-val @CapClass()">@FragTodayLabel()</span>
					</div>
				</div>

				<div class="idle-card">
					<div class="idle-card-hd">MANIFEST</div>
					@foreach ( var t in IdleBalance.Tiers )
					{
						var tier = t;
						bool got = S.CycleCores >= tier.Requires;
						<div class="idle-tier @(got ? "on" : "")">
							<span class="idle-tier-icon">@tier.Icon</span>
							<div class="idle-tier-body">
								<div class="idle-tier-name">@tier.Name</div>
								<div class="idle-tier-blurb">@TierBlurb( tier, got )</div>
							</div>
						</div>
					}
				</div>

				@* Rank card — neighbours, not a top-N. See RebuildRankRows(). *@
				<div class="idle-card">
					<div class="idle-card-hd">REACTOR RANK</div>
					@if ( _rankRows.Count == 0 )
					{
						<div class="idle-card-note">@RankEmptyLabel()</div>
					}
					else
					{
						@foreach ( var row in _rankRows )
						{
							var rw = row;
							<div class="idle-rank-row @RankRowClass( rw )">
								<span class="idle-rank-pos">@rw.RankLabel</span>
								<span class="idle-rank-name">@rw.Name</span>
								<span class="idle-rank-val">@rw.ValueLabel</span>
							</div>
						}
						<div class="idle-card-note">@GapLabel()</div>
						@if ( _rankClimb > 0 )
						{
							<div class="idle-card-note up">@ClimbLabel()</div>
						}
					}
				</div>

			</div>

			@* ────────────────────── CENTER COLUMN ────────────────────── *@
			<div class="idle-col idle-center">

				<div class="idle-board-wrap">
					<div class="idle-board @SurgeClass() @BoardShake()">
						@for ( int r = 0; r < R.Size; r++ )
						{
							<div class="idle-row">
								@for ( int c = 0; c < R.Size; c++ )
								{
									int row = r;
									int col = c;
									var cell = R.Board[row, col];
									bool golden = R.GoldenActive && R.GoldenRow == row && R.GoldenCol == col;

									<div class="idle-cell @CellClass( cell, golden )"
										 style="width:@(CellPx)px;height:@(CellPx)px;"
										 onclick=@(() => OnCellClick( row, col ))>

										@if ( golden )
										{
											<div class="idle-golden">🌟</div>
											<div class="idle-golden-timer">@GoldenTimerLabel()</div>
										}
										@if ( !golden && cell.HasChest )
										{
											<div class="idle-chest" style="@ChestStyle( cell )">@ChestGlyph( cell )</div>
										}
										@if ( !golden && cell.HasChest && cell.MaxHits > 1 )
										{
											<div class="idle-hits">@cell.HitsLeft</div>
										}
										@if ( cell.HasDrone )
										{
											<div class="idle-drone" style="@DroneStyle( cell )">💣</div>
										}
									</div>
								}
							</div>
						}
					</div>

					@foreach ( var floater in _floaters )
					{
						var f = floater;
						<div class="idle-float @f.Cls"
							 style="left:@f.X.ToString("F1")%;top:@f.Y.ToString("F1")%;opacity:@f.Alpha.ToString("F2");transform:translateY(@f.Rise.ToString("F0")px) scale(@f.Scale.ToString("F2"));">
							@f.Text
						</div>
					}

					@if ( _calloutLife > 0f )
					{
						<div class="idle-callout @_calloutCls" style="opacity:@_calloutLife.ToString("F2");">@_calloutText</div>
					}
				</div>

				<div class="idle-controls">

					<button class="idle-overload @OverloadClass()" onclick=@OnOverload>
						<div class="idle-overload-fill" style="width:@OverloadFillPct()%;"></div>
						<span class="idle-overload-label">@OverloadLabel()</span>
						<span class="idle-overload-sub">@OverloadSubLabel()</span>
					</button>

					<div class="idle-surge-wrap">
						<div class="idle-surge-track @SurgeReadyClass()">
							<div class="idle-surge-fill" style="width:@Pct( R.Charge )%;"></div>
							<span class="idle-surge-lbl">@SurgeLabel()</span>
						</div>
						@if ( R.SurgeReady )
						{
							<button class="idle-surge-btn" onclick=@OnSurge>@UnleashLabel()</button>
						}
					</div>
				</div>

				<div class="idle-meltdown-strip @MeltdownStripClass()">
					<span class="idle-melt-icon">☢️</span>
					<span class="idle-melt-text">@MeltdownLabel()</span>
					@if ( R.CanMeltdown )
					{
						<button class="idle-melt-btn" onclick=@OnOpenMeltdown>REVIEW</button>
					}
					@if ( !R.CanMeltdown )
					{
						<div class="idle-melt-bar">
							<div class="idle-melt-fill" style="width:@Pct( R.MeltdownProgress )%;"></div>
						</div>
					}
				</div>
			</div>

			@* ─────────────────────── RIGHT COLUMN ────────────────────── *@
			<div class="idle-col idle-right">

				<div class="idle-tabs">
					<button class="idle-tab @TabClass("reactor")" onclick=@(() => SetTab("reactor"))>⚙️</button>
					<button class="idle-tab @TabClass("chains")"  onclick=@(() => SetTab("chains"))>⛓️</button>
					<button class="idle-tab @TabClass("systems")" onclick=@(() => SetTab("systems"))>🛠️</button>
					<button class="idle-tab fluxtab @TabClass("flux")" onclick=@(() => SetTab("flux"))>🔷</button>
					<button class="idle-tab @TabClass("log")" onclick=@(() => SetTab("log"))>📜</button>
				</div>

				@if ( IsUpgradeTab )
				{
					<div class="idle-buyrow">
						<span class="idle-buyrow-lbl">BUY</span>
						@foreach ( var amount in BuyAmounts )
						{
							int amt = amount;
							<button class="idle-buy-amt @BuyAmtClass( amt )" onclick=@(() => SetBuyAmount( amt ))>@BuyAmountLabel( amt )</button>
						}
					</div>
				}

				<div class="idle-panel">

					@if ( IsUpgradeTab )
					{
						@foreach ( var upgrade in IdleUpgradeCatalog.InCategory( CurrentCategory ) )
						{
							var up = upgrade;
							bool revealed = R.IsRevealed( up );
							int  lvl      = S.Level( up.Id );
							bool maxed    = lvl >= up.MaxLevel;
							int  count    = revealed ? BuyCount( up.Id ) : 0;
							double cost   = (!revealed || maxed) ? 0 : R.CostFor( up.Id, count );
							bool afford   = revealed && !maxed && count > 0 && Cores >= cost;

							<div class="idle-up @UpgradeClass( revealed, maxed, afford )" onclick=@(() => OnBuy( up.Id ))>
								<div class="idle-up-icon">@UpgradeIcon( up, revealed )</div>
								<div class="idle-up-body">
									<div class="idle-up-top">
										<span class="idle-up-name">@UpgradeName( up, revealed )</span>
										<span class="idle-up-lvl">@LevelLabel( up, lvl, revealed )</span>
									</div>
									<div class="idle-up-desc">@UpgradeDesc( up, revealed )</div>
									@if ( revealed )
									{
										<div class="idle-up-bottom">
											<span class="idle-up-effect">@up.Effect( lvl )</span>
											<span class="idle-up-cost @MaxedClass( maxed )">@CostLabel( maxed, cost, count )</span>
										</div>
									}
								</div>
							</div>
						}
					}

					@if ( _tab == "flux" )
					{
						<div class="idle-flux-head">
							<div class="idle-flux-amount">@FluxHeadLabel()</div>
							<div class="idle-flux-sub">@FluxSubLabel()</div>
						</div>

						@foreach ( var perk in IdleUpgradeCatalog.Perks )
						{
							var p      = perk;
							int lvl    = S.PerkLevel( p.Id );
							bool maxed = lvl >= p.MaxLevel;
							int cost   = p.CostAt( lvl );
							bool afford = !maxed && S.Flux >= cost;

							<div class="idle-up perk @UpgradeClass( true, maxed, afford )" onclick=@(() => OnBuyPerk( p.Id ))>
								<div class="idle-up-icon">@p.Icon</div>
								<div class="idle-up-body">
									<div class="idle-up-top">
										<span class="idle-up-name">@p.Name</span>
										<span class="idle-up-lvl">@PerkLevelLabel( p, lvl )</span>
									</div>
									<div class="idle-up-desc">@p.Desc</div>
									<div class="idle-up-bottom">
										<span class="idle-up-effect">@p.Effect( lvl )</span>
										<span class="idle-up-cost flux @MaxedClass( maxed )">@PerkCostLabel( maxed, cost )</span>
									</div>
								</div>
							</div>
						}
					}

					@if ( _tab == "log" )
					{
						<div class="idle-flux-head">
							<div class="idle-flux-amount">@LogHeadLabel()</div>
							<div class="idle-flux-sub">Each one pays out once.</div>
						</div>

						@foreach ( var achievement in IdleAchievements.All )
						{
							var a = achievement;
							bool done = S.HasAchievement( a.Id );

							<div class="idle-up ach @(done ? "done" : "")">
								<div class="idle-up-icon">@AchIcon( a, done )</div>
								<div class="idle-up-body">
									<div class="idle-up-top">
										<span class="idle-up-name">@a.Name</span>
										<span class="idle-ach-reward">@RewardLabel( a )</span>
									</div>
									<div class="idle-up-desc">@a.Desc</div>
									@if ( !done )
									{
										<div class="idle-ach-bar">
											<div class="idle-ach-fill" style="width:@Pct( a.Fraction( S ) )%;"></div>
										</div>
										<div class="idle-ach-prog">@ProgressLabel( a )</div>
									}
								</div>
							</div>
						}
					}
				</div>
			</div>
		</div>
		}

		@* ═════════════════════════ TOASTS ═════════════════════════ *@
		<div class="idle-toasts">
			@foreach ( var toast in _toasts )
			{
				var t = toast;
				<div class="idle-toast @t.Cls" style="opacity:@t.Alpha.ToString("F2");">
					<span class="idle-toast-icon">@t.Icon</span>
					<span class="idle-toast-text">@t.Text</span>
				</div>
			}
		</div>

		@* ═══════════════════ WELCOME BACK OVERLAY ═══════════════════ *@
		@if ( R.OfflinePending )
		{
			<div class="idle-modal-bg"></div>
			<div class="idle-modal offline">
				<div class="idle-modal-eyebrow">REACTOR REPORT</div>
				<div class="idle-modal-title">Welcome back</div>
				<div class="idle-modal-sub">@OfflineSubLabel()</div>

				<div class="idle-offline-haul">
					<div class="idle-offline-amount">@OfflineAmountLabel()</div>
					<div class="idle-offline-lbl">CORES</div>
				</div>

				@if ( R.PendingOfflineFrags > 0 )
				{
					<div class="idle-offline-extra">@OfflineFragLabel()</div>
				}

				@if ( OfflineTankFull )
				{
					<div class="idle-offline-warn">@OfflineWarnLabel()</div>
				}

				<button class="idle-modal-btn" onclick=@OnClaimOffline>COLLECT</button>
			</div>
		}

		@* ═══════════════════ DAILY STREAK OVERLAY ═══════════════════ *@
		@if ( !R.OfflinePending && !_showIntro && R.DailyPending )
		{
			<div class="idle-modal-bg"></div>
			<div class="idle-modal daily">
				<div class="idle-modal-eyebrow">DAILY CYCLE</div>
				<div class="idle-modal-title">@DailyTitleLabel()</div>
				<div class="idle-modal-sub">Come back tomorrow to keep the streak climbing. Day 7 pays eight times.</div>

				<div class="idle-streak-row">
					@for ( int d = 1; d <= 7; d++ )
					{
						int day = d;
						<div class="idle-streak-pip @StreakPipClass( day )">@day</div>
					}
				</div>

				<div class="idle-daily-reward">
					<div class="idle-daily-mult">@DailyMultLabel()</div>
					<div class="idle-daily-lbl">@DailyRewardLabel()</div>
				</div>

				<button class="idle-modal-btn" onclick=@OnClaimDaily>CLAIM</button>
			</div>
		}

		@* ══════════════════════ MELTDOWN MODAL ══════════════════════ *@
		@if ( !R.OfflinePending && !_showIntro && !R.DailyPending && _showMeltdown )
		{
			<div class="idle-modal-bg"></div>
			<div class="idle-modal meltdown">
				<div class="idle-modal-eyebrow">PRESTIGE</div>
				<div class="idle-modal-title">MELTDOWN</div>
				<div class="idle-modal-sub">Collapse this cycle into permanent Flux. Every Flux you have ever earned adds income forever, and Flux buys perks that survive every reset.</div>

				<div class="idle-melt-payout">
					<div class="idle-melt-payout-val">@MeltPayoutLabel()</div>
					<div class="idle-melt-payout-lbl">FLUX</div>
				</div>

				<div class="idle-melt-cols">
					<div class="idle-melt-col keep">
						<div class="idle-melt-col-hd">KEPT</div>
						<div class="idle-melt-line">Flux and every perk</div>
						<div class="idle-melt-line">Reactor Log entries</div>
						<div class="idle-melt-line">Records and Fragments</div>
						<div class="idle-melt-line">Kickstart head start</div>
					</div>
					<div class="idle-melt-col lose">
						<div class="idle-melt-col-hd">BURNED</div>
						<div class="idle-melt-line">@BurnedCoresLabel()</div>
						<div class="idle-melt-line">All Core upgrades</div>
						<div class="idle-melt-line">Reactor Frame size</div>
						<div class="idle-melt-line">Unlocked chest tiers</div>
					</div>
				</div>

				<div class="idle-modal-btns">
					<button class="idle-modal-btn ghost" onclick=@OnCloseMeltdown>Not yet</button>
					<button class="idle-modal-btn danger" onclick=@OnMeltdown>MELT DOWN</button>
				</div>
			</div>
		}

		@* ════════════════════════ INTRO MODAL ═══════════════════════ *@
		@if ( !R.OfflinePending && _showIntro )
		{
			<div class="idle-modal-bg"></div>
			<div class="idle-modal intro">
				<div class="idle-modal-eyebrow">HOW IT WORKS</div>
				<div class="idle-modal-title">THE REACTOR</div>
				<div class="idle-modal-sub">A board that plays itself. Chests drop, drones bomb them, chain reactions pay Cores. You never lose here — you only grow.</div>

				<div class="idle-intro-steps">
					<div class="idle-intro-step">
						<span class="idle-intro-num">1</span>
						<span class="idle-intro-txt">Spend Cores on the right. Longer chains are worth far more than richer chests.</span>
					</div>
					<div class="idle-intro-step">
						<span class="idle-intro-num">2</span>
						<span class="idle-intro-txt">Hit OVERLOAD, or press SPACE, for a burst. It charges the Surge meter — ten times output when you unleash it.</span>
					</div>
					<div class="idle-intro-step">
						<span class="idle-intro-num">3</span>
						<span class="idle-intro-txt">It keeps running while you are on the menu or another screen. Close the game and it stops.</span>
					</div>
					<div class="idle-intro-step">
						<span class="idle-intro-num">4</span>
						<span class="idle-intro-txt">Cores condense into Fragments, which feed your companion for a permanent output bonus. Meltdown turns a whole cycle into Flux.</span>
					</div>
				</div>

				<button class="idle-modal-btn" onclick=@OnCloseIntro>START THE REACTOR</button>
			</div>
		}
	</div>
}
</root>

@code {
	public static IdleHud Instance { get; private set; }

	IdleReactor       R    => IdleReactor.Instance;
	ChainReactionGame Game => ChainReactionGame.Instance;
	IdleSave          S    => ChainReactionGame.Save?.Idle;

	bool Visible => Game?.GameStarted == true
				 && Game?.Mode == GameMode.Idle
				 && R != null && S != null;

	double Cores => S?.Cores ?? 0;

	bool ModalOpen  => R.OfflinePending || R.DailyPending || _showMeltdown || _showIntro;
	bool ShowBoosts => R.Boosts.Count > 0 || R.SurgeActive;
	bool OfflineTankFull => R.PendingOfflineSeconds > R.OfflineCapHours * 3600f;

	// ── UI state ──────────────────────────────────────────────────
	string _tab       = "reactor";
	int    _buyAmount = 1;                      // -1 == MAX
	bool   _showMeltdown;
	bool   _showIntro;
	bool   _subscribed;
	bool   _wasVisible;

	static readonly int[] BuyAmounts = { 1, 10, 25, -1 };

	bool IsUpgradeTab => _tab == "reactor" || _tab == "chains" || _tab == "systems";

	IdleUpgradeCategory CurrentCategory => _tab switch
	{
		"reactor" => IdleUpgradeCategory.Reactor,
		"chains"  => IdleUpgradeCategory.Chains,
		_         => IdleUpgradeCategory.Systems,
	};

	// ── Floating numbers ──────────────────────────────────────────
	class Floater
	{
		public string Text;
		public string Cls;
		public float X, Y;
		public float Life, MaxLife;
		public float Alpha => MathF.Min( 1f, Life / 0.35f );
		public float Rise  => -(MaxLife - Life) * 42f;
		public float Scale => 0.85f + MathF.Min( 0.35f, (MaxLife - Life) * 1.2f );
	}
	readonly List<Floater> _floaters = new();

	class ToastMsg
	{
		public string Icon;
		public string Text;
		public string Cls;
		public float Life;
		public float Alpha => MathF.Min( 1f, Life / 0.4f );
	}
	readonly List<ToastMsg> _toasts = new();

	string _calloutText = "";
	string _calloutCls  = "";
	float  _calloutLife;

	// Board shake is kept local rather than going through ScreenShaker so the
	// class string stays stable between rebuilds — a changing class would
	// restart the animation on every repaint and read as a vibration.
	string _shakeCls = "";
	float  _shakeUntil;

	int CellPx => R == null ? 92 : R.Size switch
	{
		5 => 96,
		6 => 84,
		7 => 74,
		8 => 66,
		_ => 59,
	};

	// ══════════════════════════════════════════════════════════════
	// ══════════════════════════════════════════════════════════════
	// Reactor rank card
	//
	// Deliberately NOT a top-N board. A top-N motivates whoever is already in
	// it and is scenery for everyone else; the crossable goal is the gap to the
	// player directly above, so the card shows a window around the local player
	// (two up, you, one down).
	//
	// The board snapshot is stale between refreshes but S.FluxEarned is live,
	// so the local row and the gap are always computed from the local value.
	// That makes the gap tick down while the player watches, which is the whole
	// point of the card.
	// ══════════════════════════════════════════════════════════════

	class RankRow
	{
		public string RankLabel;
		public string Name;
		public string ValueLabel;
		public bool   IsMe;
	}
	readonly List<RankRow> _rankRows = new();

	Sandbox.Services.Leaderboards.Board2 _rankBoard;
	bool  _rankLoading;
	bool  _rankFetched;
	float _rankNextFetch;
	int   _myRank;        // 1-based, 0 == not on the board
	long  _nextUpValue;   // stat value of the player directly above, 0 == none above
	int   _rankClimb;     // places gained since the last session
	int   _rankTotalPlayers; // Board2.TotalEntries — real population, not window size

	const float  RankRefreshSeconds = 90f;
	const string RankStat  = "chain_reaction_reactor";

	/// <summary>
	/// Network call — only ever driven by the throttle in OnUpdate, never from
	/// the render path. BuildHash changes many times a second, so fetching from
	/// there would hammer the backend into rate-limiting us.
	/// </summary>
	async System.Threading.Tasks.Task LoadRank()
	{
		if ( _rankLoading ) return;
		_rankLoading = true;

		try
		{
			var b = Sandbox.Services.Leaderboards.GetFromStat( RankStat );
			b.SetAggregationMax();
			b.SetSortDescending();

			// CenterOnMe is exactly what this card wants: Board2 returns the
			// entries surrounding a target player, so asking for a small window
			// centred on us gives the neighbours directly. No manual slicing.
			b.CenterOnMe();
			b.MaxEntries = 5;

			await b.Refresh();
			_rankBoard = b;
			_rankTotalPlayers = (int)b.TotalEntries;

			Log.Info( $"Rank '{RankStat}' — window: {b.Entries?.Length ?? -1}, board total: {b.TotalEntries}" );
		}
		catch ( Exception e )
		{
			Log.Warning( $"Reactor rank: {e.Message}" );
		}

		_rankLoading = false;
		_rankFetched = true;
		RebuildRankRows();
		StateHasChanged();
	}

	void RebuildRankRows()
	{
		_rankRows.Clear();
		_myRank      = 0;
		_nextUpValue = 0;
		_rankClimb   = 0;

		var entries = _rankBoard?.Entries;
		if ( entries == null || entries.Length == 0 ) return;

		// CenterOnMe means these indices are window-relative, NOT board-relative,
		// so ranks must come from Entry.Rank. Using index+1 here would label the
		// window "#1..#5" no matter where the player actually sits.
		long myId  = Sandbox.Connection.Local?.SteamId ?? 0;
		int  meIdx = -1;
		for ( int i = 0; i < entries.Length; i++ )
		{
			if ( entries[i].SteamId == myId ) { meIdx = i; break; }
		}

		if ( meIdx < 0 )
		{
			// No entry for us yet — show the window we were given so there is
			// still a named target to chase rather than an empty card.
			for ( int i = 0; i < entries.Length; i++ )
				_rankRows.Add( MakeRankRow( entries[i], false ) );

			_nextUpValue = (long)entries[entries.Length - 1].Value;
			return;
		}

		_myRank = (int)entries[meIdx].Rank;

		int start = Math.Max( 0, meIdx - 2 );
		int end   = Math.Min( entries.Length - 1, meIdx + 1 );
		for ( int i = start; i <= end; i++ )
			_rankRows.Add( MakeRankRow( entries[i], i == meIdx ) );

		if ( meIdx > 0 ) _nextUpValue = (long)entries[meIdx - 1].Value;

		// Places gained since we last recorded a rank. Persisting this is what
		// turns offline production into "I moved up while I was away".
		int prev = S?.LastReactorRank ?? 0;
		if ( prev > 0 && _myRank > 0 && _myRank < prev ) _rankClimb = prev - _myRank;

		if ( S != null && _myRank > 0 && S.LastReactorRank != _myRank )
		{
			S.LastReactorRank = _myRank;
			ChainReactionGame.Save?.Save();
		}
	}

	RankRow MakeRankRow( Sandbox.Services.Leaderboards.Board2.Entry e, bool isMe )
	{
		// The snapshot lags our own earnings, so never show the player a number
		// lower than what they have actually banked.
		long val = isMe
			? Math.Max( (long)e.Value, S?.FluxEarned ?? 0 )
			: (long)e.Value;

		return new RankRow
		{
			RankLabel  = "#" + e.Rank.ToString(),
			Name       = isMe ? "you" : RankName( e ),
			ValueLabel = val.ToString( "N0" ),
			IsMe       = isMe,
		};
	}

	string RankName( Sandbox.Services.Leaderboards.Board2.Entry e )
	{
		if ( !string.IsNullOrWhiteSpace( e.DisplayName ) ) return e.DisplayName;
		try
		{
			var friend = new Sandbox.Friend( e.SteamId );
			if ( !string.IsNullOrWhiteSpace( friend.Name ) ) return friend.Name;
		}
		catch { }
		return "Anonymous";
	}

	string RankRowClass( RankRow r ) => r.IsMe ? "me" : "";

	string RankEmptyLabel()
	{
		if ( !_rankFetched ) return "Reading the board";
		return "Earn Flux to enter the rankings";
	}

	string GapLabel()
	{
		if ( _nextUpValue <= 0 ) return "Top of the board";

		long mine = S?.FluxEarned ?? 0;
		long gap  = _nextUpValue - mine;

		if ( gap <= 0 ) return "Ahead — board updates shortly";
		return gap.ToString( "N0" ) + " Flux to pass";
	}

	string ClimbLabel()
	{
		if ( _rankClimb == 1 ) return "Up 1 place since last time";
		return "Up " + _rankClimb.ToString() + " places since last time";
	}

	// Lifecycle
	// ══════════════════════════════════════════════════════════════

	protected override void OnStart()
	{
		Instance = this;
	}

	protected override void OnUpdate()
	{
		if ( !_subscribed && R != null ) Subscribe();

		bool vis = Visible;
		if ( vis != _wasVisible )
		{
			_wasVisible = vis;
			if ( vis )
			{
				_floaters.Clear();
				_toasts.Clear();
				_calloutLife  = 0f;
				_shakeUntil   = 0f;
				_showMeltdown = false;
				_showIntro    = S != null && !S.IntroShown;
				_tab          = "reactor";
				_rankNextFetch = 0f;   // refetch on entering idle mode
			}
			StateHasChanged();
		}

		if ( !vis ) return;

		// Wall-clock throttle. Never move this into BuildHash or the render path.
		if ( RealTime.Now >= _rankNextFetch )
		{
			_rankNextFetch = RealTime.Now + RankRefreshSeconds;
			_ = LoadRank();
		}

		// SPACE is the detonate key everywhere else in the game, so it fires the
		// reactor's manual burst here too.
		if ( Input.Pressed( "Jump" ) && R.OverloadReady && !ModalOpen )
			R.Overload();

		float dt = Time.Delta;

		for ( int i = _floaters.Count - 1; i >= 0; i-- )
		{
			_floaters[i].Life -= dt;
			if ( _floaters[i].Life <= 0f ) _floaters.RemoveAt( i );
		}

		for ( int i = _toasts.Count - 1; i >= 0; i-- )
		{
			_toasts[i].Life -= dt;
			if ( _toasts[i].Life <= 0f ) _toasts.RemoveAt( i );
		}

		if ( _calloutLife > 0f ) _calloutLife -= dt * 1.4f;
	}

	void Subscribe()
	{
		var r = R;
		if ( r == null || _subscribed ) return;

		r.OnPop        += OnPop;
		r.OnToast      += OnToastEvent;
		r.OnDetonation += OnDetonation;

		_subscribed = true;
	}

	/// <summary>
	/// Rebuilds only when something on screen would actually differ.
	///
	/// The first version forced a repaint 15 times a second, which meant
	/// rebuilding the board, both side columns and every upgrade row constantly
	/// — that was the source of the stutter. Between detonations almost nothing
	/// changes, so hashing real state instead of a clock cuts the repaint rate
	/// by roughly an order of magnitude.
	/// </summary>
	protected override int BuildHash()
	{
		if ( !Visible ) return 0;

		var hc = new HashCode();
		hc.Add( _tab );
		hc.Add( _buyAmount );
		hc.Add( _showMeltdown );
		hc.Add( _showIntro );
		hc.Add( ModalOpen );

		// Currencies — quantised to what the labels actually show
		hc.Add( (long)Cores );
		hc.Add( (long)R.Cps );
		hc.Add( S.Flux );
		hc.Add( S.FluxEarned );
		hc.Add( _rankRows.Count );
		hc.Add( _myRank );
		hc.Add( _rankClimb );
		hc.Add( ChainReactionGame.Save?.TotalFragments ?? 0 );
		hc.Add( S.TodayReactorFragments );
		hc.Add( (int)(R.FragmentProgressFraction * 60f) );

		// Live controls
		hc.Add( (int)(R.OverloadCooldownLeft * 10f) );
		hc.Add( (int)(R.Charge * 100f) );
		hc.Add( R.SurgeActive );
		hc.Add( (int)R.SurgeTimeLeft );
		hc.Add( (int)R.GoldenTimeLeft );
		hc.Add( R.Boosts.Count );
		hc.Add( R.Boosts.Count > 0 ? (int)R.Boosts[0].SecondsLeft : 0 );
		hc.Add( R.FeedStarved );

		// Transient effects
		hc.Add( _floaters.Count );
		hc.Add( (int)(_calloutLife * 20f) );
		hc.Add( Time.Now < _shakeUntil ? _shakeCls : "" );
		hc.Add( _toasts.Count );

		// Board — chest layout, damage, drones and any live flash
		hc.Add( R.Size );
		float now = Time.Now;
		bool animating = _floaters.Count > 0 || _calloutLife > 0f || now < _shakeUntil;

		for ( int r = 0; r < R.Size; r++ )
			for ( int c = 0; c < R.Size; c++ )
			{
				var cell = R.Board[r, c];
				hc.Add( (int)cell.Chest );
				hc.Add( cell.HitsLeft );
				hc.Add( cell.HasDrone );
				hc.Add( cell.FlashUntil > now ? cell.FlashKind : 0 );

				if ( cell.FlashUntil > now ) animating = true;
				if ( cell.HasChest && now - cell.SpawnAt < 0.25f ) animating = true;
				if ( cell.HasDrone && now - cell.DroneAt < 0.18f ) animating = true;
			}

		hc.Add( R.GoldenRow );
		hc.Add( R.GoldenCol );
		hc.Add( R.LastChain );

		// Anything mid-flight is drawn from inline styles rather than keyframes,
		// so it needs a clock — but only while it is actually moving. The rest of
		// the time this hash is stable and the panel is never rebuilt at all.
		if ( animating ) hc.Add( (int)(now * 20f) );

		return hc.ToHashCode();
	}

	// ══════════════════════════════════════════════════════════════
	// Reactor events
	// ══════════════════════════════════════════════════════════════

	void OnPop( int row, int col, double value, bool crit )
	{
		if ( R == null || _floaters.Count > 10 ) return;

		float step = 100f / R.Size;
		_floaters.Add( new Floater {
			Text = "+" + IdleFormat.Short( value ),
			Cls  = crit ? "crit" : "",
			X = step * col + step * 0.5f,
			Y = step * row + step * 0.5f,
			Life = 0.9f,
			MaxLife = 0.9f,
		} );
	}

	void OnToastEvent( string icon, string text, string cls )
	{
		_toasts.Insert( 0, new ToastMsg { Icon = icon, Text = text, Cls = cls, Life = 3.2f } );
		while ( _toasts.Count > 4 ) _toasts.RemoveAt( _toasts.Count - 1 );
	}

	void OnDetonation( int chain, double payout, bool crit )
	{
		string shake = crit || chain >= 25 ? "idle-shake-3"
					 : chain >= 12 ? "idle-shake-2"
					 : chain >= 4  ? "idle-shake-1"
					 : "";

		if ( shake != "" )
		{
			_shakeCls   = shake;
			_shakeUntil = Time.Now + (shake == "idle-shake-3" ? 0.45f : 0.28f);
		}

		if ( chain < 15 && !crit ) return;

		double critPower = IdleBalance.CritPower( S?.Level( IdleUpgradeId.CritPower ) ?? 0 );

		_calloutText = crit
			? $"CRIT ×{critPower:F0}  ·  +{IdleFormat.Short( payout )}"
			: $"{chain} CHAIN  ·  +{IdleFormat.Short( payout )}";

		_calloutCls  = crit ? "crit" : chain >= 35 ? "mega" : "big";
		_calloutLife = 1f;
	}

	// ══════════════════════════════════════════════════════════════
	// Input
	// ══════════════════════════════════════════════════════════════

	void SetTab( string tab )       { _tab = tab; Sound.Play( "UI_Button" ); }
	void SetBuyAmount( int amount ) { _buyAmount = amount; Sound.Play( "UI_Button" ); }

	int BuyCount( IdleUpgradeId id )
		=> _buyAmount < 0 ? Math.Max( 1, R.MaxAffordable( id ) ) : _buyAmount;

	void OnBuy( IdleUpgradeId id )
	{
		if ( R == null ) return;
		if ( !R.TryBuy( id, BuyCount( id ) ) ) Sound.Play( "Pop_Button_1" );
	}

	void OnBuyPerk( IdlePerkId id )
	{
		if ( R == null ) return;
		if ( !R.TryBuyPerk( id ) ) Sound.Play( "Pop_Button_1" );
	}

	void OnOverload()  => R?.Overload();
	void OnSurge()     => R?.ActivateSurge();
	void OnCellClick( int r, int c ) => R?.ClickCell( r, c );

	void OnClaimOffline() => R?.ClaimOffline();
	void OnClaimDaily()   => R?.ClaimDaily();

	void OnOpenMeltdown()  { _showMeltdown = true;  Sound.Play( "UI_Button" ); }
	void OnCloseMeltdown() { _showMeltdown = false; Sound.Play( "UI_Button" ); }

	void OnMeltdown()
	{
		_showMeltdown = false;
		R?.DoMeltdown();
	}

	void OnCloseIntro()
	{
		_showIntro = false;
		if ( S != null )
		{
			S.IntroShown = true;
			ChainReactionGame.Save?.Save();
		}
		Sound.Play( "PlayButton" );
	}

	void OnBack()
	{
		Sound.Play( "UI_Button" );
		Game?.ReturnToMenu();
	}

	// ══════════════════════════════════════════════════════════════
	// Class helpers
	// ══════════════════════════════════════════════════════════════

	string Pct( float f ) => (Math.Clamp( f, 0f, 1f ) * 100f).ToString( "F1" );

	string GlowClass()        => R.SurgeActive ? "surging" : "";
	string SurgeClass()       => R.SurgeActive ? "surging" : "";
	string SurgeReadyClass()  => R.SurgeReady ? "ready" : "";
	string OverloadClass()    => R.OverloadReady ? "ready" : "cooling";
	string BoardShake()       => Time.Now < _shakeUntil ? _shakeCls : "";
	string TabClass( string t )     => _tab == t ? "on" : "";
	string BuyAmtClass( int amt )   => _buyAmount == amt ? "on" : "";
	string MaxedClass( bool maxed ) => maxed ? "maxed" : "";
	string CapClass()  => R.FragmentsToday >= R.DailyFragmentCap ? "capped" : "";
	string FeedClass() => R.FeedStarved ? "starved" : "";
	string MeltdownStripClass() => R.CanMeltdown ? "ready" : "";

	string StreakPipClass( int day )
	{
		int streak = S?.DailyStreak ?? 1;
		if ( day == streak ) return "on today";
		return day < streak ? "on" : "";
	}

	// ══════════════════════════════════════════════════════════════
	// Text helpers — one composed string per element (see note at top)
	// ══════════════════════════════════════════════════════════════

	string CoresLabel() => IdleFormat.Short( Cores );
	string RateLabel()  => IdleFormat.Short( R.Cps );
	string FluxLabel()  => (S?.Flux ?? 0).ToString();
	string FragLabel()  => (ChainReactionGame.Save?.TotalFragments ?? 0).ToString();

	string GlobalMultLabel() => "×" + IdleFormat.Short( R.GlobalMultiplier );
	string ExponentLabel()   => R.ChainExponent.ToString( "F2" );
	string LastChainLabel()  => $"{R.LastChain} chests";
	string BestChainLabel()  => $"{S.BestChain} chests";
	string BoardLabel()      => $"{R.Size} × {R.Size}";

	string FeedLabel() => R.FeedStarved ? "starving" : "keeping up";

	string SurgeBoostLabel() => $"SURGE ×{IdleBalance.SurgeMultiplier:F0}";
	string BoostLabel( IdleReactor.Boost b ) => $"{b.Label} ×{b.Mult:F1}";
	string SecondsLabel( float s ) => $"{s:F0}s";

	string CondenserLabel()
		=> $"next at {IdleFormat.Short( R.FragmentCost )} Cores · price climbs with each one";

	string FragTodayLabel() => $"{R.FragmentsToday} / {R.DailyFragmentCap}";

	string TierBlurb( IdleBalance.ChestTier tier, bool unlocked )
		=> unlocked ? tier.Blurb : $"unlocks at {IdleFormat.Short( tier.Requires )} cycle Cores";

	string GoldenTimerLabel() => R.GoldenTimeLeft.ToString( "F0" );

	string OverloadLabel()
		=> R.OverloadReady ? "⚡ OVERLOAD" : $"{R.OverloadCooldownLeft:F1}s";

	string OverloadSubLabel() => $"×{IdleBalance.OverloadMultiplier:F1} burst · SPACE";
	string OverloadFillPct()  => ((1f - R.OverloadCooldownFraction) * 100f).ToString( "F0" );
	string UnleashLabel()     => $"UNLEASH ×{IdleBalance.SurgeMultiplier:F0}";

	string SurgeLabel()
	{
		if ( R.SurgeActive ) return $"SURGE {R.SurgeTimeLeft:F0}s";
		if ( R.SurgeReady )  return "SURGE READY";
		return $"CHARGE {R.Charge * 100f:F0}%";
	}

	string MeltdownLabel()
		=> R.CanMeltdown
			? $"Meltdown available — +{R.PendingFlux} Flux"
			: $"Meltdown unlocks at {IdleFormat.Short( IdleBalance.MeltdownRequirement )} cycle Cores · {IdleFormat.Short( S.CycleCores )} banked";

	string BuyAmountLabel( int amount ) => amount < 0 ? "MAX" : $"×{amount}";

	string UpgradeClass( bool revealed, bool maxed, bool afford )
	{
		if ( !revealed ) return "locked";
		if ( maxed ) return "maxed";
		return afford ? "afford" : "poor";
	}

	string UpgradeIcon( IdleUpgrade up, bool revealed ) => revealed ? up.Icon : "🔒";
	string UpgradeName( IdleUpgrade up, bool revealed ) => revealed ? up.Name : "Locked";

	string UpgradeDesc( IdleUpgrade up, bool revealed )
		=> revealed ? up.Desc : $"Reach {IdleFormat.Short( up.RevealAt )} lifetime Cores to reveal.";

	string LevelLabel( IdleUpgrade up, int lvl, bool revealed )
	{
		if ( !revealed ) return "—";
		return up.MaxLevel < 100 ? $"Lv {lvl} / {up.MaxLevel}" : $"Lv {lvl}";
	}

	string CostLabel( bool maxed, double cost, int count )
	{
		if ( maxed ) return "MAXED";
		return count > 1
			? $"{IdleFormat.Short( cost )}  ×{count}"
			: IdleFormat.Short( cost );
	}

	string PerkLevelLabel( IdlePerk p, int lvl ) => $"Lv {lvl} / {p.MaxLevel}";
	string PerkCostLabel( bool maxed, int cost ) => maxed ? "MAXED" : $"🔷 {cost}";

	string FluxHeadLabel() => $"🔷 {S.Flux}";

	string FluxSubLabel()
		=> $"{S.FluxEarned} earned · ×{IdleBalance.FluxMultiplier( S.FluxEarned ):F2} permanent income";

	string LogHeadLabel() => $"{IdleAchievements.CompletedCount( S )} / {IdleAchievements.All.Count}";

	string AchIcon( IdleAchievement a, bool done ) => done ? a.Icon : "🔘";

	string RewardLabel( IdleAchievement a )
	{
		if ( a.RewardFlux > 0 && a.RewardFrags > 0 ) return $"+{a.RewardFlux}🔷  +{a.RewardFrags}💠";
		if ( a.RewardFlux > 0 ) return $"+{a.RewardFlux}🔷";
		return $"+{a.RewardFrags}💠";
	}

	string ProgressLabel( IdleAchievement a )
	{
		double cur = a.Progress( S );
		return a.BigNumbers
			? $"{IdleFormat.Short( cur )} / {IdleFormat.Short( a.Target )}"
			: $"{(long)cur} / {(long)a.Target}";
	}

	// ── Modal labels ──────────────────────────────────────────────
	string OfflineSubLabel()
		=> $"The reactor ran for {IdleFormat.Duration( R.PendingOfflineSeconds )} at {IdleFormat.Pct( R.OfflineEfficiency )} efficiency.";

	string OfflineAmountLabel() => "+" + IdleFormat.Short( R.PendingOfflineCores );

	string OfflineFragLabel() => $"condensing {R.PendingOfflineFrags} Fragments";

	string OfflineWarnLabel()
		=> $"Storage filled up after {R.OfflineCapHours:F0}h. Upgrade Core Storage to bank longer trips.";

	string DailyTitleLabel() => $"Day {S?.DailyStreak ?? 1}";
	string DailyMultLabel()  => $"×{IdleBalance.DailyReward( S?.DailyStreak ?? 1 ).mult:F1}";

	string DailyRewardLabel()
	{
		var reward = IdleBalance.DailyReward( S?.DailyStreak ?? 1 );
		return $"for {IdleFormat.Duration( reward.seconds )}  ·  +{reward.frags} Fragments";
	}

	string MeltPayoutLabel()  => "+" + R.PendingFlux;
	string BurnedCoresLabel() => $"{IdleFormat.Short( Cores )} Cores";

	// ══════════════════════════════════════════════════════════════
	// Cell rendering
	// ══════════════════════════════════════════════════════════════

	string CellClass( IdleReactor.Cell cell, bool golden )
	{
		if ( golden ) return "golden";

		string cls = cell.HasChest ? "filled" : "empty";

		if ( cell.FlashUntil > Time.Now )
		{
			cls += cell.FlashKind switch
			{
				3 => " fx-drone",
				2 => " fx-kill",
				_ => " fx-blast",
			};
		}

		if ( cell.HasDrone ) cls += " armed";

		if ( cell.HasChest )
		{
			cls += cell.Chest switch
			{
				GridManager.ChestType.Gem       => " c-gem",
				GridManager.ChestType.BombChest => " c-bomb",
				GridManager.ChestType.Locked    => " c-locked",
				GridManager.ChestType.Cursed    => " c-cursed",
				GridManager.ChestType.Void      => " c-void",
				GridManager.ChestType.Boss      => " c-boss",
				_                               => " c-normal",
			};
		}

		return cls;
	}

	/// <summary>
	/// Spawn pop, computed here rather than as a keyframe: the panel can rebuild
	/// at any moment and a class-driven animation would restart mid-flight.
	/// </summary>
	string ChestStyle( IdleReactor.Cell cell )
	{
		float age = Time.Now - cell.SpawnAt;
		if ( age >= 0.25f || age < 0f ) return "";

		float scale = 0.4f + 0.6f * (age / 0.25f);
		return $"transform:scale({scale.ToString( "F2" )});";
	}

	/// <summary>Drone drop — falls onto the cell as the volley arms.</summary>
	string DroneStyle( IdleReactor.Cell cell )
	{
		float age = Time.Now - cell.DroneAt;
		if ( age >= 0.18f || age < 0f ) return "";

		float t = age / 0.18f;
		float scale = 2.0f - 1.0f * t;
		return $"transform:scale({scale.ToString( "F2" )});opacity:{t.ToString( "F2" )};";
	}

	string ChestGlyph( IdleReactor.Cell cell ) => cell.Chest switch
	{
		GridManager.ChestType.BombChest => "💣",
		GridManager.ChestType.Locked    => "🔒",
		GridManager.ChestType.Cursed    => "💀",
		GridManager.ChestType.Void      => "🌑",
		GridManager.ChestType.Boss      => "👑",
		GridManager.ChestType.Gem       => "💎",
		_                               => "🎁",
	};
}
@using System;
@using Sandbox;
@using Sandbox.Game.Menu
@using Sandbox.UI;
@inherits PanelComponent
@namespace Sandbox

<root>
    @if(_active)
    {
		@* ── Debug card ── *@
		@if(_debugMode)
		{
			<div class="tut-debug-card">
				<div class="tut-debug-title">MM TUT — Step @_step</div>
				<div class="tut-debug-row">
					<span>BL: @_debugBL.ToString("F1")%</span>
					<button class="tut-debug-btn" onclick=@(() => { _debugBL -= 1f; StateHasChanged(); })>◀</button>
					<button class="tut-debug-btn" onclick=@(() => { _debugBL += 1f; StateHasChanged(); })>▶</button>
				</div>
				<div class="tut-debug-row">
					<span>BY: @_debugBY.ToString("F0")px</span>
					<button class="tut-debug-btn" onclick=@(() => { _debugBY -= 10f; StateHasChanged(); })>▲</button>
					<button class="tut-debug-btn" onclick=@(() => { _debugBY += 10f; StateHasChanged(); })>▼</button>
				</div>
				<div class="tut-debug-row">
					<span>BR: @_debugBR.ToString("F1")%</span>
					<button class="tut-debug-btn" onclick=@(() => { _debugBR -= 1f; StateHasChanged(); })>◀</button>
					<button class="tut-debug-btn" onclick=@(() => { _debugBR += 1f; StateHasChanged(); })>▶</button>
				</div>
				<div class="tut-debug-row">
					<span>HX: @_debugHX.ToString("F1")%</span>
					<button class="tut-debug-btn" onclick=@(() => { _debugHX -= 1f; StateHasChanged(); })>◀</button>
					<button class="tut-debug-btn" onclick=@(() => { _debugHX += 1f; StateHasChanged(); })>▶</button>
				</div>
				<div class="tut-debug-row">
					<span>HY: @_debugHY.ToString("F0")px</span>
					<button class="tut-debug-btn" onclick=@(() => { _debugHY -= 10f; StateHasChanged(); })>▲</button>
					<button class="tut-debug-btn" onclick=@(() => { _debugHY += 10f; StateHasChanged(); })>▼</button>
				</div>
				<div class="tut-debug-row">
					<span>HW: @_debugHW.ToString("F0")px</span>
					<button class="tut-debug-btn" onclick=@(() => { _debugHW -= 10f; StateHasChanged(); })>◀</button>
					<button class="tut-debug-btn" onclick=@(() => { _debugHW += 10f; StateHasChanged(); })>▶</button>
				</div>
				<div class="tut-debug-row">
					<span>HH: @_debugHH.ToString("F0")px</span>
					<button class="tut-debug-btn" onclick=@(() => { _debugHH -= 10f; StateHasChanged(); })>▲</button>
					<button class="tut-debug-btn" onclick=@(() => { _debugHH += 10f; StateHasChanged(); })>▼</button>
				</div>
				<button class="tut-debug-log" onclick=@LogCurrentValues>📋 Log</button>
			</div>
		}

		@* ── Dim layer ── *@
		<div class="tut-dim"></div>

		@* ── Highlight ── *@
		@if(_step < _steps.Length && _steps[_step].HW > 0)
		{
			var s = _steps[_step];
			float hx = _debugHX != 0f ? _debugHX : s.HX;
			float hy = _debugHY != 0f ? _debugHY : s.HY;
			float hw = _debugHW != 0f ? _debugHW : s.HW;
			float hh = _debugHH != 0f ? _debugHH : s.HH;
			<div class="tut-highlight"
					style="left:@hx.ToString("F1")%;top:@hy.ToString("F0")px;width:@hw.ToString("F0")px;height:@hh.ToString("F0")px;">
			</div>
		}

		@* ── Instruction box ── *@
		@if(_step < _steps.Length)
		{
			var s = _steps[_step];
			float bl = _debugBL != 0f ? _debugBL : s.BL;
			float br = _debugBR != 0f ? _debugBR : s.BR;
			float by = _debugBY != 0f ? _debugBY : s.BY;

			float boxWidthPct = s.BW > 0f ? (s.BW / 1920f * 100f) : (380f / 1920f * 100f);

			string posStyle = br != 0f
				? $"left:{(100f - br - boxWidthPct).ToString("F1")}%;"
				: $"left:{bl.ToString("F1")}%;";
			string boxStyle = $"{posStyle}top:{by.ToString("F0")}px;{(s.BW > 0f ? $"width:{s.BW.ToString("F0")}px;" : "")}";

			<div class="tut-box" style="@boxStyle">
				<div class="tut-step-count">@(_step + 1) / @_steps.Length</div>
				<div class="tut-title">@s.Title</div>
				<div class="tut-text">@s.Text</div>

				@* Action hint — shown when player must act *@
				@if(s.Trigger == MMTutTrigger.HeartPurchased)
				{
					<div class="tut-action-hint">👆 @s.ActionHint</div>
				}

				@* Next button — shown on Any steps *@
				@if(s.Trigger == MMTutTrigger.Any)
				{
					<button class="tut-btn-next" onclick=@NextStep>Got it →</button>
				}

				@* Final step — Play button *@
				@if(s.Trigger == MMTutTrigger.Done)
				{
					<button class="tut-btn-next" onclick=@OnPlayAndComplete>▶ Let's play!</button>
				}
			</div>
		}
    }
</root>

@code {
    public static MainMenuTutorial Instance { get; private set; }
    public bool IsActive => _active;

    bool _active = false;
	public int Step => _step;
    int  _step   = 0;

    // ── Debug mode ──────────────────────────────────────────────────── //
    const bool _debugMode = false; // set false before release
    float _debugBL = 0f;
    float _debugBR = 0f;
    float _debugBY = 0f;
    float _debugHX = 0f;
    float _debugHY = 0f;
    float _debugHW = 0f;
    float _debugHH = 0f;

    void LogCurrentValues()
    {
        Log.Info($"// MM Step {_step} — {_steps[_step].Title}");
        Log.Info($"BL={_debugBL:F1}f, BY={_debugBY:F0}f, BR={_debugBR:F1}f,");
        Log.Info($"HX={_debugHX:F1}f, HY={_debugHY:F0}f, HW={_debugHW:F0}f, HH={_debugHH:F0}f");
    }

    // ── Trigger types ────────────────────────────────────────────────── //
    public enum MMTutTrigger
    {
        Any,           // advances via Got it button
        HeartPurchased, // advances when player buys Heart consumable
        Done,          // final step — shows Play button
    }

    // ── Step data ────────────────────────────────────────────────────── //
    public struct MMTutStep
    {
        public string Title;
        public string Text;
        public string ActionHint;
        public MMTutTrigger Trigger;
        public float HX, HY, HW, HH; // highlight
        public float BL, BR, BY, BW;  // box position
    }

    // ── Steps ────────────────────────────────────────────────────────── //
    // Positions are placeholder — tune with debug card in-game
    static readonly MMTutStep[] _steps = new MMTutStep[]
    {
        // Step 1 — Welcome
        new MMTutStep {
            Title   = "Welcome to Chain Reaction!",
            Text    = "Here's a quick tour of how the game works. It'll take 30 seconds.",
            Trigger = MMTutTrigger.Any,
            HW = 0,
            BL = 47f, BY = 370f
        },

        // Step 2 — Play button
        new MMTutStep {
            Title   = "Play runs to earn 💠 Fragments",
            Text    = "Fragments are the game's currency. The further you go each run, the more you earn.",
            Trigger = MMTutTrigger.Any,
            HW = 0,
            BL = 47f, BY = 370f
        },

        // Step 3 — Shop → Modifiers
        new MMTutStep {
            Title   = "The Shop — Modifiers 🎲",
            Text    = "Spend fragments to unlock Modifiers. These permanently strengthen every run you play. Unlock once, keep forever.",
            Trigger = MMTutTrigger.Any,
            HX = 40f, HY = 200f, HW = 110f, HH = 40f,
            BR = 61f, BY = 120f
        },

        // Step 4 — Consumables explanation
        new MMTutStep {
            Title   = "Consumables 🎁",
            Text    = "Consumables are single-use items you bring into a run. Buy them once, use them at any time during a run. A bar will appear in-game when you own one.",
            Trigger = MMTutTrigger.Any,
            HX = 46f, HY = 200f, HW = 130f, HH = 40f,
            BR = 61f, BY = 110
        },

        // Step 5 — Buy Heart (action step)
        new MMTutStep {
            Title   = "Get yourself a Heart ❤️",
            Text    = "We've added 100 💠 to your balance. Buy the Heart consumable — it gives you an extra life mid-run when you need it most.",
            ActionHint = "Click the buy button on the Heart card",
            Trigger = MMTutTrigger.HeartPurchased,
            HX = 65f, HY = 860f, HW = 80f, HH = 70f,
            BR = 7f, BY = 800f
        },

        // Step 6 — Pet tab
        new MMTutStep {
            Title = "Your secret package 📦",
            Text = "Play 3 runs to unlock what's inside. This section is for cosmetic skins — coming soon.",
    		Trigger = MMTutTrigger.Any,
            HX = 53f, HY = 360f, HW = 70f, HH = 40f,
            BR = 50f, BY = 340f
        },

        // Step 7 — Bombs
        new MMTutStep {
            Title   = "Unlock new Bombs 💣",
            Text    = "Different bomb types open up new chain reaction strategies. Mix and match to find your playstyle.",
            Trigger = MMTutTrigger.Any,
            HX = 57f, HY = 220f, HW = 90f, HH = 40f,
            BR = 5f, BY = 130f
        },

        // Step 8 — Leaderboard
        new MMTutStep {
            Title   = "Compete Globally 🏅",
            Text    = "The leaderboard tracks your best score, chain reactions, and pet level. How far can you go?",
            Trigger = MMTutTrigger.Any,
            HX = 75f, HY = 400f, HW = 10f, HH = 10f,
            BR = 5f, BY = 160f
        },

        // Step 9 — Done
        new MMTutStep {
            Title   = "You're ready. 💥",
            Text    = "Play runs → earn fragments → unlock things → become stronger. Good luck!",
            Trigger = MMTutTrigger.Done,
			BR = 20f, BY = 370f
        },
    };

    // ── Lifecycle ────────────────────────────────────────────────────── //
    protected override void OnStart()
    {
        Instance = this;
        _active  = false;
    }

    public void Activate(PlayerSave save)
    {
        //var save = ChainReactionGame.Save ?? PlayerSave.Load();
        if (save?.MenuTutorialCompleted == true) return;

        _step   = save?.MenuTutorialStep ?? 0;
        _active = true;
        ResetDebug();

		// If resuming at step 4 (Buy Heart) but heart already purchased, skip it
		if (_step == 4 && (save?.GetConsumableCount(ConsumableId.Heart) ?? 0) > 0)
			_step = 5;

        // Step 3 requires shop/mods to be visible — navigate there
        ApplySideEffect(_step);
        StateHasChanged();
    }

    // ── Called from MainMenu when Heart is bought ────────────────────── //
    public void OnHeartPurchased()
    {
        if (!_active || _step >= _steps.Length) return;
        if (_steps[_step].Trigger != MMTutTrigger.HeartPurchased) return;
        NextStep();
    }

    // ── Step advancement ─────────────────────────────────────────────── //
  	void NextStep() 
	{
		_step++;
		ResetDebug();

		if (_step >= _steps.Length)
		{
			Complete();
			return;
		}

		// Persist current step so we can resume after crash - save tuto step
		var save = PlayerSave.Load();
		if (save != null) 
		{ 
			save.MenuTutorialStep = _step; 
			save.Save(); 
		}

		ApplySideEffect(_step);
		Sound.Play("UI_Button");
		StateHasChanged();
}

    // ── Side effects — navigate tabs automatically ───────────────────── //
    void ApplySideEffect(int step)
    {
        var mm = MainMenu.Instance;
        if (mm == null) return;

        switch(step)
        {
            case 2: // Show shop modifiers
                mm.SetTabPublic("shop");
                mm.SetShopTabPublic("mods");
                // Grant frags on step before heart purchase
                break;

            case 3: // Show consumables
                mm.SetTabPublic("shop");
                mm.SetShopTabPublic("consumables");
                break;

		    case 4: // Buy Heart — shop consumables + grant frags if not already granted
            var save = ChainReactionGame.Save ?? PlayerSave.Load();
            if (save != null && !save.TutorialHeartFragsGranted)
            {
                save.TotalFragments += 100;
                save.TutorialHeartFragsGranted = true;
                save.Save();
            }
            mm.SetTabPublic("shop");
            mm.SetShopTabPublic("consumables");
            mm.ReloadSave();
            break;
		
            case 5: // Pet tab
			    mm.SetTabPublic("shop");
                mm.SetShopTabPublic("pets");
                break;

            case 6: // Bombs
                mm.SetTabPublic("shop");
                mm.SetShopTabPublic("bombs");
                break;

            case 7: // Leaderboard
                mm.SetTabPublic("lb");
                break;
			case 8: // Done — no navigation needed
            break;
        }
    }

    void Skip() => Complete();

    void OnPlayAndComplete()
    {
        Complete();
        MainMenu.Instance?.StartGamePublic();
    }

    void Complete()
    {
        _active = false;

		// Get save from MainMenu directly
		var mm = MainMenu.Instance;

		// just use ChainReactionGame.Save or load fresh
		var save = PlayerSave.Load();

		if (save != null)
		{
			save.MenuTutorialCompleted = true;
			save.MenuTutorialStep = 0;
			save.Save();
		}
		StateHasChanged();
    }

    void ResetDebug()
    {
        _debugBL = 0f; _debugBR = 0f; _debugBY = 0f;
        _debugHX = 0f; _debugHY = 0f; _debugHW = 0f; _debugHH = 0f;
    }

	public void ForceReset()
	{
		_active = false;
		_step   = 0;
		ResetDebug();
		StateHasChanged();
	}

    protected override int BuildHash() =>
        System.HashCode.Combine(_active, _step, _debugBL, _debugBY, _debugHX, _debugHY, _debugHW, _debugHH);
}
@namespace KOTH.UI
@using KOTH.Notification
@using Sandbox.Events
@inherits PanelComponent

@if (!DeathCameraData.IsValid())
{
	return;
}

<root>
	<div class="dead">
		killed by:
	</div>
	<div class="name">
		@DeathCameraData.KillerName
	</div>

	<div class="hp">
		@DeathCameraData.KillerHealth HP
	</div>
</root>

@code
{
	public static PlayerDeathHUD Instance { get; set; }

	public PlayerDeathHUD()
	{
		Instance = this;
	}

	public FDeathCameraData DeathCameraData { get; set; }

	public void SetData(FDeathCameraData Data)
	{
		DeathCameraData = Data;
	}

	protected override void OnEnabled()
	{
		base.OnEnabled();
	}

	protected override void OnUpdate()
	{
		base.OnUpdate();
	}

	protected override int BuildHash()
	{
		return HashCode.Combine(Time.Now);
	}
}
@* @namespace KOTH.UI
@inherits Panel
@attribute [StyleSheet]

@if (!Player.IsValid() || !EngiePlayer.IsValid() || EngiePlayer.ActiveTurretComponent == null)
{
    return;
}

<root>
    <label class="building">@(EngiePlayer.ActiveTurretComponent.DamageComponent.Health)/@(EngiePlayer.ActiveTurretComponent.DamageComponent.MaxBaseHealth)</label>
</root>

@code
{
    public PlayerPawn Player => PlayerState.Local.PlayerPawn;
    public EngiePlayer EngiePlayer => Player.GameObject.GetComponent<EngiePlayer>();

    protected override int BuildHash()
    {
        if (!Player.IsValid())
            return 0;

        return HashCode.Combine(Time.Now);
    }
}
 *@
@namespace KOTH.UI
@attribute [StyleSheet]
@inherits Panel

<root class="">
	@* @foreach (var voice in VoiceList.Where(x => CanDisplay(x)))
	{
		<div class="row">
			<div class="item-row with-background">
				@{
					var player = GetPlayer(voice);
				}

				<Icon class="square rounded shrink-0" File="/ui/chat/radio.png" size="24" style="transform: scale( @GetAmplitudeScale( voice ) )" />
				<img class="avatar" src="avatar:@player.SteamId" />
				<PlayerPanel PlayerState=@player />
			</div>
		</div>
	} *@

</root>

@code
{
	public IEnumerable<PlayerVoiceComponent> VoiceList => Scene.GetAllComponents<PlayerVoiceComponent>().Where(x => x.PlayerState.IsConnected);

	private PlayerState GetPlayer(Voice voice)
	{
		return voice.Components.Get<PlayerState>();
	}

	private bool CanDisplay(Voice voice)
	{
		return voice.LastPlayed < 0.25f;
	}

	private string GetAmplitudeScale(Voice voice)
	{
		var scale = 0.5f + (voice.Amplitude * 2.365f);
		return (1f + scale).ToString("0.#");
	}

	protected override int BuildHash()
	{
		return HashCode.Combine(Time.Now);
	}
}
@namespace KOTH.UI
@inherits Panel
@attribute [StyleSheet]

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

<root>
    <div class="circle"></div>
</root>

@code
{
    /// The player
    public PlayerPawn Player => PlayerState.Local.PlayerPawn;

    protected override int BuildHash()
    {
        if ( !Player.IsValid() || !Player.CurrentEquipment.IsValid() )
            return 0;

        return HashCode.Combine(Player, Player.CurrentEquipment);
    }
}
@inherits PanelComponent
<root> <div class="ynal2p"> @t </div> </root>

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

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

[Rpc.Broadcast]
public void d(PanelComponent pc)
{pc.Destroy();}
}
@using Skateboard.Player
@using Skateboard.Tricks
@inherits PanelComponent

<root>
	<div class="@GetMultiplierClass()">@_multiplierText</div>
	<div class="@GetTrickClass()">@_trickText</div>
</root>

@code
{
	private float _displayTime;
	private const float MaxDisplayTime = 5f;
	private bool _lastFinished;
	private int _pulseTick;

	private string _multiplierText = "";
	private string _trickText = "";
	private bool _done;
	private bool _failed;
	private bool _fadeout;
	private bool _visuallyEmpty;

	protected override void OnStart()
	{
		TrickScoreHolder.OnLocalTrickScoreUpdate += OnTrickScoreUpdate;
	}

	protected override void OnDestroy()
	{
		TrickScoreHolder.OnLocalTrickScoreUpdate -= OnTrickScoreUpdate;
	}

	private void OnTrickScoreUpdate()
	{
		_displayTime = 0f;
		_pulseTick++;
		StateHasChanged();
	}

	protected override void OnUpdate()
	{
		var holder = SkatePawn.Local?.TrickScores;
		if ( holder is null )
			return;

		if ( _displayTime < MaxDisplayTime && holder.Finished )
			_displayTime += Time.Delta;

		if ( _lastFinished != holder.Finished )
		{
			_lastFinished = holder.Finished;
			_displayTime = 0f;
		}

		var visuallyEmpty = holder.VisuallyEmpty;
		var multiplierText = visuallyEmpty ? "" : $"{holder.Score} x {holder.Multiplier}";
		var trickText = visuallyEmpty ? "" : holder.String;
		var done = holder.Finished;
		var failed = holder.Failed;
		var fadeout = _displayTime >= MaxDisplayTime;

		if ( _visuallyEmpty == visuallyEmpty &&
			 _multiplierText == multiplierText &&
			 _trickText == trickText &&
			 _done == done &&
			 _failed == failed &&
			 _fadeout == fadeout )
			return;

		_visuallyEmpty = visuallyEmpty;
		_multiplierText = multiplierText;
		_trickText = trickText;
		_done = done;
		_failed = failed;
		_fadeout = fadeout;

		StateHasChanged();
	}

	private string GetMultiplierClass()
	{
		return BuildClass( $"multiplier skate-trick-text {GetPulseClass()}" );
	}

	private string GetTrickClass()
	{
		return BuildClass( $"tricks skate-trick-text {GetPulseClass()}" );
	}

	private string BuildClass( string baseClass )
	{
		if ( _visuallyEmpty )
			return $"{baseClass} hidden";

		if ( _fadeout )
			baseClass += " fadeout";

		if ( _failed )
			baseClass += " failed";
		else if ( _done )
			baseClass += " done";

		return baseClass;
	}

	private string GetPulseClass()
	{
		return _pulseTick % 2 == 0 ? "pulse pulse-a" : "pulse pulse-b";
	}
}
@inherits PanelComponent
<root> <div class="eyelids" style="opacity: @o;"> </div> </root>

@code
{
public float o {get;set;} = 0;
protected override int BuildHash() => System.HashCode.Combine(o);
}
@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("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;
@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;
@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;
	}
}
@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

<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 Sandbox;
@using Sandbox.UI;
@inherits PanelComponent
@namespace Sandbox

<root>

	@if ( Current is not null )
	{
		var mouse = Mouse.Position;
		var left = mouse.x * Panel.ScaleFromScreen - 32;
		var top = mouse.y * Panel.ScaleFromScreen - 32;
		var iconStyle = string.IsNullOrEmpty( Current.Icon ) ? "" : $"background-image: url({Current.Icon}); ";
		<div class="dragging" style="@(iconStyle)left: @(left)px; top: @(top)px;" @ref="DragVisual">
			@if ( string.IsNullOrEmpty( Current.Icon ) )
			{
				<div class="drag-title">@Current.Title</div>
			}
		</div>
	}

</root>

@code
{
    public static DragHandler Instance { get; private set; }

    /// <summary>
    /// The data currently being dragged, or null.
    /// </summary>
    public static DragData Current { get; private set; }

    /// <summary>
    /// True if something is actively being dragged.
    /// </summary>
    public static bool IsDragging => Current is not null;

    Panel DragVisual { get; set; }
    private RootPanel _rootPanel;

    protected override void OnStart()
    {
        Instance = this;
    }

    protected override void OnDestroy()
    {
        if ( Instance == this )
            Instance = null;
    }

    /// <summary>
    /// Start dragging with the given data.
    /// </summary>
    public static void StartDragging( DragData data )
    {
        Current = data;
        data.Source.UserData = data;

        Instance?.StateHasChanged();
    }

    /// <summary>
    /// Stop dragging and clear all state.
    /// </summary>
    public static void StopDragging()
    {
        if (!IsDragging) return;

        Current = null;
        Instance?.StateHasChanged();
    }

    protected override void OnUpdate()
    {
        if ( !IsDragging ) return;

        if ( !_rootPanel.WantsMouseInput() )
        {
            StopDragging();
            return;
        }

        if ( DragVisual is not null )
        {
            var mouse = Mouse.Position;
            DragVisual.Style.Left = Length.Pixels( mouse.x * Panel.ScaleFromScreen - 32 );
            DragVisual.Style.Top = Length.Pixels( mouse.y * Panel.ScaleFromScreen - 32 );
        }
    }

    protected override void OnTreeBuilt()
    {
        _rootPanel = Panel.FindRootPanel();
    }

	protected override int BuildHash() => HashCode.Combine( Current );
}
@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;
@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>();
    }
}