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