diff --git a/Assets/_Game/Scripts/Combat/DamageInfo.cs b/Assets/_Game/Scripts/Combat/DamageInfo.cs
index a33f87b..60f8900 100644
--- a/Assets/_Game/Scripts/Combat/DamageInfo.cs
+++ b/Assets/_Game/Scripts/Combat/DamageInfo.cs
@@ -27,6 +27,12 @@ namespace BaseGames.Combat
public BreakLevel Break;
public string SourceId;
public string SkillId;
+ ///
+ /// 攻击来源投射物(仅当攻击方是 Projectile 时非 null)。
+ /// 用于弹反成功时调用 ReflectAsPlayerProjectile() 翻转阵营。
+ /// [NonSerialized]:MonoBehaviour 引用不参与 Unity 资产序列化。
+ ///
+ [System.NonSerialized] public Projectile SourceProjectile;
// ── Builder ──────────────────────────────────────────────────────────
///
@@ -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,
};
}
}
diff --git a/Assets/_Game/Scripts/Combat/HitBox.cs b/Assets/_Game/Scripts/Combat/HitBox.cs
index b8e595f..25616b4 100644
--- a/Assets/_Game/Scripts/Combat/HitBox.cs
+++ b/Assets/_Game/Scripts/Combat/HitBox.cs
@@ -49,6 +49,9 @@ namespace BaseGames.Combat
/// 命中确认委托(PlayerCombat / EnemyCombat 订阅)。
public event System.Action OnHitConfirmed;
+ // 宿主投射物缓存(Activate 时填入,DamageInfo.SourceProjectile 写入用)
+ private Projectile _ownerProjectile;
+
///
/// 激活 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();
+ // 缓存宿主投射物(仅 Projectile GameObject 上挂载的 HitBox 非 null)
+ _ownerProjectile = GetComponent();
}
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;
diff --git a/Assets/_Game/Scripts/Combat/HurtBox.cs b/Assets/_Game/Scripts/Combat/HurtBox.cs
index 68648d1..3c6ed87 100644
--- a/Assets/_Game/Scripts/Combat/HurtBox.cs
+++ b/Assets/_Game/Scripts/Combat/HurtBox.cs
@@ -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)
diff --git a/Assets/_Game/Scripts/Combat/Projectile.cs b/Assets/_Game/Scripts/Combat/Projectile.cs
index af1dc6d..bc548b0 100644
--- a/Assets/_Game/Scripts/Combat/Projectile.cs
+++ b/Assets/_Game/Scripts/Combat/Projectile.cs
@@ -51,7 +51,25 @@ namespace BaseGames.Combat
gameObject.layer = (ownerLayer == playerLayer) ? playerProjLayer : enemyProjLayer;
}
- /// 子类在此设定初速度或附加初始化逻辑。
+ ///
+ /// 弹反:将投射物阵营从 EnemyProjectile 切换为 PlayerProjectile。
+ /// 反转飞行方向,并重置 HitBox 命中记录使其能够命中新目标(敌人)。
+ /// 由 HurtBox.ReceiveDamage() 在弹反成功后调用。
+ ///
+ 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()
diff --git a/Assets/_Game/Scripts/Core/Assets/AddressKeys.cs b/Assets/_Game/Scripts/Core/Assets/AddressKeys.cs
index c0e761b..d747669 100644
--- a/Assets/_Game/Scripts/Core/Assets/AddressKeys.cs
+++ b/Assets/_Game/Scripts/Core/Assets/AddressKeys.cs
@@ -63,6 +63,8 @@ namespace BaseGames.Core.Assets
public const string Poolable = "Poolable";
public const string BGM = "BGM";
public const string Charms = "Charms";
+ /// 游戏启动时预热下载的资产标签(BootSequencer 使用)。
+ public const string Preload = "Preload";
}
}
}
diff --git a/Assets/_Game/Scripts/Core/BootSequencer.cs b/Assets/_Game/Scripts/Core/BootSequencer.cs
new file mode 100644
index 0000000..174ea84
--- /dev/null
+++ b/Assets/_Game/Scripts/Core/BootSequencer.cs
@@ -0,0 +1,104 @@
+using System.Collections;
+using UnityEngine;
+using UnityEngine.AddressableAssets;
+using UnityEngine.ResourceManagement.AsyncOperations;
+using BaseGames.Core.Events;
+
+namespace BaseGames.Core
+{
+ ///
+ /// 游戏启动序列编排器(挂载在 Persistent 场景)。
+ ///
+ /// 职责:
+ /// 1. 触发 Splash 演出(通过 EVT_SplashStartRequest 事件,与 BaseGames.UI 程序集解耦)。
+ /// 2. 并行预热 Addressable 依赖(标签 )。
+ /// 3. 两者均完成后协程返回,由 继续加载主菜单场景。
+ ///
+ /// 配置说明(Inspector):
+ /// _preloadLabel — 留空则跳过预热,游戏仍可正常启动。
+ /// _onSplashStartRequest / _onSplashComplete — 绑定同名 SO 资产;
+ /// 若留空则视为"无 Splash"直接进入主菜单。
+ ///
+ [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 驱动)────────────────────────
+
+ ///
+ /// 运行完整启动序列(Splash 演出 + Addressable 预热并行执行)。
+ /// 两者均完成后协程返回;GameManager 随后加载主菜单场景并切换 FSM 状态。
+ ///
+ 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();
+ }
+ }
+}
diff --git a/Assets/_Game/Scripts/Core/GameManager.cs b/Assets/_Game/Scripts/Core/GameManager.cs
index 6a50965..1beb4f0 100644
--- a/Assets/_Game/Scripts/Core/GameManager.cs
+++ b/Assets/_Game/Scripts/Core/GameManager.cs
@@ -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 _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());
+ }
+
+ ///
+ /// 游戏启动引导协程:
+ /// 1. 运行 BootSequencer(Splash + Addressable 预热)
+ /// 2. 通过 ISceneService 加载主菜单场景
+ /// 3. SceneLoader 完成后发布 _onSceneLoaded → HandleSceneLoaded 切换 FSM 到 MainMenu
+ ///
+ private IEnumerator BootCoroutine()
+ {
+ if (_bootSequencer != null)
+ yield return StartCoroutine(_bootSequencer.RunBootSequenceCoroutine());
+
+ var sceneService = ServiceLocator.GetOrDefault();
+ 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);
}
+ // ── 场景加载状态同步 ──────────────────────────────────────────────
+
+ ///
+ /// 当 SceneService 开始加载主要场景(非 Room 过渡)时,更新 FSM 到 LoadingScene 状态。
+ /// Room 过渡(TransitionType.Room)不经过此处,状态保持 Gameplay。
+ ///
+ 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);
+ }
+ }
+
+ ///
+ /// SceneLoader 完成加载后根据目标场景名自动切换 FSM 状态:
+ /// SceneMainMenu → MainMenu(适用于 Initializing / Paused / GameOver / LoadingScene)
+ /// 其他场景 → Gameplay(仅当处于 LoadingScene 时;Room 过渡不受影响)
+ ///
+ 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;
diff --git a/Assets/_Game/Scripts/Core/SceneLoader.cs b/Assets/_Game/Scripts/Core/SceneLoader.cs
index 4344ee2..284f5e0 100644
--- a/Assets/_Game/Scripts/Core/SceneLoader.cs
+++ b/Assets/_Game/Scripts/Core/SceneLoader.cs
@@ -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 _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);
}
diff --git a/Assets/_Game/Scripts/Editor/Events/CreateEventChannelAssets.cs b/Assets/_Game/Scripts/Editor/Events/CreateEventChannelAssets.cs
index c712488..1987510 100644
--- a/Assets/_Game/Scripts/Editor/Events/CreateEventChannelAssets.cs
+++ b/Assets/_Game/Scripts/Editor/Events/CreateEventChannelAssets.cs
@@ -69,6 +69,18 @@ namespace BaseGames.Editor
CreateAsset ("UI", "EVT_MapOpen");
CreateAsset ("UI", "EVT_ColorblindMode");
+ // ── 启动流程 / Splash ─────────────────────────────────────────────
+ CreateAsset ("UI/Splash", "EVT_SplashStartRequest");
+ CreateAsset ("UI/Splash", "EVT_SplashComplete");
+
+ // ── 启动流程 / Loading 画面 ───────────────────────────────────────
+ CreateAsset ("UI/Loading", "EVT_LoadingStarted");
+ CreateAsset ("UI/Loading", "EVT_LoadingComplete");
+ CreateAsset ("UI/Loading", "EVT_LoadingProgressUpdated");
+
+ // ── 启动流程 / 主菜单 ─────────────────────────────────────────────
+ CreateAsset ("UI/MainMenu", "EVT_SlotConfirmed");
+
// ── World ─────────────────────────────────────────────────────────
CreateAsset ("World", "EVT_SavePointActivated");
CreateAsset ("World", "EVT_CheckpointReached");
diff --git a/Assets/_Game/Scripts/Editor/Scene/BootFlowSetupWizard.cs b/Assets/_Game/Scripts/Editor/Scene/BootFlowSetupWizard.cs
new file mode 100644
index 0000000..3022bfe
--- /dev/null
+++ b/Assets/_Game/Scripts/Editor/Scene/BootFlowSetupWizard.cs
@@ -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
+{
+ ///
+ /// 启动流程安装向导(四步检查清单窗口)。
+ ///
+ /// 功能:
+ /// Step 1 — 检测并创建启动流程所需的全部事件频道 SO 资产。
+ /// Step 2 — 检测 Persistent 场景中 BootSequencer / SplashScreenController /
+ /// LoadingScreenManager 的存在性并提供一键脚手架入口。
+ /// Step 3 — 检测 Scene_MainMenu 是否存在并提供一键脚手架入口。
+ /// Step 4 — 全局验证(必要字段绑定状态),输出操作报告。
+ ///
+ /// 菜单:BaseGames / Tools / Boot Flow Wizard
+ ///
+ 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 check)> _checks = new();
+
+ // ── 菜单入口 ──────────────────────────────────────────────────────────
+
+ [MenuItem("BaseGames/Tools/Boot Flow Wizard", priority = 10)]
+ public static void Open()
+ {
+ var wnd = GetWindow();
+ 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 fn)[]
+ {
+ ("BootSequencer 组件存在", () => FindInLoadedScene(PersistentName) != null),
+ ("SplashScreenController 组件存在", () => FindInLoadedScene(PersistentName) != null),
+ ("LoadingScreenManager 组件存在", () => FindInLoadedScene(PersistentName) != null),
+ ("BootSequencer._onSplashStartRequest 已绑定",
+ () => IsFieldBound(FindInLoadedScene(PersistentName), "_onSplashStartRequest")),
+ ("BootSequencer._onSplashComplete 已绑定",
+ () => IsFieldBound(FindInLoadedScene(PersistentName), "_onSplashComplete")),
+ ("SceneLoader._onLoadingStarted 已绑定",
+ () => IsFieldBound(FindInLoadedScene(PersistentName), "_onLoadingStarted")),
+ ("GameManager._bootSequencer 已绑定",
+ () => IsFieldBound(FindInLoadedScene(PersistentName), "_bootSequencer")),
+ ("GameManager._onSceneLoadRequest 已绑定",
+ () => IsFieldBound(FindInLoadedScene(PersistentName), "_onSceneLoadRequest")),
+ ("GameManager._onSceneLoaded 已绑定",
+ () => IsFieldBound(FindInLoadedScene(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 fn)[]
+ {
+ ("MainMenuController 组件存在", () => FindInLoadedScene(MainMenuName) != null),
+ ("SaveSlotController 组件存在", () => FindInLoadedScene(MainMenuName) != null),
+ ("MainMenuController._onSceneLoadRequest 已绑定",
+ () => IsFieldBound(FindInLoadedScene(MainMenuName), "_onSceneLoadRequest")),
+ ("MainMenuController._onSlotConfirmed 已绑定",
+ () => IsFieldBound(FindInLoadedScene(MainMenuName), "_onSlotConfirmed")),
+ ("MainMenuController._firstGameSceneKey 非空",
+ () => IsStringFieldNonEmpty(FindInLoadedScene(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();
+ 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(PersistentName, "BootSequencer", report, ref passCount, ref failCount);
+ CheckComponent(PersistentName, "SplashScreenController", report, ref passCount, ref failCount);
+ CheckComponent(PersistentName, "LoadingScreenManager", report, ref passCount, ref failCount);
+ CheckComponent(PersistentName, "GameManager", report, ref passCount, ref failCount);
+ CheckComponent(PersistentName, "SceneLoader", report, ref passCount, ref failCount);
+
+ // Field bindings
+ CheckField(PersistentName, "_onSplashStartRequest", report, ref passCount, ref failCount);
+ CheckField(PersistentName, "_onSplashComplete", report, ref passCount, ref failCount);
+ CheckField(PersistentName, "_bootSequencer", report, ref passCount, ref failCount);
+ CheckField(PersistentName, "_onSceneLoadRequest", report, ref passCount, ref failCount);
+ CheckField(PersistentName, "_onSceneLoaded", report, ref passCount, ref failCount);
+ CheckField(PersistentName, "_onLoadingStarted", report, ref passCount, ref failCount);
+ CheckField(PersistentName, "_onLoadingComplete", report, ref passCount, ref failCount);
+ CheckField(PersistentName, "_onLoadingProgressUpdated", report, ref passCount, ref failCount);
+
+ // Main menu scene
+ CheckComponent(MainMenuName, "MainMenuController", report, ref passCount, ref failCount);
+ CheckField(MainMenuName, "_onSceneLoadRequest", report, ref passCount, ref failCount);
+ CheckField(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 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(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(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(string sceneName, string label,
+ List report, ref int pass, ref int fail) where T : Component
+ {
+ if (FindInLoadedScene(sceneName) != null) pass++;
+ else { fail++; report.Add($"[缺失] {sceneName} 中未找到 {label}。请先打开该场景并运行脚手架。"); }
+ }
+
+ private static void CheckField(string sceneName, string field,
+ List report, ref int pass, ref int fail,
+ bool requireNonEmpty = false) where T : Component
+ {
+ var comp = FindInLoadedScene(sceneName);
+ bool ok = requireNonEmpty
+ ? IsStringFieldNonEmpty(comp, field)
+ : IsFieldBound(comp, field);
+ if (ok) pass++;
+ else { fail++; report.Add($"[未绑定] {typeof(T).Name}.{field}(在 {sceneName})。"); }
+ }
+ }
+}
diff --git a/Assets/_Game/Scripts/Editor/Scene/SceneScaffoldTools.cs b/Assets/_Game/Scripts/Editor/Scene/SceneScaffoldTools.cs
index 147281a..1e6376a 100644
--- a/Assets/_Game/Scripts/Editor/Scene/SceneScaffoldTools.cs
+++ b/Assets/_Game/Scripts/Editor/Scene/SceneScaffoldTools.cs
@@ -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(respawnButtonGo);
Button respawnButton = GetOrAddComponent
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, "环境不触发拼刀"),
};
// ─────────────────────────────────────────────────────────────────────
diff --git a/Assets/_Game/Scripts/UI/MainMenu/MainMenuController.cs b/Assets/_Game/Scripts/UI/MainMenu/MainMenuController.cs
new file mode 100644
index 0000000..61c3138
--- /dev/null
+++ b/Assets/_Game/Scripts/UI/MainMenu/MainMenuController.cs
@@ -0,0 +1,212 @@
+using System.Collections;
+using UnityEngine;
+using UnityEngine.UI;
+using BaseGames.Core;
+using BaseGames.Core.Events;
+
+namespace BaseGames.UI.MainMenu
+{
+ ///
+ /// 主菜单 UI 控制器(挂载在 Scene_MainMenu 的根 Canvas 上)。
+ ///
+ /// 面板结构(按 Inspector 绑定):
+ /// ├── MainButtonsPanel — 主按钮组(新游戏 / 继续 / 设置 / 制作团队 / 退出)
+ /// ├── SaveSlotPanel — 存档槽选择(新游戏 & 继续共用)
+ /// ├── SettingsPanel — 设置面板
+ /// └── CreditsPanel — 制作团队面板
+ ///
+ /// 入场动画:主按钮组从下方滑入(代码驱动,无需 Animator)。
+ ///
+ /// 流程:
+ /// 玩家选择存档槽(SaveSlotController 发布 _onSlotConfirmed)
+ /// → 关闭存档槽面板 → 发布 SceneLoadRequest(目标游戏场景)
+ /// → GameManager 响应,进入 LoadingScene 状态,显示加载画面,最终切换到 Gameplay。
+ ///
+ 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();
+ 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;
+ }
+ }
+}
diff --git a/Assets/_Game/Scripts/UI/Menus/PauseMenuController.cs b/Assets/_Game/Scripts/UI/Menus/PauseMenuController.cs
index 48c4527..42f390d 100644
--- a/Assets/_Game/Scripts/UI/Menus/PauseMenuController.cs
+++ b/Assets/_Game/Scripts/UI/Menus/PauseMenuController.cs
@@ -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,
});
}
}
diff --git a/Assets/_Game/Scripts/UI/Splash/SplashScreenController.cs b/Assets/_Game/Scripts/UI/Splash/SplashScreenController.cs
new file mode 100644
index 0000000..42d019d
--- /dev/null
+++ b/Assets/_Game/Scripts/UI/Splash/SplashScreenController.cs
@@ -0,0 +1,138 @@
+using System.Collections;
+using UnityEngine;
+using BaseGames.Core.Events;
+
+namespace BaseGames.UI.Splash
+{
+ ///
+ /// 游戏启动 Logo 演出控制器。挂载在 Persistent 场景的 Canvas_Splash 根节点。
+ ///
+ /// 演出顺序:工作室 Logo 淡入 → 停留 → 淡出 → 游戏标题淡入 → 停留 → 黑幕整体淡出。
+ /// 任意按键 / 手柄键可跳过整段演出。
+ ///
+ /// 与 Core 程序集完全解耦:
+ /// 监听 EVT_SplashStartRequest(VoidEventChannelSO)→ 开始演出。
+ /// 完成后发布 EVT_SplashComplete(VoidEventChannelSO)→ 通知 BootSequencer 继续。
+ ///
+ 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;
+ }
+ }
+}
diff --git a/Docs/Guides/01_BootFlow_Setup_Guide.md b/Docs/Guides/01_BootFlow_Setup_Guide.md
new file mode 100644
index 0000000..9b7c726
--- /dev/null
+++ b/Docs/Guides/01_BootFlow_Setup_Guide.md
@@ -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*
diff --git a/Docs/Standards/LayerSpec.md b/Docs/Standards/LayerSpec.md
index abfcd3c..27609e5 100644
--- a/Docs/Standards/LayerSpec.md
+++ b/Docs/Standards/LayerSpec.md
@@ -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()` 填入
+> 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** 编辑器工具,位于菜单:
diff --git a/Docs/Guides/Animancer_Technical_Evaluation.md b/Docs/ThirdPart/Animancer_Technical_Evaluation.md
similarity index 100%
rename from Docs/Guides/Animancer_Technical_Evaluation.md
rename to Docs/ThirdPart/Animancer_Technical_Evaluation.md
diff --git a/Docs/Guides/BehaviorDesigner_Technical_Evaluation.md b/Docs/ThirdPart/BehaviorDesigner_Technical_Evaluation.md
similarity index 100%
rename from Docs/Guides/BehaviorDesigner_Technical_Evaluation.md
rename to Docs/ThirdPart/BehaviorDesigner_Technical_Evaluation.md
diff --git a/Docs/Guides/Feel_Technical_Evaluation.md b/Docs/ThirdPart/Feel_Technical_Evaluation.md
similarity index 100%
rename from Docs/Guides/Feel_Technical_Evaluation.md
rename to Docs/ThirdPart/Feel_Technical_Evaluation.md
diff --git a/Docs/Guides/PathBerserker2d_Technical_Evaluation.md b/Docs/ThirdPart/PathBerserker2d_Technical_Evaluation.md
similarity index 100%
rename from Docs/Guides/PathBerserker2d_Technical_Evaluation.md
rename to Docs/ThirdPart/PathBerserker2d_Technical_Evaluation.md
diff --git a/ProjectSettings/TagManager.asset b/ProjectSettings/TagManager.asset
index 644cde1..dc626db 100644
--- a/ProjectSettings/TagManager.asset
+++ b/ProjectSettings/TagManager.asset
@@ -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: