385 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." ),
    };
}
@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 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 System;
@using Sandbox;
@using Sandbox.UI;
@inherits PanelComponent
@namespace Sandbox

<root>
	<div class="timer">@Timer.ToString()</div>
	<div class="title" style="color: @(IsFailed ? FaildColor.Hex : NormalColor.Hex);">@TextOnScreen</div>
</root>

@code
{
	[Property]
	public Color NormalColor;

	[Property]
	public Color FaildColor;

	bool IsFailed = false;

	bool IsStarted = false;

	string TextOnScreen = "Click before start!";

	float Timer = 0;

	protected override void OnUpdate()
	{
		if ( !IsStarted)
		{
			if ( Input.Pressed( "attack1" ))
			{
				IsStarted = true;
				TextOnScreen = "Just chill and don't move your mouse...";
			}

			return;
		}

		if ( Mouse.Delta.x != 0 && Mouse.Delta.y != 0 || Input.Pressed("attack1") || Input.Pressed("attack2") )
		{
			IsFailed = true;
			TextOnScreen = "YOUR FUCKED, IDIOT!!!";
		}

		if( !IsFailed )
			Timer += Time.Delta;

		base.OnUpdate();
	}
	protected override int BuildHash() => System.HashCode.Combine( TextOnScreen, NormalColor, FaildColor, Timer );
}
@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 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 Sandbox;
@using Sandbox.UI;
@inherits PanelComponent
@namespace VehicleProto

@* Lab / tuning (design board 1c) — toggled by T ("Debug"). Writes live onto the running car.
   Left: grouped dial panel. Right: live suspension rig (bound to VehicleWheel). *@

<root>
	@if ( UiState.TuningOpen && Car.IsValid() )
	{
		<div class="panel">
			<div class="phead">
				<div class="ptitle">Tuning — @CarName</div>
				<div class="pactions">
					<div class="qbtn" onclick=@ToggleTuneHelp>?</div>
					<div class="mono kbd">T</div>
				</div>
			</div>

			<div class="tabs">
				@foreach ( var g in Groups )
				{
					var gg = g;
					<div class="mono tab @(_tab == gg ? "on" : "")" onclick=@( () => SetTab( gg ) )>@gg</div>
				}
			</div>

			<div class="dials">
				@foreach ( var p in ActiveParams )
				{
					var pp = p;
					<div class="dial">
						<div class="drow">
							<div class="dname">@p.Name @if ( IsDirty( p ) ) { <span class="ddot"></span> }</div>
							<div class="mono dval">@p.Get().ToString( p.Fmt )@p.Suffix</div>
						</div>
						<div class="dctl">
							<div class="mono step" onclick=@( () => Adjust( pp, -1 ) )>−</div>
							<div class="track"
								onmousedown=@( e => TrackScrub( pp, e, true ) )
								onmousemove=@( e => TrackScrub( pp, e, false ) )>
								<div class="fill" style="width: @UiFmt.Pct( Frac( pp ) * 100f )%"></div>
							</div>
							<div class="mono step" onclick=@( () => Adjust( pp, +1 ) )>+</div>
						</div>
					</div>
				}
			</div>

			<div class="dirty @(DirtyCount > 0 ? "" : "clean")">
				<span class="ddot"></span>
				<span class="dtext">@DirtyText</span>
			</div>

			<div class="presets">
				<div class="prow phdr">
					<div class="mono plabel">Saved tunes · @CarName</div>
					<div class="btn primary psave" onclick=@SavePreset>Save current</div>
				</div>
				@if ( CarPresets.Count == 0 )
				{
					<div class="mono pempty">No saved tunes for this car yet — tweak the dials, then Save current.</div>
				}
				else
				{
					@* Scrollable list container (Issue 3): header/Save stay fixed above; only the ROWS
					   scroll, capped at ~3 rows so a long list can't scrunch the panel into the HUD. *@
					<div class="plist">
						@foreach ( var preset in CarPresets )
						{
							var pr = preset;
							<div class="prow">
								<div class="pname" onclick=@( () => LoadPreset( pr ) )>@pr.Name</div>
								<div class="mono pedit" onclick=@( () => BeginRename( pr ) )>Edit</div>
								<div class="mono pload" onclick=@( () => LoadPreset( pr ) )>Load</div>
								<div class="mono pdel" onclick=@( () => DeletePreset( pr ) )>Del</div>
							</div>
						}
					</div>
				}
			</div>

			<div class="foot">
				<div class="frow">
					<div class="btn ghost" onclick=@ResetAll>Reset all</div>
					<div class="btn primary" onclick=@( () => Car.Respawn() )>Respawn car · R</div>
				</div>
				<div class="mono fhint">changes apply instantly to the running car</div>
			</div>
		</div>

		<div class="suspension">
			<div class="shead">
				<div class="stitle">Suspension</div>
				<div class="mono scap">travel · load</div>
			</div>
			<div class="rig">
				@for ( int i = 0; i < Car.Wheels.Count && i < 4; i++ )
				{
					var w = Car.Wheels[i];
					var bottomed = IsBottomed( w );
					<div class="scol">
						<div class="sbar @(bottomed ? "bottom" : "")">
							<div class="sfill @(bottomed ? "bottom" : "")" style="height: @UiFmt.Pct( LoadFrac( w ) * 100f )%"></div>
						</div>
						<div class="mono slbl @(bottomed ? "bottom" : "")">@WheelLabel( i )@(bottomed ? " · BOTTOM" : "")</div>
						<div class="mono skg">@LoadKg( w ) kg</div>
					</div>
				}
			</div>
		</div>
	}
</root>

