《Cocos 游戏开发入门》12.3 本地存储与云存档冲突

玩家在手机和电脑各存了一份进度,两份对不上怎么办?本节为 mini2d 抽象统一的存储接口,用时间戳、版本与校验和检测本地/云端冲突,实现本地优先、云端优先、逐字段合并三种策略,并给出可解释的提示;末节对照 Cocos Creator 3.8 的 sys.platform 与平台存储差异。

12.3 本地存储与云存档冲突

上一节的存档格式能扛住版本升级和损坏,但只解决了「一份存档」。真实玩家的进度会出现在两个地方:本地设备,以及账号绑定的云端。他在手机上打了两关,又在电脑上打了一关,两台设备各有一份存档——哪一份是对的?如果直接「谁先同步谁覆盖」,玩家的进度就会凭空消失。这一节给《方块冲刺》加一层同步逻辑:先检测两份是否冲突,再选一种合并策略,最后给玩家一个看得懂的提示。末节对照 Cocos Creator 3.8。

12.3.1 两份存档从哪来

冲突的根源是多设备 + 离线游玩。玩家在设备 A 玩到第 8 关,云端存的是第 8 关;他在没联网的设备 B 上又玩到第 10 关。当 B 联网时,云端是第 8 关、本地是第 10 关,两个都「比对方新」——这就叫冲突。要判断冲突,光有两份存档还不够,还得记住上一次成功同步时的状态:

export interface SyncRecord {
  /** 上次同步完成时存档的校验和 */
  lastSyncedChecksum: string;
  /** 上次同步完成的时间戳 */
  lastSyncedAt: number;
}

有了这个「基线」,就能区分三种情况:本地和云端都和基线一致(无需同步)、只有一边变了(直接采用变的那边,无冲突)、两边都变了(真冲突,需要策略)。第三行才是本节要处理的。

12.3.2 存储抽象:本地与云端同接口

先把「存哪」和「存什么」解耦。本地和云端用同一套异步接口,业务代码不关心背后是 localStorage 还是 HTTP:

export interface SaveStore {
  read(): Promise<string | null>;
  write(text: string): Promise<void>;
}

export class LocalStore implements SaveStore {
  private key: string;

  constructor(key: string) {
    this.key = key;
  }

  async read(): Promise<string | null> {
    return globalThis.localStorage?.getItem(this.key) ?? null;
  }

  async write(text: string): Promise<void> {
    globalThis.localStorage?.setItem(this.key, text);
  }
}

export class CloudStore implements SaveStore {
  private endpoint: string;

  constructor(endpoint: string) {
    this.endpoint = endpoint;
  }

  async read(): Promise<string | null> {
    const res = await fetch(`${this.endpoint}/save`);
    if (res.status === 404) return null;
    return await res.text();
  }

  async write(text: string): Promise<void> {
    await fetch(`${this.endpoint}/save`, { method: 'PUT', body: text });
  }
}

SaveStore 只有两个方法,这让同步逻辑可以用内存假实现来单测:MemoryStore 里放一个 private value: string | null 就能模拟任意一端。注意 LocalStore 用了 globalThis.localStorage?——在 Node 环境下 localStorage 不存在,可选链保证单测不会崩。

12.3.3 冲突检测:时间戳 / 版本 / 哈希

检测函数拿本地、云端、基线三份数据,输出一个结论。三个信号各管一件事:

  • 哈希:两边内容完全相同(含元信息)时,直接判定无冲突,不必比较时间。
  • 时间戳 savedAt:判断哪一份更「新」。但设备时钟可能不准,所以它只是辅助信号。
  • 版本号 version:若两边版本不同,说明有设备在跑旧版本,需要先迁移到同一版本再比较。
export type ConflictKind = 'none' | 'local-newer' | 'cloud-newer' | 'diverged';

export interface ConflictResult {
  kind: ConflictKind;
  local: SaveFile | null;
  cloud: SaveFile | null;
}

export function detectConflict(
  localText: string | null,
  cloudText: string | null,
  base: SyncRecord | null,
): ConflictResult {
  const local = localText ? (JSON.parse(localText) as SaveFile) : null;
  const cloud = cloudText ? (JSON.parse(cloudText) as SaveFile) : null;

  if (!local && !cloud) return { kind: 'none', local, cloud };
  if (!local) return { kind: 'cloud-newer', local, cloud };
  if (!cloud) return { kind: 'local-newer', local, cloud };

  // 内容逐字节相同 -> 无冲突
  if (local.checksum === cloud.checksum && local.savedAt === cloud.savedAt) {
    return { kind: 'none', local, cloud };
  }

  const localChanged = !base || local.checksum !== base.lastSyncedChecksum;
  const cloudChanged = !base || cloud.checksum !== base.lastSyncedChecksum;

  if (localChanged && cloudChanged) return { kind: 'diverged', local, cloud };
  if (localChanged) return { kind: 'local-newer', local, cloud };
  if (cloudChanged) return { kind: 'cloud-newer', local, cloud };
  return { kind: 'none', local, cloud };
}

关键在最后几行:只有当两边相对基线都发生了变化,才算 diverged(真冲突)。如果只有本地变了,那云端就是基线那份,直接采用本地即可,根本不需要问玩家。这个「基线」思路把绝大多数同步都变成了无冲突的直通路径,只有少数情况才需要交互。

12.3.4 三种合并策略

真冲突时给玩家(或配置)三个选项,每个策略是一个纯函数 (local, cloud) => SaveData:

export type MergeStrategy = 'local-first' | 'cloud-first' | 'field-merge';

export function mergeSaves(
  strategy: MergeStrategy,
  local: SaveFile,
  cloud: SaveFile,
): SaveData {
  if (strategy === 'local-first') return local.data;
  if (strategy === 'cloud-first') return cloud.data;
  return fieldMerge(local.data, cloud.data);
}

local-first / cloud-first 语义直白:整份取一边。它们适合「玩家明确知道哪台设备是自己的主力」的场景。第三种 field-merge 是逐字段合并,把「取最大值的数值」和「取最新的设置」分开处理:

export function fieldMerge(a: SaveData, b: SaveData): SaveData {
  return {
    player: {
      name: a.player.name || b.player.name,
      // 货币取两边最大值:宁可多给,不可少给
      coins: Math.max(a.player.coins, b.player.coins),
      gems: Math.max(a.player.gems, b.player.gems),
    },
    progress: {
      level: Math.max(a.progress.level, b.progress.level),
      bestScore: Math.max(a.progress.bestScore, b.progress.bestScore),
    },
    // 设置项无法「取最大」,只能整体取一边,这里取本地
    settings: { ...b.settings, ...a.settings },
  };
}

field-merge 看起来最友好,但它有语义陷阱:并不是所有字段都能「取最大」。货币能取最大,但「已解锁的关卡列表」只能取并集,「当前装备」只能取一边。所以 fieldMerge 必须逐字段决定规则,绝不能无脑 Math.max——把「当前血量」也取最大,玩家就会凭空回血。这也是为什么它不是默认策略,而是一个需要按业务设计的选项。

12.3.5 可解释的提示:让玩家选

真冲突发生时,最糟的做法是静默覆盖。正确做法是把差异翻译成人话,让玩家选:

export function describeConflict(local: SaveFile, cloud: SaveFile): string[] {
  const lines: string[] = [];
  const l = local.data.progress;
  const c = cloud.data.progress;
  if (l.level !== c.level) {
    lines.push(`关卡进度:本机 ${l.level} 关 / 云端 ${c.level} 关`);
  }
  if (l.bestScore !== c.bestScore) {
    lines.push(`最高分:本机 ${l.bestScore} / 云端 ${c.bestScore}`);
  }
  const dt = Math.abs(local.savedAt - cloud.savedAt);
  lines.push(`两份存档相差约 ${Math.round(dt / 60000)} 分钟`);
  return lines;
}

