ui/PerkUnlockProgressPanel.razor

Razor UI panel that shows perk unlock progress and XP fill animation. It builds animation steps from progress data, animates fill bars and handles unlock confirmation, then presents perk choice UI and applies unlocks.

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