Defold 与 Steam 集成:成就、排行榜与云存档

从接入 extension-steam 讲起,完整覆盖 Defold 的 Steamworks 集成:依赖配置与 macOS 动态库、steam.init 与监听器生命周期、成就的解锁与回读、统计与排行榜的创建上传下载、Remote Storage 云存档与配额、富状态与好友邀请,以及本地调试与发布注意事项。

引言

Steam 集成是 PC 游戏上线前绕不开的一步,但它比看上去琐碎:成就要在合作方后台先建好,API 名字要和代码里一模一样;统计要 store_stats 才算真正写回服务器;排行榜的创建是异步的,回调没回来之前拿不到句柄。本文按「接入 → 初始化 → 成就 → 统计与排行榜 → 云存档 → 富状态」的顺序,把 Defold 的 extension-steam 用一遍。

前置阅读:原生扩展 、跨平台发布 。


目录


1. 集成前要准备什么

1. 后台先建成就与统计

Steam 的成就、统计、排行榜都必须在 Steamworks 合作方后台预先定义,代码里只能引用已存在的 API 名字:

成就   API Name(如 ACH_FIRST_WIN)、显示名、描述、隐藏与否、图标
统计   API Name(如 STAT_TOTAL_KILLS)、类型(int / float)
排行榜 API Name(如 LB_HIGHEST_SCORE)、排序方式、显示类型

代码里写错的 API Name 不会报错,只会静默失败。先把后台配好,再写代码。

2. 平台限制与调试 AppID

Steam 扩展只在 Windows / macOS / Linux 桌面构建生效,HTML5、Android、iOS、主机都不支持。多平台项目建议把 Steam 脚本放进独立的 .collection,或用 sys.get_sys_info().system_name 做开关。

编辑器里直接运行时 Steam 客户端不知道你在跑哪个游戏,需要在项目根目录放 steam_appid.txt,内容填 480(Valve 的 Spacewar 测试 AppID)。打包发布前必须删掉这个文件,否则线上会连错 AppID。


2. 接入 Steam 扩展

1. 添加依赖与 macOS 准备

在 game.project 里加入扩展的 ZIP 地址(版本号取 Releases 里的最新版),然后 Project ▸ Fetch Libraries:

[project]
dependencies#0 = https://github.com/defold/extension-steam/archive/refs/tags/4.0.0.zip

macOS 上想在编辑器里直接跑,还要把动态库拷到系统目录:

