using UnityEngine; using UnityEngine.UI; 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; [Header("Progress Bar")] [Tooltip("Filled Image (Image Type = Filled) that fills as a loading-percentage indicator. " + "Optional — leave unassigned to disable the bar entirely.")] [SerializeField] private Image progressFill; [Tooltip("Optional label that mirrors the slider as a whole-number percentage (e.g. \"42%\").")] [SerializeField] private TextMeshProUGUI percentLabel; [Tooltip("Time the bar takes to ramp from empty to the fill cap while the network load runs. " + "The load has no real progress signal, so the bar fills steadily toward the cap and " + "only completes to 100% once the ready signal arrives.")] [SerializeField] private float fillDuration = 8f; [Tooltip("Value (0–1) the ramp stalls at while loading, leaving headroom so the bar never sits " + "at 100% before the scene is actually ready.")] [Range(0f, 1f)] [SerializeField] private float fillCap = 0.9f; [Tooltip("Time the bar takes to rush from the cap to 100% once the ready signal arrives, played " + "during the curtain's hold before it fades out.")] [SerializeField] private float fillCompleteDuration = 0.35f; [SerializeField] private Ease fillEase = Ease.OutSine; #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; private Tween progressTween; #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(); progressTween?.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(); StartProgress(); } /// /// 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); CompleteProgress(); curtainTween?.Kill(); curtainTween = canvasGroup.DOFade(0f, fadeOutDuration) .SetDelay(delay) .SetEase(fadeOutEase) .SetUpdate(true) .OnComplete(() => { canvasGroup.blocksRaycasts = false; canvasGroup.interactable = false; StopQuoteCycle(); }); } #endregion #region Progress Bar /// /// Resets the bar to empty and starts the steady ramp toward over /// . The network load exposes no real progress, so this is a paced /// estimate that deliberately stops short of full — finishes it /// once the scene is genuinely ready. Runs on unscaled time so it keeps moving while the load /// freezes the timescale. No-op when no slider is assigned. /// private void StartProgress() { if (progressFill == null) return; progressTween?.Kill(); SetProgress(0f); progressTween = DOTween.To(SetProgress, 0f, fillCap, fillDuration) .SetEase(fillEase) .SetUpdate(true); } /// /// Rushes the bar from wherever the ramp stalled up to 100%, played during the curtain's hold /// before it fades out so the bar reads as a completed load. No-op when no slider is assigned. /// private void CompleteProgress() { if (progressFill == null) return; progressTween?.Kill(); progressTween = DOTween.To(SetProgress, progressFill.fillAmount, 1f, fillCompleteDuration) .SetEase(Ease.OutSine) .SetUpdate(true); } /// /// Writes a 0–1 value to the filled image's fillAmount and mirrors it onto the optional /// percentage label. /// private void SetProgress(float value) { if (progressFill != null) progressFill.fillAmount = value; if (percentLabel != null) percentLabel.text = $"{Mathf.RoundToInt(value * 100f)}%"; } #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 } }