《Cocos 游戏开发实战》11.1 本地存档与版本迁移

《方块冲刺》要在三端保存同一份进度,而三端的存储 API 各不相同:微信走 wx.setStorageSync、H5 走 localStorage、原生走 sys.localStorage。本节先定存档数据模型,再用一层 KV 适配器把差异收敛到一处,最后写一条可单测的版本迁移链,让老玩家的 v1 存档安全升到 v3。

11.1 本地存档与版本迁移

到这一章,《方块冲刺》的核心循环、关卡、动画、音频都已经能跑。但玩家一旦关掉游戏,金币、最远距离、道具数量、音效开关就全没了——一个不能记住进度的跑酷游戏,没人会玩第二次。这一节解决「记住」这件事,并顺手把它的一个隐藏难题讲清楚:存档格式会变,而老玩家的存档不会自动变。

11.1.1 存档要存什么

先把「存档」拆成三类数据,它们的更新频率和丢失代价完全不同:

类别字段示例更新频率丢失代价
进度coins、bestDistance、已解锁关卡每局结束高(玩家会投诉)
资产三种道具的数量(加速 / 无敌 / 磁铁)领取或消耗时高
设置sound、haptics、语言玩家手动改中(可重建)

三类数据放在同一份 JSON里,用同一个键保存。理由很简单:它们总是一起读写,分键保存只会带来「改了一个忘了另一个」的不一致。存档键统一定为 block-dash:save,前缀是工程名,避免和同域名下的其他页面撞键。

11.1.2 三端存储 API 与存档键

第二章定过工程设定,这里把它落成代码前的对照表。三端都能存字符串,但入口 API 不同:

平台判断方式存储 API
微信小游戏sys.platform === sys.Platform.WECHAT_GAMEwx.setStorageSync / wx.getStorageSync
H5sys.platform === sys.Platform.MOBILE_BROWSER(桌面端为 DESKTOP_BROWSER)localStorage
原生sys.isNative 为 truesys.localStorage

三端存的都是字符串,所以我们统一把对象 JSON.stringify 后再写、读出来 JSON.parse。wx.setStorageSync 是同步的、localStorage 也是同步的,sys.localStorage 在原生端由引擎桥接、同样同步——这一点很关键,意味着存档读写不需要 await,可以放在 onLoad 里直接做完。

11.1.3 版本号与迁移链(纯 TS,可单测)

存档最大的坑不是存,而是格式演进。第 1 版只有 coins,第 2 版加了 bestDistance,第 3 版又加了道具和设置。如果直接改结构,老玩家读到的 JSON 会缺字段,游戏在「读 items.magnet 却拿到 undefined」的地方崩掉。

解法是给存档加一个 version 字段,并写一条迁移链:读出来先看版本号,然后依次执行缺失的迁移步骤,直到追平当前版本。这段逻辑和引擎无关,可以直接单测:

// assets/scripts/core/save-schema.ts
export interface SaveDataV1 {
  version: 1;
  coins: number;
}

export interface SaveDataV2 {
  version: 2;
  coins: number;
  bestDistance: number;
}

export interface SaveDataV3 {
  version: 3;
  coins: number;
  bestDistance: number;
  items: Record<string, number>;
  settings: { sound: boolean; haptics: boolean };
}

export type SaveData = SaveDataV3;
export const CURRENT_VERSION = 3;

type RawSave = Record<string, unknown>;
type Migration = (data: RawSave) => RawSave;

// 索引 i 把版本 i+1 升到版本 i+2
const migrations: Migration[] = [
  (d) => ({ ...d, bestDistance: 0, version: 2 }),
  (d) => ({ ...d, items: {}, settings: { sound: true, haptics: true }, version: 3 }),
];

export function defaultSave(): SaveData {
  return {
    version: CURRENT_VERSION,
    coins: 0,
    bestDistance: 0,
    items: {},
    settings: { sound: true, haptics: true },
  };
}