@code {
	// Car is set once at Mount and re-pointed on every live car swap by UiRig.Retarget. The panel
	// caches param closures over ONE definition instance (BuildParams captures `def`), so a swap must
	// invalidate that cache — otherwise the panel keeps displaying and EDITING the orphaned old def
	// while the player drives a car built from a fresh definition instance (tester bug: redline dial
	// had no effect after a Tab/menu car switch). The setter drops the cached params + tuning state so
	// the next access rebuilds against the new car's own (untuned) definition.
	VehicleController _car;
	public VehicleController Car
	{
		get => _car;
		set
		{
			if ( ReferenceEquals( _car, value ) )
				return;
			_car = value;
			OnCarChanged();
		}
	}

	void OnCarChanged()
	{
		_params = null;   // force a rebuild of the param closures against the new def
		_carPresets = null;  // presets are per-car — re-scope the list to the active car
		_gripScale = 1f;  // the new car is untuned — grip multiplier resets to 1×
		var def = _car?.Definition;
		if ( def is not null )
		{
			_baseLat = def.LateralCurve;
			_baseLong = def.LongitudinalCurve;
		}
		StateHasChanged();
	}

	static readonly string[] Groups = { "engine", "tires", "susp", "assists" };
	string _tab = "engine";

	class Param
	{
		public string Name;
		public string Group = "engine";
		public string Suffix = "";
		public string Fmt = "F0";
		public float Step, Min, Max;
		public float Baseline;
		public Func<float> Get;
		public Action<float> Set;
	}

	List<Param> _params;
	List<Param> Params => _params ??= BuildParams();
	IEnumerable<Param> ActiveParams => Params.Where( p => p.Group == _tab );

	string CarName => Car?.Definition?.Name ?? "Car";

	TireCurve _baseLat, _baseLong;
	float _gripScale = 1f;

	// Shift points are absolute per-car constants in the definition, but the Redline dial must carry
	// them so lowering redline below ShiftUpRpm doesn't limiter-stick the car (never upshifts). Capture
	// each shift point's fraction of redline when params are (re)built for the car, then scale on Set.
	float _shiftUpFrac = 0.92f, _shiftDownFrac = 0.35f;

	// ---- player tune presets (Save / Load / Delete over the current dial set) ----
	// Presets are per-car and persisted to FileSystem.Data (TunePresetStore), so they survive restart.
	// The list is cached and re-scoped to the active car in OnCarChanged; _presetsRev bumps the
	// BuildHash on any add/delete so the list re-renders even when the car is sitting still (the panel
	// only auto-rebuilds while suspension load changes). Auto-named ("<car> tune N"): a live text field
	// is impractical here because this panel rebuilds every frame off live load and would drop focus.
	IReadOnlyList<TunePreset> _carPresets;
	IReadOnlyList<TunePreset> CarPresets => _carPresets ??= TunePresetStore.ForCar( CarSwitcher.CurrentId( Car ) );
	int _presetsRev;

	void RefreshPresets()
	{
		_carPresets = null;
		_presetsRev++;
		StateHasChanged();
	}

	void SavePreset()
	{
		if ( !Car.IsValid() )
			return;

		var def = Car.Definition;
		string carId = CarSwitcher.CurrentId( Car );
		var preset = new TunePreset
		{
			CarId = carId,
			Name = TunePresetStore.NextName( carId ),

			PeakTorque = def.PeakTorque,
			FinalDrive = def.FinalDrive,
			LaunchBoost = def.LaunchBoost,
			EngineBrakeTorque = def.EngineBrakeTorque,
			RedlineRpm = def.RedlineRpm,
			ShiftUpRpm = def.ShiftUpRpm,     // coupled to redline — stored verbatim
			ShiftDownRpm = def.ShiftDownRpm, // coupled to redline — stored verbatim

			GripScale = _gripScale,          // the multiplier, not the raw curve points
			HandbrakeGripScale = def.HandbrakeGripScale,
			BrakeTorque = def.BrakeTorque,
			BrakeAssist = def.BrakeAssist,
			HandbrakeTorque = def.HandbrakeTorque,

			SpringRate = def.SpringRate,
			DamperRate = def.DamperRate,
			Mass = def.Mass,
			GravityScale = GravityScale(),   // scene property, captured for parity

			SteerRateScale = def.SteerRateScale,
			MaxSteerAngle = def.MaxSteerAngle,
			HighSpeedSteerAngle = def.HighSpeedSteerAngle,
			ReverseSpeedCap = def.ReverseSpeedCap,
			SpinRecoveryAssist = def.SpinRecoveryAssist,
		};

		TunePresetStore.Add( preset );
		RefreshPresets();
	}

	void LoadPreset( TunePreset preset )
	{
		if ( !Car.IsValid() || preset is null )
			return;

		// per-car guard: never apply a preset saved for a different car (defensive — the UI only ever
		// lists the active car's presets, but a stale click or future importable file could mismatch).
		if ( !string.Equals( preset.CarId, CarSwitcher.CurrentId( Car ), StringComparison.OrdinalIgnoreCase ) )
		{
			Log.Warning( $"[vp] tune preset '{preset.Name}' is for '{preset.CarId}', active car is "
				+ $"'{CarSwitcher.CurrentId( Car )}' — not applying" );
			return;
		}

		var def = Car.Definition;

		def.PeakTorque = preset.PeakTorque;
		def.FinalDrive = preset.FinalDrive;
		def.LaunchBoost = preset.LaunchBoost;
		def.EngineBrakeTorque = preset.EngineBrakeTorque;
		// Redline + its coupled shift points: restore all three verbatim (identical to save time), then
		// re-derive the fractions so a later Redline dial edit scales from THIS loaded baseline.
		def.RedlineRpm = preset.RedlineRpm;
		def.ShiftUpRpm = preset.ShiftUpRpm;
		def.ShiftDownRpm = preset.ShiftDownRpm;
		if ( preset.RedlineRpm > 0f )
		{
			_shiftUpFrac = preset.ShiftUpRpm / preset.RedlineRpm;
			_shiftDownFrac = preset.ShiftDownRpm / preset.RedlineRpm;
		}

		def.HandbrakeGripScale = preset.HandbrakeGripScale;
		def.BrakeTorque = preset.BrakeTorque;
		def.BrakeAssist = preset.BrakeAssist;
		def.HandbrakeTorque = preset.HandbrakeTorque;

		def.SpringRate = preset.SpringRate;
		def.DamperRate = preset.DamperRate;
		def.Mass = preset.Mass;

		def.SteerRateScale = preset.SteerRateScale;
		def.MaxSteerAngle = preset.MaxSteerAngle;
		def.HighSpeedSteerAngle = preset.HighSpeedSteerAngle;
		def.ReverseSpeedCap = preset.ReverseSpeedCap;
		def.SpinRecoveryAssist = preset.SpinRecoveryAssist;

		// Grip: re-apply the scale through SetGrip so the tire curves are re-derived from the PRISTINE
		// baseline (_baseLat/_baseLong), never from an already-scaled state. SetGrip also runs ApplyWheels,
		// which pushes the new SpringRate/DamperRate/curves onto the live wheels.
		SetGrip( preset.GripScale );
		// Mass onto the live rigidbody + per-wheel static loads.
		ApplyChassis();
		// Gravity is a scene property — re-apply for parity with the saved state.
		SetGravity( preset.GravityScale );

		// The loaded state is now the reference the dirty markers diff against.
		if ( _params is not null )
			foreach ( var p in _params )
				p.Baseline = p.Get();

		StateHasChanged();
	}

	void DeletePreset( TunePreset preset )
	{
		TunePresetStore.Delete( preset );
		RefreshPresets();
	}

	// Open the rename modal for this preset. The text field lives in a SEPARATE panel
	// (TuneRenameOverlay) precisely because THIS panel rebuilds every frame off live suspension load and
	// would drop a focused TextEntry each frame — see TuneRenameState for the full rationale. The commit
	// mutates the preset in place; our list re-renders via _presetsRev-style BuildHash folding of
	// TuneRenameState.Revision (below) so the new name shows even with the car standing still.
	void BeginRename( TunePreset preset ) => TuneRenameState.Begin( preset );

	void SetTab( string t ) { _tab = t; StateHasChanged(); }

	// "?" header button: open/close the dial-explainer overlay. The overlay is a SEPARATE PanelComponent
	// (TuneHelpOverlay) reading UiState.TuneHelpOpen, so this flag flip re-renders THAT panel on its own
	// constant BuildHash — nothing here rebuilds mid-view. Clicking "?" again toggles it closed.
	void ToggleTuneHelp() => UiState.TuneHelpOpen = !UiState.TuneHelpOpen;

	float Frac( Param p )
	{
		if ( p.Max <= p.Min ) return 0f;
		return Math.Clamp( (p.Get() - p.Min) / (p.Max - p.Min), 0f, 1f );
	}

	bool IsDirty( Param p ) => MathF.Abs( p.Get() - p.Baseline ) > p.Step * 0.25f;
	int DirtyCount => Params.Count( IsDirty );
	string DirtyText => DirtyCount == 0
		? "matches saved definition"
		: DirtyCount == 1 ? "1 dial differs from saved definition"
		: $"{DirtyCount} dials differ from saved definition";

	// ---- suspension rig bindings (live) ----
	static readonly string[] WheelLabels = { "FL", "FR", "RL", "RR" };
	string WheelLabel( int i ) => i < WheelLabels.Length ? WheelLabels[i] : $"W{i}";
	float LoadFrac( VehicleWheel w ) => w.StaticLoad > 0f ? Math.Clamp( w.Load / (w.StaticLoad * 2f), 0f, 1f ) : 0f;
	string LoadKg( VehicleWheel w ) => (w.Load / 9.81f).ToString( "F0" );
	bool IsBottomed( VehicleWheel w ) => w.IsGrounded && w.SuspensionLength <= w.SuspensionTravel * 0.03f;

	protected override void OnUpdate()
	{
		if ( Input.Pressed( "Debug" ) )
		{
			UiState.TuningOpen = !UiState.TuningOpen;
			Mouse.Visibility = UiState.TuningOpen ? MouseVisibility.Visible : MouseVisibility.Hidden;
			// The dial-help overlay only makes sense over an open panel — closing Tuning closes it too,
			// so the explainer can't linger on screen with no dials behind it.
			if ( !UiState.TuningOpen )
				UiState.TuneHelpOpen = false;
		}

		// something else (or the engine) may reset cursor state every frame
		if ( UiState.TuningOpen )
			Mouse.Visibility = MouseVisibility.Visible;
	}

	void Adjust( Param p, int clicks )
	{
		float v = Math.Clamp( p.Get() + clicks * p.Step, p.Min, p.Max );
		p.Set( v );
		StateHasChanged();
	}

	// Click-to-jump + drag-to-scrub on the dial track. Mirrors the engine's own SliderControl mechanic
	// (no native drag helper for a hand-rolled RenderFragment). `jump` is true on onmousedown (a bare
	// click lands as jump-to-position) and false on onmousemove (scrub only while the track is actively
	// pressed). Value = mouse LOCAL x over the track width, snapped to the param's Step, pushed through
	// the SAME p.Set path as Adjust so Redline's ScaleShiftPoints, Grip's SetGrip etc. still apply.
	// (KB gotcha g-ui-draggable-slider-tracks-click-jump-drag; verified drag pattern.)
	void TrackScrub( Param p, Sandbox.UI.PanelEvent ev, bool jump )
	{
		if ( ev is not Sandbox.UI.MousePanelEvent e ) return;

		var track = e.This;
		if ( track is null ) return;

		// onmousemove fires whether or not the button is held — only scrub while the track owns the press.
		if ( !jump && !track.PseudoClass.HasFlag( Sandbox.UI.PseudoClass.Active ) ) return;

		float w = track.Box.Rect.Width;
		if ( w <= 0f ) return;

		float frac = Math.Clamp( e.LocalPosition.x / w, 0f, 1f );
		float v = p.Min + frac * (p.Max - p.Min);
		if ( p.Step > 0f ) v = MathF.Round( v / p.Step ) * p.Step;   // snap to the param's step (values stay clean)
		v = Math.Clamp( v, p.Min, p.Max );
		p.Set( v );
		StateHasChanged();
	}

	void ResetAll()
	{
		// Reset to the CURRENT car's pristine preset, not always the hatch — otherwise resetting while
		// driving a coupe/kart/pickup wrote hatch physics onto it. Presets are `=> new()` so each access
		// is a fresh untouched instance (StationCarRegistry.ResolveCar via the canonical id table).
		var fresh = VehiclePilot.ResolveCar( CarSwitcher.CurrentId( Car ) );
		var def = Car.Definition;

		def.PeakTorque = fresh.PeakTorque;
		def.LaunchBoost = fresh.LaunchBoost;
		def.FinalDrive = fresh.FinalDrive;
		def.EngineBrakeTorque = fresh.EngineBrakeTorque;
		def.RedlineRpm = fresh.RedlineRpm;
		// Redline dial now carries the shift points (ScaleShiftPoints); restore them too, and re-capture
		// the pristine fractions so a subsequent Redline edit scales from the reset baseline.
		def.ShiftUpRpm = fresh.ShiftUpRpm;
		def.ShiftDownRpm = fresh.ShiftDownRpm;
		if ( fresh.RedlineRpm > 0f )
		{
			_shiftUpFrac = fresh.ShiftUpRpm / fresh.RedlineRpm;
			_shiftDownFrac = fresh.ShiftDownRpm / fresh.RedlineRpm;
		}
		def.SteerRateScale = fresh.SteerRateScale;
		def.MaxSteerAngle = fresh.MaxSteerAngle;
		def.HighSpeedSteerAngle = fresh.HighSpeedSteerAngle;
		def.ReverseSpeedCap = fresh.ReverseSpeedCap;
		def.SpinRecoveryAssist = fresh.SpinRecoveryAssist;
		def.BrakeTorque = fresh.BrakeTorque;
		def.BrakeAssist = fresh.BrakeAssist;
		def.HandbrakeGripScale = fresh.HandbrakeGripScale;
		def.HandbrakeTorque = fresh.HandbrakeTorque;
		def.SpringRate = fresh.SpringRate;
		def.DamperRate = fresh.DamperRate;
		def.Mass = fresh.Mass;
		def.LateralCurve = fresh.LateralCurve;
		def.LongitudinalCurve = fresh.LongitudinalCurve;
		_gripScale = 1f;
		_baseLat = fresh.LateralCurve;
		_baseLong = fresh.LongitudinalCurve;

		ApplyChassis();
		ApplyWheels();
		SetGravity( 1.1f );

		if ( _params is not null )
			foreach ( var p in _params )
				p.Baseline = p.Get();

		StateHasChanged();
	}

	// push def values onto the live rigidbody + wheels (factory copies them at spawn)
	void ApplyChassis()
	{
		var def = Car.Definition;
		var body = Car.Components.Get<Rigidbody>();
		if ( body.IsValid() )
			body.MassOverride = def.Mass;

		foreach ( var w in Car.Wheels )
			w.StaticLoad = def.Mass * 9.81f / 4f;
	}

	void ApplyWheels()
	{
		var def = Car.Definition;
		foreach ( var w in Car.Wheels )
		{
			w.SpringRate = def.SpringRate;
			w.DamperRate = def.DamperRate;
			w.LateralCurve = def.LateralCurve;
			w.LongitudinalCurve = def.LongitudinalCurve;
		}
	}

	static TireCurve Scaled( TireCurve c, float k ) =>
		new( c.PeakSlip, c.PeakGrip * k, c.TailSlip, c.TailGrip * k );

	void SetGrip( float k )
	{
		_gripScale = k;
		var def = Car.Definition;
		def.LateralCurve = Scaled( _baseLat, k );
		def.LongitudinalCurve = Scaled( _baseLong, k );
		ApplyWheels();
	}

	float GravityScale() => Scene.PhysicsWorld.Gravity.Length / (9.81f * Units.MetersToUnits);
	void SetGravity( float k ) => Scene.PhysicsWorld.Gravity = Vector3.Down * 9.81f * k * Units.MetersToUnits;

	// Preserve each shift point's original fraction of redline, and never let ShiftUpRpm reach the
	// limiter (clamp to 95% of redline) or the car can't upshift out of the rev ceiling.
	void ScaleShiftPoints( CarDefinition def, float redline )
	{
		def.ShiftUpRpm = MathF.Min( redline * _shiftUpFrac, redline * 0.95f );
		def.ShiftDownRpm = redline * _shiftDownFrac;
	}

	List<Param> BuildParams()
	{
		var def = Car.Definition;
		_baseLat = def.LateralCurve;
		_baseLong = def.LongitudinalCurve;

		// capture the pristine shift-point fractions for THIS car (def is untouched at build time)
		if ( def.RedlineRpm > 0f )
		{
			_shiftUpFrac = def.ShiftUpRpm / def.RedlineRpm;
			_shiftDownFrac = def.ShiftDownRpm / def.RedlineRpm;
		}

		var list = new List<Param>
		{
			// --- engine ---
			new() { Group = "engine", Name = "Engine torque", Suffix = " Nm", Step = 20f, Min = 100f, Max = 900f,
				Get = () => def.PeakTorque, Set = v => def.PeakTorque = v },
			new() { Group = "engine", Name = "Final drive", Fmt = "F1", Step = 0.1f, Min = 2.5f, Max = 6.5f,
				Get = () => def.FinalDrive, Set = v => def.FinalDrive = v },
			new() { Group = "engine", Name = "Launch boost", Fmt = "F1", Suffix = "×", Step = 0.1f, Min = 1f, Max = 4f,
				Get = () => def.LaunchBoost, Set = v => def.LaunchBoost = v },
			new() { Group = "engine", Name = "Engine brake", Suffix = " Nm", Step = 5f, Min = 0f, Max = 60f,
				Get = () => def.EngineBrakeTorque, Set = v => def.EngineBrakeTorque = v },
			new() { Group = "engine", Name = "Redline", Suffix = " rpm", Step = 100f, Min = 5000f, Max = 9500f,
				Get = () => def.RedlineRpm, Set = v => { def.RedlineRpm = v; ScaleShiftPoints( def, v ); } },

			// --- tires / brakes ---
			new() { Group = "tires", Name = "Grip", Fmt = "F2", Suffix = "×", Step = 0.05f, Min = 0.6f, Max = 2.2f,
				Get = () => _gripScale, Set = SetGrip },
			new() { Group = "tires", Name = "Drift grip (handbrake)", Fmt = "F2", Suffix = "×", Step = 0.05f, Min = 0.15f, Max = 1f,
				Get = () => def.HandbrakeGripScale, Set = v => def.HandbrakeGripScale = v },
			new() { Group = "tires", Name = "Brake torque", Suffix = " Nm", Step = 200f, Min = 1500f, Max = 8000f,
				Get = () => def.BrakeTorque, Set = v => def.BrakeTorque = v },
			new() { Group = "tires", Name = "Brake assist", Fmt = "F1", Suffix = " m/s²", Step = 0.5f, Min = 0f, Max = 12f,
				Get = () => def.BrakeAssist, Set = v => def.BrakeAssist = v },
			new() { Group = "tires", Name = "Handbrake torque", Suffix = " Nm", Step = 250f, Min = 1000f, Max = 9000f,
				Get = () => def.HandbrakeTorque, Set = v => def.HandbrakeTorque = v },

			// --- suspension ---
			new() { Group = "susp", Name = "Spring rate", Suffix = " N/m", Step = 2000f, Min = 15000f, Max = 60000f,
				Get = () => def.SpringRate, Set = v => { def.SpringRate = v; ApplyWheels(); } },
			new() { Group = "susp", Name = "Damper rate", Step = 200f, Min = 800f, Max = 6000f,
				Get = () => def.DamperRate, Set = v => { def.DamperRate = v; ApplyWheels(); } },
			new() { Group = "susp", Name = "Mass", Suffix = " kg", Step = 50f, Min = 400f, Max = 2500f,
				Get = () => def.Mass, Set = v => { def.Mass = v; ApplyChassis(); } },
			new() { Group = "susp", Name = "Gravity", Fmt = "F2", Suffix = " g", Step = 0.05f, Min = 0.6f, Max = 2.2f,
				Get = GravityScale, Set = SetGravity },

			// --- steering / assists ---
			new() { Group = "assists", Name = "Steer speed", Fmt = "F1", Suffix = "×", Step = 0.1f, Min = 0.5f, Max = 3f,
				Get = () => def.SteerRateScale, Set = v => def.SteerRateScale = v },
			new() { Group = "assists", Name = "Steer lock (low speed)", Suffix = "°", Step = 1f, Min = 20f, Max = 45f,
				Get = () => def.MaxSteerAngle, Set = v => def.MaxSteerAngle = v },
			new() { Group = "assists", Name = "Steer lock (high speed)", Suffix = "°", Step = 1f, Min = 6f, Max = 20f,
				Get = () => def.HighSpeedSteerAngle, Set = v => def.HighSpeedSteerAngle = v },
			new() { Group = "assists", Name = "Reverse speed cap", Fmt = "F1", Suffix = " m/s", Step = 0.5f, Min = 3f, Max = 15f,
				Get = () => def.ReverseSpeedCap, Set = v => def.ReverseSpeedCap = v },
			new() { Group = "assists", Name = "Spin recovery", Fmt = "F1", Suffix = " m/s²", Step = 0.5f, Min = 0f, Max = 12f,
				Get = () => def.SpinRecoveryAssist, Set = v => def.SpinRecoveryAssist = v },
		};

		foreach ( var p in list )
			p.Baseline = p.Get();

		return list;
	}

	protected override int BuildHash()
	{
		if ( !UiState.TuningOpen )
			return 0;

		var h = new HashCode();
		h.Add( _tab );
		h.Add( DirtyCount );
		h.Add( _presetsRev ); // re-render the presets list on save/delete even when the car is still
		h.Add( TuneRenameState.Revision ); // ...and on a committed rename (the preset's Name changed in place)
		if ( _params is not null )
			foreach ( var p in _params )
				h.Add( (int)(p.Get() * 100f) );
		if ( Car.IsValid() )
			foreach ( var w in Car.Wheels )
			{
				h.Add( (int)(w.Load * 0.1f) );
				h.Add( (int)(w.SuspensionLength * 1000f) );
			}
		return h.ToHashCode();
	}
}
@using System;
@using Sandbox;
@using Sandbox.UI;
@inherits PanelComponent

