《旋灯残响》开发记录
CloudBase 云函数与远程关卡配置
G:热乎乎的屁
游戏:旋灯残响
做《旋灯残响》时,我们遇到过一个很实际的需求:想调整某关的时间和步数,但这些数字原来跟着客户端一起打包。每次调参都重新构建、上传,再让玩家更新,成本有点高。
后来,项目接入了腾讯云开发 CloudBase,把一部分运行参数放到云端。小游戏读取配置,开发者维护配置;玩家已经开始的那一局,则继续使用开局时的规则。
这篇就用这个需求,从头做一个可以自己部署的例子:在云端把演示关卡从 30 秒改成 45 秒,小游戏刷新后,新开的局使用新配置。
下面的函数名、集合名和数值是独立教学示例。它提取了项目中的配置读取、身份校验、版本控制和开局快照思路,方便在自己的测试环境里复现。
先明确客户端和云端各做什么
这个例子只需要三个部分。
| 部分 | 放什么 | 由谁使用 |
|---|---|---|
| 小游戏客户端 | 调用云函数、保存本次会话的配置、创建新局 | 玩家 |
| 云函数 | 检查微信调用身份、读取和校验配置 | 小游戏通过微信云调用访问 |
| 文档数据库 | 保存当前生效的配置文档 | 开发者维护,云函数读取 |
开发者在控制台修改数据库。玩家客户端只调用读取接口,不直接修改配置集合。
这里走的是微信 wx.cloud 调用链,服务端使用 wx-server-sdk。这个示例不需要先搭一台 Express 服务器,也不需要为了这次云函数调用单独购买 HTTPS 域名。Web 网页接 CloudBase 时的 SDK 和登录方式不同,不能把两套初始化代码直接混用。官方调用说明

图中省略部分协议字段。虚线框区分客户端与 CloudBase 环境;底部说明配置刷新与对局生效的关系。
准备环境 先确认自己用的是哪个账号
准备一个自己的微信小游戏 AppID,以及能打开该项目的微信开发者工具。已有 Cocos 项目也可以沿用现有构建流程。
打开开发者工具的云开发入口,先检查已有环境。项目接入时,我们就需要先找回原来的环境、核对账号与关联关系。如果环境列表里没看到预期内容,应该先检查登录账号和当前 AppID,而不是立即新建一套。
确实需要创建时,再按当前控制台向导操作。这个例子使用文档型 NoSQL 数据库 ,代码和权限规则都按这条路径编写;如果选择 PostgreSQL 模式,就要使用相应的数据访问和权限方案。创建环境与复用环境、数据库安全规则适用范围
创建或选定环境后,记住这三个名字:
| 名称 | 作用 | 示例 |
|---|---|---|
| AppID | 标识你的小游戏 | 使用自己的真实 AppID |
| 环境 ID | 确定请求发往哪个后端环境 | your-test-env-id |
| 云函数名 | 确定调用哪段服务端代码 | demo-runtime-config |
环境显示名称不能代替环境 ID。已有腾讯云环境还要按官方流程核对与当前微信应用的关联。
套餐、试用期和额度以创建时的控制台为准。先在测试环境完成这个例子,再接正式游戏数据。环境创建说明
把云函数目录放对位置
下载的教学示例采用下面的结构:
python
cloud-config-tutorial/
├── project.config.json
├── minigame/
│ ├── game.js
│ ├── game.json
│ ├── cloud-config.js
│ └── config-protocol.js
└── cloudfunctions/
└── demo-runtime-config/
├── index.js
├── handler.js
├── protocol.js
└── package.json
project.config.json 中,与目录有关的部分是:
python
{
"appid": "替换为自己的小游戏AppID",
"compileType": "game",
"miniprogramRoot": "minigame/",
"cloudfunctionRoot": "cloudfunctions/"
}
用开发者工具打开包含这份配置的根目录,才能同时看到小游戏代码与云函数目录。
我们的 Cocos 工程有所不同:客户端产物在 build/wechatgame/,云函数在仓库根目录的 cloudfunctions/。因此 miniprogramRoot 指向前者,cloudfunctionRoot 指向后者。曾经只打开构建目录时,游戏可以运行,却看不到外面的云函数目录。
已有工程只合并必要字段,保留原 AppID 和其他构建设置。Cocos 的 build/wechatgame/game.js 属于生成产物,接入逻辑应写在源代码里,不能靠每次手改它来维护。

