如何用TapTap 制造实现一套不丢档的云存档系统
精华修改于04/2599 浏览开发心得
TapTap 制造做的游戏以 WASM(WebAssembly)方式运行在 TapTap 平台上,本地存储依赖浏览器IndexedDB,清缓存或换设备就会丢失。所以云存档不是可选项,而是必须。


做 WASM 小游戏最怕的事:玩家换个设备,存档没了。
WASM 的本地存储(IndexedDB)既不可靠也不跨设备。所以我们必须把存档放到云端——但只用一条云端通道又不够稳,网络抖动就可能导致存档丢失。
这篇文章分享双通道云存档的完整实现:clientCloud(TapTap 平台)和 serverCloud(游戏服务器)独立运行、互为备份,加上本地缓存作为第三层保险。
核心指标:
正常流程:数据变动后 0 帧延迟写本地,2 秒内上传 serverCloud,15 秒内同步 clientCloud
异常恢复:任一通道故障,另一通道 + 本地缓存兜底,0 丢档


一、整体架构
┌─────────────┐
│ 玩家操作 │
└──────┬──────┘
│ MarkDirty()
▼
┌─────────────┐ 同步写入 ┌──────────────┐
│ PlayerData │──────────────→│ 本地 File │ ← 第一层: 即时持久化
│ (内存单例) │ │ + 校验和 │
└──────┬──────┘ └──────────────┘
│ 异步上传
▼
┌─────────────────────────────────────┐
│ CloudSync 调度器 │
├─────────────┬───────────────────────┤
│ serverCloud │ clientCloud │
│ 节流 2s │ 节流 15s │ ← 第二层: 双云备份
│ + 排行榜 │ + 排行榜 │
└─────────────┴───────────────────────┘
为什么两条通道?
| 通道 | 优点 | 缺点 |
|------|------|------|
| clientCloud (TapTap) | 平台托管,零运维 | 依赖 TapTap SDK,部分环境不可用 |
| serverCloud (游戏服务器) | 完全可控,延迟低 | 服务器宕机时不可用 |
| 本地 File | 零延迟,离线可用 | WASM 环境不持久,换设备丢失 |
三者任意一个存活,存档就不会丢。


二、模块划分(6 个文件)
```
network/
SaveEvents.lua ← 常量: 云端 Key、事件名、排行榜 Key
server_save.lua ← 服务端: serverCloud 读写 + 旧格式迁移
systems/
save.lua ← 本地持久化: File + 校验和
cloudsync.lua ← 客户端调度器: 双通道上传/下载
savesystem.lua ← 门面: 协调加载/保存/失焦自动保存
migration.lua ← 版本迁移: v1→v2→v3 增量升级
player_data/
_internal.lua ← 内存单例 + MarkDirty
persistence.lua ← 加载/保存/云同步协调
```
依赖关系(单向,无循环):
SaveSystem → PlayerData/Persistence → CloudSync → ClientNet (延迟require)
→ Save (本地)
→ Migration
CloudSync 对 ClientNet 使用 pcall(require, ...) 延迟加载,避免启动时循环依赖。


三、核心实现
3.1 常量统一管理(SaveEvents.lua)
客户端和服务端共用同一份 Key 定义,杜绝拼写不一致:
```lua
SaveEvents.CLOUDKEYS = {
CHARACTER = "characterv1", -- 角色存档
AUDIOSETTINGS = "audiosettingsv1", -- 音频设置(独立Key)
LEGACY = "gamesave", -- 旧版单Key(仅迁移读取)
}
SaveEvents.LBKEYS = {
LEVEL = "playerlevel", WAVE = "highestwave",
TOWER = "towerfloor", ARENA = "arenapoints",
NAME = "playername", RESONANCE = "resonance_time",
}
```
设计决策:音频设置独立 Key,因为它修改频率低、体积小,不需要跟着角色存档一起上传。
3.2 本地持久化 + 校验和防损坏(save.lua)
```lua
-- 写入流程: JSON编码 → 计算校验 → 备份旧文件 → 写入新文件
function Save.Write(data)
data.saveTime = os.time()
local json = cjson.encode(data)
-- 备份旧存档
if fileSystem:FileExists(SAVE_FILE) then
fileSystem:Copy(SAVE_FILE, SAVE_BACKUP)
end
-- 附加 FNV-1a 校验和
local checksum = ComputeChecksum(json)
local payload = json .. "\n--CHECKSUM:" .. checksum
local file = File(SAVE_FILE, FILE_WRITE)
file:WriteString(payload)
file:Close()
end
```
校验和算法:FNV-1a 变体 + XOR 混淆。不是加密级安全,但能检测文件意外损坏(写入中断、磁盘错误),不涉及服务端验证:
lua
local function ComputeChecksum(str)
local hash = 2166136261 -- FNV offset basis
for i = 1, #str do
hash = hash ~ string.byte(str, i)
hash = (hash * 16777619) & 0xFFFFFFFF
end
return string.format("%08x", (hash ~ 0x5A3C) & 0xFFFFFFFF)
end
读取时双重容错:主文件损坏 → 读备份文件;校验不过 → 打印警告但仍尝试使用(比完全丢档好)。
3.3 双通道上传调度(cloud_sync.lua 核心)
```lua
function CloudSync.Upload(data, callback)
-- 前置检查: 角色未创建(playerName空)时不上传
if not hasValidCharacter(data) then return end
local isForce = (callback ~= nil)
local now = os.time()
-- clientCloud 路径: 15s 节流
if isCloudAvailable() then
if isForce or (now - lastCloudSaveTime_) >= 15 then
clientCloud:BatchSet()
:Set(CK.CHARACTER, charData)
:Set(CK.AUDIO_SETTINGS, audioData)
:SetInt(LB.LEVEL, level) -- 顺带更新排行榜
:SetInt(LB.WAVE, wave)
-- ...更多排行榜字段
:Save("保存进度", { ok=..., error=..., timeout=... })
end
end
-- serverCloud 路径: 独立于 clientCloud, 2s 节流
CloudSync.UploadToServer(data, isForce)
end
```
关键设计:
1. 两条路径完全独立:clientCloud 超时不影响 serverCloud,反之亦然
2. 节流策略不同:serverCloud 2 秒(主通道,WASM 本地不持久),clientCloud 15 秒(平台限制)
3. 排行榜顺带写入:存档上传时 BatchSet 一并写入排行榜数据,省一次网络请求
4. 角色创建前不上传:防止空数据覆盖有效存档
3.4 serverCloud 上传与节流重试
```lua
function CloudSync.UploadToServer(data, force)
if not hasValidCharacter(data) then return end
local now = os.time()
-- 节流: 2s 内重复上传 → 暂存到 pendingUploadData_
if not force and (now - lastServerUploadTime_) < 2 then
pendingUploadData_ = data
return
end
-- 检查连接状态(延迟require避免循环依赖)
local ok, ClientNet = pcall(require, "network.client_main")
if not ok or not ClientNet.IsConnected() then
pendingUploadData_ = data -- 断线暂存, 等重连
return
end
ClientNet.UploadSaveToServer(data)
lastServerUploadTime_ = now
pendingUploadData_ = nil
end
-- 每帧调用: 节流窗口过后自动重试
function CloudSync.FlushPending()
if not pendingUploadData_ then return end
if (os.time() - lastServerUploadTime) >= 2 then
CloudSync.UploadToServer(pendingUploadData, true)
end
end
```
被节流的数据不会丢失:暂存到 pendingUploadData_,由 FlushPending()(每帧调用)在窗口过后自动重试。
3.5 服务端接收与写入(server_save.lua)
服务端也有独立的节流,防止恶意客户端高频刷写:
```lua
function ServerSave.HandleUpload(eventData)
local userId = info.userId
-- 服务端节流: 同一玩家 10s 内不重复写入
if (os.time() - (lastSaveTimes_[userId] or 0)) < 10 then
-- 返回 Throttled ACK, 客户端不需重试
connection:SendRemoteEvent(SAVE_ACK, true, { Success=true, Throttled=true })
return
end
-- 解析 JSON → 写入 serverCloud (角色 + 音频 + 排行榜)
ServerSave.WriteToCloud(userId, saveData, audioData, function(ok, msg)
lastSaveTimes_[userId] = os.time()
-- 返回 ACK
connection:SendRemoteEvent(SAVE_ACK, true, { Success=ok })
end)
end
```
3.6 启动时加载:三路竞速
游戏启动时的数据加载流程:
Start()
│
├─ 1. 同步读本地 File ──→ localSaveTime
│
├─ 2. 异步拉 clientCloud ──→ cloudSaveTime ← 设置 LoadPending 锁
│
└─ 3. 异步等 serverCloud 推送 ──→ serverSaveTime ← 设置 ServerLoadPending 锁
│
▼ 三路数据到齐(或超时)
比较 saveTime → 取最新的那份
核心比较逻辑(persistence.lua):
```lua
function Persistence.Load(onComplete)
-- 第一阶段: 同步加载本地
local localSaved = Save.Read()
local isNew = restoreFromSaveData(localSaved)
local localSaveTime = (localSaved and localSaved.saveTime) or 0
-- 第二阶段: 异步拉云端
CloudSync.SetLoadPending(true)
CloudSync.Download(function(cloudResult, isError)
CloudSync.SetLoadPending(false)
if cloudResult then
local useCloud = (cloudResult.saveTime > localSaveTime)
or (isNew and cloudHasData)
if useCloud then
-- 云端更新 → 覆盖本地
restoreFromSaveData(cloudResult.character)
Save.Write(CollectSaveData()) -- 回写本地
else
-- 本地更新 → 上传云端
CloudSync.Upload(CollectSaveData())
end
end
onComplete(isNew)
end)
end
```
超时保护:加载锁有 8-10 秒自动释放,防止云端永远不响应时卡死:
lua
function CloudSync.IsLoadPending()
if cloudLoadPending_ and (os.time() - cloudLoadStartTime_) > 10 then
cloudLoadPending_ = false -- 超时自动释放
end
-- serverLoadPending_ 同理, 8 秒超时
return cloudLoadPending_ or serverLoadPending_
end
3.7 服务端存档下发与冲突解决
玩家连接时,服务端主动推送存档:
lua
-- server_save.lua
function ServerSave.LoadAndSend(userId, connection)
serverCloud:BatchGet(userId)
:Key(CK.CHARACTER)
:Key(CK.AUDIO_SETTINGS)
:Key(CK.LEGACY) -- 兼容旧格式
:Fetch({
ok = function(scores)
-- 优先 character_v1, 回退 game_save
local saveData = scores[CK.CHARACTER] or scores[CK.LEGACY]
connection:SendRemoteEvent(SAVE_LOAD, true, {
SaveJson = cjson.encode(saveData),
SaveTime = saveData.saveTime,
Migrated = (scores[CK.CHARACTER] == nil), -- 标记是否旧格式
})
-- 旧格式自动迁移到新 Key
if not scores[CK.CHARACTER] and scores[CK.LEGACY] then
ServerSave._migrateToNewKeys(userId, saveData, audioData)
end
end,
})
end
客户端收到后,在 HandleServerSave
中做冲突解决:
```lua
-- 使用服务端的条件:
-- 1. 服务端 playerName 非空 (有效角色)
-- 2. 服务端时间戳更新 OR 本地为空(WASM丢失)
local useServer = serverHasValidChar
and ((serverSaveTime > localSaveTime) or localIsNew)
if useServer then
PlayerData.RestoreFromServerSave(serverSaveData)
elseif localSaveTime > serverSaveTime then
-- 本地更新 → 反向上传到服务端
CloudSync.UploadToServer(collectSaveData(), true)
end
```
WASM 场景特殊处理:本地 IndexedDB 被清空时 localIsNew=true,只要服务端有有效数据就直接恢复,不比较时间戳。


四、版本迁移系统
存档结构会随版本迭代变化。我们用类似数据库 migration 的方案:
```lua
-- migration.lua
local CURRENT_VERSION = 3
local migrations = {}
-- v2: 通用碎片 → 按品质分类碎片
migrations[2] = function(data)
if data.universalFragments and data.universalFragments > 0 then
data.qualityFragments = data.qualityFragments or { R=0, SR=0, SSR=0, UR=0 }
local half = math.floor(data.universalFragments / 2)
data.qualityFragments.R = data.qualityFragments.R + half
data.qualityFragments.SR = data.qualityFragments.SR + (data.universalFragments - half)
data.universalFragments = nil
end
end
-- v3: 音频设置从 player.settings 独立到单独 Key
migrations[3] = function(data)
if data.settings then
AudioMgr.MigrateFromPlayerData(data) -- 迁移到独立文件
data.settings.musicEnabled = nil -- 移除冗余字段
data.settings.sfxEnabled = nil
end
end
-- 顺序执行: fromVersion+1 到 CURRENTVERSION
function Migration.Run(data, fromVersion)
for v = (fromVersion or 1) + 1, CURRENTVERSION do
if migrations[v] then migrationsv end
end
return CURRENT_VERSION
end
```
加载时自动迁移:restoreFromSaveData
在恢复数据后立即调用 Migration.Run
:
lua
local function restoreFromSaveData(saved)
I.data = DeepMerge(NewData(), saved.player) -- 合并默认值(新增字段自动填充)
local toVer = Migration.Run(I.data, saved.migrationVersion or 1)
end
DeepMerge 的作用:新版本新增的字段在 NewData()
里有默认值,DeepMerge
把旧存档覆盖上去后,新字段自然保留默认值——不需要为每个新字段写迁移函数。