<root>
    <div class="crosshair" style="
		position: absolute;
		left: @( IsPercentage ? $"{Position.x}%"  : Position.x );
		top: @( IsPercentage ? $"{Position.y}%" : Position.y );
		transform: translate(-50%, -50%);
	">
		<div class="center-dot-border-wrapper" style="
			display: @(CenterDot ? "flex" : "none");
			border: @(Outline ? $"{OutlineThickness}px solid rgba(0, 0, 0, {OutlineOpacity})" : "none");
			z-index: 100;
		">
			<div class="center-dot" style="
				background-color: rgba(@(Color.r * 255), @(Color.g * 255), @(Color.b * 255), @(CenterDotOpacity));
				padding: @(CenterDotThickness)px;
			"></div>
		</div>
		<div class="inner-top-border-wrapper" style="
			position: absolute;
    		top: -@(InnerLinesOffset)px;
    		left: 50%;
    		transform: translateX(-50%);
			display:@(ShowInnerLines ? "flex" : "none");
			border: @(Outline ? $"{OutlineThickness}px solid rgba(0, 0, 0, {OutlineOpacity})" : "none");
		">
			<div class="inner-line top" style="
				background-color: rgba(@(Color.r * 255), @(Color.g * 255), @(Color.b * 255), @(InnerLineOpacity));
				padding-left: @(InnerLineThickness)px;
    			padding-top: @(InnerLineLenght)px;
			"></div>
		</div>
		<div class="inner-bottom-border-wrapper" style="
			position: absolute;
    		bottom: -@(InnerLinesOffset)px;
    		left: 50%;
    		transform: translateX(-50%);
			display:@(ShowInnerLines ? "flex" : "none");
			border: @(Outline ? $"{OutlineThickness}px solid rgba(0, 0, 0, {OutlineOpacity})" : "none");
		">
			<div class="inner-line bottom" style="
				background-color: rgba(@(Color.r * 255), @(Color.g * 255), @(Color.b * 255), @(InnerLineOpacity));
				padding-left: @(InnerLineThickness)px;
    			padding-bottom: @(InnerLineLenght)px;
			"></div>
		</div>
		<div class="inner-left-border-wrapper" style="
			position: absolute;
			left: -@(InnerLinesOffset)px;
			top: 50%;
			transform: translateY(-50%);
			display:@(ShowInnerLines ? "flex" : "none");
			border: @(Outline ? $"{OutlineThickness}px solid rgba(0, 0, 0, {OutlineOpacity})" : "none");
		">
			<div class="inner-line left" style="
				background-color: rgba(@(Color.r * 255), @(Color.g * 255), @(Color.b * 255), @(InnerLineOpacity));
				padding-top: @(InnerLineThickness)px;
				padding-left: @(InnerLineLenght)px;
			"></div>
		</div>
		<div class="inner-right-border-wrapper" style="
			position: absolute;
			right: -@(InnerLinesOffset)px;
			top: 50%;
			transform: translateY(-50%);
			display: @(ShowInnerLines ? "flex" : "none");
			border: @(Outline ? $"{OutlineThickness}px solid rgba(0, 0, 0, {OutlineOpacity})" : "none");
		">
			<div class="inner-line right" style="
				background-color: rgba(@(Color.r * 255), @(Color.g * 255), @(Color.b * 255), @(InnerLineOpacity));
				padding-top: @(InnerLineThickness)px;
				padding-right: @(InnerLineLenght)px;
			"></div>
		</div>
		<div class="outer-top-border-wrapper" style="
			position: absolute;
			top: -@(OuterLineOffset)px;
			left: 50%;
			transform: translateX(-50%);
			display: @(ShowOuterLines ? "flex" : "none");
			border: @(Outline ? $"{OutlineThickness}px solid rgba(0, 0, 0, {OutlineOpacity})" : "none");
		">
			<div class="outer-line top" style="
				background-color: rgba(@(Color.r * 255), @(Color.g * 255), @(Color.b * 255), @(OuterLineOpacity));
				padding-left: @(OuterLineThickness)px;
				padding-top: @(OuterLineLenght)px;
			"></div>
		</div>
		<div class="outer-bottom-border-wrapper" style="
			position: absolute;
			bottom: -@(OuterLineOffset)px;
			left: 50%;
			transform: translateX(-50%);
			display: @(ShowOuterLines ? "flex" : "none");
			border: @(Outline ? $"{OutlineThickness}px solid rgba(0, 0, 0, {OutlineOpacity})" : "none");
		">
			<div class="outer-line bottom" style="
				background-color: rgba(@(Color.r * 255), @(Color.g * 255), @(Color.b * 255), @(OuterLineOpacity));
				padding-left: @(OuterLineThickness)px;
				padding-bottom: @(OuterLineLenght)px;
			"></div>
		</div>
		<div class="outer-left-border-wrapper" style="
			position: absolute;
			left: -@(OuterLineOffset)px;
			top: 50%;
			transform: translateY(-50%);
			display: @(ShowOuterLines ? "flex" : "none");
			border: @(Outline ? $"{OutlineThickness}px solid rgba(0, 0, 0, {OutlineOpacity})" : "none");
		">
			<div class="outer-line left" style="
				background-color: rgba(@(Color.r * 255), @(Color.g * 255), @(Color.b * 255), @(OuterLineOpacity));
				padding-top: @(OuterLineThickness)px;
				padding-left: @(OuterLineLenght)px;
			"></div>
		</div>
		<div class="outer-right-border-wrapper" style="
			position: absolute;
			right: -@(OuterLineOffset)px;
			top: 50%;
			transform: translateY(-50%);
			display: @(ShowOuterLines ? "flex" : "none");
			border: @(Outline ? $"{OutlineThickness}px solid rgba(0, 0, 0, {OutlineOpacity})" : "none");
		">
			<div class="outer-line right" style="
				background-color: rgba(@(Color.r * 255), @(Color.g * 255), @(Color.b * 255), @(OuterLineOpacity));
				padding-top: @(OuterLineThickness)px;
				padding-right: @(OuterLineLenght)px;
			"></div>
		</div>
    </div>