此图展示 Cocos 工程的目录变体。原生教学包的客户端目录为 minigame/;Cocos 工程按实际构建目录配置。
写一个能读取配置的云函数
先在 cloudfunctions/demo-runtime-config/ 放好下面几个文件。这里拆成入口、参数校验和业务处理,方便分别测试;部署时整个目录一起上传。
package.json:
html
{
"name": "demo-runtime-config",
"version": "1.0.0",
"private": true,
"main": "index.js",
"dependencies": { "wx-server-sdk": "4.0.2" }
}
本例固定使用项目采用的 wx-server-sdk 4.0.2,便于复现,不表示它是最新版本。运行时选择当前控制台支持、且与依赖兼容的 Node.js 版本。官方 SDK
protocol.js 负责解释配置格式:
python
// 教学范围:一个演示关卡的时间、步数配置。业务可行性需要另行验题。
function parseConfig(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const allowed = ['_id', 'schemaVersion', 'revision', 'timeLimit', 'moveLimit'];
if (Object.keys(value).some(key => !allowed.includes(key))) return null;
if (value.schemaVersion !== 1) return null;
if (!Number.isSafeInteger(value.revision) || value.revision < 1) return null;
if (!Number.isInteger(value.timeLimit) || value.timeLimit < 10 || value.timeLimit > 300) return null;
if (!Number.isInteger(value.moveLimit) || value.moveLimit < 1 || value.moveLimit > 1000) return null;
return {
schemaVersion: 1,
revision: value.revision,
timeLimit: value.timeLimit,
moveLimit: value.moveLimit,
};
}
module.exports = { parseConfig };
schemaVersion 表示字段结构,revision 表示这次配置发布的版本。把 30 写成字符串 "30",或者使用客户端不认识的协议版本,都会被拒绝。
时间和步数范围是这个教学例子的约束。正式游戏还应结合关卡最少步数等数据验题;"数字合法"不能证明玩家一定能通关。

