ui/PerkUnlockPanel.razor

A Blazor-like UI panel for displaying and interacting with perk unlocks. It lists available Perk types, shows icons, allows cycling visible levels, animates clicks, and includes editor-only debug buttons to unlock or relock perks and clear XP.

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

<root>
	<div class="header_row">
		<h1>Perks</h1>
	</div>
	@{
		bool IsEffectivelyUnlocked( TypeDescription t ) => ProgressManager.IsLockedPerkUnlocked( t );

		var perkTypes = TypeLibrary.GetTypes<Perk>()
		.Where(t =>
		{
			var a = t.GetAttribute<PerkAttribute>();
			return a != null && !a.Disabled && !a.Curse;
		})
		.OrderBy(t => { var a = t.GetAttribute<PerkAttribute>(); return a.Locked && !IsEffectivelyUnlocked(t) ? 1 : 0; })
		.ThenBy(t => t.GetAttribute<PerkAttribute>()?.Rarity ?? Rarity.None)
		.ThenBy(t => Perk.GetName(t.TargetType) ?? t.Name)
		.ToList();

	}

	<div class="itemlist">
		@foreach(var (perkType, idx) in perkTypes.Select((t, i) => (t, i)))
		{
			var attrib = perkType.GetAttribute<PerkAttribute>();
			var isHidden = attrib.Locked && !IsEffectivelyUnlocked(perkType);
			var level = _perkLevels.TryGetValue(perkType, out int l) ? l : 1;
			var hideLevel = !_perkLevels.ContainsKey(perkType);

			var baseRot = Utils.Map((idx * 3f) % 10, 0, 9, -5f, 5f);
			var rot = baseRot + (_perkAngleOffsets.TryGetValue(perkType, out float offset) ? offset : 0f);
			var hasAnim = _perkClickTimes.TryGetValue(perkType, out var timeSinceClick);
			var bounce = hasAnim && timeSinceClick.Relative < BounceDuration
				? Utils.Map(timeSinceClick.Relative, 0f, BounceDuration, 1.15f, 1.0f, EasingType.QuadOut)
				: 1.0f;

			<div class="@(isHidden ? "perk_slot" : "perk_slot clickable")"
			     style="transform: rotate(@(rot)deg) scale(@(bounce));"
			     onclick=@(isHidden ? null : () => CyclePerkLevel(perkType))
			     onrightclick=@(isHidden ? null : () => DebugRelockPerk(perkType))>
				<PerkIconStatic style="width: 64px; height: 64px;"
					PerkType=@perkType Level=@level ProgressLevel=@level HideLevel=@hideLevel
					Banished=@false Percent=@(-1f)
					HideType=@isHidden />
			</div>
		}
	</div>

	<div class="hide_button" onclick=@(() => Close())>close</div>

	@if( Game.IsEditor )
	{
		<div class="debug_buttons">
			<button onclick=@(() => DebugUnlockRandom())>Unlock Random</button>
			<button onclick=@(() => DebugRelockAll())>Re-lock All</button>
			<button onclick=@(() => ProgressManager.ClearPerkUnlockXp())>Clear XP</button>
		</div>
	}

</root>

@code
{
	private const float BounceDuration = 0.3f;

	private Dictionary<TypeDescription, int> _perkLevels = new();
	private Dictionary<TypeDescription, float> _perkAngleOffsets = new();
	private Dictionary<TypeDescription, RealTimeSince> _perkClickTimes = new();
	private int _levelVersion = 0;

	void CyclePerkLevel( TypeDescription type )
	{
		var attrib = type.GetAttribute<PerkAttribute>();
		if ( attrib == null ) return;
		var maxLevel = PerkManager.GetMaxLevelForRarity( attrib.Rarity );
		var current = _perkLevels.TryGetValue( type, out int l ) ? l : 1;
		var next = current >= maxLevel ? 1 : current + 1;
		_perkLevels[type] = next;
		Manager.Instance.HoveredPerkLevel = next;
		_perkAngleOffsets[type] = Game.Random.Float( -15f, 15f );
		_perkClickTimes[type] = 0f;
		_levelVersion++;
	}

	public override void Tick()
	{
		if ( _perkAngleOffsets.Count == 0 ) return;
		foreach ( var key in _perkAngleOffsets.Keys.ToList() )
		{
			_perkAngleOffsets[key] = MathX.Lerp( _perkAngleOffsets[key], 0f, RealTime.Delta * 15f );
			if ( MathF.Abs( _perkAngleOffsets[key] ) < 0.01f )
				_perkAngleOffsets.Remove( key );
		}
	}

	void DebugUnlockRandom()
	{
		var allPerkTypes = TypeLibrary.GetTypes<Perk>()
			.Where( t => t.GetAttribute<PerkAttribute>() is { Disabled: false } )
			.ToList();

		var availableCategories = PerkUnlockHelper.GetAvailableCategories( allPerkTypes );
		var player = Manager.Instance.LocalPlayer;
		var candidates = PerkUnlockHelper.GetEligibleLockedPerks( allPerkTypes, availableCategories, player );

		if ( candidates.Count == 0 ) { Log.Info( "No eligible locked perks to unlock." ); return; }

		var pick = candidates[Game.Random.Int( 0, candidates.Count - 1 )];
		ProgressManager.UnlockLockedPerk( pick );
		Log.Info( $"Debug-unlocked: {Perk.GetName( pick.TargetType ) ?? pick.Name}" );
		StateHasChanged();
	}

	void DebugRelockPerk( TypeDescription type )
	{
		ProgressManager.RelockLockedPerk( type );
	}

	void DebugRelockAll()
	{
		ProgressManager.RelockAllLockedPerks();
		Log.Info( "Re-locked all unlocked locked perks." );
	}

	public void Close()
	{
		Manager.Instance.ShowPerkUnlockPanel = false;
	}

	protected override int BuildHash()
	{
		var anyBouncing = _perkClickTimes.Count > 0 && _perkClickTimes.Values.Any( t => t.Relative < BounceDuration );
		var anyLerping = _perkAngleOffsets.Count > 0;
		return HashCode.Combine( ProgressManager.StateVersion, _levelVersion, (anyBouncing || anyLerping) ? RealTime.Now : 0.0 );
	}
}