《Cocos 游戏开发实战》12.1 登录与分享

从这一章起游戏要真正调用平台 SDK。本节把登录拆成游客、微信小游戏、自有账号三种形态,给出以 wx.login 换 code、服务端换 openid 的完整时序,再把分享分成主动与被动两条路径,并强调微信不回调分享结果、所以「分享发奖」必须走服务端校验。

12.1 登录与分享

从这一章开始,《方块冲刺》要碰真正的平台能力:登录、分享、广告、内购。这些东西有个共同特点——它们只在特定平台的运行时里存在,本地浏览器跑不起来。所以本章的写法是固定的:给出接口约定与调用时序,平台 SDK 的具体调用写成最小接口,不编造私有 API。这样无论微信的接口细节怎么变,客户端代码的骨架都不用动。

先说登录。它是云存档(11.2)的前置条件:没有 uid,就没有「同一个账号」可言。

12.1.1 三种登录形态

形态触发玩家感知拿到什么
游客登录自动、静默无弹窗,直接进游戏一个本地生成的临时 uid
微信小游戏登录自动、静默无弹窗(授权另说)code → 服务端换 openid
自有账号玩家点「登录」输账号或第三方授权自建体系的 uid + token

设计原则:默认永远能进游戏。游客登录是兜底——哪怕所有平台授权都失败,玩家也能先玩起来,之后再引导绑定。把登录做成「不登录就进不去」,在跑酷这种轻度品类里是自杀式设计。

12.1.2 登录时序(以微信小游戏为例)

微信登录的本质是「用一次平台调用换一个短期凭证,再拿它去服务端换长期身份」。时序如下:

  1. 游戏启动,调 wx.login,成功回调里拿到一次性 code(几分钟内有效)。
  2. 客户端把 code POST 给服务端:/auth/wechat,body 为 { code }。
  3. 服务端拿 code 去微信服务器换 openid 与 session_key,生成自己的 token,返回 { uid, token }。
  4. 客户端保存 token(内存 + 本地存档),后续请求放进 Authorization 头。
  5. 用 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旧 token200 + { 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 参数落到服务端,服务端记录一次有效分享,再给分享者发奖。这是唯一防作弊的做法。

这条链路要落在四个点上,缺一不可:

  1. 分享者带参:shareAppMessage 的 query 里放 inviter=<uid>,把分享者身份编进链接。
  2. 被分享者带参进入:小游戏启动时从 wx.getLaunchOptionsSync() 读 query,拿到 inviter 后 POST /share/accept。
  3. 服务端去重:同一条 inviter 在同一设备/同一 openid 上只记一次,防止刷奖。
  4. 服务端发货:确认有效分享后,把奖励写进玩家的云端存档(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 广告与激励视频 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画