单 KEY 条件化存档:isInTower 分支序列化与塔缓存生命周期
04/2636 浏览开发心得
适用场景:同一个存档 KEY,根据玩家当前所处地图动态决定存档内容——在塔内多存一块临时数据,离塔时自动清除,实现"按需序列化"。


一、问题:一个 KEY 要存两种形态的数据
魔塔类游戏有两个截然不同的游戏阶段:阶段数据特征例子塔外(村庄/大厅)只有永久属性蘑菇币、体力、永久攻防、装备背包塔内(爬塔中)永久属性 + 临时属性 + 地图缓存临时攻防加成、钥匙、金币、当前层数、10 层地图网格、实体位置如果不分场景,一律把所有字段全存,会出现两个问题:
- 数据膨胀:塔内的 10 层地图缓存(grid + content)是存档体积的主力,塔外根本不需要这些数据
- 状态残留:离塔后如果不清除 towerPlayer 和 towerState,下次加载存档会误判为"在塔内",错误恢复旧的塔状态
二、核心设计:isInTower 分支序列化2.1 存档结构总览
saveData = {
save_version : number, -- 存档版本号(迁移用)
isInTower : boolean, -- ← 分支判定标志
-- ====== 永久区(始终存在)======
mogu : number, -- 蘑菇币
negativeEmotion : number, -- 负面情绪值
purpleKeys : number, -- 紫钥匙
stamina : number, -- 体力
lastStaminaTime : number, -- 上次体力恢复时间戳
skin : string, -- 当前皮肤
maxHp, maxMp : number, -- 永久 HP/MP 上限
patk, matk : number, -- 永久物攻/魔攻
pdef, mdef : number, -- 永久物防/魔防
spd : number, -- 永久速度
equipNextId : number, -- 装备自增 ID
equipmentData : table, -- 装备背包序列化
foodData : table, -- 食物背包序列化
-- ====== 临时区(仅 isInTower=true 时存在)======
towerPlayer : table|nil, -- 塔内临时属性快照
towerState : table|nil, -- 塔缓存快照(楼层网格+实体)
}
关键点:towerPlayer 和 towerState 不是"有时有值有时没值"——它们由代码显式控制:塔内赋值,塔外赋 nil。2.2 序列化代码(buildSaveData 核心逻辑)lua
复制local function buildSaveData(player)
-- 永久属性(始终保存)
local saveData = {
save_version = SAVE_VERSION,
mogu = player.mogu,
stamina = player.stamina,
-- ... 其余永久字段 ...
equipmentData = EquipmentInventory.Serialize(),
foodData = FoodInventory.Serialize(),
}
-- ========== 分支判定 ==========
local isInTower = TowerGenerator.IsInTower()
saveData.isInTower = isInTower
if isInTower then
-- 塔内:附加临时属性 + 塔缓存
saveData.towerPlayer = {
gold = player.gold,
level = player.level,
exp = player.exp,
hp = player.hp, mp = player.mp,
tempPatk = player.tempPatk,
tempMatk = player.tempMatk,
-- ... 其余临时加成 ...
keys = { yellow = player.keys.yellow,
blue = player.keys.blue,
red = player.keys.red },
}
saveData.towerState = TowerGenerator.SerializeState()
else
-- 塔外:显式置 nil,不存塔数据
saveData.towerPlayer = nil
saveData.towerState = nil
end
return saveData
end
逻辑极简:一个 if-else,决定存档是"胖"还是"瘦"。
三、towerPlayer:塔内临时属性快照3.1 为什么要单独存?塔内属性的生命周期和永久属性不同:属性生命周期离塔时行为mogu(蘑菇币)永久保留maxHp(永久血量上限)永久保留gold(金币)单次爬塔清零tempPatk(临时物攻加成)单次爬塔清零keys.yellow(黄钥匙)单次爬塔清零hp(当前血量)单次爬塔回满至 maxHp如果把 gold、tempPatk、keys 和永久属性混在一起存,离塔后这些值残留在存档中,下次加载时无法区分"该恢复"还是"该忽略"。3.2 分离存储的好处塔内存档:
saveData.gold = 1500 ← 混存方案(歧义:是永久金币还是塔内金币?)
saveData.towerPlayer.gold = 1500 ← 分离方案(明确:塔内临时金币)
塔外存档:
saveData.gold = 1500 ← 混存方案(这个值有意义吗?)
saveData.towerPlayer = nil ← 分离方案(没有这个字段,不存在歧义)
分离存储 = 数据自描述。读档代码不需要猜测,看 isInTower 和 towerPlayer 是否存在就知道该走哪条恢复路径。
四、towerState:塔缓存序列化
4.1 缓存了什么?TowerGenerator.SerializeState() 序列化的是当前爬塔的完整快照:lua
复制return {
towerId = towerId_, -- 当前塔 ID
floor = floor_, -- 当前层数
infiniteMode = infiniteMode_, -- 是否无尽模式
inShopFloor = inShopFloor_, -- 是否在云中小店夹层
runCounter = runCounter_, -- 运行计数器
lastTemplateId = lastTemplateId_, -- 上一个模板 ID(防重复)
stairUpPos = stairUpPos_, -- 上楼梯位置
stairDnPos = stairDnPos_, -- 下楼梯位置(玩家出生点)
floors = floors, -- 楼层缓存(核心大数据)
}
其中 floors 是体积最大的部分——每层包含:字段内容说明grid13×13 二维数组地图瓦片(整数 ID)content稀疏列表 [{y, x, e}]实体数据(怪物、宝箱、NPC 等)stairUpPos{x, y}上楼梯坐标stairDnPos{x, y}下楼梯坐标
4.2 分段缓存:不是所有层都存游戏使用 10 层分段缓存(SEGMENT_SIZE = 10)策略:楼层: 1 2 3 4 5 6 7 8 9 10 | 11 12 13 14 ...
区段: [-------- 区段 1 --------] | [---- 区段 2 ----]
- 玩家在区段 1 时,只有区段 1 的楼层在内存中
- 跨区段(从 10F 上到 11F)时,自动清除区段 1 的缓存
复制local function clearSegment(segStart)
for f = segStart, segStart + SEGMENT_SIZE - 1 do
floorCache_[f] = nil
end
end
存档时只序列化当前在内存中的楼层缓存——通常是 10 层左右,而不是从 1F 到当前层的所有数据。这是存档体积可控的关键。
4.3 content 的序列化技巧楼层实体用稀疏二维表存储(content[y][x] = entity),大部分格子是空的。序列化时转为紧凑列表:lua
复制-- 序列化:稀疏表 → 列表
local contentList = {}
for y, row in pairs(cached.content) do
for x, entity in pairs(row) do
contentList[#contentList + 1] = { y = y, x = x, e = entity }
end
end
-- 反序列化:列表 → 稀疏表
local contentMap = {}
for _, entry in ipairs(floorData.content) do
if not contentMap[entry.y] then contentMap[entry.y] = {} end
contentMap[entry.y][entry.x] = entry.e
end
只存有实体的格子,空格子不占空间。一层 13×13 = 169 格,通常只有 20-40 个有实体,压缩比约 4:1。
五、反序列化:读档时的分支恢复5.1 恢复流程lua
复制function SaveSystem.HandleLoadResponse(jsonStr)
local data = cjson.decode(jsonStr)
-- ① 永久属性恢复(无条件执行)
player.mogu = data.mogu
player.stamina = data.stamina
-- ... 其余永久字段 ...
-- ② 塔状态分支恢复
if data.isInTower and data.towerState then
-- 恢复临时属性
player.gold = data.towerPlayer.gold
player.tempPatk = data.towerPlayer.tempPatk
player.keys = data.towerPlayer.keys
-- ...
-- 恢复塔缓存 → 返回当前层地图
towerGrid, spawnX, spawnY = TowerGenerator.RestoreState(data.towerState)
end
-- ③ 回调通知上层
onLoaded_(towerGrid, spawnX, spawnY)
end
5.2 容错:恢复失败的降级lua
复制towerGrid, spawnX, spawnY = TowerGenerator.RestoreState(data.towerState)
if towerGrid then
-- 恢复成功:切到塔地图
else
-- 恢复失败:重置临时属性,当作不在塔内
PlayerController.ResetTempAttributes()
end
不怕数据损坏——最坏情况下丢失本次塔内进度(临时属性),永久属性(蘑菇币、装备等)不受影响。这就是分层存储的防御价值。
5.3 上层回调:地图切换onLoaded_ 回调在 main.lua 中处理地图切换:lua
复制onLoaded = function(towerGrid, spawnX, spawnY)
if towerGrid and spawnX and spawnY then
-- 有塔数据 → 切到对应塔地图
local towerMapIdx = 2 + TowerGenerator.GetCurrentTowerId(
)
MapManager.SetMapData(towerMapIdx, towerGrid)
MapManager.SwitchMapSilent(towerMapIdx)
PlayerController.SetPosition(
MapManager.TileCenterX(spawnX),
MapManager.TileCenterY(spawnY)
)
end
-- 无塔数据 → 默认停留在蘑菇村,什么都不做
end
注意 SwitchMapSilent 而非 SwitchMap——不触发地图切换回调,避免再次调用 EnterTower() 覆盖刚恢复的缓存。
六、离塔:缓存清除与立即存档6.1 离塔时发生什么?lua
复制-- main.lua 离塔回调
TowerGenerator.LeaveTower() -- ① 清除塔缓存
PlayerController.ResetTempAttributes() -- ② 清零临时属性
ShopSystem.Reset() -- ③ 重置商店
EquipmentInventory.Reset() -- ④ 清空临时装备
PlayerController.ResetHP() -- ⑤ HP/MP 回满
SaveSystem.SaveNow(player, "leave_tower") -- ⑥ 立即存档!
6.2 LeaveTower 做了什么?lua
复制function TowerGenerator.LeaveTower()
towerId_ = nil -- 清除塔 ID
floor_ = 0 -- 层数归零
stairUpPos_ = nil -- 清除楼梯位置
stairDnPos_ = nil
floorContent_ = nil -- 清除当前层实体
lastTemplateId_ = nil
inShopFloor_ = false
infiniteMode_ = false
clearAllCache() -- 清除所有楼层缓存
end
调用后 IsInTower() 返回 false,SerializeState() 返回 nil。6.3 为什么离塔后要立即存档?离塔前存档:{ isInTower: true, towerPlayer: {...}, towerState: {...} } ← 还有塔数据
离塔后存档:{ isInTower: false, towerPlayer: nil, towerState: nil } ← 塔数据已清除
如果不立即存档,而是等 60 秒自动保存:
- 玩家离塔后 30 秒退出游戏
- 自动保存尚未触发
- 存档中仍是 isInTower: true
- 下次登录 → 恢复到塔内 → 但临时属性已被重置 → 数据矛盾
七、完整生命周期图┌─────────────────────────────────────────────────────────────────────┐
│ 存档数据生命周期 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ [进入塔] │
│ │ │
│ ▼ │
│ EnterTower(tId) │
│ ├── towerId_ = tId │
│ ├── clearAllCache() │
│ └── 生成第 1 层 │
│ │ │
│ ▼ │
│ ┌─── 爬塔循环 ──────────────────────────────────────┐ │
│ │ │ │
│ │ 每层:loadOrGenerateFloor() → 缓存到 floorCache_ │ │
│ │ 跨区段:clearSegment(旧区段) → 只保留当前 10 层 │ │
│ │ │ │
│ │ 自动保存(60s)/ 关键操作后 SaveNow: │ │
│ │ isInTower=true │ │
│ │ + towerPlayer = {gold, tempAtk, keys, ...} │ │
│ │ + towerState = {towerId, floor, floors:{...}} │ │
│ │ │ │
│ └────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ [离开塔 / 死亡] │
│ ├── LeaveTower() → towerId_=nil, clearAllCache() │
│ ├── ResetTempAttributes() → gold=0, tempAtk=0, keys=0 │
│ ├── ResetHP() → hp=maxHp, mp=maxMp │
│ └── SaveNow() │
│ │ │
│ ▼ │
│ 存档内容: │
│ isInTower = false │
│ towerPlayer = nil ← 临时属性已清除 │
│ towerState = nil ← 塔缓存已清除 │
│ 永久属性保留 │
│ │
└─────────────────────────────────────────────────────────────────────┘
八、设计要点总结8.1 三个原则
- 数据自描述:isInTower + towerPlayer/towerState 的有无,让读档代码不需要猜测
- 显式清除优于隐式忽略:离塔时不是"不读塔数据",而是"从存档中删除塔数据"
- 立即持久化状态转换:进塔/离塔等关键节点触发 SaveNow,不依赖定时保存
九、实践建议
- 分支标志要放在存档顶层:isInTower 是第一个要读的字段,不要嵌套在子对象里
- 临时数据独立成组:towerPlayer 作为整体存取,不要把临时字段散落在永久字段中间
- 清除要显式写 nil:saveData.towerPlayer = nil 而不是"不赋值"——JSON 编码时 nil 字段不会出现在输出中,效果等价于"删除"
- 关键状态转换 = 立即存档:进塔、离塔、通关、死亡——这些时刻不能等自动保存
- 缓存有上限:分段策略保证内存中最多 10 层,存档中也最多 10 层,体积可预测




