Files
zeling_v2/Docs/Design/11_GameManager.md
2026-05-08 11:04:00 +08:00

20 KiB
Raw Permalink Blame History

11 · 游戏管理器与应用生命周期

命名空间 BaseGames.Core
所属文档集 ← 返回索引 · 总览
依赖 BaseGames.Core.Events · BaseGames.WorldSaveSystem· BaseGames.UILoadingOverlay


目录

  1. 职责概述
  2. GameState 枚举
  3. 状态转换规则
  4. 初始化序列
  5. 死亡与复活流程
  6. Boss 战管理
  7. 暂停管理
  8. 场景加载编排
  9. 快速旅行Bench 传送)
  10. 事件频道
  11. 设计约束

1. 职责概述

GameManager 是游戏应用生命周期编排器,是唯一持有全局 GameState 的对象:

职责 说明
管理 GameState 定义合法状态转换,广播 OnGameStateChanged
死亡复活编排 协调 SaveSystem + SceneLoader + UIManager 完成完整复活流程
暂停 / 恢复 控制 Time.timeScale,切换输入 Action Map
Boss 战切换 触发 BossFight 状态,联动 BGM / 镜头 / UI
快速旅行编排 Bench 选择目标 → 加载过渡 → 传送
应用启动初始化 按序初始化各子系统(见 §4

GameManager 不是服务定位器:不暴露子系统引用(GameManager.AudioManagerGameManager.UIManager子系统通过事件频道通信GameManager 仅协调时序。


2. GameState 枚举

namespace BaseGames.Core
{
    public enum GameState
    {
        Initializing,   // 应用启动,资源初始化中
        MainMenu,       // 主菜单
        LoadingScene,   // 场景异步加载中(房间切换 / 死亡后重载 / 快速旅行)
        Gameplay,       // 正常游玩
        BossFight,      // Boss 战Gameplay 子状态BGM/镜头特殊处理)
        Paused,         // 暂停Time.timeScale = 0
        Dead,           // 玩家死亡动画 + 死亡画面播放中
        Cutscene,       // 过场动画P1
    }
}

3. 状态转换规则

合法转换表 允许, 禁止):

当前 → 目标 MainMenu LoadingScene Gameplay BossFight Paused Dead Cutscene
Initializing
MainMenu
LoadingScene
Gameplay
BossFight
Paused
Dead
Cutscene
public void TransitionTo(GameState newState)
{
    if (!IsValidTransition(_currentState, newState))
    {
        Debug.LogWarning($"[GameManager] 非法状态转换: {_currentState} → {newState}");
        return;
    }
    _currentState = newState;
    _onGameStateChanged.Raise(newState);   // 广播给 UIManager / AudioManager / 等
}

4. 初始化序列

GameManager 使用 DefaultExecutionOrder(-1000),在所有其他 Awake() 之前执行:

Awake():
  1. 设置 DontDestroyOnLoad(gameObject)Persistent 场景随 GameManager 常驻)
  2. Application.targetFrameRate = 60
  3. Physics2D.simulationMode = Script手动控制物理步进与 FixedUpdate 解耦)
  4. SaveManager.Initialize()          (读取或创建存档文件)
  5. AudioManager.Initialize()         (加载 AudioMixer 快照)
  6. ObjectPoolManager.Initialize()    (预热常用对象池)
  7. SettingsManager.Initialize()      (读取设置,应用音量/分辨率等)
  8. TransitionTo(GameState.Initializing)

Start():
  9. 等待首帧渲染完成yield return null
  10. TransitionTo(GameState.MainMenu)
      → UIManager 收到事件:显示 MainMenuPanel
      → AudioManager 收到事件:播放主菜单 BGM

5. 死亡与复活流程

死亡复活是跨系统编排最复杂的流程,由 GameManager 通过 Coroutine 统一控制时序。

触发链

1. PlayerHurtState 检测 HP <= 0
   → OnPlayerDied.Raise()

2. GameManager.OnPlayerDied() 响应
   → TransitionTo(GameState.Dead)
   → 禁用输入InputReaderSO.EnableGameplayInput(false)
     ├─ UIManager: DeathScreenPanel 开始渐入0.8s
     ├─ AudioManager: BGM 渐出1.0s
     └─ Geo 掉落,若启用 Shade 机制→P1

3. 等待死亡动画播放完成yield WaitForSecondsRealtime(1.2f)

4. DeathScreen 完全可见WaitForSecondsRealtime(0.8f)

5. 等待玩家按键确认(或 WaitForSecondsRealtime(3f) 超时自动继续)

6. LoadingOverlay.FadeIn()0.3sunscaledTime

7. SceneLoader.LoadCheckpointScene()
   → 读取 SaveData.currentCheckpointScene
   → 异步 Unload 当前房间Additive Load 存档点房间
   → TransitionTo(GameState.LoadingScene)

8. 场景加载完成
   → SaveSystem.RestoreGameState()(还原 HP、Soul、Geo、玩家坐标到存档点值
   → DeathScreenPanel.SetActive(false)
   → LoadingOverlay.FadeOut()0.3s
   → InputReaderSO.EnableGameplayInput(true)
   → TransitionTo(GameState.Gameplay)
   → OnPlayerRespawned.Raise()
     ├─ PlayerController: 播放复活站起动画
     └─ AudioManager: 恢复区域 BGM淡入 1.0s

关键时序图

 0.0s │ OnPlayerDied → TransitionTo(Dead),死亡动画开始
      │
 1.2s │ 死亡动画结束DeathScreen 渐入中
      │
 2.0s │ DeathScreen 全黑可见,等待输入
      │
 ~2.xs│ 玩家按键(或 3s 超时)
      │
 +0.0s│ LoadingOverlay 开始淡入
 +0.3s│ 全黑场景开始异步加载TransitionTo LoadingScene
      │
async │ 场景卸载 + 加载中...
      │
 done │ 还原存档状态
      │ LoadingOverlay 淡出0.3s
      │ TransitionTo(Gameplay)
      │ OnPlayerRespawned.Raise()

6. Boss 战管理

入场流程

BossTrigger 挂载在 Boss 房间入口触发器上:

// BossTrigger.cs挂载在 Boss 房间入口 Trigger Collider
void OnTriggerEnter2D(Collider2D other)
{
    if (!other.CompareTag("Player")) return;

    // 1. 锁门:激活 Boss 房间入口碰撞体,阻止玩家退出
    _entranceDoor.SetActive(true);

    // 2. 通知 BossEntity 激活BT 开始运行)
    _bossEntity.ActivateBoss(_bossMaxHP);

    // 3. 广播 Boss 相关信息
    _onBossNameSet.Raise(_bossDisplayName);
    _onBossHPMaxSet.Raise(_bossMaxHP);
    _onBossFightToggled.Raise(true);

    // 4. GameManager 状态切换
    GameManager.Instance.TransitionTo(GameState.BossFight);
    // → AudioManager: BGM 切换到 Boss 主题(淡出旧 BGM 1s淡入新 BGM
    // → CameraStateController: 切换到 Boss VCamPriority 40 → 50
    // → UIManager: BossHPBar 滑入
}

击败流程

BossBase.OnBossDefeated() 协程:

1. 播放 Boss 死亡动画BossDeathAnimancerState
2. yield WaitForSeconds(死亡动画时长)
3. 解锁入口门entranceDoor.SetActive(false)
4. 掉落奖励(大量 Geo Collectible 爆散 + AbilityUnlock 物件生成)
5. SaveData 记录 defeatedBosses[bossId] = true → SaveManager.Save()
6. OnBossFightToggled.Raise(false) → UIManager BossHPBar 淡出
7. 激活该 Boss 房间内的 SavePoint若存在
8. GameManager.Instance.TransitionTo(GameState.Gameplay)
   → AudioManager: 播放胜利 Sting3s→ 之后淡入区域 BGM
   → CameraStateController: 恢复普通 VCam

Boss 阶段切换

// BossBase继承 EnemyBase内部监听 HP 变化:
void OnHPChanged(int newHP)
{
    if (_currentPhase >= _phaseConfig.phases.Length - 1) return;
    if ((float)newHP / _maxHP <= _phaseConfig.phases[_currentPhase + 1].hpThreshold)
    {
        _currentPhase++;
        StartCoroutine(PhaseTransition());
        _onBossPhaseChanged.Raise(_currentPhase);
    }
}

IEnumerator PhaseTransition()
{
    // BT 暂停 → 播放阶段过渡动画 → 数值重置(速度/伤害提升)→ BT 恢复
    _behaviorTree.DisableBehavior();
    yield return PlayAnimationAndWait(_phaseTransitionClip);
    ApplyPhaseStats(_phaseConfig.phases[_currentPhase]);
    _behaviorTree.EnableBehavior();
}

7. 暂停管理

// GameManager.cs
public void PauseGame()
{
    if (_currentState != GameState.Gameplay && _currentState != GameState.BossFight) return;
    _stateBeforePause = _currentState;
    Time.timeScale = 0f;
    TransitionTo(GameState.Paused);
    _inputReader.EnableUIInput();      // 切换到 UI Action Map菜单按钮可响应
    _inputReader.EnableGameplayInput(false);
}

public void ResumeGame()
{
    if (_currentState != GameState.Paused) return;
    Time.timeScale = 1f;
    TransitionTo(_stateBeforePause);
    _inputReader.EnableGameplayInput(true);
    _inputReader.EnableUIInput(false);
}

注意MMF_Player.TimescaleIndependent = true 已在 Feel 设置中为所有菜单动画启用,保证暂停时 UI 动画(菜单弹入、按钮 hover 等)不受 timeScale = 0 影响(使用 Time.unscaledDeltaTime)。


8. 场景加载编排

SceneLoader 组件(BaseGames.Core)封装 Addressables 异步加载细节GameManager 只调用高层接口:

// SceneLoader.cs
public class SceneLoader : MonoBehaviour
{
    // 房间切换RoomTransition 调用)
    public IEnumerator LoadRoom(string roomAddress, Vector2 spawnPos, RoomTransition.Side spawnSide);

    // 死亡复活(加载存档点所在场景)
    public IEnumerator LoadCheckpointScene();

    // 快速旅行(加载目标 Bench 所在场景)
    public IEnumerator LoadFastTravelTarget(string roomAddress, string fastTravelId);

    // 返回主菜单(卸载全部游戏场景)
    public IEnumerator UnloadAllAndLoadMainMenu();
}

所有场景通过 Addressables 加载,不使用 SceneManager.LoadSceneAsync(string)

// 内部实现示例
async Task LoadRoomInternal(string roomAddress)
{
    // 卸载旧房间
    if (_currentRoomHandle.IsValid())
        await Addressables.UnloadSceneAsync(_currentRoomHandle).Task;

    // 加载新房间Additive
    _currentRoomHandle = Addressables.LoadSceneAsync(
        roomAddress, LoadSceneMode.Additive);
    await _currentRoomHandle.Task;
}

Additive 多场景规则

规则 说明
Persistent 场景永不卸载 GameManager / AudioManager / UIManager 所在场景
同时最多 1 个房间场景MVP 每次加载新房间前 Unload 旧房间
预加载相邻房间P1 在玩家靠近出口时异步预加载下一个房间(allowSceneActivation = false
Unload 触发资源回收 离开房间时所有 Enemy / Collectible 实例随场景卸载,下次进入重新生成

9. 快速旅行Bench 传送)

Bench长椅兼具存档点与快速旅行节点功能

激活流程

// SavePoint.cs 的 OnInteract()
void OnInteract()
{
    // 1. 存档
    SaveManager.SetCheckpoint(transform.position, SceneManager.GetActiveScene().name);
    SaveManager.UnlockFastTravel(_fastTravelId);   // 记录已解锁节点
    SaveManager.Save();
    OnSavePointActivated.Raise();

    // 2. HP 全恢复Hollow Knight 风格:坐 Bench 回血)
    _onPlayerHPChanged.Raise(playerStats.MaxHP);

    // 3. 若有已解锁的其他传送点,打开 FastTravel UI
    if (SaveManager.GetUnlockedFastTravelCount() > 1)
        UIManager.OpenFastTravelPanel(_fastTravelId);
}

FastTravel UIP1

FastTravelPanel
├── RegionTabs                 (按区域分页)
└── FastTravelPointList
    ├── FTPoint [森林长椅 01]  ← 已解锁,当前位置(灰色不可选)
    ├── FTPoint [洞窟长椅 01]  ← 已解锁
    └── FTPoint [废墟长椅 01]  ← 未解锁(不显示 / 灰色)

传送流程:选择目标 → LoadingOverlay.FadeIn()SceneLoader.LoadFastTravelTarget() → 玩家在目标 Bench 位置生成 → FadeOut()


10. 事件频道

新增 / 补充频道(Assets/ScriptableObjects/Events/Game/

资产名 类型 触发时机
OnGameStateChanged.asset GameStateEventChannelSO 每次 TransitionTo() 成功时
OnPlayerRespawned.asset VoidEventChannelSO 复活流程完成,玩家已在场景中
OnBossDefeated.asset StringEventChannelSO Boss 击败,传递 bossId
OnBossPhaseChanged.asset IntEventChannelSO Boss 阶段切换,传递新阶段索引
OnFastTravelRequested.asset StringEventChannelSO 玩家选择目标,传递目标 FastTravelId
OnQuitToMainMenu.asset VoidEventChannelSO 确认退出到主菜单

OnPlayerDied.assetOnBossFightToggled.assetOnSavePointActivated.asset 已在 00_Overview.md 中定义,本文引用即可。


11. 设计约束

  1. 单例但不是服务定位器

    GameManager.Instance 只暴露状态转换方法和协程入口,不暴露子系统引用。子系统间通信始终通过事件频道。

  2. 禁止在 GameManager 中写业务逻辑

    HP 计算、战斗判定、音频播放均不属于 GameManager 职责。GameManager 只关心"什么时候做什么",不关心"怎么做"。

  3. 协程编排

    死亡复活、Boss 入场等复杂流程通过 IEnumerator Coroutine 实现,保证时序可控,避免回调嵌套地狱。所有 Coroutine 都在 GameManager 的 Persistent GameObject 上运行(不受场景卸载影响)。

  4. 失去焦点自动暂停

    void OnApplicationFocus(bool hasFocus)
    {
        if (!hasFocus && _currentState == GameState.Gameplay)
            PauseGame();
    }
    
  5. 退出确认

    从 Pause 菜单选择"退出到主菜单"时,弹出确认对话框("进度将丢失,是否确认?"),防止误操作。


12. 崩溃恢复机制

游戏意外崩溃或强制结束进程时,下次启动自动检测并提供恢复选项。

12.1 崩溃哨兵文件crash.lock

文件路径:{persistentDataPath}/crash.lock

生命周期:
  游戏启动时:若 crash.lock 不存在 → 写入 crash.lock记录启动时间戳
  游戏正常退出OnApplicationQuit删除 crash.lock
  
  结论crash.lock 存在 = 上次会话非正常结束(崩溃 / 强杀)
// CrashGuard.cs挂载于 Persistent 场景 Bootstrap
public static class CrashGuard
{
    static readonly string LockPath =
        Path.Combine(Application.persistentDataPath, "crash.lock");

    public static bool WasPreviousCrash => File.Exists(LockPath);

    public static void OnGameStarted()
    {
        // 写入时间戳(方便诊断)
        File.WriteAllText(LockPath,
            DateTime.UtcNow.ToString("o"),
            System.Text.Encoding.UTF8);
    }

    public static void OnGameExitedCleanly()
    {
        if (File.Exists(LockPath))
            File.Delete(LockPath);
    }
}

12.2 启动时崩溃检测

GameManager.InitializeAsync() 开始时检测:

async UniTask InitializeAsync()
{
    // 崩溃恢复检测(必须在 SaveManager 初始化前)
    if (CrashGuard.WasPreviousCrash)
    {
        Debug.LogWarning("[GameManager] 检测到上次会话崩溃");
        await HandleCrashRecoveryAsync();
    }

    CrashGuard.OnGameStarted();

    // 正常初始化流程...
    await _saveManager.InitializeAsync();
    // ...
}

async UniTask HandleCrashRecoveryAsync()
{
    // 尝试识别有备份的存档槽
    int recoverySlot = _saveManager.FindSlotWithValidBackup();
    if (recoverySlot < 0) return;  // 无备份,忽略

    // 显示恢复对话框(等待玩家确认,不阻塞 Update
    bool accepted = await _uiManager.ShowCrashRecoveryDialogAsync(recoverySlot);
    if (accepted)
        await _saveManager.RestoreFromBackupAsync(recoverySlot);
}

12.3 自动检查点5 分钟)

防止大量进度因崩溃丢失:每隔 5 分钟,在后台静默写入一次检查点文件(不覆盖主存档):

// 在 GameManager.Update 或 LateUpdate 中(仅 Gameplay 状态)
float _checkpointTimer;
const float CHECKPOINT_INTERVAL = 300f;  // 5 分钟

void UpdateCheckpoint()
{
    if (_currentState != GameState.Gameplay) return;
    _checkpointTimer += Time.unscaledDeltaTime;
    if (_checkpointTimer < CHECKPOINT_INTERVAL) return;
    _checkpointTimer = 0f;

    // 后台写入,不影响帧率
    _saveManager.WriteCheckpointAsync().Forget();
}

检查点路径:{persistentDataPath}/Saves/Slot{n}_checkpoint.json
崩溃恢复对话框同时提供"使用检查点"选项(若检查点比备份更新)。

12.4 OnApplicationQuit 清理

// GameManager
void OnApplicationQuit()
{
    CrashGuard.OnGameExitedCleanly();
    // 其他清理...
}

13. 遥测与分析钩子

优先级 P2,发布后用于平衡调整与 Bug 复现。
所有分析代码必须包裹在 #if UNITY_ANALYTICS || DEVELOPMENT_BUILD 中,发行版无分析时零开销。

13.1 关键事件列表

事件名 触发时机 关键参数
room_entered 进入新房间 regionId, roomId, playerHP, playerGeo
death_occurred 玩家死亡 causeOfDeath, regionId, roomId, totalDeaths
boss_encounter 进入 Boss 房间 bossId, playerHP, equippedCharms[]
boss_defeated Boss 死亡 bossId, attemptCount, timeElapsed, playerHP
ability_unlocked 获取新能力 abilityType, regionId, playtimeMinutes
session_start 游戏启动/读档 saveSlot, regionId, totalPlaytime
session_end 退出游戏(正常) saveSlot, totalPlaytime, sessionLength

13.2 AnalyticsManager

#if UNITY_ANALYTICS || DEVELOPMENT_BUILD
public class AnalyticsManager : MonoBehaviour
{
    // 订阅全局事件,被动采集(零侵入业务逻辑)
    [SerializeField] StringEventChannelSO _onRoomEntered;
    [SerializeField] VoidEventChannelSO   _onPlayerDied;
    [SerializeField] StringEventChannelSO _onBossDefeated;
    [SerializeField] StringEventChannelSO _onAbilityUnlocked;

    void OnEnable()
    {
        _onRoomEntered.OnEventRaised    += TrackRoomEntered;
        _onPlayerDied.OnEventRaised     += TrackDeath;
        _onBossDefeated.OnEventRaised   += TrackBossDefeated;
        _onAbilityUnlocked.OnEventRaised += TrackAbilityUnlocked;
    }

    void OnDisable()
    {
        _onRoomEntered.OnEventRaised    -= TrackRoomEntered;
        _onPlayerDied.OnEventRaised     -= TrackDeath;
        _onBossDefeated.OnEventRaised   -= TrackBossDefeated;
        _onAbilityUnlocked.OnEventRaised -= TrackAbilityUnlocked;
    }

    void TrackRoomEntered(string roomId)
    {
        SendEvent("room_entered", new Dictionary<string, object>
        {
            ["roomId"]    = roomId,
            ["regionId"]  = _worldState.CurrentRegionId,
            ["playerHP"]  = _playerStats.CurrentHP,
            ["playerGeo"] = _playerStats.CurrentGeo
        });
    }

    void SendEvent(string eventName, Dictionary<string, object> parameters)
    {
        // P2: 接入 Unity Analytics / 自定义后端
#if DEVELOPMENT_BUILD
        Debug.Log($"[Analytics] {eventName}: {JsonConvert.SerializeObject(parameters)}");
#endif
        // Unity.Services.Analytics.AnalyticsService.Instance.RecordEvent(...)
    }
}
#endif

13.3 死亡热图数据

death_occurred 事件额外记录玩家世界坐标,方便在地图上生成死亡热图(配合外部分析工具或本地 Debug 可视化):

["deathX"] = _playerTransform.position.x,
["deathY"] = _playerTransform.position.y,

本地 Debug 可视化(编辑器专用):AnalyticsDebugOverlay 在 Scene 视图用红点叠加所有会话内的死亡位置。