成就与统计

追踪玩家统计数据,在达到阈值时自动解锁成就,将进度持久化到磁盘,并发布解锁事件。

在数据表中声明成就;框架负责追踪、解锁与保存。

接口IAchievementService
关闭开关NullAchievementService
程序集CommonGameSystem.Core
启动启动时自动注册;定义表由你加载

功能概览

成就与统计用于追踪玩家进度并给予认可。两者都在 ScriptableObject 表中声明:**统计(stat)**是一个具名计数器("enemies_defeated""playtime_seconds"),**成就(achievement)**则是一个 id 加显示文本,可选地绑定到某个统计并设定目标阈值。服务会记录统计变化、在统计变动的那一刻检查解锁条件、通过保存/加载服务把一切写入磁盘,并在成就解锁时在事件总线上触发 AchievementUnlocked 事件。

这样的分工是有意为之:框架负责追踪、解锁和持久化;弹窗或提示由你的 UI 负责。该模块完全无界面(headless)——不包含任何渲染代码,因此适配任何 UI 方案。

快速上手

加载一次表,然后在游戏逻辑代码中记录统计:

var achievements = ServiceLocator.Resolve<IAchievementService>();
achievements.AddToStat("enemies_defeated", 1);

订阅解锁事件来显示通知:

ServiceLocator.Resolve<IEventBus>().Subscribe<AchievementUnlocked>(evt =>
{
    Debug.Log($"Unlocked: {evt.AchievementId}");
});

API 参考

表加载

  • void LoadTables(IReadOnlyList<AchievementTableAsset> achievements, IReadOnlyList<StatTableAsset> stats) — 必须最先调用。构建定义与查找索引。再次调用会替换全部内容并重置内存中的状态。

统计操作

  • long AddToStat(string statId, long delta) — 对统计做加法(或减法)。溢出安全,默认下限为 0。立即重新检查绑定到该统计的成就。返回新值。
  • long SetStat(string statId, long value) — 把统计设为绝对值,应用同样的下限与阈值检查。返回实际存储的值。
  • long GetStat(string statId) — 读取当前值。未知 id 返回 0L。零分配。

解锁

  • void Unlock(string achievementId) — 直接解锁未绑定统计的成就(剧情里程碑、隐藏发现)。重复调用是安全的;已解锁的成就不会触发第二次事件。
  • bool IsUnlocked(string achievementId) — 成就是否已解锁。未知 id 返回 false
  • bool TryGetProgress(string achievementId, out AchievementProgress progress) — 填充一份进度快照(当前值、目标、完成比例、状态)。对任何已知成就返回 true,无论是否绑定统计;仅对未知 id 返回 false

查询

  • void GetVisible(List<AchievementDefinition> results) — 把可见成就(未隐藏的,或虽隐藏但已解锁的)填入你的列表。会先清空列表。零分配——列表由你持有。
  • void GetAll(List<AchievementDefinition> results) — 把所有定义填入你的列表,包括仍锁定的隐藏成就。会先清空列表。零分配。
  • bool ContainsAchievement(string id) — 某个成就 id 是否已定义。
  • bool ContainsStat(string id) — 某个统计 id 是否已定义。

持久化

  • SaveResult Save() — 通过保存/加载服务把内存状态写入磁盘。内容无变化时跳过写入。返回保存状态。
  • void Load() — 从名为 "achievements" 的存档槽读取已保存的状态。存档缺失或损坏时静默加载为空状态。已解锁的成就不会重新触发其事件。
  • void ResetAll() — 把所有统计清回初始值并锁定全部成就,仅作用于内存。之后调用 Save() 才会把重置持久化。

示例:完整设置

using System.Collections.Generic;
using CommonGameSystem.Core;
using UnityEngine;

[DefaultExecutionOrder(100)] // Run after the framework has started.
public class AchievementManager : MonoBehaviour
{
    [SerializeField] private AchievementTableAsset[] _achievementTables; // assign in the Inspector
    [SerializeField] private StatTableAsset[] _statTables;               // assign in the Inspector

    private IAchievementService _achievements;
    private IEventBus _bus;

    private void Start()
    {
        _achievements = ServiceLocator.Resolve<IAchievementService>();
        _bus = ServiceLocator.Resolve<IEventBus>();

        // Load the table definitions first, then the saved state from disk.
        _achievements.LoadTables(_achievementTables, _statTables);
        _achievements.Load();

        // Subscribe to unlock events.
        _bus.Subscribe<AchievementUnlocked>(OnAchievementUnlocked);
    }

    public void OnEnemyDefeated()
    {
        long newCount = _achievements.AddToStat("enemies_defeated", 1);
        Debug.Log($"Enemies defeated: {newCount}");
    }

    private void OnAchievementUnlocked(AchievementUnlocked evt)
    {
        Debug.Log($"Achievement unlocked: {evt.AchievementId}");
        // Show your toast or popup here.
    }
}

关闭该服务

ServiceLocator.Replace<IAchievementService>(new NullAchievementService());

这会禁用整个模块:表加载不做任何事,所有修改都是空操作,GetStat 返回 0LIsUnlocked 返回 false,不写磁盘,也不发布事件。可用于不带成就的测试,或发行最小化构建。

常见陷阱

  • LoadTables 是必需的。 在任何统计或成就操作之前调用它。在空服务上调用 AddToStat 会记录一条警告,但不会崩溃。
  • 解锁默认立即写盘。 FlushOnUnlock 选项默认为 true,因此每次解锁都会通过保存/加载服务执行一次同步保存。如果可能出现大量成就集中解锁,请在 AchievementOptions 中设置 FlushOnUnlock = false,并在检查点自行调用 Save()
  • 阈值检查即时发生。 AddToStatSetStat 一执行,绑定的成就就会被重新评估。不存在延后的“稍后检查进度”——条件一满足事件就触发。
  • 解锁是单向的。 成就一旦解锁就保持解锁。即使统计随后下降(开启 AllowNegativeDeltas 选项时),成就也不会重新锁定。
  • 进度事件需要显式开启。 在表中的成就定义上设置 PublishProgress = true 才会启用 AchievementProgressChanged 事件。它默认关闭,以避免 playtime_seconds 这类高频统计刷爆事件。
  • 自定义类型在 IL2CPP 下需要 link.xml 模块自身的公开类型(如 AchievementSaveData)已被保留。如果你在玩家构建中用 JsonUtility 序列化自己的类型,请在项目的 link.xml 中添加条目,防止 IL2CPP 构建裁剪它们。

相关页面

  • 保存 / 加载Save()Load() 背后的存档槽与原子写入
  • 事件总线AchievementUnlockedAchievementProgressChanged 事件