18 KiB
18 KiB
32 · 成就系统(Achievement System)
命名空间
BaseGames.Achievement
所属文档集 ← 返回索引 · 总览
依赖BaseGames.Core.Events·BaseGames.Progression·BaseGames.UI·BaseGames.World(SaveManager)
目录
- 系统总览
- AchievementSO — 成就数据
- AchievementCondition — 解锁条件
- AchievementManager
- 成就通知 UI(AchievementToast)
- 成就面板(AchievementPanel)
- SaveData 集成
- 内置成就清单
- 事件频道
- 编辑器友好设计
1. 系统总览
成就系统追踪玩家在整局游戏中的里程碑行为(击败 Boss、收集物品、达成挑战),并在满足条件时弹出通知、持久化解锁状态。对标《空洞骑士》的 60+ 成就体系设计,支持三种类型的成就。
成就系统职责:
├─ AchievementSO → 成就数据 SO(名称/描述/条件/图标/类型)
├─ AchievementCondition → 解锁条件(抽象基类 + 多种内置实现)
├─ AchievementManager → 监听全局事件,评估并解锁成就
├─ AchievementToast → 解锁时右上角弹出通知 UI(2 秒后自动消失)
└─ AchievementPanel → 游戏内成就列表面板(暂停菜单子页)
零耦合原则:AchievementManager 只订阅 SO 事件频道,不直接引用 PlayerController、EnemyBase 等游戏系统。条件评估完全通过事件驱动。
2. AchievementSO — 成就数据
[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 基类定义
/// <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
[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,统一监听全局游戏事件并评估所有成就。
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
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
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 绑定逻辑:
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 字段:
"achievements": {
"unlocked": [
"Ach_SlayBoss_Forest",
"Ach_Parry_10",
"Ach_CollectCharm_QuickSlash"
],
"progress": {
"Ach_Parry_100": { "count": 47 },
"Ach_MapExplore90": { "percent": 0.63 }
}
}
// 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、分析/统计 |
// 自定义事件频道 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 辅助工具
[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
- 在
Assets/Data/Achievements/{Type}/右键 →Create → Achievement/Achievement - 填写
achievementId(格式:Ach_{Type}_{关键词}),确保全局唯一 - 配置条件数组,从
Create → Achievement/Condition/创建所需条件 SO - 将新 SO 拖入
AchievementManager._allAchievements数组 - 在
31_SaveDataSchema_Unified保持字段向后兼容(新成就默认未解锁)