# 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` 保持字段向后兼容(新成就默认未解锁)