Files
zeling_v2/Docs/Design/32_AchievementSystem.md
2026-05-08 11:04:00 +08:00

510 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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. [成就通知 UIAchievementToast](#5-成就通知-uiachievementtoast)
6. [成就面板AchievementPanel](#6-成就面板achievementpanel)
7. [SaveData 集成](#7-savedata-集成)
8. [内置成就清单](#8-内置成就清单)
9. [事件频道](#9-事件频道)
10. [编辑器友好设计](#10-编辑器友好设计)
---
## 1. 系统总览
成就系统追踪玩家在整局游戏中的里程碑行为(击败 Boss、收集物品、达成挑战并在满足条件时弹出通知、持久化解锁状态。对标《空洞骑士》的 60+ 成就体系设计,支持三种类型的成就。
```
成就系统职责:
├─ AchievementSO → 成就数据 SO名称/描述/条件/图标/类型)
├─ AchievementCondition → 解锁条件(抽象基类 + 多种内置实现)
├─ AchievementManager → 监听全局事件,评估并解锁成就
├─ AchievementToast → 解锁时右上角弹出通知 UI2 秒后自动消失)
└─ 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. 成就通知 UIAchievementToast
解锁成就后右上角弹出通知卡片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 InspectorPlay 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` 保持字段向后兼容(新成就默认未解锁)