状态:设计阶段,未开始实施。落地顺序见 §10 路线图。 适用范围:方块卡车(微信小游戏,
compileType: game,appidwxe40cbb332c979e70)。 核心参考:微信小游戏官方文档 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 经济成型,玩家"打完一关就走"的纯单机循环已经触顶。本次要补齐关系链 / 留存召回 / 拉新回流 / 数据上报四条线,目标:
- 好友榜:关卡界面显示"好友中收益最高前 3"头像,点击看首通 + 最高分详情(社交竞争 → 提升通关次数与次留)。
- 留存召回:订阅消息、桌面图标、签到提醒(让玩家愿意回来)。
- 拉新回流:分享关卡自动建立关系链(拿到前 3 好友的前提条件),邀请有礼(量级补充)。
- 数据上报:
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 平台关键限制(必须知道的硬约束)
- KVDataList 限额:
- 单 key ≤ 1024 字节
- 单玩家 ≤ 128 个 key
- 本项目 119 关 + profile + wallet + 自定义统计 = 121 个 key,刚好不超,详见 §7.1
- 好友关系链只能通过分享回流建立:
- 微信不再提供"加游戏好友"功能
- 玩家 A 分享关卡 → 好友 B 点链接进入 → A 被记入 B 的关系链(反之亦然)
- 新玩家首次进入游戏看到"暂无好友"是正常状态,需要先有分享动作
- 开放数据域与主域物理隔离:
- 两个独立 JS 上下文,不能直接 import
- 主域通过
wx.getOpenDataContext()拿 context,通过postMessage发指令 - 数据域用
wx.getSharedCanvas()画图,主域用drawImage(shared, ...)拷贝 - 数据域不能调
wx.login/wx.request/ 业务 API
- 好友头像昵称来源:
getFriendsCloudStorage返回的avatarUrl/nickname是好友最后一次授权时的快照- 好友从未授权过头像昵称 → 字段为空,要兜底(用首字母 + 渐变背景色)
- 订阅消息每次都要授权:
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)贴 - 触摸在该区域 → 转发到数据域
数据流:
- 进入关卡详情页 →
wechat.showFriendsBoard({ keyList: ['lv'+id, 'profile'], sortBy: 'best', topN: 3, anchor: { x, y, w: 60, h: 180 } }) - 数据域
getFriendsCloudStorage→ 解析排序 → 画头像和分数 - 玩家点头像 → 数据域
wx.postMessage({ type: 'hit', friend })→ 主域收到后切详情卡 - 离开关卡详情页 →
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"头像,点击看详情。
改动清单:
- 新建
js/wechat.js—— 实现 §5.2 中 §1(login)+ §2(KV 读写)+ §3(showFriendsBoard / hideFriendsBoard / showFriendDetail / getFriendsKV) - 新建
js/opendata/{index,data,render,style}.js - 改
project.config.json加"openDataContext": "js/opendata" - 改
js/main.js启动时调wechat.login() - 改
js/rules.js/js/settle.js通关时调wechat.setKV('lv'+id, ...)和wechat.setKV('profile', ...) - 改
js/ui/levelintro.js加好友榜区域 + 触摸转发 - 改
js/storage.js顶部加 1 个 key - 改
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:留存召回 + 拉新回流
目标:订阅消息、签到、分享回流激活关系链。
改动清单:
js/wechat.js实现 §5.2 中 §4(分享)+ §5(订阅消息)- 改
js/ui/settlement.js结算页加订阅入口 - 改
js/ui/levelintro.js好友榜空状态加"邀请好友"按钮 - 新建
js/ui/signin.js(可选,本期不一定要做 UI) - 改
js/ui/pause.js暂停菜单加"签到"入口(远期)
验证清单:
- [ ] 结算页能弹订阅消息授权
- [ ] 分享回流能建立关系链(用两台手机互测)
- [ ] 邀请奖励正确发放(wallet +50)
10.3 Phase 3:完整埋点 + 运营工具
目标:we 分析后台能看完整漏斗。
改动清单:
js/wechat.js实现 §5.2 中 §6(事件订阅)+ §7(数据上报)- 在
js/rules.js/js/main.js/js/ui/*关键节点埋点 - 新建
js/analytics.js(可选,如果觉得 wechat.reportEvent 散落不好,可以再封一层) - 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 索引(官方文档链接)
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.jsimport./wechat.js(leaf)合法js/main.jsimport./wechat.js(leaf)合法
无规则冲突。
12. 风险与未决问题
| 风险 | 影响 | 应对 |
|---|---|---|
| 关系链需分享激活,新玩家看到空列表 | 首日体验差 | UI 强引导分享,空状态有"邀请"按钮 |
| KV 128 限制 | 124 个 key 接近上限 | 已通关关才写 KV,远期聚合 |
| 头像昵称为空 | 好友榜显示首字母方块 | 兜底方案 §6.5 |
| 旧基础库无 openDataContext | 部分老设备无好友榜 | API 探测降级,UI 不渲染该区 |
wx.reportEvent 在 we 分析后台显示延迟 |
数据看板延后 5-10 分钟 | 仅影响运营观察,不影响游戏 |
| 分享回流建立关系链需要 B 也玩过游戏 | B 进来后立刻退出不算 | 设计上不要求"必须通关",只要进游戏就建立 |
未决问题(实施前需用户确认):
- "好友中收益最高前 3"的"收益"定义:是关卡
best(最高分)还是score(本局得分)还是stars*level(综合)?我假设best(最高分)。需确认。 - 签到奖励用什么道具 / 多少金币?需要产品和数值决定。
- 是否做"接力续关":分享给好友帮忙续一关,对单机游戏是亮点但要做分享卡片。推荐第二阶段做。
- 群排行(群内前 N)是否要做?需
groupShareInfo扩展,复杂度高。