UI/SceneTransition.cs

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.

NetworkingFile Access
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;
	}
}