UI/SceneTransitionPanel.razor

A Razor UI component (SceneTransitionPanel) for a loading/scene transition screen. It renders sweeping wipe bars, a loading spinner, optional map thumbnail and sidebar with map name, rating, stats and a friends leaderboard fetched asynchronously.

NetworkingFile Access
@using Sandbox
@using Sandbox.UI
@using Sandbox.Services
@using Machines.Resources
@using Machines.Systems
@using System.Linq
@using System.Globalization
@namespace Machines.UI
@inherits PanelComponent

<root class="scene-transition">
	<div class="wipe accent-back" style="@WipeStyle( 1.32f )"></div>
	<div class="wipe accent-mid" style="@WipeStyle( 1.18f )"></div>
	<div class="wipe cover" style="@WipeStyle( 1f )"></div>

	<div class="content" style="opacity: @Fmt( ContentAlpha )">
		<image @ref="ThumbImage" class="thumb" />
		<div class="scrim @(HasMap ? "with-sidebar" : "")"></div>

		<div class="loading-row">
			<div class="spinner">
				<div class="ring"></div>
				<div class="ring ring-2"></div>
			</div>
			<div class="loading-text">LOADING</div>
		</div>

		@if ( HasMap )
		{
			var rating = MapRating;

			<div class="sidebar">
				<div class="map-name">@Caption</div>

				@if ( rating.count >= 1 )
				{
					<div class="rating-row">
						@for ( int i = 1; i <= 5; i++ )
						{
							<i class="rstar">@RatingStarGlyph( i, rating.avg )</i>
						}
						<span class="rating-num">@rating.avg.ToString( "0.0", CultureInfo.InvariantCulture )</span>
						<span class="rating-count">(@FormatCount( rating.count ))</span>
					</div>
				}

				<div class="stats">
					<div class="stat">
						<div class="stat-value">@FormatCount( GlobalPlays )</div>
						<div class="stat-label">RACES WORLDWIDE</div>
					</div>
					<div class="stat">
						<div class="stat-value">@FormatCount( PersonalPlays )</div>
						<div class="stat-label">YOUR RACES</div>
					</div>
				</div>

				<div class="board">
					<div class="board-title">FRIENDS' BEST LAPS</div>
					@if ( _boardLoading )
					{
						<div class="board-msg">LOADING…</div>
					}
					else if ( _entries is null || _entries.Length == 0 )
					{
						<div class="board-msg">No friend times yet</div>
					}
					else
					{
						@foreach ( var entry in _entries )
						{
							<div class="brow @(entry.SteamId == LocalSteamId ? "local" : "")">
								<div class="brow-rank">@entry.Rank</div>
								<div class="brow-name">@entry.DisplayName</div>
								<div class="brow-time">@FormatLap( (float)entry.Value )</div>
							</div>
						}
					}
				</div>

				@if ( !string.IsNullOrEmpty( MinimapUrl ) )
				{
					<div class="minimap" style="background-image: url( '@MinimapUrl' )"></div>
				}
			</div>
		}
		else
		{
			<div class="lobby-caption">@Caption</div>
		}
	</div>
</root>

