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

3269 lines
138 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 4 · 内容与完善
> **周期**34 周Week 1518
> **前置条件**Phase 3 全部完成标准通过
> **核心目标**Boss 技能系统、叙事/对话/过场、按键重绑定、完整 UI 面板、支撑系统(本地化/成就/Steam/调试工具、编辑器工具、QA 就绪
> **产出物**:游戏发布前技术层面全部完成,可进入内容填充 + 关卡设计阶段
> **状态**:✅ 全部完成2026-05-11P4-1P4-6 全部 ✅)
---
## 目录
1. [实施顺序总览](#1-实施顺序总览)
2. [Week 15Boss 技能系统完整](#2-week-15boss-技能系统完整)
3. [Week 16叙事模块对话/过场/事件链)](#3-week-16叙事模块对话过场事件链)
4. [Week 17UI 完整面板 + 按键重绑定](#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世界事件链
CutsceneManagerUnity Timeline 封装)
Week 17: 完整 UI 面板套装
├─ PausePanel设置入口 + 存档 + 退出)
├─ InventoryPanel护符 + 工具槽 + 凹槽 UI
├─ MapPanel完整 Fog of War 渲染)
├─ ShopPanel商品列表 + 购买确认)
├─ AchievementPanel
└─ SettingsPanel音量/分辨率/按键重绑定)
InputRebindingUINew Input System 重绑定 API
Week 18: LocalizationManagerUnity Localization 包)
AchievementManager本地 + Steam 双通道)
PlatformBootstrapSteam 成就/存档云同步;⚠️ 非 PlatformManager 静态类,架构 16 §3
DebugCheatSystemEditor Only + Development Build
AntiSoftlockSystem出门触发器 + 快速复活备用)
AccessibilityManager色盲模式/字幕/震动/高对比)
AnalyticsManager本地 JSONL 日志,开发分析)
SpeedrunTimerIGT 计时器ISaveable
编辑器工具(验证工具套装)
QA Checklist 执行
```
---
## 2. Week 15Boss 技能系统完整 ✅ 完成2026-05-11
**参考文档**`23_BossSkillModule.md`
### 2.1 数据层 SO 创建顺序
按依赖关系由底向上:
```
BossSkillCategory.cs ← 分类枚举Melee/Ranged/Charge/AoE/…)
BossSkillType.cs ← 具体类型枚举MeleeSlash / ChargeAttack / LeapSlam / …)
InteractionTag.cs ← [Flags] 互动标签枚举Parryable/Unblockable/…)
VulnTriggerType.cs ← 弱点触发方式枚举OnAttackRecovery/OnParriedSuccess/…)
WeakPointType.cs ← 弱点位置类型枚举FullBody/HeadOnly/BackOnly/…)
VulnerabilityWindow.cs ← structTriggerDelay, Duration, WeakPointType, DamageMultiplier, ForceStagger, …)
AttackPatternSO.cs ← 单个攻击图案HitBox 范围/时序/DamageSourceSO 引用)
SkillSequenceSO.cs ← SequenceStep[] 序列 + RepeatIfPlayerInRange / MaxRepeatCount
PlayerCounterResponse.cs ← structCounterType + Boss应激反应参数
ArenaEventTrigger.cs ← struct + ArenaEventType + IArenaInteractable场景联动
BossResourceCost.cs ← struct + BossResourceConfigSOBoss 资源系统)
BossSkillSO.cs ← 技能(所有架构 23 §4 字段,含 attackPatterns/counterResponses/arenaEvents/resourceCost/poiseWindow
```
> **注意**:不存在 `BossConfigSO`。Boss 阶段技能列表直接由 `BossOrchestrator` 的 `_phaseOneSkills[]` / `_phaseTwoSkills[]` 字段管理(见 §2.3)。
### 2.1.1 BossSkillCategory + BossSkillType + 互动/弱点枚举
```csharp
// Assets/Scripts/Boss/BossSkillType.cs
namespace BaseGames.Boss
{
/// <summary>⚠️ 高层技能分类(平衡框架用)(架构 23 §2</summary>
public enum BossSkillCategory
{
Melee, Ranged, Charge, AoE, Environmental, Summon,
Buff, Debuff, Phase, Passive, Reactive
}
/// <summary>具体技能类型(战斗设计用)。</summary>
public enum BossSkillType
{
MeleeSlash,
ChargeAttack,
LeapSlam,
ProjectileVolley,
LaserBeam,
PhaseTransition,
SummonMinion,
ArenaTrap,
SpeedBurst,
DefensiveShell,
}
/// <summary>⚠️ 互动标签:玩家可对该技能执行哪些反制操作(架构 23 §2[Flags] 枚举)</summary>
[Flags]
public enum InteractionTag
{
None = 0,
Parryable = 1 << 0, // 可弹反
PerfectParryOnly = 1 << 1, // 仅完美弹反有效
DodgeWindow = 1 << 2, // 打开逃避窗口
Unblockable = 1 << 3, // 无法拦截
CanBeReflected = 1 << 4, // 弹幕可被反射
ExposesWeakPoint = 1 << 5, // 暴露弱点
GrantsPlayerReso = 1 << 6, // 命中后给予玩家资源
ArenaHazard = 1 << 7, // 场地危机
PhaseGate = 1 << 8, // 阶段门关
}
/// <summary>⚠️ 弱点触发方式(架构 23 §3</summary>
public enum VulnTriggerType
{
OnAttackRecovery, // 攻击后摇
OnParriedSuccess, // 弹反成功
OnCounterSkillHit, // 反制技能命中
OnPhaseTransition, // 阶段切换时
OnHazardBackfire, // 场地反噬
OnSummonDefeated, // 召唤物被击败
Manual, // BossSkillExecutor 手动触发
}
/// <summary>⚠️ 弱点位置类型(架构 23 §3</summary>
public enum WeakPointType
{
FullBody, // 主体全身都是弱点
HeadOnly, // 仅头部
BackOnly, // 仅背部
CoreExposed, // 核心暴露(展开中心 HurtBox
CustomPoint, // 自定义弱点 HurtBox
}
}
```
### 2.1.2 BossSkillSO
```csharp
// Assets/Scripts/Boss/BossSkillSO.cs
// ⚠️ menuName = "Boss/BossSkill"(架构 23 §4skillAnimation 为 Animancer ClipTransition
namespace BaseGames.Boss
{
[CreateAssetMenu(menuName = "Boss/BossSkill")]
public class BossSkillSO : ScriptableObject
{
[Header("元信息")]
public string skillId;
public string displayName;
[TextArea(1, 4)]
public string designNote;
[Header("技能分类")]
public BossSkillCategory category; // ⚠️ 高层分类(架构 23 §4
public BossSkillType skillType;
[Header("阶段可用性")]
public int[] availablePhaseIndices; // ⚠️ 空数组 = 全阶段可用(架构 23 §4
[Header("核心攻击动作(按执行顺序排列)")]
public AttackPatternSO[] attackPatterns; // ⚠️ 按架构 23 §4伤害参数只在 AttackPatternSO
[Header("弱点窗口(至少 1 个)")]
public VulnerabilityWindow[] vulnerabilityWindows;
[Header("互动标签")]
public InteractionTag interactionTags; // ⚠️ [Flags] 枚举(架构 23 §4非 string interactionTag
[Header("连段子序列")]
public SkillSequenceSO sequenceOnHit; // 弱点被击时触发(可空)
public SkillSequenceSO sequenceOnMiss; // 弱点未被击时触发(可空)
[Header("玩家反制接口")]
public PlayerCounterResponse[] counterResponses; // ⚠️ 架构 23 §4.1
[Header("场景联动")]
public ArenaEventTrigger[] arenaEvents; // ⚠️ 架构 23 §4.2
[Header("Boss 资源")]
public BossResourceCost resourceCost; // ⚠️ 架构 23 §4.3
public bool buildsRage; // ⚠️ 是否积累愤怒值(架构 23 §4
[Header("霸体配置")]
public PoiseWindowConfig poiseWindow; // ⚠️ 技能执行期间的霸体窗口(架构 23 §4
[Header("动画")]
public ClipTransition skillAnimation; // Animancer ClipTransition
[Header("冷却")]
[Min(0f)]
public float cooldown;
}
}
```
### 2.1.3 AttackPatternSO
```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
{
/// <summary>⚠️ 玩家反制接口(架构 23 §4.1</summary>
[Serializable]
public struct PlayerCounterResponse
{
[Header("反制条件")]
public CounterType counterType;
public string requiredSkillId; // counterType = SpecificSkill 时填写
[Header("反制效果(对 Boss")]
public float bossStaggerDuration; // 强制硬直时长0 = 不强制)
public float bossDamageBonus; // 对 Boss 的额外伤害倍率0 = 不额外)
public bool openVulnWindow; // 是否触发 VulnerabilityWindow
public bool interruptSkill; // 是否打断 Boss 当前技能
[Header("反制收益(对玩家)")]
public int soulPowerGrant;
public int spiritPowerGrant;
public MMF_Player counterFeedback; // 成功反制的特效/音效反馈
}
public enum CounterType
{
Parry, PerfectParry, DodgeThrough, SpecificSkill,
WeakPointHit, HazardBackfire, SummonKill,
}
// Assets/Scripts/Boss/ArenaEventTrigger.cs
/// <summary>⚠️ 场景联动(架构 23 §4.2</summary>
[Serializable]
public struct ArenaEventTrigger
{
public string targetArenaObjectId;
public ArenaEventType eventType;
public float delay;
public ArenaEventParams parameters;
}
public enum ArenaEventType
{
DestroyPlatform, ActivateHazard, DeactivateHazard, SpawnHazardArea,
ShakeArena, ToggleLighting, SpawnPlatform, TriggerCutscene,
}
[Serializable]
public struct ArenaEventParams
{
public float duration; // 效果持续时间0 = 永久)
public float intensity; // 强度(震动幅度/半径等)
public bool revertsOnPhaseEnd; // 阶段结束时是否恢复
}
/// <summary>⚠️ 场景中可被 Boss 技能交互的对象接口(架构 23 §4.2</summary>
public interface IArenaInteractable
{
string ArenaObjectId { get; }
void OnBossArenaEvent(ArenaEventData data);
}
[Serializable]
public struct ArenaEventData
{
public ArenaEventType type;
public ArenaEventParams parameters;
public string sourceSkillId;
}
// Assets/Scripts/Boss/BossResourceCost.cs
/// <summary>⚠️ Boss 资源消耗(架构 23 §4.3</summary>
[Serializable]
public struct BossResourceCost
{
public string resourceId; // 对应 BossResourceConfigSO.resourceId如 "Rage"
public float cost;
public float minRequired; // 使用此技能的最低资源要求
}
[CreateAssetMenu(menuName = "Boss/ResourceConfig")]
public class BossResourceConfigSO : ScriptableObject
{
public string resourceId;
public string displayName;
public float maxValue;
public float startValue;
[Header("自动变化")]
public float passiveRate; // 每秒自动变化量(正=增长/负=衰减)
public float onTakeDamageGain; // 每受 1 点伤害积累量
public float onSkillUseGain; // 每使用一次技能积累量
[Header("满值效果")]
public bool autoTriggerOnFull;
public BossSkillSO fullTriggerSkill;
public float resetValueAfterTrigger;
}
}
```
### 2.2 VulnerabilityWindow
```csharp
// ⚠️ 字段为绝对时间不是归一化时间01架构 23_BossSkillModule §3
// ⚠️ 使用 UniTask 序列按 TriggerDelay+Duration 激活弱点(非 Update() 轮询归一化时间)
namespace BaseGames.Boss
{
[Serializable]
public struct VulnerabilityWindow
{
[Tooltip("弱点触发方式")]
public VulnTriggerType TriggerType; // ⚠️ 架构 23 §3
[Tooltip("触发后延迟出现(秒)")]
[Min(0f)]
public float TriggerDelay; // ⚠️ 绝对秒数(架构 23 §3非归一化时间
[Tooltip("弱点持续时长(秒,设计约定 ≥0.5s")]
[Min(0.1f)]
public float Duration; // ⚠️ 绝对秒数(架构 23 §3非归一化时间
[Tooltip("弱点位置类型")]
public WeakPointType WeakPointType; // ⚠️ 架构 23 §3
[Tooltip("弱点激活时 Boss 受击乘数1 = 正常,>1 = 额外伤害)")]
[Min(0.1f)]
public float DamageMultiplier;
[Tooltip("命中后是否强制硬直")]
public bool ForceStagger; // ⚠️ 架构 23 §3
[Tooltip("硬直时间ForceStagger=true 时生效")]
[Min(0f)]
public float StaggerDuration; // ⚠️ 架构 23 §3
[Tooltip("弱点开启时播放的 Feedback光效等")]
public MMF_Player OpenFeedback; // ⚠️ 架构 23 §3
[Tooltip("弱点关闭时播放的 Feedback")]
public MMF_Player CloseFeedback; // ⚠️ 架构 23 §3
[Tooltip("弱点激活时的高亮颜色(指示玩家该攻击哪里)")]
public Color HighlightColor; // ⚠️ 架构 23 §3
// 向后兼容辅助属性BossSkillExecutor 判断是否激活专属 HurtBox
public bool ActivateWeakPointHurtBox => WeakPointType != WeakPointType.FullBody;
}
}
```
### 2.3 BossSkillExecutor + BossOrchestrator
```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 无 InstanceArchitecture 05 §2Boss 居小场景持有玩家 Transform 引用
[SerializeField] private Transform _playerTransform; // 由 Inspector 指定玩家 Transform
BossSkillSO _currentSkill;
AnimancerState _currentState;
bool _isExecuting;
CancellationTokenSource _cts;
public bool IsExecuting => _isExecuting;
// BD Task 节点调用
public async UniTask ExecuteSkill(BossSkillSO skill, CancellationToken ct)
{
if (_isExecuting) return;
_isExecuting = true;
_currentSkill = skill;
_onBossSkillStarted?.Raise(new BossSkillEvent { BossId = _bossId, SkillId = skill.skillId });
// 播放技能动画(直接从 BossSkillSO.skillAnimation 引用 ClipTransition
_currentState = _animancer.Play(skill.skillAnimation);
// 执行 SkillSequenceSO若挂载 sequenceOnMiss
if (skill.sequenceOnMiss != null)
await ExecuteSequence(skill.sequenceOnMiss, ct);
_isExecuting = false;
_onBossSkillEnded?.Raise(new BossSkillEvent { BossId = _bossId, SkillId = skill.skillId });
}
async UniTask ExecuteSequence(SkillSequenceSO seq, CancellationToken ct)
{
int repeatCount = 0;
do
{
foreach (var step in seq.steps)
{
ct.ThrowIfCancellationRequested();
await UniTask.WaitForSeconds(step.delayBeforeStep, cancellationToken: ct);
await ExecutePattern(step.pattern, ct);
}
repeatCount++;
if (seq.RepeatDelay > 0)
await UniTask.WaitForSeconds(seq.RepeatDelay, cancellationToken: ct);
}
while (seq.RepeatIfPlayerInRange
&& repeatCount < seq.MaxRepeatCount
&& IsPlayerInRange()
&& !ct.IsCancellationRequested);
}
async UniTask ExecutePattern(AttackPatternSO pattern, CancellationToken ct)
{
await UniTask.WaitForSeconds(pattern.WindupDuration, cancellationToken: ct);
// ⚠️ 以架构 06_CombatModule §4 为准HitBox.Activate(DamageSourceSO, Transform) + Deactivate()
foreach (var hb in _hitBoxes)
hb.Activate(pattern.DamageSource, transform);
await UniTask.WaitForSeconds(pattern.ActiveDuration, cancellationToken: ct);
foreach (var hb in _hitBoxes) hb.Deactivate();
await UniTask.WaitForSeconds(pattern.RecoveryDuration, cancellationToken: ct);
}
// ⚠️ UniTask 序列激活弱点窗口(架构 23 §7
// ⚠️ 使用绝对时间TriggerDelay + Duration不使用 Update() 归一化时间轮询
async UniTask ActivateVulnerabilityWindows(BossSkillSO skill, CancellationToken ct)
{
foreach (var window in skill.vulnerabilityWindows)
{
// 等待触发延迟
await UniTask.Delay(
TimeSpan.FromSeconds(window.TriggerDelay),
cancellationToken: ct);
// 激活弱点(⚠️ 架构 23 §8 SetActive 仅 2 参数§7 3-arg call 为内部不一致;以 §8 定义为准)
_weakPointSystem.SetActive(true, window.DamageMultiplier);
window.OpenFeedback?.PlayFeedbacks();
// 持续弱点窗口
await UniTask.Delay(
TimeSpan.FromSeconds(window.Duration),
cancellationToken: ct);
// 关闭弱点
_weakPointSystem.SetActive(false, 1f);
window.CloseFeedback?.PlayFeedbacks();
}
}
bool IsPlayerInRange() =>
_playerTransform != null &&
Vector2.Distance(transform.position, _playerTransform.position) < 8f;
}
}
// Assets/Scripts/Enemies/Boss/BossOrchestrator.cs
// Behavior Designer 的宿主;直接持有各阶段 BossSkillSO[] 数组(不通过 BossConfigSO
namespace BaseGames.Boss
{
public class BossOrchestrator : MonoBehaviour
{
[SerializeField] BossBase _boss; // ⚠️ 架构 07_EnemyModule §10
[SerializeField] TelegraphSystem _telegraph; // ⚠️ 架构 07_EnemyModule §10
[SerializeField] BoxCollider2D _arenaBlocker; // ⚠️ 架构 07_EnemyModule §10
[Header("Boss 技能")]
[SerializeField] BossSkillSO[] _phaseOneSkills;
[SerializeField] BossSkillSO[] _phaseTwoSkills;
[SerializeField] BossSkillExecutor _executor;
[Header("Event Channels")]
[SerializeField] private StringEventChannelSO _onBossFightStarted; // ⚠️ 架构 07_EnemyModule §10 + 02 §4
int _currentPhase = 1;
CancellationTokenSource _cts;
// 由触发器或 GameManager 调用(架构 07_EnemyModule §10
public void StartBossFight()
{
_arenaBlocker.enabled = true;
_onBossFightStarted.Raise(_boss.BossId);
_boss.GetComponent<BehaviorTree>().EnableBehavior();
}
// BD Task 节点调用ExecuteSkillById(skillId)
public async UniTask ExecuteSkillById(string skillId)
{
var skills = _currentPhase == 1 ? _phaseOneSkills : _phaseTwoSkills;
var skill = System.Array.Find(skills, s => s.skillId == skillId);
if (skill == null) return;
_cts?.Cancel();
_cts = new CancellationTokenSource();
await _executor.ExecuteSkill(skill, _cts.Token);
}
public void EnterPhaseTwo() { _currentPhase = 2; _cts?.Cancel(); }
}
}
```
### 2.4 WeakPointSystem
```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;
/// <summary>统一激活/关闭所有弱点 HurtBox。由 BossSkillExecutor.Update() 驱动。</summary>
public void SetActive(bool active, float multiplier = 1f)
{
_damageMultiplier = multiplier;
foreach (var wp in _weakPoints)
{
wp.hurtBox.gameObject.SetActive(active);
if (wp.visualIndicator != null)
wp.visualIndicator.SetActive(active);
}
if (active) _onVulnerabilityWindowOpened?.Raise(_bossId);
}
/// <summary>弱点 HurtBox 受击时由 BossStats 调用,获取最终伤害倍率。</summary>
public float GetDamageMultiplier() => _damageMultiplier;
}
}
```
### 2.5 首个 Boss 完整资产
**Boss_Example 资产集**`Assets/Data/Enemies/Boss/Boss_Example/`
| 资产名 | 类型 | 内容 |
|--------|------|------|
| `BSS_Skill_Slash` | `BossSkillSO` | MeleeSlashvulnerabilityWindows[0]: TriggerDelay=0.5s, Duration=1.2s(⚠️ 绝对秒数,非归一化时间) |
| `BSS_Skill_Charge` | `BossSkillSO` | ChargeAttackWeakPoint 头部TriggerDelay=0.2s, Duration=1.5s |
| `BSS_Skill_LeapSlam` | `BossSkillSO` | LeapSlam落地后全身 VulnerabilityWindow: TriggerDelay=0.1s, Duration=1.0s |
| `BSS_Seq_Slash` | `SkillSequenceSO` | 3段斩击时序 |
| `BSS_Pat_Slash_*` | `AttackPatternSO` × 3 | 各段 HitBox 参数WindupDuration/ActiveDuration/RecoveryDuration|
---
## 3. Week 16叙事模块对话/过场/事件链)✅ 完成2026-05-11
**参考文档**`14_NarrativeModule.md`
### 3.0 DialogueLine + DialogueSequenceSO数据层
按依赖关系最先实现,`DialogueManager` / `InteractableNPC` 均依赖这两个数据类型。
```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_NpcDialogueCompletednpcIdQuestManager 订阅)
[SerializeField] private GameStateEventChannelSO _onGameStateChanged;
public bool IsDialogueActive { get; private set; }
private bool _skipRequested;
// 打字机效果:每字符 TypewriterDelay 延迟显示
// 按确认键:若未显示完毕则立即显示全部;若已完毕则进入下一行
// 所有行显示完毕:结束对话,恢复 Gameplay ActionMap
// ⚠️ 正确实现模式:使用 IEnumerator + StartCoroutine架构 14_NarrativeModule §3
// 正确 API: void StartDialogue(DialogueSequenceSO sequence) — 返回 void非 UniTask
public void StartDialogue(DialogueSequenceSO sequence)
{
// 1. 若 IsDialogueActive → 返回
if (IsDialogueActive) return;
// 2. 选择 ConditionalVariant查询 WorldStateRegistry
IsDialogueActive = true;
_dialogueBox.Show(); // 步骤 3先显示对话框
_inputReader.EnableUIInput(); // 步骤 4切换 ActionMap → UI先于 Raise
_onDialogueStarted.Raise(); // 步骤 5广播在 ActionMap 切换后)
StartCoroutine(PlaySequence(sequence)); // 步骤 6
}
// 玩家按下 Submit 键时InputReaderSO.SubmitEvent
private void OnSubmit() => _skipRequested = true;
private IEnumerator PlaySequence(DialogueSequenceSO sequence)
{
foreach (var line in sequence.Lines)
{
_skipRequested = false;
yield return _dialogueBox.TypeText(line.TextKey, line.TypewriterDelay);
// 等待玩家按 Submit 继续
yield return new WaitUntil(() => _skipRequested);
}
EndDialogue();
}
private void EndDialogue()
{
_dialogueBox.Hide();
IsDialogueActive = false;
_inputReader.EnableGameplayInput(); // 先恢复 ActionMap → GameplayArchitecture 14 §3先切换再广播
_onDialogueEnded.Raise();
}
// 分支选择(当 DialogueSequenceSO 有 ConditionalVariants 时)
public DialogueSequenceSO ResolveVariant(DialogueSequenceSO sequence);
}
```
**DialoguePanel**UI Toolkit 或 UGUI
- 文本框Text Mesh Pro
- 说话人姓名框
- 头像 Image
- 翻页箭头Blink 动画)
### 3.2 InteractableNPC + NarrativeNPC
```csharp
// Assets/Scripts/Dialogue/InteractableNPC.cs
// 实现 IInteractableBaseGames.World 命名空间)
public class InteractableNPC : MonoBehaviour, IInteractable
{
[SerializeField] private string _npcId;
[SerializeField] private DialogueSequenceSO _defaultDialogue;
[SerializeField] private float _interactRadius = 1.5f;
// ⚠️ 通过场景内注入(或 Find不使用 DialogueManager.Instance 单例(架构 14_NarrativeModule §6
private DialogueManager _dialogueManager;
public bool CanInteract => true;
public string InteractPrompt => "对话"; // ⚠️ 架构 14 §6提示文本为 "对话"(非 "按 [X] 对话"
public void Interact(Transform player) // ⚠️ 参数为 Transform架构 14_NarrativeModule §1
{
Interact_Internal(player); // ⚠️ 先调用子类扩展钩子(架构 14 §6原 Plan 遗漏)
_dialogueManager.StartDialogue(GetCurrentDialogue()); // ⚠️ 通过虚方法获取对话,支持子类重写(原 Plan 遗漏)
}
/// <summary>子类覆盖此方法以在对话前注入额外逻辑(如接受/完成任务)。</summary>
protected virtual void Interact_Internal(Transform player) { } // ⚠️ 架构 14 §6原 Plan 遗漏)
/// <summary>子类覆盖此方法以根据游戏状态返回不同的对话 SO见 NarrativeNPC。</summary>
protected virtual DialogueSequenceSO GetCurrentDialogue() => _defaultDialogue; // ⚠️ 架构 14 §6原 Plan 遗漏)
public void OnPlayerEnterRange(Transform player) { } // 交互提示 UI 由 InteractableDetector 统一发布
public void OnPlayerExitRange() { } // 交互提示 UI 由 InteractableDetector 统一发布
}
```
```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<Gamepad>().Any(g => g.enabled);
_icon.sprite = isGamepad ? _gamepadIcon : _keyboardIcon;
}
public void Hide() => _promptRoot.SetActive(false);
}
```
> **检测逻辑**`InteractableDetector`(挂在 Player 子节点)通过 `CircleCollider2D` 检测范围内的 `IInteractable`,调用 `interactable.GetComponent<InteractionPromptController>()?.Show()/Hide()`。
### 3.3 EventChain + EventChainManager
```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<string> OnBossDefeated;
public event Action<string> OnCollectiblePickedUp;
public event Action<string> OnAbilityUnlocked;
public event Action<string> OnRoomEntered;
public event Action<string> OnDialogueCompleted;
public event Action<string> OnChainCompleted; // 链完成时广播 chainId供 ChainCompletedCondition
readonly HashSet<string> _completedChains = new();
void Awake()
{
// ⚠️ 从 SaveData 恢复已完成链 ID原 Plan 遗漏)
foreach (var id in SaveManager.Instance.GetCompletedChains())
_completedChains.Add(id);
}
void OnEnable()
{
_onBossDefeated.OnEventRaised += id => { OnBossDefeated?.Invoke(id); EvaluateAll(); };
_onCollectiblePickedUp.OnEventRaised += id => { OnCollectiblePickedUp?.Invoke(id); EvaluateAll(); };
_onAbilityUnlocked.OnEventRaised += id => { OnAbilityUnlocked?.Invoke(id); EvaluateAll(); };
_onRoomEntered.OnEventRaised += id => { OnRoomEntered?.Invoke(id); EvaluateAll(); };
_onDialogueCompleted.OnEventRaised += id => { OnDialogueCompleted?.Invoke(id); EvaluateAll(); };
// ⚠️ 向每个 Condition 注册中继事件(原 Plan 遗漏)
foreach (var chain in _chains)
foreach (var cond in chain.conditions)
cond.Register(this);
}
void OnDisable()
{
foreach (var chain in _chains)
foreach (var cond in chain.conditions)
cond.Unregister(this);
}
void EvaluateAll()
{
foreach (var chain in _chains)
{
// ⚠️ 使用 repeatable 字段(原 Plan 错误使用 oneShot
if (!chain.repeatable && _completedChains.Contains(chain.chainId))
continue;
if (Array.TrueForAll(chain.conditions, c => c.IsMet()))
StartCoroutine(ExecuteChain(chain));
}
}
IEnumerator ExecuteChain(EventChainSO chain)
{
if (!chain.repeatable)
_completedChains.Add(chain.chainId);
foreach (var action in chain.actions)
{
yield return action.ExecuteAsync(this); // ⚠️ ExecuteAsync(MonoBehaviour)(原 PlanExecute(EventChainContext)
// ⚠️ actionDelay 支持(原 Plan 遗漏)
if (chain.actionDelay > 0f)
yield return new WaitForSeconds(chain.actionDelay);
}
// ⚠️ 存档集成(原 Plan 遗漏)
SaveManager.Instance.SetChainCompleted(chain.chainId);
OnChainCompleted?.Invoke(chain.chainId);
}
}
```
### 3.4 CutsceneManager + CutsceneSO + CutsceneTrigger
```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<PlayableDirector>();
// ⚠️ 参数为 CutsceneSO非 TimelineAsset架构 14 §7需应用 Track→GameObject 绑定(原 Plan 遗漏)
public void PlayCutscene(CutsceneSO cutscene)
{
if (cutscene == null) return;
_director.playableAsset = cutscene.Timeline;
// ⚠️ 应用 Track → GameObject 绑定(原 Plan 遗漏,架构 14 §7
foreach (var binding in cutscene.Bindings)
{
var track = cutscene.Timeline.GetOutputTrack(
System.Array.FindIndex(cutscene.Bindings, b => b.trackName == binding.trackName));
if (track != null && binding.target != null)
_director.SetGenericBinding(track, binding.target);
}
_director.stopped += OnCutsceneStopped;
_director.Play();
_onCutsceneStarted.Raise();
// 禁用 Gameplay 输入
}
public void StopCutscene() => _director.Stop();
private void OnCutsceneStopped(PlayableDirector d)
{
_director.stopped -= OnCutsceneStopped;
_onCutsceneEnded.Raise();
// 恢复 Gameplay 输入
}
}
// Assets/Scripts/Cutscene/CutsceneSO.cs
[CreateAssetMenu(menuName = "Cutscene/Cutscene")]
public class CutsceneSO : ScriptableObject
{
[Header("Identity")]
public string cutsceneId;
public string displayName; // ⚠️ 架构 14 §11.5(原 Plan 遗漏)
public bool playOnlyOnce; // true → 仅首次播放
public bool isSkippable = true; // ⚠️ 架构 14 §11.5(原 Plan 遗漏)
public Sprite thumbnail; // ⚠️ 过场预览图(架构 14 §11.5,原 Plan 遗漏)
[Header("Timeline")]
public TimelineAsset Timeline;
[Header("Timeline Bindings")]
// ⚠️ Track→GameObject 绑定(架构 14 §11.5,原 Plan 遗漏CutsceneManager.PlayCutscene 遍历此数组设置 binding
public CutsceneBinding[] Bindings;
[Header("Camera")]
public CinemachineBlendDefinition BlendIn;
public CinemachineBlendDefinition BlendOut;
[Header("Optional Dialogue Overlay")]
public DialogueSequenceSO[] DialogueLayers; // 过场中叠加播放的对话层
}
// ⚠️ 架构 14 §11.5(原 Plan 遗漏):将一条 Timeline Track 绑定到运行时场景对象
[Serializable]
public struct CutsceneBinding
{
[Tooltip("Timeline Track 的名称(需与 PlayableDirector 内轨道名一致)")]
public string trackName;
[Tooltip("绑定的目标对象;若为 null 则 CutsceneManager 会从场景中按 tag/name 查找")]
public Object target; // UnityEngine.Object可以是 GameObject / Component / asset
}
// Assets/Scripts/Cutscene/CutsceneTrigger.cs
// 实现 IInteractableOnInteract 模式)
public class CutsceneTrigger : MonoBehaviour, IInteractable
{
public enum TriggerMode
{
OnEnter, // ⚠️ 架构 14 §11.5OnEnter原 Plan 错误使用 OnZoneEnter
OnInteract, // 玩家主动交互IInteractable
OnSceneLoad, // 场景加载完毕Start
OnEvent, // ⚠️ 架构 14 §11.5(原 Plan 遗漏):订阅事件频道触发(配合 _triggerEventChannel
}
[SerializeField] private CutsceneSO _cutscene;
[SerializeField] private TriggerMode _mode = TriggerMode.OnEnter;
[SerializeField] private CutsceneManager _cutsceneManager;
[SerializeField] private VoidEventChannelSO _triggerEventChannel; // ⚠️ OnEvent 模式用(原 Plan 遗漏,架构 14 §11.5
// ⚠️ SO 注入(架构 14_NarrativeModule §11.5 patch不使用 WorldStateRegistry.Instance
[SerializeField] private WorldStateRegistry _worldState;
public bool CanInteract => _mode == TriggerMode.OnInteract;
public string InteractPrompt => "查看";
public void Interact(Transform player) => TriggerCutscene();
public void OnPlayerEnterRange(Transform player) { }
public void OnPlayerExitRange() { }
private void OnEnable()
{
// ⚠️ OnEvent 模式(原 Plan 遗漏)
if (_mode == TriggerMode.OnEvent && _triggerEventChannel != null)
_triggerEventChannel.OnEventRaised += TriggerCutscene;
}
private void OnDisable()
{
if (_mode == TriggerMode.OnEvent && _triggerEventChannel != null)
_triggerEventChannel.OnEventRaised -= TriggerCutscene;
}
private void OnTriggerEnter2D(Collider2D other)
{
if (_mode != TriggerMode.OnEnter || !other.CompareTag("Player")) return; // ⚠️ OnEnter非 OnZoneEnter
TriggerCutscene();
}
private void Start()
{
if (_mode == TriggerMode.OnSceneLoad) TriggerCutscene();
}
private void TriggerCutscene()
{
if (_cutscene == null) return;
// ⚠️ SO 注入(非 Instance+ HasFlag非 IsFlagSet
if (_cutscene.playOnlyOnce &&
_worldState != null && _worldState.HasFlag($"cutscene_played_{_cutscene.cutsceneId}"))
return;
_cutsceneManager.PlayCutscene(_cutscene); // ⚠️ 传入 CutsceneSO原 Plan 错误传 _cutscene.Timeline架构 14 §7
_worldState?.SetFlag($"cutscene_played_{_cutscene.cutsceneId}");
if (_mode == TriggerMode.OnEnter) enabled = false; // ⚠️ OnEnter非 OnZoneEnter
}
}
```
**资产路径**`Assets/ScriptableObjects/Cutscene/`
**命名规范**`CS_{SceneId}_{ContextId}.asset`
### 3.5 SignalEmitterClip — Timeline 零耦合事件桥接(⚠️ 架构 14 §11.6,原 Plan 遗漏)
```csharp
// Assets/Scripts/Cutscene/SignalEmitterClip.cs
// ⚠️ 架构 14 §11.6(原 Plan 遗漏Timeline 过场通过此 PlayableAsset 发布 SO 事件频道,保持零耦合
// 在 Timeline 轨道上放置此 ClipClip 播放时向目标事件频道 Raise 一次事件
[CreateAssetMenu(menuName = "BaseGames/Cutscene/SignalEmitterClip")]
public class SignalEmitterClip : PlayableAsset, ITimelineClipAsset
{
[SerializeField] private EventChannelBaseSO _targetChannel; // 目标事件频道 SO
// ITimelineClipAsset
public ClipCaps clipCaps => ClipCaps.None;
public override Playable CreatePlayable(PlayableGraph graph, GameObject owner)
=> ScriptPlayable<SignalEmitterBehaviour>.Create(graph,
new SignalEmitterBehaviour { Clip = this });
}
// Assets/Scripts/Cutscene/SignalEmitterBehaviour.cs
public class SignalEmitterBehaviour : PlayableBehaviour
{
public SignalEmitterClip Clip;
private bool _fired;
public override void OnBehaviourPlay(Playable playable, FrameData info)
{
_fired = false; // 重置,支持 Timeline 循环/重播
}
public override void ProcessFrame(Playable playable, FrameData info, object playerData)
{
if (!_fired && Clip._targetChannel != null)
{
Clip._targetChannel.RaiseEvent();
_fired = true;
}
}
}
```
**使用场景示例**
- 过场第 3 秒触发 `EVT_BossCutscenePhase2` → BossOrchestrator 切换阶段
- 过场结束前 0.5 秒触发 `EVT_CutscenePreEnd` → HUD 开始淡入
### 3.6 叙事事件频道清单(架构 14 §12
| 资产名 | 类型 | Raise 方 | Subscribe 方 |
|--------|------|---------|-------------|
| `EVT_DialogueStarted` | `VoidEventChannelSO` | `DialogueManager` | `InputReaderSO`(切 UI 输入)、`PlayerController`(锁定输入)|
| `EVT_DialogueEnded` | `VoidEventChannelSO` | `DialogueManager` | `InputReaderSO`(切回 Gameplay|
| `EVT_CutsceneStarted` | `VoidEventChannelSO` | `CutsceneManager` | `HUDController`(隐藏 HUD`InputReaderSO` |
| `EVT_CutsceneEnded` | `VoidEventChannelSO` | `CutsceneManager` | `HUDController`(恢复 HUD|
---
## 4. Week 17UI 完整面板 + 按键重绑定 ✅ 完成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<GameObject> 管理面板层级(支持 ESC 逐层关闭)
private Stack<GameObject> _panelStack = new();
private void OnEnable()
{
_onGameStateChanged.OnEventRaised += HandleGameStateChanged;
_onPauseRequested.OnEventRaised += TogglePause;
_onFastTravelOpen.OnEventRaised += OpenMap;
_onShopOpen.OnEventRaised += OpenShop;
_onMapOpen.OnEventRaised += OpenMap;
}
private void OnDisable()
{
_onGameStateChanged.OnEventRaised -= HandleGameStateChanged;
_onPauseRequested.OnEventRaised -= TogglePause;
_onFastTravelOpen.OnEventRaised -= OpenMap;
_onShopOpen.OnEventRaised -= OpenShop;
_onMapOpen.OnEventRaised -= OpenMap;
}
private void HandleGameStateChanged(GameStateId state)
{
// HUD 在 Gameplay 和 BossFight 状态下均显示(⚠️ 非仅 Gameplay架构 10_UIModule §2
// ⚠️ GameStateId 是 struct不能用 switch用 if/else 比较(对齐架构 10 §2 patch
bool showHud = state == GameStates.Gameplay || state == GameStates.BossFight;
_hudRoot.SetActive(showHud);
if (state == GameStates.Dead)
_deathScreenRoot.SetActive(true); // 死亡状态显示死亡画面(架构 10 §2
else if (state == GameStates.Cutscene)
_hudRoot.SetActive(false); // 过场动画隐藏 HUD架构 10 §2
}
public void OpenPanel(GameObject panel)
{
if (_panelStack.Count > 0) _panelStack.Peek().SetActive(false);
panel.SetActive(true);
_panelStack.Push(panel);
}
public void CloseTopPanel()
{
if (_panelStack.Count == 0) return;
_panelStack.Pop().SetActive(false);
if (_panelStack.Count > 0) _panelStack.Peek().SetActive(true);
}
// 内部路由(由事件频道触发)
private void TogglePause() => OpenPanel(_pauseMenuRoot);
private void OpenShop(string npcId) => OpenPanel(_shopRoot);
private void OpenMap() => OpenPanel(_mapRoot);
}
```
### 4.2 各 Panel 内容要求
**PausePanel**
- 继续游戏Resume
- 设置(→ SettingsPanel
- 存档(调用 SaveManager.SaveAsync
- 返回主菜单(二次确认 → GameManager.ReturnToMainMenu
- 退出游戏(二次确认 → Application.Quit
**InventoryPanel**
- 护符槽可视化(总凹槽 N 个,已用 M 个)
- 拥有的护符列表(可装备/卸载)
- 工具槽Slot0/Slot1拖拽或按键分配
- 当前形态图标 + 形态切换按钮
**SettingsPanel**
- 音量滑条Master/BGM/SFX → AudioManager
- 分辨率/全屏SettingsManager
- 语言下拉LocalizationManager.SetLocale
- 按键重绑定区域(→ RebindPanel
**SaveSlotController + SaveSlotUI**(架构 10 §7.5
- 主菜单展示 3 个存档卡片;每卡展示角色名/场景/时间戳
- 调用 `SaveManager.GetSlotSummaryAsync()` 异步加载占位数据
- 支持新建游戏 / 加载 / 删除存档(二次确认)
**LoadingOverlay**(架构 10 §8
- 订阅 `EVT_LoadingOverlay`BoolEventChannelSOtrue=显示/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`BoolEventChannelSOtrue=手柄/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<RebindActionRow> _onRebindRequested;
public void Initialize(InputReaderSO reader, ConflictDetector detector, Action<RebindActionRow> onRequest)
{
_inputReader = reader; _conflictDetector = detector; _onRebindRequested = onRequest;
_bindButton.onClick.AddListener(() => _onRebindRequested?.Invoke(this));
RefreshDisplay();
}
public void StartRebind(Action onFinished)
{
_currentBindingText.text = "按下新按键…";
// ⚠️ 通过 InputReaderSO.StartRebinding(),非直接调用 PerformInteractiveRebinding
_inputReader.StartRebinding(_actionName, _bindingIndex,
onComplete: () => { RefreshDisplay(); CheckConflicts(); onFinished?.Invoke(); },
onCancel: () => { RefreshDisplay(); onFinished?.Invoke(); });
}
public void RefreshDisplay()
{
var action = _inputReader.FindAction(_actionName);
_currentBindingText.text = action != null
? InputControlPath.ToHumanReadableString(
action.bindings[_bindingIndex].effectivePath,
InputControlPath.HumanReadableStringOptions.OmitDevice)
: "—";
}
public void SetInteractable(bool v) => _bindButton.interactable = v;
private void CheckConflicts()
{
var conflicts = _conflictDetector.FindConflicts(_inputReader.GetAllActionMap());
foreach (var row in FindObjectsOfType<RebindActionRow>())
row.SetConflictHighlight(conflicts.Contains(row._actionName));
}
public void SetConflictHighlight(bool conflict)
=> _currentBindingText.color = conflict ? Color.red : Color.white;
}
// Assets/Scripts/Input/ConflictDetector.cs
// 检测同一按键路径绑定了多个 Action
public class ConflictDetector : MonoBehaviour
{
public HashSet<string> FindConflicts(IEnumerable<InputAction> actions)
{
var pathToActions = new Dictionary<string, List<string>>();
foreach (var action in actions)
foreach (var binding in action.bindings)
{
if (binding.isComposite || string.IsNullOrEmpty(binding.effectivePath)) continue;
if (!pathToActions.ContainsKey(binding.effectivePath))
pathToActions[binding.effectivePath] = new List<string>();
pathToActions[binding.effectivePath].Add(action.name);
}
var conflicted = new HashSet<string>();
foreach (var kv in pathToActions)
if (kv.Value.Count > 1)
foreach (var name in kv.Value)
conflicted.Add(name);
return conflicted;
}
}
```
---
### 4.4 HUDController 完整实现Architecture 10 §3
```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 + DeathScreenControllerArchitecture 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_BossFightToggledtrue=战斗开始false=结束)
[SerializeField] private IntEventChannelSO _onBossHPChanged; // EVT_BossHPChanged当前 HP 整数)
[SerializeField] private StringEventChannelSO _onBossNameSet; // EVT_BossNameSetBoss 名称 string
[SerializeField] private IntEventChannelSO _onBossHPMaxSet; // EVT_BossHPMaxSet最大 HP 整数)
private int _maxHP = 1;
private void OnEnable()
{
_onBossFightToggled.OnEventRaised += OnFightToggled;
_onBossHPChanged.OnEventRaised += OnHPChanged;
_onBossNameSet.OnEventRaised += OnNameSet;
_onBossHPMaxSet.OnEventRaised += hp => _maxHP = hp;
}
private void OnDisable()
{
_onBossFightToggled.OnEventRaised -= OnFightToggled;
_onBossHPChanged.OnEventRaised -= OnHPChanged;
_onBossNameSet.OnEventRaised -= OnNameSet;
_onBossHPMaxSet.OnEventRaised -= hp => _maxHP = hp;
}
private void OnFightToggled(bool started)
{
// ⚠️ 架构 10 §4使用 SlideIn/SlideOut 协程动画(原 Plan 错误使用 SetActive
if (started) StartCoroutine(SlideIn());
else StartCoroutine(SlideOut());
}
private void OnHPChanged(int hp) => _hpFill.fillAmount = _maxHP > 0 ? (float)hp / _maxHP : 0f;
private void OnNameSet(string bossName) { _bossNameText.text = bossName; } // 构建阶段标记,启用 GO
private IEnumerator SlideIn(); // 动画Boss 血条从屏幕底部滑入
private IEnumerator SlideOut(); // 动画Boss 血条滑出并隐藏
}
// Assets/Scripts/UI/Menus/PauseMenuController.cs
public class PauseMenuController : MonoBehaviour
{
[SerializeField] private UIManager _uiManager;
[SerializeField] private Button _btnResume;
[SerializeField] private Button _btnSettings;
[SerializeField] private Button _btnMainMenu;
[SerializeField] private Button _btnQuit;
[SerializeField] private GameObject _settingsRoot;
[Header("Event Channels")]
[SerializeField] private GameStateEventChannelSO _onGameStateChanged; // ⚠️ 架构 10 §5原 Plan 遗漏)
[SerializeField] private VoidEventChannelSO _onResumeRequested;
private void Awake()
{
_btnResume.onClick.AddListener(Resume);
_btnSettings.onClick.AddListener(() => _uiManager.OpenPanel(_settingsRoot));
_btnMainMenu.onClick.AddListener(GoToMainMenu);
_btnQuit.onClick.AddListener(Application.Quit);
}
private void Resume()
{
_onResumeRequested.Raise();
_uiManager.CloseTopPanel();
}
private void GoToMainMenu();
// 发布 EVT_SceneLoadRequest目标 = MainMenuScene
}
// Assets/Scripts/UI/Menus/DeathScreenController.cs
// ⚠️ 类名 DeathScreenController非 DeathPanel路径 UI/Menus/(非 UI/Panels/
public class DeathScreenController : MonoBehaviour
{
[SerializeField] private TMP_Text _deathMessage;
[SerializeField] private Button _btnRespawn;
[Header("Event Channels")]
[SerializeField] private VoidEventChannelSO _onPlayerDied;
[SerializeField] private VoidEventChannelSO _onDeathScreenConfirmed;
private void OnEnable() => _onPlayerDied.OnEventRaised += OnPlayerDied;
private void OnDisable() => _onPlayerDied.OnEventRaised -= OnPlayerDied;
// ⚠️ 必须延迟 1.5sArchitecture 10 §6 修正AI-82死亡动画播完后再显示
private void OnPlayerDied() => StartCoroutine(ShowAfterDelay(1.5f));
private IEnumerator ShowAfterDelay(float delay)
{
yield return new WaitForSeconds(delay);
Show();
}
private void Show()
{
gameObject.SetActive(true);
_btnRespawn.onClick.RemoveAllListeners();
_btnRespawn.onClick.AddListener(Confirm);
}
private void Confirm()
{
gameObject.SetActive(false);
_onDeathScreenConfirmed.Raise();
}
}
```
---
### 4.6 SettingsPanelController + LoadingOverlayArchitecture 10 §7§8
```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_LoadingOverlayBoolEventChannelSO控制全屏黑幕渐入渐出
public class LoadingOverlay : MonoBehaviour
{
[SerializeField] private CanvasGroup _canvasGroup;
[SerializeField] private float _fadeDuration = 0.3f;
[Header("Event Channels")]
[SerializeField] private BoolEventChannelSO _onLoadingOverlayRequested;
private void OnEnable() => _onLoadingOverlayRequested.OnEventRaised += SetVisible;
private void OnDisable() => _onLoadingOverlayRequested.OnEventRaised -= SetVisible;
private void SetVisible(bool visible) => StartCoroutine(FadeCoroutine(visible ? 1f : 0f));
private IEnumerator FadeCoroutine(float target)
{
float start = _canvasGroup.alpha;
float t = 0;
while (t < _fadeDuration)
{
_canvasGroup.alpha = Mathf.Lerp(start, target, t / _fadeDuration);
t += Time.unscaledDeltaTime;
yield return null;
}
_canvasGroup.alpha = target;
_canvasGroup.blocksRaycasts = target > 0.5f;
}
}
```
---
### 4.7 FloatingDamageText + ToastManager + InputDeviceIconSwitcherArchitecture 10 §10§12
```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<InputIconImage>())
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; // 01
private void OnEnable()
{
_onLoadingStarted.OnEventRaised += Show;
_onLoadingComplete.OnEventRaised += Hide;
_onLoadingProgressUpdated.OnEventRaised += SetProgress;
}
private void OnDisable()
{
_onLoadingStarted.OnEventRaised -= Show;
_onLoadingComplete.OnEventRaised -= Hide;
_onLoadingProgressUpdated.OnEventRaised -= SetProgress;
}
private void Show()
{
_loadingRoot.SetActive(true);
_progressFill.fillAmount = 0f;
foreach (var bg in _backgroundArts) bg.enabled = false;
_backgroundArts[Random.Range(0, _backgroundArts.Length)].enabled = true;
_tipText.text = LocalizationManager.Get("UI", _tipKeys[Random.Range(0, _tipKeys.Length)]);
}
private void Hide() => StartCoroutine(HideAfterMinTime());
private void SetProgress(float v) => _progressFill.fillAmount = v;
private IEnumerator HideAfterMinTime()
{
yield return new WaitForSecondsRealtime(_minDisplayTime);
_loadingRoot.SetActive(false);
}
}
// Assets/Scripts/UI/HUD/IBossHPProvider.cs
// ⚠️ 架构 10 §7.8(原 Plan 遗漏):解耦接口,让 BossHPBar 不直接依赖 BossBase
public interface IBossHPProvider
{
string BossId { get; }
string BossNameKey { get; }
float HPRatio { get; } // 01 实时 HP 比例
int TotalPhases { get; }
float[] PhaseThresholds { get; } // 各阶段切换 HP 阈值
}
// Assets/Scripts/UI/DialogueBox.cs
// ⚠️ 架构 10 §9原 Plan 遗漏):对话框组件,由 DialogueManager 直接调用(不经过事件频道)
// 挂载在 Canvas_Overlay/DialogueBox 子对象
public class DialogueBox : MonoBehaviour
{
[SerializeField] private TMP_Text _speakerNameText;
[SerializeField] private TMP_Text _dialogueText;
[SerializeField] private GameObject _continuePrompt;
public void Show(string speakerName, string text, bool showContinue);
public void Hide();
// DialogueManager 在 PlaySequence 中 yield return 此协程(实现打字机效果)
public IEnumerator TypeText(string text, float charDelay = 0.03f);
}
```
---
## 5. Week 18支撑模块 + 编辑器工具 + QA ✅ 完成2026-05-11
**参考文档**`16_SupportingModules.md`
> **✅ P4-5 验证状态(已完成验证)**
> 所有支撑模块文件已按架构 §1-§9 逐一对比,以下为最终裁定:
>
> | 模块 | 状态 | 说明 |
> |------|------|------|
> | `IPlatformService` | ✅ 已修复 | 补充了 `RunCallbacks`/`Shutdown`/`SetStat`/`IncrementStat`/`GetStat`/`IsCloudAvailable`/`CloudSaveAsync`/`CloudLoadAsync`/`SetRichPresence`/`ClearRichPresence`;保留代码额外扩展(排行榜/DLC/ShowOverlay/ClearAchievement|
> | `PlatformBootstrap` | ✅ 已修复 | 增加 `Update()→RunCallbacks()` + `OnApplicationQuit()→Shutdown()`|
> | `NullPlatformService` | ✅ 已更新 | 实现完整接口 |
> | `SteamPlatformService` | ✅ 已更新 | 增加 SetStat/IncrementStat/GetStat、CloudSaveAsync/CloudLoadAsync、SetRichPresence/ClearRichPresence、RunCallbacks/Shutdown |
> | `AnalyticsManager` | ✅ 已修复 | Awake 中添加 `#if !UNITY_EDITOR && !DEVELOPMENT_BUILD` 保护Release 包默认关闭);保留缓冲 JSON 格式(非架构 JSONL可接受|
> | `AchievementCondition` | ✅ 维持代码 | `IsMet(SaveData)` 轮询比架构的 RegisterListeners 事件模式更简洁;`GetProgress(SaveData)` 为额外增强 |
> | `AchievementManager` | ✅ 维持代码 | `ServiceLocator.Get<IPlatformService>()` 优于架构的 `#if STEAMWORKS_NET PlatformManager` 静态调用 |
> | `LocalizationManager` | ✅ 维持代码 | `Get(entryKey, tableName)` 参数顺序更符合调用习惯;`#if UNITY_LOCALIZATION` 守卫必要(包未安装)|
> | `LanguageManagerSO` | ✅ 维持代码 | 字段命名与 `ApplySaved()` 方法名不同于架构,但功能等价且更清晰 |
> | `AccessibilitySettingsSO` | ✅ 维持代码(简化版)| 架构字段更丰富(字幕/输入辅助/音频),当前代码仅实现核心子集;后续 P5/P6 可扩展 |
> | `AccessibilityManager` | ✅ 维持代码 | 缺少 `_onHighContrastChanged`/`_onSubtitlesChanged` 频道AccessibilitySettingsSO 简化对应);功能等价 |
> | `ColorBlindFilter` | ✅ 维持代码 | URP RenderFeature + Brettel/Viénot 色彩矩阵实现正确 |
> | `AntiSoftlockSystem` | ✅ 维持代码 | 代码用 `_stuckTimer + linearVelocity` 检测,比架构的位置距离对比更准确 |
> | `RoomEscapeInfoSO` | ✅ 维持代码(单路径版)| 架构用多路径 `EscapeRoute[]`,代码用单路径+优先级数组,更适合当前房间规模 |
> | `HardAbilityGate` | ✅ 维持代码 | 用 `World.Switches` key 验证物理拾取,比架构 `IsAbilityActuallyUnlocked()` 方法(不存在)更可行 |
> | `SpeedrunTimer` | ✅ 维持代码 | 代码有显式 Start/Pause/Resume/Stop API比架构订阅 `_onGameplayActive` 事件更灵活;后续可补事件订阅 |
### 5.1 LocalizationManager
`16_SupportingModules.md §1` 实现Unity Localization 包封装)。
```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<string> GetAsync(string tableKey, string entryKey)
{
var op = LocalizationSettings.StringDatabase.GetLocalizedStringAsync(tableKey, entryKey);
return await op.Task;
}
// 切换语言(由 SettingsPanelController 的语言下拉框调用)
public static void SetLocale(string localeCode)
{
var locale = LocalizationSettings.AvailableLocales.Locales
.FirstOrDefault(l => l.Identifier.Code == localeCode);
if (locale != null)
LocalizationSettings.SelectedLocale = locale;
}
// 快捷常量String Table 名称
public const string Table_UI = "UI";
public const string Table_Dialogue = "Dialogue";
public const string Table_Items = "Items";
public const string Table_Enemies = "Enemies";
}
```
### 5.1.1 LanguageManagerSO语言切换 SO 单例)
> **⚠️ 架构 16_SupportingModules §1.1 patch**:静态 `LocalizationManager` 仅做文本查询(无持久化);**语言设置持久化和设置界面切换**应使用 `LanguageManagerSO` SO 单例。
```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";
/// <summary>切换语言并持久化选择(替代 LocalizationManager.SetLocale后者不持久化</summary>
public void SetLocale(string localeCode)
{
var locale = LocalizationSettings.AvailableLocales.Locales
.FirstOrDefault(l => l.Identifier.Code == localeCode);
if (locale != null)
{
LocalizationSettings.SelectedLocale = locale;
PlayerPrefs.SetString(PrefKey, localeCode);
}
}
/// <summary>获取当前语言代码(默认 zh-CN</summary>
public string GetCurrentLocaleCode()
=> LocalizationSettings.SelectedLocale?.Identifier.Code ?? "zh-CN";
/// <summary>游戏启动时从 PlayerPrefs 读取上次选择的语言(由 GameManager.Awake 调用)</summary>
public void LoadSavedLocale()
=> SetLocale(PlayerPrefs.GetString(PrefKey, "zh-CN"));
}
```
> **注意**`SettingsPanelController` 应改用 `[SerializeField] LanguageManagerSO _languageManager;` + `_languageManager.SetLocale(code)` 而非直接调用 `LocalizationManager.SetLocale()`。
**最小本地化内容**Phase 4 必须):
- UI String Table所有 UI 按钮/标签文本)
- Dialogue String Table所有 NPC 对话行)
- Items String Table护符/工具名称 + 描述)
- Enemies String Table敌人名称 架构 16_SupportingModules §1 定义 `Table_Enemies = "Enemies"`
- 支持语言简体中文zh-CN+ 英文en
### 5.2 AchievementManager
> **⚠️ 架构 16_SupportingModules §2 完整实现**:采用 `AchievementSO` + `AchievementCondition` **ScriptableObject 策略模式****彻底废弃** `AchievementDef` + `AchievementDatabaseSO` 旧方案。
#### 5.2.1 AchievementSO成就数据 SO
```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 AchievementConditionScriptableObject 策略模式)
```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<string> OnBossDefeated;
public event Action<string> OnCollectiblePickedUp;
public event Action<int> OnAbilityUnlocked;
public event Action<string> OnRoomEntered;
public event Action OnParrySuccess;
public event Action OnNailClash;
readonly Dictionary<string, AchievementRuntimeState> _states = new();
void Awake()
{
foreach (var ach in _allAchievements)
_states[ach.achievementId] = new AchievementRuntimeState(ach);
}
void OnEnable()
{
_onBossDefeated.OnEventRaised += id => { OnBossDefeated?.Invoke(id); EvaluateAll(); };
_onCollectiblePickedUp.OnEventRaised += id => { OnCollectiblePickedUp?.Invoke(id); EvaluateAll(); };
_onAbilityUnlocked.OnEventRaised += v => { OnAbilityUnlocked?.Invoke(v); EvaluateAll(); };
_onRoomEntered.OnEventRaised += id => { OnRoomEntered?.Invoke(id); EvaluateAll(); };
_onParrySuccess.OnEventRaised += () => { OnParrySuccess?.Invoke(); EvaluateAll(); };
_onNailClash.OnEventRaised += () => { OnNailClash?.Invoke(); EvaluateAll(); };
foreach (var ach in _allAchievements)
foreach (var cond in ach.conditions)
cond.RegisterListeners(this);
}
void OnDisable()
{
foreach (var ach in _allAchievements)
foreach (var cond in ach.conditions)
cond.UnregisterListeners(this);
}
void EvaluateAll()
{
foreach (var ach in _allAchievements)
{
var state = _states[ach.achievementId];
if (state.IsUnlocked) continue;
if (Array.TrueForAll(ach.conditions, c => c.IsMet(state)))
Unlock(ach, state);
}
}
void Unlock(AchievementSO ach, AchievementRuntimeState state)
{
state.IsUnlocked = true;
_onAchievementUnlocked.Raise(ach); // → AchievementToast + Analytics
// ⚠️ 通过 ServiceLocator 获取PlatformManager 静态类不在架构中)
#if STEAMWORKS_NET
ServiceLocator.Get<IPlatformService>()?.UnlockAchievement(ach.achievementId);
#endif
}
// ── ISaveable ────────────────────────────────────────────────────
public void OnSave(SaveData data)
{
data.Achievements.Unlocked = _states
.Where(kv => kv.Value.IsUnlocked)
.Select(kv => kv.Key)
.ToList();
}
public void OnLoad(SaveData data)
{
foreach (var id in data.Achievements.Unlocked)
if (_states.TryGetValue(id, out var state))
state.IsUnlocked = true;
}
}
/// <summary>单个成就的运行时状态(条件满足记录 + 解锁状态)</summary>
public class AchievementRuntimeState
{
public bool IsUnlocked { get; set; }
readonly HashSet<AchievementCondition> _metConditions = new();
public AchievementRuntimeState(AchievementSO ach) { } // 初始状态:未解锁
public void SetConditionMet(AchievementCondition cond) => _metConditions.Add(cond);
public bool IsConditionMet(AchievementCondition cond) => _metConditions.Contains(cond);
}
}
```
**Phase 4 最小成就集**(验证系统可用):
| achievementId | AchievementCondition 类型 |
|--------------|---------|
| `ACH_FirstKill` | `EventTriggeredCondition``EVT_EnemyDied`|
| `ACH_FirstBoss` | `DefeatedBossCondition`(首个 Boss id|
| `ACH_Collector` | `CollectedAllCharmsCondition``MapExplorationCondition` |
| `ACH_Speedrunner` | `TimedBossKillCondition`(最终 Boss + maxSeconds |
### 5.3 PlatformBootstrap + IPlatformServiceSteam 集成)
> **⚠️ 架构 16_SupportingModules §3 完整接口**:旧版 `IPlatformService` 缺少 `IncrementStat`、`GetStat`、`IsAchievementUnlocked`、RichPresence、振动、生命周期方法云存档方法签名已变更返回 `Task<bool>` / `Task<byte[]>`)。
> **⚠️ 注入方式变更**:不使用 `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<string> ReadCloudSaveAsync
Task<bool> CloudSaveAsync(string fileName, byte[] data);
Task<byte[]> CloudLoadAsync(string fileName);
bool IsCloudAvailable { get; } // ⚠️ 旧版为 CloudSaveExists(string) 方法
// ── Rich Presence ──────────────────────────────────────────────────
void SetRichPresence(string key, string value); // ⚠️ 旧版缺失
void ClearRichPresence(); // ⚠️ 旧版缺失
// ── 振动 ────────────────────────────────────────────────────────────
void Rumble(float lowFreq, float highFreq, float duration); // ⚠️ 旧版缺失
void StopRumble(); // ⚠️ 旧版缺失
// ── 生命周期 ────────────────────────────────────────────────────────
void Initialize(); // ⚠️ 旧版缺失;由 PlatformBootstrap.Awake 调用
void RunCallbacks(); // ⚠️ 旧版缺失;由 PlatformBootstrap.Update 每帧调用
void Shutdown(); // ⚠️ 旧版缺失;由 PlatformBootstrap.OnApplicationQuit 调用
}
}
```
```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<bool> CloudSaveAsync(string fileName, byte[] data)
{
if (!IsCloudAvailable) return false;
return await Task.Run(() =>
SteamRemoteStorage.FileWrite(fileName, data, data.Length));
}
public async Task<byte[]> CloudLoadAsync(string fileName)
{
if (!IsCloudAvailable || !SteamRemoteStorage.FileExists(fileName))
return null;
int size = SteamRemoteStorage.GetFileSize(fileName);
var buf = new byte[size];
await Task.Run(() => SteamRemoteStorage.FileRead(fileName, buf, size));
return buf;
}
// ── Rich Presence ──────────────────────────────────────────────────
public void SetRichPresence(string k, string v) => SteamFriends.SetRichPresence(k, v);
public void ClearRichPresence() => SteamFriends.ClearRichPresence();
// ── 振动 ──────────────────────────────────────────────────────────
public void Rumble(float l, float h, float dur)
{
ushort lo = (ushort)(l * 65535);
ushort hi = (ushort)(h * 65535);
SteamController.TriggerVibration(SteamController.GetConnectedControllers()[0], lo, hi);
}
public void StopRumble() => Rumble(0f, 0f, 0f);
// ── 生命周期 ──────────────────────────────────────────────────────
public void Initialize() => SteamAPI.Init();
public void RunCallbacks() => SteamAPI.RunCallbacks();
public void Shutdown() => SteamAPI.Shutdown();
}
}
#endif
```
```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<bool> CloudSaveAsync(string f, byte[] d) => Task.FromResult(false);
public Task<byte[]> CloudLoadAsync(string f) => Task.FromResult<byte[]>(null);
public void SetRichPresence(string k, string v) { }
public void ClearRichPresence() { }
public void Rumble(float l, float h, float dur) { }
public void StopRumble() { }
public void Initialize() { }
public void RunCallbacks() { }
public void Shutdown() { }
}
}
```
```csharp
// 路径: Assets/Scripts/Support/Platform/PlatformBootstrap.cs
// ⚠️ 注入方式MonoBehaviour 挂在 Persistent 场景的 Bootstrap GameObject
// 使用 ServiceLocator.Register<IPlatformService>(service)(非旧版 PlatformManager 静态类)
// ⚠️ 旧版 PlatformManager 静态类(含 static _instance 字段)不在架构中,需替换
public class PlatformBootstrap : MonoBehaviour
{
void Awake()
{
IPlatformService service;
#if UNITY_STANDALONE && STEAMWORKS_NET
service = new SteamPlatformService();
#elif UNITY_SWITCH
service = new SwitchPlatformService(); // ⚠️ 架构 16 §3预留 Switch 平台支持(原 Plan 遗漏)
#else
service = new NullPlatformService();
#endif
service.Initialize();
ServiceLocator.Register<IPlatformService>(service);
}
void Update() => ServiceLocator.Get<IPlatformService>()?.RunCallbacks();
void OnApplicationQuit() => ServiceLocator.Get<IPlatformService>()?.Shutdown();
}
// 便捷访问AchievementManager 内部用)
// ⚠️ 通过 ServiceLocator 获取,不使用 PlatformManager.Service 静态属性
// ServiceLocator.Get<IPlatformService>().UnlockAchievement(id);
```
### 5.4 DebugCheatSystem
```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 <amount> / teleport <sceneName> / unlock <abilityId> / killall
private void ExecuteCommand(string cmd)
{
// ⚠️ PlayerController 无 Instance 单例Architecture 05 §2Debug 上下文用 FindObjectOfType
var player = FindObjectOfType<PlayerController>();
var parts = cmd.Split(' ');
switch (parts[0].ToLower())
{
case "godmode":
player?.Stats.SetGodMode(true);
break;
case "addgeo" when parts.Length > 1 && int.TryParse(parts[1], out var geo):
player?.Stats.AddGeo(geo);
break;
case "teleport" when parts.Length > 1:
// ⚠️ 通过事件频道触发SceneLoader 无 InstanceArchitecture 03 §3
_onSceneLoadRequest.Raise(new SceneLoadRequest
{ SceneName = parts[1], EntryTransitionId = "Default" });
break;
case "unlock" when parts.Length > 1:
player?.OnAbilityUnlocked(parts[1]);
break;
case "killall":
// ⚠️ DamageInfo 无单参数构造函数Architecture 06 §1使用 Builder 模式(架构 16 §4 patch
var killDmg = new DamageInfo.Builder().SetRaw(99999).Build();
foreach (var e in FindObjectsOfType<EnemyBase>()) e.TakeDamage(killDmg);
break;
default:
Debug.Log($"[Cheat] 未知命令: {cmd}");
break;
}
}
}
#endif
```
### 5.5 AntiSoftlockSystem
```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 无 InstanceArchitecture 05 §2AntiSoftlock 在 Persistent 场景,
// 通过 FindObjectOfType 获取(支撑系统可接受,非热路径)
var player = FindObjectOfType<PlayerController>();
if (player == null) return;
var playerPos = (Vector2)player.transform.position;
if (Vector2.Distance(playerPos, _lastPlayerPos) > 0.1f)
{
_lastPlayerPos = playerPos;
_idleTime = 0f;
_promptShown = false;
return;
}
_idleTime += Time.deltaTime;
if (_idleTime >= _softlockDetectionTime && !_promptShown)
{
_promptShown = true;
_onSoftlockDetected.Raise(); // UIManager 显示"是否传送到最近存档点"对话框
}
}
// 由 UI 确认按钮调用
public void TeleportToLastSavePoint()
{
// ⚠️ 通过事件频道触发SceneLoader 无 InstanceArchitecture 03 §3
_onSceneLoadRequest.Raise(new SceneLoadRequest
{
SceneName = SaveManager.LastCheckpointScene,
EntryTransitionId = SaveManager.LastCheckpointSpawnId,
IsRespawn = true
});
}
}
```
### 5.5.1 RoomEscapeInfoSO 架构 16_SupportingModules §5.1
```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
{
/// <summary>
/// 增强型能力门:除了检查能力标志,还检测玩家是否"物理上真的能做到"。
/// 用于防止 Sequence Break见 Design/49 §4.2)。
/// </summary>
public class HardAbilityGate : AbilityGate
{
[Header("额外物理验证")]
[SerializeField] bool _requirePhysicalValidation = false;
// 编辑器工具标记"此门已验证可能被绕过"
[SerializeField] bool _sequenceBreakRisk = false;
protected override bool EvaluateAccess()
{
if (!base.EvaluateAccess()) return false;
if (!_requirePhysicalValidation) return true;
// 检查能力实际已激活(非仅标志为 true
return _playerStats != null
&& _playerStats.IsAbilityActuallyUnlocked(_requiredAbility);
}
}
}
```
### 5.6 AccessibilityManager
> **⚠️ 架构 16_SupportingModules §6 完整实现**:使用独立的 `AccessibilitySettingsSO`(非 `GlobalSettingsSO``ColorBlindMode` 枚举含 5 个值(含 `Achromatopsia``AccessibilityManager.Instance` 单例模式;含 `CanPlayScreenShake()` 静态工具方法;含 `ColorBlindFilter` URP Renderer Feature。
#### AccessibilitySettingsSO数据容器
```csharp
// Assets/ScriptableObjects/Accessibility/AccessibilitySettings.asset
// ⚠️ 独立 SO非 GlobalSettingsSOmenuName = "Accessibility/AccessibilitySettings"(架构 16 §6
[CreateAssetMenu(menuName = "Accessibility/AccessibilitySettings")]
public class AccessibilitySettingsSO : ScriptableObject
{
// ── 视觉无障碍 ──────────────────────────────────────────────────────────
[Header("色盲模式")]
public ColorBlindMode colorBlindMode = ColorBlindMode.None;
public bool highContrastMode = false;
public float gameContrastBoost = 0f; // 0~1.0
// ── 运动无障碍 ──────────────────────────────────────────────────────────
[Header("运动敏感度")]
public bool disableScreenShake = false;
public bool disableCameraMotion = false;
public float cameraMotionScale = 1f; // 0~1.00 = 完全关闭)
public bool reduceParticleEffects = false;
public bool disableFlashingEffects = false;
public int flashFrequencyLimit = 3;
// ── 字幕 ────────────────────────────────────────────────────────────────
[Header("字幕系统")]
public bool subtitlesEnabled = false;
public bool sfxSubtitlesEnabled = false;
public float subtitleFontSizeMultiplier = 1f; // 0.75~2.0
public bool subtitleBackgroundEnabled = true;
public float subtitleBackgroundOpacity = 0.7f;
public bool speakerNameEnabled = true;
// ── 输入辅助 ────────────────────────────────────────────────────────────
[Header("输入辅助")]
public bool autoParryAssist = false;
public float parryWindowExtension = 0f; // 弹反窗口扩展0~0.2ParrySystem 读取此字段)
public bool holdToMash = false;
public bool stickyJump = false;
public bool autoClimb = false;
// ── 音频无障碍 ────────────────────────────────────────────────────────
[Header("音频无障碍")]
public bool monoAudio = false;
public float leftRightBalance = 0f; // -1~+1
public bool visualDangerIndicator = false;
}
// ⚠️ ColorBlindMode 枚举5 个值(含 Achromatopsia架构 16 §6
public enum ColorBlindMode
{
None, // 无(默认)
Protanopia, // 红色盲
Deuteranopia, // 绿色盲
Tritanopia, // 蓝黄色盲
Achromatopsia, // ⚠️ 全色盲(高对比灰度)(架构 16 §64 值版本遗漏此项)
}
```
#### AccessibilityManager
```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 ChannelsRaise 方)───────────────────────────────────────────
[SerializeField] private ColorBlindModeEventChannelSO _onColorBlindModeChanged; // ⚠️ 大写 B架构 16 §6
[SerializeField] private BoolEventChannelSO _onHighContrastChanged; // ⚠️ Changed非 Toggled
[SerializeField] private BoolEventChannelSO _onSubtitlesChanged; // ⚠️ Changed非 Toggled
[SerializeField] private BoolEventChannelSO _onScreenShakeChanged; // ⚠️ 架构 16 §7 清单
private void Awake()
{
if (Instance != null) { Destroy(gameObject); return; }
Instance = this;
DontDestroyOnLoad(gameObject);
}
// 由 SettingsPanelController 调用的公开 API架构 16 §6
public void ApplySettings()
{
_onColorBlindModeChanged.Raise(_settings.colorBlindMode);
_onHighContrastChanged.Raise(_settings.highContrastMode);
_onSubtitlesChanged.Raise(_settings.subtitlesEnabled);
_onScreenShakeChanged.Raise(!_settings.disableScreenShake);
}
public void SetColorBlindMode(ColorBlindMode mode)
{ _settings.colorBlindMode = mode; ApplySettings(); }
public void SetAutoParryAssist(bool v)
{ _settings.autoParryAssist = v; ApplySettings(); }
public void SetParryWindowExtension(float sec)
{ _settings.parryWindowExtension = sec; ApplySettings(); }
public void SetDisableScreenShake(bool v)
{ _settings.disableScreenShake = v; ApplySettings(); }
public void SetCameraMotionScale(float s)
{ _settings.cameraMotionScale = s; ApplySettings(); }
public void SetMonoAudio(bool v)
{ _settings.monoAudio = v; ApplySettings(); }
public void SetVisualDangerIndicator(bool v)
{ _settings.visualDangerIndicator = v; ApplySettings(); }
// ⚠️ 供 FeedbackSystem / ParrySystem 查询(静态方法)(架构 16 §6
public static bool CanPlayScreenShake()
=> Instance == null || !Instance.Settings.disableScreenShake;
}
```
> **parryWindowExtension 集成**`ParrySystem` 计算弹反窗口时读取此值:
> `float window = _config.ParryWindowDuration + (AccessibilityManager.Instance?.Settings.parryWindowExtension ?? 0f);`
#### ColorBlindFilterURP Renderer Feature
```csharp
// Assets/Scripts/Accessibility/ColorBlindFilter.cs
// ⚠️ URP 2D 后处理最终合成阶段应用色彩矩阵变换(架构 16 §6
// 在 URP 2D Renderer DataAssets/Settings/URP2DRenderer.asset中添加此 Feature
public class ColorBlindFilter : ScriptableRendererFeature
{
[SerializeField] ColorBlindMode _mode;
// 色彩矩阵3×3基于 Brettel et al. 1997 算法)
private static readonly Dictionary<ColorBlindMode, Matrix4x4> _matrices = new()
{
[ColorBlindMode.Protanopia] = new Matrix4x4(/*...*/),
[ColorBlindMode.Deuteranopia] = new Matrix4x4(/*...*/),
[ColorBlindMode.Tritanopia] = new Matrix4x4(/*...*/),
[ColorBlindMode.Achromatopsia] = new Matrix4x4(/*...*/),
};
public override void Create() { /* 初始化 RenderPass */ }
public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{
if (_mode == ColorBlindMode.None) return;
renderer.EnqueuePass(new ColorBlindPass(_matrices[_mode]));
}
// AccessibilityManager 订阅 EVT_ColorBlindModeChanged 后调用
public void SetMode(ColorBlindMode mode) => _mode = mode;
}
```
### 5.7 AnalyticsManager
```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<string, object> properties = null)
{
if (!enabled) return;
var payload = new Dictionary<string, object>
{
["event"] = eventName,
["timestamp"] = DateTime.UtcNow.ToString("o"),
["session"] = Time.realtimeSinceStartup
};
if (properties != null)
foreach (var kv in properties) payload[kv.Key] = kv.Value;
_writer?.WriteLine(JsonConvert.SerializeObject(payload));
}
public void TrackDeath(string sceneName, Vector2 position, string cause)
=> Track("player_death", new() { ["scene"] = sceneName, ["pos_x"] = position.x, ["pos_y"] = position.y, ["cause"] = cause });
public void TrackBossDefeated(string bossId, float elapsedSeconds)
=> Track("boss_defeated", new() { ["boss_id"] = bossId, ["time_s"] = elapsedSeconds });
public void TrackAbilityUnlocked(string abilityId)
=> Track("ability_unlocked", new() { ["ability"] = abilityId });
}
```
### 5.8 SpeedrunTimer
```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<string> 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<T>` 反射读取订阅者数 |
> | `PhantomPlate` | ✅ 已创建 | `Assets/Scripts/World/PhantomPlate.cs``namespace BaseGames.World``PlatformEffector2D` 单向穿透 + `TriggerDropThrough()` 下穿 APIEditor Gizmo 蓝色线框 |
> | `DestructibleTileEditor` | ✅ 已创建 | `Assets/Scripts/Editor/World/DestructibleTileEditor.cs``[DrawGizmo]` 橙红线框+半透明填充;`Handles.Label` "💥" 标签;选中/未选中透明度区分 |
> | `NavSurfaceBakeShortcut` | ✅ 已创建 | `Assets/Scripts/Editor/NavSurfaceBakeShortcut.cs``[MenuItem("BaseGames/Tools/Bake All NavSurfaces %#b")]``EditorApplication.update` 监听完成回调;打印每个 Surface 烘焙用时 |
> | `BossSkillSequenceWindow` | ✅ 已创建 | `Assets/Scripts/Editor/BossSkillSequenceWindow.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 60fps1080p
| 测试场景 | 目标 | 工具 |
|---------|------|------|
| 10 个敌人同时 Pathfinding | < 1ms PathBerserker2d 消耗 | Unity Profiler |
| 100 个粒子同时播放 | < 2ms VFX 消耗 | Profiler |
| 场景加载(中等房间) | < 2s含 FadeOut/FadeIn| 手动计时 |
| 存档读写 | < 200ms | Stopwatch |
| GC Alloc/帧 | < 1KBGameplay 稳定状态)| Profiler |
**功能回归测试清单**(所有 Phase 完成标准的并集)
---
## 6. 完成标准检查清单
```
□ BossSkillExecutor执行 LeapSlam 技能时 VulnerabilityWindow 后摇 1s 内弱点 HurtBox 激活
□ Boss Phase 切换HP 降至 50% → Phase 过渡动画 → 新技能集解锁
□ DialogueManager打字机效果 + 快进 + 最后一行结束后关闭对话框 + 恢复 Gameplay Map
□ InteractableNPC不同游戏进度对话切换正确ConditionalVariant 选择)
□ CutsceneManager播放 Timeline 期间玩家无法移动,播放结束后恢复控制
□ EventChain世界事件链对话+设置标志+奖励 Geo按顺序完整执行
□ PausePanelEscape 暂停 → PausePanel 显示 → 继续游戏恢复 Time.timeScale
□ InventoryPanel装备护符 → 效果生效 → 凹槽计数更新
□ SettingsPanel音量滑条调整 → AudioMixer 参数实时变化
□ RebindPanel重绑跳跃键 → 新键能正常触发跳跃 → 重启后绑定持久化(⚠️ 类名 RebindPanel架构 04 §6
□ LocalizationManager切换语言 → UI/对话/道具名称实时更新
□ AchievementManager首次击杀敌人 → ACH_FirstKill 解锁 → 存档记录 → 重启后不重复
□ DebugCheatSystem仅在 Editor/Development Build 中可用BackQuote`开关控制台godmode 命令生效
□ AccessibilityManager色盲模式/字幕/高对比模式切换均实时生效
□ AntiSoftlockSystem60s 静止后显示提示(非 30s
□ AddressKeysValidator无 Warning所有 key 存在)
□ EventChannelAuditor无孤立未被订阅的事件频道
□ RoomValidator所有房间场景通过验证
□ ProfilerGameplay 稳定状态 GC Alloc/帧 < 1KB
□ Console 无 Error发布构建无 Debug.Log
```
---
## 7. 发布前技术 Checklist
完成以下所有项后技术层面达到发布就绪Release Candidate状态
```
架构
□ 所有 asmdef 依赖方向正确(无循环依赖)
□ 无 using 直接引用非依赖 asmdef 的类型
存档
□ SaveData 版本迁移路径完整v1→v2→…→current
□ SteelSoul 死亡删档流程二次确认 UI 存在
□ 存档文件 SHA-256 校验通过
构建
□ Development Build 关闭 DebugCheatSystem 快捷键
□ Release Build 关闭所有 Debug.Log或通过 #if UNITY_EDITOR 过滤)
□ Addressables Remote 资产 CDN URL 指向 Production
□ IL2CPP 构建无 Managed Stripping 报错
平台
□ Steam 成就 API 集成测试TestApp 环境)
□ 手柄全功能测试PS5/Xbox 手柄)
□ 键盘+鼠标全功能测试
□ 分辨率1080p/1440p/2160p 均无 UI 错位
性能
□ 关卡中最差帧率 > 55fpsSwitch 目标)
□ 内存占用 < 1.5GBPC/ < 1GBSwitch
□ 加载时间 < 3s所有房间
内容
□ 所有已实现功能有本地化文本zh-CN + en
□ 所有 NPC 有至少 1 个 DialogueSequence
□ 所有 Boss 有完整技能套≥2 阶段)
```
**Phase 4 完成 = 游戏技术层就绪,进入关卡/内容填充阶段。**