路线阶段:Unity 入门实战第 7 章。
本章目标:从“能保存”升级到“可演进的存档系统”,避免版本更新后用户数据损坏。
学习目标
完成本章后,你应该能做到:
- 设计稳定的存档数据结构(玩家信息、关卡进度、解锁内容)。
- 实现原子写入与失败回滚,避免断电/崩溃导致坏档。
- 支持版本迁移(v1 -> v2 -> v3),保证老档可读。
- 把存档加载结果与 UI、波次系统、技能系统正确对接。
常见坏实现
PlayerPrefs.SetInt("level", level);
PlayerPrefs.SetInt("gold", gold);
PlayerPrefs.Save();
问题:
- 字段散落,难做版本管理。
- 缺少完整性校验,容易出现部分写入。
- 无法表达复杂结构(背包、技能树、关卡星级)。
- 升版本时字段变更容易直接崩溃。
存档模型设计
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;
}
写入策略:双文件 + 校验
存档采用双槽位:
save_a.jsonsave_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;
}
}
场景接入流程
启动时:
Bootstrap调用SaveService.Load()- 把数据注入
GameProfileRuntime UI刷新玩家等级/货币/解锁状态StageSelect根据Stages渲染可进入关卡
战斗结算时:
- 根据结果更新
SaveData - 写入双槽位
- 发布
SaveCompleted事件
与前面章节联动
- 波次系统:通关后记录
StageRecord。 - 技能系统:根据解锁状态决定技能栏可用性。
- UI 系统:监听
SaveCompleted更新资源展示。 - 回放系统:重要失败现场可关联存档版本与时间戳。
WebGL 特别注意
- 文件系统受限时可封装到浏览器存储适配层(IndexedDB/PlayerPrefs)。
- 存档体积要可控,避免每次写入过大 JSON。
- 自动保存频率需节流,避免阻塞主线程。
验收清单
- 首次启动可创建默认存档。
- 更新进度后重启游戏数据一致。
- 破坏一个槽位文件后,另一个槽位仍可恢复。
- 旧版本存档可自动迁移到当前版本。
常见坑
坑 1:存档结构直接用 Dictionary 深层嵌套
JsonUtility 对复杂泛型支持有限,容易踩坑。优先用明确类结构。
坑 2:先删旧文件再写新文件
写入失败会导致双丢失。应先写临时文件再原子替换。
坑 3:迁移逻辑写在 UI 层
数据演进必须在存档服务层完成,UI 只消费结果。
本月作业
实现“自动存档策略”:
- 关卡完成、购买、解锁后立即保存。
- 战斗中每 60 秒做一次增量快照。
- 添加“恢复最近稳定存档”按钮并验证异常恢复流程。
下一章进入 Unity 入门实战 08:音频系统与反馈设计(命中、施法、波次提示、UI 交互统一管理)。