Files
zeling_v2/Docs/Plan/05_Phase4_ContentPolish.md
2026-05-12 15:34:08 +08:00

138 KiB
Raw Blame History

Phase 4 · 内容与完善

周期34 周Week 1518
前置条件Phase 3 全部完成标准通过
核心目标Boss 技能系统、叙事/对话/过场、按键重绑定、完整 UI 面板、支撑系统(本地化/成就/Steam/调试工具、编辑器工具、QA 就绪
产出物:游戏发布前技术层面全部完成,可进入内容填充 + 关卡设计阶段
状态 全部完成2026-05-11P4-1P4-6 全部


目录

  1. 实施顺序总览
  2. Week 15Boss 技能系统完整
  3. Week 16叙事模块对话/过场/事件链)
  4. Week 17UI 完整面板 + 按键重绑定
  5. Week 18支撑模块 + 编辑器工具 + QA
  6. 完成标准检查清单
  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世界事件链
           ↓
         CutsceneManagerUnity Timeline 封装)

Week 17: 完整 UI 面板套装
           ├─ PausePanel设置入口 + 存档 + 退出)
           ├─ InventoryPanel护符 + 工具槽 + 凹槽 UI
           ├─ MapPanel完整 Fog of War 渲染)
           ├─ ShopPanel商品列表 + 购买确认)
           ├─ AchievementPanel
           └─ SettingsPanel音量/分辨率/按键重绑定)
           ↓
         InputRebindingUINew Input System 重绑定 API

Week 18: LocalizationManagerUnity Localization 包)
           ↓
         AchievementManager本地 + Steam 双通道)
           ↓
         PlatformBootstrapSteam 成就/存档云同步;⚠️ 非 PlatformManager 静态类,架构 16 §3
           ↓
         DebugCheatSystemEditor Only + Development Build
           ↓
         AntiSoftlockSystem出门触发器 + 快速复活备用)
           ↓
         AccessibilityManager色盲模式/字幕/震动/高对比)
           ↓
         AnalyticsManager本地 JSONL 日志,开发分析)
           ↓
         SpeedrunTimerIGT 计时器ISaveable
           ↓
         编辑器工具(验证工具套装)
           ↓
         QA Checklist 执行

