引言
Steam 集成是 PC 游戏上线前绕不开的一步,但它比看上去琐碎:成就要在合作方后台先建好,API 名字要和代码里一模一样;统计要 store_stats 才算真正写回服务器;排行榜的创建是异步的,回调没回来之前拿不到句柄。本文按「接入 → 初始化 → 成就 → 统计与排行榜 → 云存档 → 富状态」的顺序,把 Defold 的 extension-steam 用一遍。
目录
- 1. 集成前要准备什么
- 2. 接入 Steam 扩展
- 3. 初始化与生命周期
- 4. 成就系统
- 5. 统计与排行榜
- 6. 云存档 Remote Storage
- 7. 富状态与好友
- 8. 调试与发布注意
- 9. 速查表
- 相关阅读
- 延伸阅读
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 | 发布前必删 |
一句话记忆:成就「设了要存、存了要等回调」,排行榜「创建与下载都是异步、句柄从回调来」,云存档「本地一份云端一份、按时间戳取新」——三件事各自独立,任何一环漏了都表现为「代码没错但没效果」。
相关阅读
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。