服务端先检查配置结构;客户端收到后还要检查版本,避免旧响应或同版本不同内容覆盖已接受的配置。
handler.js 处理两个动作:ping 用来确认函数可执行,read 用来读取配置。
javascript
const { parseConfig } = require('./protocol');
function createHandler({ getIdentity, readActive, log = () => {} }) {
return async function handle(event) {
if (!event || typeof event !== 'object' || Array.isArray(event)) {
return { ok: false, code: 'invalid-request' };
}
// 微信附加元数据可以存在,但不用于判断业务身份。
const allowed = ['action', 'userInfo', 'tcbContext'];
if (Object.keys(event).some(key => !allowed.includes(key))) {
return { ok: false, code: 'invalid-request' };
}
if (event.action === 'ping') {
return { ok: true, service: 'demo-runtime-config', release: 'tutorial-v1' };
}
if (event.action !== 'read') return { ok: false, code: 'invalid-action' };
const identity = getIdentity();
if (typeof identity?.APPID !== 'string' || !identity.APPID ||
typeof identity?.OPENID !== 'string' || !identity.OPENID) {
return { ok: false, code: 'unauthenticated' };
}
try {
const document = await readActive();
if (!document) return { ok: false, code: 'config-missing' };
const value = parseConfig(document);
if (!value) return { ok: false, code: 'invalid-config' };
return { ok: true, value };
} catch (error) {
const sdkCode = Number.isSafeInteger(error?.errCode) ? error.errCode : undefined;
try { log({ action: 'read', code: 'config-unavailable', ...(sdkCode !== undefined ? { sdkCode } : {}) }); }
catch { /* 诊断失败不改变业务返回。 */ }
return { ok: false, code: 'config-unavailable' };
}
};
}
module.exports = { createHandler };
这里有两处容易被忽略。
第一,身份取自服务端 getWXContext()。玩家提交的 data、userInfo,甚至一个名叫 OPENID 的字段,都不能代替可信微信上下文。
第二,函数只返回经过校验的配置字段。数据库里有什么,并不意味着都应该原样返回给客户端。
最后用 index.js 把业务处理接到真实 SDK:
javascript
const cloud = require('wx-server-sdk');
const { createHandler } = require('./handler');
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV });
const db = cloud.database({ throwOnNotFound: false });
exports.main = createHandler({
getIdentity: () => cloud.getWXContext(),
readActive: async () => {
const result = await db.collection('demo_runtime_config').doc('active').get();
return result.data ?? null;
},
log: fields => console.warn('[config-read]', fields),
});
cloud.DYNAMIC_CURRENT_ENV 让函数使用自己部署所在的环境。数据库引用也来自这次初始化。读取的目标固定为 demo_runtime_config 集合中的 active 文档,客户端不能传一个任意集合名让它代查数据。
部署函数 先验证它真的能执行
在微信开发者工具中选择正确的云环境,右键 demo-runtime-config,使用上传并部署、云端安装依赖的方式。等待部署完成,再到云函数列表核对函数名称和状态。官方部署说明
先使用控制台测试下面的输入:
javascript
{ "action": "ping" }
预期返回:
javascript
{
"ok": true,
"service": "demo-runtime-config",
"release": "tutorial-v1"
}
这是示例定义的预期输出。它说明函数入口及基础依赖能工作,尚未验证小游戏的微信身份和数据库读取。
ping 刻意不读取数据库,也不要求玩家身份。这样,后面如果 read 失败,就能继续判断问题出在微信调用上下文、配置文档还是数据校验,而不是把所有问题混成"云函数不通"。
控制台手动测试 read 时,如果没有真实微信调用身份,返回 unauthenticated 是预期保护。不要为了让控制台测试变绿,临时删掉身份检查。
创建配置集合和第一份文档
在同一个测试环境的文档数据库中,创建集合:
demo_runtime_config
权限选择仅管理端可读写。使用自定义安全规则时,本例对应:
{ "read": false, "write": false }
这限制的是客户端直接访问数据库的路径。使用服务端 SDK 的云函数仍需自行校验调用者和业务权限,不能只靠数据库规则保护接口。本例只暴露读取当前配置的动作。数据库权限说明、安全规则
然后创建文档,文档 ID 为 active:
javascript
{
"_id": "active",
"schemaVersion": 1,
"revision": 1,
"timeLimit": 30,
"moveLimit": 5
}
如果控制台已经单独让你填写文档 ID,就在那个位置填 active。不要额外创建一条自动 ID 的文档,否则 .doc('active') 仍然读不到它。
此时要对齐的是三个值:客户端选择的环境 ID、函数部署所在的环境、数据库文档所在的环境。它们只要有一个不一致,就可能出现"明明建过文档,为什么函数查不到"的情况。
在小游戏里发起调用
客户端调用的核心是下面这两步:初始化一次,然后显式指定环境调用函数。
javascript
wx.cloud.init({
env: '替换为自己的环境ID',
traceUser: false,
});
const response = await wx.cloud.callFunction({
name: 'demo-runtime-config',
config: { env: '替换为自己的环境ID' },
data: { action: 'read' },
});
const result = response.result;
if (result?.ok !== true) {
// 云调用返回了结果,但业务并未成功。
console.info('[config-read]', { code: result?.code });
}
返回的业务数据在 response.result 中。进入 SDK 的成功回调,只能说明拿到了云端返回,不能直接显示"配置更新成功"。还要检查 result.ok 和配置内容。
初始化和调用的 API 形式可以参考官方调用说明。上面是核心接线片段,完整示例还封装了超时和配置版本判断。
把服务端的 protocol.js 原样复制到客户端目录,命名为 config-protocol.js。本例两端使用同一套字段规则;正式工程可以在构建时生成副本,避免手动改漏。
客户端 cloud-config.js 完整实现如下:
javascript
const { parseConfig } = require('./config-protocol');
function createConfigClient(cloud, environment, { timeoutMs = 8000 } = {}) {
let initialized = false;
let current = Object.freeze({ schemaVersion: 1, revision: 0, timeLimit: 30, moveLimit: 5 });
function request(action) {
return new Promise((resolve, reject) => {
let done = false;
const timer = setTimeout(() => finish(false, { code: 'cloud-timeout' }), timeoutMs);
function finish(ok, value) {
if (done) return;
done = true;
clearTimeout(timer);
ok ? resolve(value) : reject(value);
}
try {
if (!cloud || !/^[A-Za-z0-9_-]{1,128}$/.test(environment)) {
finish(false, { code: 'cloud-unavailable' });
return;
}
if (!initialized) {
cloud.init({ env: environment, traceUser: false });
initialized = true;
}
const success = response => finish(true, response?.result);
const fail = () => finish(false, { code: 'cloud-unavailable' });
const pending = cloud.callFunction({
name: 'demo-runtime-config',
config: { env: environment },
data: { action },
success,
fail,
});
if (pending && typeof pending.then === 'function') pending.then(success, fail);
} catch { finish(false, { code: 'cloud-unavailable' }); }
});
}
async function refresh() {
try {
const response = await request('read');
if (response?.ok !== true) {
const known = ['unauthenticated', 'invalid-action', 'invalid-request',
'config-missing', 'invalid-config', 'config-unavailable'];
return { ok: false, code: known.includes(response?.code) ? response.code : 'invalid-response' };
}
const next = parseConfig(response.value);
if (!next) return { ok: false, code: 'invalid-response' };
if (next.revision < current.revision) return { ok: false, code: 'stale-revision' };
if (next.revision === current.revision &&
(next.timeLimit !== current.timeLimit || next.moveLimit !== current.moveLimit)) {
return { ok: false, code: 'revision-conflict' };
}
current = Object.freeze(next);
return { ok: true, value: current };
} catch (error) {
return { ok: false, code: error?.code === 'cloud-timeout' ? 'cloud-timeout' : 'cloud-unavailable' };
}
}
return {
ping: () => request('ping'),
refresh,
getConfig: () => ({ ...current }),
createRun: () => Object.freeze({
configRevision: current.revision,
timeLimit: current.timeLimit,
moveLimit: current.moveLimit,
}),
};
}
module.exports = { createConfigClient };
这段代码有三个与使用体验直接相关的处理。
请求超过 8 秒就结束本次等待,迟到回调不再修改状态。这个时限是示例策略,不是 CloudBase 的平台限制;客户端停止等待也不代表云端执行被取消。
云端读取失败时保留当前可用配置。第一次启动没有远程配置,就使用包内的 30 秒、5 步默认值,玩家仍然能开局。本例只做进程内缓存,重启后回到默认值;正式游戏可另加经过校验的本地缓存。
收到低于已接受版本的配置时不回退;相同版本却变了内容也不接受。这能挡住旧请求晚到,以及开发者改了数值却忘记递增版本的情况。
在原生小游戏里运行示例
完整工程已经接好了 game.js。替换 AppID 和环境 ID 后启动,在开发者工具 Console 查看 tutorial-ping 和 tutorial-config。
这个教学壳不渲染游戏画面,目的是先把云调用跑通。它把操作暴露在 cloudTutorial 上,便于逐步验证:
cloudTutorial.ping().then(console.log);
cloudTutorial.refresh().then(console.log);
cloudTutorial.getConfig();
已有 Cocos 工程怎么接
Cocos 项目仍然从自己的启动组件或平台适配模块接入。不要把上面原生小游戏的 game.js 覆盖到 Creator 生成产物里。
附带示例采用原生小游戏 CommonJS 模块。迁入 Cocos 时,把同样的调用与校验逻辑放到 TypeScript 模块,改用项目的导入导出方式,并复用已有的微信平台封装。
《旋灯残响》已经把初始化、环境选择、总时限和调用诊断集中在微信云调用执行器里,各业务模块通过它访问云函数。这样接入第二个、第三个云能力时,不需要每个页面再写一份初始化。
把 30 秒改成 45 秒 完成一次闭环验收
现在先创建一局:
cloudTutorial.startRun();
成功读取第一版配置后,这一局应包含:
{ configRevision: 1, timeLimit: 30, moveLimit: 5 }
然后在数据库中编辑原来的 active 文档:把 revision 改成 2,把 timeLimit 改成 45,其他字段保留。
回到小游戏,主动刷新:
cloudTutorial.refresh().then(console.log);
依次检查:
cloudTutorial.getConfig(); // 当前配置:revision 2、45 秒
cloudTutorial.getRun(); // 原来的局:revision 1、30 秒
cloudTutorial.startRun(); // 新开一局:revision 2、45 秒
这里的关键是 createRun() 把配置值复制成一份开局快照。刷新只更换"下一局可用的配置",不会回头修改旧快照。实际游戏的计时器和步数判断也应该读取当前局的快照,而不是每一帧重新读取全局配置。
我们在项目里也是这样处理远程预算与旧断点的关系:新局采用新预算,已经开始的局保留原值。
这个演示只是本地对局快照。涉及正式竞赛时,规则版本、开局预算和成绩校验应由服务端共同确认,不能拿客户端快照充当可信竞赛凭证。
哪种修改需要重新部署
| 你改了什么 | 需要做什么 |
|---|---|
| 已支持字段的数值,例如时间 30 改成 45 | 保存测试库文档并递增 revision;客户端刷新后用于新局 |
| 云函数里的校验或读取逻辑 | 重新部署对应云函数 |
| 客户端第一次增加云配置读取能力 | 构建并发布包含该能力的客户端 |
| 新增旧客户端不认识的字段或规则 | 设计兼容协议,并更新相应客户端与云函数 |

