# 32 · 成就系统(Achievement System) > **命名空间** `BaseGames.Achievement` > **所属文档集** [← 返回索引](./README.md) · [总览](./00_Overview.md) > **依赖** `BaseGames.Core.Events` · `BaseGames.Progression` · `BaseGames.UI` · `BaseGames.World`(SaveManager) --- ## 目录 1. [系统总览](#1-系统总览) 2. [AchievementSO — 成就数据](#2-achievementso--成就数据) 3. [AchievementCondition — 解锁条件](#3-achievementcondition--解锁条件) 4. [AchievementManager](#4-achievementmanager) 5. [成就通知 UI(AchievementToast)](#5-成就通知-uiachievementtoast) 6. [成就面板(AchievementPanel)](#6-成就面板achievementpanel) 7. [SaveData 集成](#7-savedata-集成) 8. [内置成就清单](#8-内置成就清单) 9. [事件频道](#9-事件频道) 10. [编辑器友好设计](#10-编辑器友好设计) --- ## 1. 系统总览 成就系统追踪玩家在整局游戏中的里程碑行为(击败 Boss、收集物品、达成挑战),并在满足条件时弹出通知、持久化解锁状态。对标《空洞骑士》的 60+ 成就体系设计,支持三种类型的成就。 ``` 成就系统职责: ├─ AchievementSO → 成就数据 SO(名称/描述/条件/图标/类型) ├─ AchievementCondition → 解锁条件(抽象基类 + 多种内置实现) ├─ AchievementManager → 监听全局事件,评估并解锁成就 ├─ AchievementToast → 解锁时右上角弹出通知 UI(2 秒后自动消失) └─ AchievementPanel → 游戏内成就列表面板(暂停菜单子页) ``` **零耦合原则**:`AchievementManager` 只订阅 SO 事件频道,不直接引用 `PlayerController`、`EnemyBase` 等游戏系统。条件评估完全通过事件驱动。 --- ## 2. AchievementSO — 成就数据 ```csharp [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; // 成就图标(64×64 推荐) public Sprite hiddenIcon; // 未解锁时显示的占位图标 [Header("分类")] public AchievementType type; // 故事/收集/挑战/隐藏 public AchievementTier tier; // 铜/银/金(仅展示用) [Header("解锁条件")] public AchievementCondition[] conditions;// 所有条件同时满足才解锁(AND 逻辑) [Header("奖励(可选)")] public bool grantsNotch; // 解锁额外 Notch 槽(用于特殊成就) } public enum AchievementType { Story, // 故事成就(跟随主线自动获得,不隐藏) Collection, // 收集成就(收集特定物品、探索地图) Challenge, // 挑战成就(不受伤通关、限时等高难度行为) Hidden, // 隐藏成就(默认不显示名称与描述) } public enum AchievementTier { Bronze, Silver, Gold, } ``` --- ## 3. AchievementCondition — 解锁条件 条件使用 **ScriptableObject 策略模式**,每种条件一个子类,可自由组合。 ### 3.1 基类定义 ```csharp /// /// 成就解锁条件抽象基类。每次相关事件触发时调用 Evaluate()。 /// public abstract class AchievementCondition : ScriptableObject { // 供 AchievementManager 注册事件监听 public abstract void RegisterListeners(AchievementManager manager); public abstract void UnregisterListeners(AchievementManager manager); // 当前是否已满足 public abstract bool IsMet(AchievementRuntimeState state); } ``` ### 3.2 内置条件类型 | 条件类型 | SO 类名 | 参数 | 示例 | |---------|---------|------|------| | 击败指定 Boss | `DefeatedBossCondition` | `bossId: string` | `bossId = "Boss_Forest"` | | 击败全部 Boss | `DefeatedAllBossesCondition` | — | 击败5区域Boss | | 到达特定区域 | `EnteredRegionCondition` | `regionId: RegionId` | `regionId = RegionId.Abyss` | | 探索地图百分比 | `MapExplorationCondition` | `minPercent: float` | `minPercent = 0.9` | | 收集指定物品 | `CollectedItemCondition` | `itemId: string` | `itemId = "Charm_QuickSlash"` | | 收集全部魅力 | `CollectedAllCharmsCondition` | — | 集满全部 N 个魅力 | | 解锁全部能力 | `UnlockedAllAbilitiesCondition` | — | — | | 不使用治疗通关 | `NoHealRunCondition` | — | 挑战成就 | | 在X秒内击败Boss | `TimedBossKillCondition` | `bossId`, `maxSeconds` | 30s 内击败 | | 弹反指定次数 | `ParryCountCondition` | `requiredCount: int` | 弹反 10 次 | | 拼刀触发次数 | `NailClashCountCondition` | `requiredCount: int` | 拼刀 5 次 | | 自定义事件触发 | `EventTriggeredCondition` | `eventChannelSO` | 监听任意 VoidEvent | ### 3.3 示例:DefeatedBossCondition ```csharp [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); } ``` --- ## 4. AchievementManager AchievementManager 是 **Persistent 场景**中的单例 MonoBehaviour,统一监听全局游戏事件并评估所有成就。 ```csharp namespace BaseGames.Achievement { public class AchievementManager : MonoBehaviour { [Header("成就列表")] [SerializeField] AchievementSO[] _allAchievements; [Header("事件频道(订阅)")] [SerializeField] StringEventChannelSO _onBossDefeated; [SerializeField] StringEventChannelSO _onCollectiblePickedUp; [SerializeField] IntEventChannelSO _onAbilityUnlocked; [SerializeField] StringEventChannelSO _onRoomEntered; [SerializeField] VoidEventChannelSO _onParrySuccess; [SerializeField] VoidEventChannelSO _onNailClash; [Header("事件频道(发布)")] [SerializeField] AchievementEventChannelSO _onAchievementUnlocked; // 内部中继事件,供 AchievementCondition 订阅 public event Action OnBossDefeated; public event Action OnCollectiblePickedUp; public event Action OnAbilityUnlocked; public event Action OnRoomEntered; public event Action OnParrySuccess; public event Action OnNailClash; readonly Dictionary _states = new(); void Awake() { // 初始化运行时状态(从 SaveData 加载已解锁成就) 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; SaveManager.Instance.SetAchievementUnlocked(ach.achievementId); _onAchievementUnlocked.Raise(ach); // → AchievementToast + Analytics } } } ``` ### AchievementRuntimeState ```csharp public class AchievementRuntimeState { public bool IsUnlocked { get; set; } readonly HashSet _metConditions = new(); public AchievementRuntimeState(AchievementSO ach) { IsUnlocked = SaveManager.Instance.IsAchievementUnlocked(ach.achievementId); } public void SetConditionMet(AchievementCondition cond) => _metConditions.Add(cond); public bool IsConditionMet(AchievementCondition cond) => _metConditions.Contains(cond); } ``` --- ## 5. 成就通知 UI(AchievementToast) 解锁成就后右上角弹出通知卡片,2 秒后自动淡出。 ``` [AchievementToast] ├── CanvasGroup(用于 Alpha 淡入淡出) ├── Image_Icon ← AchievementSO.icon ├── TMP_DisplayName ← "成就解锁:{displayName}" ├── TMP_Tier ← "★ 铜/银/金" └── AchievementToast.cs ``` ```csharp public class AchievementToast : MonoBehaviour { [SerializeField] AchievementEventChannelSO _onAchievementUnlocked; [SerializeField] Image _icon; [SerializeField] TMP_Text _nameTmp; [SerializeField] TMP_Text _tierTmp; [SerializeField] float _displayDuration = 2.5f; [SerializeField] float _fadeDuration = 0.4f; readonly Queue _queue = new(); bool _isShowing; void OnEnable() => _onAchievementUnlocked.OnEventRaised += EnqueueToast; void OnDisable() => _onAchievementUnlocked.OnEventRaised -= EnqueueToast; void EnqueueToast(AchievementSO ach) { _queue.Enqueue(ach); if (!_isShowing) StartCoroutine(ShowNext()); } IEnumerator ShowNext() { while (_queue.Count > 0) { _isShowing = true; var ach = _queue.Dequeue(); _icon.sprite = ach.icon; _nameTmp.text = ach.displayName; _tierTmp.text = ach.tier.ToString(); // 淡入 yield return FadeTo(1f, _fadeDuration); yield return new WaitForSecondsRealtime(_displayDuration); // 淡出 yield return FadeTo(0f, _fadeDuration); } _isShowing = false; } IEnumerator FadeTo(float target, float duration) { /* DOTween/Lerp 实现 */ yield break; } } ``` --- ## 6. 成就面板(AchievementPanel) 暂停菜单中"成就"选项卡,展示全部成就列表,区分已解锁/隐藏状态。 ``` 布局: ├── 顶部 Tabs:故事 | 收集 | 挑战 | 隐藏 ├── ScrollRect(成就卡片列表) │ └── AchievementCard(预制件实例)×N │ ├── Image_Icon │ ├── TMP_Name (已解锁 = 全名;隐藏未解锁 = "???") │ ├── TMP_Desc │ └── Image_Tier (铜/银/金 勋章图标) └── 底部文字:"已解锁 {n}/{total}" ``` **AchievementCard 绑定逻辑**: ```csharp public void Bind(AchievementSO ach, bool isUnlocked) { bool visible = isUnlocked || ach.type != AchievementType.Hidden; _nameText.text = visible ? ach.displayName : "???"; _descText.text = visible ? ach.description : ach.hiddenDescription; _icon.sprite = isUnlocked ? ach.icon : ach.hiddenIcon; // 已解锁高亮,未解锁灰色 _icon.color = isUnlocked ? Color.white : new Color(0.4f, 0.4f, 0.4f); } ``` --- ## 7. SaveData 集成 在 `31_SaveDataSchema_Unified.md` 基础上新增 `achievements` 字段: ```json "achievements": { "unlocked": [ "Ach_SlayBoss_Forest", "Ach_Parry_10", "Ach_CollectCharm_QuickSlash" ], "progress": { "Ach_Parry_100": { "count": 47 }, "Ach_MapExplore90": { "percent": 0.63 } } } ``` ```csharp // SaveManager 扩展 public bool IsAchievementUnlocked(string id) => _saveData.achievements.unlocked.Contains(id); public void SetAchievementUnlocked(string id) { if (!_saveData.achievements.unlocked.Contains(id)) { _saveData.achievements.unlocked.Add(id); WriteDirty(); // 标记需要写盘(下次 SavePoint 时落盘) } } ``` > **注意**:成就解锁立即持久化(`WriteDirty()`),不等到下次存档点,防止玩家因死亡而丢失成就进度。 --- ## 8. 内置成就清单 ### 故事成就(Story) | ID | 名称 | 解锁条件 | |----|------|---------| | `Ach_Story_FirstBoss` | 猎手初现 | 击败第一个 Boss | | `Ach_Story_Forest` | 森林之眼 | 击败蛛网守卫 | | `Ach_Story_Cave` | 腐蚀之核 | 击败蚀骨蠕虫 | | `Ach_Story_Ruins` | 废墟遗灵 | 击败废墟遗骑士 | | `Ach_Story_Abyss` | 深渊之声 | 击败深渊之喉 | | `Ach_Story_Core` | 泽灵归来 | 击败最终 Boss | | `Ach_Story_AllBoss` | 世界的终结者 | 击败全部5个 Boss | ### 收集成就(Collection) | ID | 名称 | 解锁条件 | |----|------|---------| | `Ach_Collect_AllCharms` | 收藏家 | 集齐全部魅力 | | `Ach_Collect_AllAbilities` | 完全体 | 解锁全部能力 | | `Ach_Collect_Map100` | 地图测绘员 | 探索 100% 地图 | | `Ach_Collect_Map90` | 好奇探险家 | 探索 90% 地图 | | `Ach_Collect_AllGeo` | 财富之王 | 同时持有 2000+ Geo | ### 挑战成就(Challenge) | ID | 名称 | 解锁条件 | |----|------|---------| | `Ach_Challenge_NoHeal` | 钢铁之心 | 不使用治疗击败最终 Boss | | `Ach_Challenge_Parry10` | 剑与剑之间 | 成功弹反 10 次 | | `Ach_Challenge_Parry100` | 弹反大师 | 成功弹反 100 次 | | `Ach_Challenge_NailClash` | 刀光剑影 | 触发 5 次拼刀 | | `Ach_Challenge_BossNoHit` | 无伤之路 | 无伤击败任意 Boss | | `Ach_Challenge_SteelSoul` | 钢铁之魂 | 钢铁之魂模式通关(从不复活)| | `Ach_Challenge_FastBoss` | 迅捷猎手 | 30 秒内击败任意区域 Boss | ### 隐藏成就(Hidden) | ID | 隐藏提示 | 实际条件 | |----|---------|---------| | `Ach_Hidden_ClashBoss` | "与强者同声" | 与 Boss 触发拼刀 | | `Ach_Hidden_DeathShade` | "取回过去" | 取回死亡遗骸 Geo | | `Ach_Hidden_OverkillBoss` | "过于用力了" | 致命一击伤害 ≥ Boss 最大 HP 的 50% | --- ## 9. 事件频道 | 频道资产 | 类型 | 发布方 | 主要订阅方 | |---------|------|--------|----------| | `OnAchievementUnlocked.asset` | `AchievementEventChannelSO` | `AchievementManager` | `AchievementToast`、分析/统计 | ```csharp // 自定义事件频道 SO [CreateAssetMenu(menuName = "Events/AchievementEventChannel")] public class AchievementEventChannelSO : ScriptableObject { public event Action OnEventRaised; public void Raise(AchievementSO ach) => OnEventRaised?.Invoke(ach); } ``` --- ## 10. 编辑器友好设计 ### AchievementSO 快速创建 所有成就 SO 放置于 `Assets/Data/Achievements/`,按类型子文件夹组织: ``` Assets/Data/Achievements/ ├── Story/ │ ├── Ach_Story_FirstBoss.asset │ └── ... ├── Collection/ ├── Challenge/ └── Hidden/ ``` ### AchievementManager Inspector(Play Mode) ``` ┌─ AchievementManager ──────────────────────────────────┐ │ 已解锁: 3 / 22 │ │ ┌────────────────────────────────┐ │ │ │ Ach_Story_Forest ✅ Story │ │ │ │ Ach_Parry_10 ✅ Challenge│ │ │ │ Ach_Collect_AllCharms ⬜ 收集 │ 7/12 魅力 │ │ └────────────────────────────────┘ │ │ [手动解锁 ID: ____________] [解锁] [重置全部] │ └──────────────────────────────────────────────────────┘ ``` ### 自定义 Inspector 辅助工具 ```csharp [CustomEditor(typeof(AchievementManager))] public class AchievementManagerEditor : Editor { string _testAchId; public override void OnInspectorGUI() { base.OnInspectorGUI(); if (!Application.isPlaying) return; EditorGUILayout.Space(); EditorGUILayout.LabelField("─── 调试工具 ───", EditorStyles.boldLabel); _testAchId = EditorGUILayout.TextField("成就 ID", _testAchId); if (GUILayout.Button("手动解锁")) ((AchievementManager)target).DebugUnlock(_testAchId); if (GUILayout.Button("重置全部成就")) ((AchievementManager)target).DebugResetAll(); } } ``` ### 新增成就 SOP 1. 在 `Assets/Data/Achievements/{Type}/` 右键 → `Create → Achievement/Achievement` 2. 填写 `achievementId`(格式:`Ach_{Type}_{关键词}`),确保全局唯一 3. 配置条件数组,从 `Create → Achievement/Condition/` 创建所需条件 SO 4. 将新 SO 拖入 `AchievementManager._allAchievements` 数组 5. 在 `31_SaveDataSchema_Unified` 保持字段向后兼容(新成就默认未解锁)