11.1 本地存档与版本迁移
到这一章,《方块冲刺》的核心循环、关卡、动画、音频都已经能跑。但玩家一旦关掉游戏,金币、最远距离、道具数量、音效开关就全没了——一个不能记住进度的跑酷游戏,没人会玩第二次。这一节解决「记住」这件事,并顺手把它的一个隐藏难题讲清楚:存档格式会变,而老玩家的存档不会自动变。
11.1.1 存档要存什么
先把「存档」拆成三类数据,它们的更新频率和丢失代价完全不同:
| 类别 | 字段示例 | 更新频率 | 丢失代价 |
|---|---|---|---|
| 进度 | coins、bestDistance、已解锁关卡 | 每局结束 | 高(玩家会投诉) |
| 资产 | 三种道具的数量(加速 / 无敌 / 磁铁) | 领取或消耗时 | 高 |
| 设置 | sound、haptics、语言 | 玩家手动改 | 中(可重建) |
三类数据放在同一份 JSON里,用同一个键保存。理由很简单:它们总是一起读写,分键保存只会带来「改了一个忘了另一个」的不一致。存档键统一定为 block-dash:save,前缀是工程名,避免和同域名下的其他页面撞键。
11.1.2 三端存储 API 与存档键
第二章定过工程设定,这里把它落成代码前的对照表。三端都能存字符串,但入口 API 不同:
| 平台 | 判断方式 | 存储 API |
|---|---|---|
| 微信小游戏 | sys.platform === sys.Platform.WECHAT_GAME | wx.setStorageSync / wx.getStorageSync |
| H5 | sys.platform === sys.Platform.MOBILE_BROWSER(桌面端为 DESKTOP_BROWSER) | localStorage |
| 原生 | sys.isNative 为 true | sys.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 云存档与冲突消解 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。