Article

Unity 入门实战 07:存档与进度系统(可迁移、可回滚、可演进)

路线阶段:Unity 入门实战第 7 章。
本章目标:从“能保存”升级到“可演进的存档系统”,避免版本更新后用户数据损坏。

学习目标

完成本章后,你应该能做到:

  1. 设计稳定的存档数据结构(玩家信息、关卡进度、解锁内容)。
  2. 实现原子写入与失败回滚,避免断电/崩溃导致坏档。
  3. 支持版本迁移(v1 -> v2 -> v3),保证老档可读。
  4. 把存档加载结果与 UI、波次系统、技能系统正确对接。

常见坏实现

PlayerPrefs.SetInt("level", level);
PlayerPrefs.SetInt("gold", gold);
PlayerPrefs.Save();

问题:

  1. 字段散落,难做版本管理。
  2. 缺少完整性校验,容易出现部分写入。
  3. 无法表达复杂结构(背包、技能树、关卡星级)。
  4. 升版本时字段变更容易直接崩溃。

存档模型设计

1. 根结构

[Serializable]
public sealed class SaveData
{
    public int Version;
    public long SaveUnixMs;

    public PlayerProgress Player;
    public StageProgress Stages;
    public UnlockProgress Unlocks;
}

2. 玩家进度

[Serializable]
public sealed class PlayerProgress
{
    public int Level;
    public int Exp;
    public int Gold;
    public int Gem;

    public int MaxUnlockedSkillSlot;
    public List<int> EquippedSkillIds;
}

3. 关卡进度

[Serializable]
public sealed class StageProgress
{
    public int LastStageId;
    public List<StageRecord> Records;
}

[Serializable]
public sealed class StageRecord
{
    public int StageId;
    public bool Cleared;
    public int Stars;
    public int BestTimeSeconds;
}

4. 解锁项

[Serializable]
public sealed class UnlockProgress
{
    public List<int> UnlockedCharacters;
    public List<int> UnlockedSkills;
    public List<int> UnlockedRelics;
}

写入策略:双文件 + 校验

存档采用双槽位:

  1. save_a.json
  2. save_b.json

每次保存写入“旧时间戳较小”的文件,成功后更新时间戳。读取时选取“校验通过且时间最新”的文件。

存档服务接口

public interface ISaveService
{
    SaveData Load();
    bool Save(SaveData data);
    void Reset();
}

实现骨架

public sealed class JsonSaveService : ISaveService
{
    private readonly string _pathA;
    private readonly string _pathB;

    public JsonSaveService(string root)
    {
        _pathA = Path.Combine(root, "save_a.json");
        _pathB = Path.Combine(root, "save_b.json");
    }

    public SaveData Load()
    {
        var a = TryRead(_pathA);
        var b = TryRead(_pathB);

        if (a == null && b == null)
        {
            return CreateDefault();
        }

        if (a == null) return MigrateIfNeeded(b);
        if (b == null) return MigrateIfNeeded(a);

        return a.SaveUnixMs >= b.SaveUnixMs ? MigrateIfNeeded(a) : MigrateIfNeeded(b);
    }

    public bool Save(SaveData data)
    {
        data.SaveUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();

        var json = JsonUtility.ToJson(data, true);
        var crc = Crc32Util.ComputeUtf8(json);
        var wrapper = new SaveWrapper { DataJson = json, Crc32 = crc };
        var wrappedJson = JsonUtility.ToJson(wrapper, true);

        var target = SelectOlderSlot();
        var temp = target + ".tmp";

        File.WriteAllText(temp, wrappedJson, Encoding.UTF8);

        if (File.Exists(target))
        {
            File.Delete(target);
        }

        File.Move(temp, target);

        FoundationLog.Info("Save", "saved slot=" + Path.GetFileName(target) + " ts=" + data.SaveUnixMs);
        return true;
    }

    public void Reset()
    {
        if (File.Exists(_pathA)) File.Delete(_pathA);
        if (File.Exists(_pathB)) File.Delete(_pathB);
    }

    private SaveData TryRead(string path)
    {
        if (!File.Exists(path))
        {
            return null;
        }

        try
        {
            var text = File.ReadAllText(path, Encoding.UTF8);
            var wrapper = JsonUtility.FromJson<SaveWrapper>(text);
            if (wrapper == null || string.IsNullOrEmpty(wrapper.DataJson))
            {
                return null;
            }

            var crc = Crc32Util.ComputeUtf8(wrapper.DataJson);
            if (crc != wrapper.Crc32)
            {
                FoundationLog.Warn("Save", "crc_mismatch path=" + path);
                return null;
            }

            var data = JsonUtility.FromJson<SaveData>(wrapper.DataJson);
            return data;
        }
        catch (Exception ex)
        {
            FoundationLog.Error("Save", "read_failed path=" + path + " ex=" + ex.Message);
            return null;
        }
    }

    private string SelectOlderSlot()
    {
        var a = TryRead(_pathA);
        var b = TryRead(_pathB);

        if (a == null) return _pathA;
        if (b == null) return _pathB;

        return a.SaveUnixMs <= b.SaveUnixMs ? _pathA : _pathB;
    }

    private static SaveData CreateDefault()
    {
        return new SaveData
        {
            Version = 3,
            SaveUnixMs = 0,
            Player = new PlayerProgress
            {
                Level = 1,
                Exp = 0,
                Gold = 0,
                Gem = 0,
                MaxUnlockedSkillSlot = 2,
                EquippedSkillIds = new List<int> { 1001, 1002 }
            },
            Stages = new StageProgress
            {
                LastStageId = 1,
                Records = new List<StageRecord>()
            },
            Unlocks = new UnlockProgress
            {
                UnlockedCharacters = new List<int> { 1 },
                UnlockedSkills = new List<int> { 1001, 1002 },
                UnlockedRelics = new List<int>()
            }
        };
    }
}

[Serializable]
public sealed class SaveWrapper
{
    public string DataJson;
    public uint Crc32;
}

版本迁移

迁移入口

public static class SaveMigration
{
    public static SaveData MigrateIfNeeded(SaveData data)
    {
        if (data == null)
        {
            return null;
        }

        while (data.Version < 3)
        {
            if (data.Version == 1)
            {
                data = V1ToV2(data);
                continue;
            }

            if (data.Version == 2)
            {
                data = V2ToV3(data);
                continue;
            }

            throw new InvalidOperationException("Unknown save version=" + data.Version);
        }

        return data;
    }

    private static SaveData V1ToV2(SaveData old)
    {
        if (old.Unlocks == null)
        {
            old.Unlocks = new UnlockProgress
            {
                UnlockedCharacters = new List<int> { 1 },
                UnlockedSkills = new List<int>(),
                UnlockedRelics = new List<int>()
            };
        }

        old.Version = 2;
        return old;
    }

    private static SaveData V2ToV3(SaveData old)
    {
        if (old.Player != null && old.Player.EquippedSkillIds == null)
        {
            old.Player.EquippedSkillIds = new List<int> { 1001, 1002 };
        }

        old.Version = 3;
        return old;
    }
}

场景接入流程

启动时:

  1. Bootstrap 调用 SaveService.Load()
  2. 把数据注入 GameProfileRuntime
  3. UI 刷新玩家等级/货币/解锁状态
  4. StageSelect 根据 Stages 渲染可进入关卡

战斗结算时:

  1. 根据结果更新 SaveData
  2. 写入双槽位
  3. 发布 SaveCompleted 事件

与前面章节联动

  1. 波次系统:通关后记录 StageRecord
  2. 技能系统:根据解锁状态决定技能栏可用性。
  3. UI 系统:监听 SaveCompleted 更新资源展示。
  4. 回放系统:重要失败现场可关联存档版本与时间戳。

WebGL 特别注意

  1. 文件系统受限时可封装到浏览器存储适配层(IndexedDB/PlayerPrefs)。
  2. 存档体积要可控,避免每次写入过大 JSON。
  3. 自动保存频率需节流,避免阻塞主线程。

验收清单

  1. 首次启动可创建默认存档。
  2. 更新进度后重启游戏数据一致。
  3. 破坏一个槽位文件后,另一个槽位仍可恢复。
  4. 旧版本存档可自动迁移到当前版本。

常见坑

坑 1:存档结构直接用 Dictionary 深层嵌套

JsonUtility 对复杂泛型支持有限,容易踩坑。优先用明确类结构。

坑 2:先删旧文件再写新文件

写入失败会导致双丢失。应先写临时文件再原子替换。

坑 3:迁移逻辑写在 UI 层

数据演进必须在存档服务层完成,UI 只消费结果。

本月作业

实现“自动存档策略”:

  1. 关卡完成、购买、解锁后立即保存。
  2. 战斗中每 60 秒做一次增量快照。
  3. 添加“恢复最近稳定存档”按钮并验证异常恢复流程。

下一章进入 Unity 入门实战 08:音频系统与反馈设计(命中、施法、波次提示、UI 交互统一管理)。