</root>

@code
{
	// Position properties
	[Property] 
	[Category("Position")] public bool IsPercentage { get; set; } = true;
	[Property]
	[Category("Position")] public Vector2 Position { get; set; } = new Vector2(50, 50);

	// Crosshair properties
	[Property]
	[Category("Crosshair")] public Color Color { get; set; } = Color.FromRgb(0x2EFF00);
	[Property]
	[Category("Crosshair")] public bool Outline { get; set; } = true;
	[Property]
	[Range(0f, 1f, 0.001f)]
	[Category("Crosshair")] public float OutlineOpacity { get; set; } = 1f;
	[Property]
	[Range(1, 10, 1)]
	[Category("Crosshair")] public int OutlineThickness { get; set; } = 2;
	[Property]
	[Category("Crosshair")] public bool CenterDot { get; set; } = true;
	[Property]
	[Range(0f, 1f, 0.001f)]
	[Category("Crosshair")] public float CenterDotOpacity { get; set; } = 1f;
	[Property]
	[Range(1, 10, 1)]
	[Category("Crosshair")] public int CenterDotThickness { get; set; } = 1;

	// Inner Lines properties
	[Property]
	[Category("Inner Lines")] public bool ShowInnerLines { get; set; } = true;
	[Property]
	[Range(0f, 1f, 0.001f)]
	[Category("Inner Lines")] public float InnerLineOpacity { get; set; } = 1f;
	[Property]
	[Range(0, 20, 1)]
	[Category("Inner Lines")] public int InnerLineLenght { get; set; } = 10;
	[Property]
	[Range(0, 10, 1)]
	[Category("Inner Lines")] public int InnerLineThickness { get; set; } = 1;
	[Property]
	[Range(0, 20, 1)]
	[Category("Inner Lines")] public int InnerLinesOffset { get; set; } = 2;

	// Outer Lines properties
	[Property]
	[Category("Outer Lines")] public bool ShowOuterLines { get; set; } = false;
	[Property]
	[Range(0f, 1f, 0.001f)]
	[Category("Outer Lines")] public float OuterLineOpacity { get; set; } = 0.5f;
	[Property]
	[Range(0, 20, 1)]
	[Category("Outer Lines")] public int OuterLineLenght { get; set; }
	[Property]
	[Range(0, 10, 1)]
	[Category("Outer Lines")] public int OuterLineThickness { get; set; }
	[Property]
	[Range(0, 20, 1)]
	[Category("Outer Lines")] public int OuterLineOffset { get; set; }

	// Code properties
	[Property]
	[Category("Code")] public string Code { get; set; }

	// Member variables
	private string _cachedCode = null;

	public string EncodeCrosshairParameters()
	{
		// Convert boolean values to 1 or 0
		string EncodeBool(bool value) => value ? "1" : "0";
		// Convert float values to a shortened string representation (up to 2 decimal places)
		string EncodeFloat(float value) => Math.Round(value, 2).ToString("0.##");
		// Convert integers directly to string
		string EncodeInt(int value) => value.ToString();

		// Concatenate all parameters into a shortened format
		var parameters = $"{EncodeBool(IsPercentage)}," +
							$"{EncodeFloat(Position.x)},{EncodeFloat(Position.y)}," +
							$"{EncodeFloat(Color.r)},{EncodeFloat(Color.g)},{EncodeFloat(Color.b)}," +
							$"{EncodeBool(Outline)},{EncodeFloat(OutlineOpacity)},{EncodeInt(OutlineThickness)}," +
							$"{EncodeBool(CenterDot)},{EncodeFloat(CenterDotOpacity)},{EncodeInt(CenterDotThickness)}," +
							$"{EncodeBool(ShowInnerLines)},{EncodeFloat(InnerLineOpacity)},{EncodeInt(InnerLineLenght)}," +
							$"{EncodeInt(InnerLineThickness)},{EncodeInt(InnerLinesOffset)}," +
							$"{EncodeBool(ShowOuterLines)},{EncodeFloat(OuterLineOpacity)},{EncodeInt(OuterLineLenght)}," +
							$"{EncodeInt(OuterLineThickness)},{EncodeInt(OuterLineOffset)}";
		return parameters;
	}

	public void DecodeCrosshairParameters(string encodedString)
	{
		var parameters = encodedString.Split(',');

		// Convert "1" or "0" back to boolean
		bool DecodeBool(string value) => value == "1";
		// Convert string back to float
		float DecodeFloat(string value) => float.Parse(value);
		// Convert string back to int
		int DecodeInt(string value) => int.Parse(value);

		// Assign the decoded values back to the properties
		IsPercentage = DecodeBool(parameters[0]);
		Position = new Vector2(DecodeFloat(parameters[1]), DecodeFloat(parameters[2]));
		Color = new Color(DecodeFloat(parameters[3]), DecodeFloat(parameters[4]), DecodeFloat(parameters[5]));
		Outline = DecodeBool(parameters[6]);
		OutlineOpacity = DecodeFloat(parameters[7]);
		OutlineThickness = DecodeInt(parameters[8]);
		CenterDot = DecodeBool(parameters[9]);
		CenterDotOpacity = DecodeFloat(parameters[10]);
		CenterDotThickness = DecodeInt(parameters[11]);
		ShowInnerLines = DecodeBool(parameters[12]);
		InnerLineOpacity = DecodeFloat(parameters[13]);
		InnerLineLenght = DecodeInt(parameters[14]);
		InnerLineThickness = DecodeInt(parameters[15]);
		InnerLinesOffset = DecodeInt(parameters[16]);
		ShowOuterLines = DecodeBool(parameters[17]);
		OuterLineOpacity = DecodeFloat(parameters[18]);
		OuterLineLenght = DecodeInt(parameters[19]);
		OuterLineThickness = DecodeInt(parameters[20]);
		OuterLineOffset = DecodeInt(parameters[21]);
	}

	protected override void OnStart()
	{
		if (Code == null)
		{
			Code = EncodeCrosshairParameters();
		}
		DecodeCrosshairParameters(Code);
		_cachedCode = Code;
	}

	protected override void OnUpdate()
	{
		if (Code != _cachedCode)
		{
			DecodeCrosshairParameters(Code);
		}
		Code = EncodeCrosshairParameters();
		_cachedCode = Code;
	}

	/// <summary>
	/// the hash determines if the system should be rebuilt. If it changes, it will be rebuilt
	/// </summary>
	protected override int BuildHash()
	{
		var positionHash = System.HashCode.Combine( 
			IsPercentage,
			Position
		);

		var crosshairHash = System.HashCode.Combine( 
			Color,
			Outline,
			OutlineOpacity,
			OutlineThickness,
			CenterDot,
			CenterDotOpacity,
			CenterDotThickness
		);

		var innerLinesHash = System.HashCode.Combine(
			ShowInnerLines,
			InnerLineOpacity,
			InnerLineLenght,
			InnerLineThickness,
			InnerLinesOffset
		);

		var outerLinesHash = System.HashCode.Combine(
			ShowOuterLines,
			OuterLineOpacity,
			OuterLineLenght,
			OuterLineThickness,
			OuterLineOffset
		);

		var codeHash = Code.GetHashCode();
		
		return System.HashCode.Combine(
			positionHash,
			crosshairHash,
			innerLinesHash,
			outerLinesHash,
			codeHash
		);
	}
}
@inherits PanelComponent
<root> <div class="ynal2p"> @t </div> </root>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

		StateHasChanged();
	}

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

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

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

		if ( _fadeout )
			baseClass += " fadeout";

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

		return baseClass;
	}

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

