using System.Collections; using UnityEngine; using UnityEngine.UI; using TMPro; using BaseGames.Core.Events; using BaseGames.Localization; namespace BaseGames.UI { /// /// 全屏加载界面:进度条 + 提示文字 + 随机背景图(架构 10_UIModule §7.7)。 /// 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()); /// 接收真实加载进度。注意:进度条显示采用基于时间的"感知进度"(见 ), /// 这里的真实值仅用于判定"是否已完成"(达到 1 时冲满)——因为 Addressables 单场景的真实进度 /// 高度前置、不适合直接驱动进度条。 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); } } }