目前项目使用的存读档
修改于02/20150 浏览开发心得 包含 AI 合成内容
目前项目使用的存读档,项目刚发布还未经验证
clientScore 云存档开发指南
适用于 UrhoX/SCE 引擎 Lua 游戏的存读档系统设计。
可直接作为 AI 开发参考文档使用。
一、clientScore API 基础
1.1 两种数据类型
| 类型 | API | 存储位置 | 用途 |
|------|-----|---------|------|
| 整数 | SetInt(key, value)
/ Add(key, delta)
| iscores | 金币、钻石等可排行榜的数值 |
| 任意类型 | Set(key, value)
| values | 表、字符串等复杂结构(自动 JSON 序列化/反序列化) |
1.2 批量读写(推荐)
```lua
-- 批量写入
clientScore:BatchSet()
:SetInt("gold", 100)
:SetInt("diamonds", 50)
:Set("hero_data", { shards = {}, stars = {} })
:Save("存档原因", events)
-- 批量读取
clientScore:BatchGet()
:Key("gold")
:Key("diamonds")
:Key("hero_data")
:Fetch(events)
```
1.3 回调结构(所有 API 通用)
lua
local events = {
ok = function(...) -- 成功回调(Save 无参数,Fetch 返回 values, iscores)
end,
error = function(code, reason) -- 服务端返回了错误
end,
timeout = function() -- 客户端等不到任何响应(可选,不写则超时静默)
end,
}
三个回调互斥,一次请求只会触发其中一个。
二、error 与 timeout 的区别
```
客户端 ──请求──→ 服务端 ──查询──→ 数据库
timeout: 客户端 → 服务端 这段没收到任何回复(断网/服务端宕机)
error: 服务端收到了请求,也回复了,但回复的是"失败"
```
| | error 回调 | timeout 回调 |
|---|---|---|
| 有错误码 | 有(code + reason) | 无(无参数) |
| 触发原因 | 服务端明确返回失败 | 客户端等待超时,无响应 |
| 请求是否执行 | 明确失败 | 不确定(可能已执行但响应丢了) |
| 典型场景 | DB 异常、鉴权过期、数据超限 | 断网、服务器无响应 |
重点:timeout 存在"服务端已执行但客户端不知道"的可能。
所以存档用覆盖写(Set),重复写入是幂等的,重试不会产生副作用。
error code 103 vs timeout
两者都叫"超时"但发生在不同层级:
103 (QueryConnectionTimeout):请求到了服务端,服务端查数据库超时,服务端把 103 错误码返回给客户端 → 触发 error(103, ...)
timeout 回调:请求发出后客户端完全没收到回复 → 触发 timeout()
三、错误码分类与处理策略
3.1 错误码分类表
| 分类 | 错误码 | 含义 | 处理策略 |
|------|--------|------|---------|
| 可重试 | 3 | 数据库查询失败 (DatabaseQueryFailed) | 自动重试 + 退避 |
| | 20 | 数据库操作超出限制 (MySqlOperationLimitExceeded) | 同上 |
| | 99 | 事务中发生未知异常 (UnknownMySqlExceptionDuringTransaction) | 同上 |
| | 100 | 未知数据库异常 (UnknownMySqlException) | 同上 |
| | 101 | 结果解析失败 (ResultParseFailed) | 同上 |
| | 103 | 数据库连接超时 (QueryConnectionTimeout) | 同上 |
| | 1205 | 锁等待超时 (LockWaitTimeout) | 同上 |
| | 1213 | 检测到死锁 (DeadlockDetected) | 同上 |
| | 2013 | 连接丢失 (ConnectionLost) | 同上 |
| | 7534 | 并发等待数超限 (ConcurrentWaitCountExceeded) | 同上 |
| 数据问题 | 4 | 消息体过大 (MessageSizeTooLarge) | 不可重试,提示玩家联系客服 |
| | 22 | 容量超限 (CapExceeded) | 同上 |
| | 24 | 内存超限 (MemoryLimitExceeded) | 同上 |
| | 1406 | 数据大小超限 (DataSizeExceeded) | 同上 |
| | 1690 | 无符号整数溢出 (UnsignedIntegerOverflow) | 同上 |
| 鉴权问题 | 5 | 权限不足 (InsufficientPermission) | 不可重试,提示重启游戏 |
| | 13 | 未授权 (Unauthorized) | 同上 |
| 空结果 | -100000 | 查询结果为空 | 视为新玩家,使用初始数据 |
3.2 关于数据超限(4 / 1406)的补充
当单次 BatchSet()
的数据总量超过服务端限制时会触发此错误。
能否拆成多次请求? 技术上可以,但会丢失原子性。BatchSet
单次调用是原子的(全部成功或全部失败),拆成多次后可能出现"存了一半":
第1次 hero_data ✅ 成功(碎片已扣除)
第2次 progress ❌ 失败(通关记录没存上)
→ 数据不一致,且无法回滚
推荐处理优先级:
判断是否需要担心:普通游戏(几十个英雄、几十个关卡、设置/任务/成就)的存档数据通常只有几 KB,远不会触发此限制。只有存储大量用户生成内容(地图编辑器数据、聊天记录、详细回放等)时才需要关注。
3.3 分类处理逻辑
lua
local function ClassifyError(code)
if RETRYABLE_CODES[code] then return "retryable" end -- 自动重试
if DATA_ERROR_CODES[code] then return "data" end -- 提示联系客服
if AUTH_ERROR_CODES[code] then return "auth" end -- 提示重启游戏
return "unknown" -- 未知错误,保守重试1次
end
四、存档系统设计要点
4.1 脏标记 + 延迟存档
频繁的小操作(换阵容、切设置)不应每次都发网络请求:
数据变更 → MarkDirty() → 等待 1~2 秒 → 自动 Save()
MarkDirty()
:只打标记 + 重置计时器,不发请求
Update(dt)
:每帧检查计时器,到时间了才真正存档
短时间内多次 MarkDirty 只会触发一次存档
4.2 关键操作用强制存档
以下操作必须立即存档(ForceSave),不能等延迟:
| 操作 | 原因 |
|------|------|
| 关卡通关/失败结算 | 奖励已发放,丢档损失大 |
| 抽卡(扣钻石+发碎片) | 货币已扣除,丢档玩家感知强 |
| 看广告领奖励 | 广告已看完,丢档等于白看 |
| 重置游戏数据 | 不可逆操作 |
| 游戏退出(Stop) | 最后的存档机会 |
lua
-- ForceSave 内部也有防并发:正在存档时新请求只标记 dirty
function ForceSave(reason)
saveRetryCount = 0
Save(reason)
end
4.3 防并发
lua
function Save(reason)
if saving then -- 正在存档中
dirty = true -- 标记一下,等当前请求回来后再存
return
end
saving = true
dirty = false
-- ... 发请求
end
不需要额外加冷却。saving
标志天然保证同时只有一个请求在飞。
4.4 重试与退避
```
存档失败 → 分类错误码
├─ 可重试 → 自动重试(最多 3 次,间隔 3s/6s/12s)
├─ 数据问题 → 提示联系客服,不重试
├─ 鉴权问题 → 提示重启游戏,不重试
└─ 未知错误 → 保守重试 1 次
重试用尽 → 显示错误提示 + 重试按钮
→ 不自动重试(防无限循环)
→ 等玩家手动点"重试"时重置计数
```
实现技巧:用负值 dirtyTimer 实现延迟重试
lua
dirty = true
dirtyTimer = -delay -- 负值,需要先走到 0 再走到 SAVE_DELAY 才触发
五、读档系统设计要点
5.1 读档状态机
not_loaded → loading → loaded_ok (有存档,恢复成功)
→ loaded_empty (无存档,用初始数据)
→ load_failed (失败,阻断游戏)
5.2 写保护(防覆盖)
最危险的场景:老玩家网络不好 → 读档失败 → 使用初始数据(0 金币) → MarkDirty → 把初始数据存到云端 → 覆盖了真实存档。
解决方案:MarkDirty 检查读档状态,未完成读档时阻止写入。
lua
function MarkDirty()
if loadState ~= "loaded_ok" and loadState ~= "loaded_empty" then
print("读档未完成,阻止写入")
return
end
dirty = true
end
只有两种状态允许写入:
loaded_ok
:成功读到了存档并恢复
loaded_empty
:确认是新玩家,没有存档可覆盖
5.3 读档失败的 UI 处理
| 场景 | UI 表现 |
|------|--------|
| 存档失败 | 顶部横幅提示(不阻断游戏,玩家可继续玩) |
| 读档失败 | 全屏阻断弹窗(必须重试或重启,不能用初始数据进游戏) |
读档失败必须阻断的原因:如果让玩家用初始数据进入游戏,任何操作都可能触发存档,覆盖云端真实数据。
5.4 空结果 (-100000) 的处理
lua
if code == -100000 then
-- 不是错误,是真的没有存档
loadState = "loaded_empty" -- 视为新玩家
return
end
六、JSON 序列化陷阱
6.1 数字 key 变字符串
Set()
自动做 JSON 序列化/反序列化。JSON 规范中对象的 key 只能是字符串:
```lua
-- 存入
Set("data", { [21] = 5, [3] = true })
-- JSON 中间态
{"21": 5, "3": true}
-- 读出
{ ["21"] = 5, ["3"] = true } -- key 变成了字符串!
```
解决方案:读档恢复时,对使用数字 key 的表做转换:
```lua
local function RestoreNumericKeys(t)
if type(t) ~= "table" then return t end
local result = {}
for k, v in pairs(t) do
local nk = tonumber(k)
result[nk or k] = v
end
return result
end
-- 使用
pd.heroShards = RestoreNumericKeys(saved.heroShards)
```
6.2 哪些表需要转换
需要根据业务逻辑判断:
| 表 | key 类型 | 需要转换 |
|---|---|---|
| heroShards (英雄碎片) | 数字 (poolIndex) | 需要 |
| heroStars (英雄星级) | 数字 (poolIndex) | 需要 |
| unlockedHeroes | 数字 (poolIndex) | 需要 |
| stageProgress (关卡进度) | 数字 (stageId) | 需要 |
| achievements (成就) | 字符串 (achievementId) | 不需要 |
| settings (设置) | 字符串 (settingName) | 不需要 |
规律:用 ID 作 key 的表(英雄ID、关卡ID等)基本都是数字 key,需要转换。
6.3 数组不受影响
JSON 数组的索引会自动保持为数字:
```lua
-- 存入
Set("data", { 10, 20, 30 }) -- Lua 数组 [1]=10, [2]=20, [3]=30
-- JSON 中间态
[10, 20, 30]
-- 读出
{ 10, 20, 30 } -- 索引仍然是数字,不需要转换
```
七、数据版本兼容(防丢字段)
7.1 问题
游戏更新后可能新增、删除字段。如果读档时整表替换,新字段的默认值会丢失:
```lua
-- v1 存档
stats = { battlesPlayed = 10, mergeCount = 5 }
-- v2 代码新增了 totalDamageDealt
stats = { battlesPlayed = 0, mergeCount = 0, totalDamageDealt = 0 }
-- ❌ 整表替换:totalDamageDealt 丢失
pd.stats = saved.stats -- { battlesPlayed = 10, mergeCount = 5 }(没有 totalDamageDealt)
-- ✅ 逐字段合并:新字段保留默认值
MergeFields(pd.stats, saved.stats) -- { battlesPlayed = 10, mergeCount = 5, totalDamageDealt = 0 }
```
7.2 逐字段合并函数
lua
local function MergeFields(target, source)
if type(source) ~= "table" or type(target) ~= "table" then return end
for k, v in pairs(source) do
target[k] = v
end
end
7.3 不同数据结构的恢复策略
| 数据类型 | 恢复方式 | 示例 |
|---------|---------|------|
| 结构化对象(固定字段) | 逐字段合并 | stats, settings, dailyShop |
| 数字 key 字典 | RestoreNumericKeys 后直接替换 | heroShards, heroStars |
| 字符串 key 字典 | 直接替换 | achievements |
| 纯数组 | 直接替换 | activeRoster, universalShards |
| 简单值 | 直接赋值 | gold, diamonds, bestWave |
八、SetInt 安全
SetInt
存的是整数,超出范围会触发 1690 (UnsignedIntegerOverflow):
```lua
local INT32_MAX = 2147483647
local function ClampInt(v)
if type(v) ~= "number" then return 0 end
return math.max(0, math.min(math.floor(v), INT32_MAX))
end
-- 使用
:SetInt("gold", ClampInt(pd.gold))
```
九、完整存档流程总结
游戏启动
│
├─ 调用 Load()
│ ├─ 成功 + 有数据 → loadState = "loaded_ok", 恢复数据
│ ├─ 成功 + 空数据 → loadState = "loaded_empty", 用初始数据
│ ├─ 失败(可重试) → 自动重试 3 次(2s/4s/8s)
│ ├─ 失败(鉴权) → 全屏弹窗"登录过期"
│ └─ 重试用尽 → 全屏弹窗 + 重试按钮
│
├─ 游戏进行中
│ ├─ 小操作(换阵容等) → MarkDirty → 1.5s 后自动存档
│ ├─ 关键操作(通关/抽卡/广告奖励) → ForceSave 立即存档
│ └─ 写保护:loadState 不对时 MarkDirty 静默拒绝
│
└─ 游戏退出 Stop()
└─ ForceSave("退出前存档")


