473 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;
@using System.Threading.Tasks;
@using System.Collections.Generic;
@using System;
@inherits PanelComponent

<root class="@(IsFadingOut ? "fade-out" : "fade-in")" style="background-image: @(!string.IsNullOrEmpty(BackgroundImage) ? $"url({BackgroundImage})" : "none");">
    
    <div class="content">
        @* Logo Image (.png / .jpg) *@
        @if (!string.IsNullOrEmpty(LogoImage))
        {
            <img class="logo" src="@LogoImage" />
        }
        
        @* Text Lines *@
        @if (TextLines != null && TextLines.Count > 0)
        {
            <div class="text-container">
                @foreach (var line in TextLines)
                {
                    <label style="color: @line.TextColor.Hex; font-size: @(line.FontSize)px;">
                        @line.Text
                    </label>
                }
            </div>
        }
    </div>

</root>

@code {
    // === CUSTOM DATA CLASS FOR TEXT LINES ===
    
    public class SplashTextLine
    {
        [Property, Description("The text to display.")] 
        public string Text { get; set; } = "NEW LINE";

        [Property, Description("Text color for this specific line.")] 
        public Color TextColor { get; set; } = Color.White;

        [Property, Description("Font size for this specific line.")] 
        public float FontSize { get; set; } = 80f;
    }


    // === IMAGE SETTINGS ===
    
    [Property, ImageAssetPath, Group("Images"), Description("Supports .png and .jpg. If empty, the background will be black.")] 
    public string BackgroundImage { get; set; }

    [Property, ImageAssetPath, Group("Images"), Description("Main logo image (.png / .jpg). Appears above the text if both are set.")] 
    public string LogoImage { get; set; }


    // === TEXT SETTINGS ===

    [Property, Group("Text"), Description("Add text lines with individual settings (color, size).")]
    public List<SplashTextLine> TextLines { get; set; } = new();


    // === AUDIO & SCENE SETTINGS ===

    [Property, Group("Audio"), Description("Select a Sound Event (.sound) that contains your .mp3 or .ogg file.")] 
    public SoundEvent SplashSound { get; set; }

    [Property, Group("Scene"), Description("The scene to load after the splash screen finishes.")] 
    public SceneFile NextScene { get; set; }


    // === LOGIC ===
    
    public bool IsFadingOut { get; set; } = false;

    protected override void OnStart()
    {
        base.OnStart();
        
        // Start the asynchronous sequence
        _ = RunSplashSequence();
    }

    private async Task RunSplashSequence()
    {
        // 1. Wait half a second before starting to avoid stuttering during load
        await Task.Delay(500);

        // 2. Play the assigned sound (.mp3 / .ogg via Sound Event)
        if (SplashSound != null)
        {
            Sound.Play(SplashSound);
        }

        // 3. Wait while the logo/text is visible on the screen (3 seconds)
        await Task.Delay(3000);

        // 4. Trigger the fade-out animation
        IsFadingOut = true;
        StateHasChanged(); // Notify the UI to update CSS classes

        // 5. Wait for the fade-out animation to finish (matches the CSS transition time)
        await Task.Delay(2000);

        // 6. Load the next scene
        if (NextScene != null)
        {
            Scene.Load(NextScene);
        }
        else
        {
            Log.Warning("Next Scene is not assigned in the Splash Screen component!");
            GameObject.Destroy(); // Destroy the component if no scene is assigned
        }
    }
}
@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
@using System
@using System.Collections.Generic
@namespace FieldGuide.DayNight
@inherits PanelComponent
@attribute [StyleSheet]

@*
	The demo's on-screen key card, up from the first frame. A scene with no visible instructions reads as
	a broken scene: you press nothing, nothing happens, you close it. So this says what the demo is and
	which keys do what, before you have touched anything.

	It also carries the one thing the demo could not otherwise show. The kit ships no sky shader and no
	sky art on purpose; the sky is a SEAM, four normalized crossfade weights per hour. Those weights have
	no picture, so the card prints them live and they visibly hand off from one slot to the next as the
	clock runs. That is the seam doing its job, on screen, with no art involved.

	Rows render in MAIN markup via @foreach per the fragment-undermeasure gotcha. H hides the card (a
	letter, never an F key, which the editor eats in play). No ESC anywhere: house law.

	Look and layout follow the Field Kit UI system: docs/design/ui-system/daynight-kit.dc.html for this
	screen, tokens.dc.html for the values. Font sizes come from the {12, 13, 14, 16} panel scale and there
	is no letter-spacing; the stylesheet head lists the rest of the engine-legality rules.

	Not part of the kit's runtime surface: delete Code/Demo when you drop the kit into your own project.
