完整启动流程

This commit is contained in:
2026-05-19 23:20:44 +08:00
parent d0a1112737
commit 5fd981f5b9
22 changed files with 1938 additions and 14 deletions

View File

@@ -63,6 +63,8 @@ namespace BaseGames.Core.Assets
public const string Poolable = "Poolable";
public const string BGM = "BGM";
public const string Charms = "Charms";
/// <summary>游戏启动时预热下载的资产标签BootSequencer 使用)。</summary>
public const string Preload = "Preload";
}
}
}

View File

@@ -0,0 +1,104 @@
using System.Collections;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
using BaseGames.Core.Events;
namespace BaseGames.Core
{
/// <summary>
/// 游戏启动序列编排器(挂载在 Persistent 场景)。
///
/// 职责:
/// 1. 触发 Splash 演出(通过 EVT_SplashStartRequest 事件,与 BaseGames.UI 程序集解耦)。
/// 2. 并行预热 Addressable 依赖(标签 <see cref="Assets.AddressKeys.Labels.Preload"/>)。
/// 3. 两者均完成后协程返回,由 <see cref="GameManager"/> 继续加载主菜单场景。
///
/// 配置说明Inspector
/// _preloadLabel — 留空则跳过预热,游戏仍可正常启动。
/// _onSplashStartRequest / _onSplashComplete — 绑定同名 SO 资产;
/// 若留空则视为"无 Splash"直接进入主菜单。
/// </summary>
[DefaultExecutionOrder(-800)]
public class BootSequencer : MonoBehaviour
{
[Header("Addressable 预热(可选)")]
[Tooltip("填写 Addressable 标签名(如 'Preload')以在启动时预热该标签下的所有依赖;留空则跳过。")]
[SerializeField] private AssetLabelReference _preloadLabel;
[Header("Event Channels - Raise")]
[Tooltip("发布后 SplashScreenController 开始播放演出。留空则不显示 Splash。")]
[SerializeField] private VoidEventChannelSO _onSplashStartRequest;
[Tooltip("预热进度 [0,1]")]
[SerializeField] private FloatEventChannelSO _onPreloadProgress;
[Header("Event Channels - Listen")]
[Tooltip("SplashScreenController 演出结束时发布此事件。留空则不等待 Splash 完成。")]
[SerializeField] private VoidEventChannelSO _onSplashComplete;
private bool _splashDone;
private readonly CompositeDisposable _subs = new();
// ── 生命周期 ─────────────────────────────────────────────────────────
private void OnEnable()
{
_onSplashComplete?.Subscribe(() => _splashDone = true).AddTo(_subs);
}
private void OnDisable() => _subs.Clear();
// ── 公开 API供 GameManager 通过 yield 驱动)────────────────────────
/// <summary>
/// 运行完整启动序列Splash 演出 + Addressable 预热并行执行)。
/// 两者均完成后协程返回GameManager 随后加载主菜单场景并切换 FSM 状态。
/// </summary>
public IEnumerator RunBootSequenceCoroutine()
{
// 若未绑定 SplashComplete 频道,则无需等待 Splash直接视为已完成
_splashDone = (_onSplashComplete == null);
// 触发 Splash 演出SplashScreenController 订阅此事件)
_onSplashStartRequest?.Raise();
// 并行启动 Addressable 预热
bool preloadDone = false;
StartCoroutine(PreloadCoroutine(() => preloadDone = true));
// 等待两者均完成
yield return new WaitUntil(() => _splashDone && preloadDone);
}
// ── 内部Addressable 预热 ────────────────────────────────────────────
private IEnumerator PreloadCoroutine(System.Action onDone)
{
if (_preloadLabel == null || string.IsNullOrEmpty(_preloadLabel.labelString))
{
// 无预热配置,立即完成
_onPreloadProgress?.Raise(1f);
onDone?.Invoke();
yield break;
}
// 仅下载依赖(不实例化),最小化内存占用
var handle = Addressables.DownloadDependenciesAsync(_preloadLabel, false);
while (!handle.IsDone)
{
// 保留 10% 余量,避免 PercentComplete 到 0.9 后卡顿
_onPreloadProgress?.Raise(handle.PercentComplete * 0.9f);
yield return null;
}
if (handle.Status != AsyncOperationStatus.Succeeded)
Debug.LogWarning("[BootSequencer] Addressable 预热部分失败,游戏将继续启动。" +
" 请检查 Addressable 标签配置与网络连接。");
_onPreloadProgress?.Raise(1f);
Addressables.Release(handle);
onDone?.Invoke();
}
}
}