2. Week 15Boss 技能系统完整 完成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 ← structTriggerDelay, Duration, WeakPointType, DamageMultiplier, ForceStagger, …)
AttackPatternSO.cs     ← 单个攻击图案HitBox 范围/时序/DamageSourceSO 引用)
SkillSequenceSO.cs     ← SequenceStep[] 序列 + RepeatIfPlayerInRange / MaxRepeatCount
PlayerCounterResponse.cs ← structCounterType + Boss应激反应参数
ArenaEventTrigger.cs   ← struct + ArenaEventType + IArenaInteractable场景联动
BossResourceCost.cs    ← struct + BossResourceConfigSOBoss 资源系统)
BossSkillSO.cs         ← 技能(所有架构 23 §4 字段,含 attackPatterns/counterResponses/arenaEvents/resourceCost/poiseWindow

注意:不存在 BossConfigSO。Boss 阶段技能列表直接由 BossOrchestrator_phaseOneSkills[] / _phaseTwoSkills[] 字段管理(见 §2.3)。

2.1.1 BossSkillCategory + BossSkillType + 互动/弱点枚举

// Assets/Scripts/Boss/BossSkillType.cs
namespace BaseGames.Boss
{
    /// <summary>⚠️ 高层技能分类(平衡框架用)(架构 23 §2</summary>
    public enum BossSkillCategory
    {
        Melee, Ranged, Charge, AoE, Environmental, Summon,
        Buff, Debuff, Phase, Passive, Reactive
    }

    /// <summary>具体技能类型(战斗设计用)。</summary>
    public enum BossSkillType
    {
        MeleeSlash,
        ChargeAttack,
        LeapSlam,
        ProjectileVolley,
        LaserBeam,
        PhaseTransition,
        SummonMinion,
        ArenaTrap,
        SpeedBurst,
        DefensiveShell,
    }

    /// <summary>⚠️ 互动标签:玩家可对该技能执行哪些反制操作(架构 23 §2[Flags] 枚举)</summary>
    [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,   // 阶段门关
    }

    /// <summary>⚠️ 弱点触发方式(架构 23 §3</summary>
    public enum VulnTriggerType
    {
        OnAttackRecovery,   // 攻击后摇
        OnParriedSuccess,   // 弹反成功
        OnCounterSkillHit,  // 反制技能命中
        OnPhaseTransition,  // 阶段切换时
        OnHazardBackfire,   // 场地反噬
        OnSummonDefeated,   // 召唤物被击败
        Manual,             // BossSkillExecutor 手动触发
    }

    /// <summary>⚠️ 弱点位置类型(架构 23 §3</summary>
    public enum WeakPointType
    {
        FullBody,     // 主体全身都是弱点
        HeadOnly,     // 仅头部
        BackOnly,     // 仅背部
        CoreExposed,  // 核心暴露(展开中心 HurtBox
        CustomPoint,  // 自定义弱点 HurtBox
    }
}

2.1.2 BossSkillSO

// Assets/Scripts/Boss/BossSkillSO.cs
// ⚠️ menuName = "Boss/BossSkill"(架构 23 §4skillAnimation 为 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

// 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

// 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

// 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 命名空间。

// Assets/Scripts/Boss/PlayerCounterResponse.cs
namespace BaseGames.Boss
{
    /// <summary>⚠️ 玩家反制接口(架构 23 §4.1</summary>
    [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
    /// <summary>⚠️ 场景联动(架构 23 §4.2</summary>
    [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; // 阶段结束时是否恢复
    }

    /// <summary>⚠️ 场景中可被 Boss 技能交互的对象接口(架构 23 §4.2</summary>
    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
    /// <summary>⚠️ Boss 资源消耗(架构 23 §4.3</summary>
    [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

// ⚠️ 字段为绝对时间不是归一化时间01架构 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

// 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 无 InstanceArchitecture 05 §2Boss 居小场景持有玩家 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<BehaviorTree>().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

// 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;

        /// <summary>统一激活/关闭所有弱点 HurtBox。由 BossSkillExecutor.Update() 驱动。</summary>
        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);
        }

        /// <summary>弱点 HurtBox 受击时由 BossStats 调用,获取最终伤害倍率。</summary>
        public float GetDamageMultiplier() => _damageMultiplier;
    }
}

2.5 首个 Boss 完整资产

Boss_Example 资产集Assets/Data/Enemies/Boss/Boss_Example/

资产名 类型 内容
BSS_Skill_Slash BossSkillSO MeleeSlashvulnerabilityWindows[0]: TriggerDelay=0.5s, Duration=1.2s⚠️ 绝对秒数,非归一化时间)
BSS_Skill_Charge BossSkillSO ChargeAttackWeakPoint 头部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 均依赖这两个数据类型。

// 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
    }
}
// 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

// 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_NpcDialogueCompletednpcIdQuestManager 订阅)
    [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 → GameplayArchitecture 14 §3先切换再广播
        _onDialogueEnded.Raise();
    }

    // 分支选择(当 DialogueSequenceSO 有 ConditionalVariants 时)
    public DialogueSequenceSO ResolveVariant(DialogueSequenceSO sequence);
}

DialoguePanelUI Toolkit 或 UGUI

  • 文本框Text Mesh Pro
  • 说话人姓名框
  • 头像 Image
  • 翻页箭头Blink 动画)

3.2 InteractableNPC + NarrativeNPC

// Assets/Scripts/Dialogue/InteractableNPC.cs
// 实现 IInteractableBaseGames.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 遗漏)
    }

    /// <summary>子类覆盖此方法以在对话前注入额外逻辑(如接受/完成任务)。</summary>
    protected virtual void Interact_Internal(Transform player) { }   // ⚠️ 架构 14 §6原 Plan 遗漏)

    /// <summary>子类覆盖此方法以根据游戏状态返回不同的对话 SO见 NarrativeNPC。</summary>
    protected virtual DialogueSequenceSO GetCurrentDialogue() => _defaultDialogue;  // ⚠️ 架构 14 §6原 Plan 遗漏)

    public void OnPlayerEnterRange(Transform player) { }  // 交互提示 UI 由 InteractableDetector 统一发布
    public void OnPlayerExitRange() { }                   // 交互提示 UI 由 InteractableDetector 统一发布
}
// 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

