visitor@dapanz-cyber:~$ cat /var/www/dapanz.com/blog/67.md 🧑‍🤝‍🧑 团队

微信平台集成设计

[杂谈] 作者: BenDJoaner 时间:2026-09-14 15:40:04 游戏:方块卡车

状态:设计阶段,未开始实施。落地顺序见 §10 路线图。 适用范围:方块卡车(微信小游戏,compileType: game,appid wxe40cbb332c979e70)。 核心参考:微信小游戏官方文档 https://developers.weixin.qq.com/minigame/dev/guide/ 及 https://developers.weixin.qq.com/minigame/dev/api/ 。

1. 背景与目标

项目当前只用了微信平台 3 个能力:wx.getStorageSync / wx.setStorageSync(存档)、wx.createCanvas(渲染)、wx.createRewardedVideoAd(激励视频,stub 模式)。主体逻辑全部在客户端,无云端。

随着 119 关、9 道具 + 槽位、combo 经济成型,玩家"打完一关就走"的纯单机循环已经触顶。本次要补齐关系链 / 留存召回 / 拉新回流 / 数据上报四条线,目标:

  1. 好友榜:关卡界面显示"好友中收益最高前 3"头像,点击看首通 + 最高分详情(社交竞争 → 提升通关次数与次留)。
  2. 留存召回:订阅消息、桌面图标、签到提醒(让玩家愿意回来)。
  3. 拉新回流:分享关卡自动建立关系链(拿到前 3 好友的前提条件),邀请有礼(量级补充)。
  4. 数据上报:wx.reportEvent → 微信 we 分析后台看留存漏斗、关卡流失点(产品迭代的依据)。

不做的:微信支付(米大师虚拟支付)、公众号关联、视频号互通。两者都需额外资质或玩法大改,ROI 不高。

设计原则:

  • 零后端:优先用 wx.setUserCloudStorage / wx.getFriendsCloudStorage 这类"平台托管"API,不自建服务器。
  • 统一入口:所有 wx.* 调用收敛到 js/wechat.js 一个类,*业务模块禁止直接调 `wx.**(除存档storage.js已有约定外)。新 UI / 新场景只调wechat.xxx`。
  • 接口稳定,实现可换:类对外暴露的 API 是一次性设计,预留未来扩展点(即使当下没有 UI 调用,也要把方法列出来,避免后续改类签名)。
  • 降级优先:任何 wx API 在 stub / 旧基础库 / 网络异常下都要走降级路径,绝不阻塞主流程。

2. 结论速览

事项 结论
入口模块 新增 js/wechat.js(leaf 层),单例导出 wechat,所有 wx.* 平台 API 唯一出口
开放数据域 新增 js/opendata/index.js + js/opendata/render.js,独立 JS 上下文;主域通过 wx.getOpenDataContext() + sharedCanvas 通信
KV 存读 wx.setUserCloudStorage / getUserCloudStorage / getFriendsCloudStorage(不自建云开发)
数据上报 wx.reportEvent → 微信 we 分析后台(不接神策/友盟)
分享 复用现有 onShareAppMessage + wx.shareAppMessage,加 shareTicket 处理(建立好友关系链)
订阅消息 wx.requestSubscribeMessage,封装成"签到提醒 / 复活提醒 / 通关战报"三套模板钩子
广告 沿用现有 js/ads.js(不在本次新设计范围),wechat.js 仅转发 wechat.showAd() 调用
好友关系链来源 只能通过分享回流(玩家 A 分享 → 好友 B 点链接进入 → A 进入 B 的关系链;反之亦然)
第一优先级 好友榜(关卡界面"前 3 好友头像")
落地分阶段 §10 给出 MVP → 完整三阶段,每阶段可独立上线验证

3. 平台能力清单(按 wx API 分类)

下表覆盖本期设计会用到的全部 API。未列入的功能(云开发、广告以外的其他广告形态、URL Scheme/Short Link、客服消息、添加到桌面等)暂不接,需要时再加。

能力 API 用途 限制 本期是否接
登录 wx.login 拿 openid,玩家唯一标识 无显式授权 ✅
静默校验 wx.checkSession 校验 session_key 有效性 — ✅(包在 wechat.login 内部)
头像昵称 wx.getUserProfile 玩家主动授权拿头像昵称 2021 起必须主动触发,每次都要用户点 ⏳ 后续接(见 §6.5)
玩家 KV 存 wx.setUserCloudStorage 自己的成绩托管到微信云 单 key 1KB,最多 128 key ✅
自己 KV 读 wx.getUserCloudStorage 拉自己的 KV(云端兜底本地) 同上 ✅
好友 KV 读 wx.getFriendsCloudStorage 拉好友的 KV 好友必须最近玩过 + 触发过分享回流 ✅
开放数据域 wx.getOpenDataContext 拿独立 JS 上下文,sharedCanvas 画好友榜 数据域不能调业务 API ✅
分享 wx.onShareAppMessage / wx.shareAppMessage 转发卡片带 shareTicket 被动回调 ✅
分享菜单 wx.showShareMenu 显示右上角"···"分享按钮 — ✅
一次性订阅 wx.requestSubscribeMessage 召回推送(每次需用户主动授权) 单次有效 ✅
自定义事件 wx.reportEvent 上报到 we 分析后台 事件名英文小写下划线 ✅
数值监控 wx.reportMonitor 阈值告警(崩溃率/耗时) — ⏳ 后续接
实时日志 wx.getRealtimeLogManager 线上调试日志 — ⏳ 后续接
激励视频 wx.createRewardedVideoAd 复活/加倍奖励 走现有 js/ads.js 沿用(不重写)
桌面图标 wx.setTopBarText 等 添加到桌面 仅部分机型 ❌ 暂不接
米大师支付 wx.requestMidasPayment 虚拟支付 需资质 + 抽成 40% ❌ 本期不做
游戏圈 wx.createGameClubButton 玩家社区 接入复杂 ❌ 本期不做

