ui/InfoPanel.razor

A Razor UI component for the in-game info panel. It renders XP, dash meters, HP bar, armor icon and related numeric data, computing percentages and styles from the Player model and updating its render hash to minimize rebuilds.

Http Calls
@namespace Sandbox
@using Sandbox;
@using Sandbox.UI;
@using System;
@inherits Panel
@attribute [StyleSheet("InfoPanel.razor.scss")]

<root>
	@{
		// todo: split each ui line into its own component

		var xp_percent = Player.ExperienceCurrent / (float)Player.ExperienceRequired;
		var xp_transition_enabled = Player.GetUiStat( PlayerStat.DisableXpBarTransition ) == 0f;
		var hp_percent = Math.Clamp(Math.Max(Player.Health, 0f) / Player.GetSyncStat(PlayerStat.MaxHp), 0f, 1f);
		// var hp_regen = Player.Stats[PlayerStat.HealthRegen] + (Player.IsMoving ? 0f : Player.Stats[PlayerStat.HealthRegenStill]);
		var hp_regen = Player.GetHpRegenAmount(forDisplay: true);
		var hpBarHidden = Player.GetUiStat( PlayerStat.HpBarHidden ) > 0f;
	}

	<div class="info_bar">
		<div class="bar_container" style=@("mask-image: url(\"/textures/ui/panel/info_panel_xp_mask.png\");")>
			<div class="info_bar_overlay @(xp_transition_enabled ? "xp_transition_on" : "")" style="width:@(xp_percent * 100f)%; background-color: #8888ff77;"></div>

			@if(Manager.Instance.Difficulty >= Manager.Instance.FirstDifficultyWithCurses && Player.AvailableCurseCount > 0)
			{
				var cursePercent = Player.IsBeingShownCurseChoices ? 1f : Utils.Map(Player.CurrLevelsUntilCurseChoice, Player.NumLevelsBetweenCurseChoices, 0, 0f, 1f);
				var colorSpeed = Player.IsBeingShownCurseChoices ? 15f : Utils.Map(Player.CurrLevelsUntilCurseChoice, Player.NumLevelsBetweenCurseChoices, 0, 3f, 15f, EasingType.SineIn);

				var curseColor = Color.Lerp(new Color(0.2f, 0.1f, 0.4f, 0.5f), new Color(0.8f, 0.1f, 0.9f, 0.25f), Utils.Map(Utils.FastSin(RealTime.Now * colorSpeed), -1f, 1f, 0f, 1f));

				<div class="info_bar_overlay" style="width:@(cursePercent * 100f)%; background-color: @curseColor.Rgba;"></div>
			}
		</div>
		
		<div class="info_bar_label">XP</div>

		<div class="data_container">
			<div class="data" style="opacity: 0.3;">@($"LVL {Player.Level}")</div>
		</div>
	</div>

	@{
		var regularMaxDashes = (int)MathF.Round( Player.GetUiStat( PlayerStat.NumDashes ) );
		var maxDashes = regularMaxDashes + Player.NumTempDashesAvailable;
		var totalAvailable = Player.NumDashesAvailable + Player.NumTempDashesAvailable;
 	}

	@if( maxDashes < 9 )
	{
		<div class="info_dash_container" style="gap: @(Utils.Map(maxDashes, 1, 8, 8, 4, EasingType.QuadIn))px;">
			<div class="dash_bar_container")>
				@for(int i = 0; i < maxDashes; i++)
				{
					// Bars i < regularMaxDashes are regular dashes; i >= regularMaxDashes are temporary (from PerkDashTemporary).
					// Temp bars are always shown to the right and colored purple.
					var isTemp = i >= regularMaxDashes;
					float progress;
					bool isRecharging;
					if ( isTemp )
					{
						// Temp dashes are either fully available or gone — no partial recharge animation.
						int tempIndex = i - regularMaxDashes;
						progress = tempIndex < Player.NumTempDashesAvailable ? 1f : 0f;
						isRecharging = false;
					}
					else
					{
						// The bar at NumDashesAvailable is the one currently recharging (partial fill).
						// Bars below it are full; bars above it are empty.
						progress = i == Player.NumDashesAvailable ? Player.DashRechargeProgress : (i < Player.NumDashesAvailable ? 1f : 0f);
						isRecharging = i >= Player.NumDashesAvailable;
					}
					var filledColor   = isTemp ? "#b34dff88" : "#00ff0088";
					var rechargingColor = isTemp ? "#b34dff44" : "#00ff4444";

					<div class="info_bar" style="background-color: #00000077;">
						<div class="info_bar_overlay" style="width:@(progress * 100f)%; background-color: @(isRecharging ? rechargingColor : filledColor);"></div>
					</div>
				}
			</div>

			@if(maxDashes <= 0) 
			{
				<div class="info_bar">
				</div>
			}

			<div class="info_bar_label" style="left: 8px;">
				@("DASH")
			</div>
		</div>
	}
	else
	{
		var dashPercent = (float)totalAvailable / (float)maxDashes;

		<div class="info_bar">
			<div class="info_bar_overlay" style="width:@(dashPercent * 100f)%; background-color: #00ff0055; transition: all 0.2s ease-in-out;"></div>
			<div class="info_bar_label">DASH</div>

			<div class="data_container">
				<div class="data">@maxDashes</div>
				<div class="data">/</div>
				<div class="data">@totalAvailable</div>
			</div>
		</div>
	}

	<div class="info_bar">
		<div class="bar_container" style=@("mask-image: url(\"/textures/ui/panel/info_panel_hp_mask.png\");")>
			@if ( hpBarHidden )
			{
				<div class="info_bar_overlay" style="width: 100%; background-color: #7a0040ff;"></div>
			}
			else
			{
				<div class="info_bar_overlay" style="width:@(hp_percent * 100f)%; background-color: #ffffffff; transition: all 0.4s ease-in-out "></div>
				<div class="info_bar_overlay" style="width:@(hp_percent * 100f)%; background-color: #ff0000ff; transition: all 0.2s ease-in-out;"></div>
			}
		</div>

		<div class="info_bar_label">HP</div>

		<div class="data_container">
			<div class="data">@($"{(int)MathF.Round(Player.GetSyncStat(PlayerStat.MaxHp))}")</div>
			<div class="data">/</div>
			@if ( hpBarHidden )
			{
				<div class="data">?</div>
			}
			else
			{
				<div class="data">@($"{(Math.Max(Player.Health, 0f) > 0f && Math.Max(Player.Health, 0f) < 1f ? (int)Math.Ceiling(Math.Max(Player.Health, 0f)) : (int)Math.Round(Math.Max(Player.Health, 0f)))}")</div>

				@if (Math.Abs(hp_regen) > 0f)
				{
					<div class="data" style="color:@((hp_regen > 0f ? new Color(0f, 1f, 0f) : new Color(1f, 0f, 0f)).Rgba); letter-spacing: 2px; padding-right: 10px;">@($"{(hp_regen > 0f ? "+" : "")}{hp_regen.ToString("0.##")}")</div>
				}
			}
		</div>
	</div>

	@if (Player.Armor > 0)
	{
		<div class="armor_icon" style="transform: scale(@(Utils.Map(Player.RealTimeSinceArmorChanged, 0f, 0.5f, 1.35f, 1f, EasingType.QuadOut)));">
			<label class="armor_text">@($"{MathX.CeilToInt(Player.Armor)}")</label>
		</div>
	}
</root>

@code
{
	public Player Player { get; set; }

	protected override int BuildHash()
	{
		if( Manager.Instance.Difficulty >= Manager.Instance.FirstDifficultyWithCurses )
		{
			return HashCode.Combine(RealTime.Now);
		}

		var hpBarHidden = Player.GetUiStat( PlayerStat.HpBarHidden ) > 0f;

		var hpHash = HashCode.Combine(
			Player.Health,
			Player.GetSyncStat(PlayerStat.MaxHp),
			Player.GetSyncStat( PlayerStat.HealthRegen ),
			Player.GetSyncStat( PlayerStat.HealthRegenStill ),
			Player.IsMoving,
			Player.GetHpRegenAmount( forDisplay: true ),
			hpBarHidden
		);

		var armorHash = HashCode.Combine(
			Player.RealTimeSinceArmorChanged > 0.5f ? 0f : Player.RealTimeSinceArmorChanged.Relative,
			Player.Armor
		);

		var xpHash = HashCode.Combine(
			Player.Level,
			Player.ExperienceCurrent,
			Player.ExperienceRequired
		);
		
		return HashCode.Combine(
			Player.DashRechargeProgress,
			Player.NumDashesAvailable,
			Player.NumTempDashesAvailable,
			Player.GetUiStat( PlayerStat.NumDashes ),
			hpHash,
			armorHash,
			xpHash
		);
	}
}