ui/PerkUnlockProgressPanel.razor

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.

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