《旋灯残响》微信小游戏接入云开发 从第一个云函数到远程关卡配置

《旋灯残响》开发记录

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 测试、身份拒绝、错误配置、业务失败、重复回调、请求超时、响应乱序和新旧对局快照。

这些检查不创建云资源,也没有替读者执行部署。按本文部署后,还需要在自己的微信环境中完成第八节的读取和改值验收,再用手机确认实际调用。

最终验收时,看四个具体结果就够了:

  1. 小游戏能读到数据库中的第一版配置。
  2. 客户端直接访问配置集合受到权限限制。
  3. 修改数值并递增版本,刷新后的新局使用新值。
  4. 原来的局保留旧值,读取失败时仍有可用配置。

把这条链路做完整后,再扩展公告、活动展示和更多关卡参数,会容易很多。余额、库存、奖励发放等有实际价值的数据,则需要另行设计服务端权限、事务和重复请求处理。

如果你也给小游戏接过后端,第一项接入的是云存档、排行榜,还是远程配置?可以在评论区聊聊当时最费时间的一步。

本文案例来自解谜小游戏《旋灯残响》的开发过程。微信搜索游戏名,可以体验我们正在做的旋转灯路解谜。

完整教学工程包含云函数、原生小游戏调用壳、数据库样例和本地测试。

相关推荐
郝学胜-神的一滴7 小时前
游戏设计:原型与开发 17:数字化系统里的思维体操
人工智能·游戏·游戏程序·游戏策划·游戏设计
Solange__3 天前
AI模型编辑器、Blender和3D Agent怎么选?按5类修改任务比较局部编辑、拓扑与自动化
人工智能·3d·自然语言处理·编辑器·音视频·blender·游戏策划
郝学胜-神的一滴5 天前
游戏设计:原型与开发 12:概率和反馈与数值平衡实战
人工智能·游戏·游戏程序·游戏策划·游戏设计
Solange__6 天前
影视预演怎么用AI生成3D场景?从分镜、Blockout到镜头交付的完整流程
人工智能·游戏·3d·ai·自然语言处理·aigc·游戏策划
郝学胜-神的一滴6 天前
游戏设计:原型与开发 11:用概率与电子表格搞定骰子系统
程序人生·游戏·游戏策划·1024程序员节·游戏设计
Solange__8 天前
AI材质生成、图片贴图和材质扫描怎么选?按5类3D资产比较PBR、可编辑与授权
人工智能·3d·ai·自然语言处理·材质·贴图·游戏策划
郝学胜-神的一滴9 天前
游戏设计:原型与开发 08:我们到底在为谁做游戏
程序人生·游戏·游戏引擎·游戏开发·游戏策划
Martina_03219 天前
AI生成3D模型UV岛太碎,重绘贴图全是接缝?用6步重整UV并保留原材质
图像处理·人工智能·3d·自然语言处理·贴图·uv·游戏策划
郝学胜-神的一滴9 天前
游戏设计:原型与开发 07:像游戏设计师一样工作
图像处理·游戏·unity·图形渲染·游戏策划·游戏设计