三条路径分别对应配置保存、后端部署和客户端发布。是否需要发版,取决于改动位于哪一层。
"改数据库无需重新发小游戏"有一个前提:**玩家当前使用的客户端已经具备读取和解释这些配置的能力。**旧包没有这段代码,在云端建好集合也不会自动生效。
教学示例直接由开发者编辑测试库。多人维护正式配置时,再增加保存草稿、校验、查看差异、发布和历史记录;不要让普通管理表单直接任意修改生效配置。
出错时按调用路径排查

先确认入口,再检查真实微信调用,最后核对配置版本与新局快照。图中的业务错误码由本教学示例定义。
| 现象 | 先检查什么 |
|---|---|
| 开发者工具看不到云函数目录 | 打开的项目根目录,以及 cloudfunctionRoot |
| 提示未初始化云能力 | 微信宿主是否提供 wx.cloud,初始化是否执行且成功 |
| 函数不存在或环境不可用 | AppID 与环境关联、环境 ID、函数名、实际部署状态 |
| ping 成功,read 返回 unauthenticated | 是否从真实小游戏调用,服务端微信上下文是否有效 |
| 返回 config-missing | 当前环境是否存在 active 文档,ID 是否填对 |
| 返回 invalid-config | 数字类型、字段名称、schemaVersion 及取值范围 |
| 返回 config-unavailable | 云端数据库或 SDK 访问错误,结合函数日志检查 |
| 保存后当前设备仍是旧配置 | 是否主动刷新、是否递增 revision、是否仍在看旧局 |
| 返回 revision-conflict | 同一个 revision 的数值被改过;按发布规则使用新版本 |
日志记录动作、固定业务错误码、耗时和必要的 SDK 数字错误码即可。直接把完整请求、微信上下文或配置凭据打印出来,往往会让后续分享排查截图变得麻烦。
项目开发中,我们还遇到过工具操作成功却没有得到预期云端产物的情况。所以部署结束后,至少调用一次可识别版本的接口,再通过小游戏验证真实读取。配置读取正常、客户端接入正常、正式版本已发布,是三个分别需要确认的结果。
这份示例验证到了哪里
附带代码通过了 11 项本地自动检查:包括执行真实入口文件的模拟 SDK 测试、身份拒绝、错误配置、业务失败、重复回调、请求超时、响应乱序和新旧对局快照。
这些检查不创建云资源,也没有替读者执行部署。按本文部署后,还需要在自己的微信环境中完成第八节的读取和改值验收,再用手机确认实际调用。
最终验收时,看四个具体结果就够了:
- 小游戏能读到数据库中的第一版配置。
- 客户端直接访问配置集合受到权限限制。
- 修改数值并递增版本,刷新后的新局使用新值。
- 原来的局保留旧值,读取失败时仍有可用配置。
把这条链路做完整后,再扩展公告、活动展示和更多关卡参数,会容易很多。余额、库存、奖励发放等有实际价值的数据,则需要另行设计服务端权限、事务和重复请求处理。
如果你也给小游戏接过后端,第一项接入的是云存档、排行榜,还是远程配置?可以在评论区聊聊当时最费时间的一步。
本文案例来自解谜小游戏《旋灯残响》的开发过程。微信搜索游戏名,可以体验我们正在做的旋转灯路解谜。
完整教学工程包含云函数、原生小游戏调用壳、数据库样例和本地测试。