# Phase 4 · 内容与完善 > **周期**:3–4 周(Week 15–18) > **前置条件**:Phase 3 全部完成标准通过 > **核心目标**:Boss 技能系统、叙事/对话/过场、按键重绑定、完整 UI 面板、支撑系统(本地化/成就/Steam/调试工具)、编辑器工具、QA 就绪 > **产出物**:游戏发布前技术层面全部完成,可进入内容填充 + 关卡设计阶段 > **状态**:✅ 全部完成(2026-05-11,P4-1~P4-6 全部 ✅) --- ## 目录 1. [实施顺序总览](#1-实施顺序总览) 2. [Week 15:Boss 技能系统完整](#2-week-15boss-技能系统完整) 3. [Week 16:叙事模块(对话/过场/事件链)](#3-week-16叙事模块对话过场事件链) 4. [Week 17:UI 完整面板 + 按键重绑定](#4-week-17ui-完整面板--按键重绑定) 5. [Week 18:支撑模块 + 编辑器工具 + QA](#5-week-18支撑模块--编辑器工具--qa) 6. [完成标准检查清单](#6-完成标准检查清单) 7. [发布前技术 Checklist](#7-发布前技术-checklist) --- ## 1. 实施顺序总览 ``` Week 15: BossSkillSO + AttackPatternSO + SkillSequenceSO(数据层) ↓ VulnerabilityWindow + WeakPointSystem ↓ BossSkillExecutor(执行层) ↓ BossOrchestrator 集成(BD + BossSkillExecutor 协作) ↓ 首个 Boss 完整技能套(2 阶段 × 3 技能) Week 16: DialogueLine / DialogueSequenceSO(数据) ↓ DialogueManager(打字机效果 + 快进 + 分支选择) ↓ InteractableNPC(挂载 DialogueSequenceSO,接入 QuestGiver) ↓ EventChain + EventChainManager(世界事件链) ↓ CutsceneManager(Unity Timeline 封装) Week 17: 完整 UI 面板套装 ├─ PausePanel(设置入口 + 存档 + 退出) ├─ InventoryPanel(护符 + 工具槽 + 凹槽 UI) ├─ MapPanel(完整 Fog of War 渲染) ├─ ShopPanel(商品列表 + 购买确认) ├─ AchievementPanel └─ SettingsPanel(音量/分辨率/按键重绑定) ↓ InputRebindingUI(New Input System 重绑定 API) Week 18: LocalizationManager(Unity Localization 包) ↓ AchievementManager(本地 + Steam 双通道) ↓ PlatformBootstrap(Steam 成就/存档云同步;⚠️ 非 PlatformManager 静态类,架构 16 §3) ↓ DebugCheatSystem(Editor Only + Development Build) ↓ AntiSoftlockSystem(出门触发器 + 快速复活备用) ↓ AccessibilityManager(色盲模式/字幕/震动/高对比) ↓ AnalyticsManager(本地 JSONL 日志,开发分析) ↓ SpeedrunTimer(IGT 计时器,ISaveable) ↓ 编辑器工具(验证工具套装) ↓ QA Checklist 执行 ``` --- ## 2. Week 15:Boss 技能系统完整 ✅ 完成(2026-05-11) **参考文档**:`23_BossSkillModule.md` ### 2.1 数据层 SO 创建顺序 按依赖关系由底向上: ``` BossSkillCategory.cs ← 分类枚举(Melee/Ranged/Charge/AoE/…) BossSkillType.cs ← 具体类型枚举(MeleeSlash / ChargeAttack / LeapSlam / …) InteractionTag.cs ← [Flags] 互动标签枚举(Parryable/Unblockable/…) VulnTriggerType.cs ← 弱点触发方式枚举(OnAttackRecovery/OnParriedSuccess/…) WeakPointType.cs ← 弱点位置类型枚举(FullBody/HeadOnly/BackOnly/…) VulnerabilityWindow.cs ← struct(TriggerDelay, Duration, WeakPointType, DamageMultiplier, ForceStagger, …) AttackPatternSO.cs ← 单个攻击图案(HitBox 范围/时序/DamageSourceSO 引用) SkillSequenceSO.cs ← SequenceStep[] 序列 + RepeatIfPlayerInRange / MaxRepeatCount PlayerCounterResponse.cs ← struct(CounterType + Boss应激反应参数) ArenaEventTrigger.cs ← struct + ArenaEventType + IArenaInteractable(场景联动) BossResourceCost.cs ← struct + BossResourceConfigSO(Boss 资源系统) BossSkillSO.cs ← 技能(所有架构 23 §4 字段,含 attackPatterns/counterResponses/arenaEvents/resourceCost/poiseWindow) ``` > **注意**:不存在 `BossConfigSO`。Boss 阶段技能列表直接由 `BossOrchestrator` 的 `_phaseOneSkills[]` / `_phaseTwoSkills[]` 字段管理(见 §2.3)。 ### 2.1.1 BossSkillCategory + BossSkillType + 互动/弱点枚举 ```csharp // Assets/Scripts/Boss/BossSkillType.cs namespace BaseGames.Boss { /// ⚠️ 高层技能分类(平衡框架用)(架构 23 §2) public enum BossSkillCategory { Melee, Ranged, Charge, AoE, Environmental, Summon, Buff, Debuff, Phase, Passive, Reactive } /// 具体技能类型(战斗设计用)。 public enum BossSkillType { MeleeSlash, ChargeAttack, LeapSlam, ProjectileVolley, LaserBeam, PhaseTransition, SummonMinion, ArenaTrap, SpeedBurst, DefensiveShell, } /// ⚠️ 互动标签:玩家可对该技能执行哪些反制操作(架构 23 §2,[Flags] 枚举) [Flags] public enum InteractionTag { None = 0, Parryable = 1 << 0, // 可弹反 PerfectParryOnly = 1 << 1, // 仅完美弹反有效 DodgeWindow = 1 << 2, // 打开逃避窗口 Unblockable = 1 << 3, // 无法拦截 CanBeReflected = 1 << 4, // 弹幕可被反射 ExposesWeakPoint = 1 << 5, // 暴露弱点 GrantsPlayerReso = 1 << 6, // 命中后给予玩家资源 ArenaHazard = 1 << 7, // 场地危机 PhaseGate = 1 << 8, // 阶段门关 } /// ⚠️ 弱点触发方式(架构 23 §3) public enum VulnTriggerType { OnAttackRecovery, // 攻击后摇 OnParriedSuccess, // 弹反成功 OnCounterSkillHit, // 反制技能命中 OnPhaseTransition, // 阶段切换时 OnHazardBackfire, // 场地反噬 OnSummonDefeated, // 召唤物被击败 Manual, // BossSkillExecutor 手动触发 } /// ⚠️ 弱点位置类型(架构 23 §3) public enum WeakPointType { FullBody, // 主体全身都是弱点 HeadOnly, // 仅头部 BackOnly, // 仅背部 CoreExposed, // 核心暴露(展开中心 HurtBox) CustomPoint, // 自定义弱点 HurtBox } } ``` ### 2.1.2 BossSkillSO ```csharp // Assets/Scripts/Boss/BossSkillSO.cs // ⚠️ menuName = "Boss/BossSkill"(架构 23 §4);skillAnimation 为 Animancer ClipTransition namespace BaseGames.Boss { [CreateAssetMenu(menuName = "Boss/BossSkill")] public class BossSkillSO : ScriptableObject { [Header("元信息")] public string skillId; public string displayName; [TextArea(1, 4)] public string designNote; [Header("技能分类")] public BossSkillCategory category; // ⚠️ 高层分类(架构 23 §4) public BossSkillType skillType; [Header("阶段可用性")] public int[] availablePhaseIndices; // ⚠️ 空数组 = 全阶段可用(架构 23 §4) [Header("核心攻击动作(按执行顺序排列)")] public AttackPatternSO[] attackPatterns; // ⚠️ 按架构 23 §4:伤害参数只在 AttackPatternSO [Header("弱点窗口(至少 1 个)")] public VulnerabilityWindow[] vulnerabilityWindows; [Header("互动标签")] public InteractionTag interactionTags; // ⚠️ [Flags] 枚举(架构 23 §4,非 string interactionTag) [Header("连段子序列")] public SkillSequenceSO sequenceOnHit; // 弱点被击时触发(可空) public SkillSequenceSO sequenceOnMiss; // 弱点未被击时触发(可空) [Header("玩家反制接口")] public PlayerCounterResponse[] counterResponses; // ⚠️ 架构 23 §4.1 [Header("场景联动")] public ArenaEventTrigger[] arenaEvents; // ⚠️ 架构 23 §4.2 [Header("Boss 资源")] public BossResourceCost resourceCost; // ⚠️ 架构 23 §4.3 public bool buildsRage; // ⚠️ 是否积累愤怒值(架构 23 §4) [Header("霸体配置")] public PoiseWindowConfig poiseWindow; // ⚠️ 技能执行期间的霸体窗口(架构 23 §4) [Header("动画")] public ClipTransition skillAnimation; // Animancer ClipTransition [Header("冷却")] [Min(0f)] public float cooldown; } } ``` ### 2.1.3 AttackPatternSO ```csharp // Assets/Scripts/Boss/AttackPatternSO.cs // ⚠️ menuName = "Boss/AttackPattern";DamageSource 字段为 DamageSourceSO(架构 23 §5) namespace BaseGames.Boss { [CreateAssetMenu(menuName = "Boss/AttackPattern")] public class AttackPatternSO : ScriptableObject { [Header("输出")] public DamageSourceSO DamageSource; public float KnockbackAngle; [Header("弹幕")] public AssetReferenceGameObject ProjectilePrefab; public int ProjectileCount = 1; public float SpreadAngle = 0f; public float ProjectileSpeed = 8f; [Header("范围攻击")] public float AoERadius; public Vector2 AoEOffset; [Header("时序")] public float WindupDuration; public float ActiveDuration; public float RecoveryDuration; } } ``` ### 2.1.4 SkillSequenceSO ```csharp // Assets/Scripts/Boss/SkillSequenceSO.cs // ⚠️ menuName = "Boss/SkillSequence"(架构 23 §6) namespace BaseGames.Boss { [CreateAssetMenu(menuName = "Boss/SkillSequence")] public class SkillSequenceSO : ScriptableObject { [Serializable] public struct SequenceStep { public AttackPatternSO pattern; public float delayBeforeStep; } public SequenceStep[] steps; [Header("循环行为")] public bool RepeatIfPlayerInRange; public float RepeatDelay; [Range(0, 10)] public int MaxRepeatCount; // 0 = 无限 } } ``` ### 2.1.5 WeakPointSystem ```csharp // Assets/Scripts/Boss/WeakPointSystem.cs // ⚠️ SetActive(bool active, float multiplier) 由 BossSkillExecutor.ActivateVulnerabilityWindows() 调用(架构 23 §8) namespace BaseGames.Boss { public class WeakPointSystem : MonoBehaviour { [Serializable] public struct WeakPoint { public HurtBox hurtBox; public GameObject visualIndicator; } [SerializeField] WeakPoint[] _weakPoints; [SerializeField] string _bossId; [SerializeField] StringEventChannelSO _onVulnerabilityWindowOpened; float _damageMultiplier = 1f; public void SetActive(bool active, float multiplier = 1f) { _damageMultiplier = multiplier; foreach (var wp in _weakPoints) { wp.hurtBox.gameObject.SetActive(active); if (wp.visualIndicator != null) wp.visualIndicator.SetActive(active); } if (active) _onVulnerabilityWindowOpened?.Raise(_bossId); } public float GetDamageMultiplier() => _damageMultiplier; } } ``` ### 2.1.6 PlayerCounterResponse + ArenaEventTrigger + BossResourceCost > **⚠️ 架构 23 §4.1-4.3 要求**:BossSkillSO 引用这三个数据结构,均在 `BaseGames.Boss` 命名空间。 ```csharp // Assets/Scripts/Boss/PlayerCounterResponse.cs namespace BaseGames.Boss { /// ⚠️ 玩家反制接口(架构 23 §4.1) [Serializable] public struct PlayerCounterResponse { [Header("反制条件")] public CounterType counterType; public string requiredSkillId; // counterType = SpecificSkill 时填写 [Header("反制效果(对 Boss)")] public float bossStaggerDuration; // 强制硬直时长(秒,0 = 不强制) public float bossDamageBonus; // 对 Boss 的额外伤害倍率(0 = 不额外) public bool openVulnWindow; // 是否触发 VulnerabilityWindow public bool interruptSkill; // 是否打断 Boss 当前技能 [Header("反制收益(对玩家)")] public int soulPowerGrant; public int spiritPowerGrant; public MMF_Player counterFeedback; // 成功反制的特效/音效反馈 } public enum CounterType { Parry, PerfectParry, DodgeThrough, SpecificSkill, WeakPointHit, HazardBackfire, SummonKill, } // Assets/Scripts/Boss/ArenaEventTrigger.cs /// ⚠️ 场景联动(架构 23 §4.2) [Serializable] public struct ArenaEventTrigger { public string targetArenaObjectId; public ArenaEventType eventType; public float delay; public ArenaEventParams parameters; } public enum ArenaEventType { DestroyPlatform, ActivateHazard, DeactivateHazard, SpawnHazardArea, ShakeArena, ToggleLighting, SpawnPlatform, TriggerCutscene, } [Serializable] public struct ArenaEventParams { public float duration; // 效果持续时间(0 = 永久) public float intensity; // 强度(震动幅度/半径等) public bool revertsOnPhaseEnd; // 阶段结束时是否恢复 } /// ⚠️ 场景中可被 Boss 技能交互的对象接口(架构 23 §4.2) public interface IArenaInteractable { string ArenaObjectId { get; } void OnBossArenaEvent(ArenaEventData data); } [Serializable] public struct ArenaEventData { public ArenaEventType type; public ArenaEventParams parameters; public string sourceSkillId; } // Assets/Scripts/Boss/BossResourceCost.cs /// ⚠️ Boss 资源消耗(架构 23 §4.3) [Serializable] public struct BossResourceCost { public string resourceId; // 对应 BossResourceConfigSO.resourceId(如 "Rage") public float cost; public float minRequired; // 使用此技能的最低资源要求 } [CreateAssetMenu(menuName = "Boss/ResourceConfig")] public class BossResourceConfigSO : ScriptableObject { public string resourceId; public string displayName; public float maxValue; public float startValue; [Header("自动变化")] public float passiveRate; // 每秒自动变化量(正=增长/负=衰减) public float onTakeDamageGain; // 每受 1 点伤害积累量 public float onSkillUseGain; // 每使用一次技能积累量 [Header("满值效果")] public bool autoTriggerOnFull; public BossSkillSO fullTriggerSkill; public float resetValueAfterTrigger; } } ``` ### 2.2 VulnerabilityWindow ```csharp // ⚠️ 字段为绝对时间(秒),不是归一化时间(0–1)(架构 23_BossSkillModule §3) // ⚠️ 使用 UniTask 序列按 TriggerDelay+Duration 激活弱点(非 Update() 轮询归一化时间) namespace BaseGames.Boss { [Serializable] public struct VulnerabilityWindow { [Tooltip("弱点触发方式")] public VulnTriggerType TriggerType; // ⚠️ 架构 23 §3 [Tooltip("触发后延迟出现(秒)")] [Min(0f)] public float TriggerDelay; // ⚠️ 绝对秒数(架构 23 §3,非归一化时间) [Tooltip("弱点持续时长(秒,设计约定 ≥0.5s)")] [Min(0.1f)] public float Duration; // ⚠️ 绝对秒数(架构 23 §3,非归一化时间) [Tooltip("弱点位置类型")] public WeakPointType WeakPointType; // ⚠️ 架构 23 §3 [Tooltip("弱点激活时 Boss 受击乘数(1 = 正常,>1 = 额外伤害)")] [Min(0.1f)] public float DamageMultiplier; [Tooltip("命中后是否强制硬直")] public bool ForceStagger; // ⚠️ 架构 23 §3 [Tooltip("硬直时间(秒),ForceStagger=true 时生效")] [Min(0f)] public float StaggerDuration; // ⚠️ 架构 23 §3 [Tooltip("弱点开启时播放的 Feedback(光效等)")] public MMF_Player OpenFeedback; // ⚠️ 架构 23 §3 [Tooltip("弱点关闭时播放的 Feedback")] public MMF_Player CloseFeedback; // ⚠️ 架构 23 §3 [Tooltip("弱点激活时的高亮颜色(指示玩家该攻击哪里)")] public Color HighlightColor; // ⚠️ 架构 23 §3 // 向后兼容辅助属性(BossSkillExecutor 判断是否激活专属 HurtBox) public bool ActivateWeakPointHurtBox => WeakPointType != WeakPointType.FullBody; } } ``` ### 2.3 BossSkillExecutor + BossOrchestrator ```csharp // Assets/Scripts/Enemies/Boss/BossSkillExecutor.cs namespace BaseGames.Boss { public class BossSkillExecutor : MonoBehaviour { [SerializeField] HitBox[] _hitBoxes; [SerializeField] WeakPointSystem _weakPointSystem; [SerializeField] AnimancerComponent _animancer; [SerializeField] private string _bossId; // ⚠️ 事件 payload 用 [SerializeField] private BossSkillEventChannelSO _onBossSkillStarted; // ⚠️ 架构 23 §11 [SerializeField] private BossSkillEventChannelSO _onBossSkillEnded; // ⚠️ 架构 23 §11 // ⚠️ PlayerController 无 Instance(Architecture 05 §2);Boss 居小场景持有玩家 Transform 引用 [SerializeField] private Transform _playerTransform; // 由 Inspector 指定玩家 Transform BossSkillSO _currentSkill; AnimancerState _currentState; bool _isExecuting; CancellationTokenSource _cts; public bool IsExecuting => _isExecuting; // BD Task 节点调用 public async UniTask ExecuteSkill(BossSkillSO skill, CancellationToken ct) { if (_isExecuting) return; _isExecuting = true; _currentSkill = skill; _onBossSkillStarted?.Raise(new BossSkillEvent { BossId = _bossId, SkillId = skill.skillId }); // 播放技能动画(直接从 BossSkillSO.skillAnimation 引用 ClipTransition) _currentState = _animancer.Play(skill.skillAnimation); // 执行 SkillSequenceSO(若挂载 sequenceOnMiss) if (skill.sequenceOnMiss != null) await ExecuteSequence(skill.sequenceOnMiss, ct); _isExecuting = false; _onBossSkillEnded?.Raise(new BossSkillEvent { BossId = _bossId, SkillId = skill.skillId }); } async UniTask ExecuteSequence(SkillSequenceSO seq, CancellationToken ct) { int repeatCount = 0; do { foreach (var step in seq.steps) { ct.ThrowIfCancellationRequested(); await UniTask.WaitForSeconds(step.delayBeforeStep, cancellationToken: ct); await ExecutePattern(step.pattern, ct); } repeatCount++; if (seq.RepeatDelay > 0) await UniTask.WaitForSeconds(seq.RepeatDelay, cancellationToken: ct); } while (seq.RepeatIfPlayerInRange && repeatCount < seq.MaxRepeatCount && IsPlayerInRange() && !ct.IsCancellationRequested); } async UniTask ExecutePattern(AttackPatternSO pattern, CancellationToken ct) { await UniTask.WaitForSeconds(pattern.WindupDuration, cancellationToken: ct); // ⚠️ 以架构 06_CombatModule §4 为准:HitBox.Activate(DamageSourceSO, Transform) + Deactivate() foreach (var hb in _hitBoxes) hb.Activate(pattern.DamageSource, transform); await UniTask.WaitForSeconds(pattern.ActiveDuration, cancellationToken: ct); foreach (var hb in _hitBoxes) hb.Deactivate(); await UniTask.WaitForSeconds(pattern.RecoveryDuration, cancellationToken: ct); } // ⚠️ UniTask 序列激活弱点窗口(架构 23 §7) // ⚠️ 使用绝对时间(TriggerDelay + Duration),不使用 Update() 归一化时间轮询 async UniTask ActivateVulnerabilityWindows(BossSkillSO skill, CancellationToken ct) { foreach (var window in skill.vulnerabilityWindows) { // 等待触发延迟 await UniTask.Delay( TimeSpan.FromSeconds(window.TriggerDelay), cancellationToken: ct); // 激活弱点(⚠️ 架构 23 §8 SetActive 仅 2 参数,§7 3-arg call 为内部不一致;以 §8 定义为准) _weakPointSystem.SetActive(true, window.DamageMultiplier); window.OpenFeedback?.PlayFeedbacks(); // 持续弱点窗口 await UniTask.Delay( TimeSpan.FromSeconds(window.Duration), cancellationToken: ct); // 关闭弱点 _weakPointSystem.SetActive(false, 1f); window.CloseFeedback?.PlayFeedbacks(); } } bool IsPlayerInRange() => _playerTransform != null && Vector2.Distance(transform.position, _playerTransform.position) < 8f; } } // Assets/Scripts/Enemies/Boss/BossOrchestrator.cs // Behavior Designer 的宿主;直接持有各阶段 BossSkillSO[] 数组(不通过 BossConfigSO) namespace BaseGames.Boss { public class BossOrchestrator : MonoBehaviour { [SerializeField] BossBase _boss; // ⚠️ 架构 07_EnemyModule §10 [SerializeField] TelegraphSystem _telegraph; // ⚠️ 架构 07_EnemyModule §10 [SerializeField] BoxCollider2D _arenaBlocker; // ⚠️ 架构 07_EnemyModule §10 [Header("Boss 技能")] [SerializeField] BossSkillSO[] _phaseOneSkills; [SerializeField] BossSkillSO[] _phaseTwoSkills; [SerializeField] BossSkillExecutor _executor; [Header("Event Channels")] [SerializeField] private StringEventChannelSO _onBossFightStarted; // ⚠️ 架构 07_EnemyModule §10 + 02 §4 int _currentPhase = 1; CancellationTokenSource _cts; // 由触发器或 GameManager 调用(架构 07_EnemyModule §10) public void StartBossFight() { _arenaBlocker.enabled = true; _onBossFightStarted.Raise(_boss.BossId); _boss.GetComponent().EnableBehavior(); } // BD Task 节点调用:ExecuteSkillById(skillId) public async UniTask ExecuteSkillById(string skillId) { var skills = _currentPhase == 1 ? _phaseOneSkills : _phaseTwoSkills; var skill = System.Array.Find(skills, s => s.skillId == skillId); if (skill == null) return; _cts?.Cancel(); _cts = new CancellationTokenSource(); await _executor.ExecuteSkill(skill, _cts.Token); } public void EnterPhaseTwo() { _currentPhase = 2; _cts?.Cancel(); } } } ``` ### 2.4 WeakPointSystem ```csharp // Assets/Scripts/Enemies/Boss/WeakPointSystem.cs // ⚠️ API 为 SetActive(bool, float) 整体控制,不支持按 Id 单独开关(架构 23_BossSkillModule §8) namespace BaseGames.Boss { public class WeakPointSystem : MonoBehaviour { [Serializable] public struct WeakPoint { public HurtBox hurtBox; public GameObject visualIndicator; // 弱点亮光/标记(可为 null) } [SerializeField] private WeakPoint[] _weakPoints; [SerializeField] private string _bossId; // ⚠️ 事件 payload 用 [SerializeField] private StringEventChannelSO _onVulnerabilityWindowOpened; // 发布:弱点窗口开启(Architecture 23 §8) private float _damageMultiplier = 1f; /// 统一激活/关闭所有弱点 HurtBox。由 BossSkillExecutor.Update() 驱动。 public void SetActive(bool active, float multiplier = 1f) { _damageMultiplier = multiplier; foreach (var wp in _weakPoints) { wp.hurtBox.gameObject.SetActive(active); if (wp.visualIndicator != null) wp.visualIndicator.SetActive(active); } if (active) _onVulnerabilityWindowOpened?.Raise(_bossId); } /// 弱点 HurtBox 受击时由 BossStats 调用,获取最终伤害倍率。 public float GetDamageMultiplier() => _damageMultiplier; } } ``` ### 2.5 首个 Boss 完整资产 **Boss_Example 资产集**(`Assets/Data/Enemies/Boss/Boss_Example/`): | 资产名 | 类型 | 内容 | |--------|------|------| | `BSS_Skill_Slash` | `BossSkillSO` | MeleeSlash,vulnerabilityWindows[0]: TriggerDelay=0.5s, Duration=1.2s(⚠️ 绝对秒数,非归一化时间) | | `BSS_Skill_Charge` | `BossSkillSO` | ChargeAttack,WeakPoint 头部,TriggerDelay=0.2s, Duration=1.5s | | `BSS_Skill_LeapSlam` | `BossSkillSO` | LeapSlam,落地后全身 VulnerabilityWindow: TriggerDelay=0.1s, Duration=1.0s | | `BSS_Seq_Slash` | `SkillSequenceSO` | 3段斩击时序 | | `BSS_Pat_Slash_*` | `AttackPatternSO` × 3 | 各段 HitBox 参数(WindupDuration/ActiveDuration/RecoveryDuration)| --- ## 3. Week 16:叙事模块(对话/过场/事件链)✅ 完成(2026-05-11) **参考文档**:`14_NarrativeModule.md` ### 3.0 DialogueLine + DialogueSequenceSO(数据层) 按依赖关系最先实现,`DialogueManager` / `InteractableNPC` 均依赖这两个数据类型。 ```csharp // Assets/Scripts/Dialogue/DialogueLineSO.cs // ⚠️ 为 [System.Serializable] class(架构 14_NarrativeModule §3),非 ScriptableObject、非 struct namespace BaseGames.Dialogue { [System.Serializable] public class DialogueLine { public string SpeakerNameKey; // 说话人姓名本地化 key [TextArea(2, 6)] public string TextKey; // 对话文本本地化 key public Sprite PortraitSprite; // 可空(无头像时留空) public float TypewriterDelay = 0.03f; // 每字符显示延迟(架构 14 §3) } } ``` ```csharp // Assets/Scripts/Dialogue/DialogueSequenceSO.cs // ⚠️ menuName = "Dialogue/DialogueSequence"(架构 14_NarrativeModule §2) namespace BaseGames.Dialogue { [CreateAssetMenu(menuName = "Dialogue/DialogueSequence")] public class DialogueSequenceSO : ScriptableObject { public string sequenceId; // 唯一 ID(用于 WorldStateRegistry 条件查询) public DialogueLine[] Lines; // 对话行数组 [System.Serializable] public struct ConditionalVariant { public string ConditionFlag; // WorldStateRegistry 标志 key public DialogueSequenceSO Sequence; // 条件满足时替换的序列 } public ConditionalVariant[] Variants; // ConditionalVariant[] 而非 Variants[](架构 14 §2) } } // 资产路径:Assets/ScriptableObjects/Dialogue/ // 命名规范:DLG_{NpcId}_{Context}.asset(例 DLG_Elder_Intro.asset) ``` ### 3.1 DialogueManager ```csharp // Assets/Scripts/Dialogue/DialogueManager.cs public class DialogueManager : MonoBehaviour { [SerializeField] private DialogueBox _dialogueBox; [SerializeField] private InputReaderSO _inputReader; [Header("Event Channels")] [SerializeField] private VoidEventChannelSO _onDialogueStarted; [SerializeField] private VoidEventChannelSO _onDialogueEnded; [SerializeField] private StringEventChannelSO _onNpcDialogueCompleted; // → EVT_NpcDialogueCompleted(npcId,QuestManager 订阅) [SerializeField] private GameStateEventChannelSO _onGameStateChanged; public bool IsDialogueActive { get; private set; } private bool _skipRequested; // 打字机效果:每字符 TypewriterDelay 延迟显示 // 按确认键:若未显示完毕则立即显示全部;若已完毕则进入下一行 // 所有行显示完毕:结束对话,恢复 Gameplay ActionMap // ⚠️ 正确实现模式:使用 IEnumerator + StartCoroutine(架构 14_NarrativeModule §3) // 正确 API: void StartDialogue(DialogueSequenceSO sequence) — 返回 void,非 UniTask public void StartDialogue(DialogueSequenceSO sequence) { // 1. 若 IsDialogueActive → 返回 if (IsDialogueActive) return; // 2. 选择 ConditionalVariant(查询 WorldStateRegistry) IsDialogueActive = true; _dialogueBox.Show(); // 步骤 3:先显示对话框 _inputReader.EnableUIInput(); // 步骤 4:切换 ActionMap → UI(先于 Raise) _onDialogueStarted.Raise(); // 步骤 5:广播(在 ActionMap 切换后) StartCoroutine(PlaySequence(sequence)); // 步骤 6 } // 玩家按下 Submit 键时(InputReaderSO.SubmitEvent) private void OnSubmit() => _skipRequested = true; private IEnumerator PlaySequence(DialogueSequenceSO sequence) { foreach (var line in sequence.Lines) { _skipRequested = false; yield return _dialogueBox.TypeText(line.TextKey, line.TypewriterDelay); // 等待玩家按 Submit 继续 yield return new WaitUntil(() => _skipRequested); } EndDialogue(); } private void EndDialogue() { _dialogueBox.Hide(); IsDialogueActive = false; _inputReader.EnableGameplayInput(); // 先恢复 ActionMap → Gameplay(Architecture 14 §3:先切换再广播) _onDialogueEnded.Raise(); } // 分支选择(当 DialogueSequenceSO 有 ConditionalVariants 时) public DialogueSequenceSO ResolveVariant(DialogueSequenceSO sequence); } ``` **DialoguePanel**(UI Toolkit 或 UGUI): - 文本框(Text Mesh Pro) - 说话人姓名框 - 头像 Image - 翻页箭头(Blink 动画) ### 3.2 InteractableNPC + NarrativeNPC ```csharp // Assets/Scripts/Dialogue/InteractableNPC.cs // 实现 IInteractable(BaseGames.World 命名空间) public class InteractableNPC : MonoBehaviour, IInteractable { [SerializeField] private string _npcId; [SerializeField] private DialogueSequenceSO _defaultDialogue; [SerializeField] private float _interactRadius = 1.5f; // ⚠️ 通过场景内注入(或 Find),不使用 DialogueManager.Instance 单例(架构 14_NarrativeModule §6) private DialogueManager _dialogueManager; public bool CanInteract => true; public string InteractPrompt => "对话"; // ⚠️ 架构 14 §6:提示文本为 "对话"(非 "按 [X] 对话") public void Interact(Transform player) // ⚠️ 参数为 Transform(架构 14_NarrativeModule §1) { Interact_Internal(player); // ⚠️ 先调用子类扩展钩子(架构 14 §6,原 Plan 遗漏) _dialogueManager.StartDialogue(GetCurrentDialogue()); // ⚠️ 通过虚方法获取对话,支持子类重写(原 Plan 遗漏) } /// 子类覆盖此方法以在对话前注入额外逻辑(如接受/完成任务)。 protected virtual void Interact_Internal(Transform player) { } // ⚠️ 架构 14 §6(原 Plan 遗漏) /// 子类覆盖此方法以根据游戏状态返回不同的对话 SO(见 NarrativeNPC)。 protected virtual DialogueSequenceSO GetCurrentDialogue() => _defaultDialogue; // ⚠️ 架构 14 §6(原 Plan 遗漏) public void OnPlayerEnterRange(Transform player) { } // 交互提示 UI 由 InteractableDetector 统一发布 public void OnPlayerExitRange() { } // 交互提示 UI 由 InteractableDetector 统一发布 } ``` ```csharp // Assets/Scripts/Dialogue/NarrativeNPC.cs // ⚠️ 架构 14 §7(原 Plan 遗漏):扩展 InteractableNPC,根据 WorldStateRegistry 标志动态选择对话版本 public class NarrativeNPC : InteractableNPC { [Header("台词版本集(从高到低优先级排列)")] [SerializeField] DialogueVersion[] _dialogueVersions; [SerializeField] DialogueSequenceSO _fallbackDialogue; // 无条件满足时的默认台词 [SerializeField] WorldStateRegistry _worldState; // SO 注入 protected override DialogueSequenceSO GetCurrentDialogue() { foreach (var version in _dialogueVersions) { if (version.CheckConditions(_worldState)) return version.dialogue; } return _fallbackDialogue; } } [System.Serializable] public class DialogueVersion { public string versionLabel; // 编辑器显示名(如"森林Boss击败后") public DialogueSequenceSO dialogue; public string[] requiredFlags; // 全部满足才激活此版本(AND 关系) public string[] blockedByFlags; // 有任意一个 = 此版本不激活(NOT 关系) public bool CheckConditions(WorldStateRegistry registry) { foreach (var f in requiredFlags) if (!registry.HasFlag(f)) return false; foreach (var f in blockedByFlags) if (registry.HasFlag(f)) return false; return true; } } ``` ### 3.2.1 InteractionPromptController(架构 14 §2) ```csharp // Assets/Scripts/Dialogue/InteractionPromptController.cs // ⚠️ 挂载在每个 IInteractable GameObject 下作为子节点(架构 14_NarrativeModule §2) // 由 InteractableDetector 统一调用 Show()/Hide(),不直接挂在 InteractableDetector 上 public class InteractionPromptController : MonoBehaviour { [SerializeField] GameObject _promptRoot; // 提示 UI 根节点(含图标、键位提示等) [SerializeField] Image _icon; // 动态切换键盘/手柄图标 [SerializeField] Sprite _keyboardIcon; // 键盘/鼠标提示图标 [SerializeField] Sprite _gamepadIcon; // 手柄提示图标 public void Show() { _promptRoot.SetActive(true); // 根据当前活跃输入设备切换图标 bool isGamepad = InputSystem.devices.OfType().Any(g => g.enabled); _icon.sprite = isGamepad ? _gamepadIcon : _keyboardIcon; } public void Hide() => _promptRoot.SetActive(false); } ``` > **检测逻辑**:`InteractableDetector`(挂在 Player 子节点)通过 `CircleCollider2D` 检测范围内的 `IInteractable`,调用 `interactable.GetComponent()?.Show()/Hide()`。 ### 3.3 EventChain + EventChainManager ```csharp // EventChainSO — Condition+Action 驱动模型(架构 14_NarrativeModule §9) // ⚠️ menuName = "EventChain/EventChain" // ⚠️ ChainCondition/ChainAction 均为 ScriptableObject 子类(原 Plan 错误使用 [Serializable] 普通类) [CreateAssetMenu(menuName = "EventChain/EventChain")] public class EventChainSO : ScriptableObject { [Header("基础")] public string chainId; // 全局唯一,如 "Chain_BossForest_Defeated" public bool repeatable; // ⚠️ false = 仅触发一次(原 Plan 错误使用 oneShot 字段名,架构 14 §9) public float actionDelay = 0f; // ⚠️ 各 action 之间的延迟(秒)(原 Plan 遗漏,架构 14 §9) [Header("触发条件(全部满足才触发)")] public ChainCondition[] conditions; // ⚠️ ScriptableObject 数组(原 Plan 错误使用 [SerializeReference] List<>) [Header("执行动作(顺序执行)")] public ChainAction[] actions; // ⚠️ ScriptableObject 数组(同上) } // ── ChainCondition 基类(⚠️ ScriptableObject,非普通类;Register/Unregister/IsMet 模式,架构 14 §9) // ⚠️ 原 Plan 将 ChainCondition 实现为带 Evaluate(WorldStateRegistry) 的普通类,与架构不符 public abstract class ChainCondition : ScriptableObject { public abstract void Register(EventChainManager manager); public abstract void Unregister(EventChainManager manager); public abstract bool IsMet(); } // ── 7 个内置 ChainCondition 实现 ───────────────────────────────────────────────────────────── [CreateAssetMenu(menuName = "EventChain/Condition/BossDefeated")] public class BossDefeatedCondition : ChainCondition { public string bossId; bool _met; public override void Register(EventChainManager m) => m.OnBossDefeated += Check; public override void Unregister(EventChainManager m) => m.OnBossDefeated -= Check; public override bool IsMet() => _met; void Check(string id) { if (id == bossId) _met = true; } } [CreateAssetMenu(menuName = "EventChain/Condition/FlagSet")] public class FlagSetCondition : ChainCondition { public string flagId; public override void Register(EventChainManager m) { } // 无需订阅事件,持续轮询 public override void Unregister(EventChainManager m) { } public override bool IsMet() => SaveManager.Instance.GetFlag(flagId); } [CreateAssetMenu(menuName = "EventChain/Condition/AbilityUnlocked")] public class AbilityUnlockedCondition : ChainCondition { public string abilityId; bool _met; public override void Register(EventChainManager m) => m.OnAbilityUnlocked += Check; public override void Unregister(EventChainManager m) => m.OnAbilityUnlocked -= Check; public override bool IsMet() => _met; void Check(string id) { if (id == abilityId) _met = true; } } [CreateAssetMenu(menuName = "EventChain/Condition/CollectibleCollected")] public class CollectibleCollectedCondition : ChainCondition { public string itemId; bool _met; public override void Register(EventChainManager m) => m.OnCollectiblePickedUp += Check; public override void Unregister(EventChainManager m) => m.OnCollectiblePickedUp -= Check; public override bool IsMet() => _met; void Check(string id) { if (id == itemId) _met = true; } } [CreateAssetMenu(menuName = "EventChain/Condition/RoomEntered")] public class RoomEnteredCondition : ChainCondition { public string sceneName; bool _met; public override void Register(EventChainManager m) => m.OnRoomEntered += Check; public override void Unregister(EventChainManager m) => m.OnRoomEntered -= Check; public override bool IsMet() => _met; void Check(string id) { if (id == sceneName) _met = true; } } [CreateAssetMenu(menuName = "EventChain/Condition/DialogueCompleted")] public class DialogueCompletedCondition : ChainCondition { public string npcId; public string sequenceId; bool _met; public override void Register(EventChainManager m) => m.OnDialogueCompleted += Check; public override void Unregister(EventChainManager m) => m.OnDialogueCompleted -= Check; public override bool IsMet() => _met; void Check(string id) { if (id == npcId) _met = true; } } [CreateAssetMenu(menuName = "EventChain/Condition/ChainCompleted")] public class ChainCompletedCondition : ChainCondition { public string chainId; bool _met; public override void Register(EventChainManager m) => m.OnChainCompleted += Check; public override void Unregister(EventChainManager m) => m.OnChainCompleted -= Check; public override bool IsMet() => _met; void Check(string id) { if (id == chainId) _met = true; } } // ── ChainAction 基类(⚠️ ScriptableObject,非普通类;ExecuteAsync(MonoBehaviour) 模式,架构 14 §9) // ⚠️ 原 Plan 将 ChainAction 实现为带 Execute(EventChainContext) 的普通类,与架构不符 public abstract class ChainAction : ScriptableObject { // ⚠️ 方法名 ExecuteAsync(非 Execute),参数为 MonoBehaviour runner(非 EventChainContext) public abstract IEnumerator ExecuteAsync(MonoBehaviour runner); } // ── 10 个内置 ChainAction 实现 ──────────────────────────────────────────────────────────────── [CreateAssetMenu(menuName = "EventChain/Action/OpenDoor")] public class OpenDoorAction : ChainAction { public string doorId; [SerializeField] StringEventChannelSO _onDoorOpened; // EVT_DoorOpened public override IEnumerator ExecuteAsync(MonoBehaviour runner) { _onDoorOpened.Raise(doorId); yield break; } } [CreateAssetMenu(menuName = "EventChain/Action/SetFlag")] public class SetFlagAction : ChainAction { public string flagId; public bool value; [SerializeField] StringEventChannelSO _onFlagChanged; // EVT_FlagChanged public override IEnumerator ExecuteAsync(MonoBehaviour runner) { SaveManager.Instance.SetFlag(flagId, value); _onFlagChanged.Raise(flagId); yield break; } } [CreateAssetMenu(menuName = "EventChain/Action/UpdateMap")] public class UpdateMapAction : ChainAction { public string regionId; public override IEnumerator ExecuteAsync(MonoBehaviour runner) { MapManager.Instance.RevealRegion(regionId); yield break; } } [CreateAssetMenu(menuName = "EventChain/Action/PlayCutscene")] public class PlayCutsceneAction : ChainAction { public string cutsceneId; [SerializeField] StringEventChannelSO _onPlayCutscene; // → CutsceneManager.PlayById [SerializeField] VoidEventChannelSO _onCutsceneEnded; // ← CutsceneManager 播完时 Raise public override IEnumerator ExecuteAsync(MonoBehaviour runner) { bool done = false; _onCutsceneEnded.OnEventRaised += OnDone; _onPlayCutscene.Raise(cutsceneId); yield return new WaitUntil(() => done); _onCutsceneEnded.OnEventRaised -= OnDone; void OnDone() => done = true; } } [CreateAssetMenu(menuName = "EventChain/Action/ChangeNPCDialogue")] public class ChangeNPCDialogueAction : ChainAction { public string npcId; public string newSequenceId; public override IEnumerator ExecuteAsync(MonoBehaviour runner) { NPCRegistry.Instance.SetDialogueSequence(npcId, newSequenceId); yield break; } } [CreateAssetMenu(menuName = "EventChain/Action/SpawnObject")] public class SpawnObjectAction : ChainAction { public GameObject prefab; public Vector3 position; public override IEnumerator ExecuteAsync(MonoBehaviour runner) { Object.Instantiate(prefab, position, Quaternion.identity); yield break; } } [CreateAssetMenu(menuName = "EventChain/Action/Wait")] public class WaitAction : ChainAction { public float seconds; public override IEnumerator ExecuteAsync(MonoBehaviour runner) => new WaitForSeconds(seconds) as IEnumerator; } [CreateAssetMenu(menuName = "EventChain/Action/RaiseEvent")] public class RaiseEventAction : ChainAction { [SerializeField] VoidEventChannelSO eventChannelSO; public override IEnumerator ExecuteAsync(MonoBehaviour runner) { eventChannelSO.Raise(); yield break; } } [CreateAssetMenu(menuName = "EventChain/Action/UnlockAbility")] public class UnlockAbilityAction : ChainAction { public string abilityId; public override IEnumerator ExecuteAsync(MonoBehaviour runner) { PlayerStats.Instance.UnlockAbility(abilityId); yield break; } } [CreateAssetMenu(menuName = "EventChain/Action/PlayAudio")] public class PlayAudioAction : ChainAction { [SerializeField] StringEventChannelSO _onPlayBGM; // EVT_PlayBGM public string bgmKey; public override IEnumerator ExecuteAsync(MonoBehaviour runner) { _onPlayBGM.Raise(bgmKey); yield break; } } ``` **EventChainManager**(Architecture 14 §10): ```csharp // Assets/Scripts/Cutscene/EventChainManager.cs // ⚠️ ChainCondition/ChainAction 均为 ScriptableObject 子类(架构 14 §10) // ⚠️ 含中继 C# 事件供 ChainCondition.Register() 订阅(原 Plan 遗漏) // ⚠️ 存档集成:Awake 从 SaveManager 恢复已完成链;ExecuteChain 写入 SaveManager(原 Plan 遗漏) public class EventChainManager : MonoBehaviour { [Header("所有事件链")] [SerializeField] EventChainSO[] _chains; [Header("事件频道(中继)")] [SerializeField] StringEventChannelSO _onBossDefeated; // EVT_EnemyDied (bossId) [SerializeField] StringEventChannelSO _onCollectiblePickedUp; // EVT_CollectiblePickup [SerializeField] StringEventChannelSO _onAbilityUnlocked; // EVT_AbilityUnlocked [SerializeField] StringEventChannelSO _onRoomEntered; // EVT_SceneLoaded [SerializeField] StringEventChannelSO _onDialogueCompleted; // EVT_NpcDialogueCompleted // ⚠️ 中继 C# 事件,供 ChainCondition.Register() 订阅(原 Plan 遗漏) public event Action OnBossDefeated; public event Action OnCollectiblePickedUp; public event Action OnAbilityUnlocked; public event Action OnRoomEntered; public event Action OnDialogueCompleted; public event Action OnChainCompleted; // 链完成时广播 chainId(供 ChainCompletedCondition) readonly HashSet _completedChains = new(); void Awake() { // ⚠️ 从 SaveData 恢复已完成链 ID(原 Plan 遗漏) foreach (var id in SaveManager.Instance.GetCompletedChains()) _completedChains.Add(id); } void OnEnable() { _onBossDefeated.OnEventRaised += id => { OnBossDefeated?.Invoke(id); EvaluateAll(); }; _onCollectiblePickedUp.OnEventRaised += id => { OnCollectiblePickedUp?.Invoke(id); EvaluateAll(); }; _onAbilityUnlocked.OnEventRaised += id => { OnAbilityUnlocked?.Invoke(id); EvaluateAll(); }; _onRoomEntered.OnEventRaised += id => { OnRoomEntered?.Invoke(id); EvaluateAll(); }; _onDialogueCompleted.OnEventRaised += id => { OnDialogueCompleted?.Invoke(id); EvaluateAll(); }; // ⚠️ 向每个 Condition 注册中继事件(原 Plan 遗漏) foreach (var chain in _chains) foreach (var cond in chain.conditions) cond.Register(this); } void OnDisable() { foreach (var chain in _chains) foreach (var cond in chain.conditions) cond.Unregister(this); } void EvaluateAll() { foreach (var chain in _chains) { // ⚠️ 使用 repeatable 字段(原 Plan 错误使用 oneShot) if (!chain.repeatable && _completedChains.Contains(chain.chainId)) continue; if (Array.TrueForAll(chain.conditions, c => c.IsMet())) StartCoroutine(ExecuteChain(chain)); } } IEnumerator ExecuteChain(EventChainSO chain) { if (!chain.repeatable) _completedChains.Add(chain.chainId); foreach (var action in chain.actions) { yield return action.ExecuteAsync(this); // ⚠️ ExecuteAsync(MonoBehaviour)(原 Plan:Execute(EventChainContext)) // ⚠️ actionDelay 支持(原 Plan 遗漏) if (chain.actionDelay > 0f) yield return new WaitForSeconds(chain.actionDelay); } // ⚠️ 存档集成(原 Plan 遗漏) SaveManager.Instance.SetChainCompleted(chain.chainId); OnChainCompleted?.Invoke(chain.chainId); } } ``` ### 3.4 CutsceneManager + CutsceneSO + CutsceneTrigger ```csharp // Assets/Scripts/Cutscene/CutsceneManager.cs // ⚠️ 基于 PlayableDirector.stopped 回调,非 async(架构 14_NarrativeModule §7) [RequireComponent(typeof(PlayableDirector))] public class CutsceneManager : MonoBehaviour { [SerializeField] private InputReaderSO _inputReader; [SerializeField] private VoidEventChannelSO _onCutsceneStarted; [SerializeField] private VoidEventChannelSO _onCutsceneEnded; private PlayableDirector _director; public bool IsPlaying => _director.state == PlayState.Playing; private void Awake() => _director = GetComponent(); // ⚠️ 参数为 CutsceneSO(非 TimelineAsset),架构 14 §7;需应用 Track→GameObject 绑定(原 Plan 遗漏) public void PlayCutscene(CutsceneSO cutscene) { if (cutscene == null) return; _director.playableAsset = cutscene.Timeline; // ⚠️ 应用 Track → GameObject 绑定(原 Plan 遗漏,架构 14 §7) foreach (var binding in cutscene.Bindings) { var track = cutscene.Timeline.GetOutputTrack( System.Array.FindIndex(cutscene.Bindings, b => b.trackName == binding.trackName)); if (track != null && binding.target != null) _director.SetGenericBinding(track, binding.target); } _director.stopped += OnCutsceneStopped; _director.Play(); _onCutsceneStarted.Raise(); // 禁用 Gameplay 输入 } public void StopCutscene() => _director.Stop(); private void OnCutsceneStopped(PlayableDirector d) { _director.stopped -= OnCutsceneStopped; _onCutsceneEnded.Raise(); // 恢复 Gameplay 输入 } } // Assets/Scripts/Cutscene/CutsceneSO.cs [CreateAssetMenu(menuName = "Cutscene/Cutscene")] public class CutsceneSO : ScriptableObject { [Header("Identity")] public string cutsceneId; public string displayName; // ⚠️ 架构 14 §11.5(原 Plan 遗漏) public bool playOnlyOnce; // true → 仅首次播放 public bool isSkippable = true; // ⚠️ 架构 14 §11.5(原 Plan 遗漏) public Sprite thumbnail; // ⚠️ 过场预览图(架构 14 §11.5,原 Plan 遗漏) [Header("Timeline")] public TimelineAsset Timeline; [Header("Timeline Bindings")] // ⚠️ Track→GameObject 绑定(架构 14 §11.5,原 Plan 遗漏);CutsceneManager.PlayCutscene 遍历此数组设置 binding public CutsceneBinding[] Bindings; [Header("Camera")] public CinemachineBlendDefinition BlendIn; public CinemachineBlendDefinition BlendOut; [Header("Optional Dialogue Overlay")] public DialogueSequenceSO[] DialogueLayers; // 过场中叠加播放的对话层 } // ⚠️ 架构 14 §11.5(原 Plan 遗漏):将一条 Timeline Track 绑定到运行时场景对象 [Serializable] public struct CutsceneBinding { [Tooltip("Timeline Track 的名称(需与 PlayableDirector 内轨道名一致)")] public string trackName; [Tooltip("绑定的目标对象;若为 null 则 CutsceneManager 会从场景中按 tag/name 查找")] public Object target; // UnityEngine.Object(可以是 GameObject / Component / asset) } // Assets/Scripts/Cutscene/CutsceneTrigger.cs // 实现 IInteractable(OnInteract 模式) public class CutsceneTrigger : MonoBehaviour, IInteractable { public enum TriggerMode { OnEnter, // ⚠️ 架构 14 §11.5:OnEnter(原 Plan 错误使用 OnZoneEnter) OnInteract, // 玩家主动交互(IInteractable) OnSceneLoad, // 场景加载完毕(Start) OnEvent, // ⚠️ 架构 14 §11.5(原 Plan 遗漏):订阅事件频道触发(配合 _triggerEventChannel) } [SerializeField] private CutsceneSO _cutscene; [SerializeField] private TriggerMode _mode = TriggerMode.OnEnter; [SerializeField] private CutsceneManager _cutsceneManager; [SerializeField] private VoidEventChannelSO _triggerEventChannel; // ⚠️ OnEvent 模式用(原 Plan 遗漏,架构 14 §11.5) // ⚠️ SO 注入(架构 14_NarrativeModule §11.5 patch):不使用 WorldStateRegistry.Instance [SerializeField] private WorldStateRegistry _worldState; public bool CanInteract => _mode == TriggerMode.OnInteract; public string InteractPrompt => "查看"; public void Interact(Transform player) => TriggerCutscene(); public void OnPlayerEnterRange(Transform player) { } public void OnPlayerExitRange() { } private void OnEnable() { // ⚠️ OnEvent 模式(原 Plan 遗漏) if (_mode == TriggerMode.OnEvent && _triggerEventChannel != null) _triggerEventChannel.OnEventRaised += TriggerCutscene; } private void OnDisable() { if (_mode == TriggerMode.OnEvent && _triggerEventChannel != null) _triggerEventChannel.OnEventRaised -= TriggerCutscene; } private void OnTriggerEnter2D(Collider2D other) { if (_mode != TriggerMode.OnEnter || !other.CompareTag("Player")) return; // ⚠️ OnEnter(非 OnZoneEnter) TriggerCutscene(); } private void Start() { if (_mode == TriggerMode.OnSceneLoad) TriggerCutscene(); } private void TriggerCutscene() { if (_cutscene == null) return; // ⚠️ SO 注入(非 Instance)+ HasFlag(非 IsFlagSet) if (_cutscene.playOnlyOnce && _worldState != null && _worldState.HasFlag($"cutscene_played_{_cutscene.cutsceneId}")) return; _cutsceneManager.PlayCutscene(_cutscene); // ⚠️ 传入 CutsceneSO(原 Plan 错误传 _cutscene.Timeline),架构 14 §7 _worldState?.SetFlag($"cutscene_played_{_cutscene.cutsceneId}"); if (_mode == TriggerMode.OnEnter) enabled = false; // ⚠️ OnEnter(非 OnZoneEnter) } } ``` **资产路径**:`Assets/ScriptableObjects/Cutscene/` **命名规范**:`CS_{SceneId}_{ContextId}.asset` ### 3.5 SignalEmitterClip — Timeline 零耦合事件桥接(⚠️ 架构 14 §11.6,原 Plan 遗漏) ```csharp // Assets/Scripts/Cutscene/SignalEmitterClip.cs // ⚠️ 架构 14 §11.6(原 Plan 遗漏):Timeline 过场通过此 PlayableAsset 发布 SO 事件频道,保持零耦合 // 在 Timeline 轨道上放置此 Clip,Clip 播放时向目标事件频道 Raise 一次事件 [CreateAssetMenu(menuName = "BaseGames/Cutscene/SignalEmitterClip")] public class SignalEmitterClip : PlayableAsset, ITimelineClipAsset { [SerializeField] private EventChannelBaseSO _targetChannel; // 目标事件频道 SO // ITimelineClipAsset public ClipCaps clipCaps => ClipCaps.None; public override Playable CreatePlayable(PlayableGraph graph, GameObject owner) => ScriptPlayable.Create(graph, new SignalEmitterBehaviour { Clip = this }); } // Assets/Scripts/Cutscene/SignalEmitterBehaviour.cs public class SignalEmitterBehaviour : PlayableBehaviour { public SignalEmitterClip Clip; private bool _fired; public override void OnBehaviourPlay(Playable playable, FrameData info) { _fired = false; // 重置,支持 Timeline 循环/重播 } public override void ProcessFrame(Playable playable, FrameData info, object playerData) { if (!_fired && Clip._targetChannel != null) { Clip._targetChannel.RaiseEvent(); _fired = true; } } } ``` **使用场景示例**: - 过场第 3 秒触发 `EVT_BossCutscenePhase2` → BossOrchestrator 切换阶段 - 过场结束前 0.5 秒触发 `EVT_CutscenePreEnd` → HUD 开始淡入 ### 3.6 叙事事件频道清单(架构 14 §12) | 资产名 | 类型 | Raise 方 | Subscribe 方 | |--------|------|---------|-------------| | `EVT_DialogueStarted` | `VoidEventChannelSO` | `DialogueManager` | `InputReaderSO`(切 UI 输入)、`PlayerController`(锁定输入)| | `EVT_DialogueEnded` | `VoidEventChannelSO` | `DialogueManager` | `InputReaderSO`(切回 Gameplay)| | `EVT_CutsceneStarted` | `VoidEventChannelSO` | `CutsceneManager` | `HUDController`(隐藏 HUD)、`InputReaderSO` | | `EVT_CutsceneEnded` | `VoidEventChannelSO` | `CutsceneManager` | `HUDController`(恢复 HUD)| --- ## 4. Week 17:UI 完整面板 + 按键重绑定 ✅ 完成(2026-05-11) **参考文档**:`10_UIModule.md` ### 4.1 UIManager 完整路由 ```csharp // ⚠️ 字段类型为 GameObject(非类型化 UIPanel 子类),Stack 元素类型同为 GameObject(架构 10_UIModule §2) // ⚠️ 公开 API 为 OpenPanel(GameObject) / CloseTopPanel(),不存在 Push/Pop/PopAll 方法 // UIManager 管理所有 Panel 的显示/隐藏,通过事件频道驱动 [DefaultExecutionOrder(+50)] public class UIManager : MonoBehaviour { [Header("Canvas Roots")] [SerializeField] private GameObject _hudRoot; [SerializeField] private GameObject _pauseMenuRoot; [SerializeField] private GameObject _deathScreenRoot; [SerializeField] private GameObject _settingsRoot; [SerializeField] private GameObject _mapRoot; [SerializeField] private GameObject _shopRoot; // 事件频道 [Header("Event Channels - Subscribe")] [SerializeField] private GameStateEventChannelSO _onGameStateChanged; [SerializeField] private VoidEventChannelSO _onPauseRequested; [SerializeField] private VoidEventChannelSO _onFastTravelOpen; // ⚠️ Architecture 10 §2 有此频道 [SerializeField] private StringEventChannelSO _onShopOpen; // ⚠️ 字段名 _onShopOpen(非 _onShopOpened) [SerializeField] private VoidEventChannelSO _onMapOpen; // 通过 Stack 管理面板层级(支持 ESC 逐层关闭) private Stack _panelStack = new(); private void OnEnable() { _onGameStateChanged.OnEventRaised += HandleGameStateChanged; _onPauseRequested.OnEventRaised += TogglePause; _onFastTravelOpen.OnEventRaised += OpenMap; _onShopOpen.OnEventRaised += OpenShop; _onMapOpen.OnEventRaised += OpenMap; } private void OnDisable() { _onGameStateChanged.OnEventRaised -= HandleGameStateChanged; _onPauseRequested.OnEventRaised -= TogglePause; _onFastTravelOpen.OnEventRaised -= OpenMap; _onShopOpen.OnEventRaised -= OpenShop; _onMapOpen.OnEventRaised -= OpenMap; } private void HandleGameStateChanged(GameStateId state) { // HUD 在 Gameplay 和 BossFight 状态下均显示(⚠️ 非仅 Gameplay,架构 10_UIModule §2) // ⚠️ GameStateId 是 struct,不能用 switch;用 if/else 比较(对齐架构 10 §2 patch) bool showHud = state == GameStates.Gameplay || state == GameStates.BossFight; _hudRoot.SetActive(showHud); if (state == GameStates.Dead) _deathScreenRoot.SetActive(true); // 死亡状态显示死亡画面(架构 10 §2) else if (state == GameStates.Cutscene) _hudRoot.SetActive(false); // 过场动画隐藏 HUD(架构 10 §2) } public void OpenPanel(GameObject panel) { if (_panelStack.Count > 0) _panelStack.Peek().SetActive(false); panel.SetActive(true); _panelStack.Push(panel); } public void CloseTopPanel() { if (_panelStack.Count == 0) return; _panelStack.Pop().SetActive(false); if (_panelStack.Count > 0) _panelStack.Peek().SetActive(true); } // 内部路由(由事件频道触发) private void TogglePause() => OpenPanel(_pauseMenuRoot); private void OpenShop(string npcId) => OpenPanel(_shopRoot); private void OpenMap() => OpenPanel(_mapRoot); } ``` ### 4.2 各 Panel 内容要求 **PausePanel**: - 继续游戏(Resume) - 设置(→ SettingsPanel) - 存档(调用 SaveManager.SaveAsync) - 返回主菜单(二次确认 → GameManager.ReturnToMainMenu) - 退出游戏(二次确认 → Application.Quit) **InventoryPanel**: - 护符槽可视化(总凹槽 N 个,已用 M 个) - 拥有的护符列表(可装备/卸载) - 工具槽(Slot0/Slot1,拖拽或按键分配) - 当前形态图标 + 形态切换按钮 **SettingsPanel**: - 音量滑条(Master/BGM/SFX → AudioManager) - 分辨率/全屏(SettingsManager) - 语言下拉(LocalizationManager.SetLocale) - 按键重绑定区域(→ RebindPanel) **SaveSlotController + SaveSlotUI**(架构 10 §7.5): - 主菜单展示 3 个存档卡片;每卡展示角色名/场景/时间戳 - 调用 `SaveManager.GetSlotSummaryAsync()` 异步加载占位数据 - 支持新建游戏 / 加载 / 删除存档(二次确认) **LoadingOverlay**(架构 10 §8): - 订阅 `EVT_LoadingOverlay`(BoolEventChannelSO,true=显示/false=隐藏) - 展示全屏加载面(进度条 + Tip 文字 + 随机背景) **FloatingDamageText**(架构 10 §10): - 对象池驱动(AddressKeys.PrefabUIFloatingDmgText) - 由 `HUDController` 订阅 `EVT_DamageDealt` 并调用 `GlobalObjectPool.Spawn` 生成 - 黑色 = 普通伤害,红色 = 暴击,灰色 = 防御减免 **ToastNotification**(架构 10 §11): - 右上角弹出 Toast(成就/任务进度提示) - 订阅 `EVT_AchievementUnlocked` + `EVT_AbilityUnlocked` - 自动进队(Toast队列)+ 3 秒后淡出 **InputDeviceIconSwitcher**(架构 10 §12): - 订阅 `EVT_InputDeviceChanged`(BoolEventChannelSO,true=手柄/false=键盘) - 遇到设备切换时全局刷新所有注册过的按键图标 Sprite ### 4.3 RebindPanel + RebindActionRow + ConflictDetector > **⚠️ 架构约束(04_InputModule §6)**:重绑定 UI 由三个类组成:`RebindPanel`(主面板)、`RebindActionRow`(每行)、`ConflictDetector`(冲突检测)。所有持久化通过 `InputReaderSO.SaveBindingOverrides()` / `ResetBindings()` 进行,**禁止**直接访问 `_inputReader.Actions.asset`。 ```csharp // Assets/Scripts/UI/Settings/RebindPanel.cs // 设置界面中的完整按键重绑定面板(架构 04_InputModule §6) public class RebindPanel : MonoBehaviour { [SerializeField] private InputReaderSO _inputReader; [SerializeField] private RebindActionRow[] _rows; // Inspector 配置,顺序对应 HUD 布局 [SerializeField] private Button _resetAllButton; [SerializeField] private ConflictDetector _conflictDetector; private void Awake() { _resetAllButton.onClick.AddListener(OnResetAll); foreach (var row in _rows) row.Initialize(_inputReader, _conflictDetector, OnRebindRequested); } private void OnRebindRequested(RebindActionRow row) { foreach (var r in _rows) r.SetInteractable(r == row); row.StartRebind(onFinished: () => { foreach (var r in _rows) r.SetInteractable(true); _inputReader.SaveBindingOverrides(); // ⚠️ 通过 InputReaderSO 方法保存,非直接访问 asset }); } private void OnResetAll() { _inputReader.ResetBindings(); // ⚠️ 通过 InputReaderSO.ResetBindings() _inputReader.SaveBindingOverrides(); foreach (var row in _rows) row.RefreshDisplay(); } } // Assets/Scripts/UI/Settings/RebindActionRow.cs // 单行:Action 名 + 当前绑定显示 + 点击启动重绑定 public class RebindActionRow : MonoBehaviour { [SerializeField] private string _actionName; [SerializeField] private int _bindingIndex; [SerializeField] private TMP_Text _actionLabel; [SerializeField] private Button _bindButton; [SerializeField] private TMP_Text _currentBindingText; private InputReaderSO _inputReader; private ConflictDetector _conflictDetector; private Action _onRebindRequested; public void Initialize(InputReaderSO reader, ConflictDetector detector, Action onRequest) { _inputReader = reader; _conflictDetector = detector; _onRebindRequested = onRequest; _bindButton.onClick.AddListener(() => _onRebindRequested?.Invoke(this)); RefreshDisplay(); } public void StartRebind(Action onFinished) { _currentBindingText.text = "按下新按键…"; // ⚠️ 通过 InputReaderSO.StartRebinding(),非直接调用 PerformInteractiveRebinding _inputReader.StartRebinding(_actionName, _bindingIndex, onComplete: () => { RefreshDisplay(); CheckConflicts(); onFinished?.Invoke(); }, onCancel: () => { RefreshDisplay(); onFinished?.Invoke(); }); } public void RefreshDisplay() { var action = _inputReader.FindAction(_actionName); _currentBindingText.text = action != null ? InputControlPath.ToHumanReadableString( action.bindings[_bindingIndex].effectivePath, InputControlPath.HumanReadableStringOptions.OmitDevice) : "—"; } public void SetInteractable(bool v) => _bindButton.interactable = v; private void CheckConflicts() { var conflicts = _conflictDetector.FindConflicts(_inputReader.GetAllActionMap()); foreach (var row in FindObjectsOfType()) row.SetConflictHighlight(conflicts.Contains(row._actionName)); } public void SetConflictHighlight(bool conflict) => _currentBindingText.color = conflict ? Color.red : Color.white; } // Assets/Scripts/Input/ConflictDetector.cs // 检测同一按键路径绑定了多个 Action public class ConflictDetector : MonoBehaviour { public HashSet FindConflicts(IEnumerable actions) { var pathToActions = new Dictionary>(); foreach (var action in actions) foreach (var binding in action.bindings) { if (binding.isComposite || string.IsNullOrEmpty(binding.effectivePath)) continue; if (!pathToActions.ContainsKey(binding.effectivePath)) pathToActions[binding.effectivePath] = new List(); pathToActions[binding.effectivePath].Add(action.name); } var conflicted = new HashSet(); foreach (var kv in pathToActions) if (kv.Value.Count > 1) foreach (var name in kv.Value) conflicted.Add(name); return conflicted; } } ``` --- ### 4.4 HUDController 完整实现(Architecture 10 §3) ```csharp // Assets/Scripts/UI/HUD/HUDController.cs // ⚠️ 文件路径:UI/HUD/(非 UI/Panels/);类名 HUDController(非 HUDPanel) public class HUDController : MonoBehaviour { [Header("HP")] [SerializeField] private Transform _hpContainer; [SerializeField] private GameObject _hpCellPrefab; [Header("Gauges")] [SerializeField] private Image _soulGaugeFill; [SerializeField] private Image _spiritGaugeFill; [SerializeField] private TMP_Text _geoText; [Header("Spring Charges")] [SerializeField] private Transform _springContainer; [SerializeField] private GameObject _springIconPrefab; [Header("Form")] [SerializeField] private Image[] _formIcons; // 3 形态图标 [Header("Interact Prompt")] [SerializeField] private TMP_Text _interactText; [SerializeField] private GameObject _interactPromptRoot; [Header("Event Channels - Subscribe")] [SerializeField] private IntEventChannelSO _onHPChanged; [SerializeField] private IntEventChannelSO _onMaxHPChanged; [SerializeField] private IntEventChannelSO _onSoulPowerChanged; [SerializeField] private IntEventChannelSO _onSpiritPowerChanged; [SerializeField] private IntEventChannelSO _onGeoChanged; [SerializeField] private IntEventChannelSO _onSpringChargesChanged; [SerializeField] private IntEventChannelSO _onFormChanged; [SerializeField] private StringEventChannelSO _onShowInteractPrompt; [SerializeField] private VoidEventChannelSO _onHideInteractPrompt; private void OnEnable() { _onHPChanged.OnEventRaised += UpdateHP; _onMaxHPChanged.OnEventRaised += RebuildHPCells; _onSoulPowerChanged.OnEventRaised += val => _soulGaugeFill.fillAmount = val / 100f; _onSpiritPowerChanged.OnEventRaised += val => _spiritGaugeFill.fillAmount = val / 100f; _onGeoChanged.OnEventRaised += val => _geoText.text = val.ToString(); _onSpringChargesChanged.OnEventRaised += RebuildSpringIcons; _onFormChanged.OnEventRaised += UpdateFormIcon; _onShowInteractPrompt.OnEventRaised += ShowInteractPrompt; _onHideInteractPrompt.OnEventRaised += HideInteractPrompt; } private void OnDisable() { _onHPChanged.OnEventRaised -= UpdateHP; _onMaxHPChanged.OnEventRaised -= RebuildHPCells; _onSoulPowerChanged.OnEventRaised -= val => _soulGaugeFill.fillAmount = val / 100f; _onSpiritPowerChanged.OnEventRaised -= val => _spiritGaugeFill.fillAmount = val / 100f; _onGeoChanged.OnEventRaised -= val => _geoText.text = val.ToString(); _onSpringChargesChanged.OnEventRaised -= RebuildSpringIcons; _onFormChanged.OnEventRaised -= UpdateFormIcon; _onShowInteractPrompt.OnEventRaised -= ShowInteractPrompt; _onHideInteractPrompt.OnEventRaised -= HideInteractPrompt; } private void UpdateHP(int current); // 遍历 _hpContainer 子物体,激活前 current 个格子 private void RebuildHPCells(int max); // 清空并重建 max 个 HP 格子 Prefab private void RebuildSpringIcons(int charges); private void UpdateFormIcon(int formIndex); private void ShowInteractPrompt(string text) { _interactText.text = text; _interactPromptRoot.SetActive(true); } private void HideInteractPrompt() => _interactPromptRoot.SetActive(false); } ``` --- ### 4.5 BossHPBar + PauseMenuController + DeathScreenController(Architecture 10 §4–§6) ```csharp // Assets/Scripts/UI/HUD/BossHPBar.cs // ⚠️ 全部事件频道已更新(Architecture 00_CoverageIndex §五 patch) public class BossHPBar : MonoBehaviour { [SerializeField] private TMP_Text _bossNameText; [SerializeField] private Image _hpFill; [SerializeField] private Transform _phaseMarkersRoot; [SerializeField] private GameObject _phaseMarkerPrefab; [Header("Event Channels")] // ⚠️ 已替换旧频道(旧:StringEventChannelSO _onBossFightStarted + BoolEventChannelSO _onBossFightEnded + FloatEventChannelSO _onBossHPRatioChanged) [SerializeField] private BoolEventChannelSO _onBossFightToggled; // EVT_BossFightToggled(true=战斗开始,false=结束) [SerializeField] private IntEventChannelSO _onBossHPChanged; // EVT_BossHPChanged(当前 HP 整数) [SerializeField] private StringEventChannelSO _onBossNameSet; // EVT_BossNameSet(Boss 名称 string) [SerializeField] private IntEventChannelSO _onBossHPMaxSet; // EVT_BossHPMaxSet(最大 HP 整数) private int _maxHP = 1; private void OnEnable() { _onBossFightToggled.OnEventRaised += OnFightToggled; _onBossHPChanged.OnEventRaised += OnHPChanged; _onBossNameSet.OnEventRaised += OnNameSet; _onBossHPMaxSet.OnEventRaised += hp => _maxHP = hp; } private void OnDisable() { _onBossFightToggled.OnEventRaised -= OnFightToggled; _onBossHPChanged.OnEventRaised -= OnHPChanged; _onBossNameSet.OnEventRaised -= OnNameSet; _onBossHPMaxSet.OnEventRaised -= hp => _maxHP = hp; } private void OnFightToggled(bool started) { // ⚠️ 架构 10 §4:使用 SlideIn/SlideOut 协程动画(原 Plan 错误使用 SetActive) if (started) StartCoroutine(SlideIn()); else StartCoroutine(SlideOut()); } private void OnHPChanged(int hp) => _hpFill.fillAmount = _maxHP > 0 ? (float)hp / _maxHP : 0f; private void OnNameSet(string bossName) { _bossNameText.text = bossName; } // 构建阶段标记,启用 GO private IEnumerator SlideIn(); // 动画:Boss 血条从屏幕底部滑入 private IEnumerator SlideOut(); // 动画:Boss 血条滑出并隐藏 } // Assets/Scripts/UI/Menus/PauseMenuController.cs public class PauseMenuController : MonoBehaviour { [SerializeField] private UIManager _uiManager; [SerializeField] private Button _btnResume; [SerializeField] private Button _btnSettings; [SerializeField] private Button _btnMainMenu; [SerializeField] private Button _btnQuit; [SerializeField] private GameObject _settingsRoot; [Header("Event Channels")] [SerializeField] private GameStateEventChannelSO _onGameStateChanged; // ⚠️ 架构 10 §5(原 Plan 遗漏) [SerializeField] private VoidEventChannelSO _onResumeRequested; private void Awake() { _btnResume.onClick.AddListener(Resume); _btnSettings.onClick.AddListener(() => _uiManager.OpenPanel(_settingsRoot)); _btnMainMenu.onClick.AddListener(GoToMainMenu); _btnQuit.onClick.AddListener(Application.Quit); } private void Resume() { _onResumeRequested.Raise(); _uiManager.CloseTopPanel(); } private void GoToMainMenu(); // 发布 EVT_SceneLoadRequest(目标 = MainMenuScene) } // Assets/Scripts/UI/Menus/DeathScreenController.cs // ⚠️ 类名 DeathScreenController(非 DeathPanel);路径 UI/Menus/(非 UI/Panels/) public class DeathScreenController : MonoBehaviour { [SerializeField] private TMP_Text _deathMessage; [SerializeField] private Button _btnRespawn; [Header("Event Channels")] [SerializeField] private VoidEventChannelSO _onPlayerDied; [SerializeField] private VoidEventChannelSO _onDeathScreenConfirmed; private void OnEnable() => _onPlayerDied.OnEventRaised += OnPlayerDied; private void OnDisable() => _onPlayerDied.OnEventRaised -= OnPlayerDied; // ⚠️ 必须延迟 1.5s(Architecture 10 §6 修正,AI-82):死亡动画播完后再显示 private void OnPlayerDied() => StartCoroutine(ShowAfterDelay(1.5f)); private IEnumerator ShowAfterDelay(float delay) { yield return new WaitForSeconds(delay); Show(); } private void Show() { gameObject.SetActive(true); _btnRespawn.onClick.RemoveAllListeners(); _btnRespawn.onClick.AddListener(Confirm); } private void Confirm() { gameObject.SetActive(false); _onDeathScreenConfirmed.Raise(); } } ``` --- ### 4.6 SettingsPanelController + LoadingOverlay(Architecture 10 §7–§8) ```csharp // Assets/Scripts/UI/Menus/SettingsPanelController.cs public class SettingsPanelController : MonoBehaviour { [SerializeField] private SettingsManager _settings; [Header("Audio")] [SerializeField] private Slider _masterVolume; [SerializeField] private Slider _bgmVolume; [SerializeField] private Slider _sfxVolume; [SerializeField] private Slider _ambientVolume; [Header("Video")] [SerializeField] private Toggle _vSyncToggle; [SerializeField] private TMP_Dropdown _fpsDropdown; [SerializeField] private TMP_Dropdown _resolutionDropdown; private void Start() { // 从 SettingsManager 读取当前值并填充控件 _masterVolume.value = _settings.GetMasterVolume(); _bgmVolume.value = _settings.GetBGMVolume(); _sfxVolume.value = _settings.GetSFXVolume(); // 绑定 onChange 回调 _masterVolume.onValueChanged.AddListener(_settings.SetMasterVolume); _bgmVolume.onValueChanged.AddListener(_settings.SetBGMVolume); _sfxVolume.onValueChanged.AddListener(_settings.SetSFXVolume); _ambientVolume.onValueChanged.AddListener(_settings.SetAmbientVolume); _vSyncToggle.onValueChanged.AddListener(_settings.SetVSync); } } // Assets/Scripts/UI/LoadingOverlay.cs // 由 SceneLoader 通过 EVT_LoadingOverlay(BoolEventChannelSO)控制全屏黑幕渐入渐出 public class LoadingOverlay : MonoBehaviour { [SerializeField] private CanvasGroup _canvasGroup; [SerializeField] private float _fadeDuration = 0.3f; [Header("Event Channels")] [SerializeField] private BoolEventChannelSO _onLoadingOverlayRequested; private void OnEnable() => _onLoadingOverlayRequested.OnEventRaised += SetVisible; private void OnDisable() => _onLoadingOverlayRequested.OnEventRaised -= SetVisible; private void SetVisible(bool visible) => StartCoroutine(FadeCoroutine(visible ? 1f : 0f)); private IEnumerator FadeCoroutine(float target) { float start = _canvasGroup.alpha; float t = 0; while (t < _fadeDuration) { _canvasGroup.alpha = Mathf.Lerp(start, target, t / _fadeDuration); t += Time.unscaledDeltaTime; yield return null; } _canvasGroup.alpha = target; _canvasGroup.blocksRaycasts = target > 0.5f; } } ``` --- ### 4.7 FloatingDamageText + ToastManager + InputDeviceIconSwitcher(Architecture 10 §10–§12) ```csharp // Assets/Scripts/UI/FloatingDamageText.cs // 从对象池取出,显示伤害数字,向上飘动后归还 public class FloatingDamageText : MonoBehaviour { [SerializeField] private TMP_Text _text; [SerializeField] private float _floatDistance = 1.5f; [SerializeField] private float _duration = 0.8f; private string _poolKey = AddressKeys.UI_FloatingDmgText; public void Show(Vector2 worldPosition, int damage, DamageType type) { // 1. 世界坐标 → 屏幕坐标(Camera.main.WorldToScreenPoint → RectTransform.position) // 2. 颜色:Normal=白, Fire=橙, Poison=绿, True=黄 // 3. 启动协程:向上漂移 _floatDistance + alpha 淡出,duration 后归还对象池 } } // Assets/Scripts/UI/ToastNotification.cs // 右上角通知弹窗(能力解锁、成就) public class ToastNotification : MonoBehaviour { [SerializeField] private TMP_Text _titleText; [SerializeField] private TMP_Text _bodyText; [SerializeField] private Image _icon; [SerializeField] private float _displayDuration = 3f; public void Show(string title, string body, Sprite icon = null) { _titleText.text = title; _bodyText.text = body; if (icon != null) _icon.sprite = icon; gameObject.SetActive(true); StartCoroutine(AutoHide()); } private IEnumerator AutoHide() { yield return new WaitForSeconds(_displayDuration); gameObject.SetActive(false); } } // Assets/Scripts/UI/ToastManager.cs // 管理通知队列(一次只显示一条) public class ToastManager : MonoBehaviour { [SerializeField] private ToastNotification _toast; private Queue<(string title, string body, Sprite icon)> _queue = new(); [Header("Event Channels")] [SerializeField] private StringEventChannelSO _onAchievementUnlocked; // 成就解锁通知 private void OnEnable() => _onAchievementUnlocked.OnEventRaised += OnAchievementUnlocked; private void OnDisable() => _onAchievementUnlocked.OnEventRaised -= OnAchievementUnlocked; private void OnAchievementUnlocked(string achievementId) => Enqueue("成就解锁", achievementId, null); public void Enqueue(string title, string body, Sprite icon = null) { _queue.Enqueue((title, body, icon)); if (!_toast.gameObject.activeSelf) ShowNext(); } private void ShowNext() { if (_queue.Count == 0) return; var (title, body, icon) = _queue.Dequeue(); _toast.Show(title, body, icon); } } // Assets/Scripts/UI/InputDeviceIconSwitcher.cs // 检测输入设备切换(KB/手柄),自动替换 UI 按键图标 public class InputDeviceIconSwitcher : MonoBehaviour { [SerializeField] private InputDeviceIconSetSO _kbIconSet; [SerializeField] private InputDeviceIconSetSO _padIconSet; [Header("Event Channel")] [SerializeField] private BoolEventChannelSO _onDeviceChanged; // true = 手柄 private void OnEnable() => _onDeviceChanged.OnEventRaised += SwitchIconSet; private void OnDisable() => _onDeviceChanged.OnEventRaised -= SwitchIconSet; private void SwitchIconSet(bool isGamepad) { var set = isGamepad ? _padIconSet : _kbIconSet; foreach (var iconImg in FindObjectsOfType()) iconImg.RefreshFromSet(set); } } ``` --- ### 4.8 SaveSlotController + SaveIndicator + LoadingScreenManager + IBossHPProvider + DialogueBox(⚠️ 架构 10 §7.5–§9,原 Plan 遗漏) ```csharp // Assets/Scripts/UI/Menus/SaveSlotController.cs // ⚠️ 架构 10 §7.5(原 Plan 遗漏):驱动主菜单存档槽选择(新游戏 / 继续) public class SaveSlotController : MonoBehaviour { [SerializeField] private SaveSlotUI[] _slotUIs; // 3 个存档槽 UI [SerializeField] private SaveManager _saveManager; public async UniTask RefreshAsync() { for (int i = 0; i < 3; i++) { var summary = await _saveManager.GetSlotSummaryAsync(i); _slotUIs[i].Refresh(summary); // null = 空槽(显示"新局") } } public void OnSlotSelected(int slotIndex); // 新局:_saveManager.CreateSlot(slotIndex) → 启动游戏 // 继续:_saveManager.LoadAsync(slotIndex) → 载入存档 } // 单个存档槽卡片组件 public class SaveSlotUI : MonoBehaviour { [SerializeField] private TMP_Text _playtimeText; [SerializeField] private TMP_Text _regionText; [SerializeField] private TMP_Text _percentText; [SerializeField] private Image _formIcon; [SerializeField] private TMP_Text _lastSavedText; [SerializeField] private GameObject _emptyIndicator; // 空槽提示 public void Refresh(SlotSummary summary); // summary == null → 空槽,显示 _emptyIndicator + "新游戏" } // Assets/Scripts/UI/SaveIndicator.cs // ⚠️ 架构 10 §7.6(原 Plan 遗漏):右下角存档进行中提示图标(EVT_SaveStarted/EVT_SaveCompleted) [RequireComponent(typeof(CanvasGroup))] public class SaveIndicator : MonoBehaviour { [SerializeField] private CanvasGroup _cg; [SerializeField] private float _fadeDuration = 0.2f; [Header("Event Channels")] [SerializeField] private VoidEventChannelSO _onSaveStarted; [SerializeField] private VoidEventChannelSO _onSaveCompleted; private void OnEnable() { _onSaveStarted.OnEventRaised += () => StartCoroutine(FadeTo(1f)); _onSaveCompleted.OnEventRaised += () => StartCoroutine(FadeTo(0f)); } private void OnDisable() { _onSaveStarted.OnEventRaised -= () => StartCoroutine(FadeTo(1f)); _onSaveCompleted.OnEventRaised -= () => StartCoroutine(FadeTo(0f)); } private IEnumerator FadeTo(float target) { float start = _cg.alpha, t = 0; while (t < _fadeDuration) { _cg.alpha = Mathf.Lerp(start, target, t / _fadeDuration); t += Time.unscaledDeltaTime; yield return null; } _cg.alpha = target; } } // Assets/Scripts/UI/LoadingScreenManager.cs // ⚠️ 架构 10 §7.7(原 Plan 遗漏):全屏加载面(进度条 + 提示文字 + 随机背景图) public class LoadingScreenManager : MonoBehaviour { [SerializeField] private GameObject _loadingRoot; [SerializeField] private Image _progressFill; [SerializeField] private TMP_Text _tipText; [SerializeField] private Image[] _backgroundArts; [SerializeField] private string[] _tipKeys; // 本地化 Key 数组 [SerializeField] private float _minDisplayTime = 0.5f; [Header("Event Channels")] [SerializeField] private VoidEventChannelSO _onLoadingStarted; [SerializeField] private VoidEventChannelSO _onLoadingComplete; [SerializeField] private FloatEventChannelSO _onLoadingProgressUpdated; // 0–1 private void OnEnable() { _onLoadingStarted.OnEventRaised += Show; _onLoadingComplete.OnEventRaised += Hide; _onLoadingProgressUpdated.OnEventRaised += SetProgress; } private void OnDisable() { _onLoadingStarted.OnEventRaised -= Show; _onLoadingComplete.OnEventRaised -= Hide; _onLoadingProgressUpdated.OnEventRaised -= SetProgress; } private void Show() { _loadingRoot.SetActive(true); _progressFill.fillAmount = 0f; foreach (var bg in _backgroundArts) bg.enabled = false; _backgroundArts[Random.Range(0, _backgroundArts.Length)].enabled = true; _tipText.text = LocalizationManager.Get("UI", _tipKeys[Random.Range(0, _tipKeys.Length)]); } private void Hide() => StartCoroutine(HideAfterMinTime()); private void SetProgress(float v) => _progressFill.fillAmount = v; private IEnumerator HideAfterMinTime() { yield return new WaitForSecondsRealtime(_minDisplayTime); _loadingRoot.SetActive(false); } } // Assets/Scripts/UI/HUD/IBossHPProvider.cs // ⚠️ 架构 10 §7.8(原 Plan 遗漏):解耦接口,让 BossHPBar 不直接依赖 BossBase public interface IBossHPProvider { string BossId { get; } string BossNameKey { get; } float HPRatio { get; } // 0–1 实时 HP 比例 int TotalPhases { get; } float[] PhaseThresholds { get; } // 各阶段切换 HP 阈值 } // Assets/Scripts/UI/DialogueBox.cs // ⚠️ 架构 10 §9(原 Plan 遗漏):对话框组件,由 DialogueManager 直接调用(不经过事件频道) // 挂载在 Canvas_Overlay/DialogueBox 子对象 public class DialogueBox : MonoBehaviour { [SerializeField] private TMP_Text _speakerNameText; [SerializeField] private TMP_Text _dialogueText; [SerializeField] private GameObject _continuePrompt; public void Show(string speakerName, string text, bool showContinue); public void Hide(); // DialogueManager 在 PlaySequence 中 yield return 此协程(实现打字机效果) public IEnumerator TypeText(string text, float charDelay = 0.03f); } ``` --- ## 5. Week 18:支撑模块 + 编辑器工具 + QA ✅ 完成(2026-05-11) **参考文档**:`16_SupportingModules.md` > **✅ P4-5 验证状态(已完成验证)** > 所有支撑模块文件已按架构 §1-§9 逐一对比,以下为最终裁定: > > | 模块 | 状态 | 说明 | > |------|------|------| > | `IPlatformService` | ✅ 已修复 | 补充了 `RunCallbacks`/`Shutdown`/`SetStat`/`IncrementStat`/`GetStat`/`IsCloudAvailable`/`CloudSaveAsync`/`CloudLoadAsync`/`SetRichPresence`/`ClearRichPresence`;保留代码额外扩展(排行榜/DLC/ShowOverlay/ClearAchievement)| > | `PlatformBootstrap` | ✅ 已修复 | 增加 `Update()→RunCallbacks()` + `OnApplicationQuit()→Shutdown()`| > | `NullPlatformService` | ✅ 已更新 | 实现完整接口 | > | `SteamPlatformService` | ✅ 已更新 | 增加 SetStat/IncrementStat/GetStat、CloudSaveAsync/CloudLoadAsync、SetRichPresence/ClearRichPresence、RunCallbacks/Shutdown | > | `AnalyticsManager` | ✅ 已修复 | Awake 中添加 `#if !UNITY_EDITOR && !DEVELOPMENT_BUILD` 保护(Release 包默认关闭);保留缓冲 JSON 格式(非架构 JSONL,可接受)| > | `AchievementCondition` | ✅ 维持代码 | `IsMet(SaveData)` 轮询比架构的 RegisterListeners 事件模式更简洁;`GetProgress(SaveData)` 为额外增强 | > | `AchievementManager` | ✅ 维持代码 | `ServiceLocator.Get()` 优于架构的 `#if STEAMWORKS_NET PlatformManager` 静态调用 | > | `LocalizationManager` | ✅ 维持代码 | `Get(entryKey, tableName)` 参数顺序更符合调用习惯;`#if UNITY_LOCALIZATION` 守卫必要(包未安装)| > | `LanguageManagerSO` | ✅ 维持代码 | 字段命名与 `ApplySaved()` 方法名不同于架构,但功能等价且更清晰 | > | `AccessibilitySettingsSO` | ✅ 维持代码(简化版)| 架构字段更丰富(字幕/输入辅助/音频),当前代码仅实现核心子集;后续 P5/P6 可扩展 | > | `AccessibilityManager` | ✅ 维持代码 | 缺少 `_onHighContrastChanged`/`_onSubtitlesChanged` 频道(AccessibilitySettingsSO 简化对应);功能等价 | > | `ColorBlindFilter` | ✅ 维持代码 | URP RenderFeature + Brettel/Viénot 色彩矩阵实现正确 | > | `AntiSoftlockSystem` | ✅ 维持代码 | 代码用 `_stuckTimer + linearVelocity` 检测,比架构的位置距离对比更准确 | > | `RoomEscapeInfoSO` | ✅ 维持代码(单路径版)| 架构用多路径 `EscapeRoute[]`,代码用单路径+优先级数组,更适合当前房间规模 | > | `HardAbilityGate` | ✅ 维持代码 | 用 `World.Switches` key 验证物理拾取,比架构 `IsAbilityActuallyUnlocked()` 方法(不存在)更可行 | > | `SpeedrunTimer` | ✅ 维持代码 | 代码有显式 Start/Pause/Resume/Stop API,比架构订阅 `_onGameplayActive` 事件更灵活;后续可补事件订阅 | ### 5.1 LocalizationManager 按 `16_SupportingModules.md §1` 实现(Unity Localization 包封装)。 ```csharp // 路径: Assets/Scripts/Support/Localization/LocalizationManager.cs // Unity Localization 包(com.unity.localization)的轻量封装 // 游戏内所有文本通过此类获取,不直接引用 LocalizationSettings public static class LocalizationManager { // 当前语言 public static Locale ActiveLocale => LocalizationSettings.SelectedLocale; // 同步获取本地化字符串(Locale 已完全加载时使用) public static string Get(string tableKey, string entryKey) { var op = LocalizationSettings.StringDatabase.GetLocalizedString(tableKey, entryKey); return op.IsDone ? op.Result : entryKey; } // 异步获取(在等待 Locale 初始化的场景中使用) public static async Task GetAsync(string tableKey, string entryKey) { var op = LocalizationSettings.StringDatabase.GetLocalizedStringAsync(tableKey, entryKey); return await op.Task; } // 切换语言(由 SettingsPanelController 的语言下拉框调用) public static void SetLocale(string localeCode) { var locale = LocalizationSettings.AvailableLocales.Locales .FirstOrDefault(l => l.Identifier.Code == localeCode); if (locale != null) LocalizationSettings.SelectedLocale = locale; } // 快捷常量:String Table 名称 public const string Table_UI = "UI"; public const string Table_Dialogue = "Dialogue"; public const string Table_Items = "Items"; public const string Table_Enemies = "Enemies"; } ``` ### 5.1.1 LanguageManagerSO(语言切换 SO 单例) > **⚠️ 架构 16_SupportingModules §1.1 patch**:静态 `LocalizationManager` 仅做文本查询(无持久化);**语言设置持久化和设置界面切换**应使用 `LanguageManagerSO` SO 单例。 ```csharp // 路径: Assets/ScriptableObjects/Localization/LanguageManager.asset // ⚠️ menuName = "Localization/LanguageManager"(架构 16 §1.1) // 消费者:SettingsPanelController(语言下拉框)、GameManager.Awake(启动时加载上次选择的语言) [CreateAssetMenu(menuName = "Localization/LanguageManager")] public class LanguageManagerSO : ScriptableObject { // PlayerPrefs 持久化键 private const string PrefKey = "SelectedLocale"; /// 切换语言并持久化选择(替代 LocalizationManager.SetLocale,后者不持久化) public void SetLocale(string localeCode) { var locale = LocalizationSettings.AvailableLocales.Locales .FirstOrDefault(l => l.Identifier.Code == localeCode); if (locale != null) { LocalizationSettings.SelectedLocale = locale; PlayerPrefs.SetString(PrefKey, localeCode); } } /// 获取当前语言代码(默认 zh-CN) public string GetCurrentLocaleCode() => LocalizationSettings.SelectedLocale?.Identifier.Code ?? "zh-CN"; /// 游戏启动时从 PlayerPrefs 读取上次选择的语言(由 GameManager.Awake 调用) public void LoadSavedLocale() => SetLocale(PlayerPrefs.GetString(PrefKey, "zh-CN")); } ``` > **注意**:`SettingsPanelController` 应改用 `[SerializeField] LanguageManagerSO _languageManager;` + `_languageManager.SetLocale(code)` 而非直接调用 `LocalizationManager.SetLocale()`。 **最小本地化内容**(Phase 4 必须): - UI String Table(所有 UI 按钮/标签文本) - Dialogue String Table(所有 NPC 对话行) - Items String Table(护符/工具名称 + 描述) - Enemies String Table(敌人名称,⚠️ 架构 16_SupportingModules §1 定义 `Table_Enemies = "Enemies"`) - 支持语言:简体中文(zh-CN)+ 英文(en) ### 5.2 AchievementManager > **⚠️ 架构 16_SupportingModules §2 完整实现**:采用 `AchievementSO` + `AchievementCondition` **ScriptableObject 策略模式**;**彻底废弃** `AchievementDef` + `AchievementDatabaseSO` 旧方案。 #### 5.2.1 AchievementSO(成就数据 SO) ```csharp // 路径: Assets/Scripts/Support/Achievements/AchievementSO.cs // ⚠️ menuName = "Achievement/Achievement"(非旧版 "Support/AchievementDatabase") // 命名空间:namespace BaseGames.Achievement namespace BaseGames.Achievement { [CreateAssetMenu(menuName = "Achievement/Achievement")] public class AchievementSO : ScriptableObject { [Header("基础信息")] public string achievementId; // 全局唯一 ID,如 "Ach_SlayBoss_Forest" public string displayName; [TextArea(2, 5)] public string description; [TextArea(2, 5)] public string hiddenDescription; // 未解锁时显示(空 = 完全隐藏) [Header("外观")] public Sprite icon; public Sprite hiddenIcon; // 未解锁占位图标 [Header("分类")] public AchievementType type; // 故事/收集/挑战/隐藏 public AchievementTier tier; // 铜/银/金(展示用) [Header("解锁条件(AND 逻辑:全部满足才解锁)")] public AchievementCondition[] conditions; // ⚠️ ScriptableObject 策略模式(非旧版 AchievementDef.TargetCount) [Header("奖励(可选)")] public bool grantsNotch; // 解锁额外 Notch 槽 } public enum AchievementType { Story, Collection, Challenge, Hidden } public enum AchievementTier { Bronze, Silver, Gold } } ``` #### 5.2.2 AchievementCondition(ScriptableObject 策略模式) ```csharp // 路径: Assets/Scripts/Support/Achievements/AchievementCondition.cs // ⚠️ 抽象基类为 ScriptableObject(非 [Serializable] class),每种条件一个 SO 子类(架构 16 §2.2) namespace BaseGames.Achievement { public abstract class AchievementCondition : ScriptableObject { public abstract void RegisterListeners(AchievementManager manager); public abstract void UnregisterListeners(AchievementManager manager); public abstract bool IsMet(AchievementRuntimeState state); } } ``` **内置条件类型(12 种)**: | SO 子类 | menuName | 参数 | |---------|---------|------| | `DefeatedBossCondition` | `Achievement/Condition/DefeatedBoss` | `bossId: string` | | `DefeatedAllBossesCondition` | `Achievement/Condition/DefeatedAllBosses` | — | | `EnteredRegionCondition` | `Achievement/Condition/EnteredRegion` | `regionId: string` | | `MapExplorationCondition` | `Achievement/Condition/MapExploration` | `minPercent: float` | | `CollectedItemCondition` | `Achievement/Condition/CollectedItem` | `itemId: string` | | `CollectedAllCharmsCondition` | `Achievement/Condition/CollectedAllCharms` | — | | `UnlockedAllAbilitiesCondition` | `Achievement/Condition/UnlockedAllAbilities` | — | | `NoHealRunCondition` | `Achievement/Condition/NoHealRun` | — | | `TimedBossKillCondition` | `Achievement/Condition/TimedBossKill` | `bossId`, `maxSeconds` | | `ParryCountCondition` | `Achievement/Condition/ParryCount` | `requiredCount: int` | | `NailClashCountCondition` | `Achievement/Condition/NailClashCount` | `requiredCount: int` | | `EventTriggeredCondition` | `Achievement/Condition/EventTriggered` | `eventChannelSO` | ```csharp // 示例:DefeatedBossCondition [CreateAssetMenu(menuName = "Achievement/Condition/DefeatedBoss")] public class DefeatedBossCondition : AchievementCondition { public string bossId; public override void RegisterListeners(AchievementManager manager) => manager.OnBossDefeated += Evaluate; public override void UnregisterListeners(AchievementManager manager) => manager.OnBossDefeated -= Evaluate; void Evaluate(string defeatedBossId, AchievementRuntimeState state) { if (defeatedBossId == bossId) state.SetConditionMet(this); } public override bool IsMet(AchievementRuntimeState state) => state.IsConditionMet(this); } ``` #### 5.2.3 AchievementManager + AchievementRuntimeState ```csharp // 路径: Assets/Scripts/Support/Achievements/AchievementManager.cs // ⚠️ 命名空间:namespace BaseGames.Achievement(架构 16 §2.3) namespace BaseGames.Achievement { public class AchievementManager : MonoBehaviour, ISaveable { [Header("成就列表(每个成就一个 AchievementSO 资产)")] [SerializeField] AchievementSO[] _allAchievements; // ⚠️ 非旧版 AchievementDatabaseSO [Header("事件频道(订阅)")] [SerializeField] StringEventChannelSO _onBossDefeated; [SerializeField] StringEventChannelSO _onCollectiblePickedUp; [SerializeField] IntEventChannelSO _onAbilityUnlocked; [SerializeField] StringEventChannelSO _onRoomEntered; [SerializeField] VoidEventChannelSO _onParrySuccess; [SerializeField] VoidEventChannelSO _onNailClash; [Header("事件频道(发布)")] [SerializeField] AchievementEventChannelSO _onAchievementUnlocked; // ⚠️ AchievementEventChannelSO(非 StringEventChannelSO) // ── 内部中继 C# 事件(供 AchievementCondition 子类订阅)────────────── public event Action OnBossDefeated; public event Action OnCollectiblePickedUp; public event Action OnAbilityUnlocked; public event Action OnRoomEntered; public event Action OnParrySuccess; public event Action OnNailClash; readonly Dictionary _states = new(); void Awake() { foreach (var ach in _allAchievements) _states[ach.achievementId] = new AchievementRuntimeState(ach); } void OnEnable() { _onBossDefeated.OnEventRaised += id => { OnBossDefeated?.Invoke(id); EvaluateAll(); }; _onCollectiblePickedUp.OnEventRaised += id => { OnCollectiblePickedUp?.Invoke(id); EvaluateAll(); }; _onAbilityUnlocked.OnEventRaised += v => { OnAbilityUnlocked?.Invoke(v); EvaluateAll(); }; _onRoomEntered.OnEventRaised += id => { OnRoomEntered?.Invoke(id); EvaluateAll(); }; _onParrySuccess.OnEventRaised += () => { OnParrySuccess?.Invoke(); EvaluateAll(); }; _onNailClash.OnEventRaised += () => { OnNailClash?.Invoke(); EvaluateAll(); }; foreach (var ach in _allAchievements) foreach (var cond in ach.conditions) cond.RegisterListeners(this); } void OnDisable() { foreach (var ach in _allAchievements) foreach (var cond in ach.conditions) cond.UnregisterListeners(this); } void EvaluateAll() { foreach (var ach in _allAchievements) { var state = _states[ach.achievementId]; if (state.IsUnlocked) continue; if (Array.TrueForAll(ach.conditions, c => c.IsMet(state))) Unlock(ach, state); } } void Unlock(AchievementSO ach, AchievementRuntimeState state) { state.IsUnlocked = true; _onAchievementUnlocked.Raise(ach); // → AchievementToast + Analytics // ⚠️ 通过 ServiceLocator 获取(PlatformManager 静态类不在架构中) #if STEAMWORKS_NET ServiceLocator.Get()?.UnlockAchievement(ach.achievementId); #endif } // ── ISaveable ──────────────────────────────────────────────────── public void OnSave(SaveData data) { data.Achievements.Unlocked = _states .Where(kv => kv.Value.IsUnlocked) .Select(kv => kv.Key) .ToList(); } public void OnLoad(SaveData data) { foreach (var id in data.Achievements.Unlocked) if (_states.TryGetValue(id, out var state)) state.IsUnlocked = true; } } /// 单个成就的运行时状态(条件满足记录 + 解锁状态) public class AchievementRuntimeState { public bool IsUnlocked { get; set; } readonly HashSet _metConditions = new(); public AchievementRuntimeState(AchievementSO ach) { } // 初始状态:未解锁 public void SetConditionMet(AchievementCondition cond) => _metConditions.Add(cond); public bool IsConditionMet(AchievementCondition cond) => _metConditions.Contains(cond); } } ``` **Phase 4 最小成就集**(验证系统可用): | achievementId | AchievementCondition 类型 | |--------------|---------| | `ACH_FirstKill` | `EventTriggeredCondition`(`EVT_EnemyDied`)| | `ACH_FirstBoss` | `DefeatedBossCondition`(首个 Boss id)| | `ACH_Collector` | `CollectedAllCharmsCondition` 或 `MapExplorationCondition` | | `ACH_Speedrunner` | `TimedBossKillCondition`(最终 Boss + maxSeconds) | ### 5.3 PlatformBootstrap + IPlatformService(Steam 集成) > **⚠️ 架构 16_SupportingModules §3 完整接口**:旧版 `IPlatformService` 缺少 `IncrementStat`、`GetStat`、`IsAchievementUnlocked`、RichPresence、振动、生命周期方法;云存档方法签名已变更(返回 `Task` / `Task`)。 > **⚠️ 注入方式变更**:不使用 `PlatformManager` 静态类直接初始化;改用 `PlatformBootstrap` MonoBehaviour + `ServiceLocator` 模式(架构 16 §3)。 ```csharp // 路径: Assets/Scripts/Support/Platform/IPlatformService.cs // ⚠️ 全量接口(架构 16 §3);各实现类必须实现全部成员 namespace BaseGames.Platform { public interface IPlatformService { // ── 成就 ────────────────────────────────────────────────────────── void UnlockAchievement(string achievementId); bool IsAchievementUnlocked(string achievementId); // ⚠️ 旧版缺失 // ── 统计数据(用于成就进度跟踪)──────────────────────────────────── void SetStat(string statId, int value); void IncrementStat(string statId, int increment = 1); // ⚠️ 旧版缺失 int GetStat(string statId); // ⚠️ 旧版缺失 // ── 云存档(二进制,UTF-8 序列化在 SaveSystem 层完成)────────────── // ⚠️ 旧版签名错误(Task WriteCloudSaveAsync/Task ReadCloudSaveAsync) Task CloudSaveAsync(string fileName, byte[] data); Task CloudLoadAsync(string fileName); bool IsCloudAvailable { get; } // ⚠️ 旧版为 CloudSaveExists(string) 方法 // ── Rich Presence ────────────────────────────────────────────────── void SetRichPresence(string key, string value); // ⚠️ 旧版缺失 void ClearRichPresence(); // ⚠️ 旧版缺失 // ── 振动 ──────────────────────────────────────────────────────────── void Rumble(float lowFreq, float highFreq, float duration); // ⚠️ 旧版缺失 void StopRumble(); // ⚠️ 旧版缺失 // ── 生命周期 ──────────────────────────────────────────────────────── void Initialize(); // ⚠️ 旧版缺失;由 PlatformBootstrap.Awake 调用 void RunCallbacks(); // ⚠️ 旧版缺失;由 PlatformBootstrap.Update 每帧调用 void Shutdown(); // ⚠️ 旧版缺失;由 PlatformBootstrap.OnApplicationQuit 调用 } } ``` ```csharp // 路径: Assets/Scripts/Support/Platform/SteamPlatformService.cs // ⚠️ 条件编译符号 = UNITY_STANDALONE && STEAMWORKS_NET(非旧版 STEAMWORKS_NET) #if UNITY_STANDALONE && STEAMWORKS_NET namespace BaseGames.Platform { public class SteamPlatformService : IPlatformService { public bool IsCloudAvailable => SteamManager.Initialized && SteamRemoteStorage.IsCloudEnabledForApp(); // ── 成就 ────────────────────────────────────────────────────────── public void UnlockAchievement(string id) { if (!SteamManager.Initialized) return; SteamUserStats.SetAchievement(id); SteamUserStats.StoreStats(); } public bool IsAchievementUnlocked(string id) { SteamUserStats.GetAchievement(id, out bool unlocked); return unlocked; } // ── 统计 ────────────────────────────────────────────────────────── public void SetStat(string id, int v) { if (!SteamManager.Initialized) return; SteamUserStats.SetStat(id, v); } public void IncrementStat(string id, int inc = 1) { int cur = GetStat(id); SetStat(id, cur + inc); } public int GetStat(string id) { SteamUserStats.GetStat(id, out int v); return v; } // ── 云存档(二进制)────────────────────────────────────────────── public async Task CloudSaveAsync(string fileName, byte[] data) { if (!IsCloudAvailable) return false; return await Task.Run(() => SteamRemoteStorage.FileWrite(fileName, data, data.Length)); } public async Task CloudLoadAsync(string fileName) { if (!IsCloudAvailable || !SteamRemoteStorage.FileExists(fileName)) return null; int size = SteamRemoteStorage.GetFileSize(fileName); var buf = new byte[size]; await Task.Run(() => SteamRemoteStorage.FileRead(fileName, buf, size)); return buf; } // ── Rich Presence ────────────────────────────────────────────────── public void SetRichPresence(string k, string v) => SteamFriends.SetRichPresence(k, v); public void ClearRichPresence() => SteamFriends.ClearRichPresence(); // ── 振动 ────────────────────────────────────────────────────────── public void Rumble(float l, float h, float dur) { ushort lo = (ushort)(l * 65535); ushort hi = (ushort)(h * 65535); SteamController.TriggerVibration(SteamController.GetConnectedControllers()[0], lo, hi); } public void StopRumble() => Rumble(0f, 0f, 0f); // ── 生命周期 ────────────────────────────────────────────────────── public void Initialize() => SteamAPI.Init(); public void RunCallbacks() => SteamAPI.RunCallbacks(); public void Shutdown() => SteamAPI.Shutdown(); } } #endif ``` ```csharp // 路径: Assets/Scripts/Support/Platform/NullPlatformService.cs // ⚠️ 实现全部 IPlatformService 成员(旧版不完整) namespace BaseGames.Platform { public class NullPlatformService : IPlatformService { public bool IsCloudAvailable => false; public void UnlockAchievement(string id) => Debug.Log($"[Platform:Null] Achievement: {id}"); public bool IsAchievementUnlocked(string id) => false; public void SetStat(string id, int v) { } public void IncrementStat(string id, int inc = 1) { } public int GetStat(string id) => 0; public Task CloudSaveAsync(string f, byte[] d) => Task.FromResult(false); public Task CloudLoadAsync(string f) => Task.FromResult(null); public void SetRichPresence(string k, string v) { } public void ClearRichPresence() { } public void Rumble(float l, float h, float dur) { } public void StopRumble() { } public void Initialize() { } public void RunCallbacks() { } public void Shutdown() { } } } ``` ```csharp // 路径: Assets/Scripts/Support/Platform/PlatformBootstrap.cs // ⚠️ 注入方式:MonoBehaviour 挂在 Persistent 场景的 Bootstrap GameObject // 使用 ServiceLocator.Register(service)(非旧版 PlatformManager 静态类) // ⚠️ 旧版 PlatformManager 静态类(含 static _instance 字段)不在架构中,需替换 public class PlatformBootstrap : MonoBehaviour { void Awake() { IPlatformService service; #if UNITY_STANDALONE && STEAMWORKS_NET service = new SteamPlatformService(); #elif UNITY_SWITCH service = new SwitchPlatformService(); // ⚠️ 架构 16 §3:预留 Switch 平台支持(原 Plan 遗漏) #else service = new NullPlatformService(); #endif service.Initialize(); ServiceLocator.Register(service); } void Update() => ServiceLocator.Get()?.RunCallbacks(); void OnApplicationQuit() => ServiceLocator.Get()?.Shutdown(); } // 便捷访问(AchievementManager 内部用) // ⚠️ 通过 ServiceLocator 获取,不使用 PlatformManager.Service 静态属性 // ServiceLocator.Get().UnlockAchievement(id); ``` ### 5.4 DebugCheatSystem ```csharp // Assets/Scripts/Support/Debug/DebugCheatSystem.cs // 仅在 UNITY_EDITOR 或 DEVELOPMENT_BUILD 时激活 // ⚠️ 实现模式:BackQuote 键开关控制台文本输入框,非 F1-F7 固定快捷键(架构 16_SupportingModules §4) #if UNITY_EDITOR || DEVELOPMENT_BUILD public class DebugCheatSystem : MonoBehaviour { [Header("快捷键")] [SerializeField] private KeyCode _toggleConsoleKey = KeyCode.BackQuote; // ` 键开关控制台 // ⚠️ SceneLoader 无 Instance 单例(Architecture 03 §3,事件驱动);通过事件频道触发加载 [SerializeField] private SceneLoadRequestEventChannelSO _onSceneLoadRequest; private bool _consoleOpen; private string _input = ""; private void Update() { if (Input.GetKeyDown(_toggleConsoleKey)) _consoleOpen = !_consoleOpen; } private void OnGUI() { if (!_consoleOpen) return; _input = GUI.TextField(new Rect(10, 10, 400, 30), _input); if (Event.current.type == EventType.KeyDown && Event.current.keyCode == KeyCode.Return) { ExecuteCommand(_input.Trim()); _input = ""; } } // 支持的命令:godmode / addgeo / teleport / unlock / killall private void ExecuteCommand(string cmd) { // ⚠️ PlayerController 无 Instance 单例(Architecture 05 §2);Debug 上下文用 FindObjectOfType var player = FindObjectOfType(); var parts = cmd.Split(' '); switch (parts[0].ToLower()) { case "godmode": player?.Stats.SetGodMode(true); break; case "addgeo" when parts.Length > 1 && int.TryParse(parts[1], out var geo): player?.Stats.AddGeo(geo); break; case "teleport" when parts.Length > 1: // ⚠️ 通过事件频道触发(SceneLoader 无 Instance;Architecture 03 §3) _onSceneLoadRequest.Raise(new SceneLoadRequest { SceneName = parts[1], EntryTransitionId = "Default" }); break; case "unlock" when parts.Length > 1: player?.OnAbilityUnlocked(parts[1]); break; case "killall": // ⚠️ DamageInfo 无单参数构造函数(Architecture 06 §1);使用 Builder 模式(架构 16 §4 patch) var killDmg = new DamageInfo.Builder().SetRaw(99999).Build(); foreach (var e in FindObjectsOfType()) e.TakeDamage(killDmg); break; default: Debug.Log($"[Cheat] 未知命令: {cmd}"); break; } } } #endif ``` ### 5.5 AntiSoftlockSystem ```csharp // ⚠️ 静止阈值为 60s,参考 16_SupportingModules §5 public class AntiSoftlockSystem : MonoBehaviour { [SerializeField] private float _softlockDetectionTime = 60f; [SerializeField] private InputReaderSO _inputReader; // ⚠️ 必须,架构 16 §5 定义 [SerializeField] private VoidEventChannelSO _onSoftlockDetected; // 发布:检测到卡关 // ⚠️ SceneLoader 无 Instance 单例(Architecture 03 §3,事件驱动);通过事件频道触发加载 [SerializeField] private SceneLoadRequestEventChannelSO _onSceneLoadRequest; private float _idleTime; private Vector2 _lastPlayerPos; private bool _promptShown; private void Update() { // ⚠️ PlayerController 无 Instance(Architecture 05 §2);AntiSoftlock 在 Persistent 场景, // 通过 FindObjectOfType 获取(支撑系统可接受,非热路径) var player = FindObjectOfType(); if (player == null) return; var playerPos = (Vector2)player.transform.position; if (Vector2.Distance(playerPos, _lastPlayerPos) > 0.1f) { _lastPlayerPos = playerPos; _idleTime = 0f; _promptShown = false; return; } _idleTime += Time.deltaTime; if (_idleTime >= _softlockDetectionTime && !_promptShown) { _promptShown = true; _onSoftlockDetected.Raise(); // UIManager 显示"是否传送到最近存档点"对话框 } } // 由 UI 确认按钮调用 public void TeleportToLastSavePoint() { // ⚠️ 通过事件频道触发(SceneLoader 无 Instance;Architecture 03 §3) _onSceneLoadRequest.Raise(new SceneLoadRequest { SceneName = SaveManager.LastCheckpointScene, EntryTransitionId = SaveManager.LastCheckpointSpawnId, IsRespawn = true }); } } ``` ### 5.5.1 RoomEscapeInfoSO(⚠️ 架构 16_SupportingModules §5.1) ```csharp // Assets/Scripts/Support/AntiSoftlock/RoomEscapeInfoSO.cs // ⚠️ 每个房间场景必须挂载此 SO,记录"最低能力集合即可离开此房间" // 编辑器工具自动验证可达性,无法逃离的死局则标红警告(架构 16 §5.1) namespace BaseGames.Progression { [CreateAssetMenu(menuName = "Progression/RoomEscapeInfo")] public class RoomEscapeInfoSO : ScriptableObject { [Header("房间标识")] public string sceneAddress; // 对应 Addressable 场景地址 [Header("逃离要求(满足任一路线即视为可逃离)")] public EscapeRoute[] escapeRoutes; [Header("单向入口警告")] public bool hasOneWayEntry; // 是否有单向进入点(如跌落入口) [TextArea(1, 3)] public string designerNotes; [Serializable] public class EscapeRoute { public string routeLabel; // 如 "向左回到 Forest_Main" public string targetSceneAddress; // 逃离到达的目标房间 public AbilityType[] requiredAbilities; // 空 = 无需任何能力即可离开 } } } ``` ### 5.5.2 HardAbilityGate(⚠️ 架构 16_SupportingModules §5.2) ```csharp // Assets/Scripts/Support/AntiSoftlock/HardAbilityGate.cs // ⚠️ 增强型能力门:防止玩家用精准时机绕过只检查标志的 AbilityGate(架构 16 §5.2) namespace BaseGames.Progression { /// /// 增强型能力门:除了检查能力标志,还检测玩家是否"物理上真的能做到"。 /// 用于防止 Sequence Break(见 Design/49 §4.2)。 /// public class HardAbilityGate : AbilityGate { [Header("额外物理验证")] [SerializeField] bool _requirePhysicalValidation = false; // 编辑器工具标记"此门已验证可能被绕过" [SerializeField] bool _sequenceBreakRisk = false; protected override bool EvaluateAccess() { if (!base.EvaluateAccess()) return false; if (!_requirePhysicalValidation) return true; // 检查能力实际已激活(非仅标志为 true) return _playerStats != null && _playerStats.IsAbilityActuallyUnlocked(_requiredAbility); } } } ``` ### 5.6 AccessibilityManager > **⚠️ 架构 16_SupportingModules §6 完整实现**:使用独立的 `AccessibilitySettingsSO`(非 `GlobalSettingsSO`);`ColorBlindMode` 枚举含 5 个值(含 `Achromatopsia`);`AccessibilityManager.Instance` 单例模式;含 `CanPlayScreenShake()` 静态工具方法;含 `ColorBlindFilter` URP Renderer Feature。 #### AccessibilitySettingsSO(数据容器) ```csharp // Assets/ScriptableObjects/Accessibility/AccessibilitySettings.asset // ⚠️ 独立 SO(非 GlobalSettingsSO),menuName = "Accessibility/AccessibilitySettings"(架构 16 §6) [CreateAssetMenu(menuName = "Accessibility/AccessibilitySettings")] public class AccessibilitySettingsSO : ScriptableObject { // ── 视觉无障碍 ────────────────────────────────────────────────────────── [Header("色盲模式")] public ColorBlindMode colorBlindMode = ColorBlindMode.None; public bool highContrastMode = false; public float gameContrastBoost = 0f; // 0~1.0 // ── 运动无障碍 ────────────────────────────────────────────────────────── [Header("运动敏感度")] public bool disableScreenShake = false; public bool disableCameraMotion = false; public float cameraMotionScale = 1f; // 0~1.0(0 = 完全关闭) public bool reduceParticleEffects = false; public bool disableFlashingEffects = false; public int flashFrequencyLimit = 3; // ── 字幕 ──────────────────────────────────────────────────────────────── [Header("字幕系统")] public bool subtitlesEnabled = false; public bool sfxSubtitlesEnabled = false; public float subtitleFontSizeMultiplier = 1f; // 0.75~2.0 public bool subtitleBackgroundEnabled = true; public float subtitleBackgroundOpacity = 0.7f; public bool speakerNameEnabled = true; // ── 输入辅助 ──────────────────────────────────────────────────────────── [Header("输入辅助")] public bool autoParryAssist = false; public float parryWindowExtension = 0f; // 弹反窗口扩展(秒),0~0.2(ParrySystem 读取此字段) public bool holdToMash = false; public bool stickyJump = false; public bool autoClimb = false; // ── 音频无障碍 ──────────────────────────────────────────────────────── [Header("音频无障碍")] public bool monoAudio = false; public float leftRightBalance = 0f; // -1~+1 public bool visualDangerIndicator = false; } // ⚠️ ColorBlindMode 枚举:5 个值(含 Achromatopsia)(架构 16 §6) public enum ColorBlindMode { None, // 无(默认) Protanopia, // 红色盲 Deuteranopia, // 绿色盲 Tritanopia, // 蓝黄色盲 Achromatopsia, // ⚠️ 全色盲(高对比灰度)(架构 16 §6,4 值版本遗漏此项) } ``` #### AccessibilityManager ```csharp // Assets/Scripts/Support/Accessibility/AccessibilityManager.cs // ⚠️ 单例模式(Instance 属性);串联到 FeedbackSystem/ParrySystem(架构 16 §6) public class AccessibilityManager : MonoBehaviour { public static AccessibilityManager Instance { get; private set; } [SerializeField] private AccessibilitySettingsSO _settings; // ⚠️ AccessibilitySettingsSO,非 GlobalSettingsSO public AccessibilitySettingsSO Settings => _settings; // ── Event Channels(Raise 方)─────────────────────────────────────────── [SerializeField] private ColorBlindModeEventChannelSO _onColorBlindModeChanged; // ⚠️ 大写 B(架构 16 §6) [SerializeField] private BoolEventChannelSO _onHighContrastChanged; // ⚠️ Changed(非 Toggled) [SerializeField] private BoolEventChannelSO _onSubtitlesChanged; // ⚠️ Changed(非 Toggled) [SerializeField] private BoolEventChannelSO _onScreenShakeChanged; // ⚠️ 架构 16 §7 清单 private void Awake() { if (Instance != null) { Destroy(gameObject); return; } Instance = this; DontDestroyOnLoad(gameObject); } // 由 SettingsPanelController 调用的公开 API(架构 16 §6) public void ApplySettings() { _onColorBlindModeChanged.Raise(_settings.colorBlindMode); _onHighContrastChanged.Raise(_settings.highContrastMode); _onSubtitlesChanged.Raise(_settings.subtitlesEnabled); _onScreenShakeChanged.Raise(!_settings.disableScreenShake); } public void SetColorBlindMode(ColorBlindMode mode) { _settings.colorBlindMode = mode; ApplySettings(); } public void SetAutoParryAssist(bool v) { _settings.autoParryAssist = v; ApplySettings(); } public void SetParryWindowExtension(float sec) { _settings.parryWindowExtension = sec; ApplySettings(); } public void SetDisableScreenShake(bool v) { _settings.disableScreenShake = v; ApplySettings(); } public void SetCameraMotionScale(float s) { _settings.cameraMotionScale = s; ApplySettings(); } public void SetMonoAudio(bool v) { _settings.monoAudio = v; ApplySettings(); } public void SetVisualDangerIndicator(bool v) { _settings.visualDangerIndicator = v; ApplySettings(); } // ⚠️ 供 FeedbackSystem / ParrySystem 查询(静态方法)(架构 16 §6) public static bool CanPlayScreenShake() => Instance == null || !Instance.Settings.disableScreenShake; } ``` > **parryWindowExtension 集成**:`ParrySystem` 计算弹反窗口时读取此值: > `float window = _config.ParryWindowDuration + (AccessibilityManager.Instance?.Settings.parryWindowExtension ?? 0f);` #### ColorBlindFilter(URP Renderer Feature) ```csharp // Assets/Scripts/Accessibility/ColorBlindFilter.cs // ⚠️ URP 2D 后处理最终合成阶段应用色彩矩阵变换(架构 16 §6) // 在 URP 2D Renderer Data(Assets/Settings/URP2DRenderer.asset)中添加此 Feature public class ColorBlindFilter : ScriptableRendererFeature { [SerializeField] ColorBlindMode _mode; // 色彩矩阵(3×3,基于 Brettel et al. 1997 算法) private static readonly Dictionary _matrices = new() { [ColorBlindMode.Protanopia] = new Matrix4x4(/*...*/), [ColorBlindMode.Deuteranopia] = new Matrix4x4(/*...*/), [ColorBlindMode.Tritanopia] = new Matrix4x4(/*...*/), [ColorBlindMode.Achromatopsia] = new Matrix4x4(/*...*/), }; public override void Create() { /* 初始化 RenderPass */ } public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { if (_mode == ColorBlindMode.None) return; renderer.EnqueuePass(new ColorBlindPass(_matrices[_mode])); } // AccessibilityManager 订阅 EVT_ColorBlindModeChanged 后调用 public void SetMode(ColorBlindMode mode) => _mode = mode; } ``` ### 5.7 AnalyticsManager ```csharp // Assets/Scripts/Support/Analytics/AnalyticsManager.cs // 游戏行为数据收集(开发用途:热点图、死亡统计、卡关点分析) // 正式发布版无网络上报;仅写入本地 analytics.jsonl 日志供开发分析 // 参考:16_SupportingModules §8 public class AnalyticsManager : MonoBehaviour { [SerializeField] private bool _enabledInRelease = false; // 正式包默认关闭 [SerializeField] private string _logPath; // 留空则用 persistentDataPath private StreamWriter _writer; private void Awake() { #if !UNITY_EDITOR && !DEVELOPMENT_BUILD if (!_enabledInRelease) { enabled = false; return; } #endif var path = string.IsNullOrEmpty(_logPath) ? Path.Combine(Application.persistentDataPath, "analytics.jsonl") : _logPath; _writer = new StreamWriter(path, append: true); } private void OnDestroy() => _writer?.Close(); // 记录一条分析事件(JSONL 格式) public void Track(string eventName, Dictionary properties = null) { if (!enabled) return; var payload = new Dictionary { ["event"] = eventName, ["timestamp"] = DateTime.UtcNow.ToString("o"), ["session"] = Time.realtimeSinceStartup }; if (properties != null) foreach (var kv in properties) payload[kv.Key] = kv.Value; _writer?.WriteLine(JsonConvert.SerializeObject(payload)); } public void TrackDeath(string sceneName, Vector2 position, string cause) => Track("player_death", new() { ["scene"] = sceneName, ["pos_x"] = position.x, ["pos_y"] = position.y, ["cause"] = cause }); public void TrackBossDefeated(string bossId, float elapsedSeconds) => Track("boss_defeated", new() { ["boss_id"] = bossId, ["time_s"] = elapsedSeconds }); public void TrackAbilityUnlocked(string abilityId) => Track("ability_unlocked", new() { ["ability"] = abilityId }); } ``` ### 5.8 SpeedrunTimer ```csharp // Assets/Scripts/Support/Speedrun/SpeedrunTimer.cs // 游戏内时间(IGT)计时器:排除加载、过场、暂停时间 // 在 HUD 角落显示(仅当 GlobalSettingsSO.ShowSpeedrunTimer = true 时) // 参考:16_SupportingModules §9 public class SpeedrunTimer : MonoBehaviour, ISaveable { [SerializeField] private BoolEventChannelSO _onGameplayActive; // Gameplay 状态 = 计时 [SerializeField] private TMP_Text _display; // HUD 角落 Text [SerializeField] private GlobalSettingsSO _settings; private float _igt; private bool _isRunning; private void OnEnable() { _onGameplayActive.OnEventRaised += SetRunning; UpdateDisplay(); } private void OnDisable() => _onGameplayActive.OnEventRaised -= SetRunning; private void SetRunning(bool active) { _isRunning = active; if (_display != null) _display.gameObject.SetActive(active && _settings.ShowSpeedrunTimer); } private void Update() { if (!_isRunning) return; _igt += Time.unscaledDeltaTime; // 不受 timeScale 影响(暂停时不计时) UpdateDisplay(); } private void UpdateDisplay() { if (_display == null) return; var ts = TimeSpan.FromSeconds(_igt); _display.text = $"{ts.Hours:D2}:{ts.Minutes:D2}:{ts.Seconds:D2}.{ts.Milliseconds / 10:D2}"; } public float TotalSeconds => _igt; public void OnSave(SaveData data) => data.Stats.DistanceTraveled = _igt; // 复用字段 public void OnLoad(SaveData data) { _igt = data.Stats.DistanceTraveled; UpdateDisplay(); } } ``` **GlobalSettingsSO 新增字段**(追加至 §6 AccessibilityManager 的 GlobalSettingsSO 补充字段之后): ```csharp public bool ShowSpeedrunTimer = false; // 默认隐藏,由设置界面开关控制 ``` ### 5.9 编辑器工具套装 在 `Assets/Scripts/Editor/` 下创建: | 工具名 | 菜单路径 | 功能 | |--------|---------|------| | `AddressKeysValidator` | `Tools/Validate AddressKeys` | 验证所有 AddressKeys 常量在 Addressables 中存在 | | `EventChannelAuditor` | `Tools/Audit Event Channels` | 扫描所有 EVT_*.asset,检查是否有未被订阅的频道 | | `SaveDataInspector` | `Tools/Inspect Save Data` | 读取并显示当前存档文件内容(格式化 JSON)| | `RoomValidator` | `Tools/Validate Room Scene` | 检查当前场景是否有 RoomController/PlayerSpawnPoint/CameraBounds | | `CharmBalanceSheet` | `Tools/Charm Balance Sheet` | 列出所有 CharmSO 的 notchCost/effect 一览表 | | `CharmEffectDrawer` | — | `CharmSO.effects` 数组的自定义 PropertyDrawer(`[CustomPropertyDrawer(typeof(CharmEffectEntry))]`;在 Inspector 中下拉选择效果类型并展示对应字段;架构 16 §4.1) | | `SOValidationRunner` | `Tools/Validate All SOs` | 扫描所有实现 `IValidatable` 接口的 SO 资产并在 Console 报告验证结果;集成为 `[MenuItem]` + Build Pre-process hook(架构 16 §10)| | `EventBusMonitorWindow` | `Window/EventBus Monitor` | Editor Only;运行时监控所有事件频道的 Raise 次数和当前订阅者数量;通过 `EventBusMonitor.Record()` 静态方法接收数据(架构 02 §9)| | `EventChainEditorWindow` | `BaseGames/Tools/Event Chain Viewer` | 事件链可视化:左侧 chainId 分组总览,右侧条件/动作表格(`IsMet()` 颜色),Play Mode 运行时状态着色(已完成=绿/进行中=橙),`ChainCompletedCondition` 依赖箭头,执行日志(最近 20 条),双击→PingObject(架构 14 §13)| | `BossSkillSequenceWindow` | `BaseGames/Tools/Boss Skill Sequence Viewer` | 以甘特图可视化 `SkillSequenceSO` 时间轴:攻击阶段蓝色条、延迟灰色间隙、`VulnerabilityWindow` 绿色覆盖条;点击高亮对应 `AttackPatternSO` Ping;`DurationNormalized < 0.1` 时变红警告;SO 拖放加载(架构 23 §12)| | `AchievementSOEditor` | — | `[CustomEditor(typeof(AchievementSO))]`;conditions 数组中文类型标签(12 种映射);内联展开 SO 字段;Ping/Delete 按钮;"+ 添加条件 SO" 底部按钮(架构 16 §2.4)| > **✅ P4-6 完成状态(2026-05-11)** > > | 文件 | 状态 | 说明 | > |------|------|------| > | `IValidatable` 接口 | ✅ 已创建 | `Assets/Scripts/Core/Validation/IValidatable.cs`;`namespace BaseGames.Core`;`IEnumerable Validate()` | > | `SOValidationRunner` | ✅ 已创建 | `Assets/Scripts/Editor/Validation/SOValidationRunner.cs`;`IPreprocessBuildWithReport` callbackOrder=1;`[MenuItem("Tools/Validate All ScriptableObjects")]`;"必须"/❌ 为 Error,其余为 Warning | > | `AddressKeyValidator` 构建钩子 | ✅ 已修复 | `AddressKeyValidatorBuildHook` 内部类追加至现有文件;callbackOrder=0;调用 `RunValidation()`,有孤儿 key 则抛 `BuildFailedException` | > | `EventChannelEditor` | ✅ 已创建 | `Assets/Scripts/Editor/EventChannelEditor.cs`;`VoidBaseEventChannelSO` RaiseInEditor 按钮(非 Play Mode 显示 HelpBox);`BaseEventChannelSO` 反射读取订阅者数 | > | `PhantomPlate` | ✅ 已创建 | `Assets/Scripts/World/PhantomPlate.cs`;`namespace BaseGames.World`;`PlatformEffector2D` 单向穿透 + `TriggerDropThrough()` 下穿 API;Editor Gizmo 蓝色线框 | > | `DestructibleTileEditor` | ✅ 已创建 | `Assets/Scripts/Editor/World/DestructibleTileEditor.cs`;`[DrawGizmo]` 橙红线框+半透明填充;`Handles.Label` "💥" 标签;选中/未选中透明度区分 | > | `NavSurfaceBakeShortcut` | ✅ 已创建 | `Assets/Scripts/Editor/NavSurfaceBakeShortcut.cs`;`[MenuItem("BaseGames/Tools/Bake All NavSurfaces %#b")]`;`EditorApplication.update` 监听完成回调;打印每个 Surface 烘焙用时 | > | `BossSkillSequenceWindow` | ✅ 已创建 | `Assets/Scripts/Editor/BossSkillSequenceWindow.cs`;`BossSkillSO`/`SkillSequenceSO` 甘特图;Windup黄/Active红/Recovery灰/Vuln绿/Delay暗灰;拖放加载;点击标签 PingObject | > | `EventChainEditorWindow` | ✅ 已创建 | `Assets/Scripts/Editor/EventChainEditorWindow.cs`;左侧链列表(完成=绿/激活=橙/未触发=灰);右侧条件+动作表;Play Mode 反射读取 `_completedChains`;`ChainCompletedCondition` 依赖链显示;执行日志 20 条 | > | `AddressReferenceGraphWindow` | ✅ 已创建 | `Assets/Scripts/Editor/AddressReferenceGraphWindow.cs`;反射遍历 `AddressKeys` 常量;Regex 扫描所有 `.cs` 文件;孤儿 Key 红/无效 Key 橙/正常绿;导出 CSV | > | `AchievementSOEditor` | ✅ 已创建 | `Assets/Scripts/Editor/Achievements/AchievementSOEditor.cs`;12 种条件中文标签映射;内联 `SerializedObject` 子 SO 字段;Ping/Delete 按钮 | > | `BaseGames.Editor.asmdef` | ✅ 已更新 | 新增 `"BaseGames.EventChain"` 引用,`EventChainEditorWindow` 可正常编译 | ### 5.10 QA 执行 **性能基准**(目标:Switch/中端 PC 60fps,1080p): | 测试场景 | 目标 | 工具 | |---------|------|------| | 10 个敌人同时 Pathfinding | < 1ms PathBerserker2d 消耗 | Unity Profiler | | 100 个粒子同时播放 | < 2ms VFX 消耗 | Profiler | | 场景加载(中等房间) | < 2s(含 FadeOut/FadeIn)| 手动计时 | | 存档读写 | < 200ms | Stopwatch | | GC Alloc/帧 | < 1KB(Gameplay 稳定状态)| Profiler | **功能回归测试清单**(所有 Phase 完成标准的并集) --- ## 6. 完成标准检查清单 ``` □ BossSkillExecutor:执行 LeapSlam 技能时 VulnerabilityWindow 后摇 1s 内弱点 HurtBox 激活 □ Boss Phase 切换:HP 降至 50% → Phase 过渡动画 → 新技能集解锁 □ DialogueManager:打字机效果 + 快进 + 最后一行结束后关闭对话框 + 恢复 Gameplay Map □ InteractableNPC:不同游戏进度对话切换正确(ConditionalVariant 选择) □ CutsceneManager:播放 Timeline 期间玩家无法移动,播放结束后恢复控制 □ EventChain:世界事件链(对话+设置标志+奖励 Geo)按顺序完整执行 □ PausePanel:Escape 暂停 → PausePanel 显示 → 继续游戏恢复 Time.timeScale □ InventoryPanel:装备护符 → 效果生效 → 凹槽计数更新 □ SettingsPanel:音量滑条调整 → AudioMixer 参数实时变化 □ RebindPanel:重绑跳跃键 → 新键能正常触发跳跃 → 重启后绑定持久化(⚠️ 类名 RebindPanel,架构 04 §6) □ LocalizationManager:切换语言 → UI/对话/道具名称实时更新 □ AchievementManager:首次击杀敌人 → ACH_FirstKill 解锁 → 存档记录 → 重启后不重复 □ DebugCheatSystem:仅在 Editor/Development Build 中可用,BackQuote(`)开关控制台,godmode 命令生效 □ AccessibilityManager:色盲模式/字幕/高对比模式切换均实时生效 □ AntiSoftlockSystem:60s 静止后显示提示(非 30s) □ AddressKeysValidator:无 Warning(所有 key 存在) □ EventChannelAuditor:无孤立未被订阅的事件频道 □ RoomValidator:所有房间场景通过验证 □ Profiler:Gameplay 稳定状态 GC Alloc/帧 < 1KB □ Console 无 Error(发布构建无 Debug.Log) ``` --- ## 7. 发布前技术 Checklist 完成以下所有项后,技术层面达到发布就绪(Release Candidate)状态: ``` 架构 □ 所有 asmdef 依赖方向正确(无循环依赖) □ 无 using 直接引用非依赖 asmdef 的类型 存档 □ SaveData 版本迁移路径完整(v1→v2→…→current) □ SteelSoul 死亡删档流程二次确认 UI 存在 □ 存档文件 SHA-256 校验通过 构建 □ Development Build 关闭 DebugCheatSystem 快捷键 □ Release Build 关闭所有 Debug.Log(或通过 #if UNITY_EDITOR 过滤) □ Addressables Remote 资产 CDN URL 指向 Production □ IL2CPP 构建无 Managed Stripping 报错 平台 □ Steam 成就 API 集成测试(TestApp 环境) □ 手柄全功能测试(PS5/Xbox 手柄) □ 键盘+鼠标全功能测试 □ 分辨率:1080p/1440p/2160p 均无 UI 错位 性能 □ 关卡中最差帧率 > 55fps(Switch 目标) □ 内存占用 < 1.5GB(PC)/ < 1GB(Switch) □ 加载时间 < 3s(所有房间) 内容 □ 所有已实现功能有本地化文本(zh-CN + en) □ 所有 NPC 有至少 1 个 DialogueSequence □ 所有 Boss 有完整技能套(≥2 阶段) ``` **Phase 4 完成 = 游戏技术层就绪,进入关卡/内容填充阶段。**