171 lines
8.9 KiB
C#
171 lines
8.9 KiB
C#
using System.Collections;
|
||
using UnityEngine;
|
||
using UnityEngine.UI;
|
||
using TMPro;
|
||
using BaseGames.Core.Events;
|
||
using BaseGames.Localization;
|
||
|
||
namespace BaseGames.UI
|
||
{
|
||
/// <summary>
|
||
/// 全屏加载界面:进度条 + 提示文字 + 随机背景图(架构 10_UIModule §7.7)。
|
||
/// </summary>
|
||
public class LoadingScreenManager : MonoBehaviour
|
||
{
|
||
[Tooltip("数据配置(策划编辑):随机提示、标题、时长/手感。\n" +
|
||
"非空时优先于下方序列化默认值;为空则回退默认值(向后兼容)。\n" +
|
||
"视觉样式(图片/配色/布局)在 UI_LoadingScreen 预制件里由美术编辑,不在此表。")]
|
||
[SerializeField] private LoadingScreenConfigSO _config;
|
||
|
||
[Header("视图引用(由 UI_LoadingScreen 预制件绑定)")]
|
||
[SerializeField] private GameObject _loadingRoot;
|
||
[SerializeField] private Image _progressFill;
|
||
[SerializeField] private TMP_Text _titleText;
|
||
[SerializeField] private TMP_Text _tipText;
|
||
[SerializeField] private Image[] _backgroundArts;
|
||
|
||
[Header("默认值(无 _config 时回退使用)")]
|
||
[SerializeField] private string[] _tipMessages; // 本地化 key(对应 "UI" 表中的条目,如 "tip_explore")
|
||
[SerializeField] private float _minDisplayTime = 0.5f;
|
||
|
||
[Tooltip("进度条填充缓动速度(每秒可填充的比例),用于把显示值平滑推向目标、避免瞬跳;越大越快、越小越平滑。")]
|
||
[SerializeField] private float _fillLerpSpeed = 1.6f;
|
||
|
||
[Tooltip("预计加载时长(秒),用于‘感知进度’:进度条按时间平滑爬升至约 90%,真正加载完成时再冲满。\n" +
|
||
"原因:Addressables 对单个场景的真实进度高度前置(毫秒内跳到 ~90% 再长时间停滞),\n" +
|
||
"直接驱动会让进度条‘瞬间近满→卡死’;按时间爬升能保证进度条始终可见地推进。\n" +
|
||
"建议设为该游戏场景的典型加载耗时;偏长则爬得慢、偏短则更快到 90%。")]
|
||
[SerializeField] private float _expectedLoadTime = 2.5f;
|
||
|
||
[Header("Event Channels")]
|
||
[SerializeField] private VoidEventChannelSO _onLoadingStarted;
|
||
[SerializeField] private VoidEventChannelSO _onLoadingComplete;
|
||
[SerializeField] private FloatEventChannelSO _onLoadingProgressUpdated;
|
||
|
||
// 未完成时进度条按时间爬升的上限(约 90%),余下 10% 留给"加载完成"那一冲。
|
||
private const float kCreepCap = 0.9f;
|
||
|
||
private float _shownAt;
|
||
private float _targetProgress; // 真实进度 [0,1],仅用于判定"是否已完成"(达到 1 冲满)
|
||
private float _displayedProgress; // 进度条当前显示值(感知进度,缓动推进)
|
||
private bool _isShowing;
|
||
private readonly CompositeDisposable _subs = new();
|
||
|
||
// 配置取值:_config 优先,否则用组件上的序列化默认值(向后兼容)。
|
||
private float MinDisplay => _config != null ? _config.MinDisplayTime : _minDisplayTime;
|
||
private float ExpectedLoad => _config != null ? _config.ExpectedLoadTime : _expectedLoadTime;
|
||
private float FillSpeed => _config != null ? _config.FillLerpSpeed : _fillLerpSpeed;
|
||
private string[] TipKeys => (_config != null && _config.TipKeys != null && _config.TipKeys.Length > 0)
|
||
? _config.TipKeys : _tipMessages;
|
||
private string TitleKey => _config != null ? _config.TitleKey : null;
|
||
|
||
private void OnEnable()
|
||
{
|
||
_onLoadingStarted?.Subscribe(Show).AddTo(_subs);
|
||
_onLoadingComplete?.Subscribe(Hide).AddTo(_subs);
|
||
_onLoadingProgressUpdated?.Subscribe(SetProgress).AddTo(_subs);
|
||
|
||
// 关键:本组件的宿主 Canvas 必须保持 active,OnEnable 才会执行并订阅上面的事件
|
||
// (管理器不能挂在被自己关闭的 GameObject 上,否则永不订阅、加载画面永不显示)。
|
||
// 因此这里只把内层遮罩根节点初始隐藏,显隐由 Show()/Hide() 切换 _loadingRoot。
|
||
_isShowing = false;
|
||
if (_loadingRoot != null) _loadingRoot.SetActive(false);
|
||
}
|
||
private void OnDisable()
|
||
{
|
||
_subs.Clear();
|
||
}
|
||
|
||
// ── 公开 API(SceneLoader 可直接调用)────────────────────────────────
|
||
|
||
public void Show()
|
||
{
|
||
_shownAt = Time.unscaledTime;
|
||
_targetProgress = 0f;
|
||
_displayedProgress = 0f;
|
||
_isShowing = true;
|
||
if (_loadingRoot != null) _loadingRoot.SetActive(true);
|
||
if (_progressFill != null) _progressFill.fillAmount = 0f;
|
||
|
||
// 随机背景
|
||
if (_backgroundArts != null && _backgroundArts.Length > 0)
|
||
{
|
||
foreach (var bg in _backgroundArts) if (bg != null) bg.enabled = false;
|
||
_backgroundArts[Random.Range(0, _backgroundArts.Length)].enabled = true;
|
||
}
|
||
|
||
// 标题(config 提供 key;留空则隐藏 Title 节点)
|
||
if (_titleText != null)
|
||
{
|
||
string titleKey = TitleKey;
|
||
bool hasTitle = !string.IsNullOrEmpty(titleKey);
|
||
_titleText.gameObject.SetActive(hasTitle);
|
||
if (hasTitle)
|
||
_titleText.text = LocalizationManager.Get(titleKey, LocalizationTable.UI);
|
||
}
|
||
|
||
// 随机提示(通过 LocalizationManager 解析 key)
|
||
var tipKeys = TipKeys;
|
||
if (_tipText != null && tipKeys != null && tipKeys.Length > 0)
|
||
_tipText.text = LocalizationManager.Get(tipKeys[Random.Range(0, tipKeys.Length)], LocalizationTable.UI);
|
||
}
|
||
|
||
public void Hide() => StartCoroutine(HideAfterMinTime());
|
||
|
||
/// <summary>接收真实加载进度。注意:进度条显示采用基于时间的"感知进度"(见 <see cref="Update"/>),
|
||
/// 这里的真实值仅用于判定"是否已完成"(达到 1 时冲满)——因为 Addressables 单场景的真实进度
|
||
/// 高度前置、不适合直接驱动进度条。</summary>
|
||
public void SetProgress(float value) => _targetProgress = Mathf.Clamp01(value);
|
||
|
||
// ── 内部 ─────────────────────────────────────────────────────────────
|
||
|
||
// 进度条采用"感知进度":未完成时按时间预算(_expectedLoadTime)平滑爬升至 kCreepCap(~90%),
|
||
// 加载完成(_targetProgress 达到 1,由 SceneLoader 末帧或 Hide() 置位)时冲满到 1。
|
||
// 不直接跟随 Addressables 的真实 PercentComplete —— 它对单场景高度前置(毫秒内近满后长时间停滞),
|
||
// 直接映射会让进度条"瞬间近满→卡死"。使用 unscaledDeltaTime,不受 timeScale 影响。
|
||
private void Update()
|
||
{
|
||
if (!_isShowing || _progressFill == null) return;
|
||
|
||
float effectiveTarget;
|
||
if (_targetProgress >= 1f)
|
||
{
|
||
effectiveTarget = 1f; // 加载完成 → 冲满
|
||
}
|
||
else
|
||
{
|
||
float elapsed = Time.unscaledTime - _shownAt;
|
||
float expected = ExpectedLoad;
|
||
effectiveTarget = expected > 0f
|
||
? Mathf.Clamp01(elapsed / expected) * kCreepCap // 按时间爬升至 ~90%
|
||
: kCreepCap;
|
||
}
|
||
|
||
_displayedProgress = Mathf.MoveTowards(
|
||
_displayedProgress, effectiveTarget, FillSpeed * Time.unscaledDeltaTime);
|
||
_progressFill.fillAmount = _displayedProgress;
|
||
}
|
||
|
||
private IEnumerator HideAfterMinTime()
|
||
{
|
||
// 收尾:把目标推到满,让缓动平滑填满进度条后再隐藏。
|
||
_targetProgress = 1f;
|
||
|
||
// 同时满足两个条件才隐藏:① 至少显示 MinDisplay ② 进度条视觉已填满。
|
||
// 安全上限避免极端卡顿(缓动追不上 / 进度条缺失)时永不隐藏。
|
||
float minDisplay = MinDisplay;
|
||
float safetyDeadline = Time.unscaledTime + minDisplay + 1.5f;
|
||
while (Time.unscaledTime < safetyDeadline)
|
||
{
|
||
bool minTimeElapsed = Time.unscaledTime - _shownAt >= minDisplay;
|
||
bool barFilled = _progressFill == null || _displayedProgress >= 0.999f;
|
||
if (minTimeElapsed && barFilled) break;
|
||
yield return null;
|
||
}
|
||
|
||
_isShowing = false;
|
||
if (_loadingRoot != null) _loadingRoot.SetActive(false);
|
||
}
|
||
}
|
||
}
|