A UI Razor panel that displays perk unlock progress and animates XP fill steps, shows unlock confirmation, and presents perk choice results. It builds animation steps from ProgressManager data, animates fill/timers, handles unlocking logic and selecting or pre-unlocking perks.
@namespace Sandbox
@using Sandbox;
@using Sandbox.UI;
@using System;
@using System.Linq;
@using System.Collections.Generic;
@inherits Panel
@attribute [StyleSheet("PerkUnlockProgressPanel.razor.scss")]
<root>
<label class="run_xp_label">+@((int)ProgressManager.RunXpEarned) XP</label>
<div class="panel_bg">
<label class="title_label">Perk Progress</label>
<div class="xp_bar_container">
<div class="xp_bar_new" style="width:@((_stepTargetPct * 100f).ToString("0.#"))%;"></div>
<div class="xp_bar_fill @(_fillAnimating ? "animated" : "")"
style="width:@((_displayPct * 100f).ToString("0.#"))%;"></div>
</div>
<label class="xp_label">@_xpLabelText</label>
@if ( _showUnlockText )
{
<label class="unlock_text">Perk Unlocked!</label>
}
@if ( _phase == XpPhase.UnlockConfirm || _phase == XpPhase.Done )
{
<button class="continue_button" onclick=@(() => Continue())>Continue</button>
}
</div>
@if ( _resultPicks != null )
{
<PerkUnlockResultPanel Picks=@_resultPicks OnClose=@((Action<TypeDescription>)(t => OnResultClosed( t ))) />
}
</root>
@code
{
private enum XpPhase
{
Init = 0,
Filling = 1,
UnlockPause = 2,
UnlockConfirm = 3,
NextStep = 4,
ShowingChoice = 5,
Done = 6,
}
private struct AnimStep
{
public float StartPct;
public float TargetPct;
public bool CausesUnlock;
public float Threshold;
}
private XpPhase _phase = XpPhase.Init;
private List<AnimStep> _steps;
private int _stepIndex = 0;
private float _displayPct = 0f;
private float _stepTargetPct = 0f;
private bool _fillAnimating = false;
private bool _waitingForFillDelay = false;
private RealTimeSince _fillDelayTimer;
private const float FillDelayFirst = 0.5f;
private const float FillDelaySubsequent = 0.3f;
private int _stepsStarted = 0;
private int _pendingFillFrames = 0;
private RealTimeSince _fillTimer;
private const float FillDurationPerStep = 0.8f;
private bool _showUnlockText = false;
private RealTimeSince _unlockPauseTimer;
private string _xpLabelText = "";
private int _initFramesRemaining = 3;
private List<TypeDescription> _resultPicks = null;
private TypeDescription _preUnlockedPick = null;
private int _frozenStateVersion = -1;
protected override void OnAfterTreeRender( bool firstTime )
{
base.OnAfterTreeRender( firstTime );
if ( firstTime )
{
_phase = XpPhase.Init;
_initFramesRemaining = 3;
_stepIndex = 0;
_displayPct = 0f;
_stepTargetPct = 0f;
_fillAnimating = false;
_waitingForFillDelay = false;
_showUnlockText = false;
_steps = null;
}
}
public override void Tick()
{
base.Tick();
switch ( _phase )
{
case XpPhase.Init:
_initFramesRemaining--;
if ( _initFramesRemaining <= 0 )
{
_steps = BuildAnimSteps();
if ( _steps.Count == 0 )
{
_phase = XpPhase.Done;
}
else
{
StartStep( 0 );
}
}
StateHasChanged();
break;
case XpPhase.Filling:
if ( _waitingForFillDelay )
{
float fillDelay = _stepsStarted <= 1 ? FillDelayFirst : FillDelaySubsequent;
if ( _fillDelayTimer > fillDelay )
_waitingForFillDelay = false;
break;
}
if ( _pendingFillFrames > 0 )
{
_pendingFillFrames--;
if ( _pendingFillFrames == 0 )
{
_displayPct = _steps[_stepIndex].TargetPct;
_fillAnimating = true;
_fillTimer = 0f;
}
StateHasChanged();
break;
}
if ( _fillTimer > FillDurationPerStep )
{
var step = _steps[_stepIndex];
if ( step.CausesUnlock )
{
_showUnlockText = true;
_unlockPauseTimer = 0f;
_phase = XpPhase.UnlockPause;
}
else
{
_phase = XpPhase.NextStep;
}
StateHasChanged();
}
break;
case XpPhase.UnlockPause:
if ( _unlockPauseTimer > 1.2f )
{
_phase = XpPhase.UnlockConfirm;
StateHasChanged();
}
break;
case XpPhase.UnlockConfirm:
// Waiting for player to click Continue
break;
case XpPhase.NextStep:
_stepIndex++;
if ( _stepIndex >= _steps.Count )
{
_phase = XpPhase.Done;
StateHasChanged();
}
else
{
StartStep( _stepIndex );
}
break;
case XpPhase.ShowingChoice:
// Waiting for PerkUnlockResultPanel — handled in OnResultClosed
break;
case XpPhase.Done:
// Waiting for player to click Continue
break;
}
}
private void StartStep( int index )
{
var step = _steps[index];
_displayPct = step.StartPct;
_stepTargetPct = step.TargetPct;
_fillAnimating = false;
_waitingForFillDelay = true;
_fillDelayTimer = 0f;
_stepsStarted++;
_pendingFillFrames = 2;
_phase = XpPhase.Filling;
_xpLabelText = $"{(int)(step.TargetPct * step.Threshold)} / {(int)step.Threshold} XP to next unlock";
StateHasChanged();
}
private List<AnimStep> BuildAnimSteps()
{
var steps = new List<AnimStep>();
float xpBefore = ProgressManager.PerkUnlockXpBeforeRun;
int unlocksAdded = ProgressManager.RunPerkUnlocksEarned;
float currXp = xpBefore;
int baseUnlocks = ProgressManager.UnlockedLockedPerksCount;
for ( int i = 0; i < unlocksAdded; i++ )
{
float threshold = ThresholdForTotal( baseUnlocks + i );
float startPct = currXp / threshold;
steps.Add( new AnimStep { StartPct = startPct, TargetPct = 1.0f, CausesUnlock = true, Threshold = threshold } );
currXp = 0f;
}
// Final partial step
float finalThreshold = ThresholdForTotal( baseUnlocks + unlocksAdded );
float finalStartPct = steps.Count == 0 ? xpBefore / finalThreshold : 0f;
float finalEndPct = ProgressManager.PerkUnlockXp / finalThreshold;
if ( MathF.Abs( finalEndPct - finalStartPct ) > 0.001f )
{
steps.Add( new AnimStep { StartPct = finalStartPct, TargetPct = finalEndPct, CausesUnlock = false, Threshold = finalThreshold } );
}
return steps;
}
private void ShowNextPerkChoice()
{
var player = Manager.Instance.LocalPlayer;
var allPerkTypes = TypeLibrary.GetTypes<Perk>()
.Where( t => t.GetAttribute<PerkAttribute>() is { Disabled: false } )
.ToList();
var availableCategories = PerkUnlockHelper.GetAvailableCategories( allPerkTypes );
var candidates = PerkUnlockHelper.GetEligibleLockedPerks( allPerkTypes, availableCategories, player );
if ( candidates.Count == 0 )
{
Manager.Instance.ContinueToQuestPanel();
StateHasChanged();
return;
}
_frozenStateVersion = ProgressManager.StateVersion;
if ( candidates.Count == 1 )
{
var single = candidates[0];
ProgressManager.UnlockLockedPerk( single );
_resultPicks = new List<TypeDescription> { single };
}
else
{
var firstPick = PickWeighted( candidates );
var remaining = candidates.Where( t => t != firstPick ).ToList();
var secondPick = PickWeighted( remaining );
_resultPicks = new List<TypeDescription> { firstPick, secondPick };
_preUnlockedPick = Game.Random.Float() < 0.5f ? firstPick : secondPick;
ProgressManager.UnlockLockedPerk( _preUnlockedPick );
}
StateHasChanged();
}
private void OnResultClosed( TypeDescription chosenPerk )
{
if ( chosenPerk != null && _preUnlockedPick != null && chosenPerk != _preUnlockedPick )
{
ProgressManager.RelockLockedPerk( _preUnlockedPick );
ProgressManager.UnlockLockedPerk( chosenPerk );
}
_preUnlockedPick = null;
_resultPicks = null;
_frozenStateVersion = -1;
Manager.Instance.ContinueToQuestPanel();
}
private void Continue()
{
if ( _phase == XpPhase.UnlockConfirm )
{
_phase = XpPhase.ShowingChoice;
ShowNextPerkChoice();
StateHasChanged();
}
else if ( _phase == XpPhase.Done )
{
Manager.Instance.ContinueToQuestPanel();
}
}
private static float ThresholdForTotal( int total ) => Math.Min( 50f + 5f * total, 1000f );
private static TypeDescription PickWeighted( List<TypeDescription> pool )
{
var weighted = pool.Select( t => (t, w: PerkUnlockHelper.WeightForRarity( t.GetAttribute<PerkAttribute>().Rarity )) ).ToList();
float total = weighted.Sum( x => x.w );
float rand = Game.Random.Float( 0f, total );
var result = weighted[^1].t;
foreach ( var (t, w) in weighted ) { rand -= w; if ( rand <= 0f ) { result = t; break; } }
return result;
}
protected override int BuildHash()
{
var sv = _frozenStateVersion >= 0 ? _frozenStateVersion : ProgressManager.StateVersion;
return HashCode.Combine( (int)_phase, _stepIndex, _showUnlockText, _fillAnimating, _stepTargetPct, sv, _resultPicks != null );
}
}