@code
{
public float o {get;set;} = 0;
protected override int BuildHash() => System.HashCode.Combine(o);
}
@namespace Sandbox
@using Sandbox;
@using Sandbox.UI;
@using System;
@inherits Panel
@attribute [StyleSheet("PerkChoicePanel.razor.scss")]

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

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

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

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

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

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

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

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

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

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

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

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

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

</root>

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

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

		return player;
	}

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

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

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

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

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

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

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

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

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

		if ( !player.CanInteractWithChoices )
			return;

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

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

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

		player.UseReroll();
	}

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

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

		player.ToggleBanish();
	}

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

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

		player.RefreshAfterChoosingPerk();

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

	public bool IsChoice { get; set; }

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

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

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

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

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

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

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

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

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

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

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

					e.StopPropagation();
				}
			}
		}
	}

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

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

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

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

					e.StopPropagation();
				}
			}
		}

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

@if(PerkItem.ShouldHidePanel)
{
	return;
}

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

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

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

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

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

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

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

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

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

		e.StopPropagation();
	}

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

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

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

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

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

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

		return;
	}

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

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

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

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

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

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

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

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

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

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

				var i = 0;

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

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

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

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

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

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

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

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

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

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

	private bool _isLoadingPerks;
	private long _loadedSteamId;

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

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

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

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

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

		_isLoadingPerks = true;

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

		_isLoadingPerks = false;

		_perkPickPercents.Clear();

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

			if(attrib.Disabled)
				continue;

			if(attrib.Curse)
				continue;

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

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

				_perkPickPercents[type] = percent;
			}
		}
		*/

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

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

		// }

		StateHasChanged();

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

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

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

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

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

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

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

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

	public static float GetFontSizeForNumber( float number )
	{
		if(number >= 1000000000f) return 10f;
		if(number >= 100000000f) return 11f;
		if(number >= 10000000f) return 12f;
		if(number >= 1000000f) return 14f;
		return 16f;
	}
}
@namespace Sandbox
@using Sandbox;
@using Sandbox.UI;
@using System;
@inherits Panel
@attribute [StyleSheet("PlayerTooltip.razor.scss")]

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

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

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

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

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

	public string Name { get; set; }

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

		var mousePos = Mouse.Position;

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

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

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


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

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


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

	string Text1;
	string Text2;

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

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

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

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

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

	protected override int BuildHash()
	{
		return System.HashCode.Combine( Text1, Text2, Color );
	}
}
@using Sandbox;
@using Sandbox.UI;
@inherits Panel
@namespace Sandbox

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


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

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

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

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

