ui/InfoPanel.razor

A Razor UI component for the in-game information panel. It renders XP, dash, HP, and armor displays using Player properties and UI stats, includes conditional visuals for curses and temporary dashes, and computes a BuildHash to drive rerendering.

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