View File

@@ -1,5 +1,6 @@
using System.Collections;
using UnityEngine;
using BaseGames.Core.Assets;
using BaseGames.Core.Events;
using BaseGames.Core.States;
@@ -16,6 +17,7 @@ namespace BaseGames.Core
// ── Inspector 引用 ────────────────────────────────────────────────
[Header("Managers")]
[SerializeField] private SettingsManager _settingsManager;
[SerializeField] private BootSequencer _bootSequencer;
[Header("Event Channels - Listen")]
[SerializeField] private VoidEventChannelSO _onPlayerDied;
@@ -24,6 +26,10 @@ namespace BaseGames.Core
[SerializeField] private StringEventChannelSO _onBossFightStarted;
[SerializeField] private BoolEventChannelSO _onBossFightEnded;
[SerializeField] private VoidEventChannelSO _onDeathScreenConfirmed;
[Tooltip("所有场景切换请求(与 SceneService 共享同一 SO")]
[SerializeField] private SceneLoadRequestEventChannelSO _onSceneLoadRequest;
[Tooltip("SceneLoader 完成加载后发布(携带场景名称)")]
[SerializeField] private StringEventChannelSO _onSceneLoaded;
[Header("Event Channels - Raise")]
[SerializeField] private BaseEventChannelSO<GameStateId> _onGameStateChanged;
@@ -51,8 +57,30 @@ namespace BaseGames.Core
private void Start()
{
// 在 Start 广播初始状态,确保其他组件已在 OnEnable 中完成订阅。
// 广播初始状态,确保其他组件已在 OnEnable 中完成订阅。
_onGameStateChanged?.Raise(new Events.GameStateId(GameStates.Initializing.Id));
// 启动引导序列Splash → 预热 → 加载主菜单场景 → 切换到 MainMenu 状态
StartCoroutine(BootCoroutine());
}
/// <summary>
/// 游戏启动引导协程:
/// 1. 运行 BootSequencerSplash + Addressable 预热)
/// 2. 通过 ISceneService 加载主菜单场景
/// 3. SceneLoader 完成后发布 _onSceneLoaded → HandleSceneLoaded 切换 FSM 到 MainMenu
/// </summary>
private IEnumerator BootCoroutine()
{
if (_bootSequencer != null)
yield return StartCoroutine(_bootSequencer.RunBootSequenceCoroutine());
var sceneService = ServiceLocator.GetOrDefault<ISceneService>();
if (sceneService != null)
yield return StartCoroutine(sceneService.LoadMainMenuCoroutine());
else
Debug.LogError("[GameManager] ISceneService 未注册,无法加载主菜单。" +
" 请检查 Persistent 场景的 GameServiceRegistrar 配置。");
}
private void OnEnable()
@@ -63,6 +91,8 @@ namespace BaseGames.Core
_onBossFightStarted? .Subscribe(HandleBossFightStarted).AddTo(_subs);
_onBossFightEnded? .Subscribe(HandleBossFightEnded).AddTo(_subs);
_onDeathScreenConfirmed?.Subscribe(HandleDeathScreenConfirmed).AddTo(_subs);
_onSceneLoadRequest? .Subscribe(HandleSceneLoadRequest).AddTo(_subs);
_onSceneLoaded? .Subscribe(HandleSceneLoaded).AddTo(_subs);
}
private void OnDisable() => _subs.Clear();
@@ -125,6 +155,45 @@ namespace BaseGames.Core
else RequestTransition(GameStates.GameOver);
}
// ── 场景加载状态同步 ──────────────────────────────────────────────
/// <summary>
/// 当 SceneService 开始加载主要场景(非 Room 过渡)时,更新 FSM 到 LoadingScene 状态。
/// Room 过渡TransitionType.Room不经过此处状态保持 Gameplay。
/// </summary>
private void HandleSceneLoadRequest(SceneLoadRequest request)
{
if (request.TransitionType != TransitionType.Scene) return;
// 返回主菜单不经过 LoadingScene 中间状态_onSceneLoaded 会直接处理)
if (request.SceneName == AddressKeys.SceneMainMenu) return;
var cur = _fsm.CurrentStateId;
if (cur == GameStates.MainMenu
|| cur == GameStates.Gameplay
|| cur == GameStates.BossFight)
{
RequestTransition(GameStates.LoadingScene);
}
}
/// <summary>
/// SceneLoader 完成加载后根据目标场景名自动切换 FSM 状态:
/// SceneMainMenu → MainMenu适用于 Initializing / Paused / GameOver / LoadingScene
/// 其他场景 → Gameplay仅当处于 LoadingScene 时Room 过渡不受影响)
/// </summary>
private void HandleSceneLoaded(string sceneName)
{
if (sceneName == AddressKeys.SceneMainMenu)
{
RequestTransition(GameStates.MainMenu);
return;
}
if (_fsm.CurrentStateId == GameStates.LoadingScene)
RequestTransition(GameStates.Gameplay);
}
private bool _deathScreenConfirmed;
private GameStateId _prePauseState = GameStates.Gameplay;
private void HandleDeathScreenConfirmed() => _deathScreenConfirmed = true;