<root>

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

</root>

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

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

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

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

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

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

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

        Instance?.StateHasChanged();
    }

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

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

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

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

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

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

	protected override int BuildHash() => HashCode.Combine( Current );
}
@using Sandbox;
@using Sandbox.UI;
@inherits Panel
@namespace Sandbox
@attribute [SpawnMenuHost.SpawnMenuMode]
@attribute [Icon( "🎨" )]
@attribute [Title( "#spawnmenu.mode.effects" )]
@attribute [Order( -75 )]

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

<root>

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

</root>

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

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

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

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

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

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

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

        PlayerLoadout.SaveLoadoutPreset( name, json );
    }

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

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

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

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

        PlayerLoadout.SaveLoadoutPreset( name, json );
    }

    PlayerLoadout GetLoadout()
    {
        return Inventory?.GetComponent<PlayerLoadout>();
    }
}
@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
@implements IUtilityTab
@attribute [Icon("🌍")]
@attribute [Title("#spawnmenu.tab.utilities")]
@attribute [Order(0)]

<root class="tab">
    <div class="left">
        <VerticalMenu class="menuinner">
            <Options>
                @foreach ( var group in GetVisiblePages().GroupBy( x => x.Group ).OrderBy( x => x.Min( t => t.Order ) ) )
                {
                    @if ( !string.IsNullOrWhiteSpace( group.Key ) )
                    {
                        <h2>@group.Key</h2>
                    }

                    @foreach ( var type in group.OrderBy( x => x.Order ).ThenBy( x => x.Title ) )
                    {
                        <MenuOption Text="@type.Title" Icon="@type.Icon"
                            class=@( SelectedPageType == type ? "active" : "" )
                            @onclick="@(() => OnSelect( type ))">
                        </MenuOption>
                    }
                }
            </Options>
        </VerticalMenu>
    </div>

    <div class="body menuinner" @ref="PageContainer"></div>