// 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<Gamepad>().Any(g => g.enabled);
        _icon.sprite   = isGamepad ? _gamepadIcon : _keyboardIcon;
    }

    public void Hide() => _promptRoot.SetActive(false);
}

检测逻辑InteractableDetector(挂在 Player 子节点)通过 CircleCollider2D 检测范围内的 IInteractable,调用 interactable.GetComponent<InteractionPromptController>()?.Show()/Hide()

3.3 EventChain + EventChainManager

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

EventChainManagerArchitecture 14 §10

// 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<string> OnBossDefeated;
    public event Action<string> OnCollectiblePickedUp;
    public event Action<string> OnAbilityUnlocked;
    public event Action<string> OnRoomEntered;
    public event Action<string> OnDialogueCompleted;
    public event Action<string> OnChainCompleted;  // 链完成时广播 chainId供 ChainCompletedCondition

    readonly HashSet<string> _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)(原 PlanExecute(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

// 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<PlayableDirector>();

    // ⚠️ 参数为 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
// 实现 IInteractableOnInteract 模式)
public class CutsceneTrigger : MonoBehaviour, IInteractable
{
    public enum TriggerMode
    {
        OnEnter,       // ⚠️ 架构 14 §11.5OnEnter原 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 遗漏)

// Assets/Scripts/Cutscene/SignalEmitterClip.cs
// ⚠️ 架构 14 §11.6(原 Plan 遗漏Timeline 过场通过此 PlayableAsset 发布 SO 事件频道,保持零耦合
// 在 Timeline 轨道上放置此 ClipClip 播放时向目标事件频道 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<SignalEmitterBehaviour>.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(隐藏 HUDInputReaderSO
EVT_CutsceneEnded VoidEventChannelSO CutsceneManager HUDController(恢复 HUD

4. Week 17UI 完整面板 + 按键重绑定 完成2026-05-11

参考文档10_UIModule.md

4.1 UIManager 完整路由

// ⚠️ 字段类型为 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<GameObject> 管理面板层级(支持 ESC 逐层关闭)
    private Stack<GameObject> _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_LoadingOverlayBoolEventChannelSOtrue=显示/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_InputDeviceChangedBoolEventChannelSOtrue=手柄/false=键盘)
  • 遇到设备切换时全局刷新所有注册过的按键图标 Sprite

4.3 RebindPanel + RebindActionRow + ConflictDetector

⚠️ 架构约束04_InputModule §6:重绑定 UI 由三个类组成:RebindPanel(主面板)、RebindActionRow(每行)、ConflictDetector(冲突检测)。所有持久化通过 InputReaderSO.SaveBindingOverrides() / ResetBindings() 进行,禁止直接访问 _inputReader.Actions.asset

// 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<RebindActionRow>     _onRebindRequested;

    public void Initialize(InputReaderSO reader, ConflictDetector detector, Action<RebindActionRow> 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<RebindActionRow>())
            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<string> FindConflicts(IEnumerable<InputAction> actions)
    {
        var pathToActions = new Dictionary<string, List<string>>();
        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<string>();
                pathToActions[binding.effectivePath].Add(action.name);
            }
        var conflicted = new HashSet<string>();
        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

// 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 + DeathScreenControllerArchitecture 10 §4§6

// 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_BossFightToggledtrue=战斗开始false=结束)
    [SerializeField] private IntEventChannelSO    _onBossHPChanged;     // EVT_BossHPChanged当前 HP 整数)
    [SerializeField] private StringEventChannelSO _onBossNameSet;       // EVT_BossNameSetBoss 名称 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.5sArchitecture 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 + LoadingOverlayArchitecture 10 §7§8

// 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_LoadingOverlayBoolEventChannelSO控制全屏黑幕渐入渐出
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 + InputDeviceIconSwitcherArchitecture 10 §10§12

// 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<InputIconImage>())
            iconImg.RefreshFromSet(set);
    }
}

4.8 SaveSlotController + SaveIndicator + LoadingScreenManager + IBossHPProvider + DialogueBox⚠️ 架构 10 §7.5§9原 Plan 遗漏)

// 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;  // 01

    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; }   // 01 实时 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<IPlatformService>() 优于架构的 #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 包封装)。

