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