435 lines
17 KiB
C#
435 lines
17 KiB
C#
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 (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
|
||
|
||
/// <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 0–1 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
|
||
}
|
||
}
|