完整启动流程

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

View File

@@ -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,
};
}
}

View File

@@ -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);
// 缓存 IClashServiceOnTriggerEnter2D 为物理热路径,避免每次调用 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;

View File

@@ -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)

View File

@@ -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()

View File

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

View File

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

View File

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

View File

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

View File

@@ -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");

View 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})。"); }
}
}
}

View File

@@ -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 绑定 _progressBarSlider和 _loadingPanelGameObject。");
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);

View File

@@ -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, "环境不触发拼刀"),
};
// ─────────────────────────────────────────────────────────────────────

View 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;
}
}
}

View File

@@ -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,
});
}
}

View 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_SplashStartRequestVoidEventChannelSO→ 开始演出。
/// 完成后发布 EVT_SplashCompleteVoidEventChannelSO→ 通知 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;
}
}
}

View 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` 为索引 0Player 设置中的第一场景)。
> **注意:** 其他所有游戏场景(关卡等)应通过 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*

View File

@@ -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** 编辑器工具,位于菜单:

View File

@@ -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: