Feat - Add Loading menju

This commit is contained in:
2026-06-24 14:04:12 +02:00
parent c9b085ebea
commit 48588ccfed
935 changed files with 433163 additions and 602 deletions
+78
View File
@@ -0,0 +1,78 @@
using UnityEngine;
namespace Ashwild.UI
{
/// <summary>
/// Authoring container for the rotating quotes shown on the loading screen. A logic-free data
/// asset (mirrors MusicPlaylist): create one via Assets ▸ Create ▸ UI ▸ Loading Quotes, fill the
/// list in the inspector, and assign it to the LoadingScreen. Keeping the quotes here lets us add
/// or reword lines without touching code, and lets the loading screen pick from them at random.
/// </summary>
[CreateAssetMenu(fileName = "LoadingQuotes", menuName = "UI/Loading Quotes")]
public class LoadingQuotes : ScriptableObject
{
#region Types
/// <summary>
/// A single line shown on the loading screen: the quote itself plus an optional author shown
/// underneath. Leave the author empty for anonymous lines or plain loading tips.
/// </summary>
[System.Serializable]
public struct Quote
{
[TextArea] public string text;
public string author;
}
#endregion
#region Serialized Fields
[Header("Quotes")]
[Tooltip("The pool of quotes the loading screen cycles through at random.")]
[SerializeField] private Quote[] quotes;
#endregion
#region Public API
/// <summary>
/// True when at least one quote is authored — guards the loading screen against an empty asset.
/// </summary>
public bool HasQuotes => quotes != null && quotes.Length > 0;
/// <summary>
/// Number of authored quotes.
/// </summary>
public int Count => quotes != null ? quotes.Length : 0;
/// <summary>
/// Returns a random quote while avoiding the one at <paramref name="avoidIndex"/> so the same
/// line never shows twice in a row (unless there is only one quote). Outputs the chosen index
/// so the caller can feed it back in on the next call.
/// </summary>
public Quote GetRandom(int avoidIndex, out int chosenIndex)
{
if (!HasQuotes)
{
chosenIndex = -1;
return default;
}
if (quotes.Length == 1)
{
chosenIndex = 0;
return quotes[0];
}
int index = Random.Range(0, quotes.Length);
if (index == avoidIndex)
index = (index + 1) % quotes.Length;
chosenIndex = index;
return quotes[index];
}
#endregion
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8543f62e39304aa4cba9f993de5af91a
+286
View File
@@ -0,0 +1,286 @@
using UnityEngine;
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,
/// LocalPlayerSpawned lowers it, and SessionError/SessionStopped lower it as a safety net so a
/// failed host never leaves the player stuck behind the curtain.
/// </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;
[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
/// <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>
/// 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;
#endregion
#region Unity Lifecycle
/// <summary>
/// Enforces the singleton, persists across the menu→game scene swap, and starts hidden.
/// </summary>
private void Awake()
{
if (instance != null && instance != this)
{
Destroy(gameObject);
return;
}
instance = this;
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.SessionError += HandleSessionError;
PlayerEvents.SessionStopped += HandleSessionStopped;
}
/// <summary>
/// Unsubscribes — mirrors OnEnable exactly.
/// </summary>
private void OnDisable()
{
PlayerEvents.SessionStarting -= HandleSessionBeginning;
PlayerEvents.SessionJoining -= HandleSessionJoining;
PlayerEvents.LocalPlayerSpawned -= HandleLocalPlayerSpawned;
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();
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() => Show();
/// <summary>
/// Raises the curtain when the local player begins joining a remote session.
/// </summary>
private void HandleSessionJoining(string code) => Show();
/// <summary>
/// Lowers the curtain once the local player has spawned into the loaded game scene.
/// </summary>
private void HandleLocalPlayerSpawned() => Hide();
/// <summary>
/// Safety net: a connection failure must drop the curtain so the menu becomes usable again.
/// </summary>
private void HandleSessionError(string reason) => Hide();
/// <summary>
/// Safety net: if the session tears down before a player spawns, never leave the curtain up.
/// </summary>
private void HandleSessionStopped() => 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.
/// </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);
ShowNextQuote(instant: true);
StartQuoteCycle();
}
/// <summary>
/// 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.
/// </summary>
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
/// <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
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e417e8562dcadeb43933d3687febc562
@@ -1,5 +1,7 @@
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using Ashwild.Player;
using Ashwild.Settings;
namespace Ashwild.UI
@@ -28,6 +30,12 @@ namespace Ashwild.UI
[SerializeField] private Button inviteButton;
[SerializeField] private Button quitButton;
[Header("Session")]
[Tooltip("Displays the shareable room code (host SteamID-derived) of the current session.")]
[SerializeField] private TMP_Text roomCodeLabel;
[Tooltip("Shown in place of the code when no online session is active.")]
[SerializeField] private string offlinePlaceholder = "—";
#endregion
#region State
@@ -55,5 +63,35 @@ namespace Ashwild.UI
}
#endregion
#region Panel Visibility
/// <summary>
/// Refreshes the room code each time the pause menu opens, so it always reflects the
/// session that is live at that moment rather than a value cached at spawn.
/// </summary>
public override void Show()
{
base.Show();
RefreshRoomCode();
}
#endregion
#region Internal Helpers
/// <summary>
/// Writes the current shareable session code into the label, falling back to the offline
/// placeholder when no online session is active.
/// </summary>
private void RefreshRoomCode()
{
if (roomCodeLabel == null) return;
string code = PlayerEvents.SessionCode;
roomCodeLabel.text = string.IsNullOrEmpty(code) ? offlinePlaceholder : code;
}
#endregion
}
}
+68
View File
@@ -0,0 +1,68 @@
using TMPro;
using UnityEngine;
namespace Ashwild.UI
{
/// <summary>
/// Stamps the current build version onto a TextMeshPro label. Reads <see cref="Application.version"/>
/// (authored in Project Settings ▸ Player ▸ Version) so the displayed string always matches the
/// real build without anyone editing the scene. Drop this on any UGUI element that carries a
/// TextMeshProUGUI — typically a small corner label on the main menu, pause or loading canvas.
/// </summary>
[DisallowMultipleComponent]
[RequireComponent(typeof(TextMeshProUGUI))]
public class VersionLabel : MonoBehaviour
{
#region Serialized Fields
[Header("Target")]
[Tooltip("The label to write the version into. Auto-filled from this GameObject if left empty.")]
[SerializeField] private TextMeshProUGUI label;
[Header("Format")]
[Tooltip("Wraps the version. Use {0} as the placeholder for Application.version, e.g. \"v{0}\".")]
[SerializeField] private string format = "v{0}";
#endregion
#region Unity Lifecycle
/// <summary>
/// Caches the label reference so the component works even when assigned by RequireComponent.
/// </summary>
private void Awake()
{
if (label == null)
label = GetComponent<TextMeshProUGUI>();
}
/// <summary>
/// Writes the version each time the object is enabled, so re-opening a menu always shows it.
/// </summary>
private void OnEnable()
{
Refresh();
}
#endregion
#region Public API
/// <summary>
/// Composes the version string from the format and pushes it onto the label. Guards against a
/// missing label and logs the culprit so the broken object is selectable from the console.
/// </summary>
public void Refresh()
{
if (label == null)
{
Debug.LogError($"[VersionLabel] '{name}' has no TextMeshProUGUI assigned — cannot show version.", this);
return;
}
label.text = string.Format(format, Application.version);
}
#endregion
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a56065a3033ee664c845e94708384c4e