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 有限状态机与行为树 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。