using UnityEngine; using TMPro; using DG.Tweening; using Ashwild.Player; namespace Ashwild.UI { /// /// Full-screen loading overlay shown while a session starts and the game scene loads over the /// network. It is deliberately NOT a UIPanel: panels live on a per-scene UIManager and are /// destroyed when their scene unloads, but FishNet loads the game scene with ReplaceOption.All — /// so the menu (and anything on it) is torn down mid-transition. This controller therefore marks /// itself DontDestroyOnLoad and survives the swap, covering the screen continuously from the save /// click until the local player has spawned. It hides the rotating menu camera and the scene /// switch behind a quote-cycling curtain. /// /// Place it on its own high-sorting-order Canvas in the menu scene (mirror SplashScreenController). /// It is driven entirely by the PlayerEvents bus: SessionStarting/SessionJoining raise it for the /// menu→game transition (lowered by LocalPlayerSpawned), and ReturningToMenu raises it for the /// game→menu transition (lowered by MenuReady once the menu has loaded). SessionError lowers it as /// a safety net so a failed host never leaves the player stuck behind the curtain; SessionStopped /// does the same except while a return-to-menu is in flight, where that teardown event is expected. /// [DisallowMultipleComponent] [RequireComponent(typeof(CanvasGroup))] public class LoadingScreen : MonoBehaviour { #region Serialized Fields [Header("References")] [Tooltip("Label that displays the current quote (and its author on a second line).")] [SerializeField] private TextMeshProUGUI quoteLabel; [Tooltip("Pool of quotes to cycle through. Assign a Loading Quotes asset.")] [SerializeField] private LoadingQuotes quotes; [Header("Curtain Fade")] [SerializeField] private float fadeInDuration = 0.35f; [SerializeField] private float fadeOutDuration = 0.5f; [SerializeField] private Ease fadeInEase = Ease.OutQuad; [SerializeField] private Ease fadeOutEase = Ease.InQuad; [Tooltip("Minimum time the curtain stays up once raised, so an instant load doesn't flash.")] [SerializeField] private float minimumDisplayTime = 1.5f; [Tooltip("Extra time the curtain holds after the ready signal (player spawned / menu loaded) " + "before fading out, so the scene's first heavy frames (object pop-in, shader warmup) " + "settle behind it instead of stuttering on screen. Stacks as the max with the " + "remaining minimum display time.")] [SerializeField] private float settleDelay = 0.75f; [Header("Quote Cycling")] [Tooltip("How long each quote stays on screen before swapping to the next.")] [SerializeField] private float quoteDuration = 4f; [Tooltip("Cross-fade duration when swapping one quote for the next.")] [SerializeField] private float quoteFadeDuration = 0.4f; #endregion #region State /// /// Single persistent instance — a second copy arriving with a reloaded menu scene self-destructs. /// private static LoadingScreen instance; private CanvasGroup canvasGroup; /// /// True while the curtain is up (or being raised). Guards against double-show / double-hide. /// private bool isShown; /// /// Unscaled timestamp at which the curtain was raised, used to enforce the minimum display time. /// private float shownAt; /// /// True while the curtain is covering a return-to-menu transition. SessionStopped fires during /// that teardown, so this flag tells the curtain to ignore it and wait for MenuReady instead — /// otherwise the curtain would drop the instant the session stopped, before the menu has loaded. /// private bool awaitingMenu; /// /// Index of the quote currently displayed, so the next pick can avoid repeating it. /// private int currentQuoteIndex = -1; private Tween curtainTween; private Sequence quoteSequence; #endregion #region Unity Lifecycle /// /// Enforces the singleton, persists across the menu→game scene swap, and starts hidden. /// /// Detaches to a root object before : that call is a /// no-op on a child, so a nested curtain would be destroyed the moment FishNet unloads the menu /// (ReplaceOption.All) — leaving the load uncovered and, later, no curtain at all to raise on /// the return to menu. Promoting it here keeps the scene authoring free to nest it under a /// layout object while still guaranteeing it survives every scene swap. /// private void Awake() { if (instance != null && instance != this) { Destroy(gameObject); return; } instance = this; transform.SetParent(null, false); DontDestroyOnLoad(gameObject); canvasGroup = GetComponent(); canvasGroup.alpha = 0f; canvasGroup.blocksRaycasts = false; canvasGroup.interactable = false; } /// /// Subscribes to the session lifecycle events that raise and lower the curtain. /// private void OnEnable() { PlayerEvents.SessionStarting += HandleSessionBeginning; PlayerEvents.SessionJoining += HandleSessionJoining; PlayerEvents.LocalPlayerSpawned += HandleLocalPlayerSpawned; PlayerEvents.ReturningToMenu += HandleReturningToMenu; PlayerEvents.MenuReady += HandleMenuReady; PlayerEvents.SessionError += HandleSessionError; PlayerEvents.SessionStopped += HandleSessionStopped; } /// /// Unsubscribes — mirrors OnEnable exactly. /// private void OnDisable() { PlayerEvents.SessionStarting -= HandleSessionBeginning; PlayerEvents.SessionJoining -= HandleSessionJoining; PlayerEvents.LocalPlayerSpawned -= HandleLocalPlayerSpawned; PlayerEvents.ReturningToMenu -= HandleReturningToMenu; PlayerEvents.MenuReady -= HandleMenuReady; PlayerEvents.SessionError -= HandleSessionError; PlayerEvents.SessionStopped -= HandleSessionStopped; } /// /// Kills any running tweens so nothing animates a destroyed target on teardown. /// private void OnDestroy() { curtainTween?.Kill(); quoteSequence?.Kill(); if (instance == this) instance = null; } #endregion #region Event Handlers /// /// Raises the curtain when the local player starts hosting a session. /// private void HandleSessionBeginning() { awaitingMenu = false; Show(); } /// /// Raises the curtain when the local player begins joining a remote session. /// private void HandleSessionJoining(string code) { awaitingMenu = false; Show(); } /// /// Raises the curtain over a return-to-menu transition and arms the wait for MenuReady, so the /// upcoming SessionStopped (part of the teardown) does not drop it prematurely. /// private void HandleReturningToMenu() { awaitingMenu = true; Show(); } /// /// Lowers the curtain once the local player has spawned into the loaded game scene. /// private void HandleLocalPlayerSpawned() => Hide(); /// /// Lowers the curtain once the menu scene has finished loading on a return-to-menu transition. /// private void HandleMenuReady() { awaitingMenu = false; Hide(); } /// /// Safety net: a connection failure must drop the curtain so the menu becomes usable again. /// private void HandleSessionError(string reason) { awaitingMenu = false; Hide(); } /// /// Safety net for a session that tears down before a player spawns. Skipped while returning to /// the menu, where SessionStopped is expected and the curtain must stay up until MenuReady. /// private void HandleSessionStopped() { if (awaitingMenu) return; Hide(); } #endregion #region Curtain /// /// Fades the curtain in (blocking input underneath) and starts cycling quotes. Ignored when /// already shown so re-entrant session events don't restart the animation. /// /// Raises only once the fade-in has fully /// completed: NetworkSessionManager waits for that signal before kicking off the heavy game /// scene load, so the unavoidable activation freeze always happens behind an already-opaque /// curtain instead of stalling the very frame the curtain was meant to appear on. Killing the /// tween early (an instant Hide) suppresses the signal, which is the intended behaviour. /// private void Show() { if (isShown) return; isShown = true; shownAt = Time.unscaledTime; curtainTween?.Kill(); canvasGroup.blocksRaycasts = true; canvasGroup.interactable = true; curtainTween = canvasGroup.DOFade(1f, fadeInDuration) .SetEase(fadeInEase) .SetUpdate(true) .OnComplete(PlayerEvents.RaiseLoadingCurtainShown); ShowNextQuote(instant: true); StartQuoteCycle(); } /// /// Fades the curtain out and stops the quote cycle. The fade is delayed by the larger of the /// remaining minimum display time (so an instant load still shows the curtain for a readable /// beat instead of flashing) and the settle delay (so the scene's first heavy frames after the /// ready signal happen behind the curtain rather than stuttering on screen). /// private void Hide() { if (!isShown) return; isShown = false; float elapsed = Time.unscaledTime - shownAt; float delay = Mathf.Max(settleDelay, minimumDisplayTime - elapsed); curtainTween?.Kill(); curtainTween = canvasGroup.DOFade(0f, fadeOutDuration) .SetDelay(delay) .SetEase(fadeOutEase) .SetUpdate(true) .OnComplete(() => { canvasGroup.blocksRaycasts = false; canvasGroup.interactable = false; StopQuoteCycle(); }); } #endregion #region Quotes /// /// Builds the looping cross-fade that swaps the quote every seconds. /// Runs on unscaled time so it keeps animating even while the load freezes the timescale. /// /// Bails out (leaving the first quote shown statically) when there is nothing to cycle or when /// is not positive: a looping DOTween sequence with a zero total /// duration replays infinitely within a single frame and hard-freezes the editor, so the /// positive-duration guard here is mandatory, not cosmetic. /// private void StartQuoteCycle() { StopQuoteCycle(); if (quotes == null || quotes.Count <= 1 || quoteLabel == null) return; if (quoteDuration <= 0f) return; float fade = Mathf.Max(0f, quoteFadeDuration); quoteSequence = DOTween.Sequence().SetUpdate(true); quoteSequence.AppendInterval(quoteDuration); quoteSequence.Append(quoteLabel.DOFade(0f, fade)); quoteSequence.AppendCallback(() => ShowNextQuote(instant: false)); quoteSequence.Append(quoteLabel.DOFade(1f, fade)); quoteSequence.SetLoops(-1); } /// /// Kills the running quote cycle, if any. /// private void StopQuoteCycle() { quoteSequence?.Kill(); quoteSequence = null; } /// /// Picks the next random quote and writes it to the label. When is /// true the label is forced fully opaque (used on the very first quote, before the cross-fade /// loop takes over); otherwise the surrounding sequence owns the alpha. /// private void ShowNextQuote(bool instant) { if (quotes == null || !quotes.HasQuotes || quoteLabel == null) return; LoadingQuotes.Quote quote = quotes.GetRandom(currentQuoteIndex, out currentQuoteIndex); quoteLabel.text = Format(quote); if (instant) { Color c = quoteLabel.color; c.a = 1f; quoteLabel.color = c; } } /// /// Composes the displayed string: the quote, plus an em-dashed author line when one is set. /// private static string Format(LoadingQuotes.Quote quote) { return string.IsNullOrWhiteSpace(quote.author) ? quote.text : $"{quote.text}\n— {quote.author}"; } #endregion } }