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, /// LocalPlayerSpawned lowers it, and SessionError/SessionStopped lower it as a safety net so a /// failed host never leaves the player stuck behind the curtain. /// [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; [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; /// /// 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. /// private void Awake() { if (instance != null && instance != this) { Destroy(gameObject); return; } instance = this; 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.SessionError += HandleSessionError; PlayerEvents.SessionStopped += HandleSessionStopped; } /// /// Unsubscribes — mirrors OnEnable exactly. /// private void OnDisable() { PlayerEvents.SessionStarting -= HandleSessionBeginning; PlayerEvents.SessionJoining -= HandleSessionJoining; PlayerEvents.LocalPlayerSpawned -= HandleLocalPlayerSpawned; 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() => Show(); /// /// Raises the curtain when the local player begins joining a remote session. /// private void HandleSessionJoining(string code) => Show(); /// /// Lowers the curtain once the local player has spawned into the loaded game scene. /// private void HandleLocalPlayerSpawned() => Hide(); /// /// Safety net: a connection failure must drop the curtain so the menu becomes usable again. /// private void HandleSessionError(string reason) => Hide(); /// /// Safety net: if the session tears down before a player spawns, never leave the curtain up. /// private void HandleSessionStopped() => 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. /// 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); ShowNextQuote(instant: true); StartQuoteCycle(); } /// /// Fades the curtain out and stops the quote cycle. Honours the minimum display time so an /// instant load still shows the loading screen for a readable beat instead of flashing. /// private void Hide() { if (!isShown) return; isShown = false; float elapsed = Time.unscaledTime - shownAt; float delay = Mathf.Max(0f, 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 } }