Files
Emberwild/Assets/GAME/Script/UI/LoadingScreen.cs
T
2026-06-25 08:25:32 +02:00

435 lines
17 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using DG.Tweening;
using Ashwild.Player;
namespace Ashwild.UI
{
/// <summary>
/// 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.
/// </summary>
[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 (01) 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
/// <summary>
/// Single persistent instance — a second copy arriving with a reloaded menu scene self-destructs.
/// </summary>
private static LoadingScreen instance;
private CanvasGroup canvasGroup;
/// <summary>
/// True while the curtain is up (or being raised). Guards against double-show / double-hide.
/// </summary>
private bool isShown;
/// <summary>
/// Unscaled timestamp at which the curtain was raised, used to enforce the minimum display time.
/// </summary>
private float shownAt;
/// <summary>
/// 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.
/// </summary>
private bool awaitingMenu;
/// <summary>
/// Index of the quote currently displayed, so the next pick can avoid repeating it.
/// </summary>
private int currentQuoteIndex = -1;
private Tween curtainTween;
private Sequence quoteSequence;
private Tween progressTween;
#endregion
#region Unity Lifecycle
/// <summary>
/// Enforces the singleton, persists across the menu→game scene swap, and starts hidden.
///
/// Detaches to a root object before <see cref="Object.DontDestroyOnLoad"/>: 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.
/// </summary>
private void Awake()
{
if (instance != null && instance != this)
{
Destroy(gameObject);
return;
}
instance = this;
transform.SetParent(null, false);
DontDestroyOnLoad(gameObject);
canvasGroup = GetComponent<CanvasGroup>();
canvasGroup.alpha = 0f;
canvasGroup.blocksRaycasts = false;
canvasGroup.interactable = false;
}
/// <summary>
/// Subscribes to the session lifecycle events that raise and lower the curtain.
/// </summary>
private void OnEnable()
{
PlayerEvents.SessionStarting += HandleSessionBeginning;
PlayerEvents.SessionJoining += HandleSessionJoining;
PlayerEvents.LocalPlayerSpawned += HandleLocalPlayerSpawned;
PlayerEvents.ReturningToMenu += HandleReturningToMenu;
PlayerEvents.MenuReady += HandleMenuReady;
PlayerEvents.SessionError += HandleSessionError;
PlayerEvents.SessionStopped += HandleSessionStopped;
}
/// <summary>
/// Unsubscribes — mirrors OnEnable exactly.
/// </summary>
private void OnDisable()
{
PlayerEvents.SessionStarting -= HandleSessionBeginning;
PlayerEvents.SessionJoining -= HandleSessionJoining;
PlayerEvents.LocalPlayerSpawned -= HandleLocalPlayerSpawned;
PlayerEvents.ReturningToMenu -= HandleReturningToMenu;
PlayerEvents.MenuReady -= HandleMenuReady;
PlayerEvents.SessionError -= HandleSessionError;
PlayerEvents.SessionStopped -= HandleSessionStopped;
}
/// <summary>
/// Kills any running tweens so nothing animates a destroyed target on teardown.
/// </summary>
private void OnDestroy()
{
curtainTween?.Kill();
quoteSequence?.Kill();
progressTween?.Kill();
if (instance == this) instance = null;
}
#endregion
#region Event Handlers
/// <summary>
/// Raises the curtain when the local player starts hosting a session.
/// </summary>
private void HandleSessionBeginning()
{
awaitingMenu = false;
Show();
}
/// <summary>
/// Raises the curtain when the local player begins joining a remote session.
/// </summary>
private void HandleSessionJoining(string code)
{
awaitingMenu = false;
Show();
}
/// <summary>
/// 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.
/// </summary>
private void HandleReturningToMenu()
{
awaitingMenu = true;
Show();
}
/// <summary>
/// Lowers the curtain once the local player has spawned into the loaded game scene.
/// </summary>
private void HandleLocalPlayerSpawned() => Hide();
/// <summary>
/// Lowers the curtain once the menu scene has finished loading on a return-to-menu transition.
/// </summary>
private void HandleMenuReady()
{
awaitingMenu = false;
Hide();
}
/// <summary>
/// Safety net: a connection failure must drop the curtain so the menu becomes usable again.
/// </summary>
private void HandleSessionError(string reason)
{
awaitingMenu = false;
Hide();
}
/// <summary>
/// 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.
/// </summary>
private void HandleSessionStopped()
{
if (awaitingMenu) return;
Hide();
}
#endregion
#region Curtain
/// <summary>
/// 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 <see cref="PlayerEvents.LoadingCurtainShown"/> 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.
/// </summary>
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();
}
/// <summary>
/// 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).
/// </summary>
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
/// <summary>
/// Resets the bar to empty and starts the steady ramp toward <see cref="fillCap"/> over
/// <see cref="fillDuration"/>. The network load exposes no real progress, so this is a paced
/// estimate that deliberately stops short of full — <see cref="CompleteProgress"/> 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.
/// </summary>
private void StartProgress()
{
if (progressFill == null) return;
progressTween?.Kill();
SetProgress(0f);
progressTween = DOTween.To(SetProgress, 0f, fillCap, fillDuration)
.SetEase(fillEase)
.SetUpdate(true);
}
/// <summary>
/// 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.
/// </summary>
private void CompleteProgress()
{
if (progressFill == null) return;
progressTween?.Kill();
progressTween = DOTween.To(SetProgress, progressFill.fillAmount, 1f, fillCompleteDuration)
.SetEase(Ease.OutSine)
.SetUpdate(true);
}
/// <summary>
/// Writes a 01 value to the filled image's fillAmount and mirrors it onto the optional
/// percentage label.
/// </summary>
private void SetProgress(float value)
{
if (progressFill != null) progressFill.fillAmount = value;
if (percentLabel != null) percentLabel.text = $"{Mathf.RoundToInt(value * 100f)}%";
}
#endregion
#region Quotes
/// <summary>
/// Builds the looping cross-fade that swaps the quote every <see cref="quoteDuration"/> 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
/// <see cref="quoteDuration"/> 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.
/// </summary>
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);
}
/// <summary>
/// Kills the running quote cycle, if any.
/// </summary>
private void StopQuoteCycle()
{
quoteSequence?.Kill();
quoteSequence = null;
}
/// <summary>
/// Picks the next random quote and writes it to the label. When <paramref name="instant"/> 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.
/// </summary>
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;
}
}
/// <summary>
/// Composes the displayed string: the quote, plus an em-dashed author line when one is set.
/// </summary>
private static string Format(LoadingQuotes.Quote quote)
{
return string.IsNullOrWhiteSpace(quote.author)
? quote.text
: $"{quote.text}\n<size=80%>— {quote.author}</size>";
}
#endregion
}
}