@code
{
	/// <summary>Current wipe phase, driven by the controller.</summary>
	public SceneTransitionPhase Phase { get; set; } = SceneTransitionPhase.In;

	/// <summary>0..1 progress within the current phase.</summary>
	public float Progress { get; set; }

	/// <summary>Large label shown on the loading image.</summary>
	public string Caption { get; set; }

	/// <summary>Optional map resource path used to source the loading artwork.</summary>
	public string MapIdent { get; set; }

	/// <summary>True when we have a destination map to show (false for the lobby-return screen).</summary>
	private bool HasMap => !string.IsNullOrEmpty( MapIdent );

	/// <summary>Local player's SteamId, used to highlight their leaderboard row.</summary>
	private static long LocalSteamId => Connection.Local is not null ? (long)Connection.Local.SteamId : 0;

	// Friends-only lap leaderboard for the destination map, fetched once on start.
	private Leaderboards.Board2.Entry[] _entries;
	private bool _boardLoading;

	// Rotation (degrees) of the sweeping bars; gives the wipe a diagonal leading edge.
	private const float Rotation = -26f;

	// How far (in % of the over-sized bar's own width) a bar travels to fully clear the screen.
	// Large enough that the rotated, over-sized rectangle is completely off-screen at the extremes.
	private const float Travel = 120f;

	/// <summary>
	/// Build the transform for a sweeping bar. <paramref name="agility"/> &gt; 1 makes accent bars
	/// advance faster than the cover so they lead it on the way in and trail it on the way out.
	/// All bars share the same <see cref="Travel"/>, so every bar starts and ends fully off-screen.
	/// </summary>
	private string WipeStyle( float agility )
	{
		float p = Math.Clamp( Progress, 0f, 1f );

		// Time-warp the bar's progress: accents reach the cover state sooner on the way in and
		// leave later on the way out, while still beginning/ending at the off-screen extreme.
		float eff = Phase switch
		{
			SceneTransitionPhase.In => Math.Clamp( p * agility, 0f, 1f ),
			SceneTransitionPhase.Out => Math.Clamp( 1f - (1f - p) * agility, 0f, 1f ),
			_ => 1f
		};

		float eased = EaseInOut( eff );

		// translateX in % of the bar's own width: off-screen-right (+) -> covering (0) -> off-screen-left (-).
		float tx = Phase switch
		{
			SceneTransitionPhase.In => (1f - eased) * Travel,
			SceneTransitionPhase.Out => -eased * Travel,
			_ => 0f
		};

		return $"transform: translateX( {Fmt( tx )}% ) rotate( {Fmt( Rotation )}deg );";
	}

	/// <summary>Opacity of the upright loading-artwork layer.</summary>
	private float ContentAlpha
	{
		get
		{
			float eased = EaseInOut( Math.Clamp( Progress, 0f, 1f ) );
			return Phase switch
			{
				// Fade in over the back half of the cover-in.
				SceneTransitionPhase.In => Math.Clamp( (eased - 0.55f) / 0.45f, 0f, 1f ),
				// Fade out quickly at the start of the reveal so the scene shows through.
				SceneTransitionPhase.Out => 1f - Math.Clamp( eased / 0.25f, 0f, 1f ),
				_ => 1f
			};
		}
	}

	/// <summary>Track-layout PNG that sits next to the scene file, shown as the corner minimap.</summary>
	private string MinimapUrl
	{
		get
		{
			var scenePath = ResolvedMap?.Scene?.ResourcePath;
			if ( string.IsNullOrEmpty( scenePath ) )
				return null;

			return scenePath.Replace( ".scene", "_minimap.png" );
		}
	}

	/// <summary>The destination map, resolved from its resource path.</summary>
	private MapResource ResolvedMap => string.IsNullOrEmpty( MapIdent )
		? null
		: ResourceLibrary.Get<MapResource>( MapIdent );

	/// <summary>Aggregate 1-5 star rating for the destination map: average and rater count.</summary>
	private (double avg, long count) MapRating => HasMap ? GameStats.GetMapRating( MapIdent ) : (0d, 0L);

	// Material glyph for star i (1-5) given an average: full, half, or outline. Mirrors MapCard.
	private static string RatingStarGlyph( int i, double avg )
	{
		if ( avg >= i ) return "star";
		if ( avg >= i - 0.5 ) return "star_half";
		return "star_outline";
	}

	/// <summary>Large background thumbnail image element (texture pushed in <see cref="OnUpdate"/>).</summary>
	public Image ThumbImage { get; set; }

	// Stat name for finishing a race on this map; matches GameStats' "-map-" scope naming.
	private string PlayStatName => string.IsNullOrEmpty( MapIdent )
		? null
		: $"races-finished-map-{GameStats.Slug( MapIdent )}";

	/// <summary>Total races finished on this map across every player, worldwide.</summary>
	private long GlobalPlays
	{
		get
		{
			var name = PlayStatName;
			return name is null ? 0 : (long)Sandbox.Services.Stats.Global.Get( name ).Sum;
		}
	}

	/// <summary>Races the local player has finished on this map.</summary>
	private long PersonalPlays
	{
		get
		{
			var name = PlayStatName;
			return name is null ? 0 : (long)Sandbox.Services.Stats.LocalPlayer.Get( name ).Value;
		}
	}

	protected override void OnStart()
	{
		if ( !HasMap )
			return;

		// Pull fresh global/personal totals; cached values render immediately, the refresh fills in.
		_ = Sandbox.Services.Stats.Global.Refresh();
		_ = Sandbox.Services.Stats.LocalPlayer.Refresh();

		_ = LoadBoardAsync();
	}

	// Fetch the friends-only lap leaderboard for the map we're loading into.
	private async Task LoadBoardAsync()
	{
		_boardLoading = true;
		try
		{
			_entries = await LapTimeService.GetLeaderboard( MapIdent, 5, friendsOnly: true );
		}
		catch ( Exception e )
		{
			Log.Warning( e, "SceneTransitionPanel: friends leaderboard fetch failed" );
		}
		finally
		{
			_boardLoading = false;
		}
	}

	protected override void OnUpdate()
	{
		// Bind the map thumbnail imperatively so it survives the per-frame re-render without flicker.
		if ( ThumbImage is not null )
		{
			var tex = ResolvedMap?.Thumbnail;
			if ( tex is not null && ThumbImage.Texture != tex )
				ThumbImage.Texture = tex;
		}
	}

	private static string FormatCount( long value ) => value.ToString( "N0", CultureInfo.InvariantCulture );

	// Lap times use mm:ss.fff precision, matching the leaderboard page.
	private static string FormatLap( float seconds )
	{
		var mins = (int)(seconds / 60f);
		var secs = seconds - mins * 60f;
		return $"{mins:00}:{secs:00.000}";
	}

	private static string Fmt( float v ) => v.ToString( "0.###", CultureInfo.InvariantCulture );

	// Smooth cubic ease for a punchy yet soft sweep.
	private static float EaseInOut( float t ) => t < 0.5f
		? 4f * t * t * t
		: 1f - MathF.Pow( -2f * t + 2f, 3f ) / 2f;

	// Re-render whenever the animation state or the displayed stats change (progress quantised to limit churn).
	protected override int BuildHash() => HashCode.Combine( Phase, (int)(Progress * 240f), Caption, MapIdent, GlobalPlays, PersonalPlays, HashCode.Combine( _boardLoading, _entries?.Length ?? 0, (int)(MapRating.avg * 10), MapRating.count ) );
}