UI component that implements a fullscreen scene transition/loading overlay. It creates a persistent GameObject with a SceneTransitionPanel, drives animate-in, hold and animate-out phases, waits for scene loading to complete (or a timeout), and invokes a host-provided OnCovered callback to perform the actual scene swap.
namespace Machines.UI;
public enum SceneTransitionPhase
{
In,
Hold,
Out
}
public static class SceneTransition
{
public static SceneTransitionController Active { get; internal set; }
/// <summary>
/// Begin a transition overlay
/// </summary>
/// <param name="caption">Large label shown on the loading image (e.g. the map title).</param>
/// <param name="mapIdent">Optional map resource path used to source the loading artwork; null for a generic screen.</param>
/// <param name="onCovered">Invoked once the screen is fully covered. Hosts perform the scene load here; pass null on clients.</param>
public static SceneTransitionController Show( string caption, string mapIdent, Action onCovered )
{
// Never stack transitions; if one is already mid-flight, reuse it.
if ( Active.IsValid() )
return Active;
// Persistent, non-networked overlay so it survives the scene swap on every peer.
var go = new GameObject( true, "SceneTransition" );
go.Flags = GameObjectFlags.DontDestroyOnLoad;
go.NetworkMode = NetworkMode.Never;
var controller = go.AddComponent<SceneTransitionController>();
controller.Caption = caption;
controller.MapIdent = mapIdent;
controller.OnCovered = onCovered;
Active = controller;
return controller;
}
}
public sealed class SceneTransitionController : Component
{
/// <summary>Large caption shown while covered.</summary>
public string Caption { get; set; }
/// <summary>Map resource path used to source the loading artwork (optional).</summary>
public string MapIdent { get; set; }
/// <summary>Host-only: performs the actual scene load once the screen is covered.</summary>
public Action OnCovered { get; set; }
// Animation timings (seconds). In is kept a touch long so networked peers have time to
// receive the "begin" RPC and cover their screens before the host swaps the scene.
private const float AnimateInDuration = 0.7f;
private const float AnimateOutDuration = 0.8f;
// Minimum time the loading image stays up so instant loads still read as a deliberate transition.
private const float MinHold = 0.4f;
// Safety net: if we somehow never observe the load completing, bail out rather than hang forever.
private const float MaxLoadWait = 6f;
private enum State { AnimatingIn, Loading, AnimatingOut, Done }
private State _state = State.AnimatingIn;
private float _stateTime;
private bool _loadStarted;
private bool _sawLoading;
private Scene _initialScene;
private SceneTransitionPanel _panel;
protected override void OnStart()
{
// Remember the scene we started in so we can detect the swap even if we miss the
// IsLoading window (e.g. a client whose load happened during the animate-in).
_initialScene = Game.ActiveScene;
// Screen-space panel with a very high ZIndex so nothing in any scene can draw over it.
var screenPanel = GameObject.AddComponent<ScreenPanel>();
screenPanel.ZIndex = 99999;
_panel = GameObject.AddComponent<SceneTransitionPanel>();
_panel.Caption = Caption;
_panel.MapIdent = MapIdent;
Push( SceneTransitionPhase.In, 0f );
}
protected override void OnUpdate()
{
_stateTime += Time.Delta;
switch ( _state )
{
case State.AnimatingIn:
float pin = MathF.Min( _stateTime / AnimateInDuration, 1f );
Push( SceneTransitionPhase.In, pin );
if ( pin >= 1f )
{
_state = State.Loading;
_stateTime = 0f;
Push( SceneTransitionPhase.Hold, 0f );
StartLoad();
}
break;
case State.Loading:
Push( SceneTransitionPhase.Hold, 0f );
var active = Game.ActiveScene;
if ( active.IsValid() && active.IsLoading )
_sawLoading = true;
// Done when: we saw it loading and it stopped, or the scene reference swapped out
// from under us (covers the missed-window case). MaxLoadWait is the hard fallback.
bool sceneSwapped = active.IsValid() && active != _initialScene;
bool settled = active.IsValid() && !active.IsLoading;
bool loaded = settled && (_sawLoading || sceneSwapped);
if ( (loaded && _stateTime >= MinHold) || _stateTime >= MaxLoadWait )
{
Sandbox.LoadingScreen.IsVisible = false;
_state = State.AnimatingOut;
_stateTime = 0f;
}
break;
case State.AnimatingOut:
float pout = MathF.Min( _stateTime / AnimateOutDuration, 1f );
Push( SceneTransitionPhase.Out, pout );
if ( pout >= 1f )
{
_state = State.Done;
if ( SceneTransition.Active == this )
SceneTransition.Active = null;
GameObject.Destroy();
}
break;
}
}
protected override void OnDestroy()
{
if ( SceneTransition.Active == this )
SceneTransition.Active = null;
}
private void StartLoad()
{
if ( _loadStarted )
return;
_loadStarted = true;
// Suppress the engine's default loading screen; our overlay is doing the job.
Sandbox.LoadingScreen.IsVisible = false;
// Host performs the networked scene change here, behind the cover. Clients pass null.
OnCovered?.Invoke();
}
private void Push( SceneTransitionPhase phase, float progress )
{
if ( _panel is null )
return;
_panel.Phase = phase;
_panel.Progress = progress;
}
}