单 KEY 条件化存档:isInTower 分支序列化与塔缓存生命周期

04/2636 浏览开发心得
适用场景:同一个存档 KEY,根据玩家当前所处地图动态决定存档内容——在塔内多存一块临时数据,离塔时自动清除,实现"按需序列化"。
horizontal linehorizontal line
一、问题:一个 KEY 要存两种形态的数据
魔塔类游戏有两个截然不同的游戏阶段:阶段数据特征例子塔外(村庄/大厅)只有永久属性蘑菇币、体力、永久攻防、装备背包塔内(爬塔中)永久属性 + 临时属性 + 地图缓存临时攻防加成、钥匙、金币、当前层数、10 层地图网格、实体位置如果不分场景,一律把所有字段全存,会出现两个问题:
  1. 数据膨胀:塔内的 10 层地图缓存(grid + content)是存档体积的主力,塔外根本不需要这些数据
  2. 状态残留:离塔后如果不清除 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 秒自动保存:
  1. 玩家离塔后 30 秒退出游戏
  2. 自动保存尚未触发
  3. 存档中仍是 isInTower: true
  4. 下次登录 → 恢复到塔内 → 但临时属性已被重置 → 数据矛盾
七、完整生命周期图┌─────────────────────────────────────────────────────────────────────┐
│                        存档数据生命周期                               │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  [进入塔]                                                           │
│     │                                                               │
│     ▼                                                               │
│  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 三个原则
  1. 数据自描述:isInTower + towerPlayer/towerState 的有无,让读档代码不需要猜测
  2. 显式清除优于隐式忽略:离塔时不是"不读塔数据",而是"从存档中删除塔数据"
  3. 立即持久化状态转换:进塔/离塔等关键节点触发 SaveNow,不依赖定时保存
九、实践建议
  1. 分支标志要放在存档顶层:isInTower 是第一个要读的字段,不要嵌套在子对象里
  2. 临时数据独立成组:towerPlayer 作为整体存取,不要把临时字段散落在永久字段中间
  3. 清除要显式写 nil:saveData.towerPlayer = nil 而不是"不赋值"——JSON 编码时 nil 字段不会出现在输出中,效果等价于"删除"
  4. 关键状态转换 = 立即存档:进塔、离塔、通关、死亡——这些时刻不能等自动保存
  5. 缓存有上限:分段策略保证内存中最多 10 层,存档中也最多 10 层,体积可预测
6
2
3