3269 lines
138 KiB
Markdown
3269 lines
138 KiB
Markdown
# Phase 4 · 内容与完善
|
||
|
||
> **周期**:3–4 周(Week 15–18)
|
||
> **前置条件**:Phase 3 全部完成标准通过
|
||
> **核心目标**:Boss 技能系统、叙事/对话/过场、按键重绑定、完整 UI 面板、支撑系统(本地化/成就/Steam/调试工具)、编辑器工具、QA 就绪
|
||
> **产出物**:游戏发布前技术层面全部完成,可进入内容填充 + 关卡设计阶段
|
||
> **状态**:✅ 全部完成(2026-05-11,P4-1~P4-6 全部 ✅)
|
||
|
||
---
|
||
|
||
## 目录
|
||
|
||
1. [实施顺序总览](#1-实施顺序总览)
|
||
2. [Week 15:Boss 技能系统完整](#2-week-15boss-技能系统完整)
|
||
3. [Week 16:叙事模块(对话/过场/事件链)](#3-week-16叙事模块对话过场事件链)
|
||
4. [Week 17:UI 完整面板 + 按键重绑定](#4-week-17ui-完整面板--按键重绑定)
|
||
5. [Week 18:支撑模块 + 编辑器工具 + QA](#5-week-18支撑模块--编辑器工具--qa)
|
||
6. [完成标准检查清单](#6-完成标准检查清单)
|
||
7. [发布前技术 Checklist](#7-发布前技术-checklist)
|
||
|
||
---
|
||
|
||
## 1. 实施顺序总览
|
||
|
||
```
|
||
Week 15: BossSkillSO + AttackPatternSO + SkillSequenceSO(数据层)
|
||
↓
|
||
VulnerabilityWindow + WeakPointSystem
|
||
↓
|
||
BossSkillExecutor(执行层)
|
||
↓
|
||
BossOrchestrator 集成(BD + BossSkillExecutor 协作)
|
||
↓
|
||
首个 Boss 完整技能套(2 阶段 × 3 技能)
|
||
|
||
Week 16: DialogueLine / DialogueSequenceSO(数据)
|
||
↓
|
||
DialogueManager(打字机效果 + 快进 + 分支选择)
|
||
↓
|
||
InteractableNPC(挂载 DialogueSequenceSO,接入 QuestGiver)
|
||
↓
|
||
EventChain + EventChainManager(世界事件链)
|
||
↓
|
||
CutsceneManager(Unity Timeline 封装)
|
||
|
||
Week 17: 完整 UI 面板套装
|
||
├─ PausePanel(设置入口 + 存档 + 退出)
|
||
├─ InventoryPanel(护符 + 工具槽 + 凹槽 UI)
|
||
├─ MapPanel(完整 Fog of War 渲染)
|
||
├─ ShopPanel(商品列表 + 购买确认)
|
||
├─ AchievementPanel
|
||
└─ SettingsPanel(音量/分辨率/按键重绑定)
|
||
↓
|
||
InputRebindingUI(New Input System 重绑定 API)
|
||
|
||
Week 18: LocalizationManager(Unity Localization 包)
|
||
↓
|
||
AchievementManager(本地 + Steam 双通道)
|
||
↓
|
||
PlatformBootstrap(Steam 成就/存档云同步;⚠️ 非 PlatformManager 静态类,架构 16 §3)
|
||
↓
|
||
DebugCheatSystem(Editor Only + Development Build)
|
||
↓
|
||
AntiSoftlockSystem(出门触发器 + 快速复活备用)
|
||
↓
|
||
AccessibilityManager(色盲模式/字幕/震动/高对比)
|
||
↓
|
||
AnalyticsManager(本地 JSONL 日志,开发分析)
|
||
↓
|
||
SpeedrunTimer(IGT 计时器,ISaveable)
|
||
↓
|
||
编辑器工具(验证工具套装)
|
||
↓
|
||
QA Checklist 执行
|
||
```
|
||
|
||
---
|
||
|
||
## 2. Week 15:Boss 技能系统完整 ✅ 完成(2026-05-11)
|
||
|
||
**参考文档**:`23_BossSkillModule.md`
|
||
|
||
### 2.1 数据层 SO 创建顺序
|
||
|
||
按依赖关系由底向上:
|
||
|
||
```
|
||
BossSkillCategory.cs ← 分类枚举(Melee/Ranged/Charge/AoE/…)
|
||
BossSkillType.cs ← 具体类型枚举(MeleeSlash / ChargeAttack / LeapSlam / …)
|
||
InteractionTag.cs ← [Flags] 互动标签枚举(Parryable/Unblockable/…)
|
||
VulnTriggerType.cs ← 弱点触发方式枚举(OnAttackRecovery/OnParriedSuccess/…)
|
||
WeakPointType.cs ← 弱点位置类型枚举(FullBody/HeadOnly/BackOnly/…)
|
||
VulnerabilityWindow.cs ← struct(TriggerDelay, Duration, WeakPointType, DamageMultiplier, ForceStagger, …)
|
||
AttackPatternSO.cs ← 单个攻击图案(HitBox 范围/时序/DamageSourceSO 引用)
|
||
SkillSequenceSO.cs ← SequenceStep[] 序列 + RepeatIfPlayerInRange / MaxRepeatCount
|
||
PlayerCounterResponse.cs ← struct(CounterType + Boss应激反应参数)
|
||
ArenaEventTrigger.cs ← struct + ArenaEventType + IArenaInteractable(场景联动)
|
||
BossResourceCost.cs ← struct + BossResourceConfigSO(Boss 资源系统)
|
||
BossSkillSO.cs ← 技能(所有架构 23 §4 字段,含 attackPatterns/counterResponses/arenaEvents/resourceCost/poiseWindow)
|
||
```
|
||
> **注意**:不存在 `BossConfigSO`。Boss 阶段技能列表直接由 `BossOrchestrator` 的 `_phaseOneSkills[]` / `_phaseTwoSkills[]` 字段管理(见 §2.3)。
|
||
|
||
### 2.1.1 BossSkillCategory + BossSkillType + 互动/弱点枚举
|
||
|
||
```csharp
|
||
// Assets/Scripts/Boss/BossSkillType.cs
|
||
namespace BaseGames.Boss
|
||
{
|
||
/// <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 §4);skillAnimation 为 Animancer ClipTransition
|
||
namespace BaseGames.Boss
|
||
{
|
||
[CreateAssetMenu(menuName = "Boss/BossSkill")]
|
||
public class BossSkillSO : ScriptableObject
|
||
{
|
||
[Header("元信息")]
|
||
public string skillId;
|
||
public string displayName;
|
||
[TextArea(1, 4)]
|
||
public string designNote;
|
||
|
||
[Header("技能分类")]
|
||
public BossSkillCategory category; // ⚠️ 高层分类(架构 23 §4)
|
||
public BossSkillType skillType;
|
||
|
||
[Header("阶段可用性")]
|
||
public int[] availablePhaseIndices; // ⚠️ 空数组 = 全阶段可用(架构 23 §4)
|
||
|
||
[Header("核心攻击动作(按执行顺序排列)")]
|
||
public AttackPatternSO[] attackPatterns; // ⚠️ 按架构 23 §4:伤害参数只在 AttackPatternSO
|
||
|
||
[Header("弱点窗口(至少 1 个)")]
|
||
public VulnerabilityWindow[] vulnerabilityWindows;
|
||
|
||
[Header("互动标签")]
|
||
public InteractionTag interactionTags; // ⚠️ [Flags] 枚举(架构 23 §4,非 string interactionTag)
|
||
|
||
[Header("连段子序列")]
|
||
public SkillSequenceSO sequenceOnHit; // 弱点被击时触发(可空)
|
||
public SkillSequenceSO sequenceOnMiss; // 弱点未被击时触发(可空)
|
||
|
||
[Header("玩家反制接口")]
|
||
public PlayerCounterResponse[] counterResponses; // ⚠️ 架构 23 §4.1
|
||
|
||
[Header("场景联动")]
|
||
public ArenaEventTrigger[] arenaEvents; // ⚠️ 架构 23 §4.2
|
||
|
||
[Header("Boss 资源")]
|
||
public BossResourceCost resourceCost; // ⚠️ 架构 23 §4.3
|
||
public bool buildsRage; // ⚠️ 是否积累愤怒值(架构 23 §4)
|
||
|
||
[Header("霸体配置")]
|
||
public PoiseWindowConfig poiseWindow; // ⚠️ 技能执行期间的霸体窗口(架构 23 §4)
|
||
|
||
[Header("动画")]
|
||
public ClipTransition skillAnimation; // Animancer ClipTransition
|
||
|
||
[Header("冷却")]
|
||
[Min(0f)]
|
||
public float cooldown;
|
||
}
|
||
}
|
||
```
|
||
|
||
### 2.1.3 AttackPatternSO
|
||
|
||
```csharp
|
||
// Assets/Scripts/Boss/AttackPatternSO.cs
|
||
// ⚠️ menuName = "Boss/AttackPattern";DamageSource 字段为 DamageSourceSO(架构 23 §5)
|
||
namespace BaseGames.Boss
|
||
{
|
||
[CreateAssetMenu(menuName = "Boss/AttackPattern")]
|
||
public class AttackPatternSO : ScriptableObject
|
||
{
|
||
[Header("输出")]
|
||
public DamageSourceSO DamageSource;
|
||
public float KnockbackAngle;
|
||
|
||
[Header("弹幕")]
|
||
public AssetReferenceGameObject ProjectilePrefab;
|
||
public int ProjectileCount = 1;
|
||
public float SpreadAngle = 0f;
|
||
public float ProjectileSpeed = 8f;
|
||
|
||
[Header("范围攻击")]
|
||
public float AoERadius;
|
||
public Vector2 AoEOffset;
|
||
|
||
[Header("时序")]
|
||
public float WindupDuration;
|
||
public float ActiveDuration;
|
||
public float RecoveryDuration;
|
||
}
|
||
}
|
||
```
|
||
|
||
### 2.1.4 SkillSequenceSO
|
||
|
||
```csharp
|
||
// Assets/Scripts/Boss/SkillSequenceSO.cs
|
||
// ⚠️ menuName = "Boss/SkillSequence"(架构 23 §6)
|
||
namespace BaseGames.Boss
|
||
{
|
||
[CreateAssetMenu(menuName = "Boss/SkillSequence")]
|
||
public class SkillSequenceSO : ScriptableObject
|
||
{
|
||
[Serializable]
|
||
public struct SequenceStep
|
||
{
|
||
public AttackPatternSO pattern;
|
||
public float delayBeforeStep;
|
||
}
|
||
|
||
public SequenceStep[] steps;
|
||
|
||
[Header("循环行为")]
|
||
public bool RepeatIfPlayerInRange;
|
||
public float RepeatDelay;
|
||
[Range(0, 10)]
|
||
public int MaxRepeatCount; // 0 = 无限
|
||
}
|
||
}
|
||
```
|
||
|
||
### 2.1.5 WeakPointSystem
|
||
|
||
```csharp
|
||
// Assets/Scripts/Boss/WeakPointSystem.cs
|
||
// ⚠️ SetActive(bool active, float multiplier) 由 BossSkillExecutor.ActivateVulnerabilityWindows() 调用(架构 23 §8)
|
||
namespace BaseGames.Boss
|
||
{
|
||
public class WeakPointSystem : MonoBehaviour
|
||
{
|
||
[Serializable]
|
||
public struct WeakPoint
|
||
{
|
||
public HurtBox hurtBox;
|
||
public GameObject visualIndicator;
|
||
}
|
||
|
||
[SerializeField] WeakPoint[] _weakPoints;
|
||
[SerializeField] string _bossId;
|
||
[SerializeField] StringEventChannelSO _onVulnerabilityWindowOpened;
|
||
|
||
float _damageMultiplier = 1f;
|
||
|
||
public void SetActive(bool active, float multiplier = 1f)
|
||
{
|
||
_damageMultiplier = multiplier;
|
||
foreach (var wp in _weakPoints)
|
||
{
|
||
wp.hurtBox.gameObject.SetActive(active);
|
||
if (wp.visualIndicator != null)
|
||
wp.visualIndicator.SetActive(active);
|
||
}
|
||
if (active) _onVulnerabilityWindowOpened?.Raise(_bossId);
|
||
}
|
||
|
||
public float GetDamageMultiplier() => _damageMultiplier;
|
||
}
|
||
}
|
||
```
|
||
|
||
### 2.1.6 PlayerCounterResponse + ArenaEventTrigger + BossResourceCost
|
||
|
||
> **⚠️ 架构 23 §4.1-4.3 要求**:BossSkillSO 引用这三个数据结构,均在 `BaseGames.Boss` 命名空间。
|
||
|
||
```csharp
|
||
// Assets/Scripts/Boss/PlayerCounterResponse.cs
|
||
namespace BaseGames.Boss
|
||
{
|
||
/// <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
|
||
// ⚠️ 字段为绝对时间(秒),不是归一化时间(0–1)(架构 23_BossSkillModule §3)
|
||
// ⚠️ 使用 UniTask 序列按 TriggerDelay+Duration 激活弱点(非 Update() 轮询归一化时间)
|
||
namespace BaseGames.Boss
|
||
{
|
||
[Serializable]
|
||
public struct VulnerabilityWindow
|
||
{
|
||
[Tooltip("弱点触发方式")]
|
||
public VulnTriggerType TriggerType; // ⚠️ 架构 23 §3
|
||
|
||
[Tooltip("触发后延迟出现(秒)")]
|
||
[Min(0f)]
|
||
public float TriggerDelay; // ⚠️ 绝对秒数(架构 23 §3,非归一化时间)
|
||
|
||
[Tooltip("弱点持续时长(秒,设计约定 ≥0.5s)")]
|
||
[Min(0.1f)]
|
||
public float Duration; // ⚠️ 绝对秒数(架构 23 §3,非归一化时间)
|
||
|
||
[Tooltip("弱点位置类型")]
|
||
public WeakPointType WeakPointType; // ⚠️ 架构 23 §3
|
||
|
||
[Tooltip("弱点激活时 Boss 受击乘数(1 = 正常,>1 = 额外伤害)")]
|
||
[Min(0.1f)]
|
||
public float DamageMultiplier;
|
||
|
||
[Tooltip("命中后是否强制硬直")]
|
||
public bool ForceStagger; // ⚠️ 架构 23 §3
|
||
|
||
[Tooltip("硬直时间(秒),ForceStagger=true 时生效")]
|
||
[Min(0f)]
|
||
public float StaggerDuration; // ⚠️ 架构 23 §3
|
||
|
||
[Tooltip("弱点开启时播放的 Feedback(光效等)")]
|
||
public MMF_Player OpenFeedback; // ⚠️ 架构 23 §3
|
||
|
||
[Tooltip("弱点关闭时播放的 Feedback")]
|
||
public MMF_Player CloseFeedback; // ⚠️ 架构 23 §3
|
||
|
||
[Tooltip("弱点激活时的高亮颜色(指示玩家该攻击哪里)")]
|
||
public Color HighlightColor; // ⚠️ 架构 23 §3
|
||
|
||
// 向后兼容辅助属性(BossSkillExecutor 判断是否激活专属 HurtBox)
|
||
public bool ActivateWeakPointHurtBox => WeakPointType != WeakPointType.FullBody;
|
||
}
|
||
}
|
||
```
|
||
|
||
### 2.3 BossSkillExecutor + BossOrchestrator
|
||
|
||
```csharp
|
||
// Assets/Scripts/Enemies/Boss/BossSkillExecutor.cs
|
||
namespace BaseGames.Boss
|
||
{
|
||
public class BossSkillExecutor : MonoBehaviour
|
||
{
|
||
[SerializeField] HitBox[] _hitBoxes;
|
||
[SerializeField] WeakPointSystem _weakPointSystem;
|
||
[SerializeField] AnimancerComponent _animancer;
|
||
[SerializeField] private string _bossId; // ⚠️ 事件 payload 用
|
||
[SerializeField] private BossSkillEventChannelSO _onBossSkillStarted; // ⚠️ 架构 23 §11
|
||
[SerializeField] private BossSkillEventChannelSO _onBossSkillEnded; // ⚠️ 架构 23 §11
|
||
// ⚠️ PlayerController 无 Instance(Architecture 05 §2);Boss 居小场景持有玩家 Transform 引用
|
||
[SerializeField] private Transform _playerTransform; // 由 Inspector 指定玩家 Transform
|
||
|
||
BossSkillSO _currentSkill;
|
||
AnimancerState _currentState;
|
||
bool _isExecuting;
|
||
CancellationTokenSource _cts;
|
||
|
||
public bool IsExecuting => _isExecuting;
|
||
|
||
// BD Task 节点调用
|
||
public async UniTask ExecuteSkill(BossSkillSO skill, CancellationToken ct)
|
||
{
|
||
if (_isExecuting) return;
|
||
_isExecuting = true;
|
||
_currentSkill = skill;
|
||
_onBossSkillStarted?.Raise(new BossSkillEvent { BossId = _bossId, SkillId = skill.skillId });
|
||
|
||
// 播放技能动画(直接从 BossSkillSO.skillAnimation 引用 ClipTransition)
|
||
_currentState = _animancer.Play(skill.skillAnimation);
|
||
|
||
// 执行 SkillSequenceSO(若挂载 sequenceOnMiss)
|
||
if (skill.sequenceOnMiss != null)
|
||
await ExecuteSequence(skill.sequenceOnMiss, ct);
|
||
|
||
_isExecuting = false;
|
||
_onBossSkillEnded?.Raise(new BossSkillEvent { BossId = _bossId, SkillId = skill.skillId });
|
||
}
|
||
|
||
async UniTask ExecuteSequence(SkillSequenceSO seq, CancellationToken ct)
|
||
{
|
||
int repeatCount = 0;
|
||
do
|
||
{
|
||
foreach (var step in seq.steps)
|
||
{
|
||
ct.ThrowIfCancellationRequested();
|
||
await UniTask.WaitForSeconds(step.delayBeforeStep, cancellationToken: ct);
|
||
await ExecutePattern(step.pattern, ct);
|
||
}
|
||
repeatCount++;
|
||
if (seq.RepeatDelay > 0)
|
||
await UniTask.WaitForSeconds(seq.RepeatDelay, cancellationToken: ct);
|
||
}
|
||
while (seq.RepeatIfPlayerInRange
|
||
&& repeatCount < seq.MaxRepeatCount
|
||
&& IsPlayerInRange()
|
||
&& !ct.IsCancellationRequested);
|
||
}
|
||
|
||
async UniTask ExecutePattern(AttackPatternSO pattern, CancellationToken ct)
|
||
{
|
||
await UniTask.WaitForSeconds(pattern.WindupDuration, cancellationToken: ct);
|
||
// ⚠️ 以架构 06_CombatModule §4 为准:HitBox.Activate(DamageSourceSO, Transform) + Deactivate()
|
||
foreach (var hb in _hitBoxes)
|
||
hb.Activate(pattern.DamageSource, transform);
|
||
await UniTask.WaitForSeconds(pattern.ActiveDuration, cancellationToken: ct);
|
||
foreach (var hb in _hitBoxes) hb.Deactivate();
|
||
await UniTask.WaitForSeconds(pattern.RecoveryDuration, cancellationToken: ct);
|
||
}
|
||
|
||
// ⚠️ UniTask 序列激活弱点窗口(架构 23 §7)
|
||
// ⚠️ 使用绝对时间(TriggerDelay + Duration),不使用 Update() 归一化时间轮询
|
||
async UniTask ActivateVulnerabilityWindows(BossSkillSO skill, CancellationToken ct)
|
||
{
|
||
foreach (var window in skill.vulnerabilityWindows)
|
||
{
|
||
// 等待触发延迟
|
||
await UniTask.Delay(
|
||
TimeSpan.FromSeconds(window.TriggerDelay),
|
||
cancellationToken: ct);
|
||
|
||
// 激活弱点(⚠️ 架构 23 §8 SetActive 仅 2 参数,§7 3-arg call 为内部不一致;以 §8 定义为准)
|
||
_weakPointSystem.SetActive(true, window.DamageMultiplier);
|
||
window.OpenFeedback?.PlayFeedbacks();
|
||
|
||
// 持续弱点窗口
|
||
await UniTask.Delay(
|
||
TimeSpan.FromSeconds(window.Duration),
|
||
cancellationToken: ct);
|
||
|
||
// 关闭弱点
|
||
_weakPointSystem.SetActive(false, 1f);
|
||
window.CloseFeedback?.PlayFeedbacks();
|
||
}
|
||
}
|
||
|
||
bool IsPlayerInRange() =>
|
||
_playerTransform != null &&
|
||
Vector2.Distance(transform.position, _playerTransform.position) < 8f;
|
||
}
|
||
}
|
||
|
||
// Assets/Scripts/Enemies/Boss/BossOrchestrator.cs
|
||
// Behavior Designer 的宿主;直接持有各阶段 BossSkillSO[] 数组(不通过 BossConfigSO)
|
||
namespace BaseGames.Boss
|
||
{
|
||
public class BossOrchestrator : MonoBehaviour
|
||
{
|
||
[SerializeField] BossBase _boss; // ⚠️ 架构 07_EnemyModule §10
|
||
[SerializeField] TelegraphSystem _telegraph; // ⚠️ 架构 07_EnemyModule §10
|
||
[SerializeField] BoxCollider2D _arenaBlocker; // ⚠️ 架构 07_EnemyModule §10
|
||
|
||
[Header("Boss 技能")]
|
||
[SerializeField] BossSkillSO[] _phaseOneSkills;
|
||
[SerializeField] BossSkillSO[] _phaseTwoSkills;
|
||
[SerializeField] BossSkillExecutor _executor;
|
||
|
||
[Header("Event Channels")]
|
||
[SerializeField] private StringEventChannelSO _onBossFightStarted; // ⚠️ 架构 07_EnemyModule §10 + 02 §4
|
||
|
||
int _currentPhase = 1;
|
||
CancellationTokenSource _cts;
|
||
|
||
// 由触发器或 GameManager 调用(架构 07_EnemyModule §10)
|
||
public void StartBossFight()
|
||
{
|
||
_arenaBlocker.enabled = true;
|
||
_onBossFightStarted.Raise(_boss.BossId);
|
||
_boss.GetComponent<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` | MeleeSlash,vulnerabilityWindows[0]: TriggerDelay=0.5s, Duration=1.2s(⚠️ 绝对秒数,非归一化时间) |
|
||
| `BSS_Skill_Charge` | `BossSkillSO` | ChargeAttack,WeakPoint 头部,TriggerDelay=0.2s, Duration=1.5s |
|
||
| `BSS_Skill_LeapSlam` | `BossSkillSO` | LeapSlam,落地后全身 VulnerabilityWindow: TriggerDelay=0.1s, Duration=1.0s |
|
||
| `BSS_Seq_Slash` | `SkillSequenceSO` | 3段斩击时序 |
|
||
| `BSS_Pat_Slash_*` | `AttackPatternSO` × 3 | 各段 HitBox 参数(WindupDuration/ActiveDuration/RecoveryDuration)|
|
||
|
||
---
|
||
|
||
## 3. Week 16:叙事模块(对话/过场/事件链)✅ 完成(2026-05-11)
|
||
|
||
**参考文档**:`14_NarrativeModule.md`
|
||
|
||
### 3.0 DialogueLine + DialogueSequenceSO(数据层)
|
||
|
||
按依赖关系最先实现,`DialogueManager` / `InteractableNPC` 均依赖这两个数据类型。
|
||
|
||
```csharp
|
||
// Assets/Scripts/Dialogue/DialogueLineSO.cs
|
||
// ⚠️ 为 [System.Serializable] class(架构 14_NarrativeModule §3),非 ScriptableObject、非 struct
|
||
namespace BaseGames.Dialogue
|
||
{
|
||
[System.Serializable]
|
||
public class DialogueLine
|
||
{
|
||
public string SpeakerNameKey; // 说话人姓名本地化 key
|
||
[TextArea(2, 6)]
|
||
public string TextKey; // 对话文本本地化 key
|
||
public Sprite PortraitSprite; // 可空(无头像时留空)
|
||
public float TypewriterDelay = 0.03f; // 每字符显示延迟(架构 14 §3)
|
||
}
|
||
}
|
||
```
|
||
|
||
```csharp
|
||
// Assets/Scripts/Dialogue/DialogueSequenceSO.cs
|
||
// ⚠️ menuName = "Dialogue/DialogueSequence"(架构 14_NarrativeModule §2)
|
||
namespace BaseGames.Dialogue
|
||
{
|
||
[CreateAssetMenu(menuName = "Dialogue/DialogueSequence")]
|
||
public class DialogueSequenceSO : ScriptableObject
|
||
{
|
||
public string sequenceId; // 唯一 ID(用于 WorldStateRegistry 条件查询)
|
||
public DialogueLine[] Lines; // 对话行数组
|
||
|
||
[System.Serializable]
|
||
public struct ConditionalVariant
|
||
{
|
||
public string ConditionFlag; // WorldStateRegistry 标志 key
|
||
public DialogueSequenceSO Sequence; // 条件满足时替换的序列
|
||
}
|
||
|
||
public ConditionalVariant[] Variants; // ConditionalVariant[] 而非 Variants[](架构 14 §2)
|
||
}
|
||
}
|
||
// 资产路径:Assets/ScriptableObjects/Dialogue/
|
||
// 命名规范:DLG_{NpcId}_{Context}.asset(例 DLG_Elder_Intro.asset)
|
||
```
|
||
|
||
### 3.1 DialogueManager
|
||
|
||
```csharp
|
||
// Assets/Scripts/Dialogue/DialogueManager.cs
|
||
public class DialogueManager : MonoBehaviour
|
||
{
|
||
[SerializeField] private DialogueBox _dialogueBox;
|
||
[SerializeField] private InputReaderSO _inputReader;
|
||
|
||
[Header("Event Channels")]
|
||
[SerializeField] private VoidEventChannelSO _onDialogueStarted;
|
||
[SerializeField] private VoidEventChannelSO _onDialogueEnded;
|
||
[SerializeField] private StringEventChannelSO _onNpcDialogueCompleted; // → EVT_NpcDialogueCompleted(npcId,QuestManager 订阅)
|
||
[SerializeField] private GameStateEventChannelSO _onGameStateChanged;
|
||
|
||
public bool IsDialogueActive { get; private set; }
|
||
private bool _skipRequested;
|
||
|
||
// 打字机效果:每字符 TypewriterDelay 延迟显示
|
||
// 按确认键:若未显示完毕则立即显示全部;若已完毕则进入下一行
|
||
// 所有行显示完毕:结束对话,恢复 Gameplay ActionMap
|
||
|
||
// ⚠️ 正确实现模式:使用 IEnumerator + StartCoroutine(架构 14_NarrativeModule §3)
|
||
// 正确 API: void StartDialogue(DialogueSequenceSO sequence) — 返回 void,非 UniTask
|
||
public void StartDialogue(DialogueSequenceSO sequence)
|
||
{
|
||
// 1. 若 IsDialogueActive → 返回
|
||
if (IsDialogueActive) return;
|
||
// 2. 选择 ConditionalVariant(查询 WorldStateRegistry)
|
||
IsDialogueActive = true;
|
||
_dialogueBox.Show(); // 步骤 3:先显示对话框
|
||
_inputReader.EnableUIInput(); // 步骤 4:切换 ActionMap → UI(先于 Raise)
|
||
_onDialogueStarted.Raise(); // 步骤 5:广播(在 ActionMap 切换后)
|
||
StartCoroutine(PlaySequence(sequence)); // 步骤 6
|
||
}
|
||
|
||
// 玩家按下 Submit 键时(InputReaderSO.SubmitEvent)
|
||
private void OnSubmit() => _skipRequested = true;
|
||
|
||
private IEnumerator PlaySequence(DialogueSequenceSO sequence)
|
||
{
|
||
foreach (var line in sequence.Lines)
|
||
{
|
||
_skipRequested = false;
|
||
yield return _dialogueBox.TypeText(line.TextKey, line.TypewriterDelay);
|
||
// 等待玩家按 Submit 继续
|
||
yield return new WaitUntil(() => _skipRequested);
|
||
}
|
||
EndDialogue();
|
||
}
|
||
|
||
private void EndDialogue()
|
||
{
|
||
_dialogueBox.Hide();
|
||
IsDialogueActive = false;
|
||
_inputReader.EnableGameplayInput(); // 先恢复 ActionMap → Gameplay(Architecture 14 §3:先切换再广播)
|
||
_onDialogueEnded.Raise();
|
||
}
|
||
|
||
// 分支选择(当 DialogueSequenceSO 有 ConditionalVariants 时)
|
||
public DialogueSequenceSO ResolveVariant(DialogueSequenceSO sequence);
|
||
}
|
||
```
|
||
|
||
**DialoguePanel**(UI Toolkit 或 UGUI):
|
||
- 文本框(Text Mesh Pro)
|
||
- 说话人姓名框
|
||
- 头像 Image
|
||
- 翻页箭头(Blink 动画)
|
||
|
||
### 3.2 InteractableNPC + NarrativeNPC
|
||
|
||
```csharp
|
||
// Assets/Scripts/Dialogue/InteractableNPC.cs
|
||
// 实现 IInteractable(BaseGames.World 命名空间)
|
||
public class InteractableNPC : MonoBehaviour, IInteractable
|
||
{
|
||
[SerializeField] private string _npcId;
|
||
[SerializeField] private DialogueSequenceSO _defaultDialogue;
|
||
[SerializeField] private float _interactRadius = 1.5f;
|
||
|
||
// ⚠️ 通过场景内注入(或 Find),不使用 DialogueManager.Instance 单例(架构 14_NarrativeModule §6)
|
||
private DialogueManager _dialogueManager;
|
||
|
||
public bool CanInteract => true;
|
||
public string InteractPrompt => "对话"; // ⚠️ 架构 14 §6:提示文本为 "对话"(非 "按 [X] 对话")
|
||
|
||
public void Interact(Transform player) // ⚠️ 参数为 Transform(架构 14_NarrativeModule §1)
|
||
{
|
||
Interact_Internal(player); // ⚠️ 先调用子类扩展钩子(架构 14 §6,原 Plan 遗漏)
|
||
_dialogueManager.StartDialogue(GetCurrentDialogue()); // ⚠️ 通过虚方法获取对话,支持子类重写(原 Plan 遗漏)
|
||
}
|
||
|
||
/// <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)(原 Plan:Execute(EventChainContext))
|
||
|
||
// ⚠️ actionDelay 支持(原 Plan 遗漏)
|
||
if (chain.actionDelay > 0f)
|
||
yield return new WaitForSeconds(chain.actionDelay);
|
||
}
|
||
|
||
// ⚠️ 存档集成(原 Plan 遗漏)
|
||
SaveManager.Instance.SetChainCompleted(chain.chainId);
|
||
OnChainCompleted?.Invoke(chain.chainId);
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3.4 CutsceneManager + CutsceneSO + CutsceneTrigger
|
||
|
||
```csharp
|
||
// Assets/Scripts/Cutscene/CutsceneManager.cs
|
||
// ⚠️ 基于 PlayableDirector.stopped 回调,非 async(架构 14_NarrativeModule §7)
|
||
[RequireComponent(typeof(PlayableDirector))]
|
||
public class CutsceneManager : MonoBehaviour
|
||
{
|
||
[SerializeField] private InputReaderSO _inputReader;
|
||
[SerializeField] private VoidEventChannelSO _onCutsceneStarted;
|
||
[SerializeField] private VoidEventChannelSO _onCutsceneEnded;
|
||
|
||
private PlayableDirector _director;
|
||
public bool IsPlaying => _director.state == PlayState.Playing;
|
||
|
||
private void Awake() => _director = GetComponent<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
|
||
// 实现 IInteractable(OnInteract 模式)
|
||
public class CutsceneTrigger : MonoBehaviour, IInteractable
|
||
{
|
||
public enum TriggerMode
|
||
{
|
||
OnEnter, // ⚠️ 架构 14 §11.5:OnEnter(原 Plan 错误使用 OnZoneEnter)
|
||
OnInteract, // 玩家主动交互(IInteractable)
|
||
OnSceneLoad, // 场景加载完毕(Start)
|
||
OnEvent, // ⚠️ 架构 14 §11.5(原 Plan 遗漏):订阅事件频道触发(配合 _triggerEventChannel)
|
||
}
|
||
|
||
[SerializeField] private CutsceneSO _cutscene;
|
||
[SerializeField] private TriggerMode _mode = TriggerMode.OnEnter;
|
||
[SerializeField] private CutsceneManager _cutsceneManager;
|
||
[SerializeField] private VoidEventChannelSO _triggerEventChannel; // ⚠️ OnEvent 模式用(原 Plan 遗漏,架构 14 §11.5)
|
||
|
||
// ⚠️ SO 注入(架构 14_NarrativeModule §11.5 patch):不使用 WorldStateRegistry.Instance
|
||
[SerializeField] private WorldStateRegistry _worldState;
|
||
|
||
public bool CanInteract => _mode == TriggerMode.OnInteract;
|
||
public string InteractPrompt => "查看";
|
||
|
||
public void Interact(Transform player) => TriggerCutscene();
|
||
public void OnPlayerEnterRange(Transform player) { }
|
||
public void OnPlayerExitRange() { }
|
||
|
||
private void OnEnable()
|
||
{
|
||
// ⚠️ OnEvent 模式(原 Plan 遗漏)
|
||
if (_mode == TriggerMode.OnEvent && _triggerEventChannel != null)
|
||
_triggerEventChannel.OnEventRaised += TriggerCutscene;
|
||
}
|
||
|
||
private void OnDisable()
|
||
{
|
||
if (_mode == TriggerMode.OnEvent && _triggerEventChannel != null)
|
||
_triggerEventChannel.OnEventRaised -= TriggerCutscene;
|
||
}
|
||
|
||
private void OnTriggerEnter2D(Collider2D other)
|
||
{
|
||
if (_mode != TriggerMode.OnEnter || !other.CompareTag("Player")) return; // ⚠️ OnEnter(非 OnZoneEnter)
|
||
TriggerCutscene();
|
||
}
|
||
|
||
private void Start()
|
||
{
|
||
if (_mode == TriggerMode.OnSceneLoad) TriggerCutscene();
|
||
}
|
||
|
||
private void TriggerCutscene()
|
||
{
|
||
if (_cutscene == null) return;
|
||
// ⚠️ SO 注入(非 Instance)+ HasFlag(非 IsFlagSet)
|
||
if (_cutscene.playOnlyOnce &&
|
||
_worldState != null && _worldState.HasFlag($"cutscene_played_{_cutscene.cutsceneId}"))
|
||
return;
|
||
_cutsceneManager.PlayCutscene(_cutscene); // ⚠️ 传入 CutsceneSO(原 Plan 错误传 _cutscene.Timeline),架构 14 §7
|
||
_worldState?.SetFlag($"cutscene_played_{_cutscene.cutsceneId}");
|
||
if (_mode == TriggerMode.OnEnter) enabled = false; // ⚠️ OnEnter(非 OnZoneEnter)
|
||
}
|
||
}
|
||
```
|
||
|
||
**资产路径**:`Assets/ScriptableObjects/Cutscene/`
|
||
**命名规范**:`CS_{SceneId}_{ContextId}.asset`
|
||
|
||
### 3.5 SignalEmitterClip — Timeline 零耦合事件桥接(⚠️ 架构 14 §11.6,原 Plan 遗漏)
|
||
|
||
```csharp
|
||
// Assets/Scripts/Cutscene/SignalEmitterClip.cs
|
||
// ⚠️ 架构 14 §11.6(原 Plan 遗漏):Timeline 过场通过此 PlayableAsset 发布 SO 事件频道,保持零耦合
|
||
// 在 Timeline 轨道上放置此 Clip,Clip 播放时向目标事件频道 Raise 一次事件
|
||
[CreateAssetMenu(menuName = "BaseGames/Cutscene/SignalEmitterClip")]
|
||
public class SignalEmitterClip : PlayableAsset, ITimelineClipAsset
|
||
{
|
||
[SerializeField] private EventChannelBaseSO _targetChannel; // 目标事件频道 SO
|
||
|
||
// ITimelineClipAsset
|
||
public ClipCaps clipCaps => ClipCaps.None;
|
||
|
||
public override Playable CreatePlayable(PlayableGraph graph, GameObject owner)
|
||
=> ScriptPlayable<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 17:UI 完整面板 + 按键重绑定 ✅ 完成(2026-05-11)
|
||
|
||
**参考文档**:`10_UIModule.md`
|
||
|
||
### 4.1 UIManager 完整路由
|
||
|
||
```csharp
|
||
// ⚠️ 字段类型为 GameObject(非类型化 UIPanel 子类),Stack 元素类型同为 GameObject(架构 10_UIModule §2)
|
||
// ⚠️ 公开 API 为 OpenPanel(GameObject) / CloseTopPanel(),不存在 Push/Pop/PopAll 方法
|
||
// UIManager 管理所有 Panel 的显示/隐藏,通过事件频道驱动
|
||
[DefaultExecutionOrder(+50)]
|
||
public class UIManager : MonoBehaviour
|
||
{
|
||
[Header("Canvas Roots")]
|
||
[SerializeField] private GameObject _hudRoot;
|
||
[SerializeField] private GameObject _pauseMenuRoot;
|
||
[SerializeField] private GameObject _deathScreenRoot;
|
||
[SerializeField] private GameObject _settingsRoot;
|
||
[SerializeField] private GameObject _mapRoot;
|
||
[SerializeField] private GameObject _shopRoot;
|
||
|
||
// 事件频道
|
||
[Header("Event Channels - Subscribe")]
|
||
[SerializeField] private GameStateEventChannelSO _onGameStateChanged;
|
||
[SerializeField] private VoidEventChannelSO _onPauseRequested;
|
||
[SerializeField] private VoidEventChannelSO _onFastTravelOpen; // ⚠️ Architecture 10 §2 有此频道
|
||
[SerializeField] private StringEventChannelSO _onShopOpen; // ⚠️ 字段名 _onShopOpen(非 _onShopOpened)
|
||
[SerializeField] private VoidEventChannelSO _onMapOpen;
|
||
|
||
// 通过 Stack<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`(BoolEventChannelSO,true=显示/false=隐藏)
|
||
- 展示全屏加载面(进度条 + Tip 文字 + 随机背景)
|
||
|
||
**FloatingDamageText**(架构 10 §10):
|
||
- 对象池驱动(AddressKeys.PrefabUIFloatingDmgText)
|
||
- 由 `HUDController` 订阅 `EVT_DamageDealt` 并调用 `GlobalObjectPool.Spawn` 生成
|
||
- 黑色 = 普通伤害,红色 = 暴击,灰色 = 防御减免
|
||
|
||
**ToastNotification**(架构 10 §11):
|
||
- 右上角弹出 Toast(成就/任务进度提示)
|
||
- 订阅 `EVT_AchievementUnlocked` + `EVT_AbilityUnlocked`
|
||
- 自动进队(Toast队列)+ 3 秒后淡出
|
||
|
||
**InputDeviceIconSwitcher**(架构 10 §12):
|
||
- 订阅 `EVT_InputDeviceChanged`(BoolEventChannelSO,true=手柄/false=键盘)
|
||
- 遇到设备切换时全局刷新所有注册过的按键图标 Sprite
|
||
|
||
### 4.3 RebindPanel + RebindActionRow + ConflictDetector
|
||
|
||
> **⚠️ 架构约束(04_InputModule §6)**:重绑定 UI 由三个类组成:`RebindPanel`(主面板)、`RebindActionRow`(每行)、`ConflictDetector`(冲突检测)。所有持久化通过 `InputReaderSO.SaveBindingOverrides()` / `ResetBindings()` 进行,**禁止**直接访问 `_inputReader.Actions.asset`。
|
||
|
||
```csharp
|
||
// Assets/Scripts/UI/Settings/RebindPanel.cs
|
||
// 设置界面中的完整按键重绑定面板(架构 04_InputModule §6)
|
||
public class RebindPanel : MonoBehaviour
|
||
{
|
||
[SerializeField] private InputReaderSO _inputReader;
|
||
[SerializeField] private RebindActionRow[] _rows; // Inspector 配置,顺序对应 HUD 布局
|
||
[SerializeField] private Button _resetAllButton;
|
||
[SerializeField] private ConflictDetector _conflictDetector;
|
||
|
||
private void Awake()
|
||
{
|
||
_resetAllButton.onClick.AddListener(OnResetAll);
|
||
foreach (var row in _rows)
|
||
row.Initialize(_inputReader, _conflictDetector, OnRebindRequested);
|
||
}
|
||
|
||
private void OnRebindRequested(RebindActionRow row)
|
||
{
|
||
foreach (var r in _rows) r.SetInteractable(r == row);
|
||
row.StartRebind(onFinished: () =>
|
||
{
|
||
foreach (var r in _rows) r.SetInteractable(true);
|
||
_inputReader.SaveBindingOverrides(); // ⚠️ 通过 InputReaderSO 方法保存,非直接访问 asset
|
||
});
|
||
}
|
||
|
||
private void OnResetAll()
|
||
{
|
||
_inputReader.ResetBindings(); // ⚠️ 通过 InputReaderSO.ResetBindings()
|
||
_inputReader.SaveBindingOverrides();
|
||
foreach (var row in _rows) row.RefreshDisplay();
|
||
}
|
||
}
|
||
|
||
// Assets/Scripts/UI/Settings/RebindActionRow.cs
|
||
// 单行:Action 名 + 当前绑定显示 + 点击启动重绑定
|
||
public class RebindActionRow : MonoBehaviour
|
||
{
|
||
[SerializeField] private string _actionName;
|
||
[SerializeField] private int _bindingIndex;
|
||
[SerializeField] private TMP_Text _actionLabel;
|
||
[SerializeField] private Button _bindButton;
|
||
[SerializeField] private TMP_Text _currentBindingText;
|
||
|
||
private InputReaderSO _inputReader;
|
||
private ConflictDetector _conflictDetector;
|
||
private Action<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 + DeathScreenController(Architecture 10 §4–§6)
|
||
|
||
```csharp
|
||
// Assets/Scripts/UI/HUD/BossHPBar.cs
|
||
// ⚠️ 全部事件频道已更新(Architecture 00_CoverageIndex §五 patch)
|
||
public class BossHPBar : MonoBehaviour
|
||
{
|
||
[SerializeField] private TMP_Text _bossNameText;
|
||
[SerializeField] private Image _hpFill;
|
||
[SerializeField] private Transform _phaseMarkersRoot;
|
||
[SerializeField] private GameObject _phaseMarkerPrefab;
|
||
|
||
[Header("Event Channels")]
|
||
// ⚠️ 已替换旧频道(旧:StringEventChannelSO _onBossFightStarted + BoolEventChannelSO _onBossFightEnded + FloatEventChannelSO _onBossHPRatioChanged)
|
||
[SerializeField] private BoolEventChannelSO _onBossFightToggled; // EVT_BossFightToggled(true=战斗开始,false=结束)
|
||
[SerializeField] private IntEventChannelSO _onBossHPChanged; // EVT_BossHPChanged(当前 HP 整数)
|
||
[SerializeField] private StringEventChannelSO _onBossNameSet; // EVT_BossNameSet(Boss 名称 string)
|
||
[SerializeField] private IntEventChannelSO _onBossHPMaxSet; // EVT_BossHPMaxSet(最大 HP 整数)
|
||
|
||
private int _maxHP = 1;
|
||
|
||
private void OnEnable()
|
||
{
|
||
_onBossFightToggled.OnEventRaised += OnFightToggled;
|
||
_onBossHPChanged.OnEventRaised += OnHPChanged;
|
||
_onBossNameSet.OnEventRaised += OnNameSet;
|
||
_onBossHPMaxSet.OnEventRaised += hp => _maxHP = hp;
|
||
}
|
||
private void OnDisable()
|
||
{
|
||
_onBossFightToggled.OnEventRaised -= OnFightToggled;
|
||
_onBossHPChanged.OnEventRaised -= OnHPChanged;
|
||
_onBossNameSet.OnEventRaised -= OnNameSet;
|
||
_onBossHPMaxSet.OnEventRaised -= hp => _maxHP = hp;
|
||
}
|
||
|
||
private void OnFightToggled(bool started)
|
||
{
|
||
// ⚠️ 架构 10 §4:使用 SlideIn/SlideOut 协程动画(原 Plan 错误使用 SetActive)
|
||
if (started) StartCoroutine(SlideIn());
|
||
else StartCoroutine(SlideOut());
|
||
}
|
||
private void OnHPChanged(int hp) => _hpFill.fillAmount = _maxHP > 0 ? (float)hp / _maxHP : 0f;
|
||
private void OnNameSet(string bossName) { _bossNameText.text = bossName; } // 构建阶段标记,启用 GO
|
||
private IEnumerator SlideIn(); // 动画:Boss 血条从屏幕底部滑入
|
||
private IEnumerator SlideOut(); // 动画:Boss 血条滑出并隐藏
|
||
}
|
||
|
||
// Assets/Scripts/UI/Menus/PauseMenuController.cs
|
||
public class PauseMenuController : MonoBehaviour
|
||
{
|
||
[SerializeField] private UIManager _uiManager;
|
||
[SerializeField] private Button _btnResume;
|
||
[SerializeField] private Button _btnSettings;
|
||
[SerializeField] private Button _btnMainMenu;
|
||
[SerializeField] private Button _btnQuit;
|
||
[SerializeField] private GameObject _settingsRoot;
|
||
|
||
[Header("Event Channels")]
|
||
[SerializeField] private GameStateEventChannelSO _onGameStateChanged; // ⚠️ 架构 10 §5(原 Plan 遗漏)
|
||
[SerializeField] private VoidEventChannelSO _onResumeRequested;
|
||
|
||
private void Awake()
|
||
{
|
||
_btnResume.onClick.AddListener(Resume);
|
||
_btnSettings.onClick.AddListener(() => _uiManager.OpenPanel(_settingsRoot));
|
||
_btnMainMenu.onClick.AddListener(GoToMainMenu);
|
||
_btnQuit.onClick.AddListener(Application.Quit);
|
||
}
|
||
|
||
private void Resume()
|
||
{
|
||
_onResumeRequested.Raise();
|
||
_uiManager.CloseTopPanel();
|
||
}
|
||
|
||
private void GoToMainMenu();
|
||
// 发布 EVT_SceneLoadRequest(目标 = MainMenuScene)
|
||
}
|
||
|
||
// Assets/Scripts/UI/Menus/DeathScreenController.cs
|
||
// ⚠️ 类名 DeathScreenController(非 DeathPanel);路径 UI/Menus/(非 UI/Panels/)
|
||
public class DeathScreenController : MonoBehaviour
|
||
{
|
||
[SerializeField] private TMP_Text _deathMessage;
|
||
[SerializeField] private Button _btnRespawn;
|
||
|
||
[Header("Event Channels")]
|
||
[SerializeField] private VoidEventChannelSO _onPlayerDied;
|
||
[SerializeField] private VoidEventChannelSO _onDeathScreenConfirmed;
|
||
|
||
private void OnEnable() => _onPlayerDied.OnEventRaised += OnPlayerDied;
|
||
private void OnDisable() => _onPlayerDied.OnEventRaised -= OnPlayerDied;
|
||
|
||
// ⚠️ 必须延迟 1.5s(Architecture 10 §6 修正,AI-82):死亡动画播完后再显示
|
||
private void OnPlayerDied() => StartCoroutine(ShowAfterDelay(1.5f));
|
||
|
||
private IEnumerator ShowAfterDelay(float delay)
|
||
{
|
||
yield return new WaitForSeconds(delay);
|
||
Show();
|
||
}
|
||
|
||
private void Show()
|
||
{
|
||
gameObject.SetActive(true);
|
||
_btnRespawn.onClick.RemoveAllListeners();
|
||
_btnRespawn.onClick.AddListener(Confirm);
|
||
}
|
||
|
||
private void Confirm()
|
||
{
|
||
gameObject.SetActive(false);
|
||
_onDeathScreenConfirmed.Raise();
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 4.6 SettingsPanelController + LoadingOverlay(Architecture 10 §7–§8)
|
||
|
||
```csharp
|
||
// Assets/Scripts/UI/Menus/SettingsPanelController.cs
|
||
public class SettingsPanelController : MonoBehaviour
|
||
{
|
||
[SerializeField] private SettingsManager _settings;
|
||
|
||
[Header("Audio")]
|
||
[SerializeField] private Slider _masterVolume;
|
||
[SerializeField] private Slider _bgmVolume;
|
||
[SerializeField] private Slider _sfxVolume;
|
||
[SerializeField] private Slider _ambientVolume;
|
||
|
||
[Header("Video")]
|
||
[SerializeField] private Toggle _vSyncToggle;
|
||
[SerializeField] private TMP_Dropdown _fpsDropdown;
|
||
[SerializeField] private TMP_Dropdown _resolutionDropdown;
|
||
|
||
private void Start()
|
||
{
|
||
// 从 SettingsManager 读取当前值并填充控件
|
||
_masterVolume.value = _settings.GetMasterVolume();
|
||
_bgmVolume.value = _settings.GetBGMVolume();
|
||
_sfxVolume.value = _settings.GetSFXVolume();
|
||
|
||
// 绑定 onChange 回调
|
||
_masterVolume.onValueChanged.AddListener(_settings.SetMasterVolume);
|
||
_bgmVolume.onValueChanged.AddListener(_settings.SetBGMVolume);
|
||
_sfxVolume.onValueChanged.AddListener(_settings.SetSFXVolume);
|
||
_ambientVolume.onValueChanged.AddListener(_settings.SetAmbientVolume);
|
||
_vSyncToggle.onValueChanged.AddListener(_settings.SetVSync);
|
||
}
|
||
}
|
||
|
||
// Assets/Scripts/UI/LoadingOverlay.cs
|
||
// 由 SceneLoader 通过 EVT_LoadingOverlay(BoolEventChannelSO)控制全屏黑幕渐入渐出
|
||
public class LoadingOverlay : MonoBehaviour
|
||
{
|
||
[SerializeField] private CanvasGroup _canvasGroup;
|
||
[SerializeField] private float _fadeDuration = 0.3f;
|
||
|
||
[Header("Event Channels")]
|
||
[SerializeField] private BoolEventChannelSO _onLoadingOverlayRequested;
|
||
|
||
private void OnEnable() => _onLoadingOverlayRequested.OnEventRaised += SetVisible;
|
||
private void OnDisable() => _onLoadingOverlayRequested.OnEventRaised -= SetVisible;
|
||
|
||
private void SetVisible(bool visible) => StartCoroutine(FadeCoroutine(visible ? 1f : 0f));
|
||
|
||
private IEnumerator FadeCoroutine(float target)
|
||
{
|
||
float start = _canvasGroup.alpha;
|
||
float t = 0;
|
||
while (t < _fadeDuration)
|
||
{
|
||
_canvasGroup.alpha = Mathf.Lerp(start, target, t / _fadeDuration);
|
||
t += Time.unscaledDeltaTime;
|
||
yield return null;
|
||
}
|
||
_canvasGroup.alpha = target;
|
||
_canvasGroup.blocksRaycasts = target > 0.5f;
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 4.7 FloatingDamageText + ToastManager + InputDeviceIconSwitcher(Architecture 10 §10–§12)
|
||
|
||
```csharp
|
||
// Assets/Scripts/UI/FloatingDamageText.cs
|
||
// 从对象池取出,显示伤害数字,向上飘动后归还
|
||
public class FloatingDamageText : MonoBehaviour
|
||
{
|
||
[SerializeField] private TMP_Text _text;
|
||
[SerializeField] private float _floatDistance = 1.5f;
|
||
[SerializeField] private float _duration = 0.8f;
|
||
|
||
private string _poolKey = AddressKeys.UI_FloatingDmgText;
|
||
|
||
public void Show(Vector2 worldPosition, int damage, DamageType type)
|
||
{
|
||
// 1. 世界坐标 → 屏幕坐标(Camera.main.WorldToScreenPoint → RectTransform.position)
|
||
// 2. 颜色:Normal=白, Fire=橙, Poison=绿, True=黄
|
||
// 3. 启动协程:向上漂移 _floatDistance + alpha 淡出,duration 后归还对象池
|
||
}
|
||
}
|
||
|
||
// Assets/Scripts/UI/ToastNotification.cs
|
||
// 右上角通知弹窗(能力解锁、成就)
|
||
public class ToastNotification : MonoBehaviour
|
||
{
|
||
[SerializeField] private TMP_Text _titleText;
|
||
[SerializeField] private TMP_Text _bodyText;
|
||
[SerializeField] private Image _icon;
|
||
[SerializeField] private float _displayDuration = 3f;
|
||
|
||
public void Show(string title, string body, Sprite icon = null)
|
||
{
|
||
_titleText.text = title;
|
||
_bodyText.text = body;
|
||
if (icon != null) _icon.sprite = icon;
|
||
gameObject.SetActive(true);
|
||
StartCoroutine(AutoHide());
|
||
}
|
||
|
||
private IEnumerator AutoHide()
|
||
{
|
||
yield return new WaitForSeconds(_displayDuration);
|
||
gameObject.SetActive(false);
|
||
}
|
||
}
|
||
|
||
// Assets/Scripts/UI/ToastManager.cs
|
||
// 管理通知队列(一次只显示一条)
|
||
public class ToastManager : MonoBehaviour
|
||
{
|
||
[SerializeField] private ToastNotification _toast;
|
||
private Queue<(string title, string body, Sprite icon)> _queue = new();
|
||
|
||
[Header("Event Channels")]
|
||
[SerializeField] private StringEventChannelSO _onAchievementUnlocked; // 成就解锁通知
|
||
|
||
private void OnEnable() => _onAchievementUnlocked.OnEventRaised += OnAchievementUnlocked;
|
||
private void OnDisable() => _onAchievementUnlocked.OnEventRaised -= OnAchievementUnlocked;
|
||
|
||
private void OnAchievementUnlocked(string achievementId)
|
||
=> Enqueue("成就解锁", achievementId, null);
|
||
|
||
public void Enqueue(string title, string body, Sprite icon = null)
|
||
{
|
||
_queue.Enqueue((title, body, icon));
|
||
if (!_toast.gameObject.activeSelf) ShowNext();
|
||
}
|
||
|
||
private void ShowNext()
|
||
{
|
||
if (_queue.Count == 0) return;
|
||
var (title, body, icon) = _queue.Dequeue();
|
||
_toast.Show(title, body, icon);
|
||
}
|
||
}
|
||
|
||
// Assets/Scripts/UI/InputDeviceIconSwitcher.cs
|
||
// 检测输入设备切换(KB/手柄),自动替换 UI 按键图标
|
||
public class InputDeviceIconSwitcher : MonoBehaviour
|
||
{
|
||
[SerializeField] private InputDeviceIconSetSO _kbIconSet;
|
||
[SerializeField] private InputDeviceIconSetSO _padIconSet;
|
||
|
||
[Header("Event Channel")]
|
||
[SerializeField] private BoolEventChannelSO _onDeviceChanged; // true = 手柄
|
||
|
||
private void OnEnable() => _onDeviceChanged.OnEventRaised += SwitchIconSet;
|
||
private void OnDisable() => _onDeviceChanged.OnEventRaised -= SwitchIconSet;
|
||
|
||
private void SwitchIconSet(bool isGamepad)
|
||
{
|
||
var set = isGamepad ? _padIconSet : _kbIconSet;
|
||
foreach (var iconImg in FindObjectsOfType<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; // 0–1
|
||
|
||
private void OnEnable()
|
||
{
|
||
_onLoadingStarted.OnEventRaised += Show;
|
||
_onLoadingComplete.OnEventRaised += Hide;
|
||
_onLoadingProgressUpdated.OnEventRaised += SetProgress;
|
||
}
|
||
private void OnDisable()
|
||
{
|
||
_onLoadingStarted.OnEventRaised -= Show;
|
||
_onLoadingComplete.OnEventRaised -= Hide;
|
||
_onLoadingProgressUpdated.OnEventRaised -= SetProgress;
|
||
}
|
||
|
||
private void Show()
|
||
{
|
||
_loadingRoot.SetActive(true);
|
||
_progressFill.fillAmount = 0f;
|
||
foreach (var bg in _backgroundArts) bg.enabled = false;
|
||
_backgroundArts[Random.Range(0, _backgroundArts.Length)].enabled = true;
|
||
_tipText.text = LocalizationManager.Get("UI", _tipKeys[Random.Range(0, _tipKeys.Length)]);
|
||
}
|
||
|
||
private void Hide() => StartCoroutine(HideAfterMinTime());
|
||
private void SetProgress(float v) => _progressFill.fillAmount = v;
|
||
|
||
private IEnumerator HideAfterMinTime()
|
||
{
|
||
yield return new WaitForSecondsRealtime(_minDisplayTime);
|
||
_loadingRoot.SetActive(false);
|
||
}
|
||
}
|
||
|
||
// Assets/Scripts/UI/HUD/IBossHPProvider.cs
|
||
// ⚠️ 架构 10 §7.8(原 Plan 遗漏):解耦接口,让 BossHPBar 不直接依赖 BossBase
|
||
public interface IBossHPProvider
|
||
{
|
||
string BossId { get; }
|
||
string BossNameKey { get; }
|
||
float HPRatio { get; } // 0–1 实时 HP 比例
|
||
int TotalPhases { get; }
|
||
float[] PhaseThresholds { get; } // 各阶段切换 HP 阈值
|
||
}
|
||
|
||
// Assets/Scripts/UI/DialogueBox.cs
|
||
// ⚠️ 架构 10 §9(原 Plan 遗漏):对话框组件,由 DialogueManager 直接调用(不经过事件频道)
|
||
// 挂载在 Canvas_Overlay/DialogueBox 子对象
|
||
public class DialogueBox : MonoBehaviour
|
||
{
|
||
[SerializeField] private TMP_Text _speakerNameText;
|
||
[SerializeField] private TMP_Text _dialogueText;
|
||
[SerializeField] private GameObject _continuePrompt;
|
||
|
||
public void Show(string speakerName, string text, bool showContinue);
|
||
public void Hide();
|
||
|
||
// DialogueManager 在 PlaySequence 中 yield return 此协程(实现打字机效果)
|
||
public IEnumerator TypeText(string text, float charDelay = 0.03f);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 5. Week 18:支撑模块 + 编辑器工具 + QA ✅ 完成(2026-05-11)
|
||
|
||
**参考文档**:`16_SupportingModules.md`
|
||
|
||
> **✅ P4-5 验证状态(已完成验证)**
|
||
> 所有支撑模块文件已按架构 §1-§9 逐一对比,以下为最终裁定:
|
||
>
|
||
> | 模块 | 状态 | 说明 |
|
||
> |------|------|------|
|
||
> | `IPlatformService` | ✅ 已修复 | 补充了 `RunCallbacks`/`Shutdown`/`SetStat`/`IncrementStat`/`GetStat`/`IsCloudAvailable`/`CloudSaveAsync`/`CloudLoadAsync`/`SetRichPresence`/`ClearRichPresence`;保留代码额外扩展(排行榜/DLC/ShowOverlay/ClearAchievement)|
|
||
> | `PlatformBootstrap` | ✅ 已修复 | 增加 `Update()→RunCallbacks()` + `OnApplicationQuit()→Shutdown()`|
|
||
> | `NullPlatformService` | ✅ 已更新 | 实现完整接口 |
|
||
> | `SteamPlatformService` | ✅ 已更新 | 增加 SetStat/IncrementStat/GetStat、CloudSaveAsync/CloudLoadAsync、SetRichPresence/ClearRichPresence、RunCallbacks/Shutdown |
|
||
> | `AnalyticsManager` | ✅ 已修复 | Awake 中添加 `#if !UNITY_EDITOR && !DEVELOPMENT_BUILD` 保护(Release 包默认关闭);保留缓冲 JSON 格式(非架构 JSONL,可接受)|
|
||
> | `AchievementCondition` | ✅ 维持代码 | `IsMet(SaveData)` 轮询比架构的 RegisterListeners 事件模式更简洁;`GetProgress(SaveData)` 为额外增强 |
|
||
> | `AchievementManager` | ✅ 维持代码 | `ServiceLocator.Get<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 AchievementCondition(ScriptableObject 策略模式)
|
||
|
||
```csharp
|
||
// 路径: Assets/Scripts/Support/Achievements/AchievementCondition.cs
|
||
// ⚠️ 抽象基类为 ScriptableObject(非 [Serializable] class),每种条件一个 SO 子类(架构 16 §2.2)
|
||
namespace BaseGames.Achievement
|
||
{
|
||
public abstract class AchievementCondition : ScriptableObject
|
||
{
|
||
public abstract void RegisterListeners(AchievementManager manager);
|
||
public abstract void UnregisterListeners(AchievementManager manager);
|
||
public abstract bool IsMet(AchievementRuntimeState state);
|
||
}
|
||
}
|
||
```
|
||
|
||
**内置条件类型(12 种)**:
|
||
|
||
| SO 子类 | menuName | 参数 |
|
||
|---------|---------|------|
|
||
| `DefeatedBossCondition` | `Achievement/Condition/DefeatedBoss` | `bossId: string` |
|
||
| `DefeatedAllBossesCondition` | `Achievement/Condition/DefeatedAllBosses` | — |
|
||
| `EnteredRegionCondition` | `Achievement/Condition/EnteredRegion` | `regionId: string` |
|
||
| `MapExplorationCondition` | `Achievement/Condition/MapExploration` | `minPercent: float` |
|
||
| `CollectedItemCondition` | `Achievement/Condition/CollectedItem` | `itemId: string` |
|
||
| `CollectedAllCharmsCondition` | `Achievement/Condition/CollectedAllCharms` | — |
|
||
| `UnlockedAllAbilitiesCondition` | `Achievement/Condition/UnlockedAllAbilities` | — |
|
||
| `NoHealRunCondition` | `Achievement/Condition/NoHealRun` | — |
|
||
| `TimedBossKillCondition` | `Achievement/Condition/TimedBossKill` | `bossId`, `maxSeconds` |
|
||
| `ParryCountCondition` | `Achievement/Condition/ParryCount` | `requiredCount: int` |
|
||
| `NailClashCountCondition` | `Achievement/Condition/NailClashCount` | `requiredCount: int` |
|
||
| `EventTriggeredCondition` | `Achievement/Condition/EventTriggered` | `eventChannelSO` |
|
||
|
||
```csharp
|
||
// 示例:DefeatedBossCondition
|
||
[CreateAssetMenu(menuName = "Achievement/Condition/DefeatedBoss")]
|
||
public class DefeatedBossCondition : AchievementCondition
|
||
{
|
||
public string bossId;
|
||
|
||
public override void RegisterListeners(AchievementManager manager)
|
||
=> manager.OnBossDefeated += Evaluate;
|
||
public override void UnregisterListeners(AchievementManager manager)
|
||
=> manager.OnBossDefeated -= Evaluate;
|
||
|
||
void Evaluate(string defeatedBossId, AchievementRuntimeState state)
|
||
{
|
||
if (defeatedBossId == bossId) state.SetConditionMet(this);
|
||
}
|
||
|
||
public override bool IsMet(AchievementRuntimeState state)
|
||
=> state.IsConditionMet(this);
|
||
}
|
||
```
|
||
|
||
#### 5.2.3 AchievementManager + AchievementRuntimeState
|
||
|
||
```csharp
|
||
// 路径: Assets/Scripts/Support/Achievements/AchievementManager.cs
|
||
// ⚠️ 命名空间:namespace BaseGames.Achievement(架构 16 §2.3)
|
||
namespace BaseGames.Achievement
|
||
{
|
||
public class AchievementManager : MonoBehaviour, ISaveable
|
||
{
|
||
[Header("成就列表(每个成就一个 AchievementSO 资产)")]
|
||
[SerializeField] AchievementSO[] _allAchievements; // ⚠️ 非旧版 AchievementDatabaseSO
|
||
|
||
[Header("事件频道(订阅)")]
|
||
[SerializeField] StringEventChannelSO _onBossDefeated;
|
||
[SerializeField] StringEventChannelSO _onCollectiblePickedUp;
|
||
[SerializeField] IntEventChannelSO _onAbilityUnlocked;
|
||
[SerializeField] StringEventChannelSO _onRoomEntered;
|
||
[SerializeField] VoidEventChannelSO _onParrySuccess;
|
||
[SerializeField] VoidEventChannelSO _onNailClash;
|
||
|
||
[Header("事件频道(发布)")]
|
||
[SerializeField] AchievementEventChannelSO _onAchievementUnlocked; // ⚠️ AchievementEventChannelSO(非 StringEventChannelSO)
|
||
|
||
// ── 内部中继 C# 事件(供 AchievementCondition 子类订阅)──────────────
|
||
public event Action<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 + IPlatformService(Steam 集成)
|
||
|
||
> **⚠️ 架构 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 §2);Debug 上下文用 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 无 Instance;Architecture 03 §3)
|
||
_onSceneLoadRequest.Raise(new SceneLoadRequest
|
||
{ SceneName = parts[1], EntryTransitionId = "Default" });
|
||
break;
|
||
case "unlock" when parts.Length > 1:
|
||
player?.OnAbilityUnlocked(parts[1]);
|
||
break;
|
||
case "killall":
|
||
// ⚠️ DamageInfo 无单参数构造函数(Architecture 06 §1);使用 Builder 模式(架构 16 §4 patch)
|
||
var killDmg = new DamageInfo.Builder().SetRaw(99999).Build();
|
||
foreach (var e in FindObjectsOfType<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 无 Instance(Architecture 05 §2);AntiSoftlock 在 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 无 Instance;Architecture 03 §3)
|
||
_onSceneLoadRequest.Raise(new SceneLoadRequest
|
||
{
|
||
SceneName = SaveManager.LastCheckpointScene,
|
||
EntryTransitionId = SaveManager.LastCheckpointSpawnId,
|
||
IsRespawn = true
|
||
});
|
||
}
|
||
}
|
||
```
|
||
|
||
### 5.5.1 RoomEscapeInfoSO(⚠️ 架构 16_SupportingModules §5.1)
|
||
|
||
```csharp
|
||
// Assets/Scripts/Support/AntiSoftlock/RoomEscapeInfoSO.cs
|
||
// ⚠️ 每个房间场景必须挂载此 SO,记录"最低能力集合即可离开此房间"
|
||
// 编辑器工具自动验证可达性,无法逃离的死局则标红警告(架构 16 §5.1)
|
||
namespace BaseGames.Progression
|
||
{
|
||
[CreateAssetMenu(menuName = "Progression/RoomEscapeInfo")]
|
||
public class RoomEscapeInfoSO : ScriptableObject
|
||
{
|
||
[Header("房间标识")]
|
||
public string sceneAddress; // 对应 Addressable 场景地址
|
||
|
||
[Header("逃离要求(满足任一路线即视为可逃离)")]
|
||
public EscapeRoute[] escapeRoutes;
|
||
|
||
[Header("单向入口警告")]
|
||
public bool hasOneWayEntry; // 是否有单向进入点(如跌落入口)
|
||
[TextArea(1, 3)]
|
||
public string designerNotes;
|
||
|
||
[Serializable]
|
||
public class EscapeRoute
|
||
{
|
||
public string routeLabel; // 如 "向左回到 Forest_Main"
|
||
public string targetSceneAddress; // 逃离到达的目标房间
|
||
public AbilityType[] requiredAbilities; // 空 = 无需任何能力即可离开
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 5.5.2 HardAbilityGate(⚠️ 架构 16_SupportingModules §5.2)
|
||
|
||
```csharp
|
||
// Assets/Scripts/Support/AntiSoftlock/HardAbilityGate.cs
|
||
// ⚠️ 增强型能力门:防止玩家用精准时机绕过只检查标志的 AbilityGate(架构 16 §5.2)
|
||
namespace BaseGames.Progression
|
||
{
|
||
/// <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(非 GlobalSettingsSO),menuName = "Accessibility/AccessibilitySettings"(架构 16 §6)
|
||
[CreateAssetMenu(menuName = "Accessibility/AccessibilitySettings")]
|
||
public class AccessibilitySettingsSO : ScriptableObject
|
||
{
|
||
// ── 视觉无障碍 ──────────────────────────────────────────────────────────
|
||
[Header("色盲模式")]
|
||
public ColorBlindMode colorBlindMode = ColorBlindMode.None;
|
||
public bool highContrastMode = false;
|
||
public float gameContrastBoost = 0f; // 0~1.0
|
||
|
||
// ── 运动无障碍 ──────────────────────────────────────────────────────────
|
||
[Header("运动敏感度")]
|
||
public bool disableScreenShake = false;
|
||
public bool disableCameraMotion = false;
|
||
public float cameraMotionScale = 1f; // 0~1.0(0 = 完全关闭)
|
||
public bool reduceParticleEffects = false;
|
||
public bool disableFlashingEffects = false;
|
||
public int flashFrequencyLimit = 3;
|
||
|
||
// ── 字幕 ────────────────────────────────────────────────────────────────
|
||
[Header("字幕系统")]
|
||
public bool subtitlesEnabled = false;
|
||
public bool sfxSubtitlesEnabled = false;
|
||
public float subtitleFontSizeMultiplier = 1f; // 0.75~2.0
|
||
public bool subtitleBackgroundEnabled = true;
|
||
public float subtitleBackgroundOpacity = 0.7f;
|
||
public bool speakerNameEnabled = true;
|
||
|
||
// ── 输入辅助 ────────────────────────────────────────────────────────────
|
||
[Header("输入辅助")]
|
||
public bool autoParryAssist = false;
|
||
public float parryWindowExtension = 0f; // 弹反窗口扩展(秒),0~0.2(ParrySystem 读取此字段)
|
||
public bool holdToMash = false;
|
||
public bool stickyJump = false;
|
||
public bool autoClimb = false;
|
||
|
||
// ── 音频无障碍 ────────────────────────────────────────────────────────
|
||
[Header("音频无障碍")]
|
||
public bool monoAudio = false;
|
||
public float leftRightBalance = 0f; // -1~+1
|
||
public bool visualDangerIndicator = false;
|
||
}
|
||
|
||
// ⚠️ ColorBlindMode 枚举:5 个值(含 Achromatopsia)(架构 16 §6)
|
||
public enum ColorBlindMode
|
||
{
|
||
None, // 无(默认)
|
||
Protanopia, // 红色盲
|
||
Deuteranopia, // 绿色盲
|
||
Tritanopia, // 蓝黄色盲
|
||
Achromatopsia, // ⚠️ 全色盲(高对比灰度)(架构 16 §6,4 值版本遗漏此项)
|
||
}
|
||
```
|
||
|
||
#### AccessibilityManager
|
||
|
||
```csharp
|
||
// Assets/Scripts/Support/Accessibility/AccessibilityManager.cs
|
||
// ⚠️ 单例模式(Instance 属性);串联到 FeedbackSystem/ParrySystem(架构 16 §6)
|
||
public class AccessibilityManager : MonoBehaviour
|
||
{
|
||
public static AccessibilityManager Instance { get; private set; }
|
||
|
||
[SerializeField] private AccessibilitySettingsSO _settings; // ⚠️ AccessibilitySettingsSO,非 GlobalSettingsSO
|
||
public AccessibilitySettingsSO Settings => _settings;
|
||
|
||
// ── Event Channels(Raise 方)───────────────────────────────────────────
|
||
[SerializeField] private ColorBlindModeEventChannelSO _onColorBlindModeChanged; // ⚠️ 大写 B(架构 16 §6)
|
||
[SerializeField] private BoolEventChannelSO _onHighContrastChanged; // ⚠️ Changed(非 Toggled)
|
||
[SerializeField] private BoolEventChannelSO _onSubtitlesChanged; // ⚠️ Changed(非 Toggled)
|
||
[SerializeField] private BoolEventChannelSO _onScreenShakeChanged; // ⚠️ 架构 16 §7 清单
|
||
|
||
private void Awake()
|
||
{
|
||
if (Instance != null) { Destroy(gameObject); return; }
|
||
Instance = this;
|
||
DontDestroyOnLoad(gameObject);
|
||
}
|
||
|
||
// 由 SettingsPanelController 调用的公开 API(架构 16 §6)
|
||
public void ApplySettings()
|
||
{
|
||
_onColorBlindModeChanged.Raise(_settings.colorBlindMode);
|
||
_onHighContrastChanged.Raise(_settings.highContrastMode);
|
||
_onSubtitlesChanged.Raise(_settings.subtitlesEnabled);
|
||
_onScreenShakeChanged.Raise(!_settings.disableScreenShake);
|
||
}
|
||
|
||
public void SetColorBlindMode(ColorBlindMode mode)
|
||
{ _settings.colorBlindMode = mode; ApplySettings(); }
|
||
public void SetAutoParryAssist(bool v)
|
||
{ _settings.autoParryAssist = v; ApplySettings(); }
|
||
public void SetParryWindowExtension(float sec)
|
||
{ _settings.parryWindowExtension = sec; ApplySettings(); }
|
||
public void SetDisableScreenShake(bool v)
|
||
{ _settings.disableScreenShake = v; ApplySettings(); }
|
||
public void SetCameraMotionScale(float s)
|
||
{ _settings.cameraMotionScale = s; ApplySettings(); }
|
||
public void SetMonoAudio(bool v)
|
||
{ _settings.monoAudio = v; ApplySettings(); }
|
||
public void SetVisualDangerIndicator(bool v)
|
||
{ _settings.visualDangerIndicator = v; ApplySettings(); }
|
||
|
||
// ⚠️ 供 FeedbackSystem / ParrySystem 查询(静态方法)(架构 16 §6)
|
||
public static bool CanPlayScreenShake()
|
||
=> Instance == null || !Instance.Settings.disableScreenShake;
|
||
}
|
||
```
|
||
|
||
> **parryWindowExtension 集成**:`ParrySystem` 计算弹反窗口时读取此值:
|
||
> `float window = _config.ParryWindowDuration + (AccessibilityManager.Instance?.Settings.parryWindowExtension ?? 0f);`
|
||
|
||
#### ColorBlindFilter(URP Renderer Feature)
|
||
|
||
```csharp
|
||
// Assets/Scripts/Accessibility/ColorBlindFilter.cs
|
||
// ⚠️ URP 2D 后处理最终合成阶段应用色彩矩阵变换(架构 16 §6)
|
||
// 在 URP 2D Renderer Data(Assets/Settings/URP2DRenderer.asset)中添加此 Feature
|
||
public class ColorBlindFilter : ScriptableRendererFeature
|
||
{
|
||
[SerializeField] ColorBlindMode _mode;
|
||
|
||
// 色彩矩阵(3×3,基于 Brettel et al. 1997 算法)
|
||
private static readonly Dictionary<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()` 下穿 API;Editor Gizmo 蓝色线框 |
|
||
> | `DestructibleTileEditor` | ✅ 已创建 | `Assets/Scripts/Editor/World/DestructibleTileEditor.cs`;`[DrawGizmo]` 橙红线框+半透明填充;`Handles.Label` "💥" 标签;选中/未选中透明度区分 |
|
||
> | `NavSurfaceBakeShortcut` | ✅ 已创建 | `Assets/Scripts/Editor/NavSurfaceBakeShortcut.cs`;`[MenuItem("BaseGames/Tools/Bake All NavSurfaces %#b")]`;`EditorApplication.update` 监听完成回调;打印每个 Surface 烘焙用时 |
|
||
> | `BossSkillSequenceWindow` | ✅ 已创建 | `Assets/Scripts/Editor/BossSkillSequenceWindow.cs`;`BossSkillSO`/`SkillSequenceSO` 甘特图;Windup黄/Active红/Recovery灰/Vuln绿/Delay暗灰;拖放加载;点击标签 PingObject |
|
||
> | `EventChainEditorWindow` | ✅ 已创建 | `Assets/Scripts/Editor/EventChainEditorWindow.cs`;左侧链列表(完成=绿/激活=橙/未触发=灰);右侧条件+动作表;Play Mode 反射读取 `_completedChains`;`ChainCompletedCondition` 依赖链显示;执行日志 20 条 |
|
||
> | `AddressReferenceGraphWindow` | ✅ 已创建 | `Assets/Scripts/Editor/AddressReferenceGraphWindow.cs`;反射遍历 `AddressKeys` 常量;Regex 扫描所有 `.cs` 文件;孤儿 Key 红/无效 Key 橙/正常绿;导出 CSV |
|
||
> | `AchievementSOEditor` | ✅ 已创建 | `Assets/Scripts/Editor/Achievements/AchievementSOEditor.cs`;12 种条件中文标签映射;内联 `SerializedObject` 子 SO 字段;Ping/Delete 按钮 |
|
||
> | `BaseGames.Editor.asmdef` | ✅ 已更新 | 新增 `"BaseGames.EventChain"` 引用,`EventChainEditorWindow` 可正常编译 |
|
||
|
||
### 5.10 QA 执行
|
||
|
||
**性能基准**(目标:Switch/中端 PC 60fps,1080p):
|
||
|
||
| 测试场景 | 目标 | 工具 |
|
||
|---------|------|------|
|
||
| 10 个敌人同时 Pathfinding | < 1ms PathBerserker2d 消耗 | Unity Profiler |
|
||
| 100 个粒子同时播放 | < 2ms VFX 消耗 | Profiler |
|
||
| 场景加载(中等房间) | < 2s(含 FadeOut/FadeIn)| 手动计时 |
|
||
| 存档读写 | < 200ms | Stopwatch |
|
||
| GC Alloc/帧 | < 1KB(Gameplay 稳定状态)| Profiler |
|
||
|
||
**功能回归测试清单**(所有 Phase 完成标准的并集)
|
||
|
||
---
|
||
|
||
## 6. 完成标准检查清单
|
||
|
||
```
|
||
□ BossSkillExecutor:执行 LeapSlam 技能时 VulnerabilityWindow 后摇 1s 内弱点 HurtBox 激活
|
||
□ Boss Phase 切换:HP 降至 50% → Phase 过渡动画 → 新技能集解锁
|
||
□ DialogueManager:打字机效果 + 快进 + 最后一行结束后关闭对话框 + 恢复 Gameplay Map
|
||
□ InteractableNPC:不同游戏进度对话切换正确(ConditionalVariant 选择)
|
||
□ CutsceneManager:播放 Timeline 期间玩家无法移动,播放结束后恢复控制
|
||
□ EventChain:世界事件链(对话+设置标志+奖励 Geo)按顺序完整执行
|
||
□ PausePanel:Escape 暂停 → PausePanel 显示 → 继续游戏恢复 Time.timeScale
|
||
□ InventoryPanel:装备护符 → 效果生效 → 凹槽计数更新
|
||
□ SettingsPanel:音量滑条调整 → AudioMixer 参数实时变化
|
||
□ RebindPanel:重绑跳跃键 → 新键能正常触发跳跃 → 重启后绑定持久化(⚠️ 类名 RebindPanel,架构 04 §6)
|
||
□ LocalizationManager:切换语言 → UI/对话/道具名称实时更新
|
||
□ AchievementManager:首次击杀敌人 → ACH_FirstKill 解锁 → 存档记录 → 重启后不重复
|
||
□ DebugCheatSystem:仅在 Editor/Development Build 中可用,BackQuote(`)开关控制台,godmode 命令生效
|
||
□ AccessibilityManager:色盲模式/字幕/高对比模式切换均实时生效
|
||
□ AntiSoftlockSystem:60s 静止后显示提示(非 30s)
|
||
□ AddressKeysValidator:无 Warning(所有 key 存在)
|
||
□ EventChannelAuditor:无孤立未被订阅的事件频道
|
||
□ RoomValidator:所有房间场景通过验证
|
||
□ Profiler:Gameplay 稳定状态 GC Alloc/帧 < 1KB
|
||
□ Console 无 Error(发布构建无 Debug.Log)
|
||
```
|
||
|
||
---
|
||
|
||
## 7. 发布前技术 Checklist
|
||
|
||
完成以下所有项后,技术层面达到发布就绪(Release Candidate)状态:
|
||
|
||
```
|
||
架构
|
||
□ 所有 asmdef 依赖方向正确(无循环依赖)
|
||
□ 无 using 直接引用非依赖 asmdef 的类型
|
||
|
||
存档
|
||
□ SaveData 版本迁移路径完整(v1→v2→…→current)
|
||
□ SteelSoul 死亡删档流程二次确认 UI 存在
|
||
□ 存档文件 SHA-256 校验通过
|
||
|
||
构建
|
||
□ Development Build 关闭 DebugCheatSystem 快捷键
|
||
□ Release Build 关闭所有 Debug.Log(或通过 #if UNITY_EDITOR 过滤)
|
||
□ Addressables Remote 资产 CDN URL 指向 Production
|
||
□ IL2CPP 构建无 Managed Stripping 报错
|
||
|
||
平台
|
||
□ Steam 成就 API 集成测试(TestApp 环境)
|
||
□ 手柄全功能测试(PS5/Xbox 手柄)
|
||
□ 键盘+鼠标全功能测试
|
||
□ 分辨率:1080p/1440p/2160p 均无 UI 错位
|
||
|
||
性能
|
||
□ 关卡中最差帧率 > 55fps(Switch 目标)
|
||
□ 内存占用 < 1.5GB(PC)/ < 1GB(Switch)
|
||
□ 加载时间 < 3s(所有房间)
|
||
|
||
内容
|
||
□ 所有已实现功能有本地化文本(zh-CN + en)
|
||
□ 所有 NPC 有至少 1 个 DialogueSequence
|
||
□ 所有 Boss 有完整技能套(≥2 阶段)
|
||
```
|
||
|
||
**Phase 4 完成 = 游戏技术层就绪,进入关卡/内容填充阶段。**
|