12.1 登录与分享
从这一章开始,《方块冲刺》要碰真正的平台能力:登录、分享、广告、内购。这些东西有个共同特点——它们只在特定平台的运行时里存在,本地浏览器跑不起来。所以本章的写法是固定的:给出接口约定与调用时序,平台 SDK 的具体调用写成最小接口,不编造私有 API。这样无论微信的接口细节怎么变,客户端代码的骨架都不用动。
先说登录。它是云存档(11.2)的前置条件:没有 uid,就没有「同一个账号」可言。
12.1.1 三种登录形态
| 形态 | 触发 | 玩家感知 | 拿到什么 |
|---|---|---|---|
| 游客登录 | 自动、静默 | 无弹窗,直接进游戏 | 一个本地生成的临时 uid |
| 微信小游戏登录 | 自动、静默 | 无弹窗(授权另说) | code → 服务端换 openid |
| 自有账号 | 玩家点「登录」 | 输账号或第三方授权 | 自建体系的 uid + token |
设计原则:默认永远能进游戏。游客登录是兜底——哪怕所有平台授权都失败,玩家也能先玩起来,之后再引导绑定。把登录做成「不登录就进不去」,在跑酷这种轻度品类里是自杀式设计。
12.1.2 登录时序(以微信小游戏为例)
微信登录的本质是「用一次平台调用换一个短期凭证,再拿它去服务端换长期身份」。时序如下:
- 游戏启动,调
wx.login,成功回调里拿到一次性code(几分钟内有效)。 - 客户端把
codePOST给服务端:/auth/wechat,body 为{ code }。 - 服务端拿
code去微信服务器换openid与session_key,生成自己的token,返回{ uid, token }。 - 客户端保存
token(内存 + 本地存档),后续请求放进Authorization头。 - 用
uid触发 11.2 的云存档同步。
关键约定:session_key 永远不下发给客户端,它只存在于服务端,用来解密敏感数据。客户端只需要 uid 和 token。把 session_key 发到客户端是常见的安全错误。
客户端要打交道的认证接口一共三个,形状固定:
| 方法 | 路径 | 请求 | 响应 |
|---|---|---|---|
POST | /auth/wechat | { code } | 200 + { uid, token } |
POST | /auth/guest | 无(或设备指纹) | 200 + { uid, token } |
POST | /auth/refresh | 旧 token | 200 + { uid, token };失效返回 401 |
三个接口都返回同样的 LoginResult,所以客户端的「登录成功」处理只有一份。平台差异被彻底压在 /auth/wechat 这一个入口后面。
12.1.3 登录态与存档的衔接
登录成功后要做两件事:把 uid/token 落进存档(这样下次启动能免登),然后立刻触发一次云同步。顺序不能反——先同步、后落盘,会让「换设备后第一次进游戏」丢进度。
token 有有效期,过期时服务端返回 401。客户端遇到 401 的正确反应是重新走一次静默登录,而不是弹错误框:微信的 wx.login 随时可调,重新换一个 code 即可无缝续期。
把「落盘 + 续期」写成一个薄封装,所有业务请求都从它发出去:
// assets/scripts/platform/session.ts
export interface LoginResult {
uid: string;
token: string;
}
// 11.1 的 KVStore:读、写、删三个方法
interface KVStore {
read(key: string): string | null;
write(key: string, value: string): void;
remove(key: string): void;
}
const SESSION_KEY = 'block-dash:session';
// 由平台层注入:微信走 wx.login,其余走游客登录
declare function login(): Promise<LoginResult>;
let cache: LoginResult | null = null;
export function loadSession(store: KVStore): LoginResult | null {
if (cache) return cache;
const raw = store.read(SESSION_KEY);
if (!raw) return null;
try {
cache = JSON.parse(raw) as LoginResult;
return cache;
} catch {
store.remove(SESSION_KEY);
return null;
}
}
export function saveSession(store: KVStore, session: LoginResult): void {
cache = session;
store.write(SESSION_KEY, JSON.stringify(session));
}
export async function ensureSession(store: KVStore): Promise<LoginResult> {
const existing = loadSession(store);
if (existing) return existing;
const fresh = await login();
saveSession(store, fresh);
return fresh;
}
export async function request(
store: KVStore,
input: string,
init?: RequestInit,
): Promise<Response> {
let session = await ensureSession(store);
const withAuth = (token: string): RequestInit => ({
...init,
headers: { ...(init?.headers ?? {}), Authorization: `Bearer ${token}` },
});
let res = await globalThis.fetch(input, withAuth(session.token));
if (res.status === 401) {
// token 过期:重新静默登录一次再重试,只重试一次
const fresh = await login();
saveSession(store, fresh);
session = fresh;
res = await globalThis.fetch(input, withAuth(session.token));
}
return res;
}
request() 把「带 token、遇 401 自动续期、只重试一次」这套逻辑收在一处,业务代码只管调它。注意这个模块完全不 import cc——RequestInit 与 Response 都是 DOM 类型,平台判断被推到 login() 的实现里。所以这段逻辑不依赖引擎,可以直接单测:注入一个假的 KVStore 和一个假的 login(),就能覆盖「无 session 时自动登录」「401 时续期重试一次」两条路径。
12.1.4 LoginService(L2)
登录服务对外只暴露一个 login(),内部按平台分派:
// assets/scripts/platform/login.ts
import { sys } from 'cc';
export interface LoginResult {
uid: string;
token: string;
}
interface WeChatLoginApi {
login(opts: {
success(res: { code: string }): void;
fail(err: unknown): void;
}): void;
}
function getWeChat(): WeChatLoginApi | null {
const g = globalThis as { wx?: WeChatLoginApi };
return g.wx ?? null;
}
// 服务端约定:POST /auth/wechat { code } -> LoginResult
declare function exchangeCode(code: string): Promise<LoginResult>;
// 服务端约定:POST /auth/guest -> LoginResult
declare function guestLogin(): Promise<LoginResult>;
export async function login(): Promise<LoginResult> {
if (sys.platform === sys.Platform.WECHAT_GAME) {
const wx = getWeChat();
if (wx) {
const code = await new Promise<string>((resolve, reject) => {
wx.login({
success: (res) => resolve(res.code),
fail: (err) => reject(err),
});
});
return exchangeCode(code);
}
}
// H5 / 原生:先用游客身份进游戏,账号体系后续再绑
return guestLogin();
}
这里 wx.login 用最小接口描述、exchangeCode 与 guestLogin 用 declare 声明——整个模块不依赖任何平台类型包,却能被 tsc 完整检查。真正接入时,只需把 declare 换成真实实现,组件逻辑一行不改。
12.1.5 分享:主动与被动
分享有两条完全不同的路径,别混为一谈:
| 路径 | 触发方 | 微信侧 API(约定) | 可控性 |
|---|---|---|---|
| 主动分享 | 游戏内按钮 | wx.shareAppMessage | 高(文案、图片、参数都能指定) |
| 被动分享 | 玩家点右上角菜单 | wx.showShareMenu + wx.onShareAppMessage | 低(只能预设内容) |
被动分享要在启动时就注册:调一次 showShareMenu 打开菜单里的「转发」,再注册 onShareAppMessage 提供转发内容。注册晚了,玩家点菜单会没有反应。
一个必须记住的事实:微信不会回调分享结果。你无法知道玩家到底转发成功了还是点了取消。因此「分享后发奖励」绝不能靠客户端回调,只能靠分享链接里的参数——被分享者打开后,带 query 参数落到服务端,服务端记录一次有效分享,再给分享者发奖。这是唯一防作弊的做法。
这条链路要落在四个点上,缺一不可:
- 分享者带参:
shareAppMessage的query里放inviter=<uid>,把分享者身份编进链接。 - 被分享者带参进入:小游戏启动时从
wx.getLaunchOptionsSync()读query,拿到inviter后POST /share/accept。 - 服务端去重:同一条
inviter在同一设备/同一openid上只记一次,防止刷奖。 - 服务端发货:确认有效分享后,把奖励写进玩家的云端存档(11.2),客户端下次同步自然拿到。
注意奖励不能由客户端直接加。玩家可以伪造 query 参数反复触发,所以判定与发货全在服务端,客户端只负责把参数原样上传。
12.1.6 ShareService(L2)
把两条路径和平台回退写在一处:
// assets/scripts/platform/share.ts
import { sys } from 'cc';
interface WeChatShareApi {
shareAppMessage(opts: { title: string; imageUrl?: string; query?: string }): void;
showShareMenu(opts: { withShareTicket?: boolean }): void;
onShareAppMessage(cb: () => { title: string; query?: string }): void;
}
function getWeChat(): WeChatShareApi | null {
const g = globalThis as { wx?: WeChatShareApi };
return g.wx ?? null;
}
export function share(title: string, imageUrl?: string): void {
if (sys.platform === sys.Platform.WECHAT_GAME) {
getWeChat()?.shareAppMessage({ title, imageUrl, query: 'from=button' });
return;
}
// H5:优先用 Web Share API,不支持则退回复制链接
const nav = globalThis.navigator as Navigator & {
share?: (data: { title: string }) => Promise<void>;
};
if (typeof nav.share === 'function') {
void nav.share({ title }).catch(() => undefined);
return;
}
void globalThis.navigator.clipboard?.writeText(globalThis.location.href);
}
export function enablePassiveShare(): void {
if (sys.platform !== sys.Platform.WECHAT_GAME) return;
const wx = getWeChat();
if (!wx) return;
wx.showShareMenu({ withShareTicket: true });
wx.onShareAppMessage(() => ({ title: '来玩《方块冲刺》', query: 'from=menu' }));
}
enablePassiveShare 应在启动场景的常驻节点 onLoad 里调一次。H5 侧没有「右上角菜单」这回事,所以直接 return——平台差异被这两个函数吃掉了,业务层调 share() 时不用判断自己在哪里。
12.1.7 登录场景与节点结构
登录界面是游戏的第一个场景 assets/scenes/login.scene。以下是该场景文件的结构摘录。需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。
{
"__type__": "cc.SceneAsset",
"_name": "login",
"scene": { "__id__": 1 }
}
在编辑器里双击打开 login.scene,层级管理器会显示这样的结构:根节点 login 下有 Canvas(挂 Canvas + UITransform + Widget),Canvas 下有 Logo(挂 Sprite)、BtnGuest(挂 Button + Sprite,子节点 Label 文案为「游客进入」)、BtnWeChat(挂 Button,_active 在 H5 端为 false)、Tip(挂 Label)。选中 Canvas,属性检查器里 Widget 的四边对齐都勾选,UITransform 的 contentSize 为设计分辨率 960 × 540。这个场景要作为构建的「起始场景」配在构建面板里,否则游戏启动会落到默认场景。
12.1.8 收口
本章新增两个平台模块:assets/scripts/platform/login.ts、assets/scripts/platform/share.ts,以及启动场景 assets/scenes/login.scene。可配置项:各平台的 AppID(微信小游戏 AppID 在构建面板填,不进代码)、token 的本地存储键。下一节处理变现的第一条腿——广告与激励视频。
阅读导航:上一节:目录 · 下一节:12.2 广告与激励视频 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。