// 路径: 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<string> 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 单例。

// 路径: 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";

    /// <summary>切换语言并持久化选择(替代 LocalizationManager.SetLocale后者不持久化</summary>
    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);
        }
    }

    /// <summary>获取当前语言代码(默认 zh-CN</summary>
    public string GetCurrentLocaleCode()
        => LocalizationSettings.SelectedLocale?.Identifier.Code ?? "zh-CN";

    /// <summary>游戏启动时从 PlayerPrefs 读取上次选择的语言(由 GameManager.Awake 调用)</summary>
    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

// 路径: 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 AchievementConditionScriptableObject 策略模式)

// 路径: 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
// 示例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

// 路径: 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<string>  OnBossDefeated;
        public event Action<string>  OnCollectiblePickedUp;
        public event Action<int>     OnAbilityUnlocked;
        public event Action<string>  OnRoomEntered;
        public event Action          OnParrySuccess;
        public event Action          OnNailClash;

        readonly Dictionary<string, AchievementRuntimeState> _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<IPlatformService>()?.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;
        }
    }

    /// <summary>单个成就的运行时状态(条件满足记录 + 解锁状态)</summary>
    public class AchievementRuntimeState
    {
        public bool IsUnlocked { get; set; }
        readonly HashSet<AchievementCondition> _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 EventTriggeredConditionEVT_EnemyDied
ACH_FirstBoss DefeatedBossCondition(首个 Boss id
ACH_Collector CollectedAllCharmsConditionMapExplorationCondition
ACH_Speedrunner TimedBossKillCondition(最终 Boss + maxSeconds

5.3 PlatformBootstrap + IPlatformServiceSteam 集成)

⚠️ 架构 16_SupportingModules §3 完整接口:旧版 IPlatformService 缺少 IncrementStatGetStatIsAchievementUnlocked、RichPresence、振动、生命周期方法云存档方法签名已变更返回 Task<bool> / Task<byte[]>)。
⚠️ 注入方式变更:不使用 PlatformManager 静态类直接初始化;改用 PlatformBootstrap MonoBehaviour + ServiceLocator 模式(架构 16 §3

// 路径: 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<string> ReadCloudSaveAsync
        Task<bool>   CloudSaveAsync(string fileName, byte[] data);
        Task<byte[]> 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 调用
    }
}
// 路径: 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<bool> CloudSaveAsync(string fileName, byte[] data)
        {
            if (!IsCloudAvailable) return false;
            return await Task.Run(() =>
                SteamRemoteStorage.FileWrite(fileName, data, data.Length));
        }
        public async Task<byte[]> 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
// 路径: 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<bool>   CloudSaveAsync(string f, byte[] d) => Task.FromResult(false);
        public Task<byte[]> CloudLoadAsync(string f)           => Task.FromResult<byte[]>(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()                                 { }
    }
}
// 路径: Assets/Scripts/Support/Platform/PlatformBootstrap.cs
// ⚠️ 注入方式MonoBehaviour 挂在 Persistent 场景的 Bootstrap GameObject
//    使用 ServiceLocator.Register<IPlatformService>(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<IPlatformService>(service);
    }

    void Update()            => ServiceLocator.Get<IPlatformService>()?.RunCallbacks();
    void OnApplicationQuit() => ServiceLocator.Get<IPlatformService>()?.Shutdown();
}

// 便捷访问AchievementManager 内部用)
// ⚠️ 通过 ServiceLocator 获取,不使用 PlatformManager.Service 静态属性
// ServiceLocator.Get<IPlatformService>().UnlockAchievement(id);

5.4 DebugCheatSystem

