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: