完整启动流程
This commit is contained in:
@@ -27,6 +27,12 @@ namespace BaseGames.Combat
|
||||
public BreakLevel Break;
|
||||
public string SourceId;
|
||||
public string SkillId;
|
||||
/// <summary>
|
||||
/// 攻击来源投射物(仅当攻击方是 Projectile 时非 null)。
|
||||
/// 用于弹反成功时调用 ReflectAsPlayerProjectile() 翻转阵营。
|
||||
/// [NonSerialized]:MonoBehaviour 引用不参与 Unity 资产序列化。
|
||||
/// </summary>
|
||||
[System.NonSerialized] public Projectile SourceProjectile;
|
||||
|
||||
// ── Builder ──────────────────────────────────────────────────────────
|
||||
/// <summary>
|
||||
@@ -48,6 +54,7 @@ namespace BaseGames.Combat
|
||||
private BreakLevel _break;
|
||||
private Vector2 _sourcePosition;
|
||||
private int _sourceLayer;
|
||||
private Projectile _sourceProjectile;
|
||||
|
||||
public Builder() { }
|
||||
|
||||
@@ -65,6 +72,7 @@ namespace BaseGames.Combat
|
||||
public Builder SetBreak(BreakLevel v) { _break = v; return this; }
|
||||
public Builder SetSourcePos(Vector2 v) { _sourcePosition = v; return this; }
|
||||
public Builder SetLayer(int v) { _sourceLayer = v; return this; }
|
||||
public Builder SetProjectile(Projectile v) { _sourceProjectile = v; return this; }
|
||||
|
||||
public DamageInfo Build() => new DamageInfo
|
||||
{
|
||||
@@ -83,6 +91,7 @@ namespace BaseGames.Combat
|
||||
Break = _break,
|
||||
SourcePosition = _sourcePosition,
|
||||
SourceLayer = _sourceLayer,
|
||||
SourceProjectile = _sourceProjectile,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -95,7 +104,8 @@ namespace BaseGames.Combat
|
||||
DamageSourceSO so,
|
||||
Vector2 knockbackDir = default,
|
||||
Vector2 sourcePos = default,
|
||||
int sourceLayer = 0)
|
||||
int sourceLayer = 0,
|
||||
Projectile sourceProjectile = null)
|
||||
{
|
||||
int baseAmt = Mathf.RoundToInt(so.BaseDamage * so.DamageMultiplier);
|
||||
return new DamageInfo
|
||||
@@ -115,6 +125,7 @@ namespace BaseGames.Combat
|
||||
KnockbackForce = so.KnockbackForce,
|
||||
SourcePosition = sourcePos,
|
||||
SourceLayer = sourceLayer,
|
||||
SourceProjectile = sourceProjectile,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,9 @@ namespace BaseGames.Combat
|
||||
/// <summary>命中确认委托(PlayerCombat / EnemyCombat 订阅)。</summary>
|
||||
public event System.Action<DamageInfo> OnHitConfirmed;
|
||||
|
||||
// 宿主投射物缓存(Activate 时填入,DamageInfo.SourceProjectile 写入用)
|
||||
private Projectile _ownerProjectile;
|
||||
|
||||
/// <summary>
|
||||
/// 激活 HitBox。source/attacker 均可选,未传则使用 Inspector 默认值。
|
||||
/// ⚠️ 不存在 Activate(float duration) 重载。
|
||||
@@ -86,6 +89,8 @@ namespace BaseGames.Combat
|
||||
Debug.LogWarning($"[HitBox] {name}: Collider2D.isTrigger 应为 true。", this);
|
||||
// 缓存 IClashService:OnTriggerEnter2D 为物理热路径,避免每次调用 Dictionary 查找
|
||||
_clashService = ServiceLocator.GetOrDefault<IClashService>();
|
||||
// 缓存宿主投射物(仅 Projectile GameObject 上挂载的 HitBox 非 null)
|
||||
_ownerProjectile = GetComponent<Projectile>();
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
@@ -124,7 +129,8 @@ namespace BaseGames.Combat
|
||||
_currentSource,
|
||||
knockDir,
|
||||
_attackerTransform.position,
|
||||
_attackerTransform.gameObject.layer);
|
||||
_attackerTransform.gameObject.layer,
|
||||
_ownerProjectile);
|
||||
|
||||
// ① 拼刀检测:当前 HitBox 携带 CanClash 标记,且碰到对立阵营的 HitBox 层
|
||||
int otherLayer = other.gameObject.layer;
|
||||
|
||||
@@ -64,7 +64,14 @@ namespace BaseGames.Combat
|
||||
// 2. 弹反检查(_parrySystem == null 时跳过)
|
||||
// ParrySystem 只暴露窗口状态,伤害数据留在 Combat 层,无跨程序集数据依赖。
|
||||
if (_parrySystem != null && info.Flags.HasFlag(DamageFlags.CanBeParried))
|
||||
if (_parrySystem.ConsumeParry()) return;
|
||||
{
|
||||
if (_parrySystem.ConsumeParry())
|
||||
{
|
||||
// 若攻击来源是投射物,翻转其阵营 Layer 与飞行方向
|
||||
info.SourceProjectile?.ReflectAsPlayerProjectile();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 霸体检查(_poiseSource == null 时跳过)
|
||||
if (!info.Flags.HasFlag(DamageFlags.ForceBreak) && _poiseSource != null)
|
||||
|
||||
@@ -51,7 +51,25 @@ namespace BaseGames.Combat
|
||||
gameObject.layer = (ownerLayer == playerLayer) ? playerProjLayer : enemyProjLayer;
|
||||
}
|
||||
|
||||
/// <summary>子类在此设定初速度或附加初始化逻辑。</summary>
|
||||
/// <summary>
|
||||
/// 弹反:将投射物阵营从 EnemyProjectile 切换为 PlayerProjectile。
|
||||
/// 反转飞行方向,并重置 HitBox 命中记录使其能够命中新目标(敌人)。
|
||||
/// 由 HurtBox.ReceiveDamage() 在弹反成功后调用。
|
||||
/// </summary>
|
||||
public virtual void ReflectAsPlayerProjectile()
|
||||
{
|
||||
int playerProjLayer = LayerMask.NameToLayer("PlayerProjectile");
|
||||
if (playerProjLayer < 0) return;
|
||||
|
||||
gameObject.layer = playerProjLayer;
|
||||
Direction = -Direction;
|
||||
_rb.velocity = -_rb.velocity;
|
||||
|
||||
// 重置 HitBox 命中记录,确保反射后可命中新目标
|
||||
_hitBox.Deactivate();
|
||||
_hitBox.Activate(_config?.DamageSource);
|
||||
}
|
||||
|
||||
protected virtual void OnInitialized() { }
|
||||
|
||||
protected virtual void Update()
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
104
Assets/_Game/Scripts/Core/BootSequencer.cs
Normal file
104
Assets/_Game/Scripts/Core/BootSequencer.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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. 运行 BootSequencer(Splash + 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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,18 @@ namespace BaseGames.Editor
|
||||
CreateAsset<VoidEventChannelSO> ("UI", "EVT_MapOpen");
|
||||
CreateAsset<ColorblindModeEventChannelSO> ("UI", "EVT_ColorblindMode");
|
||||
|
||||
// ── 启动流程 / Splash ─────────────────────────────────────────────
|
||||
CreateAsset<VoidEventChannelSO> ("UI/Splash", "EVT_SplashStartRequest");
|
||||
CreateAsset<VoidEventChannelSO> ("UI/Splash", "EVT_SplashComplete");
|
||||
|
||||
// ── 启动流程 / Loading 画面 ───────────────────────────────────────
|
||||
CreateAsset<VoidEventChannelSO> ("UI/Loading", "EVT_LoadingStarted");
|
||||
CreateAsset<VoidEventChannelSO> ("UI/Loading", "EVT_LoadingComplete");
|
||||
CreateAsset<FloatEventChannelSO> ("UI/Loading", "EVT_LoadingProgressUpdated");
|
||||
|
||||
// ── 启动流程 / 主菜单 ─────────────────────────────────────────────
|
||||
CreateAsset<IntEventChannelSO> ("UI/MainMenu", "EVT_SlotConfirmed");
|
||||
|
||||
// ── World ─────────────────────────────────────────────────────────
|
||||
CreateAsset<StringEventChannelSO> ("World", "EVT_SavePointActivated");
|
||||
CreateAsset<VoidEventChannelSO> ("World", "EVT_CheckpointReached");
|
||||
|
||||
530
Assets/_Game/Scripts/Editor/Scene/BootFlowSetupWizard.cs
Normal file
530
Assets/_Game/Scripts/Editor/Scene/BootFlowSetupWizard.cs
Normal file
@@ -0,0 +1,530 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Events;
|
||||
using BaseGames.UI;
|
||||
using BaseGames.UI.MainMenu;
|
||||
using BaseGames.UI.Splash;
|
||||
|
||||
namespace BaseGames.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// 启动流程安装向导(四步检查清单窗口)。
|
||||
///
|
||||
/// 功能:
|
||||
/// Step 1 — 检测并创建启动流程所需的全部事件频道 SO 资产。
|
||||
/// Step 2 — 检测 Persistent 场景中 BootSequencer / SplashScreenController /
|
||||
/// LoadingScreenManager 的存在性并提供一键脚手架入口。
|
||||
/// Step 3 — 检测 Scene_MainMenu 是否存在并提供一键脚手架入口。
|
||||
/// Step 4 — 全局验证(必要字段绑定状态),输出操作报告。
|
||||
///
|
||||
/// 菜单:BaseGames / Tools / Boot Flow Wizard
|
||||
/// </summary>
|
||||
public class BootFlowSetupWizard : EditorWindow
|
||||
{
|
||||
// ── 常量 ──────────────────────────────────────────────────────────────
|
||||
private const string EventRoot = "Assets/_Game/Data/Events";
|
||||
private const string PersistentName = "Scene_Persistent";
|
||||
private const string MainMenuName = "Scene_MainMenu";
|
||||
|
||||
// 启动流程所需的事件频道资产清单 (subfolder, assetName, SO type)
|
||||
private static readonly (string folder, string name, System.Type type)[] BootChannels =
|
||||
{
|
||||
("UI/Splash", "EVT_SplashStartRequest", typeof(VoidEventChannelSO)),
|
||||
("UI/Splash", "EVT_SplashComplete", typeof(VoidEventChannelSO)),
|
||||
("UI/Loading", "EVT_LoadingStarted", typeof(VoidEventChannelSO)),
|
||||
("UI/Loading", "EVT_LoadingComplete", typeof(VoidEventChannelSO)),
|
||||
("UI/Loading", "EVT_LoadingProgressUpdated",typeof(FloatEventChannelSO)),
|
||||
("UI/MainMenu", "EVT_SlotConfirmed", typeof(IntEventChannelSO)),
|
||||
("Core", "EVT_SceneLoadRequest", typeof(SceneLoadRequestEventChannelSO)),
|
||||
("Core", "EVT_SceneLoaded", typeof(StringEventChannelSO)),
|
||||
};
|
||||
|
||||
// ── UI 引用 ───────────────────────────────────────────────────────────
|
||||
private ScrollView _scroll;
|
||||
private Label _statusBar;
|
||||
private readonly List<(Label label, System.Func<bool> check)> _checks = new();
|
||||
|
||||
// ── 菜单入口 ──────────────────────────────────────────────────────────
|
||||
|
||||
[MenuItem("BaseGames/Tools/Boot Flow Wizard", priority = 10)]
|
||||
public static void Open()
|
||||
{
|
||||
var wnd = GetWindow<BootFlowSetupWizard>();
|
||||
wnd.titleContent = new GUIContent("Boot Flow Wizard",
|
||||
EditorGUIUtility.IconContent("d_PlayButton").image);
|
||||
wnd.minSize = new Vector2(480, 620);
|
||||
}
|
||||
|
||||
// ── 生命周期 ──────────────────────────────────────────────────────────
|
||||
|
||||
public void CreateGUI()
|
||||
{
|
||||
rootVisualElement.style.flexDirection = FlexDirection.Column;
|
||||
BuildHeader();
|
||||
_scroll = new ScrollView(ScrollViewMode.Vertical);
|
||||
_scroll.style.flexGrow = 1;
|
||||
rootVisualElement.Add(_scroll);
|
||||
BuildStep1();
|
||||
BuildStep2();
|
||||
BuildStep3();
|
||||
BuildStep4();
|
||||
BuildStatusBar();
|
||||
RefreshAllChecks();
|
||||
}
|
||||
|
||||
private void OnFocus() => RefreshAllChecks();
|
||||
|
||||
// ── UI 构建 ───────────────────────────────────────────────────────────
|
||||
|
||||
private void BuildHeader()
|
||||
{
|
||||
var header = new VisualElement();
|
||||
StyleRow(header, new Color(0.17f, 0.17f, 0.17f));
|
||||
header.style.paddingLeft = 12;
|
||||
header.style.paddingTop = 10;
|
||||
header.style.paddingBottom = 10;
|
||||
|
||||
var title = new Label("🚀 Boot Flow Setup Wizard");
|
||||
title.style.fontSize = 15;
|
||||
title.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
title.style.color = new Color(0.85f, 0.92f, 1f);
|
||||
header.Add(title);
|
||||
|
||||
var sub = new Label("完成以下四步即可激活完整游戏启动流程。");
|
||||
sub.style.color = new Color(0.55f, 0.55f, 0.55f);
|
||||
sub.style.fontSize = 11;
|
||||
header.Add(sub);
|
||||
|
||||
rootVisualElement.Add(header);
|
||||
}
|
||||
|
||||
private void BuildStep1()
|
||||
{
|
||||
var section = BuildSection("Step 1 — 创建事件频道资产", new Color(0.22f, 0.35f, 0.22f));
|
||||
|
||||
section.Add(MakeDescription("以下 SO 资产在 Assets/_Game/Data/Events/ 下创建,是启动流程各组件通信的基础。"));
|
||||
|
||||
foreach (var (folder, name, _) in BootChannels)
|
||||
{
|
||||
string path = $"{EventRoot}/{folder}/{name}.asset";
|
||||
string display = $"{name} ({folder})";
|
||||
var (row, statusLbl) = MakeCheckRow(display, () => AssetDatabase.LoadMainAssetAtPath(path) != null);
|
||||
_checks.Add((statusLbl, () => AssetDatabase.LoadMainAssetAtPath(path) != null));
|
||||
section.Add(row);
|
||||
}
|
||||
|
||||
var btn = new Button(OnCreateEventChannels) { text = "一键创建所有缺失资产" };
|
||||
StylePrimaryButton(btn);
|
||||
section.Add(btn);
|
||||
}
|
||||
|
||||
private void BuildStep2()
|
||||
{
|
||||
var section = BuildSection("Step 2 — 配置 Persistent 场景", new Color(0.22f, 0.27f, 0.38f));
|
||||
section.Add(MakeDescription(
|
||||
$"在打开 {PersistentName} 的情况下,点击下方按钮执行脚手架(幂等,已有组件不会重建)。"));
|
||||
|
||||
var checks = new (string label, System.Func<bool> fn)[]
|
||||
{
|
||||
("BootSequencer 组件存在", () => FindInLoadedScene<BootSequencer>(PersistentName) != null),
|
||||
("SplashScreenController 组件存在", () => FindInLoadedScene<SplashScreenController>(PersistentName) != null),
|
||||
("LoadingScreenManager 组件存在", () => FindInLoadedScene<LoadingScreenManager>(PersistentName) != null),
|
||||
("BootSequencer._onSplashStartRequest 已绑定",
|
||||
() => IsFieldBound(FindInLoadedScene<BootSequencer>(PersistentName), "_onSplashStartRequest")),
|
||||
("BootSequencer._onSplashComplete 已绑定",
|
||||
() => IsFieldBound(FindInLoadedScene<BootSequencer>(PersistentName), "_onSplashComplete")),
|
||||
("SceneLoader._onLoadingStarted 已绑定",
|
||||
() => IsFieldBound(FindInLoadedScene<SceneLoader>(PersistentName), "_onLoadingStarted")),
|
||||
("GameManager._bootSequencer 已绑定",
|
||||
() => IsFieldBound(FindInLoadedScene<GameManager>(PersistentName), "_bootSequencer")),
|
||||
("GameManager._onSceneLoadRequest 已绑定",
|
||||
() => IsFieldBound(FindInLoadedScene<GameManager>(PersistentName), "_onSceneLoadRequest")),
|
||||
("GameManager._onSceneLoaded 已绑定",
|
||||
() => IsFieldBound(FindInLoadedScene<GameManager>(PersistentName), "_onSceneLoaded")),
|
||||
};
|
||||
|
||||
foreach (var (lbl, fn) in checks)
|
||||
{
|
||||
var (row, statusLbl) = MakeCheckRow(lbl, fn);
|
||||
_checks.Add((statusLbl, fn));
|
||||
section.Add(row);
|
||||
}
|
||||
|
||||
var btn = new Button(OnScaffoldPersistent) { text = "脚手架 Persistent 场景(需先打开该场景)" };
|
||||
StylePrimaryButton(btn);
|
||||
section.Add(btn);
|
||||
}
|
||||
|
||||
private void BuildStep3()
|
||||
{
|
||||
var section = BuildSection("Step 3 — 搭建 Scene_MainMenu", new Color(0.32f, 0.25f, 0.18f));
|
||||
section.Add(MakeDescription(
|
||||
$"创建新场景命名为 {MainMenuName},打开后点击下方按钮生成主菜单层级。"));
|
||||
|
||||
var checks = new (string label, System.Func<bool> fn)[]
|
||||
{
|
||||
("MainMenuController 组件存在", () => FindInLoadedScene<MainMenuController>(MainMenuName) != null),
|
||||
("SaveSlotController 组件存在", () => FindInLoadedScene<SaveSlotController>(MainMenuName) != null),
|
||||
("MainMenuController._onSceneLoadRequest 已绑定",
|
||||
() => IsFieldBound(FindInLoadedScene<MainMenuController>(MainMenuName), "_onSceneLoadRequest")),
|
||||
("MainMenuController._onSlotConfirmed 已绑定",
|
||||
() => IsFieldBound(FindInLoadedScene<MainMenuController>(MainMenuName), "_onSlotConfirmed")),
|
||||
("MainMenuController._firstGameSceneKey 非空",
|
||||
() => IsStringFieldNonEmpty(FindInLoadedScene<MainMenuController>(MainMenuName), "_firstGameSceneKey")),
|
||||
("Scene_MainMenu 已加入 Build Settings",
|
||||
() => IsInBuildSettings(MainMenuName)),
|
||||
};
|
||||
|
||||
foreach (var (lbl, fn) in checks)
|
||||
{
|
||||
var (row, statusLbl) = MakeCheckRow(lbl, fn);
|
||||
_checks.Add((statusLbl, fn));
|
||||
section.Add(row);
|
||||
}
|
||||
|
||||
var row2 = new VisualElement();
|
||||
row2.style.flexDirection = FlexDirection.Row;
|
||||
row2.style.marginTop = 6;
|
||||
|
||||
var scaffoldBtn = new Button(OnScaffoldMainMenu) { text = "脚手架 MainMenu 场景" };
|
||||
StylePrimaryButton(scaffoldBtn);
|
||||
scaffoldBtn.style.flexGrow = 1;
|
||||
scaffoldBtn.style.marginRight = 4;
|
||||
row2.Add(scaffoldBtn);
|
||||
|
||||
var buildSettingsBtn = new Button(OpenBuildSettings) { text = "打开 Build Settings" };
|
||||
StyleSecondaryButton(buildSettingsBtn);
|
||||
buildSettingsBtn.style.width = 140;
|
||||
row2.Add(buildSettingsBtn);
|
||||
|
||||
section.Add(row2);
|
||||
}
|
||||
|
||||
private void BuildStep4()
|
||||
{
|
||||
var section = BuildSection("Step 4 — 全局验证", new Color(0.28f, 0.20f, 0.32f));
|
||||
section.Add(MakeDescription("点击「运行全量检查」获取带详情的报告,帮助排查剩余遗漏项。"));
|
||||
|
||||
var btn = new Button(OnRunFullValidation) { text = "运行全量检查并输出报告" };
|
||||
StylePrimaryButton(btn);
|
||||
section.Add(btn);
|
||||
}
|
||||
|
||||
private void BuildStatusBar()
|
||||
{
|
||||
_statusBar = new Label("— 点击「刷新」或切换焦点以更新状态 —");
|
||||
_statusBar.style.paddingLeft = 8;
|
||||
_statusBar.style.paddingTop = 4;
|
||||
_statusBar.style.paddingBottom = 4;
|
||||
_statusBar.style.borderTopWidth = 1;
|
||||
_statusBar.style.borderTopColor = new Color(0.15f, 0.15f, 0.15f);
|
||||
_statusBar.style.color = new Color(0.55f, 0.55f, 0.55f);
|
||||
_statusBar.style.fontSize = 11;
|
||||
|
||||
var refreshBtn = new Button(RefreshAllChecks) { text = "↻ 刷新" };
|
||||
refreshBtn.style.position = Position.Absolute;
|
||||
refreshBtn.style.right = 8;
|
||||
refreshBtn.style.top = 2;
|
||||
refreshBtn.style.width = 60;
|
||||
refreshBtn.style.height = 18;
|
||||
refreshBtn.style.fontSize = 10;
|
||||
|
||||
var bar = new VisualElement();
|
||||
bar.style.flexDirection = FlexDirection.Row;
|
||||
bar.style.alignItems = Align.Center;
|
||||
bar.Add(_statusBar);
|
||||
bar.Add(refreshBtn);
|
||||
rootVisualElement.Add(bar);
|
||||
}
|
||||
|
||||
// ── 操作回调 ──────────────────────────────────────────────────────────
|
||||
|
||||
private void OnCreateEventChannels()
|
||||
{
|
||||
CreateEventChannelAssets.CreateAll();
|
||||
AssetDatabase.Refresh();
|
||||
RefreshAllChecks();
|
||||
}
|
||||
|
||||
private void OnScaffoldPersistent()
|
||||
{
|
||||
SceneScaffoldTools.ScaffoldPersistentScene();
|
||||
RefreshAllChecks();
|
||||
}
|
||||
|
||||
private void OnScaffoldMainMenu()
|
||||
{
|
||||
SceneScaffoldTools.ScaffoldMainMenuScene();
|
||||
RefreshAllChecks();
|
||||
}
|
||||
|
||||
private static void OpenBuildSettings()
|
||||
=> EditorWindow.GetWindow(System.Type.GetType(
|
||||
"UnityEditor.BuildPlayerWindow,UnityEditor"));
|
||||
|
||||
private void OnRunFullValidation()
|
||||
{
|
||||
var report = new List<string>();
|
||||
int passCount = 0;
|
||||
int failCount = 0;
|
||||
|
||||
// Event channels
|
||||
foreach (var (folder, name, _) in BootChannels)
|
||||
{
|
||||
string path = $"{EventRoot}/{folder}/{name}.asset";
|
||||
bool ok = AssetDatabase.LoadMainAssetAtPath(path) != null;
|
||||
if (ok) passCount++;
|
||||
else { failCount++; report.Add($"[缺失] 事件频道资产:{path}"); }
|
||||
}
|
||||
|
||||
// Components in Persistent
|
||||
CheckComponent<BootSequencer>(PersistentName, "BootSequencer", report, ref passCount, ref failCount);
|
||||
CheckComponent<SplashScreenController>(PersistentName, "SplashScreenController", report, ref passCount, ref failCount);
|
||||
CheckComponent<LoadingScreenManager>(PersistentName, "LoadingScreenManager", report, ref passCount, ref failCount);
|
||||
CheckComponent<GameManager>(PersistentName, "GameManager", report, ref passCount, ref failCount);
|
||||
CheckComponent<SceneLoader>(PersistentName, "SceneLoader", report, ref passCount, ref failCount);
|
||||
|
||||
// Field bindings
|
||||
CheckField<BootSequencer>(PersistentName, "_onSplashStartRequest", report, ref passCount, ref failCount);
|
||||
CheckField<BootSequencer>(PersistentName, "_onSplashComplete", report, ref passCount, ref failCount);
|
||||
CheckField<GameManager>(PersistentName, "_bootSequencer", report, ref passCount, ref failCount);
|
||||
CheckField<GameManager>(PersistentName, "_onSceneLoadRequest", report, ref passCount, ref failCount);
|
||||
CheckField<GameManager>(PersistentName, "_onSceneLoaded", report, ref passCount, ref failCount);
|
||||
CheckField<SceneLoader>(PersistentName, "_onLoadingStarted", report, ref passCount, ref failCount);
|
||||
CheckField<SceneLoader>(PersistentName, "_onLoadingComplete", report, ref passCount, ref failCount);
|
||||
CheckField<SceneLoader>(PersistentName, "_onLoadingProgressUpdated", report, ref passCount, ref failCount);
|
||||
|
||||
// Main menu scene
|
||||
CheckComponent<MainMenuController>(MainMenuName, "MainMenuController", report, ref passCount, ref failCount);
|
||||
CheckField<MainMenuController>(MainMenuName, "_onSceneLoadRequest", report, ref passCount, ref failCount);
|
||||
CheckField<MainMenuController>(MainMenuName, "_firstGameSceneKey", report, ref passCount, ref failCount, requireNonEmpty: true);
|
||||
|
||||
if (!IsInBuildSettings(MainMenuName))
|
||||
{
|
||||
failCount++;
|
||||
report.Add($"[缺失] {MainMenuName} 未加入 Build Settings。");
|
||||
}
|
||||
else passCount++;
|
||||
|
||||
// Output
|
||||
string summary = $"验证完成:{passCount} 项通过 / {failCount} 项需处理。";
|
||||
if (failCount == 0)
|
||||
Debug.Log($"[BootFlowWizard] ✅ {summary}");
|
||||
else
|
||||
{
|
||||
foreach (string msg in report)
|
||||
Debug.LogWarning($"[BootFlowWizard] {msg}");
|
||||
Debug.LogWarning($"[BootFlowWizard] ⚠ {summary}");
|
||||
}
|
||||
|
||||
_statusBar.text = summary;
|
||||
_statusBar.style.color = failCount == 0
|
||||
? new Color(0.4f, 0.9f, 0.4f)
|
||||
: new Color(1f, 0.75f, 0.3f);
|
||||
|
||||
RefreshAllChecks();
|
||||
}
|
||||
|
||||
// ── UI 辅助 ───────────────────────────────────────────────────────────
|
||||
|
||||
private VisualElement BuildSection(string title, Color headerColor)
|
||||
{
|
||||
var section = new VisualElement();
|
||||
section.style.marginTop = 8;
|
||||
section.style.marginLeft = 8;
|
||||
section.style.marginRight = 8;
|
||||
section.style.marginBottom = 0;
|
||||
section.style.borderTopWidth = 1;
|
||||
section.style.borderBottomWidth = 1;
|
||||
section.style.borderLeftWidth = 1;
|
||||
section.style.borderRightWidth = 1;
|
||||
section.style.borderTopColor = new Color(0.25f, 0.25f, 0.25f);
|
||||
section.style.borderBottomColor = new Color(0.25f, 0.25f, 0.25f);
|
||||
section.style.borderLeftColor = new Color(0.25f, 0.25f, 0.25f);
|
||||
section.style.borderRightColor = new Color(0.25f, 0.25f, 0.25f);
|
||||
section.style.borderTopLeftRadius = 4;
|
||||
section.style.borderTopRightRadius = 4;
|
||||
section.style.borderBottomLeftRadius = 4;
|
||||
section.style.borderBottomRightRadius = 4;
|
||||
section.style.overflow = Overflow.Hidden;
|
||||
|
||||
var header = new Label(title);
|
||||
header.style.backgroundColor = headerColor;
|
||||
header.style.paddingLeft = 10;
|
||||
header.style.paddingTop = 6;
|
||||
header.style.paddingBottom = 6;
|
||||
header.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
header.style.fontSize = 12;
|
||||
header.style.color = new Color(0.92f, 0.92f, 0.92f);
|
||||
section.Add(header);
|
||||
|
||||
var body = new VisualElement();
|
||||
body.style.paddingLeft = 10;
|
||||
body.style.paddingRight = 10;
|
||||
body.style.paddingTop = 8;
|
||||
body.style.paddingBottom = 10;
|
||||
body.style.backgroundColor = new Color(0.18f, 0.18f, 0.18f);
|
||||
body.name = "body";
|
||||
section.Add(body);
|
||||
|
||||
_scroll.Add(section);
|
||||
|
||||
// Return body so callers add children to it
|
||||
return body;
|
||||
}
|
||||
|
||||
private static Label MakeDescription(string text)
|
||||
{
|
||||
var lbl = new Label(text);
|
||||
lbl.style.whiteSpace = WhiteSpace.Normal;
|
||||
lbl.style.color = new Color(0.55f, 0.55f, 0.55f);
|
||||
lbl.style.fontSize = 11;
|
||||
lbl.style.marginBottom = 6;
|
||||
return lbl;
|
||||
}
|
||||
|
||||
private static (VisualElement row, Label statusLabel) MakeCheckRow(string text, System.Func<bool> check)
|
||||
{
|
||||
var row = new VisualElement();
|
||||
row.style.flexDirection = FlexDirection.Row;
|
||||
row.style.alignItems = Align.Center;
|
||||
row.style.marginBottom = 2;
|
||||
|
||||
bool ok = check();
|
||||
var icon = new Label(ok ? "✅" : "⬜");
|
||||
icon.style.width = 22;
|
||||
icon.style.fontSize = 12;
|
||||
|
||||
var lbl = new Label(text);
|
||||
lbl.style.flexGrow = 1;
|
||||
lbl.style.fontSize = 11;
|
||||
lbl.style.color = ok ? new Color(0.75f, 0.95f, 0.75f) : new Color(0.80f, 0.80f, 0.80f);
|
||||
|
||||
row.Add(icon);
|
||||
row.Add(lbl);
|
||||
return (row, icon);
|
||||
}
|
||||
|
||||
private static void StylePrimaryButton(Button btn)
|
||||
{
|
||||
btn.style.backgroundColor = new Color(0.22f, 0.44f, 0.22f);
|
||||
btn.style.color = Color.white;
|
||||
btn.style.marginTop = 6;
|
||||
btn.style.height = 26;
|
||||
btn.style.borderTopLeftRadius = 3;
|
||||
btn.style.borderTopRightRadius = 3;
|
||||
btn.style.borderBottomLeftRadius = 3;
|
||||
btn.style.borderBottomRightRadius = 3;
|
||||
}
|
||||
|
||||
private static void StyleSecondaryButton(Button btn)
|
||||
{
|
||||
btn.style.backgroundColor = new Color(0.25f, 0.25f, 0.32f);
|
||||
btn.style.color = new Color(0.85f, 0.85f, 0.95f);
|
||||
btn.style.marginTop = 6;
|
||||
btn.style.height = 26;
|
||||
btn.style.borderTopLeftRadius = 3;
|
||||
btn.style.borderTopRightRadius = 3;
|
||||
btn.style.borderBottomLeftRadius = 3;
|
||||
btn.style.borderBottomRightRadius = 3;
|
||||
}
|
||||
|
||||
private static void StyleRow(VisualElement el, Color bg)
|
||||
{
|
||||
el.style.backgroundColor = bg;
|
||||
}
|
||||
|
||||
private void RefreshAllChecks()
|
||||
{
|
||||
int done = 0;
|
||||
foreach (var (lbl, fn) in _checks)
|
||||
{
|
||||
bool ok = fn();
|
||||
lbl.text = ok ? "✅" : "⬜";
|
||||
if (ok) done++;
|
||||
}
|
||||
if (_statusBar != null)
|
||||
{
|
||||
_statusBar.text = $"通过 {done} / {_checks.Count} 项检查";
|
||||
_statusBar.style.color = done == _checks.Count
|
||||
? new Color(0.4f, 0.9f, 0.4f)
|
||||
: new Color(0.75f, 0.75f, 0.75f);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 检查辅助 ──────────────────────────────────────────────────────────
|
||||
|
||||
private static T FindInLoadedScene<T>(string sceneName) where T : Component
|
||||
{
|
||||
for (int i = 0; i < UnityEngine.SceneManagement.SceneManager.sceneCount; i++)
|
||||
{
|
||||
var scene = UnityEngine.SceneManagement.SceneManager.GetSceneAt(i);
|
||||
if (!scene.isLoaded) continue;
|
||||
if (!string.IsNullOrEmpty(sceneName) &&
|
||||
!Path.GetFileNameWithoutExtension(scene.path).Equals(
|
||||
sceneName, System.StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
foreach (GameObject go in scene.GetRootGameObjects())
|
||||
{
|
||||
var comp = go.GetComponentInChildren<T>(true);
|
||||
if (comp != null) return comp;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsFieldBound(Object target, string fieldName)
|
||||
{
|
||||
if (target == null) return false;
|
||||
var so = new SerializedObject(target);
|
||||
var prop = so.FindProperty(fieldName);
|
||||
if (prop == null) return false;
|
||||
return prop.propertyType == SerializedPropertyType.ObjectReference
|
||||
? prop.objectReferenceValue != null
|
||||
: !string.IsNullOrEmpty(prop.stringValue);
|
||||
}
|
||||
|
||||
private static bool IsStringFieldNonEmpty(Object target, string fieldName)
|
||||
{
|
||||
if (target == null) return false;
|
||||
var so = new SerializedObject(target);
|
||||
var prop = so.FindProperty(fieldName);
|
||||
return prop != null && !string.IsNullOrEmpty(prop.stringValue);
|
||||
}
|
||||
|
||||
private static bool IsInBuildSettings(string sceneName)
|
||||
{
|
||||
foreach (var scene in EditorBuildSettings.scenes)
|
||||
{
|
||||
if (Path.GetFileNameWithoutExtension(scene.path)
|
||||
.Equals(sceneName, System.StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void CheckComponent<T>(string sceneName, string label,
|
||||
List<string> report, ref int pass, ref int fail) where T : Component
|
||||
{
|
||||
if (FindInLoadedScene<T>(sceneName) != null) pass++;
|
||||
else { fail++; report.Add($"[缺失] {sceneName} 中未找到 {label}。请先打开该场景并运行脚手架。"); }
|
||||
}
|
||||
|
||||
private static void CheckField<T>(string sceneName, string field,
|
||||
List<string> report, ref int pass, ref int fail,
|
||||
bool requireNonEmpty = false) where T : Component
|
||||
{
|
||||
var comp = FindInLoadedScene<T>(sceneName);
|
||||
bool ok = requireNonEmpty
|
||||
? IsStringFieldNonEmpty(comp, field)
|
||||
: IsFieldBound(comp, field);
|
||||
if (ok) pass++;
|
||||
else { fail++; report.Add($"[未绑定] {typeof(T).Name}.{field}(在 {sceneName})。"); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,9 @@ using BaseGames.Core.Pool;
|
||||
using BaseGames.Input;
|
||||
using BaseGames.UI;
|
||||
using BaseGames.UI.HUD;
|
||||
using BaseGames.UI.MainMenu;
|
||||
using BaseGames.UI.Menus;
|
||||
using BaseGames.UI.Splash;
|
||||
using BaseGames.World;
|
||||
using PathBerserker2d;
|
||||
using Unity.Cinemachine;
|
||||
@@ -133,6 +135,38 @@ namespace BaseGames.Editor
|
||||
GetOrAddComponent<Image>(respawnButtonGo);
|
||||
Button respawnButton = GetOrAddComponent<Button>(respawnButtonGo);
|
||||
|
||||
// ── BootSequencer(启动流程)──────────────────────────────────────
|
||||
GameObject bootSequencerGo = GetOrCreateChild(services, "BootSequencer").gameObject;
|
||||
BootSequencer bootSequencer = GetOrAddComponent<BootSequencer>(bootSequencerGo);
|
||||
AssignAsset(bootSequencer, "_onSplashStartRequest", report, false, "EVT_SplashStartRequest");
|
||||
AssignAsset(bootSequencer, "_onSplashComplete", report, false, "EVT_SplashComplete");
|
||||
AssignAsset(bootSequencer, "_onPreloadProgress", report, false, "EVT_LoadingProgressUpdated");
|
||||
|
||||
AssignReference(gameManager, "_bootSequencer", bootSequencer);
|
||||
AssignAsset(gameManager, "_onSceneLoadRequest", report, false, "EVT_SceneLoadRequest");
|
||||
AssignAsset(gameManager, "_onSceneLoaded", report, false, "EVT_SceneLoaded");
|
||||
|
||||
AssignAsset(sceneLoader, "_onLoadingStarted", report, false, "EVT_LoadingStarted");
|
||||
AssignAsset(sceneLoader, "_onLoadingComplete", report, false, "EVT_LoadingComplete");
|
||||
AssignAsset(sceneLoader, "_onLoadingProgressUpdated", report, false, "EVT_LoadingProgressUpdated");
|
||||
|
||||
// ── Canvas_Splash(启动演出)──────────────────────────────────────
|
||||
GameObject splashCanvasGo = GetOrCreateCanvas(ui.transform, "Canvas_Splash", 100);
|
||||
SplashScreenController splashCtrl = GetOrAddComponent<SplashScreenController>(splashCanvasGo);
|
||||
AssignAsset(splashCtrl, "_onSplashStartRequest", report, false, "EVT_SplashStartRequest");
|
||||
AssignAsset(splashCtrl, "_onSplashComplete", report, false, "EVT_SplashComplete");
|
||||
|
||||
// ── LoadingScreenManager(加载遮罩)──────────────────────────────
|
||||
GameObject loadingCanvasGo = GetOrCreateCanvas(ui.transform, "Canvas_Loading", 99);
|
||||
LoadingScreenManager loadingMgr = GetOrAddComponent<LoadingScreenManager>(loadingCanvasGo);
|
||||
AssignAsset(loadingMgr, "_onLoadingStarted", report, false, "EVT_LoadingStarted");
|
||||
AssignAsset(loadingMgr, "_onLoadingComplete", report, false, "EVT_LoadingComplete");
|
||||
AssignAsset(loadingMgr, "_onLoadingProgressUpdated", report, false, "EVT_LoadingProgressUpdated");
|
||||
loadingCanvasGo.SetActive(false);
|
||||
|
||||
report.Add("Canvas_Splash:请将工作室 Logo CanvasGroup 赋给 _studioLogoGroup,游戏标题 CanvasGroup 赋给 _gameTitleGroup。");
|
||||
report.Add("Canvas_Loading:请为 LoadingScreenManager 绑定 _progressBar(Slider)和 _loadingPanel(GameObject)。");
|
||||
|
||||
EnsureAudioSources(audioManagerGo, audioManager, report);
|
||||
|
||||
AssignReference(registrar, "_deathRespawnService", deathRespawnService);
|
||||
@@ -141,8 +175,6 @@ namespace BaseGames.Editor
|
||||
AssignReference(registrar, "_saveManager", gameSaveManager);
|
||||
|
||||
AssignReference(gameManager, "_settingsManager", settingsManager);
|
||||
AssignReference(gameManager, "_deathRespawnService", deathRespawnService);
|
||||
AssignReference(gameManager, "_sceneService", sceneService);
|
||||
AssignAsset(gameManager, "_onPlayerDied", report, true, "EVT_PlayerDied");
|
||||
AssignAsset(gameManager, "_onPauseRequested", report, false, "EVT_PauseRequested");
|
||||
AssignAsset(gameManager, "_onResumeRequested", report, false, "EVT_ResumeRequested", "EVT_PauseResumed");
|
||||
@@ -196,6 +228,76 @@ namespace BaseGames.Editor
|
||||
MarkDirtyAndLog("Persistent 场景脚手架", root, report);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Scaffold Main Menu Scene
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 在当前活动场景中生成完整的主菜单场景层级结构:
|
||||
/// [MainMenu] → [Canvas_MainMenu] → 主按钮组 / SaveSlotPanel / SettingsPanel / CreditsPanel
|
||||
/// 自动绑定所有已存在的相关事件频道 SO 资产。
|
||||
/// </summary>
|
||||
[MenuItem("BaseGames/Tools/Scaffold Main Menu Scene", priority = 202)]
|
||||
public static void ScaffoldMainMenuScene()
|
||||
{
|
||||
var report = new List<string>();
|
||||
|
||||
EnsureEventChannelAssets(report);
|
||||
|
||||
// ── [MainMenu] 根节点 ─────────────────────────────────────────────
|
||||
GameObject root = GetOrCreateRoot("[MainMenu]");
|
||||
|
||||
// ── Canvas_MainMenu(排序层 10,显示在 HUD 之上)────────────────
|
||||
GameObject canvasGo = GetOrCreateCanvas(root.transform, "Canvas_MainMenu", 10);
|
||||
|
||||
// ── 主菜单控制器 ──────────────────────────────────────────────────
|
||||
MainMenuController menuCtrl = GetOrAddComponent<MainMenuController>(canvasGo);
|
||||
AssignAsset(menuCtrl, "_onGameStateChanged", report, false, "EVT_GameStateChanged", "EVT_GameState");
|
||||
AssignAsset(menuCtrl, "_onSceneLoadRequest", report, false, "EVT_SceneLoadRequest");
|
||||
AssignAsset(menuCtrl, "_onSlotConfirmed", report, false, "EVT_SlotConfirmed");
|
||||
|
||||
// ── 主按钮区域 ────────────────────────────────────────────────────
|
||||
GameObject menuPanelGo = GetOrCreateChild(canvasGo.transform, "MenuPanel").gameObject;
|
||||
GetOrAddComponent<VerticalLayoutGroup>(menuPanelGo);
|
||||
|
||||
GameObject btnNewGameGo = GetOrCreateButtonChild(menuPanelGo.transform, "Btn_NewGame", "新游戏");
|
||||
GameObject btnContinueGo = GetOrCreateButtonChild(menuPanelGo.transform, "Btn_Continue", "继续");
|
||||
GameObject btnSettingsGo = GetOrCreateButtonChild(menuPanelGo.transform, "Btn_Settings", "设置");
|
||||
GameObject btnCreditsGo = GetOrCreateButtonChild(menuPanelGo.transform, "Btn_Credits", "制作团队");
|
||||
GameObject btnQuitGo = GetOrCreateButtonChild(menuPanelGo.transform, "Btn_Quit", "退出");
|
||||
|
||||
AssignReference(menuCtrl, "_btnNewGame", btnNewGameGo.GetComponent<Button>());
|
||||
AssignReference(menuCtrl, "_btnContinue", btnContinueGo.GetComponent<Button>());
|
||||
AssignReference(menuCtrl, "_btnSettings", btnSettingsGo.GetComponent<Button>());
|
||||
AssignReference(menuCtrl, "_btnCredits", btnCreditsGo.GetComponent<Button>());
|
||||
AssignReference(menuCtrl, "_btnQuit", btnQuitGo.GetComponent<Button>());
|
||||
AssignReference(menuCtrl, "_menuPanel", menuPanelGo);
|
||||
|
||||
// ── SaveSlotPanel ─────────────────────────────────────────────────
|
||||
GameObject saveSlotPanelGo = GetOrCreateChild(canvasGo.transform, "SaveSlotPanel").gameObject;
|
||||
saveSlotPanelGo.SetActive(false);
|
||||
SaveSlotController saveSlotCtrl = GetOrAddComponent<SaveSlotController>(saveSlotPanelGo);
|
||||
AssignAsset(saveSlotCtrl, "_onSlotConfirmed", report, false, "EVT_SlotConfirmed");
|
||||
AssignReference(menuCtrl, "_saveSlotPanel", saveSlotPanelGo);
|
||||
AssignReference(menuCtrl, "_saveSlotController", saveSlotCtrl);
|
||||
|
||||
// ── SettingsPanel ─────────────────────────────────────────────────
|
||||
GameObject settingsPanelGo = GetOrCreateChild(canvasGo.transform, "SettingsPanel").gameObject;
|
||||
settingsPanelGo.SetActive(false);
|
||||
AssignReference(menuCtrl, "_settingsPanel", settingsPanelGo);
|
||||
|
||||
// ── CreditsPanel ──────────────────────────────────────────────────
|
||||
GameObject creditsPanelGo = GetOrCreateChild(canvasGo.transform, "CreditsPanel").gameObject;
|
||||
creditsPanelGo.SetActive(false);
|
||||
AssignReference(menuCtrl, "_creditsPanel", creditsPanelGo);
|
||||
|
||||
report.Add("设置 MainMenuController._firstGameSceneKey 为第一个游戏场景的 Addressable Key(字符串)。");
|
||||
report.Add("SaveSlotPanel 需要补充 3 个存档槽 Button 引用(_slot0Btn / _slot1Btn / _slot2Btn)。");
|
||||
report.Add("建议为 MenuPanel 添加 RectTransform 入场动画所需的锚点配置,参考 MainMenuController._menuPanel 的偏移量。");
|
||||
|
||||
MarkDirtyAndLog("Main Menu 场景脚手架", root, report);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Scaffold Game Room
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
@@ -403,6 +505,16 @@ namespace BaseGames.Editor
|
||||
return canvasGo;
|
||||
}
|
||||
|
||||
/// <summary>在指定父节点下创建一个带 Button 的菜单按钮子节点(幂等)。文本由美术后续补充。</summary>
|
||||
private static GameObject GetOrCreateButtonChild(Transform parent, string name, string label)
|
||||
{
|
||||
GameObject go = GetOrCreateChild(parent, name).gameObject;
|
||||
GetOrAddComponent<Image>(go);
|
||||
GetOrAddComponent<Button>(go);
|
||||
_ = label; // 占位:文本内容由美术在 Prefab/Scene 中设置
|
||||
return go;
|
||||
}
|
||||
|
||||
private static void AssignReference(Object target, string propertyName, Object value)
|
||||
{
|
||||
AssignReference(target, propertyName, value, null);
|
||||
|
||||
@@ -24,6 +24,10 @@ namespace BaseGames.Editor
|
||||
/// · EnemyProjectile ↔ Ground → 应碰撞(敌人投射物命中地形)
|
||||
/// · PlayerHitBox ↔ PlayerHurtBox → 应忽略(玩家不自伤)
|
||||
/// · PlayerProjectile ↔ EnemyProjectile → 应忽略(子弹不互相碰撞,Clash 系统单独处理)
|
||||
/// · HazardHitBox ↔ PlayerHurtBox → 应碰撞(环境危险伤害玩家)
|
||||
/// · HazardHitBox ↔ EnemyHurtBox → 应碰撞(环境危险伤害敌人,阵营中立)
|
||||
/// · HazardHitBox ↔ PlayerHitBox → 应忽略(环境不触发拼刀)
|
||||
/// · HazardHitBox ↔ EnemyHitBox → 应忽略(环境不触发拼刀)
|
||||
/// </summary>
|
||||
public static class Physics2DLayerReport
|
||||
{
|
||||
@@ -43,6 +47,10 @@ namespace BaseGames.Editor
|
||||
new("EnemyProjectile", "Ground", true, "敌人投射物命中地形"),
|
||||
new("PlayerHitBox", "PlayerHurtBox", false, "玩家不自伤"),
|
||||
new("PlayerProjectile", "EnemyProjectile", false, "子弹不互相碰撞(Clash 系统单独处理)"),
|
||||
new("HazardHitBox", "PlayerHurtBox", true, "环境危险伤害玩家"),
|
||||
new("HazardHitBox", "EnemyHurtBox", true, "环境危险伤害敌人(阵营中立)"),
|
||||
new("HazardHitBox", "PlayerHitBox", false, "环境不触发拼刀"),
|
||||
new("HazardHitBox", "EnemyHitBox", false, "环境不触发拼刀"),
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
212
Assets/_Game/Scripts/UI/MainMenu/MainMenuController.cs
Normal file
212
Assets/_Game/Scripts/UI/MainMenu/MainMenuController.cs
Normal file
@@ -0,0 +1,212 @@
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.UI.MainMenu
|
||||
{
|
||||
/// <summary>
|
||||
/// 主菜单 UI 控制器(挂载在 Scene_MainMenu 的根 Canvas 上)。
|
||||
///
|
||||
/// 面板结构(按 Inspector 绑定):
|
||||
/// ├── MainButtonsPanel — 主按钮组(新游戏 / 继续 / 设置 / 制作团队 / 退出)
|
||||
/// ├── SaveSlotPanel — 存档槽选择(新游戏 & 继续共用)
|
||||
/// ├── SettingsPanel — 设置面板
|
||||
/// └── CreditsPanel — 制作团队面板
|
||||
///
|
||||
/// 入场动画:主按钮组从下方滑入(代码驱动,无需 Animator)。
|
||||
///
|
||||
/// 流程:
|
||||
/// 玩家选择存档槽(SaveSlotController 发布 _onSlotConfirmed)
|
||||
/// → 关闭存档槽面板 → 发布 SceneLoadRequest(目标游戏场景)
|
||||
/// → GameManager 响应,进入 LoadingScene 状态,显示加载画面,最终切换到 Gameplay。
|
||||
/// </summary>
|
||||
public class MainMenuController : MonoBehaviour
|
||||
{
|
||||
// ── 面板引用 ──────────────────────────────────────────────────────────
|
||||
|
||||
[Header("面板")]
|
||||
[SerializeField] private CanvasGroup _mainButtonsGroup;
|
||||
[SerializeField] private RectTransform _mainButtonsRect; // 用于滑入动画
|
||||
[SerializeField] private GameObject _saveSlotPanel;
|
||||
[SerializeField] private GameObject _settingsPanel;
|
||||
[SerializeField] private GameObject _creditsPanel;
|
||||
|
||||
// ── 按钮引用 ──────────────────────────────────────────────────────────
|
||||
|
||||
[Header("主菜单按钮")]
|
||||
[SerializeField] private Button _btnNewGame;
|
||||
[SerializeField] private Button _btnContinue;
|
||||
[SerializeField] private Button _btnSettings;
|
||||
[SerializeField] private Button _btnCredits;
|
||||
[SerializeField] private Button _btnQuit;
|
||||
|
||||
// ── 按钮(子面板关闭)────────────────────────────────────────────────
|
||||
|
||||
[Header("子面板关闭按钮(可选)")]
|
||||
[SerializeField] private Button _btnCloseSaveSlot;
|
||||
[SerializeField] private Button _btnCloseSettings;
|
||||
[SerializeField] private Button _btnCloseCredits;
|
||||
|
||||
// ── 入场动画参数 ──────────────────────────────────────────────────────
|
||||
|
||||
[Header("入场动画")]
|
||||
[Tooltip("按钮组初始偏移(像素,向下)")]
|
||||
[SerializeField] private float _entrySlideOffset = 80f;
|
||||
[Tooltip("入场动画持续时间(秒)")]
|
||||
[SerializeField] private float _entryDuration = 0.55f;
|
||||
|
||||
// ── 游戏场景 ──────────────────────────────────────────────────────────
|
||||
|
||||
[Header("场景")]
|
||||
[Tooltip("新游戏 / 继续后进入的第一个游戏场景(Addressable Key)")]
|
||||
[SerializeField] private string _firstGameSceneKey = "Scene_Game_Chapter1";
|
||||
|
||||
// ── Event Channels ────────────────────────────────────────────────────
|
||||
|
||||
[Header("Event Channels - Listen")]
|
||||
[SerializeField] private GameStateEventChannelSO _onGameStateChanged;
|
||||
[Tooltip("SaveSlotController 完成选槽后发布(携带槽索引)")]
|
||||
[SerializeField] private IntEventChannelSO _onSlotConfirmed;
|
||||
|
||||
[Header("Event Channels - Raise")]
|
||||
[SerializeField] private SceneLoadRequestEventChannelSO _onSceneLoadRequest;
|
||||
|
||||
// ── 内部状态 ──────────────────────────────────────────────────────────
|
||||
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
private Vector2 _buttonsPanelOriginalPos;
|
||||
|
||||
// ── 生命周期 ──────────────────────────────────────────────────────────
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
// 按钮绑定
|
||||
_btnNewGame? .onClick.AddListener(OnNewGameClicked);
|
||||
_btnContinue?.onClick.AddListener(OnContinueClicked);
|
||||
_btnSettings?.onClick.AddListener(OnSettingsClicked);
|
||||
_btnCredits? .onClick.AddListener(OnCreditsClicked);
|
||||
_btnQuit? .onClick.AddListener(Application.Quit);
|
||||
|
||||
_btnCloseSaveSlot?.onClick.AddListener(() => SetPanel(_saveSlotPanel, false));
|
||||
_btnCloseSettings?.onClick.AddListener(() => SetPanel(_settingsPanel, false));
|
||||
_btnCloseCredits? .onClick.AddListener(() => SetPanel(_creditsPanel, false));
|
||||
|
||||
// 记录按钮组原始位置(供动画使用)
|
||||
if (_mainButtonsRect != null)
|
||||
_buttonsPanelOriginalPos = _mainButtonsRect.anchoredPosition;
|
||||
|
||||
// 初始状态:隐藏子面板,主按钮组不可见(等待入场动画)
|
||||
SetPanel(_saveSlotPanel, false);
|
||||
SetPanel(_settingsPanel, false);
|
||||
SetPanel(_creditsPanel, false);
|
||||
SetButtonsGroupVisible(false);
|
||||
|
||||
// 刷新"继续"按钮可用性(需要至少一个有效存档)
|
||||
RefreshContinueButton();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_onGameStateChanged?.Subscribe(HandleGameStateChanged).AddTo(_subs);
|
||||
_onSlotConfirmed? .Subscribe(HandleSlotConfirmed).AddTo(_subs);
|
||||
}
|
||||
|
||||
private void OnDisable() => _subs.Clear();
|
||||
|
||||
private void Start()
|
||||
{
|
||||
// 场景初始化完成后播放入场动画
|
||||
StartCoroutine(PlayEntryAnimation());
|
||||
}
|
||||
|
||||
// ── 入场动画 ─────────────────────────────────────────────────────────
|
||||
|
||||
private IEnumerator PlayEntryAnimation()
|
||||
{
|
||||
if (_mainButtonsGroup == null) yield break;
|
||||
|
||||
Vector2 startPos = _buttonsPanelOriginalPos - new Vector2(0f, _entrySlideOffset);
|
||||
if (_mainButtonsRect != null)
|
||||
_mainButtonsRect.anchoredPosition = startPos;
|
||||
|
||||
float elapsed = 0f;
|
||||
while (elapsed < _entryDuration)
|
||||
{
|
||||
float t = Mathf.SmoothStep(0f, 1f, elapsed / _entryDuration);
|
||||
_mainButtonsGroup.alpha = t;
|
||||
if (_mainButtonsRect != null)
|
||||
_mainButtonsRect.anchoredPosition = Vector2.Lerp(startPos, _buttonsPanelOriginalPos, t);
|
||||
elapsed += Time.unscaledDeltaTime;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
_mainButtonsGroup.alpha = 1f;
|
||||
if (_mainButtonsRect != null)
|
||||
_mainButtonsRect.anchoredPosition = _buttonsPanelOriginalPos;
|
||||
|
||||
_mainButtonsGroup.interactable = true;
|
||||
_mainButtonsGroup.blocksRaycasts = true;
|
||||
}
|
||||
|
||||
// ── 按钮回调 ─────────────────────────────────────────────────────────
|
||||
|
||||
private void OnNewGameClicked() => SetPanel(_saveSlotPanel, true);
|
||||
private void OnContinueClicked() => SetPanel(_saveSlotPanel, true);
|
||||
private void OnSettingsClicked() => SetPanel(_settingsPanel, true);
|
||||
private void OnCreditsClicked() => SetPanel(_creditsPanel, true);
|
||||
|
||||
// ── 存档槽确认 ───────────────────────────────────────────────────────
|
||||
|
||||
private void HandleSlotConfirmed(int _)
|
||||
{
|
||||
SetPanel(_saveSlotPanel, false);
|
||||
|
||||
_onSceneLoadRequest?.Raise(new SceneLoadRequest
|
||||
{
|
||||
SceneName = _firstGameSceneKey,
|
||||
TransitionType = TransitionType.Scene,
|
||||
ShowLoadingScreen = true,
|
||||
});
|
||||
}
|
||||
|
||||
// ── 游戏状态响应 ─────────────────────────────────────────────────────
|
||||
|
||||
private void HandleGameStateChanged(GameStateId state)
|
||||
{
|
||||
bool isMainMenu = state == GameStates.MainMenu;
|
||||
// 离开 MainMenu(加载游戏中)时锁定所有交互,防止重复点击
|
||||
if (_mainButtonsGroup != null)
|
||||
{
|
||||
_mainButtonsGroup.interactable = isMainMenu;
|
||||
_mainButtonsGroup.blocksRaycasts = isMainMenu;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 工具方法 ─────────────────────────────────────────────────────────
|
||||
|
||||
private void RefreshContinueButton()
|
||||
{
|
||||
if (_btnContinue == null) return;
|
||||
|
||||
var saveService = ServiceLocator.GetOrDefault<ISaveService>();
|
||||
bool hasAny = saveService != null
|
||||
&& (saveService.HasSave(0) || saveService.HasSave(1) || saveService.HasSave(2));
|
||||
_btnContinue.interactable = hasAny;
|
||||
}
|
||||
|
||||
private static void SetPanel(GameObject panel, bool active)
|
||||
{
|
||||
if (panel != null) panel.SetActive(active);
|
||||
}
|
||||
|
||||
private void SetButtonsGroupVisible(bool visible)
|
||||
{
|
||||
if (_mainButtonsGroup == null) return;
|
||||
_mainButtonsGroup.alpha = visible ? 1f : 0f;
|
||||
_mainButtonsGroup.interactable = visible;
|
||||
_mainButtonsGroup.blocksRaycasts = visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Assets;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.UI
|
||||
@@ -51,9 +53,9 @@ namespace BaseGames.UI
|
||||
_uiManager.CloseTopPanel();
|
||||
_onSceneLoadRequest?.Raise(new SceneLoadRequest
|
||||
{
|
||||
SceneName = "MainMenu",
|
||||
SceneName = AddressKeys.SceneMainMenu,
|
||||
TransitionType = TransitionType.Scene,
|
||||
ShowLoadingScreen = true
|
||||
ShowLoadingScreen = false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
138
Assets/_Game/Scripts/UI/Splash/SplashScreenController.cs
Normal file
138
Assets/_Game/Scripts/UI/Splash/SplashScreenController.cs
Normal file
@@ -0,0 +1,138 @@
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.UI.Splash
|
||||
{
|
||||
/// <summary>
|
||||
/// 游戏启动 Logo 演出控制器。挂载在 Persistent 场景的 Canvas_Splash 根节点。
|
||||
///
|
||||
/// 演出顺序:工作室 Logo 淡入 → 停留 → 淡出 → 游戏标题淡入 → 停留 → 黑幕整体淡出。
|
||||
/// 任意按键 / 手柄键可跳过整段演出。
|
||||
///
|
||||
/// 与 Core 程序集完全解耦:
|
||||
/// 监听 EVT_SplashStartRequest(VoidEventChannelSO)→ 开始演出。
|
||||
/// 完成后发布 EVT_SplashComplete(VoidEventChannelSO)→ 通知 BootSequencer 继续。
|
||||
/// </summary>
|
||||
public class SplashScreenController : MonoBehaviour
|
||||
{
|
||||
[Header("Canvas Groups")]
|
||||
[Tooltip("整个 Splash 画布的根节点 CanvasGroup(用于整体淡出黑幕)")]
|
||||
[SerializeField] private CanvasGroup _splashRoot;
|
||||
[Tooltip("工作室 Logo CanvasGroup")]
|
||||
[SerializeField] private CanvasGroup _studioLogoGroup;
|
||||
[Tooltip("游戏标题 Logo CanvasGroup")]
|
||||
[SerializeField] private CanvasGroup _gameTitleGroup;
|
||||
|
||||
[Header("时序(秒)")]
|
||||
[SerializeField] private float _fadeInDuration = 0.8f;
|
||||
[SerializeField] private float _holdDuration = 1.5f;
|
||||
[SerializeField] private float _fadeOutDuration = 0.6f;
|
||||
[SerializeField] private float _stageGapDuration = 0.3f;
|
||||
|
||||
[Header("Event Channels")]
|
||||
[SerializeField] private VoidEventChannelSO _onSplashStartRequest; // Listen
|
||||
[SerializeField] private VoidEventChannelSO _onSplashComplete; // Raise
|
||||
|
||||
private bool _skipRequested;
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
|
||||
// ── 生命周期 ─────────────────────────────────────────────────────────
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
// 初始态:根节点完全不透明(充当开场黑幕),各 Logo 完全透明
|
||||
SetAlpha(_splashRoot, 1f, blocksRaycasts: true);
|
||||
SetAlpha(_studioLogoGroup, 0f, blocksRaycasts: false);
|
||||
SetAlpha(_gameTitleGroup, 0f, blocksRaycasts: false);
|
||||
}
|
||||
|
||||
private void OnEnable() => _onSplashStartRequest?.Subscribe(OnStartRequested).AddTo(_subs);
|
||||
private void OnDisable() => _subs.Clear();
|
||||
|
||||
// ── 入口 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private void OnStartRequested() => StartCoroutine(PlayAndNotify());
|
||||
|
||||
private IEnumerator PlayAndNotify()
|
||||
{
|
||||
yield return StartCoroutine(PlayAsync());
|
||||
_onSplashComplete?.Raise();
|
||||
}
|
||||
|
||||
// ── 演出主逻辑 ───────────────────────────────────────────────────────
|
||||
|
||||
private IEnumerator PlayAsync()
|
||||
{
|
||||
_skipRequested = false;
|
||||
|
||||
// Stage 1:工作室 Logo
|
||||
if (_studioLogoGroup != null)
|
||||
{
|
||||
yield return StartCoroutine(FadeGroup(_studioLogoGroup, 0f, 1f, _fadeInDuration));
|
||||
yield return StartCoroutine(WaitOrSkip(_holdDuration));
|
||||
yield return StartCoroutine(FadeGroup(_studioLogoGroup, 1f, 0f, _fadeOutDuration));
|
||||
}
|
||||
|
||||
if (!_skipRequested)
|
||||
yield return new WaitForSecondsRealtime(_stageGapDuration);
|
||||
|
||||
// Stage 2:游戏标题
|
||||
if (_gameTitleGroup != null)
|
||||
{
|
||||
yield return StartCoroutine(FadeGroup(_gameTitleGroup, 0f, 1f, _fadeInDuration));
|
||||
yield return StartCoroutine(WaitOrSkip(_holdDuration));
|
||||
yield return StartCoroutine(FadeGroup(_gameTitleGroup, 1f, 0f, _fadeOutDuration));
|
||||
}
|
||||
|
||||
// 黑幕整体淡出(显露主菜单场景)
|
||||
yield return StartCoroutine(FadeGroup(_splashRoot, 1f, 0f, _fadeOutDuration));
|
||||
|
||||
if (_splashRoot != null)
|
||||
_splashRoot.blocksRaycasts = false;
|
||||
|
||||
gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
// ── Unity 输入(任意按键跳过)────────────────────────────────────────
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (Input.anyKeyDown)
|
||||
_skipRequested = true;
|
||||
}
|
||||
|
||||
// ── 内部工具 ─────────────────────────────────────────────────────────
|
||||
|
||||
private IEnumerator FadeGroup(CanvasGroup group, float from, float to, float duration)
|
||||
{
|
||||
if (group == null) yield break;
|
||||
group.alpha = from;
|
||||
float elapsed = 0f;
|
||||
while (elapsed < duration && !_skipRequested)
|
||||
{
|
||||
group.alpha = Mathf.Lerp(from, to, elapsed / duration);
|
||||
elapsed += Time.unscaledDeltaTime;
|
||||
yield return null;
|
||||
}
|
||||
group.alpha = to;
|
||||
}
|
||||
|
||||
private IEnumerator WaitOrSkip(float duration)
|
||||
{
|
||||
float elapsed = 0f;
|
||||
while (elapsed < duration && !_skipRequested)
|
||||
{
|
||||
elapsed += Time.unscaledDeltaTime;
|
||||
yield return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void SetAlpha(CanvasGroup group, float alpha, bool blocksRaycasts)
|
||||
{
|
||||
if (group == null) return;
|
||||
group.alpha = alpha;
|
||||
group.blocksRaycasts = blocksRaycasts;
|
||||
}
|
||||
}
|
||||
}
|
||||
580
Docs/Guides/01_BootFlow_Setup_Guide.md
Normal file
580
Docs/Guides/01_BootFlow_Setup_Guide.md
Normal file
@@ -0,0 +1,580 @@
|
||||
# 游戏启动流程开发手册
|
||||
|
||||
> 文件位置:`Docs/Guides/01_BootFlow_Setup_Guide.md`
|
||||
> 版本:1.0 · 适用项目:zeling_v2
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [架构概览](#1-架构概览)
|
||||
2. [完整启动时序图](#2-完整启动时序图)
|
||||
3. [关键脚本说明](#3-关键脚本说明)
|
||||
4. [事件频道速查表](#4-事件频道速查表)
|
||||
5. [分步配置教程](#5-分步配置教程)
|
||||
- 5.1 [创建事件频道资产(Step 1)](#51-创建事件频道资产step-1)
|
||||
- 5.2 [配置 Persistent 场景(Step 2)](#52-配置-persistent-场景step-2)
|
||||
- 5.3 [配置 Splash 演出(Step 2 续)](#53-配置-splash-演出step-2-续)
|
||||
- 5.4 [配置 Loading 画面(Step 2 续)](#54-配置-loading-画面step-2-续)
|
||||
- 5.5 [创建并配置主菜单场景(Step 3)](#55-创建并配置主菜单场景step-3)
|
||||
- 5.6 [Build Settings 配置(Step 4)](#56-build-settings-配置step-4)
|
||||
6. [使用编辑器向导工具](#6-使用编辑器向导工具)
|
||||
7. [FSM 状态转换关系](#7-fsm-状态转换关系)
|
||||
8. [自定义扩展指南](#8-自定义扩展指南)
|
||||
9. [常见问题排查](#9-常见问题排查)
|
||||
|
||||
---
|
||||
|
||||
## 1. 架构概览
|
||||
|
||||
启动流程由以下四个程序集中的组件协同完成,通过事件频道(`ScriptableObject` 事件总线)彻底解耦:
|
||||
|
||||
```
|
||||
BaseGames.Core BaseGames.UI
|
||||
───────────────────────────── ─────────────────────────────
|
||||
GameManager ◄──────────── SplashScreenController
|
||||
└─ BootSequencer ────────────► (via EVT_SplashStartRequest)
|
||||
└─ GameStateMachine MainMenuController
|
||||
LoadingScreenManager
|
||||
BaseGames.Core.Events (共享)
|
||||
─────────────────────────────
|
||||
VoidEventChannelSO FloatEventChannelSO
|
||||
IntEventChannelSO StringEventChannelSO
|
||||
SceneLoadRequestEventChannelSO
|
||||
```
|
||||
|
||||
**核心设计原则:**
|
||||
- `BaseGames.Core` 不依赖 `BaseGames.UI`,所有跨程序集通信通过 SO 事件频道进行。
|
||||
- 所有场景加载请求都汇聚到 `EVT_SceneLoadRequest`,GameManager 和 SceneService 共享同一 SO 实例。
|
||||
- GameStateMachine 是状态权威,所有 UI 面板的显隐跟随 `EVT_GameStateChanged` 事件。
|
||||
|
||||
---
|
||||
|
||||
## 2. 完整启动时序图
|
||||
|
||||
```
|
||||
应用启动(首场景加载前)
|
||||
│
|
||||
├── [RuntimeInitializeOnLoadMethod] GameBootstrap
|
||||
│ └── 以 Additive 模式加载 Scene_Persistent
|
||||
│
|
||||
▼
|
||||
Scene_Persistent 加载完成
|
||||
│
|
||||
├── GameServiceRegistrar.Awake(-2000)
|
||||
│ └── 向 ServiceLocator 注册:SceneService、DeathRespawnService、
|
||||
│ EventChannelRegistry、GameSaveManager、…
|
||||
│
|
||||
├── GameManager.Awake(-1000)
|
||||
│ ├── DontDestroyOnLoad(root)
|
||||
│ ├── FSM.TransitionTo(Initializing)
|
||||
│ └── 注册所有 FSM 状态
|
||||
│
|
||||
└── GameManager.Start()
|
||||
├── Raise(EVT_GameStateChanged, Initializing)
|
||||
└── StartCoroutine(BootCoroutine)
|
||||
│
|
||||
├── BootSequencer.RunBootSequenceCoroutine()
|
||||
│ ├── Raise(EVT_SplashStartRequest) ──► SplashScreenController 开始
|
||||
│ │ 播放演出(工作室 Logo →
|
||||
│ │ 游戏标题)可任意键跳过
|
||||
│ │
|
||||
│ ├── [并行] PreloadCoroutine()
|
||||
│ │ └── Addressables.DownloadDependenciesAsync("Preload")
|
||||
│ │ 每帧 Raise(EVT_LoadingProgressUpdated, progress*0.9)
|
||||
│ │
|
||||
│ └── WaitUntil(SplashDone && PreloadDone)
|
||||
│ SplashScreenController 结束 → Raise(EVT_SplashComplete)
|
||||
│
|
||||
└── SceneService.LoadMainMenuCoroutine()
|
||||
└── SceneLoader.LoadSceneCoroutine(Scene_MainMenu)
|
||||
└── 加载成功 → Raise(EVT_SceneLoaded, "Scene_MainMenu")
|
||||
└── GameManager.HandleSceneLoaded
|
||||
└── FSM: Initializing → MainMenu
|
||||
Raise(EVT_GameStateChanged, MainMenu)
|
||||
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
主菜单激活
|
||||
│
|
||||
├── MainMenuController.OnEnable()
|
||||
│ └── 订阅 EVT_GameStateChanged / EVT_SlotConfirmed
|
||||
│
|
||||
└── MainMenuController 响应 EVT_GameStateChanged(MainMenu)
|
||||
└── 播放入场动画(菜单面板下滑)
|
||||
└── RefreshContinueButton()(检查存档是否存在)
|
||||
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
新游戏 / 继续
|
||||
│
|
||||
├── 玩家点击「新游戏」或「继续」
|
||||
│ └── 显示 SaveSlotPanel(选择存档槽 0/1/2)
|
||||
│
|
||||
├── SaveSlotController → Raise(EVT_SlotConfirmed, slotIndex)
|
||||
│
|
||||
├── MainMenuController.HandleSlotConfirmed()
|
||||
│ └── 关闭 SaveSlotPanel
|
||||
│ Raise(EVT_SceneLoadRequest, {firstGameSceneKey, Scene, ShowLoadingScreen=true})
|
||||
│
|
||||
├── GameManager.HandleSceneLoadRequest()
|
||||
│ └── FSM: MainMenu → LoadingScene
|
||||
│ Raise(EVT_GameStateChanged, LoadingScene)
|
||||
│
|
||||
├── SceneLoader.LoadSceneCoroutine()
|
||||
│ ├── Raise(EVT_LoadingStarted) → LoadingScreenManager 显示进度画面
|
||||
│ ├── 每帧 Raise(EVT_LoadingProgressUpdated, p*0.9)
|
||||
│ ├── Raise(EVT_LoadingProgressUpdated, 1.0)
|
||||
│ └── Raise(EVT_LoadingComplete) → LoadingScreenManager 隐藏
|
||||
│ Raise(EVT_SceneLoaded, gameSceneName)
|
||||
│
|
||||
└── GameManager.HandleSceneLoaded()
|
||||
└── FSM: LoadingScene → Gameplay
|
||||
Raise(EVT_GameStateChanged, Gameplay)
|
||||
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
暂停 → 返回主菜单
|
||||
│
|
||||
├── PauseMenuController.GoToMainMenu()
|
||||
│ └── Raise(EVT_SceneLoadRequest, {Scene_MainMenu, Scene, ShowLoadingScreen=false})
|
||||
│
|
||||
├── GameManager.HandleSceneLoadRequest()
|
||||
│ └── 目标为 Scene_MainMenu → 跳过 LoadingScene 中间状态
|
||||
│
|
||||
└── SceneLoader 加载完成 → Raise(EVT_SceneLoaded, "Scene_MainMenu")
|
||||
└── FSM: Paused → MainMenu
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 关键脚本说明
|
||||
|
||||
### `GameManager`(Core/GameManager.cs)
|
||||
全局游戏管理器,持有 FSM 并协调所有顶层服务。
|
||||
|
||||
| 新增字段 | 用途 |
|
||||
|---|---|
|
||||
| `_bootSequencer` | 引用 Persistent 场景中的 BootSequencer |
|
||||
| `_onSceneLoadRequest` | 监听场景加载请求(与 SceneService 共享同一 SO)|
|
||||
| `_onSceneLoaded` | 监听 SceneLoader 加载完成(携带场景名)|
|
||||
|
||||
`Start()` 启动 `BootCoroutine()`,`HandleSceneLoadRequest()` / `HandleSceneLoaded()` 自动驱动 FSM 转换。
|
||||
|
||||
---
|
||||
|
||||
### `BootSequencer`(Core/BootSequencer.cs)
|
||||
挂载在 Persistent 场景,驱动 Splash 演出与 Addressable 预热并行执行。
|
||||
|
||||
| Inspector 字段 | 说明 |
|
||||
|---|---|
|
||||
| `_preloadLabel` | Addressable 预热标签(留空则跳过预热)|
|
||||
| `_onSplashStartRequest` | 赋 `EVT_SplashStartRequest`(Raise)|
|
||||
| `_onSplashComplete` | 赋 `EVT_SplashComplete`(Listen)|
|
||||
| `_onPreloadProgress` | 赋 `EVT_LoadingProgressUpdated`(可选,供进度条显示)|
|
||||
|
||||
**重要:** `_onSplashComplete` 留空时,BootSequencer 不等待 Splash,直接进入主菜单。适合调试阶段快速跳过 Splash。
|
||||
|
||||
---
|
||||
|
||||
### `SplashScreenController`(UI/Splash/SplashScreenController.cs)
|
||||
挂载在 `Canvas_Splash`(排序层 100),播放两段淡入/淡出动画,任意键可跳过。
|
||||
|
||||
| Inspector 字段 | 说明 |
|
||||
|---|---|
|
||||
| `_studioLogoGroup` | 工作室 Logo 的 CanvasGroup |
|
||||
| `_gameTitleGroup` | 游戏标题的 CanvasGroup |
|
||||
| `_onSplashStartRequest` | 赋 `EVT_SplashStartRequest`(Listen)|
|
||||
| `_onSplashComplete` | 赋 `EVT_SplashComplete`(Raise)|
|
||||
| `_fadeDuration` | 淡入/淡出时长(秒,默认 1.0)|
|
||||
| `_holdDuration` | 每帧持续时长(秒,默认 1.5)|
|
||||
|
||||
---
|
||||
|
||||
### `MainMenuController`(UI/MainMenu/MainMenuController.cs)
|
||||
挂载在主菜单场景的 Canvas 上,管理按钮、子面板、入场动画。
|
||||
|
||||
| Inspector 字段 | 说明 |
|
||||
|---|---|
|
||||
| `_onGameStateChanged` | 赋 `EVT_GameStateChanged`(Listen)|
|
||||
| `_onSceneLoadRequest` | 赋 `EVT_SceneLoadRequest`(Raise)|
|
||||
| `_onSlotConfirmed` | 赋 `EVT_SlotConfirmed`(Listen)|
|
||||
| `_firstGameSceneKey` | 第一个游戏场景的 Addressable Key(**必填**)|
|
||||
| `_btnNewGame/Continue/Settings/Credits/Quit` | 各按钮引用 |
|
||||
| `_menuPanel` | 主按钮区 GameObject(用于入场动画)|
|
||||
| `_saveSlotPanel / _settingsPanel / _creditsPanel` | 子面板 |
|
||||
| `_saveSlotController` | SaveSlotController 引用 |
|
||||
|
||||
---
|
||||
|
||||
### `SceneLoader`(Core/SceneLoader.cs)
|
||||
加载时序改为逐帧轮询,当 `ShowLoadingScreen = true` 时自动发布进度事件。
|
||||
|
||||
| 新增字段 | 用途 |
|
||||
|---|---|
|
||||
| `_onLoadingStarted` | 赋 `EVT_LoadingStarted`(Raise 给 LoadingScreenManager)|
|
||||
| `_onLoadingComplete` | 赋 `EVT_LoadingComplete`(Raise 给 LoadingScreenManager)|
|
||||
| `_onLoadingProgressUpdated` | 赋 `EVT_LoadingProgressUpdated`(Raise 进度值 0~1)|
|
||||
|
||||
---
|
||||
|
||||
## 4. 事件频道速查表
|
||||
|
||||
| SO 资产名 | 类型 | 发布者 | 监听者 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| `EVT_SplashStartRequest` | VoidEventChannelSO | BootSequencer | SplashScreenController | 触发 Splash 演出开始 |
|
||||
| `EVT_SplashComplete` | VoidEventChannelSO | SplashScreenController | BootSequencer | Splash 结束通知 |
|
||||
| `EVT_LoadingStarted` | VoidEventChannelSO | SceneLoader | LoadingScreenManager | 加载开始,显示进度画面 |
|
||||
| `EVT_LoadingComplete` | VoidEventChannelSO | SceneLoader | LoadingScreenManager | 加载结束,隐藏进度画面 |
|
||||
| `EVT_LoadingProgressUpdated` | FloatEventChannelSO | SceneLoader / BootSequencer | LoadingScreenManager | 进度值 0~1 |
|
||||
| `EVT_SlotConfirmed` | IntEventChannelSO | SaveSlotController | MainMenuController | 存档槽选择完成(携带槽索引)|
|
||||
| `EVT_SceneLoadRequest` | SceneLoadRequestEventChannelSO | MainMenuController / PauseMenuController / … | GameManager + SceneService | 场景加载请求(共享同一 SO)|
|
||||
| `EVT_SceneLoaded` | StringEventChannelSO | SceneLoader | GameManager | 加载完成(携带场景名)|
|
||||
| `EVT_GameStateChanged` | GameStateEventChannelSO | GameManager | MainMenuController / UIManager / … | FSM 状态改变通知 |
|
||||
|
||||
> **注意:** `EVT_SceneLoadRequest` 和 `EVT_SceneLoaded` 在 Inspector 中必须使用同一个 SO 实例赋给所有相关组件。不同组件使用不同 SO 实例是最常见的配置错误。
|
||||
|
||||
---
|
||||
|
||||
## 5. 分步配置教程
|
||||
|
||||
### 5.1 创建事件频道资产(Step 1)
|
||||
|
||||
**方法 A — 一键创建(推荐):**
|
||||
|
||||
1. 打开菜单 **BaseGames → Tools → Boot Flow Wizard**。
|
||||
2. 在 **Step 1** 区域点击 **「一键创建所有缺失资产」**。
|
||||
3. 资产将创建在 `Assets/_Game/Data/Events/UI/Splash/`、`UI/Loading/`、`UI/MainMenu/`、`Core/` 目录下。
|
||||
4. 检查清单全部变绿后进入下一步。
|
||||
|
||||
**方法 B — 手动创建:**
|
||||
|
||||
在 Project 窗口右键 → Create,按以下清单逐一创建:
|
||||
|
||||
```
|
||||
Assets/_Game/Data/Events/UI/Splash/
|
||||
EVT_SplashStartRequest.asset (VoidEventChannelSO)
|
||||
EVT_SplashComplete.asset (VoidEventChannelSO)
|
||||
|
||||
Assets/_Game/Data/Events/UI/Loading/
|
||||
EVT_LoadingStarted.asset (VoidEventChannelSO)
|
||||
EVT_LoadingComplete.asset (VoidEventChannelSO)
|
||||
EVT_LoadingProgressUpdated.asset (FloatEventChannelSO)
|
||||
|
||||
Assets/_Game/Data/Events/UI/MainMenu/
|
||||
EVT_SlotConfirmed.asset (IntEventChannelSO)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.2 配置 Persistent 场景(Step 2)
|
||||
|
||||
**前置条件:** 已完成 Step 1,`Scene_Persistent` 已在 Hierarchy 中打开。
|
||||
|
||||
**方法 A — 自动脚手架(推荐):**
|
||||
|
||||
1. 在 Hierarchy 中打开 `Scene_Persistent`(双击或在 Build Settings 中打开)。
|
||||
2. 打开 **BaseGames → Tools → Boot Flow Wizard**,点击 **Step 2** 中的 **「脚手架 Persistent 场景」**。
|
||||
3. 工具将自动:
|
||||
- 在 `[Services]` 下创建 `BootSequencer` GameObject 并挂载组件。
|
||||
- 在 `[UI]` 下创建 `Canvas_Splash`(排序层 100)并挂载 `SplashScreenController`。
|
||||
- 在 `[UI]` 下创建 `Canvas_Loading`(排序层 99)并挂载 `LoadingScreenManager`。
|
||||
- 自动绑定所有存在的事件频道 SO。
|
||||
- 为 `GameManager` 绑定 `_bootSequencer`、`_onSceneLoadRequest`、`_onSceneLoaded`。
|
||||
- 为 `SceneLoader` 绑定三个加载事件频道。
|
||||
4. 保存场景(Ctrl+S)。
|
||||
|
||||
**方法 B — 手动配置(如需精细控制):**
|
||||
|
||||
1. 在 `[Services]` 下新建空 GameObject 命名为 `BootSequencer`,挂载 `BootSequencer` 组件。
|
||||
2. 在 Inspector 中赋值:
|
||||
- `_onSplashStartRequest` → `EVT_SplashStartRequest`
|
||||
- `_onSplashComplete` → `EVT_SplashComplete`
|
||||
- `_onPreloadProgress` → `EVT_LoadingProgressUpdated`(可选)
|
||||
- `_preloadLabel` → Addressable 标签名(如 `"Preload"`,留空则跳过)
|
||||
3. 在 `GameManager` 组件 Inspector 中赋值:
|
||||
- `_bootSequencer` → 上述 BootSequencer 组件
|
||||
- `_onSceneLoadRequest` → `EVT_SceneLoadRequest`
|
||||
- `_onSceneLoaded` → `EVT_SceneLoaded`
|
||||
4. 在 `SceneLoader` 组件 Inspector 中赋值:
|
||||
- `_onLoadingStarted` → `EVT_LoadingStarted`
|
||||
- `_onLoadingComplete` → `EVT_LoadingComplete`
|
||||
- `_onLoadingProgressUpdated` → `EVT_LoadingProgressUpdated`
|
||||
|
||||
---
|
||||
|
||||
### 5.3 配置 Splash 演出(Step 2 续)
|
||||
|
||||
1. 找到 `Canvas_Splash` GameObject。
|
||||
2. 在子节点中创建两个 Image + CanvasGroup 结构:
|
||||
```
|
||||
Canvas_Splash (SplashScreenController)
|
||||
└── StudioLogo <- 工作室 Logo 图片
|
||||
└── CanvasGroup
|
||||
└── GameTitle <- 游戏标题图片/文字
|
||||
└── CanvasGroup
|
||||
```
|
||||
3. 将 `StudioLogo` 的 CanvasGroup 赋给 `SplashScreenController._studioLogoGroup`。
|
||||
4. 将 `GameTitle` 的 CanvasGroup 赋给 `SplashScreenController._gameTitleGroup`。
|
||||
5. 调整 `_fadeDuration`(淡入/淡出时长)和 `_holdDuration`(停留时长)。
|
||||
|
||||
**跳过 Splash(调试模式):**
|
||||
- 将 `BootSequencer._onSplashComplete` 留空,BootSequencer 不会等待 Splash 完成,直接进入主菜单。
|
||||
|
||||
---
|
||||
|
||||
### 5.4 配置 Loading 画面(Step 2 续)
|
||||
|
||||
1. 找到 `Canvas_Loading` GameObject(默认已由脚手架创建,初始 `SetActive(false)`)。
|
||||
2. 为 `LoadingScreenManager` 创建所需 UI 子节点:
|
||||
```
|
||||
Canvas_Loading (LoadingScreenManager)
|
||||
└── LoadingPanel (背景遮罩 + 内容)
|
||||
└── ProgressBar (Slider)
|
||||
└── TipText (可选:随机提示文字)
|
||||
```
|
||||
3. 在 `LoadingScreenManager` Inspector 中赋值:
|
||||
- `_loadingPanel` → `LoadingPanel` GameObject
|
||||
- `_progressBar` → `ProgressBar` Slider 组件
|
||||
- `_onLoadingStarted` → `EVT_LoadingStarted`(已由脚手架绑定)
|
||||
- `_onLoadingComplete` → `EVT_LoadingComplete`(已由脚手架绑定)
|
||||
- `_onLoadingProgressUpdated` → `EVT_LoadingProgressUpdated`(已由脚手架绑定)
|
||||
|
||||
---
|
||||
|
||||
### 5.5 创建并配置主菜单场景(Step 3)
|
||||
|
||||
**创建场景:**
|
||||
|
||||
1. 在 Project 窗口右键 → Create → Scene,命名为 `Scene_MainMenu`。
|
||||
2. 将其放置在 `Assets/_Game/Scenes/` 目录下。
|
||||
3. 双击打开场景。
|
||||
|
||||
**自动脚手架:**
|
||||
|
||||
1. 打开 **Boot Flow Wizard → Step 3**,点击 **「脚手架 MainMenu 场景」**。
|
||||
2. 工具将在场景中生成:
|
||||
```
|
||||
[MainMenu]
|
||||
└── Canvas_MainMenu (Canvas, CanvasScaler, GraphicRaycaster, MainMenuController)
|
||||
└── MenuPanel (VerticalLayoutGroup)
|
||||
├── Btn_NewGame (Image, Button)
|
||||
├── Btn_Continue (Image, Button)
|
||||
├── Btn_Settings (Image, Button)
|
||||
├── Btn_Credits (Image, Button)
|
||||
└── Btn_Quit (Image, Button)
|
||||
├── SaveSlotPanel (SetActive=false, SaveSlotController)
|
||||
├── SettingsPanel (SetActive=false)
|
||||
└── CreditsPanel (SetActive=false)
|
||||
```
|
||||
3. **必须手动填写** `MainMenuController._firstGameSceneKey`:
|
||||
- 在 Inspector 中输入第一个游戏场景的 Addressable Key(字符串)。
|
||||
- 例如:`"Scene_Prologue"` 或 `"Scene_Town_01"`。
|
||||
|
||||
**SaveSlotPanel 配置:**
|
||||
|
||||
1. 打开 `SaveSlotPanel`。
|
||||
2. 为 `SaveSlotController` 补充三个存档槽按钮引用(`_slot0Btn`、`_slot1Btn`、`_slot2Btn`)。
|
||||
3. 为每个按钮添加适当的 UI 样式(背景图、存档信息文本等)。
|
||||
|
||||
**SettingsPanel / CreditsPanel:**
|
||||
这两个面板为空节点,由各自项目美术/策划填充内容。`MainMenuController` 通过 `_settingsPanel.SetActive(true/false)` 控制其显隐。
|
||||
|
||||
---
|
||||
|
||||
### 5.6 Build Settings 配置(Step 4)
|
||||
|
||||
1. 打开菜单 **File → Build Settings**(或 Boot Flow Wizard 中点击 **「打开 Build Settings」**)。
|
||||
2. 将以下场景加入 Scenes in Build(顺序重要):
|
||||
| 索引 | 场景 | 说明 |
|
||||
|---|---|---|
|
||||
| 0 | `Assets/_Game/Scenes/Scene_Boot.unity` | 启动入口(仅包含 `GameBootstrap`)|
|
||||
| — | `Assets/_Game/Scenes/Scene_Persistent.unity` | DontDestroyOnLoad 场景(不需要显式索引)|
|
||||
| — | `Assets/_Game/Scenes/Scene_MainMenu.unity` | 主菜单(通过 Addressables 加载)|
|
||||
3. 确保 `Scene_Boot` 为索引 0(Player 设置中的第一场景)。
|
||||
|
||||
> **注意:** 其他所有游戏场景(关卡等)应通过 Addressables 打包,**不应**加入 Build Settings 的 Scene 列表,以避免包体膨胀。
|
||||
|
||||
---
|
||||
|
||||
## 6. 使用编辑器向导工具
|
||||
|
||||
### Boot Flow Wizard
|
||||
|
||||
菜单:**BaseGames → Tools → Boot Flow Wizard**
|
||||
|
||||
窗口提供四个步骤的实时状态检查:
|
||||
|
||||
- **✅(绿色)** = 该项已正确配置
|
||||
- **⬜(灰色)** = 该项尚未完成
|
||||
|
||||
| 步骤 | 功能 |
|
||||
|---|---|
|
||||
| Step 1 | 检测所有 8 个启动流程事件频道资产,一键创建缺失项 |
|
||||
| Step 2 | 检测 Persistent 场景中 9 个组件/字段绑定状态,一键脚手架 |
|
||||
| Step 3 | 检测主菜单场景组件状态,一键脚手架,快速打开 Build Settings |
|
||||
| Step 4 | 运行全量验证并在 Console 输出带位置信息的报告 |
|
||||
|
||||
底部状态栏实时显示 `通过 N / 总计 M 项检查`。
|
||||
|
||||
### Create Event Channel Assets
|
||||
|
||||
菜单:**BaseGames → Tools → Create Event Channel Assets**
|
||||
|
||||
在 `Assets/_Game/Data/Events/` 目录下批量创建**所有**系统所需事件频道(含启动流程部分)。已存在资产自动跳过(幂等操作)。
|
||||
|
||||
### Scaffold Persistent Scene / Scaffold Main Menu Scene
|
||||
|
||||
菜单:**BaseGames → Tools → Scaffold Persistent Scene**
|
||||
菜单:**BaseGames → Tools → Scaffold Main Menu Scene**(优先级 202)
|
||||
|
||||
独立的脚手架工具,适合不打开向导窗口时直接执行。
|
||||
|
||||
---
|
||||
|
||||
## 7. FSM 状态转换关系
|
||||
|
||||
启动流程涉及的 GameStateMachine 转换:
|
||||
|
||||
```
|
||||
Initializing
|
||||
│
|
||||
└──(EVT_SceneLoaded: Scene_MainMenu)──► MainMenu
|
||||
│
|
||||
┌─────────────────────┤
|
||||
│ │
|
||||
(新游戏/继续) (其他)
|
||||
│
|
||||
▼
|
||||
LoadingScene
|
||||
│
|
||||
└──(EVT_SceneLoaded: GameScene)──► Gameplay
|
||||
│
|
||||
┌──────────┤
|
||||
│ │
|
||||
(Pause) (PlayerDied)
|
||||
│ │
|
||||
Paused Dead
|
||||
│
|
||||
┌──────────────┤
|
||||
│ │
|
||||
(Resume) (GoToMainMenu)
|
||||
│ │
|
||||
Gameplay MainMenu
|
||||
|
||||
GameOver ──(EVT_SceneLoaded: Scene_MainMenu)──► MainMenu
|
||||
```
|
||||
|
||||
**`HandleSceneLoadRequest` 逻辑(GameManager):**
|
||||
- `TransitionType.Scene` + 目标 ≠ `Scene_MainMenu` → FSM 当前在 MainMenu/Gameplay/BossFight → 转换到 `LoadingScene`
|
||||
- `TransitionType.Scene` + 目标 = `Scene_MainMenu` → 不经过 `LoadingScene`,由 `HandleSceneLoaded` 直接转换
|
||||
- `TransitionType.Room` → 完全忽略,状态保持 Gameplay
|
||||
|
||||
---
|
||||
|
||||
## 8. 自定义扩展指南
|
||||
|
||||
### 添加第三段 Splash(如游戏内 IP 授权方 Logo)
|
||||
|
||||
在 `SplashScreenController` 中 `PlayAsync()` 方法末尾追加:
|
||||
|
||||
```csharp
|
||||
// 第三段:IP Logo
|
||||
if (_ipLogoGroup != null)
|
||||
{
|
||||
yield return StartCoroutine(Fade(_ipLogoGroup, 0f, 1f, _fadeDuration));
|
||||
yield return new WaitForSeconds(_holdDuration);
|
||||
yield return StartCoroutine(Fade(_ipLogoGroup, 1f, 0f, _fadeDuration));
|
||||
}
|
||||
```
|
||||
|
||||
同时在 Inspector 中添加 `[SerializeField] private CanvasGroup _ipLogoGroup;` 字段。
|
||||
|
||||
---
|
||||
|
||||
### 修改 Loading 画面为视频背景
|
||||
|
||||
1. 给 `Canvas_Loading` 添加 `RawImage` 组件用于显示视频。
|
||||
2. 在 `LoadingScreenManager` 的 `Show()` 中启动 `VideoPlayer.Play()`,`Hide()` 中停止。
|
||||
3. 使用 `EVT_LoadingStarted` / `EVT_LoadingComplete` 频道触发视频播放/停止。
|
||||
|
||||
---
|
||||
|
||||
### 新游戏时跳过存档选择(单存档模式)
|
||||
|
||||
在 `MainMenuController` 中,将 `OnNewGameClicked` 修改为直接触发槽 0:
|
||||
|
||||
```csharp
|
||||
private void OnNewGameClicked()
|
||||
{
|
||||
// 单存档:直接使用槽 0
|
||||
HandleSlotConfirmed(0);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 添加主菜单背景音乐
|
||||
|
||||
在 `MainMenuController.Start()` 或响应 `EVT_GameStateChanged(MainMenu)` 时:
|
||||
|
||||
```csharp
|
||||
_onBGMRequest?.Raise("BGM_MainMenu"); // 赋值 EVT_BGMRequest 频道
|
||||
```
|
||||
|
||||
AudioManager 的 `EVT_BGMRequest` 监听器会自动淡入播放。
|
||||
|
||||
---
|
||||
|
||||
## 9. 常见问题排查
|
||||
|
||||
### ❌ 游戏启动后停在黑屏,不显示 Splash 也不进入主菜单
|
||||
|
||||
**原因:** `GameManager._bootSequencer` 未绑定,BootCoroutine 无法执行。
|
||||
**解决:** 打开 Boot Flow Wizard → Step 2,检查 `GameManager._bootSequencer 已绑定` 是否为绿色;否则重新执行脚手架。
|
||||
|
||||
---
|
||||
|
||||
### ❌ Splash 演出播放完毕后一直黑屏(不进入主菜单)
|
||||
|
||||
**原因 1:** `GameManager._onSceneLoaded` 未绑定或绑定了错误的 SO 实例,`HandleSceneLoaded` 从未被调用。
|
||||
**解决:** 确认 `GameManager._onSceneLoaded` 与 `SceneLoader._onSceneLoaded` 绑定同一个 `EVT_SceneLoaded.asset`。
|
||||
|
||||
**原因 2:** `ISceneService` 未注册(`GameServiceRegistrar` 未正确引用 `SceneService`)。
|
||||
**解决:** Console 中搜索 `[GameManager] ISceneService 未注册`,检查 Persistent 场景中的 `GameServiceRegistrar._sceneService` 引用。
|
||||
|
||||
---
|
||||
|
||||
### ❌ 点击「新游戏」后进入黑屏,加载进度条不出现
|
||||
|
||||
**原因 1:** `LoadingScreenManager._onLoadingStarted` 未绑定。
|
||||
**原因 2:** `LoadingScreenManager._loadingPanel` 为空(未赋值 UI 根节点)。
|
||||
**解决:** 检查 Boot Flow Wizard Step 2 中 `SceneLoader._onLoadingStarted 已绑定` 状态。
|
||||
|
||||
---
|
||||
|
||||
### ❌ `MainMenuController._firstGameSceneKey` 填写后仍报 Addressable 加载失败
|
||||
|
||||
**原因:** Key 填写有误,或对应场景未在 Addressables 中标记。
|
||||
**解决:**
|
||||
1. 打开 **Window → Asset Management → Addressables → Groups**。
|
||||
2. 找到目标场景资产,确认其 Address 与 `_firstGameSceneKey` 完全一致(大小写敏感)。
|
||||
3. 确保该场景已勾选 Include in Build。
|
||||
|
||||
---
|
||||
|
||||
### ❌ 返回主菜单时 FSM 报 Invalid transition 警告
|
||||
|
||||
**原因:** 通常是从 `Dead` 或 `GameOver` 状态直接触发 `HandleSceneLoaded(MainMenu)`,而这两个状态的 `ValidNextStates` 不包含 `MainMenu`。
|
||||
**解决:** 确保 `DeathRespawnService.StartGameOverCoroutine()` 在加载主菜单场景**之前**已调用 `GameManager.RequestTransition(GameOver)`。参见 `BuiltinGameStates.cs` 中 `GameOverState.ValidNextStates`。
|
||||
|
||||
---
|
||||
|
||||
### ❌ 编辑器中运行正常,打包后 Splash 不显示
|
||||
|
||||
**原因:** `Canvas_Splash` 上的 Image Sprite 未加入 Addressable Build 或 Sprite Atlas。
|
||||
**解决:** 将 Splash 使用的所有 Texture/Sprite 加入 Addressable Group(标签 `"Preload"` 以便启动时预热),或直接内嵌进默认 Resources。
|
||||
|
||||
---
|
||||
|
||||
*文档最后更新:2026-05-19*
|
||||
@@ -58,7 +58,13 @@
|
||||
|---|---|---|
|
||||
| `TriggerZone` | 存档点(`CheckpointMarker`)、存档台(`SavePoint`)、传送站(`TeleportStation`)、房间过渡(`RoomTransition` / `DoorTransition`)、相机区域(`CameraArea`) | 纯触发碰撞体(isTrigger),不参与物理阻挡;统一使用此 Layer 方便在碰撞矩阵中集中管理 |
|
||||
|
||||
### 2.5 特殊用途
|
||||
### 2.5 环境危险
|
||||
|
||||
| Layer 名称 | 挂载对象 | 用途说明 |
|
||||
|---|---|---|
|
||||
| `HazardHitBox` | 对双方均造成伤害的环境危险(落石、熔岩、毒区、AOE 爆炸范围等) | 同时与 `PlayerHurtBox` 和 `EnemyHurtBox` 碰撞;区别于 `EnemyHitBox`(只对玩家),此 Layer 用于阵营中立的环境伤害源 |
|
||||
|
||||
### 2.6 特殊用途
|
||||
|
||||
| Layer 名称 | 挂载对象 | 用途说明 |
|
||||
|---|---|---|
|
||||
@@ -85,6 +91,10 @@
|
||||
| `EnemyProjectile` | `Ground` | ✅ | 敌人投射物命中地形 |
|
||||
| `PlayerHitBox` | `PlayerHurtBox` | ❌ | 玩家不自伤 |
|
||||
| `PlayerProjectile` | `EnemyProjectile` | ❌ | 子弹不互相碰撞(Clash 系统单独处理) |
|
||||
| `HazardHitBox` | `PlayerHurtBox` | ✅ | 环境危险伤害玩家 |
|
||||
| `HazardHitBox` | `EnemyHurtBox` | ✅ | 环境危险伤害敌人(中立伤害) |
|
||||
| `HazardHitBox` | `PlayerHitBox` | ❌ | 环境不触发拼刀 |
|
||||
| `HazardHitBox` | `EnemyHitBox` | ❌ | 环境不触发拼刀 |
|
||||
|
||||
> 未在上表中列出的 Layer 对默认继承 Unity 全局设置(默认全部碰撞)。
|
||||
|
||||
@@ -122,7 +132,83 @@ void Awake()
|
||||
|
||||
---
|
||||
|
||||
## 5. 检查与修复工具
|
||||
## 6. 复杂场景处理模式
|
||||
|
||||
### 6.1 弹反投射物(EnemyProjectile → 伤害敌人)
|
||||
|
||||
**问题**:`EnemyProjectile ↔ EnemyHurtBox = 不碰撞`,弹反后的投射物无法对敌人造成伤害。
|
||||
|
||||
**正确方案:运行时 Layer 切换**(不新增 Layer)
|
||||
|
||||
弹反成功时,将投射物的 Layer 从 `EnemyProjectile` 切换为 `PlayerProjectile`,同时反转飞行方向。`Projectile` 已有 `SetFactionLayer(int ownerLayer)` 方法,弹反逻辑只需调用它:
|
||||
|
||||
```csharp
|
||||
// HurtBox.ReceiveDamage() 弹反成功后(步骤2)
|
||||
if (_parrySystem.ConsumeParry())
|
||||
{
|
||||
// 若攻击来源是投射物,翻转其阵营 Layer
|
||||
if (info.SourceProjectile != null)
|
||||
{
|
||||
info.SourceProjectile.ReflectAsPlayerProjectile();
|
||||
// ReflectAsPlayerProjectile() 内部:
|
||||
// gameObject.layer = LayerMask.NameToLayer("PlayerProjectile");
|
||||
// rb.velocity = -rb.velocity; // 反向
|
||||
}
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**:当前 `DamageInfo` 尚未携带 `SourceProjectile` 字段,实现此功能需要:
|
||||
> 1. 在 `DamageInfo` 中添加 `Projectile SourceProjectile` 字段
|
||||
> 2. `HitBox.OnTriggerEnter2D` 通过 `_attackerTransform.GetComponent<Projectile>()` 填入
|
||||
> 3. `Projectile` 实现 `ReflectAsPlayerProjectile()` 方法
|
||||
|
||||
**为什么不新建 `ParriedProjectile` 层?**
|
||||
`PlayerProjectile` 的语义本就是"对敌人有效、对玩家无效"的攻击来源,弹反后的投射物完全符合此语义,无需新层。
|
||||
|
||||
---
|
||||
|
||||
### 6.2 环境伤害(同时对玩家与敌人有效)
|
||||
|
||||
`LethalTrap`(陷阱)当前使用 `EnemyHitBox` 层,只对玩家生效,是**有意为之**的设计(跑酷陷阱不伤敌人)。
|
||||
|
||||
对于确实需要**同时伤害双方**的环境机关(落石、熔岩、AOE 区域等),使用 `HazardHitBox` 层并配合 `HazardHitBoxTrigger` 组件(待实现),收到碰撞时向 `PlayerHurtBox` 和 `EnemyHurtBox` 均发送伤害:
|
||||
|
||||
```
|
||||
HazardHitBox ↔ PlayerHurtBox → 碰撞
|
||||
HazardHitBox ↔ EnemyHurtBox → 碰撞
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6.3 敌人之间互伤(友伤/AOE)
|
||||
|
||||
`EnemyHitBox ↔ EnemyHurtBox = 碰撞` 已在碰撞矩阵中开启。
|
||||
自伤防护由 `HitBox.OnTriggerEnter2D` 的根节点比较负责:
|
||||
|
||||
```csharp
|
||||
if (other.transform.root == _attackerTransform.root) return; // 排除自身
|
||||
```
|
||||
|
||||
这意味着:
|
||||
- 同一 GameObject 树的 EnemyHitBox 不会命中自身的 EnemyHurtBox ✅
|
||||
- 敌人 A 的 HitBox 可以命中敌人 B 的 HurtBox ✅(天然支持友伤)
|
||||
- Boss 的 AOE 技能可以对场景中所有其他敌人造成伤害 ✅
|
||||
|
||||
---
|
||||
|
||||
### 6.4 不需要细分的场景
|
||||
|
||||
以下场景**不需要新增 Layer**,通过 `DamageInfo` 字段在逻辑层区分即可:
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
|---|---|
|
||||
| 区分攻击来源(玩家技能 vs 玩家普攻) | `DamageInfo.SkillId` / `DamageInfo.SourceId` |
|
||||
| 不同类型伤害(物理 vs 魔法) | `DamageInfo.Type`(`DamageType` 枚举) |
|
||||
| Boss 阶段专属伤害规则 | `DamageInfo.Tags`(`DamageTags` 标记位) |
|
||||
| 不可弹反的攻击 | `DamageInfo.Flags` 不含 `CanBeParried` |
|
||||
| 穿透无敌帧的伤害 | `DamageInfo.Flags` 含 `IgnoreIFrame` |
|
||||
|
||||
|
||||
项目提供了 **Physics2DLayerReport** 编辑器工具,位于菜单:
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ TagManager:
|
||||
- Volumes
|
||||
- OneWayPlatforms
|
||||
- EnemyProjectile
|
||||
- Enemies
|
||||
- Enemy
|
||||
- PlayerHitBox
|
||||
- PlayerHurtBox
|
||||
- PlayerProjectiles
|
||||
@@ -34,7 +34,7 @@ TagManager:
|
||||
- MidHeightOneWayPlatforms
|
||||
- EnemyHurtBox
|
||||
- PhantomBody
|
||||
- CanAttackProjectiles
|
||||
- HazardHitBox
|
||||
- Lights
|
||||
- ForceZone
|
||||
m_SortingLayers:
|
||||
|
||||
Reference in New Issue
Block a user