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