上午那篇把菜单和 HUD 统一成了「一套 Theme」。这一篇换个方向——玩家留存。很多独立游戏玩法不错,却缺少「再玩一局」的理由;Steam 的 成就(Achievements)与统计(Stats) 就是最便宜、最标准化的那个理由。今天用 Steamworks 官方文档 + GodotSteam 教程,把「通关一次就删」变成「总想再刷一把」。

今日速览

  • 成就与统计绑定 Steam 账号、随账号漫游,换电脑也在;玩家个人资料页会自动展示——等于一个免费的炫耀位。
  • 统计只有三种类型:INT(整数)/ FLOAT(浮点)/ AVGRATE(滑动平均);成就可以挂一个「进度统计」,社区里自动显示进度条。
  • 关键 API 全在 ISteamUserStats:GetStat/SetStat 读写、StoreStats 落库、SetAchievement/ClearAchievement 解锁与清除。
  • 必须先在后台上架(Publish)成就定义,否则 API 调用会报错——哪怕游戏还没正式发行,也可以先发布这些定义。
  • GodotSteam 3.28 / 4.14 起,登录时自动同步本地用户数据,不再需要手动请求回调。

核心学习内容

1. 先想清楚「成就为什么存在」

成就不是彩蛋,是设计工具。Steamworks 文档列了它的四大用途:奖励玩家、鼓励合作与交互、给目标增加维度、奖励玩得久的玩家。翻译成人话,好成就要做到三件事:

  • 教机制:初次格挡成功、用环境杀死敌人,教会玩家系统还能这么用。
  • 给方向:不受伤通关第一章,让高手有挑战目标。
  • 记里程碑:首次通关、收集全部 10 张地图碎片,给正反馈。

最忌讳的是「击杀 1000 只怪」这种纯刷——它是留存杀手,不是留存钩子。

2. 统计的三种类型与关键属性

在 Steamworks 后台(App Admin)定义统计时,每个统计有这些要点:

  • Type:INT / FLOAT / AVGRATE。AVGRATE 是 Steam 帮你做滑动平均,适合「平均帧率」「平均通关时长」这类指标。
  • Increment Only:勾上后数值只能增——总击杀数、总游玩时长必须勾,防止读档/回滚把数据改小。
  • Max Change:单次写入的最大变化量,防止一次异常调用把数据写飞。
  • Min / Max Value:上下限。
  • Aggregated:勾上后 Steam 会维护全局总计(Global Stats),可用来做「全服累计击杀 X 亿」这类社区目标。

成就侧最有用的是 Progress Stat:给它挂一个统计,社区里就自动显示进度条,而且统计到达解锁值时成就自动解锁——不用你手写判断。

3. 读写的正确时机(GodotSteam 实操)

调用顺序错了,数据就会丢。推荐的最小骨架:

# autoload: SteamStats.gd
var achievements: Dictionary = {"first_win": false, "no_hit_boss": false}
var statistics: Dictionary = {"total_kills": 0, "best_time": 0}

func _ready() -> void:
    var r: Dictionary = Steam.steamInitEx()
    if r.get("status", 1) > Steam.STEAM_API_INIT_RESULT_OK:
        push_error("Steam 初始化失败: %s" % r)
        return
    load_from_steam()          # 先拉云端
    merge_local_save()         # 再和本地存档合并

func load_from_steam() -> void:
    for key in statistics.keys():
        statistics[key] = Steam.getStatInt(key)

func set_stat(key: String, value: int) -> void:
    if value <= statistics.get(key, 0):   # Increment Only 语义:只增不减
        return
    statistics[key] = value
    Steam.setStatInt(key, value)

func unlock(api_name: String) -> void:
    if achievements.get(api_name, false):
        return
    achievements[api_name] = true
    Steam.setAchievement(api_name)
    Steam.storeStats()        # 解锁后立刻落库