3.1 平台关键限制(必须知道的硬约束)

  1. KVDataList 限额:
    • 单 key ≤ 1024 字节
    • 单玩家 ≤ 128 个 key
    • 本项目 119 关 + profile + wallet + 自定义统计 = 121 个 key,刚好不超,详见 §7.1
  2. 好友关系链只能通过分享回流建立:
    • 微信不再提供"加游戏好友"功能
    • 玩家 A 分享关卡 → 好友 B 点链接进入 → A 被记入 B 的关系链(反之亦然)
    • 新玩家首次进入游戏看到"暂无好友"是正常状态,需要先有分享动作
  3. 开放数据域与主域物理隔离:
    • 两个独立 JS 上下文,不能直接 import
    • 主域通过 wx.getOpenDataContext() 拿 context,通过 postMessage 发指令
    • 数据域用 wx.getSharedCanvas() 画图,主域用 drawImage(shared, ...) 拷贝
    • 数据域不能调 wx.login / wx.request / 业务 API
  4. 好友头像昵称来源:
    • getFriendsCloudStorage 返回的 avatarUrl / nickname 是好友最后一次授权时的快照
    • 好友从未授权过头像昵称 → 字段为空,要兜底(用首字母 + 渐变背景色)
  5. 订阅消息每次都要授权:
    • wx.requestSubscribeMessage 弹窗是用户每次主动点的"开关"
    • 不能像公众号一样"关注一次永久推送"
    • 设计签到提醒时只能在用户进入签到页时弹,不能自动弹

4. 总体架构

4.1 分层与文件组织

js/
├── wechat.js               新增·leaf 层:wx.* 平台 API 唯一出口,单例导出
├── opendata/               新增·独立运行环境
│   ├── index.js            数据域入口:接收主域 postMessage 指令
│   ├── render.js           sharedCanvas 绘制(好友榜 / 详情卡)
│   └── data.js             数据解析与排序逻辑
├── ads.js                  已有·leaf:激励视频(本次不改,仅被 wechat 转发)
├── storage.js              已有·leaf:本地存档(key 集中管理,新加 1 个 key)
├── main.js                 改:组合根接线 wechat / opendata
├── ui/
│   ├── levelintro.js       改:关卡界面嵌入"好友榜"区域
│   ├── settlement.js       改:结算后调用 wechat.reportEvent + share
│   └── pause.js            改:暂停菜单加"签到"入口(远期)
└── (其他既有模块不动)
game.js                     改:项目级 openDataContext 配置(其实在 project.config.json)
project.config.json         改:加 openDataContext 字段

依赖方向校验(scripts/check-imports.mjs 自动检查):

模块 层 可依赖 备注
js/wechat.js leaf core / render / leaf / dispatcher 可读 game state / 调 storage
js/opendata/*.js leaf(路径前缀未命中其他层) core / leaf 物理隔离,运行时与主域无 import 关系
js/ui/levelintro.js ui core / leaf / ui 可调 wechat
js/main.js root 全部 组合根接线

4.2 数据流

4.2.1 通关上报流(主域 → 微信云)

[rules.js: settle 关卡]
   ↓ 调 wechat.reportLevel(levelId, score, stars, isFirstPass)
[js/wechat.js]
   ├─→ 本地存档 storage.saveProgress()  (已有逻辑)
   └─→ wx.setUserCloudStorage({ KVDataList: [...] })
         └─→ 微信云存(每个玩家一个 KV 空间)

4.2.2 好友榜查询流(主域 → 数据域 → sharedCanvas)

[js/ui/levelintro.js: 进入关卡]
   ↓ 调 wechat.showFriendsBoard(levelId, myScore)
[js/wechat.js]
   ↓ wx.getOpenDataContext().postMessage({ type: 'renderLevelFriends', levelId, myScore })
[js/opendata/index.js]
   ↓ wx.getFriendsCloudStorage({ keyList: [...] })
   ↓ 解析 + 排序 + 截前 3
   ↓ 画到 wx.getSharedCanvas()  (drawTop3 / drawDetail)
[主域渲染]
   每帧 canvas.drawImage(sharedCanvas, x, y, w, h)  // 把数据域画的内容贴到主屏
   触摸事件转发到数据域: openContext.postMessage({ type: 'tap', x, y })

4.2.3 分享回流建立关系链

[主域:玩家 A 点"分享关卡"按钮]
   ↓ onShareAppMessage 返回 { title, imageUrl, query: { lv: 3, from: 'share' } }
[好友 B 收到卡片,点开]
   ↓ 微信自动把 A 记入 B 的 wx.getFriendsCloudStorage 关系链
   ↓ 同时 B 启动时主域检测 query.from === 'share',给 A 发"邀请奖励"(wallet +50)
[主域:玩家 A 的关卡界面]
   ↓ 下次进入时 wx.getFriendsCloudStorage 已能拉到 B

注意:关系链是单向的。B 玩 A 分享的链接进入,A 进 B 的列表;但 B 不一定在 A 的列表里(要 B 也分享给 A 才互为好友)。设计上只展示"我邀请过的人 + 邀请过我的人"的并集,逻辑在 opendata/data.js。

5. 核心类设计:js/wechat.js

5.1 总体定位

  • 唯一职责:所有 wx.* 平台 API 的包装与转发。业务模块不直接调 wx。
  • 不持有游戏业务状态:不存玩家分数、不算关卡奖励。结算逻辑在 main.js 组合根(与现有 ads.js 同构)。
  • 单例:与 ads.js / audio.js 一致,模块顶部 const wechat = { ... } 或 let wechat; export function getWechat()。本设计选模块级单例直接 export。
  • 降级:每个方法在 wx API 缺失 / 报错时返回安全的默认值(null / [] / false),绝不抛异常。
  • 冗余设计:很多方法本期没有 UI 调用,但类签名先列出来,让未来加 UI 时直接调即可,不需改类。

5.2 模块签名(最终代码骨架)

// js/wechat.js
// 微信平台 API 包装层(leaf):唯一出口,所有业务模块通过此文件调 wx.*。
// 设计原则:降级优先、零状态、接口稳定、冗余预留。
// 详见 docs/微信平台集成设计.md。

import { loadProgress, saveProgress, loadWallet, saveWallet } from './storage.js';
// 注意:core 层的 state 读取走参数注入(不在模块顶层引入 rules.js),避免循环依赖

// === 1. 登录与身份 ====================================================

/** 静默登录拿 openid;返回 Promise<string|null>。已登录则直接返回缓存 */
export function login() { ... }

/** 强制重新登录(session 过期时) */
export function reLogin() { ... }

/** 获取当前 openid(同步,未登录返回 null) */
export function getOpenid() { ... }

/** 玩家主动授权拿头像昵称(每次都要点按钮触发,弹 wx.getUserProfile) */
export function requestUserProfile() { ... }

/** 获取当前已授权的头像昵称(无则返回 null) */
export function getUserInfo() { ... }

// === 2. 玩家 KV 托管(云存) ==========================================

/** 通用 KV 写入;value 必须是 string,自动 JSON.stringify */
export function setKV(key, value) { ... }

/** 通用 KV 读取;返回解析后的对象,key 不存在返回 null */
export function getKV(key) { ... }

/** 批量读取多个 KV;返回 { [key]: parsedValue } */
export function getKVList(keys) { ... }

/** 批量写入;[{ key, value }] */
export function setKVList(list) { ... }

/** 拉取所有 KV(128 个全量);用于启动时同步云端状态 */
export function getAllKV() { ... }

// === 3. 好友关系链与排行 =============================================

/** 拉取好友的指定 KV 列表;返回 Promise<Array<FriendRecord>> */
export function getFriendsKV(keys) { ... }

/** 拉取好友关系链(不关心 KV,只看好友列表);用于"邀请好友"功能 */
export function getFriends() { ... }

/** 在数据域渲染"好友榜 top N";回调返回 sharedCanvas 贴图坐标 */
export function showFriendsBoard({ keyList, sortBy, topN, anchor }) { ... }

/** 关闭好友榜(数据域停止渲染) */
export function hideFriendsBoard() { ... }

/** 在数据域渲染好友详情卡(首通 + 最高分) */
export function showFriendDetail({ friend, keyList }) { ... }

// === 4. 分享与拉新 ===================================================

/** 设置全局 onShareAppMessage(覆盖微信"···"分享) */
export function setShareConfig({ title, imageUrl, query, callback }) { ... }

/** 主动拉起分享(带 shareTicket 拿好友关系) */
export function share({ title, imageUrl, query }) { ... }

/** 显示右上角"···"分享菜单(默认开启,必要时关闭) */
export function showShareMenu() { ... }
export function hideShareMenu() { ... }

/** 检测是否从分享卡片进入;返回 { from: 'share', inviterOpenid } */
export function getShareTicket() { ... }

// === 5. 订阅消息(召回) =============================================

/** 请求一次性订阅授权;templateIds 是微信后台申请的模板 id 列表 */
export function requestSubscribe(templateIds) { ... }

/** 业务封装:签到提醒授权(模板 id 在配置) */
export function requestSignInReminder() { ... }

/** 业务封装:复活提醒授权 */
export function requestReviveReminder() { ... }

/** 业务封装:通关战报授权 */
export function requestLevelPassReport() { ... }

// === 6. 事件订阅(冗余预留) =========================================

/** 订阅事件:login 成功 / share 成功 / friends 更新 / ad 结束等 */
export function on(event, callback) { ... }

/** 取消订阅 */
export function off(event, callback) { ... }

// 事件常量
export const WX_EVENT = Object.freeze({
  LOGIN_SUCCESS: 'login_success',
  LOGIN_FAIL: 'login_fail',
  SHARE_SUCCESS: 'share_success',
  SHARE_FAIL: 'share_fail',
  FRIENDS_UPDATED: 'friends_updated',
  AD_REWARDED: 'ad_rewarded',          // 由 ads.js 转发
  AD_CLOSED: 'ad_closed',              // 由 ads.js 转发
  KV_SYNCED: 'kv_synced',              // 上报完成
});

// === 7. 数据上报(埋点) =============================================

/** 自定义事件上报(推荐,we 分析后台可视化) */
export function reportEvent(name, data = {}) { ... }

/** 数值监控上报(阈值告警) */
export function reportMonitor(name, value) { ... }

/** 实时日志(线上排错) */
export function getRealtimeLogManager() { ... }

// === 8. 广告转发(不重写 ads.js) ====================================

/** 转发到 ads.js 的激励视频;返回 Promise<{ ok, reason }> */
export function showAd(reason) { ... }  // 内部: import { showRewardedAd } from './ads.js'

// === 9. 系统能力(冗余预留) =========================================

/** 主动拉起添加到桌面(部分机型支持) */
export function addToDesktop() { ... }

/** 短链接生成(站外拉新) */
export function generateShortLink(path) { ... }

/** 检测微信版本与基础库 */
export function getSDKInfo() { ... }

/** 检测网络状态 */
export function getNetworkType() { ... }

5.3 关键方法详细规约

5.3.1 login()

// 伪代码
let cachedOpenid = null;

export function login() {
  return new Promise((resolve) => {
    if (cachedOpenid) return resolve(cachedOpenid);
    wx.login({
      success: (res) => {
        // res.code 发送到自己的服务端换 openid(如果用云开发则调云函数)
        // 本期无后端:用 wx.getUserCloudStorage 的隐式 session 做标识
        cachedOpenid = res.code; // 临时以 code 作 openid 占位
        emit(WX_EVENT.LOGIN_SUCCESS, cachedOpenid);
        resolve(cachedOpenid);
      },
      fail: (err) => {
        emit(WX_EVENT.LOGIN_FAIL, err);
        resolve(null);  // 降级:返回 null,UI 走游客模式
      }
    });
  });
}

降级:login 失败时返回 null,UI 显示"游客模式",不阻塞游戏。后续可在云函数中 code → openid,本期不做。

5.3.2 setKV(key, value) / getKVList(keys)

写入合并:单次 wx.setUserCloudStorage 调用是整批覆盖(KVDataList 是完整列表),所以要先拉后写:

export async function setKV(key, value) {
  try {
    // 拉已有 KV(避免覆盖其他 key)
    const existing = await getAllKV();
    const next = { ...existing, [key]: { key, value: typeof value === 'string' ? value : JSON.stringify(value) } };
    const list = Object.values(next).map(kv => ({ key: kv.key, value: kv.value }));
    if (list.length > 128) throw new Error('KV overflow');
    return new Promise((resolve) => {
      wx.setUserCloudStorage({
        KVDataList: list,
        success: () => { emit(WX_EVENT.KV_SYNCED, { key }); resolve(true); },
        fail: (e) => { resolve(false); }
      });
    });
  } catch (e) { return false; }
}

写入时机(业务方调用):

  • setKV('profile', { level, stars, wallet }) —— 每次结算/槽位升级
  • setKV('lv3', { best, first, stars, attempts }) —— 通关某关时
  • 批量合并:高频写入做 500ms debounce,避免每次 1 分钱变动都拉全量

5.3.3 showFriendsBoard({ keyList, sortBy, topN, anchor })

这是核心 API,流程:

export function showFriendsBoard({ keyList, sortBy = 'best', topN = 3, anchor }) {
  if (typeof wx.getOpenDataContext !== 'function') return false;
  const ctx = wx.getOpenDataContext();
  ctx.postMessage({ type: 'renderTop', keyList, sortBy, topN, anchor });
  return true;
}

anchor = { x, y, w, h } 告诉数据域把画布缩放到这个区域(数据域 sharedCanvas 物理尺寸固定,靠主域 drawImage 缩放)。

5.3.4 事件总线内部实现

简单的内存订阅表,不引外部库:

const listeners = new Map();
function emit(event, data) {
  const list = listeners.get(event) || [];
  list.forEach(cb => { try { cb(data); } catch (e) { /* 单个 cb 异常不影响其他 */ } });
}
export function on(event, cb) {
  if (!listeners.has(event)) listeners.set(event, []);
  listeners.get(event).push(cb);
}
export function off(event, cb) {
  const list = listeners.get(event);
  if (!list) return;
  const i = list.indexOf(cb);
  if (i >= 0) list.splice(i, 1);
}

ad 事件由 ads.js 主动 emit(ads.js 调 wechat.emit(WX_EVENT.AD_REWARDED, ...)),不破坏现有 ads.js 接口。

5.4 与 storage.js 的职责边界

关注点 走 storage.js 走 wechat.js
本地存档 ✅ ❌
云端托管 KV ❌ ✅
钱包/进度持久化 ✅ ❌
好友可见的关卡数据 ❌ ✅
频控 / 限流 ✅ ❌
关系链 / 头像 / 昵称 ❌ ✅

两者关系:wechat.js 在 setKV 时同时调 storage.js(保持本地一致),不替代 storage。

6. 开放数据域设计

6.1 物理隔离与通信

[主域(main.js 等)]              [数据域(opendata/)]
   ↓ postMessage              ↑ onMessage
   wx.getOpenDataContext()  → 独立 JS 上下文
   ↓ 拉 sharedCanvas 贴图
   canvas.drawImage(shared)
   ↓ 触摸事件
   ctx.postMessage({ type: 'tap', x, y })  →  数据域内 hitTest

通信协议(postMessage 双向):

主域 → 数据域 含义
{ type: 'renderTop', keyList, sortBy, topN, anchor } 渲染好友榜 top N
{ type: 'renderDetail', friend, keyList } 渲染详情卡
{ type: 'hide' } 清空 sharedCanvas
{ type: 'tap', x, y } 触摸命中测试
数据域 → 主域 含义
{ type: 'ready' } 数据域 JS 加载完成
{ type: 'hit', friend } 命中某好友头像
{ type: 'close' } 用户点关闭

6.2 文件结构

js/opendata/
├── index.js       入口:onMessage 路由
├── data.js        拉取 + 解析 + 排序(纯函数)
├── render.js      sharedCanvas 绘制
├── style.js       颜色 / 字体 / 间距常量
└── README.md      数据域开发说明(给未来的自己)

6.3 opendata/index.js 骨架

// js/opendata/index.js
// 开放数据域入口:独立 JS 上下文,只能调 wx.getUserCloudStorage / getFriendsCloudStorage / getSharedCanvas。
// 不能调 wx.login / wx.request / 业务 API。

import { getTopFriends } from './data.js';
import { renderTop, renderDetail, renderEmpty } from './render.js';

wx.onMessage((msg) => {
  switch (msg.type) {
    case 'renderTop':
      getTopFriends(msg.keyList, msg.sortBy, msg.topN)
        .then(list => renderTop(list, msg.anchor));
      break;
    case 'renderDetail':
      renderDetail(msg.friend, msg.keyList, msg.anchor);
      break;
    case 'hide':
      clearCanvas();
      break;
    case 'tap':
      handleTap(msg.x, msg.y);
      break;
  }
});

6.4 opendata/data.js 排序逻辑

// js/opendata/data.js
// 拉取好友 KV 并按指定字段排序,返回 top N。

export function getTopFriends(keyList, sortBy = 'best', topN = 3) {
  return new Promise((resolve) => {
    wx.getFriendsCloudStorage({
      keyList,
      success: (res) => {
        const list = res.data || [];
        // 每个 friend 的 KVDataList 是 [{ key, value }],value 是字符串
        const parsed = list.map(f => {
          const parsedKV = {};
          (f.KVDataList || []).forEach(kv => {
            try { parsedKV[kv.key] = JSON.parse(kv.value); }
            catch (e) { parsedKV[kv.key] = kv.value; }
          });
          return {
            openid: f.openid,
            nickname: f.nickname || '',
            avatarUrl: f.avatarUrl || '',
            KV: parsedKV,
          };
        });
        // 按指定字段降序
        parsed.sort((a, b) => (b.KV[extractKey(keyList)]?.[sortBy] || 0)
                              - (a.KV[extractKey(keyList)]?.[sortBy] || 0));
        resolve(parsed.slice(0, topN));
      },
      fail: () => resolve([])
    });
  });
}

6.5 头像兜底与缓存

问题:好友未授权过头像 → avatarUrl 为空。 方案:

  • render.js 里发现 avatarUrl 为空时,画首字母 + 渐变色方块
  • 头像加载:wx.createImage() + 本地缓存(用 wx.setStorage 存头像 base64,跨会话复用)
// opendata/render.js(片段)
const avatarCache = new Map();
function loadAvatar(url) {
  if (!url) return Promise.resolve(null);
  if (avatarCache.has(url)) return Promise.resolve(avatarCache.get(url));
  return new Promise((resolve) => {
    const img = wx.createImage();
    img.onload = () => { avatarCache.set(url, img); resolve(img); };
    img.onerror = () => resolve(null);
    img.src = url;
  });
}

首次拉取头像授权:玩家第一次进入游戏时主域调 wechat.requestUserProfile(),玩家点头后云存的 avatarUrl / nickname 才有值。不强制:游客也能玩,只是云存里没头像。

6.6 触摸事件转发

主域每帧把 wx.onTouchStart / onTouchMove / onTouchEnd 坐标转发到数据域:

// 主域 main.js 片段
const openCtx = wx.getOpenDataContext();
wx.onTouchStart(e => {
  const t = e.touches[0];
  if (isInFriendsBoardArea(t.clientX, t.clientY)) {
    openCtx.postMessage({ type: 'tap', x: t.clientX, y: t.clientY });
  }
});

数据域内 hitTest(x, y) 命中后调 wx.postMessage(注意:数据域 → 主域用 wx.postMessage 而不是 openCtx.postMessage):

// 数据域内
function handleTap(x, y) {
  const hit = currentTopList.find(f => pointInRect(x, y, f.rect));
  if (hit) {
    wx.postMessage({ type: 'hit', friend: hit });
  }
}

7. 数据模型

7.1 KVDataList Schema

Key Value 结构 写入时机 占用
profile { level: 119, stars: 245, wallet: 12800 } 结算、升级槽位 ~80B
lv1 ~ lv119 { best: 1234, first: 1234567890, stars: 3, attempts: 5 } 通关某关时(首通/破纪录) ~80B × 119 = ~9.5KB
wallet { value: 12800, lastChange: 1234567890 } 金币变动(debounce 500ms) ~60B
signin { last: '2026-09-14', streak: 7, total: 30 } 签到时 ~60B
loadout { slots: [1, 5, 9, null] } 槽位装备变更 ~40B
adCount { date: '2026-09-14', count: 3 } 看广告时 ~40B
custom:<key> 自定义(业务自行约定) — <1KB

总数:119 + 1 + 1 + 1 + 1 + 1 = 124 个 key,超出 128 限制 4 个。

应对:

  • 方案 A(推荐):仅有数据的关卡才写 KV,玩家没玩到的关不写入。统计下来平均活跃玩家通关 30-50 关,占 30-50 个 key,稳。
  • 方案 B:用 lvFirst / lvBest 两个 key 存所有关卡首通时间戳/最高分(每个 key 内 JSON 数组),降低 key 数。但单 key 不能超 1KB,119 关 × (8B 时间戳 + 4B 分数) ≈ 1.4KB,超限,不可行。
  • 方案 C:超过 100 关时把已三星的关卡 KV 合并到 lvStars:{ '100-119': [3,3,3,3,...] } 等聚合 key。留作远期优化。

结论:方案 A,文档里写明"未通关关卡不占 KV 配额"。

7.2 好友关系链 Schema

数据域从 wx.getFriendsCloudStorage 拿到的原始结构:

{
  data: [
    {
      openid: 'oXyz...',
      nickname: '玩家昵称',
      avatarUrl: 'http://...',  // 可能为空
      KVDataList: [
        { key: 'lv3', value: '{"best":1234,"first":1234567890,...}' },
        { key: 'profile', value: '{...}' }
      ]
    }
  ]
}

opendata/data.js 解析后:

{
  openid: 'oXyz...',
  nickname: '玩家昵称',
  avatarUrl: 'http://...',
  KV: {
    lv3: { best: 1234, first: 1234567890, stars: 3, attempts: 5 },
    profile: { level: 119, stars: 245, wallet: 12800 },
  }
}

7.3 本地存档新增 key

storage.js 顶部新增:

export const WECHAT_OPENID_KEY = 'truck-tetris-wx-openid';        // 登录后的 openid
export const WECHAT_PROFILE_KEY = 'truck-tetris-wx-profile';      // 玩家头像昵称快照

沿用"所有 key 集中此文件"的约定。

8. 业务场景接入

8.1 关卡界面:好友榜前 3

UI 改动(js/ui/levelintro.js):

  • 关卡详情卡右侧加 60×180 区域
  • 区域内容由主域每帧 drawImage(sharedCanvas, x, y, 60, 180) 贴
  • 触摸在该区域 → 转发到数据域

数据流:

  1. 进入关卡详情页 → wechat.showFriendsBoard({ keyList: ['lv'+id, 'profile'], sortBy: 'best', topN: 3, anchor: { x, y, w: 60, h: 180 } })
  2. 数据域 getFriendsCloudStorage → 解析排序 → 画头像和分数
  3. 玩家点头像 → 数据域 wx.postMessage({ type: 'hit', friend }) → 主域收到后切详情卡
  4. 离开关卡详情页 → wechat.hideFriendsBoard()

空状态:

  • 好友数 0 → 画"暂无好友,分享关卡邀请吧" + 分享按钮
  • 好友有但都没玩过这关 → 画"好友还没挑战过" + 邀请按钮

8.2 结算页:上报 + 分享

js/ui/settlement.js 改动:

  • 通关成功后 → wechat.reportEvent('level_pass', { level, stars, score, duration })
  • 通关失败 → wechat.reportEvent('level_fail', { level, score, failReason })
  • 结算页"分享战绩"按钮 → wechat.share({ title, imageUrl, query: { lv: levelId, from: 'settle' } })

8.3 暂停菜单:签到入口(远期)

js/ui/pause.js 改动(第二阶段才做):

  • 暂停菜单加"每日签到"按钮
  • 点击 → 弹签到面板 → 调 wechat.requestSignInReminder() 申请订阅授权
  • 用户授权后 → 本地存签到状态 → wechat.setKV('signin', { last, streak })

8.4 启动流程:登录 + KV 同步

js/main.js 改动(启动阶段):

// 伪代码
async function bootstrap() {
  await wechat.login();                              // 静默登录
  const cloudKV = await wechat.getAllKV();           // 拉云端 KV
  const localProgress = loadProgress();              // 读本地
  mergeProgress(localProgress, cloudKV);             // 合并(云端优先,仅补充本地缺失)
  // 订阅关系链更新(弱提示)
  wechat.on(WX_EVENT.FRIENDS_UPDATED, () => {
    // 拉一次新好友,触发 open data 重绘
  });
}

8.5 通用埋点规约

事件名规范(wechat.reportEvent):

  • 全小写 + 下划线:level_pass / level_fail / item_use / share_click / ad_watch / signin_done
  • 单次会话内高频事件(cell.moved 等)不打,只打用户能感知的节点

参数规范:

  • level(关卡 id, 1-119)
  • score(数值)
  • duration(毫秒)
  • reason(字符串枚举,见事件名)
  • ts(本地时间戳,we 分析后台按此去重)

完整事件清单:见 §11.1 附录。

9. 错误处理与降级

