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