</root>

@code
{
    TypeDescription SelectedPageType { get; set; }
    Panel PageContainer;
    UtilityPage ActivePage;

    IEnumerable<TypeDescription> GetVisiblePages()
    {
        foreach ( var type in Game.TypeLibrary.GetTypes<UtilityPage>() )
        {
            if ( type.IsAbstract ) continue;
            var instance = type.Create<UtilityPage>();
            if ( instance is null || !instance.IsPageVisible() ) continue;
            instance.Delete();
            yield return type;
        }
    }

    void OnSelect( TypeDescription type )
    {
        SelectedPageType = type;

        ActivePage?.Delete();
        ActivePage = type.Create<UtilityPage>();
        PageContainer.AddChild( ActivePage );

        StateHasChanged();
    }
}
@using Sandbox;
@using Sandbox.UI;
@inherits PanelComponent
@namespace Sandbox

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

<root>
    <div class="vitals">
        @if ( Player.Armour > 0 )
        {
            <div class="stat armour hud-panel">
                <Image class="icon" Texture=@ArmourIcon />
                <label class="value">@(DisplayArmour)</label>
            </div>
        }
        <div class="stat health hud-panel">
            <Image class="icon" Texture=@HealthIcon />
            <label class="value">@(DisplayHealth)</label>
        </div>
    </div>

    @if ( Weapon.IsValid() && Weapon.UsesAmmo )
    {
        <div class="ammo hud-panel">
            @if ( WeaponConVars.UnlimitedAmmo )
            {
                <label class="value">∞</label>
            }
            else
            {
                <label class="value">@(Weapon.UsesClips ? Weapon.ClipContents.ToString() : (WeaponConVars.InfiniteReserves ? "∞" : Weapon.ReserveAmmo.ToString()))</label>
                @if ( Weapon.UsesClips )
                {
                    <label class="alternate">@(WeaponConVars.InfiniteReserves ? "∞" : Weapon.ReserveAmmo.ToString())</label>
                }
            }
            <Image class="icon" Texture=@AmmoIcon />
        </div>
    }
</root>

@code
{
    [Property] public Texture HealthIcon { get; set; }
    [Property] public Texture ArmourIcon { get; set; }
    [Property] public Texture AmmoIcon { get; set; }

    Player Player => Player.FindLocalPlayer();
    BaseWeapon Weapon => Player?.GetComponent<PlayerInventory>()?.ActiveWeapon as BaseWeapon;

    int DisplayHealth => (int)Player.Health;
    int DisplayArmour => (int)Player.Armour;

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

    protected override void OnUpdate()
    {
        Panel.SetClass( "spawnmenu-open", IsSpawnMenuOpen() );
    }

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

        return System.HashCode.Combine( Player.Health, Player.Armour, Weapon?.ClipContents, Weapon?.ReserveAmmo );
    }
}
@namespace Facepunch.UI
@inherits PanelComponent

<root>

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

</root>

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

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

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

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

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

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

    protected override int BuildHash()
    {
        return HashCode.Combine( Time.Now );
    }
}
@using Sandbox;
@using Sandbox.UI;
@inherits PanelComponent
@namespace Sandbox

<root>
	<h2>Hat Selection</h2>
	<div class="hatselector">
		<div class="image-button" onclick=@Henrietta>
			<image src="images/henriettapicture.png" />
		</div>
		<div class="image-button" onclick=@Watermelon>
			<image src="images/melonchickenpic.png" />
		</div>
		<div class="image-button" onclick=@Shooter>
			<image src="images/shooterchickenpic.png" />
		</div>
		<div class="image-button" onclick=@Tofu>
			<image src="images/tofuchickenpic.png" />
		</div>

	</div>

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

		<div class="button" onclick=@Back>
			Back
		</div>

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

		<p>Press A & D or Left and Right on a gamepad to move.</p>
		<p>Press Space or A on a gamepad to jump, double jump, and triple jump.</p>
		<p>Press Shift or B on a gamepad to dodge.</p>
	</div>
</root>
@code
{
	[Property] public SceneFile hen1 { get; set; }
	[Property] public SceneFile hen2 { get; set; }
	[Property] public SceneFile hen3 { get; set; }
	[Property] public SceneFile hen4 { get; set; }
	[Property] public SceneFile LB { get; set; }

	
	[Property, TextArea] public string MyStringValue { get; set; } = "Hat Selection";
	void Henrietta()
	{
		Scene.Load(hen1);
	}
	void Watermelon()
	{
		Scene.Load(hen2);
	}
	void Shooter()
	{
		Scene.Load(hen3);
	}
	void Tofu()
	{
		Scene.Load(hen4);
	}
	void Back()
	{
		Scene.Load(LB);
	}
	/// <summary>
	/// the hash determines if the system should be rebuilt. If it changes, it will be rebuilt
	/// </summary>
	protected override int BuildHash() => System.HashCode.Combine( MyStringValue );
}
@using Sandbox
@using Sandbox.UI
@using System.Linq
@inherits PanelComponent

<root>
    <div class="board">
        <div class="title">@CurrentPageTitle</div>
        <div class="page">
            @switch (CurrentPage)
            {
                case PageType.Controls:
                    <div class="controls-list">
                        <div class="control-row">
                            <div class="label">Move</div>
                            <div class="glyphs">
                                <Image class="glyph" [email protected]("Forward", InputGlyphSize.Large, GlyphStyle.Light) />
                                <Image class="glyph" [email protected]("Left", InputGlyphSize.Large, GlyphStyle.Light) />
                                <Image class="glyph" [email protected]("Backward", InputGlyphSize.Large, GlyphStyle.Light) />
                                <Image class="glyph" [email protected]("Right", InputGlyphSize.Large, GlyphStyle.Light) />
                            </div>
                        </div>
                        <div class="control-row">
                            <div class="label">Jump</div>
                            <div class="glyphs">
                                <Image class="glyph" [email protected]("Jump", InputGlyphSize.Large, GlyphStyle.Light) />
                            </div>
                        </div>
                        <div class="control-row">
                            <div class="label">Leap Dive</div>
                            <div class="glyphs">
                                <Image class="glyph" [email protected]("Attack1", InputGlyphSize.Large, GlyphStyle.Light) />
                            </div>
                        </div>
                    </div>
                    break;
                case PageType.Title:
                    <div class="title-page">
                        <img class="title-thumbnail" src="textures/dropout_thumbnail_910x512.png" />
                    </div>
                    break;
            }
        </div>
    </div>
</root>

@code {
    // To add a new page: add an enum value, add it to Pages, and add a switch case above.
    private enum PageType
    {
        Controls,
        Title,
    }

    private static readonly PageType[] Pages = new[]
    {
        PageType.Controls,
        PageType.Title,
    };

    /// <summary>Seconds each page is shown before cycling to the next.</summary>
    private const float PageDuration = 8f;

    private int _pageIndex = 0;
    private TimeUntil _nextPageAt = PageDuration;

    private PageType CurrentPage => Pages[_pageIndex];

    private string CurrentPageTitle => CurrentPage switch
    {
        PageType.Controls => "Controls",
        PageType.Title => "",
        _ => string.Empty,
    };

    protected override void OnUpdate()
    {
        if (Pages.Length <= 1) return;

        if ((float)_nextPageAt <= 0f)
        {
            _pageIndex = (_pageIndex + 1) % Pages.Length;
            _nextPageAt = PageDuration;
        }
    }

    protected override int BuildHash() => System.HashCode.Combine(_pageIndex, Input.UsingController);
}
@using Sandbox
@using Sandbox.UI
@using System.Linq
@inherits PanelComponent

