This commit is contained in:
2026-06-07 11:49:55 +08:00
parent ff0f3bde54
commit 1897658a00
98 changed files with 9903 additions and 13907 deletions

View File

@@ -12,26 +12,64 @@ namespace BaseGames.UI
/// </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()
{
@@ -43,6 +81,9 @@ namespace BaseGames.UI
public void Show()
{
_shownAt = Time.unscaledTime;
_targetProgress = 0f;
_displayedProgress = 0f;
_isShowing = true;
if (_loadingRoot != null) _loadingRoot.SetActive(true);
if (_progressFill != null) _progressFill.fillAmount = 0f;
@@ -53,41 +94,76 @@ namespace BaseGames.UI
_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
if (_tipText != null && _tipMessages != null && _tipMessages.Length > 0)
_tipText.text = LocalizationManager.Get(_tipMessages[Random.Range(0, _tipMessages.Length)], LocalizationTable.UI);
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());
public void SetProgress(float value)
{
if (_progressFill != null)
_progressFill.fillAmount = Mathf.Clamp01(value);
}
/// <summary>接收真实加载进度。注意:进度条显示采用基于时间的"感知进度"(见 <see cref="Update"/>
/// 这里的真实值仅用于判定"是否已完成"(达到 1 时冲满)——因为 Addressables 单场景的真实进度
/// 高度前置、不适合直接驱动进度条。</summary>
public void SetProgress(float value) => _targetProgress = Mathf.Clamp01(value);
// ── 内部 ─────────────────────────────────────────────────────────────
// 缓存等待对象以避免典型路径(剩余时间 == _minDisplayTime下的重复分配。
private WaitForSecondsRealtime _cachedFullWait;
// 进度条采用"感知进度"未完成时按时间预算_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()
{
float elapsed = Time.unscaledTime - _shownAt;
float remaining = _minDisplayTime - elapsed;
if (remaining > 0f)
// 收尾:把目标推到满,让缓动平滑填满进度条后再隐藏。
_targetProgress = 1f;
// 同时满足两个条件才隐藏:① 至少显示 MinDisplay ② 进度条视觉已填满。
// 安全上限避免极端卡顿(缓动追不上 / 进度条缺失)时永不隐藏。
float minDisplay = MinDisplay;
float safetyDeadline = Time.unscaledTime + minDisplay + 1.5f;
while (Time.unscaledTime < safetyDeadline)
{
// 完整剩余 ≈ 显示时间时复用缓存对象;否则按需新建(罕见路径)。
if (Mathf.Approximately(remaining, _minDisplayTime))
{
_cachedFullWait ??= new WaitForSecondsRealtime(_minDisplayTime);
yield return _cachedFullWait;
}
else
{
yield return new WaitForSecondsRealtime(remaining);
}
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);
}
}