// 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 <amount> / teleport <sceneName> / unlock <abilityId> / killall
    private void ExecuteCommand(string cmd)
    {
        // ⚠️ PlayerController 无 Instance 单例Architecture 05 §2Debug 上下文用 FindObjectOfType
        var player = FindObjectOfType<PlayerController>();
        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 无 InstanceArchitecture 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<EnemyBase>()) e.TakeDamage(killDmg);
                break;
            default:
                Debug.Log($"[Cheat] 未知命令: {cmd}");
                break;
        }
    }
}
#endif

5.5 AntiSoftlockSystem

// ⚠️ 静止阈值为 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 无 InstanceArchitecture 05 §2AntiSoftlock 在 Persistent 场景,
        // 通过 FindObjectOfType 获取(支撑系统可接受,非热路径)
        var player = FindObjectOfType<PlayerController>();
        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 无 InstanceArchitecture 03 §3
        _onSceneLoadRequest.Raise(new SceneLoadRequest
        {
            SceneName         = SaveManager.LastCheckpointScene,
            EntryTransitionId = SaveManager.LastCheckpointSpawnId,
            IsRespawn         = true
        });
    }
}

5.5.1 RoomEscapeInfoSO⚠️ 架构 16_SupportingModules §5.1

// 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

// Assets/Scripts/Support/AntiSoftlock/HardAbilityGate.cs
// ⚠️ 增强型能力门:防止玩家用精准时机绕过只检查标志的 AbilityGate架构 16 §5.2
namespace BaseGames.Progression
{
    /// <summary>
    /// 增强型能力门:除了检查能力标志,还检测玩家是否"物理上真的能做到"。
    /// 用于防止 Sequence Break见 Design/49 §4.2)。
    /// </summary>
    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(非 GlobalSettingsSOColorBlindMode 枚举含 5 个值(含 AchromatopsiaAccessibilityManager.Instance 单例模式;含 CanPlayScreenShake() 静态工具方法;含 ColorBlindFilter URP Renderer Feature。

AccessibilitySettingsSO数据容器

// Assets/ScriptableObjects/Accessibility/AccessibilitySettings.asset
// ⚠️ 独立 SO非 GlobalSettingsSOmenuName = "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.00 = 完全关闭)
    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.2ParrySystem 读取此字段)
    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 §64 值版本遗漏此项)
}

AccessibilityManager

// 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 ChannelsRaise 方)───────────────────────────────────────────
    [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);

ColorBlindFilterURP Renderer Feature

// Assets/Scripts/Accessibility/ColorBlindFilter.cs
// ⚠️ URP 2D 后处理最终合成阶段应用色彩矩阵变换(架构 16 §6
// 在 URP 2D Renderer DataAssets/Settings/URP2DRenderer.asset中添加此 Feature
public class ColorBlindFilter : ScriptableRendererFeature
{
    [SerializeField] ColorBlindMode _mode;

