如何用TapTap 制造实现一套不丢档的云存档系统

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

一、整体架构

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

二、模块划分(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, ...) 延迟加载,避免启动时循环依赖。
horizontal linehorizontal line

三、核心实现

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,只要服务端有有效数据就直接恢复,不比较时间戳。
horizontal linehorizontal line

四、版本迁移系统

存档结构会随版本迭代变化。我们用类似数据库 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
把旧存档覆盖上去后,新字段自然保留默认值——不需要为每个新字段写迁移函数。
horizontal linehorizontal line

五、自动保存与失焦保护

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
```
horizontal linehorizontal line

六、踩过的坑

#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,客户端不需重试。
horizontal linehorizontal line

七、完整数据流时序

正常启动

客户端                          服务端
  │                               │
  ├─ 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 恢复 |
| 校验和不匹配 | 警告日志 + 仍尝试使用(比丢档好) |
horizontal linehorizontal line

八、可扩展方向

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