然后弹一个三选一的界面:「使用本机存档」「使用云端存档」「合并(取进度较快的)」。默认高亮哪个也要有依据:一般建议高亮「进度更靠前」的那份,因为它更可能代表玩家的真实意图,但绝不自动应用——冲突是少数事件,问一句的成本远低于丢档的代价。选完之后把结果写回两端,并更新 SyncRecord 基线,下次就不再冲突。

把上面的零件串成一个完整的同步入口,逻辑就一目了然:

export async function sync(
  local: SaveStore,
  cloud: SaveStore,
  base: SyncRecord | null,
  choose: (r: ConflictResult) => Promise<MergeStrategy>,
): Promise<SaveData | null> {
  const result = detectConflict(await local.read(), await cloud.read(), base);

  let data: SaveData | null = null;
  if (result.kind === 'none') {
    data = result.local?.data ?? result.cloud?.data ?? null;
  } else if (result.kind === 'local-newer') {
    data = result.local?.data ?? null;
  } else if (result.kind === 'cloud-newer') {
    data = result.cloud?.data ?? null;
  } else if (result.local && result.cloud) {
    // 真冲突:交给玩家/配置决定
    const strategy = await choose(result);
    data = mergeSaves(strategy, result.local, result.cloud);
  }

  if (!data) return null;
  const text = serializeSave(data);
  await local.write(text);
  await cloud.write(text);
  // 写入成功后两端一致,下次同步即可视为无冲突
  return data;
}

这个函数的形状值得记住:检测 -> 分支 -> 合并 -> 回写 -> 更新基线。只有 diverged 分支需要 await choose(...),其余都是无交互的直通。把交互点收窄到一个分支,是这类同步逻辑能写清楚的关键。

同步还需要考虑时机:不要在每次存档时都打网络。常见做法是「关键节点同步」——通关、退出游戏、进入主菜单时各同步一次,其余时间只写本地。这样既保证进度不丢,又不至于让玩家在跑酷途中因为一次网络抖动而卡顿。若同步失败,本地存档仍然完好,下次重试即可。

12.3.6 Cocos 对照:sys.platform 与平台存储差异

Cocos Creator 3.8 里,判断「当前在哪个平台」用 sys.platform,类型是 sys.Platform 枚举。cc.d.ts 中它的取值包括 Platform.WECHAT_GAME、Platform.BYTEDANCE_MINI_GAME、Platform.IOS、Platform.ANDROID、Platform.DESKTOP_BROWSER 等,用来区分各端的存储与网络能力:

import { sys } from 'cc';

export function isMiniGame(): boolean {
  return (
    sys.platform === sys.Platform.WECHAT_GAME ||
    sys.platform === sys.Platform.BYTEDANCE_MINI_GAME
  );
}

export function storageKey(): string {
  // 小游戏与浏览器共用同一份逻辑,只是存储实现不同
  return isMiniGame() ? 'blockdash.save.mini' : 'blockdash.save.web';
}

sys.localStorage 在 Web 端就是 window.localStorage,在微信小游戏端会被转发到 wx.setStorageSync 一类的接口,在原生端转发到文件系统。这意味着我们的 LocalStore 只要把 globalThis.localStorage 换成 sys.localStorage,就能在所有 Cocos 目标平台上工作:

import { sys } from 'cc';

export function readCocosSave(key: string): string | null {
  return sys.localStorage.getItem(key) as string | null;
}

export function writeCocosSave(key: string, text: string): void {
  sys.localStorage.setItem(key, text);
}

云端那一路在 Cocos 里没有内置实现,需要自己用 fetch(Web/小游戏)或平台的网络 API(原生)去接。对照下来,mini2d 的 SaveStore 抽象正好对应 Cocos 的「sys.localStorage + 自建网络层」;而冲突检测、合并策略这些纯逻辑与平台无关,可以在两套实现里逐字复用。至此,状态与存档这一章讲完:状态机管住「现在处于哪个阶段」,存档管住「玩到哪了」,下一章我们进入 AI 与关卡生成。

阅读导航:上一节:12.2 序列化格式、版本与迁移 · 下一节:13.1 有限状态机与行为树 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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