<root>
    <div class="board">
        @if (LobbyManager.HasActiveLobbyMessage)
        {
            <div class="message-banner">@LobbyManager.LobbyMessage</div>
        }

        @if (LaunchSeconds > 0)
        {
            <div class="title">Starting in</div>
            <div class="launch-number">@LaunchSeconds</div>
        }
        else
        {
            <div class="title">Players</div>
            <div class="grid">
                @foreach (PlayerReadyState player in Scene.GetAllComponents<PlayerReadyState>())
                {
                    <div class="cell @(player.IsReady ? "ready" : "notready")">
                        @if (player.IsBot())
                        {
                            <div class="avatar bot-avatar" style="background-color: @(BotColor(player.DisplayName));">
                                @(BotInitial(player.DisplayName))
                            </div>
                        }
                        else
                        {
                            <img class="avatar" src="avatar:@(player.Network.Owner.SteamId)" />
                        }
                        <div class="name">@player.DisplayName</div>
                        <div class="wins" title="Wins">
                            <div class="trophy">🏆</div>
                            <div class="count">@player.WinCount</div>
                        </div>
                    </div>
                }
            </div>
        }
    </div>
</root>

@code {
    private int LaunchSeconds
    {
        get
        {
            LobbyManager mgr = LobbyManager.Current;
            if (mgr is null || !mgr.IsLaunching) return 0;
            return System.Math.Max(1, (int)System.MathF.Ceiling(mgr.LaunchSecondsRemaining));
        }
    }

    private static string BotInitial(string name) =>
        string.IsNullOrEmpty(name) ? "?" : name.Substring(0, 1).ToUpper();

    // Stable per-name HSL so each bot keeps the same badge color across ticks.
    private static string BotColor(string seed)
    {
        int hue = System.Math.Abs(seed.GetHashCode()) % 360;
        return $"hsl({hue}, 55%, 45%)";
    }

    protected override int BuildHash()
    {
        IEnumerable<string> parts = Scene.GetAllComponents<PlayerReadyState>()
            .Select(p => $"{p.DisplayName}:{p.IsReady}:{p.WinCount}");
        return System.HashCode.Combine(string.Join(",", parts), LaunchSeconds, LobbyManager.HasActiveLobbyMessage);
    }
}
@namespace CardGames
@using Sandbox
@using Sandbox.UI
@using System
@using System.Linq
@using System.Collections.Generic
@inherits Panel

@*
	The full games browser - reached from the launcher's "Browse Games" button (host only).
	A search box and sort tabs drive a Package.FindAsync query; results show as cards. Picking one asks the
	director to mount its cloud package and start it. Failures (won't download, not a card game) stay here.
*@

<root class="cgui">
	<button class="back" onclick=@(() => Director?.CloseBrowser())>‹ Back</button>

	<div class="hall">
		<div class="controls">
			<div class="search">
				<span class="ico">search</span>
				<TextEntry placeholder="Search games…" OnTextEdited=@OnSearchEdited></TextEntry>
			</div>
			<div class="sorts">
				@foreach ( var s in Sorts )
				{
					var sort = s;
					<button class="sort @(_sort == sort.Key ? "active" : "")" onclick=@(() => SetSort( sort.Key ))>@sort.Value</button>
				}
			</div>
		</div>

		@if ( !string.IsNullOrEmpty( _error ) )
		{
			<div class="cg-error">@_error</div>
		}

		@if ( _busy )
		{
			<div class="cg-note">Searching…</div>
		}
		else if ( _results.Count == 0 )
		{
			<div class="cg-note">No games found. Try a different search.</div>
		}
		else
		{
			<div class="grid">
				@foreach ( var package in _results )
				{
					var p = package;
					<GameCard Package=@p OnClick=@(() => Select( p )) Loading=@(_loadingIdent == p.FullIdent) />
				}
			</div>
		}
	</div>
</root>

@code
{
	private GameDirector _director;
	private GameDirector Director => _director ??= Sandbox.Game.ActiveScene?.Get<GameDirector>();

	// Sort orders offered as tabs - the key goes straight into the FindAsync query (sort:<key>).
	private static readonly KeyValuePair<string, string>[] Sorts =
	{
		new( "trending", "Trending" ),
		new( "popular", "Popular" ),
		new( "newest", "Newest" ),
		new( "quality", "Top Rated" ),
	};

	private string _sort = "popular";
	private string _error = "";
	private bool _busy;
	private string _loadingIdent;                 // the package currently being mounted/started (per-card spinner)
	private List<Package> _results = new();

	private string _query = "";
	private bool _searchPending;
	private RealTimeSince _sinceEdited;
	private bool _searched;                        // kick off the first search once, after the panel mounts

	private void SetSort( string sort )
	{
		if ( _sort == sort ) return;
		_sort = sort;
		Refresh();
	}

	// Debounce typing: note the edit, then fire the search a beat after the last keystroke (see Tick).
	private void OnSearchEdited( string text )
	{
		_query = text ?? "";
		_searchPending = true;
		_sinceEdited = 0f;
	}

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

		if ( !_searched )
		{
			_searched = true;
			Refresh();
		}

		if ( _searchPending && _sinceEdited > 0.3f )
		{
			_searchPending = false;
			Refresh();
		}
	}

	// Query the cloud for games matching the current sort + search text.
	private async void Refresh()
	{
		_busy = true;
		_error = "";
		StateHasChanged();

		try
		{
			var query = $"type:cggame sort:{_sort} {_query}".Trim();
			var result = await Package.FindAsync( query, 60 );
			_results = result.Packages?.ToList() ?? new();
		}
		catch ( Exception e )
		{
			_results = new();
			_error = "Couldn't reach the games list. Check your connection.";
			Log.Warning( e, "Games search failed" );
		}

		_busy = false;
		StateHasChanged();
	}

	// Mount + start the chosen package. On success the HUD swaps this panel out; on failure we show why.
	private async void Select( Package package )
	{
		if ( _loadingIdent is not null || Director is null ) return;

		_loadingIdent = package.FullIdent;
		_error = "";
		StateHasChanged();

		var error = await Director.StartCommunityGame( package );

		_loadingIdent = null;
		if ( !string.IsNullOrEmpty( error ) )
			_error = error;
		StateHasChanged();
	}

	protected override int BuildHash() => HashCode.Combine( _busy, _error, _loadingIdent, _sort, _results.Count );
}
@namespace CardGames
@using Sandbox
@using Sandbox.UI
@using System
@using System.Linq
@inherits Panel

@*
	Hosts a SeatTag over each occupied seat's hand. Generic across games - the tags read names, chips and
	per-seat badges (CardGame.SeatBadges) and position themselves in the world. This panel just decides
	which seats exist; each tag handles its own placement and content.
*@

<root class="players">
	@if ( Game is { } game )
	{
		@foreach ( var s in game.Seats.Where( s => s.Occupied ).OrderBy( s => s.Index ) )
		{
			var seat = s;
			<SeatTag Seat=@seat />
		}
	}
</root>

@code
{
	private CardGame Game => Sandbox.Game.ActiveScene?.Get<GameDirector>()?.ActiveGame;

	// Re-list only when the set of occupied seats changes; the tags repaint themselves otherwise.
	protected override int BuildHash()
	{
		if ( Game is not { } game ) return 0;
		int h = 0;
		foreach ( var s in game.Seats.Where( s => s.Occupied ) )
			h = HashCode.Combine( h, s.Index );
		return h;
	}
}