场景 降级方案 UI 表现
玩家拒绝 getUserProfile 走游客模式 顶部显示"游客",关卡界面好友榜只显示自己
wx.login 失败 走游客模式 同上
setUserCloudStorage 失败 本地存档已写,云端丢失 静默,不提示
getFriendsCloudStorage 失败 返回空数组 好友榜显示"暂无好友"
旧基础库无 wx.getOpenDataContext 整块好友榜不显示 关卡界面不渲染该区域
旧基础库无 wx.requestSubscribeMessage 订阅按钮不显示 暂停菜单无"签到"入口
玩家从未邀请过好友 关系链空 好友榜显示"暂无好友" + 引导分享
微信 SDK 报错 静默吞掉 不影响游戏

核心原则:任何 wx 错误都不能阻塞游戏主循环。wechat.js 内所有方法一律 try-catch,所有 Promise 都有 .catch(() => null)。

10. 落地路线(分阶段实施)

10.1 Phase 1:好友榜 MVP(最小可用版本)

目标:关卡界面显示"好友中收益最高前 3"头像,点击看详情。

改动清单:

  1. 新建 js/wechat.js —— 实现 §5.2 中 §1(login)+ §2(KV 读写)+ §3(showFriendsBoard / hideFriendsBoard / showFriendDetail / getFriendsKV)
  2. 新建 js/opendata/{index,data,render,style}.js
  3. 改 project.config.json 加 "openDataContext": "js/opendata"
  4. 改 js/main.js 启动时调 wechat.login()
  5. 改 js/rules.js / js/settle.js 通关时调 wechat.setKV('lv'+id, ...) 和 wechat.setKV('profile', ...)
  6. 改 js/ui/levelintro.js 加好友榜区域 + 触摸转发
  7. 改 js/storage.js 顶部加 1 个 key
  8. 改 game.js(如需)

验证清单:

  • [ ] 微信开发者工具模拟器编译通过
  • [ ] 进入关卡能拉取到自己的 KV
  • [ ] 模拟好友 KV(用云函数 mock 或另一台手机登录)能正确显示
  • [ ] 触摸头像能切到详情
  • [ ] 通关后云存确实写入了(开发者工具 → 调试 → 云存面板)
  • [ ] node scripts/check-imports.mjs 通过
  • [ ] node --experimental-vm-modules scripts/check-syntax.mjs 通过
  • [ ] node scripts/test-integration.mjs 通过

预计代码量:~600 行(wechat.js ~300 行 + opendata ~200 行 + UI 改动 ~100 行)

10.2 Phase 2:留存召回 + 拉新回流

目标:订阅消息、签到、分享回流激活关系链。

改动清单:

  1. js/wechat.js 实现 §5.2 中 §4(分享)+ §5(订阅消息)
  2. 改 js/ui/settlement.js 结算页加订阅入口
  3. 改 js/ui/levelintro.js 好友榜空状态加"邀请好友"按钮
  4. 新建 js/ui/signin.js(可选,本期不一定要做 UI)
  5. 改 js/ui/pause.js 暂停菜单加"签到"入口(远期)

验证清单:

  • [ ] 结算页能弹订阅消息授权
  • [ ] 分享回流能建立关系链(用两台手机互测)
  • [ ] 邀请奖励正确发放(wallet +50)

10.3 Phase 3:完整埋点 + 运营工具

目标:we 分析后台能看完整漏斗。

