510 lines
18 KiB
Markdown
510 lines
18 KiB
Markdown
# 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
|
||
/// <summary>
|
||
/// 成就解锁条件抽象基类。每次相关事件触发时调用 Evaluate()。
|
||
/// </summary>
|
||
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<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()
|
||
{
|
||
// 初始化运行时状态(从 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<AchievementCondition> _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<AchievementSO> _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<AchievementSO> 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` 保持字段向后兼容(新成就默认未解锁)
|