UI/HudPanel.razor

UI component (Blazor/Sandbox.UI Panel) that displays the player's live score and best score. It smoothly animates the displayed score toward the real score over time, toggles a "popped" CSS class while counting, and updates once per digit change via BuildHash.

Native Interop
@using Sandbox;
@using Sandbox.UI;
@namespace Coilgarden
@inherits Panel
@attribute [StyleSheet( "/UI/GameUi.razor.scss" )]

@*
	The live readout: score and best, and nothing else. Deliberately small - everything else
	the player needs during a run is in the arena itself, and length lives in the game-over
	summary instead of here because it is interesting after a run and noise during one.

	Refreshes itself off its own Tick(), independently of whichever overlay GameUi has on top
	of it, so a paused or finished run still shows a live-looking score without dragging the
	rest of the interface through a rebuild to do it.
*@

<root class="readout">
	<div class="@ScoreClass">@ScoreText</div>
	<div class="best">
		<span class="best-label">BEST</span>
		<span class="best-value">@BestText</span>
	</div>
</root>

@code
{
	[Parameter] public GameSession Session { get; set; }

	private string ScoreText { get; set; } = "0";
	private string BestText { get; set; } = "0";

	/// <summary>Carries the pop class while the score is counting, built here rather than in
	/// markup so the attribute is never a mix of literal text and an @@expression.</summary>
	private string ScoreClass { get; set; } = "score";

	/// <summary>
	/// The score as it is being shown, which chases the real one rather than jumping to it.
	/// A number that counts up turns a state change into an event the eye can follow, and it
	/// is the difference between the score being a readout and the score being the reward.
	/// Kept as a float so the chase is smooth, and always floored so the player never sees a
	/// number higher than what they have actually earned.
	/// </summary>
	private float shownScore;

	private int lastRealScore;

	/// <summary>Seconds since the score last changed, driving the pop.</summary>
	private float scoreAge = 99f;

	public override void Tick()
	{
		base.Tick();

		var run = Session?.Run;
		if ( run is null ) return;

		TickScore( run.Score );

		ScoreText = ((int)shownScore).ToString();
		BestText = (Session.Records?.BestScore ?? 0).ToString();
	}

	/// <summary>
	/// Advances the displayed score towards the real one and drives the pop.
	/// <para>
	/// A restart drops the target to zero, and that is the one case where counting is wrong -
	/// watching your score tick down to nothing is a strange thing to do to somebody who just
	/// asked to play again, so it snaps instead.
	/// </para>
	/// </summary>
	private void TickScore( int real )
	{
		if ( real != lastRealScore )
		{
			if ( real < lastRealScore ) shownScore = real;

			lastRealScore = real;
			scoreAge = 0f;
		}

		scoreAge += Time.Delta;

		// Rate comes from the size of the gap, so an award of any size lands in about the same
		// time rather than a big one taking noticeably longer.
		var gap = MathF.Abs( real - shownScore );

		if ( gap > 0.01f )
		{
			var rate = MathF.Max( gap, 1f ) / GameConfig.ScoreCountDuration;

			shownScore = shownScore < real
				? MathF.Min( shownScore + rate * Time.Delta, real )
				: MathF.Max( shownScore - rate * Time.Delta, real );
		}
		else
		{
			shownScore = real;
		}

		ScoreClass = scoreAge < GameConfig.ScoreCountDuration ? "score popped" : "score";
	}

	/// <summary>
	/// The counting score is folded in as its whole number, so the panel redraws once per
	/// digit change rather than every frame.
	/// </summary>
	protected override int BuildHash() => System.HashCode.Combine( (int)shownScore, ScoreClass, BestText );
}