    // 色彩矩阵3×3基于 Brettel et al. 1997 算法)
    private static readonly Dictionary<ColorBlindMode, Matrix4x4> _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

// 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<string, object> properties = null)
    {
        if (!enabled) return;
        var payload = new Dictionary<string, object>
        {
            ["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

// 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 补充字段之后):

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 PingDurationNormalized < 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.csnamespace BaseGames.CoreIEnumerable<string> Validate()
SOValidationRunner 已创建 Assets/Scripts/Editor/Validation/SOValidationRunner.csIPreprocessBuildWithReport callbackOrder=1[MenuItem("Tools/Validate All ScriptableObjects")]"必须"/ 为 Error其余为 Warning
AddressKeyValidator 构建钩子 已修复 AddressKeyValidatorBuildHook 内部类追加至现有文件callbackOrder=0调用 RunValidation(),有孤儿 key 则抛 BuildFailedException
EventChannelEditor 已创建 Assets/Scripts/Editor/EventChannelEditor.csVoidBaseEventChannelSO RaiseInEditor 按钮(非 Play Mode 显示 HelpBoxBaseEventChannelSO<T> 反射读取订阅者数
PhantomPlate 已创建 Assets/Scripts/World/PhantomPlate.csnamespace BaseGames.WorldPlatformEffector2D 单向穿透 + TriggerDropThrough() 下穿 APIEditor 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.csBossSkillSO/SkillSequenceSO 甘特图Windup黄/Active红/Recovery灰/Vuln绿/Delay暗灰拖放加载点击标签 PingObject
EventChainEditorWindow 已创建 Assets/Scripts/Editor/EventChainEditorWindow.cs;左侧链列表(完成=绿/激活=橙/未触发=灰);右侧条件+动作表Play Mode 反射读取 _completedChainsChainCompletedCondition 依赖链显示;执行日志 20 条
AddressReferenceGraphWindow 已创建 Assets/Scripts/Editor/AddressReferenceGraphWindow.cs;反射遍历 AddressKeys 常量Regex 扫描所有 .cs 文件;孤儿 Key 红/无效 Key 橙/正常绿;导出 CSV
AchievementSOEditor 已创建 Assets/Scripts/Editor/Achievements/AchievementSOEditor.cs12 种条件中文标签映射;内联 SerializedObject 子 SO 字段Ping/Delete 按钮
BaseGames.Editor.asmdef 已更新 新增 "BaseGames.EventChain" 引用,EventChainEditorWindow 可正常编译

5.10 QA 执行

性能基准目标Switch/中端 PC 60fps1080p

测试场景 目标 工具
10 个敌人同时 Pathfinding < 1ms PathBerserker2d 消耗 Unity Profiler
100 个粒子同时播放 < 2ms VFX 消耗 Profiler
场景加载(中等房间) < 2s含 FadeOut/FadeIn 手动计时
存档读写 < 200ms Stopwatch
GC Alloc/帧 < 1KBGameplay 稳定状态) Profiler

功能回归测试清单(所有 Phase 完成标准的并集)


6. 完成标准检查清单

□ BossSkillExecutor执行 LeapSlam 技能时 VulnerabilityWindow 后摇 1s 内弱点 HurtBox 激活
□ Boss Phase 切换HP 降至 50% → Phase 过渡动画 → 新技能集解锁
□ DialogueManager打字机效果 + 快进 + 最后一行结束后关闭对话框 + 恢复 Gameplay Map
□ InteractableNPC不同游戏进度对话切换正确ConditionalVariant 选择)
□ CutsceneManager播放 Timeline 期间玩家无法移动,播放结束后恢复控制
□ EventChain世界事件链对话+设置标志+奖励 Geo按顺序完整执行
□ PausePanelEscape 暂停 → PausePanel 显示 → 继续游戏恢复 Time.timeScale
□ InventoryPanel装备护符 → 效果生效 → 凹槽计数更新
□ SettingsPanel音量滑条调整 → AudioMixer 参数实时变化
□ RebindPanel重绑跳跃键 → 新键能正常触发跳跃 → 重启后绑定持久化(⚠️ 类名 RebindPanel架构 04 §6
□ LocalizationManager切换语言 → UI/对话/道具名称实时更新
□ AchievementManager首次击杀敌人 → ACH_FirstKill 解锁 → 存档记录 → 重启后不重复
□ DebugCheatSystem仅在 Editor/Development Build 中可用BackQuote`开关控制台godmode 命令生效
□ AccessibilityManager色盲模式/字幕/高对比模式切换均实时生效
□ AntiSoftlockSystem60s 静止后显示提示(非 30s
□ AddressKeysValidator无 Warning所有 key 存在)
□ EventChannelAuditor无孤立未被订阅的事件频道
□ RoomValidator所有房间场景通过验证
□ ProfilerGameplay 稳定状态 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 错位

性能
□ 关卡中最差帧率 > 55fpsSwitch 目标)
□ 内存占用 < 1.5GBPC/ < 1GBSwitch
□ 加载时间 < 3s所有房间

内容
□ 所有已实现功能有本地化文本zh-CN + en
□ 所有 NPC 有至少 1 个 DialogueSequence
□ 所有 Boss 有完整技能套≥2 阶段)

Phase 4 完成 = 游戏技术层就绪,进入关卡/内容填充阶段。