*@

<root>
@* DemoActive is the inert-by-construction gate (library law 11): only DayNightDemoBootstrap sets it, so
   this card cannot appear in a consumer's game even if Code/Demo was left in the project. *@
@if ( CardOpen && DayNightDemoBootstrap.DemoActive )
{
	<div class="dh-card">
		<div class="dh-hdr">
			<span class="dh-title">DAY / NIGHT KIT DEMO</span>
			<div class="dh-x" onclick=@(() => CardOpen = false)>×</div>
		</div>

		<div class="dh-lede">One directional light, one skybox, one clock. Watch the sun sweep and the colour grade follow it, or open the time panel and drive the cycle yourself.</div>

		<div class="dh-rows">
			@foreach ( var r in Keys )
			{
				string key = r.key;     // plain locals before interpolating: an inline tuple read can render blank
				string what = r.what;
				<div class="dh-row">
					<span class="dh-key">@key</span>
					<span class="dh-what">@what</span>
				</div>
			}
		</div>

		<div class="dh-live">
			@foreach ( var w in Weights )
			{
				string run = w;
				<span class="dh-lk">@run</span>
			}
		</div>

		<div class="dh-foot">Those four are the sky seam. The kit ships no sky shader and no sky art; you crossfade your own sky from these weights.</div>
	</div>
}
</root>

@code
{
	static bool _open = true;

	/// <summary>Console fallback: `daynight_hint 1` / `daynight_hint 0` shows or hides the card (H also
	/// toggles). Starts SHOWN, unlike the time panel, because it is the thing that tells you the time panel
	/// exists.</summary>
	[ConVar( "daynight_hint", Help = "Show or hide the demo scene's key card (same as the H key)" )]
	public static bool CardOpen { get => _open; set => _open = value; }

	static readonly List<(string key, string what)> Keys = new()
	{
		( "N", "Open the time panel: scrub the clock, change the pace, pin the weather" ),
		( "H", "Hide this card" ),
	};

	DayNightClock _clock;

	DayNightClock Clock
	{
		get
		{
			if ( _clock.IsValid() ) return _clock;
			_clock = DayNightClock.For( Scene );
			return _clock;
		}
	}

	/// <summary>The live sky weights as four short atomic runs. Split into separate spans rather than one
	/// sentence so a wrap breaks BETWEEN runs; a single long run wraps mid-word, which is a live bug class
	/// in this engine's text layout.</summary>
	List<string> Weights
	{
		get
		{
			var c = Clock;
			var cfg = c?.Config ?? DayNightConfig.Default;
			var w = SkyWeights.WeightsFor( c?.GetTimeHours() ?? 0f, cfg );
			return new List<string>
			{
				$"MORNING {w.x:0.00}",
				$"NOON {w.y:0.00}",
				$"EVENING {w.z:0.00}",
				$"NIGHT {w.w:0.00}",
			};
		}
	}

	protected override void OnUpdate()
	{
		if ( Input.Keyboard.Pressed( "H" ) )
			CardOpen = !CardOpen;
	}

	// Fold the card state and every printed weight (to the two decimals shown), or the strip freezes at
	// whatever it read on the first frame while the sun keeps moving.
	protected override int BuildHash()
	{
		var c = Clock;
		var cfg = c?.Config ?? DayNightConfig.Default;
		var w = SkyWeights.WeightsFor( c?.GetTimeHours() ?? 0f, cfg );
		return HashCode.Combine( CardOpen, DayNightDemoBootstrap.DemoActive,
			(int)MathF.Round( w.x * 100f ),
			(int)MathF.Round( w.y * 100f ),
			(int)MathF.Round( w.z * 100f ),
			(int)MathF.Round( w.w * 100f ) );
	}
}
@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 );
}