View File

@@ -20,19 +20,39 @@ namespace BaseGames.Core
[Header("Event Channels - Raise")]
[SerializeField] private StringEventChannelSO _onSceneLoaded;
[Header("Event Channels - Raise加载画面")]
[Tooltip("ShowLoadingScreen=true 时,在加载开始时发布。")]
[SerializeField] private VoidEventChannelSO _onLoadingStarted;
[Tooltip("ShowLoadingScreen=true 时,在加载完成时发布。")]
[SerializeField] private VoidEventChannelSO _onLoadingComplete;
[Tooltip("ShowLoadingScreen=true 时,持续发布加载进度 [0,1]")]
[SerializeField] private FloatEventChannelSO _onLoadingProgressUpdated;
private string _currentRoomScene;
private AsyncOperationHandle<SceneInstance> _currentHandle;
public IEnumerator LoadSceneCoroutine(SceneLoadRequest request)
{
if (request.ShowLoadingScreen)
_onLoadingStarted?.Raise();
// 先加载新场景Additive成功后再卸载旧场景
// 顺序保证:若加载失败,旧场景仍保持可用,不会出现无场景的空状态
var loadOp = Addressables.LoadSceneAsync(request.SceneName, LoadSceneMode.Additive);
yield return loadOp;
// 逐帧轮询以上报进度(不能直接 yield return loadOp那样无法回调进度
while (!loadOp.IsDone)
{
if (request.ShowLoadingScreen)
_onLoadingProgressUpdated?.Raise(loadOp.PercentComplete * 0.9f);
yield return null;
}
if (loadOp.Status != AsyncOperationStatus.Succeeded)
{
Debug.LogError($"[SceneLoader] 加载场景失败:{request.SceneName}(旧场景保持不变)");
if (request.ShowLoadingScreen)
_onLoadingComplete?.Raise();
yield break;
}
@@ -45,6 +65,13 @@ namespace BaseGames.Core
_currentHandle = loadOp;
_currentRoomScene = request.SceneName;
if (request.ShowLoadingScreen)
{
_onLoadingProgressUpdated?.Raise(1f);
_onLoadingComplete?.Raise();
}
_onSceneLoaded?.Raise(request.SceneName);
}