export function migrate(raw: unknown): SaveData {
  let data: RawSave =
    raw && typeof raw === 'object' ? { ...(raw as RawSave) } : {};
  let version = typeof data.version === 'number' ? data.version : 1;
  for (let i = version - 1; i < migrations.length; i++) {
    data = migrations[i](data);
    version = typeof data.version === 'number' ? data.version : version;
  }
  return normalize(data);
}

function normalize(data: RawSave): SaveData {
  const base = defaultSave();
  const settings = (data.settings ?? {}) as Record<string, unknown>;
  return {
    version: CURRENT_VERSION,
    coins: clampInt(data.coins, base.coins, 0),
    bestDistance: clampInt(data.bestDistance, base.bestDistance, 0),
    items: (data.items as Record<string, number>) ?? {},
    settings: {
      sound: typeof settings.sound === 'boolean' ? settings.sound : base.settings.sound,
      haptics: typeof settings.haptics === 'boolean' ? settings.haptics : base.settings.haptics,
    },
  };
}

function clampInt(value: unknown, fallback: number, min: number): number {
  if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
  return Math.max(min, Math.floor(value));
}

migrate 只做「升级」,normalize 负责兜底:任何缺失或类型不对的字段都被替换成默认值。这样即便存档被人手动改坏,游戏也能用一份合理的数据启动,而不是白屏。测试时只要喂进 { version: 1, coins: 120 },断言输出 version === 3 且 bestDistance === 0,就覆盖了「老存档升级」这条最危险的路径。

11.1.4 一层 KV 适配器

三端的 API 名字不同,但语义一样:读、写、删。抽成 KVStore 接口后,上层逻辑只认接口:

// assets/scripts/platform/storage.ts
import { sys } from 'cc';

interface WeChatStorageApi {
  getStorageSync(key: string): string;
  setStorageSync(key: string, value: string): void;
  removeStorageSync(key: string): void;
}

function getWeChat(): WeChatStorageApi | null {
  const g = globalThis as { wx?: WeChatStorageApi };
  return g.wx ?? null;
}

export interface KVStore {
  read(key: string): string | null;
  write(key: string, value: string): void;
  remove(key: string): void;
}

function createWeChatStore(wx: WeChatStorageApi): KVStore {
  return {
    read: (key) => wx.getStorageSync(key) || null,
    write: (key, value) => wx.setStorageSync(key, value),
    remove: (key) => wx.removeStorageSync(key),
  };
}

function createH5Store(): KVStore {
  return {
    read: (key) => globalThis.localStorage.getItem(key),
    write: (key, value) => globalThis.localStorage.setItem(key, value),
    remove: (key) => globalThis.localStorage.removeItem(key),
  };
}

function createNativeStore(): KVStore {
  const ls = sys.localStorage as {
    getItem(k: string): string | null;
    setItem(k: string, v: string): void;
    removeItem(k: string): void;
  };
  return {
    read: (key) => ls.getItem(key),
    write: (key, value) => ls.setItem(key, value),
    remove: (key) => ls.removeItem(key),
  };
}

export function createStore(): KVStore {
  const platform = sys.platform;
  if (platform === sys.Platform.WECHAT_GAME) {
    const wx = getWeChat();
    if (wx) return createWeChatStore(wx);
  }
  if (sys.isNative) return createNativeStore();
  if (platform === sys.Platform.MOBILE_BROWSER || platform === sys.Platform.DESKTOP_BROWSER) {
    return createH5Store();
  }
  return createH5Store();
}

sys.platform 返回的是 sys.Platform 枚举值,用 === 和 sys.Platform.WECHAT_GAME 比较即可。原生端把 sys.localStorage(声明为 any)收窄成一个最小接口,既通过类型检查,又不用引入额外类型包。最后一个分支回退到 H5 实现,保证在编辑器预览和未知环境下也能读写。

11.1.5 SaveManager 组件

把存储与迁移串起来,就是一个挂在常驻节点上的组件。它只在 onLoad 读一次、在关键节点写回:

// assets/scripts/game/SaveManager.ts
import { _decorator, Component, log, warn } from 'cc';

// 下面两个接口与两个函数分别在 11.1.4 的存储适配器、11.1.3 的迁移模块里实现;
// 这里只依赖它们的签名,便于把组件单独拿出来做类型检查。
interface KVStore {
  read(key: string): string | null;
  write(key: string, value: string): void;
  remove(key: string): void;
}
interface SaveData {
  version: number;
  coins: number;
  bestDistance: number;
  items: Record<string, number>;
  settings: { sound: boolean; haptics: boolean };
}
declare function createStore(): KVStore;
declare function migrate(raw: unknown): SaveData;

const { ccclass } = _decorator;

const SAVE_KEY = 'block-dash:save';
const CURRENT_VERSION = 3;

@ccclass('SaveManager')
export class SaveManager extends Component {
  private store: KVStore | null = null;
  private data: SaveData | null = null;

  onLoad(): void {
    this.store = createStore();
    this.data = this.load();
  }

  get save(): SaveData {
    if (!this.data) this.data = this.load();
    return this.data;
  }

  private load(): SaveData {
    const raw = this.store?.read(SAVE_KEY) ?? null;
    if (!raw) return migrate(null);
    try {
      return migrate(JSON.parse(raw));
    } catch (e) {
      warn('[save] 存档解析失败,回退默认存档', e);
      return migrate(null);
    }
  }

  flush(): void {
    if (!this.store || !this.data) return;
    this.data.version = CURRENT_VERSION;
    this.store.write(SAVE_KEY, JSON.stringify(this.data));
    log('[save] 已写入', SAVE_KEY);
  }
}

flush() 刻意不做节流:一局游戏结束、领取道具、切换设置都是低频事件,每次全量写一遍 JSON 的开销可以忽略。真正要防的是每帧都写——那会把同步存储变成帧率杀手,所以任何 update 里都不该出现 flush。

11.1.6 脚本资源的 .meta 与编辑器视图

新建脚本后,编辑器会在同目录生成一个 .meta 文件,它记录资源的唯一 uuid,是场景、预制体引用脚本的凭据。

以下为 Cocos Creator 3.8 编辑器生成的脚本资源元数据。需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。

{
  "ver": "4.0.23",
  "importer": "typescript",
  "imported": true,
  "uuid": "b3f1c0a2-7d4e-4a9b-9c21-2f6e8d0a5c34",
  "files": [],
  "subMetas": {},
  "userData": {}
}

在编辑器里打开这个工程,资源管理器左侧 assets/scripts/game/ 下会多出一个 SaveManager 脚本图标,选中它,属性检查器顶部显示的正是上面这串 uuid。把 SaveManager 拖到 main 场景的常驻节点上,场景文件里会新增一条 _components 记录,其 __type__ 是脚本的压缩 uuid,属性面板则列出脚本的公开属性。这个 .meta 一旦提交进版本库就不能再改 uuid,否则所有引用它的场景都会丢组件——第 1.3 节的 .meta 入库约定正是为这件事服务。

11.1.7 存档键规划与常见坑

  • 键名带前缀:block-dash:save 而不是 save,避免同域多页面互相覆盖。
  • 只存字符串:三端 API 都只接受字符串,对象必须先 JSON.stringify。
  • 迁移链只加不改:新版本只往 migrations 末尾追加一步,绝不回头改已有步骤,否则不同版本玩家升上来的结果会不一致。
  • 先 normalize 再使用:永远不要相信存档里的字段类型,读出来立刻兜底。
  • 写入时机固定:onLoad 读、关键事件写,不要散落在各处。

下一节把这套本地存档接到服务端,处理「同一个账号在两台设备上玩」时必然出现的冲突——那时本地存档会从「唯一真相」变成「一个副本」。

阅读导航:上一节:目录 · 下一节:11.2 云存档与冲突消解 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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