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

18 KiB
Raw Blame History

32 · 成就系统Achievement System

命名空间 BaseGames.Achievement
所属文档集 ← 返回索引 · 总览
依赖 BaseGames.Core.Events · BaseGames.Progression · BaseGames.UI · BaseGames.WorldSaveManager


目录

  1. 系统总览
  2. AchievementSO — 成就数据
  3. AchievementCondition — 解锁条件
  4. AchievementManager
  5. 成就通知 UIAchievementToast
  6. 成就面板AchievementPanel
  7. SaveData 集成
  8. 内置成就清单
  9. 事件频道
  10. 编辑器友好设计

1. 系统总览

成就系统追踪玩家在整局游戏中的里程碑行为(击败 Boss、收集物品、达成挑战并在满足条件时弹出通知、持久化解锁状态。对标《空洞骑士》的 60+ 成就体系设计,支持三种类型的成就。

成就系统职责:
  ├─ AchievementSO          → 成就数据 SO名称/描述/条件/图标/类型)
  ├─ AchievementCondition   → 解锁条件(抽象基类 + 多种内置实现)
  ├─ AchievementManager     → 监听全局事件,评估并解锁成就
  ├─ AchievementToast       → 解锁时右上角弹出通知 UI2 秒后自动消失)
  └─ AchievementPanel       → 游戏内成就列表面板(暂停菜单子页)

零耦合原则AchievementManager 只订阅 SO 事件频道,不直接引用 PlayerControllerEnemyBase 等游戏系统。条件评估完全通过事件驱动。


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. 成就通知 UIAchievementToast

解锁成就后右上角弹出通知卡片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 InspectorPlay 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

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