五、自动保存与失焦保护
5.1 脏标记 + 即时存档
```lua
-- _internal.lua
function M.MarkDirty()
if not M.dirty then M.dirtyElapsed = 0 end
M.dirty = true
end
-- persistence.lua (每帧 tick)
function Persistence.AutoSaveTick(dt)
CloudSync.FlushPending() -- 先处理被节流的上传
if CloudSync.IsLoadPending() then return end -- 加载中不存档
-- 周期性强制云端同步 (30s)
if (os.time() - lastForceCloudTime) >= 30 then
Persistence.Save(true)
return
end
-- 脏数据: AUTO_SAVE_DELAY=0, 下一帧立即存
if I.dirty then
I.dirtyElapsed = I.dirtyElapsed + dt
if I.dirtyElapsed >= 0 then -- 实时保存
Persistence.Save()
end
end
end
```
AUTO_SAVE_DELAY = 0 意味着数据变动后下一帧就写本地。这在 WASM 环境是必要的——用户随时可能关闭标签页。
5.2 页面失焦强制保存
```lua
-- savesystem.lua
SubscribeToEvent("InputFocus", "HandleInputFocusSaveSystem")
function HandleInputFocus_SaveSystem(eventType, eventData)
if not eventData["Focus"]:GetBool() then
-- 失焦(切标签页/最小化) → 强制保存
SaveSystem.Save(true) -- force=true, 绕过节流
end
end
```


六、踩过的坑
#1 WASM 的 os.clock() 不可靠
os.clock() 在 WASM 返回 CPU 时间而非墙钟时间,导致自动存档计时器永远不触发。解决:用Update 的 dt 累加替代 os.clock(),时间戳比较用 os.time()。
#2 空角色覆盖有效存档
玩家打开游戏但还没创角就触发了自动上传,空的 playerName="" 覆盖了服务端的有效存档。解决:上传前检查 hasValidCharacter(),playerName 为空直接跳过。
#3 clientCloud 延迟初始化
TapTap SDK 的 clientCloud 对象不是启动时立即可用,可能在几帧后才初始化完成。如果永久缓存"不可用"的结论,后续所有上传都会跳过。解决:只缓存 true,false 不缓存,每次重新检测。
#4 双锁加载防止提前进入游戏
联网模式下有两个异步加载源(clientCloud + serverCloud),如果只等一个就释放锁,另一个还没到时玩家可能已经进入创角页面。解决:两个独立加载锁,两个都释放后才允许正常存档。
#5 循环依赖
CloudSync 需要调用 ClientNet.UploadSaveToServer(),但 ClientNet 的初始化又依赖 CloudSync。解决:pcall(require, "network.client_main") 延迟加载,调用时才 require。
#6 节流丢数据
serverCloud 2 秒节流期间如果有新的存档请求,直接丢弃会导致最后几秒的改动丢失。解决:节流时暂存到 pendingUploadData_,FlushPending() 每帧检查,窗口过后自动重试。
#7 服务端也要节流
没有服务端节流时,一个频繁操作的客户端每 2 秒就往 serverCloud 写一次,50 个在线玩家 = 每秒 25 次写入。解决:服务端每用户 10 秒节流,返回 Throttled=true ACK,客户端不需重试。


七、完整数据流时序
正常启动
客户端 服务端
│ │
├─ Save.Read() 同步读本地 │
├─ clientCloud.BatchGet() ─────→│ (TapTap 平台)
│ │
│ ←── SAVE_LOAD 推送 ────────┤ serverCloud.BatchGet(userId)
│ │
├─ 比较三个 saveTime │
├─ 取最新的覆盖本地 │
└─ 释放加载锁, 进入游戏 │
正常游戏中
玩家操作 → MarkDirty()
│
├─ 下一帧: Save.Write() 写本地 (即时)
├─ 2s 后: UploadToServer() → 服务端写 serverCloud
└─ 15s 后: clientCloud.BatchSet() → 写 clientCloud + 排行榜
异常恢复
| 场景 | 恢复路径 |
|------|---------|
| WASM 本地存储被清 | serverCloud 推送 → 恢复 |
| TapTap SDK 不可用 | 本地 + serverCloud 双保险 |
| 游戏服务器宕机 | 本地 + clientCloud 双保险 |
| 存档 JSON 损坏 | 备份文件 _backup.json 恢复 |
| 校验和不匹配 | 警告日志 + 仍尝试使用(比丢档好) |


八、可扩展方向
1. 存档加密:当前只有校验和,可升级为 AES 加密存储
2. 多槽位存档:当前单角色,扩展 Key 命名为 character_v1_slot2
3. 存档差异同步:只上传变化的字段,减少带宽(当前是全量覆盖)
4. 存档回滚:serverCloud 保留最近 N 个版本,支持客服回档
5. 离线队列:断网时攒一批操作,重连后批量上传(当前只暂存最后一份)
我也不敢说完全不丢档,我有款游戏使用的正是这套存档系统目前为止还没丢过档。仅供参考,大佬们有任何补充或批量建议请评论区留言。