cp steam/lib/osx/*.dylib /usr/local/lib

Windows 与 Linux 不需要这一步,扩展的构建脚本会处理。


3. 初始化与生命周期

1. 三步走

Steam 扩展的生命周期很短:初始化、每帧更新、退出时收尾。

local function on_steam_event(self, event, data)
    if event == "UserStatsReceived_t" then
        self.stats_ready = true   -- 统计与成就已从服务器拉回
    elseif event == "GameOverlayActivated_t" then
        print("Overlay is", data.m_bActive)
    end
end

function init(self)
    local status, error = steam.init()
    if not status then
        print("Steam init failed: " .. error)
        return
    end
    steam.set_listener(on_steam_event)
end

function update(self, dt) steam.update() end   -- 不调用则回调不派发
function final(self) steam.final() end

2. 监听器是唯一的回调入口

steam.set_listener(fn) 注册的函数签名固定为 (self, event, data)。所有异步结果——成就存储、排行榜创建、分数上传、云存档——都从这里回来,用 event 字符串区分:

UserStatsReceived_t             统计数据已拉取
GlobalStatsReceived_t           全局统计已返回
LeaderboardFindResult_t         排行榜查找完成,data 里有句柄
LeaderboardScoreUploaded_t      分数上传完成
LeaderboardScoresDownloaded_t   排行榜数据已下载
GameRichPresenceJoinRequested_t 好友通过富状态加入

踩坑:忘记在 update 里调用 steam.update(),所有回调都不会触发,表现为「代码没错但什么也不发生」。这是最常见的集成问题。


4. 成就系统

1. 解锁与回读

-- 解锁
steam.user_stats_set_achievement("ACH_FIRST_WIN")

-- 回读:ok 表示调用成功,achieved 表示是否已解锁
local ok, achieved = steam.user_stats_get_achievement("ACH_FIRST_WIN")

-- 用于「首次进入」类判断
if ok and not achieved then
    steam.user_stats_set_achievement("ACH_FIRST_WIN")
    steam.user_stats_store_stats()
end

设置完必须调用 user_stats_store_stats(),否则只改在内存里,退出游戏就丢了。

2. 封装成一个小模块

散落各处的成就调用很难维护,封装一层:

-- achievements.lua
local M = {}
local unlocked = {}

function M.unlock(name)
    if unlocked[name] then return end          -- 本地去重
    local ok, achieved = steam.user_stats_get_achievement(name)
    if ok and achieved then unlocked[name] = true; return end
    if steam.user_stats_set_achievement(name) then
        unlocked[name] = true
        steam.user_stats_store_stats()
        print("achievement unlocked:", name)
    end
end

return M

unlocked 这张表避免每帧重复查询 Steam——get_achievement 是本地调用不算贵,但没必要反复问。

3. 进度型成就

Steam 原生不支持「3/10」这种进度条成就,要靠统计自己实现:

local ok, kills = steam.user_stats_get_stat_int("STAT_TOTAL_KILLS")
kills = (kills or 0) + 1
steam.user_stats_set_stat_int("STAT_TOTAL_KILLS", kills)
if kills >= 100 then
    achievements.unlock("ACH_CENTURION")
end
steam.user_stats_store_stats()

4. 遍历全部成就

做成就界面时用得上:

local n = steam.user_stats_get_num_achievements()
for i = 0, n - 1 do
    local name = steam.user_stats_get_achievement_name(i)
    local disp = steam.user_stats_get_achievement_display_attribute(name, "name")
    local ok, achieved = steam.user_stats_get_achievement(name)
    print(name, disp, achieved)
end

get_achievement_display_attribute 的第二个参数只能取 "name"、"desc"、"hidden" 三个值。


5. 统计与排行榜

1. 统计的读写

-- int 型
local ok, v = steam.user_stats_get_stat_int("STAT_TOTAL_KILLS")
steam.user_stats_set_stat_int("STAT_TOTAL_KILLS", (v or 0) + 1)
steam.user_stats_set_stat_float("STAT_BEST_TIME", 12.5)   -- float 型
steam.user_stats_store_stats()                            -- 别忘

2. 创建排行榜是异步的

排行榜必须先 find_or_create,等 LeaderboardFindResult_t 回来才拿到可用句柄:

local function on_steam_event(self, event, data)
    if event == "LeaderboardFindResult_t" then
        if data.m_bLeaderboardFound == 1 then self.lb = data.m_hSteamLeaderboard end
    elseif event == "LeaderboardScoreUploaded_t" then
        print("uploaded, new rank:", data.m_nGlobalRankNew)
    end
end

steam.user_stats_find_or_create_leaderboard(
    "LB_HIGHEST_SCORE",
    steam.ELeaderboardSortMethodDescending,     -- 分数越高越靠前
    steam.ELeaderboardDisplayTypeNumeric)

ELeaderboardSortMethodDescending 表示降序(高分在前),Numeric 表示显示为纯数字;计时类排行榜用 ELeaderboardDisplayTypeTimeSeconds。

3. 上传分数

if self.lb then
    steam.user_stats_upload_leaderboard_score(
        self.lb,
        steam.ELeaderboardUploadScoreMethodKeepBest,   -- 只保留最好成绩
        score)
end

KeepBest 只在分数更高时覆盖,适合大多数排行榜;ForceUpdate 无条件覆盖。

4. 下载并读取条目

下载同样是异步的,回调里拿到的是一批条目的句柄,再逐条取:

local function on_steam_event(self, event, data)
    if event == "LeaderboardScoresDownloaded_t" then
        local handle, count = data.m_hSteamLeaderboardEntries, data.m_cEntryCount
        self.entries = {}
        for i = 0, count - 1 do
            local ok, e = steam.user_stats_get_downloaded_leaderboard_entry(handle, i)
            if ok then
                table.insert(self.entries,
                    { rank = e.m_nGlobalRank, score = e.m_nScore, user = e.m_steamIDUser })
            end
        end
    end
end

steam.user_stats_download_leaderboard_entries(
    self.lb, steam.ELeaderboardDataRequestGlobal, 1, 10)

ELeaderboardDataRequestGlobal 是全局榜,GlobalAroundUser 取当前用户附近的名次(start 用负数表示「我在前几名」),Friends 只看好友。注意索引从 0 开始,而请求范围从 1 开始。

踩坑:find_or_create_leaderboard 和 download_leaderboard_entries 都是异步的,必须等前一个回调回来才能发起下一个。在回调没回来时连发两次下载,第二次会用旧的句柄失败。


6. 云存档 Remote Storage

1. 读写文件

Steam 云的本质是一个按用户同步的文件系统。写入与读取都是同步调用:

-- 写:返回是否成功
local ok = steam.remote_storage_file_write("save1.dat", sys.serialize(save_data))

-- 读:返回文件内容字符串
local data = steam.remote_storage_file_read("save1.dat")
if data and #data > 0 then local save = sys.deserialize(data) end

与 sys.save / sys.load 的区别在于:Steam 云的文件会在用户换机器时自动同步,而 sys.save 只写本地磁盘。上线 Steam 的项目建议统一走 Remote Storage,本地存档只作为回退。

2. 配额

local available, total = steam.remote_storage_get_quota()
print(string.format("cloud: %.1f / %.1f MB", available/1048576, total/1048576))

默认配额是每个用户 1 GB 左右(可在后台调整),单文件也有上限。存档前先检查剩余空间,写失败要给出明确提示,而不是静默丢档。

3. 存档策略

1. 本地先写一份(sys.save),保证离线也能玩
2. 再写一份到 Steam 云
3. 读档时比较两边时间戳,取更新的那份

第 3 条是换机与共用机器时的必要保护,否则会被旧存档覆盖。写入只在关键节点(通关、换关卡)做,不要每帧写。


7. 富状态与好友

1. 富状态

富状态是显示在好友列表里的一句话,让好友知道你在干什么:

steam.friends_set_rich_presence("status", "在第三关")
steam.friends_set_rich_presence("steam_display", "#Status_Level")
steam.friends_clear_rich_presence()   -- 退出时清空

steam_display 是特殊的 key,配合后台配置的本地化字符串模板使用,能做到多语言。

2. 好友加入

好友点「加入游戏」时,回调里带回连接串,主动邀请则传好友 ID 与连接串:

local function on_steam_event(self, event, data)
    if event == "GameRichPresenceJoinRequested_t" then
        self:join_session(data.m_rgchConnect)
    end
end

steam.friends_invite_user_to_game(steam.user_get_steam_id(), "level3")

3. 当前用户信息

local id = steam.user_get_steam_id()          -- CSteamID 字符串
local name = steam.friends_get_persona_name()
local level = steam.user_get_player_steam_level()
local logged = steam.user_logged_on()         -- 是否连上 Steam 服务器

user_logged_on() 返回 false 时(离线模式),成就与云存档都不可用,UI 要能优雅降级。


8. 调试与发布注意

1. 调试手段

- 放 steam_appid.txt(内容 480)在项目根目录,编辑器里即可调试
- 用 steam.user_get_steam_id() 确认真的连上了 Steam
- 在监听器里把所有 event 打出来,看清回调顺序
- Steam 覆盖层按 Shift+Tab,可直接看成就与云存档状态

2. 发布前检查清单

1. 删除 steam_appid.txt
2. game.project 依赖指向正式版本,不是本地路径
3. 后台所有成就 / 统计 / 排行榜的 API Name 与代码一致
4. 云存档配额够用,写失败有降级逻辑
5. 非 Steam 环境(离线、无客户端)下游戏不崩溃

3. 常见失败模式

set_achievement 返回 true 但没解锁 → 没调 store_stats,或后台没建 API Name
排行榜句柄一直是 nil          → find_or_create 回调没等到,或忘了 update()
换机器读不到云存档            → 客户端没同步完,或写的是本地 sys.save
编辑器里 init 就失败          → macOS 没拷 .dylib,或没放 steam_appid.txt

9. 速查表

需求做法备注
初始化steam.init() 返回 status, error失败要处理
注册回调steam.set_listener(fn)签名 (self, event, data)
每帧驱动update 里调 steam.update()漏了回调全不触发
解锁成就steam.user_stats_set_achievement(name)之后要 store
查成就steam.user_stats_get_achievement(name)返回 ok, achieved
存回服务器steam.user_stats_store_stats()不调用等于没存
统计set/get_stat_int / _float也要 store
建排行榜find_or_create_leaderboard(name, sort, display)异步,等回调拿句柄
上传分数upload_leaderboard_score(lb, method, score)KeepBest 常用
下载榜单download_leaderboard_entries(lb, req, start, end)索引从 0 开始
写云存档remote_storage_file_write(name, data)返回是否成功
读云存档remote_storage_file_read(name)返回字符串
富状态friends_set_rich_presence(k, v)退出时 clear
调试 AppID根目录放 steam_appid.txt发布前必删

一句话记忆:成就「设了要存、存了要等回调」,排行榜「创建与下载都是异步、句柄从回调来」,云存档「本地一份云端一份、按时间戳取新」——三件事各自独立,任何一环漏了都表现为「代码没错但没效果」。


相关阅读

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「defold」更多文章

  1. Defold 行为树与游戏 AI 决策
  2. Defold 材质与着色器语言详解
  3. Defold HTML5 导出与 Web 性能优化