改动清单:

  1. js/wechat.js 实现 §5.2 中 §6(事件订阅)+ §7(数据上报)
  2. 在 js/rules.js / js/main.js / js/ui/* 关键节点埋点
  3. 新建 js/analytics.js(可选,如果觉得 wechat.reportEvent 散落不好,可以再封一层)
  4. we 分析后台配置漏斗 + 留存看板

验证清单:

  • [ ] 所有关键事件均能在 we 分析后台看到
  • [ ] 留存漏斗配置正确
  • [ ] 性能监控(崩溃率、加载耗时)告警设置完成

11. 附录

11.1 完整事件清单(埋点规范)

事件名 触发时机 参数
app_launch 小程序启动 { scene, version }
session_start 进入主界面 {}
session_end 离开主界面 { duration }
level_start 进入关卡 { level, attempt }
level_pass 通关 { level, stars, score, duration, combo }
level_fail 失败/放弃 { level, score, reason }
item_use 用道具 { item, level }
item_buy 买道具 { item, cost }
slot_upgrade 槽位升级 { slot, from, to }
share_click 点分享 { source }
share_success 分享成功 { source, channel }
ad_watch 看广告 { type, scene, ended }
wallet_change 金币变动 { delta, reason, balance }
signin_done 完成签到 { streak, total }
friendsboard_view 查看好友榜 { level, friendCount }
friendsboard_hit 点击好友头像 { friend, level }
subscribe_request 请求订阅 { templateId, scene }
subscribe_accept 用户同意 { templateId, scene }
subscribe_deny 用户拒绝 { templateId, scene }

事件名总数:18 个核心事件 + 预留扩展位。控制在 30 以内(we 分析后台维护成本)。

11.2 wx API 索引(官方文档链接)

API 文档
wx.login https://developers.weixin.qq.com/minigame/dev/api/open-api/login/wx.login.html
wx.getUserProfile https://developers.weixin.qq.com/minigame/dev/api/open-api/user-info/wx.getUserProfile.html
wx.setUserCloudStorage https://developers.weixin.qq.com/minigame/dev/api/open-api/data/wx.setUserCloudStorage.html
wx.getUserCloudStorage https://developers.weixin.qq.com/minigame/dev/api/open-api/data/wx.getUserCloudStorage.html
wx.getFriendsCloudStorage https://developers.weixin.qq.com/minigame/dev/api/open-api/data/wx.getFriendsCloudStorage.html
wx.getOpenDataContext https://developers.weixin.qq.com/minigame/dev/api/open-api/data/wx.getOpenDataContext.html
wx.onShareAppMessage https://developers.weixin.qq.com/minigame/dev/api/share/wx.onShareAppMessage.html
wx.shareAppMessage https://developers.weixin.qq.com/minigame/dev/api/share/wx.shareAppMessage.html
wx.requestSubscribeMessage https://developers.weixin.qq.com/minigame/dev/api/open-api/subscribe-message/wx.requestSubscribeMessage.html
wx.reportEvent https://developers.weixin.qq.com/minigame/dev/api/data-analysis/wx.reportEvent.html
wx.reportMonitor https://developers.weixin.qq.com/minigame/dev/api/data-analysis/wx.reportMonitor.html
wx.getRealtimeLogManager https://developers.weixin.qq.com/minigame/dev/api/base/debug/wx.getRealtimeLogManager.html
开放数据域指南 https://developers.weixin.qq.com/minigame/dev/guide/open-ability/open-data.html
关系链数据 https://developers.weixin.qq.com/minigame/dev/guide/open-ability/open-data.html#%E5%85%B3%E7%B3%BB%E9%93%BE%E6%95%B0%E6%8D%AE
关系链互动数据 https://developers.weixin.qq.com/minigame/dev/guide/open-ability/open-data.html#%E5%85%B3%E7%B3%BB%E9%93%BE%E4%BA%92%E5%8A%A8%E6%95%B0%E6%8D%AE
分享与转发 https://developers.weixin.qq.com/minigame/dev/guide/share-and-forward/share.html
订阅消息 https://developers.weixin.qq.com/minigame/dev/guide/open-ability/subscribe-message.html
自定义分析 / we 分析 https://developers.weixin.qq.com/minigame/dev/guide/data-analysis/custom.html

11.3 参考已有项目(同类游戏常见做法)

模式 描述 适合本项目
好友通关对比 关卡结算页显示"你比 N% 好友快" ✅ 适合加在结算页
接力模式 分享给好友续关 ⏳ 第二阶段可加
邀请有礼 双方各得奖励 ✅ 适合做拉新
群排行 群里分享后展示"群内前 N" ⏳ 需 group/shareInfo 扩展
桌面挂件 添加到桌面后显示倒计时 ❌ 微信小游戏桌面挂件能力有限

11.4 与现有模块的依赖关系图

┌─────────────────────────────────────────────────────────────┐
│ main.js (root)                                              │
│  ├─ import wechat (new)                                     │
│  ├─ import ads (existing)                                   │
│  ├─ import storage (existing)                               │
│  ├─ import rules (dispatcher)                               │
│  └─ import ui/* (ui)                                        │
└────────────┬────────────────────────────────────────────────┘
             │
             ▼
┌─────────────────────────────────────────────────────────────┐
│ js/wechat.js (leaf) ── imports storage (leaf) ── allows ok  │
│  exports: login / setKV / showFriendsBoard / share / ...    │
└────────────┬────────────────────────────────────────────────┘
             │  postMessage
             ▼
┌─────────────────────────────────────────────────────────────┐
│ js/opendata/* (独立上下文, import 关系与主域隔离)            │
│  - 物理上由微信运行时隔离,check-imports.mjs 不会扫到跨域引用 │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│ js/ui/levelintro.js (ui)                                    │
│  ├─ import wechat (leaf→ui 合法)                            │
│  ├─ import core/* (ui→core 合法)                            │
│  └─ import ui/* (ui→ui 合法)                                │
└─────────────────────────────────────────────────────────────┘

check-imports.mjs 校验预期:

  • js/wechat.js 是 leaf 层,import 任何其他 leaf 合法
  • js/opendata/* 是 leaf 层(路径前缀未命中其他层),import 任何 leaf 合法
  • js/ui/levelintro.js import ./wechat.js(leaf)合法
  • js/main.js import ./wechat.js(leaf)合法

无规则冲突。

12. 风险与未决问题

风险 影响 应对
关系链需分享激活,新玩家看到空列表 首日体验差 UI 强引导分享,空状态有"邀请"按钮
KV 128 限制 124 个 key 接近上限 已通关关才写 KV,远期聚合
头像昵称为空 好友榜显示首字母方块 兜底方案 §6.5
旧基础库无 openDataContext 部分老设备无好友榜 API 探测降级,UI 不渲染该区
wx.reportEvent 在 we 分析后台显示延迟 数据看板延后 5-10 分钟 仅影响运营观察,不影响游戏
分享回流建立关系链需要 B 也玩过游戏 B 进来后立刻退出不算 设计上不要求"必须通关",只要进游戏就建立

未决问题(实施前需用户确认):

  1. "好友中收益最高前 3"的"收益"定义:是关卡 best(最高分)还是 score(本局得分)还是 stars*level(综合)?我假设 best(最高分)。需确认。
  2. 签到奖励用什么道具 / 多少金币?需要产品和数值决定。
  3. 是否做"接力续关":分享给好友帮忙续一关,对单机游戏是亮点但要做分享卡片。推荐第二阶段做。
  4. 群排行(群内前 N)是否要做?需 groupShareInfo 扩展,复杂度高。
Comment:
────────────── 评论区 ──────────────
(暂无评论)