func save() -> void:
    Steam.storeStats()        # 不要每帧调用,退出/关键节点调用即可

三个容易踩的坑:

  1. storeStats() 要节流:它是一次网络/磁盘写入,别放在 _process 里每帧调用,放在存档点、关卡结束、退出前。
  2. 离线要兜底:steamInitEx 失败时(无 Steam 客户端、断网)应降级为纯本地存档,而不是崩溃。
  3. 本地与云端「取大值」:高分类统计冲突时保留较大值,避免玩家换机后进度倒退;但像「当前生命」这种非单调统计不能盲目取大,需要按语义分别处理。

4. 上架前必须做的一步

在 Steamworks 后台把统计与成就定义并发布(Publish)。GodotSteam 文档特别强调:游戏不需要通过审核、也不需要已发行,但这些定义必须处于已发布状态,否则 API 读写会直接报错。默认上限是 100 个成就,达到 Profile Features 门槛后可以申请更多。

🎯 DeepSeek 提示词

提示词 1 · 设计一套成就不坐班清单

你是一名资深游戏系统设计师。我要为一款【2D 横版动作冒险游戏】设计 Steam 成就系统。
游戏核心循环:探索地图 → 战斗 → 收集碎片 → 挑战 BOSS。
请输出 12 个成就,要求覆盖四类:教学(教机制)、挑战(高手)、里程碑、隐藏(剧透保护)。
用 Markdown 表格输出,列:API Name(小写下划线) | 显示名 | 描述(≤80字) | 是否隐藏 | 触发条件 | 建议挂的进度统计(没有则填 -)。
约束:最多 2 个纯累积型成就;每个成就必须一句话说清"为什么它能提升留存或教学效果"。

提示词 2 · 生成 GodotSteam 成就管理 autoload

你是 Godot 4 + GodotSteam 专家。请写一个 autoload 单例 SteamAchievements.gd,要求:
1. 用字典管理统计与成就,键为 Steam API Name;
2. 初始化时从 Steam 读取,并与本地 JSON 存档合并(分类统计取较大值,非单调统计按"以云端为准");
3. 提供 set_stat_int / set_stat_float / unlock / check_all 四个方法;
4. storeStats() 只在存档点、关卡结束、退出前调用,不得出现在 _process;
5. steamInitEx 失败时自动降级为纯本地模式,并在控制台给出明确警告。
只返回完整 .gd 代码 + 关键行中文注释,不要多解释。

提示词 3 · 审查「漏触发 / 重复触发 / 丢存档」

你是代码审查员。下面是我的成就触发点列表和成就定义(粘贴进来)。
请逐条检查并输出表格:成就 API Name | 是否存在触发点 | 重复触发风险 | 存档/离线丢失风险 | 修复建议。
重点排查:① 定义了但从未触发的"僵尸成就";② 在 _process 里重复解锁;③ storeStats 未节流;
④ 断网或 Steam 未启动时的崩溃路径。按严重程度排序,只列问题,不要客套。

学习心得

今天最大的收获是:成就系统本质上是「设计目标的可视化」,而不是技术炫技。它逼我提前想清楚「这款游戏希望玩家追求什么」。对「用 DeepSeek 做一款 Steam 游戏」这个目标来说,这一环尤其划算——成就定义是纯数据,正好让 DeepSeek 生成、我来审核;GodotSteam 的接入代码也高度模板化,DeepSeek 一次就能写出能跑的骨架。先把成就清单定下来,再写代码,比反过来高效得多。

明日关注

  • 在 Steamworks 后台实际定义 3 个统计 + 3 个成就并 Publish,跑通 GodotSteam 读写闭环。
  • 把成就解锁接进已有的战斗/收集系统,验证「进度统计自动解锁」是否生效。
  • 研究 Steam 云存档(Steam Cloud)与统计的联动,避免「云端有成就、本地没存档」的割裂感。

本文由自动化学习流水线每日生成,坚持即学即记。