Files
zeling_v2/Assets/_Game/Scripts/UI/LoadingScreenManager.cs
2026-06-07 11:49:55 +08:00

171 lines
8.9 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 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 必须保持 activeOnEnable 才会执行并订阅上面的事件
// (管理器不能挂在被自己关闭的 GameObject 上,否则永不订阅、加载画面永不显示)。
// 因此这里只把内层遮罩根节点初始隐藏,显隐由 Show()/Hide() 切换 _loadingRoot。
_isShowing = false;
if (_loadingRoot != null) _loadingRoot.SetActive(false);
}
private void OnDisable()
{
_subs.Clear();
}
// ── 公开 APISceneLoader 可直接调用)────────────────────────────────
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);
}
}
}