mcpField
U
unity-save-system

unity-save-system

github.com/nice-wolf-studio开发4072 次安装6 周前更新
代码仓库

关于 unity-save-system

unity-save-system 是用于设计 Unity 游戏存档与读取系统的参考技能,比较序列化、存储、迁移、云同步和自动存档方案,并说明如何避免数据丢失及平台存储问题。

主要功能

  1. 01序列化格式选择
  2. 02存档数据与 DTO 设计
  3. 03文件位置和 PlayerPrefs 指南
  4. 04版本管理与迁移模式
  5. 05本地优先的云存档同步
  6. 06自动存档与移动端持久化

适用场景

  1. 01为游戏存档数据选择格式
  2. 02按游戏系统组织存档文件
  3. 03数据结构变更后迁移旧存档
  4. 04将本地存档同步到云端

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/nice-wolf-studio/unity-claude-skills --skill unity-save-system

安装后在 Claude Code 中输入/unity-save-system确认已加载。

附带文件references/save-load-implementation.md

详细介绍

存档/加载系统——决策模式

前置技能: unity-lifecycle(OnApplicationPause、OnApplicationQuit、退出顺序)、unity-data-driven(JSON 序列化、版本控制)、unity-packages-services(Cloud Save API)、unity-async-patterns(BackgroundThreadAsync)

这些模式旨在解决最常见的存档系统故障:Claude 什么都用 PlayerPrefs,生成脆弱且没有版本控制的序列化代码,并忽略移动平台特有的持久化要求。


模式:序列化格式选择

适用场景:选择如何将存档数据序列化到磁盘

决策:

  • JSON(JsonUtility)——可读性强,便于调试,存档文件较小。不支持字典/多态。适合大多数游戏,是最佳默认选择。
  • JSON(Newtonsoft)——支持完整的 JSON 功能(字典、多态、LINQ)。依赖项稍大。适用于存档数据较复杂的情况。
  • 二进制(自定义或 MessagePack)——适用于对性能要求高、存档较大或需要防作弊(更难编辑)的场景。不可读。适合竞技类游戏或规模非常庞大的世界。
  • BinaryFormatter——绝对不要使用。存在安全漏洞(反序列化时可执行任意代码)。Unity 和 Microsoft 已将其弃用。

代码框架(JSON 存档/加载):

using System.IO;
using UnityEngine;

public static class SaveFileIO
{
    public static void SaveJson<T>(T data, string fileName)
    {
        string path = GetSavePath(fileName);
        string json = JsonUtility.ToJson(data, prettyPrint: true);

        // Write to temp file first, then rename (atomic write)
        string tempPath = path + ".tmp";
        File.WriteAllText(tempPath, json);
        if (File.Exists(path))
            File.Replace(tempPath, path, path + ".bak");
        else
            File.Move(tempPath, path);
    }

    public static T LoadJson<T>(string fileName) where T : new()
    {
        string path = GetSavePath(fileName);
        if (!File.Exists(path)) return new T();

        string json = File.ReadAllText(path);
        return JsonUtility.FromJson<T>(json);
    }

    public static bool SaveExists(string fileName) =>
        File.Exists(GetSavePath(fileName));

    public static void DeleteSave(string fileName)
    {
        string path = GetSavePath(fileName);
        if (File.Exists(path)) File.Delete(path);
        if (File.Exists(path + ".bak")) File.Delete(path + ".bak");
    }

    static string GetSavePath(string fileName) =>
        Path.Combine(Application.persistentDataPath, fileName);
}

注意:BinaryFormatter 存在安全漏洞——反序列化可以执行任意代码。Unity 和 Microsoft 已将其弃用。如果在现有代码中发现它,请立即替换。JsonUtility 无法序列化字典——请转换为 List<SerializableKeyValue<K,V>>,或使用 Newtonsoft。原子写入模式(先写入 .tmp,再重命名)可防止应用在写入过程中崩溃导致数据损坏。


模式:存档数据架构(DTO 模式)

适用场景:决定要序列化哪些内容以及如何组织数据

决策:

  • 单个整体存档——使用一个包含所有内容的 SaveData 类。加载/保存是原子操作。适合加载/保存速度快的小型游戏。
  • 按系统拆分存档文件——PlayerSave.json、InventorySave.json、WorldSave.json。可部分加载、减少写入量,也更容易单独迁移各个系统。

代码框架(ISaveable 接口):

// Save Data Transfer Object -- plain C# class, NOT MonoBehaviour
[System.Serializable]
public class GameSaveData
{
    public int version = 1;
    public PlayerSaveData player;
    public InventorySaveData inventory;
    public WorldSaveData world;
}

[System.Serializable]
public class PlayerSaveData
{
    public float[] position = new float[3]; // Vector3 as array for JSON compat
    public int health;
    public int level;
    public int xp;

    public void FromPlayer(Transform t, int hp, int lvl, int xp)
    {
        position[0] = t.position.x;
        position[1] = t.position.y;
        position[2] = t.position.z;
        health = hp; level = lvl; this.xp = xp;
    }

    public Vector3 GetPosition() => new(position[0], position[1], position[2]);
}

// Systems implement ISaveable to participate in save/load
public interface ISaveable
{
    void GatherSaveData(GameSaveData data);  // Write state to DTO
    void ApplySaveData(GameSaveData data);   // Read state from DTO
}

注意:存档 DTO 必须是带有 [System.Serializable] 的普通 C# 类,不能是 MonoBehaviours 或 ScriptableObjects。它们只能包含可序列化类型(基元类型、数组、列表、嵌套的 [Serializable] 类)。Unity 类型(如 Vector3)可通过 JsonUtility 正常序列化,但使用 Newtonsoft 时则不行,除非提供自定义转换器——为确保兼容性,请使用 float[]。


模式:存档文件位置

适用场景:选择存档文件的写入位置

决策:

  • Application.persistentDataPath(正确的默认选择)——可写入、应用更新后仍会保留,并且适用于相应平台。所有存档数据都应使用此路径。
  • Application.streamingAssetsPath——在构建版本中为只读。切勿写入此处。仅用于捆绑的只读数据。
  • PlayerPrefs——仅适用于键值数据。用于设置/偏好项,不适用于游戏状态。

代码框架:

// Always use Path.Combine -- never concatenate with "/"
string savePath = Path.Combine(Application.persistentDataPath, "saves", "slot1.json");

// Ensure directory exists before writing
string dir = Path.GetDirectoryName(savePath);
if (!Directory.Exists(dir))
    Directory.CreateDirectory(dir);

注意:persistentDataPath 在不同平台上的位置各不相同:AppData/LocalLow/<company>/<product>(Windows)、~/Library/Application Support/<bundleID>(macOS)、内部存储(Android/iOS)。在 WebGL 上,它使用 IndexedDB(异步,且在隐私浏览模式下可能失败)。始终使用 Path.Combine——macOS/Linux 支持正斜杠,但 Path.Combine 可安全地跨平台使用。


模式:PlayerPrefs 作用域

适用场景:使用 PlayerPrefs 存储设置和少量持久化值

决策:

  • 直接调用 PlayerPrefs —— 设置少于 10 项,项目规模较小。实现简单。
  • 封装类 —— 设置达到 10 项或更多,需要类型安全、变更事件和默认值。可避免键冲突。

脚手架(设置封装类):

public static class GameSettings
{
    // Prefixed keys prevent collision between systems
    private const string Prefix = "settings.";

    public static float MasterVolume
    {
        get => PlayerPrefs.GetFloat(Prefix + "masterVolume", 1f);
        set { PlayerPrefs.SetFloat(Prefix + "masterVolume", value); OnSettingsChanged?.Invoke(); }
    }

    public static bool Fullscreen
    {
        get => PlayerPrefs.GetInt(Prefix + "fullscreen", 1) == 1;
        set { PlayerPrefs.SetInt(Prefix + "fullscreen", value ? 1 : 0); OnSettingsChanged?.Invoke(); }
    }

    public static int QualityLevel
    {
        get => PlayerPrefs.GetInt(Prefix + "quality", QualitySettings.GetQualityLevel());
        set { PlayerPrefs.SetInt(Prefix + "quality", value); OnSettingsChanged?.Invoke(); }
    }

    public static event Action OnSettingsChanged;

    public static void Save() => PlayerPrefs.Save();

    public static void ResetToDefaults()
    {
        PlayerPrefs.DeleteKey(Prefix + "masterVolume");
        PlayerPrefs.DeleteKey(Prefix + "fullscreen");
        PlayerPrefs.DeleteKey(Prefix + "quality");
        OnSettingsChanged?.Invoke();
    }
}

注意事项:PlayerPrefs 的键是全局字符串,没有命名空间。使用带前缀的键("audio.masterVolume"、"video.fullscreen")来避免冲突。PlayerPrefs 没有 bool 类型,应使用 int(0/1)。在 Windows 上,PlayerPrefs 会将数据存储到 Windows 注册表中,因此应避免存储较大的值。PlayerPrefs.Save() 会在 OnApplicationQuit 时自动调用,但在移动设备上进行重要变更后应手动调用(应用可能会在未退出的情况下被终止)。


模式:存档版本控制与迁移

适用场景:游戏更新后存档格式发生变化(新增、重命名或重构字段)

决策:

  • 版本字段 + 顺序迁移器 —— 适用于不兼容变更。在存档中存储 int version。加载时,按迁移链执行迁移:v1 -> v2 -> v3 -> 当前版本。
  • 容错读取 —— 适用于仅添加字段的变更。新字段使用默认值,已移除的字段会被忽略。JsonUtility 会自动处理。

脚手架(迁移链):

public static class SaveMigrator
{
    private static readonly int CurrentVersion = 3;

    public static GameSaveData LoadAndMigrate(string json)
    {
        // Parse version from raw JSON first
        var versionCheck = JsonUtility.FromJson<VersionOnly>(json);
        int version = versionCheck.version;

        if (version == CurrentVersion)
            return JsonUtility.FromJson<GameSaveData>(json);

        // Sequential migration using Newtonsoft for raw JSON manipulation
        var jObj = Newtonsoft.Json.Linq.JObject.Parse(json);

        if (version < 2) MigrateV1ToV2(jObj);
        if (version < 3) MigrateV2ToV3(jObj);

        jObj["version"] = CurrentVersion;
        return Newtonsoft.Json.JsonConvert.DeserializeObject<GameSaveData>(jObj.ToString());
    }

    static void MigrateV1ToV2(Newtonsoft.Json.Linq.JObject data)
    {
        // v2 moved "playerX/Y/Z" into "player.position" array
        var player = (Newtonsoft.Json.Linq.JObject)data["player"];
        if (player != null && player["playerX"] != null)
        {
            float x = (float)player["playerX"];
            float y = (float)player["playerY"];
            float z = (float)player["playerZ"];
            player["position"] = new Newtonsoft.Json.Linq.JArray(x, y, z);
            player.Remove("playerX"); player.Remove("playerY"); player.Remove("playerZ");
        }
    }

    static void MigrateV2ToV3(Newtonsoft.Json.Linq.JObject data)
    {
        // v3 added inventory system (default to empty)
        if (data["inventory"] == null)
            data["inventory"] = Newtonsoft.Json.Linq.JObject.FromObject(new InventorySaveData());
    }

    [System.Serializable]
    private class VersionOnly { public int version; }
}

注意事项:迁移必须针对原始 JSON 执行(在反序列化为新的 C# 类型之前),因为旧类型定义可能已不再存在于代码中。结构发生变化时,请使用 Newtonsoft JObject。每个迁移步骤都是一个从版本 N 转换到 N+1 的函数,并按顺序组合执行。使用各版本保存的 JSON 测试迁移(存储测试样例)。


模式:云存档集成

适用场景:存档数据需要在多台设备间同步,或需要在本地存储之外持久保存

决策:

  • Unity Cloud Save(UGS) —— 托管服务,支持键值或文件存储,每位玩家可免费使用 200KB。API 参见 unity-packages-services。最适合大多数独立游戏和中型游戏。
  • 自定义后端 —— 完全自主控制、存储空间不限,可自定义冲突解决方式。需要自行构建和维护。

脚手架(本地优先并与云端同步):

public class SaveManager : MonoBehaviour
{
    public async Awaitable Save(GameSaveData data)
    {
        // Always save locally first (fast, reliable)
        SaveFileIO.SaveJson(data, "savegame.json");

        // Then sync to cloud in background (best-effort)
        try
        {
            var cloudData = new Dictionary<string, object>
            {
                { "savegame", JsonUtility.ToJson(data) }
            };
            await Unity.Services.CloudSave.CloudSaveService.Instance
                .Data.Player.SaveAsync(cloudData);
        }
        catch (Exception e)
        {
            Debug.LogWarning($"Cloud save failed (local save preserved): {e.Message}");
        }
    }
}

注意事项:云存档必须支持离线游玩。始终先在本地保存,再在后台同步到云端。妥善处理 CloudSaveException——网络故障绝不能妨碍本地游玩或导致游戏崩溃。冲突解决方面,“最后写入者胜出”最简单;如需合并逻辑,则使用时间戳。


模式:自动保存策略

适用场景:决定何时触发保存

决策:

  • 检查点式 —— 在特定游戏事件时保存(关卡完成、休息点、手动保存)。触发明确,由设计师控制。传统做法。
  • 定时自动保存 —— 每隔 N 秒或在场景切换时保存。可作为防崩溃的安全保障,也可与检查点结合使用。
  • 事件驱动 —— 每当重要状态发生变化时保存(获得物品、完成任务)。粒度更细,但 I/O 更多。

脚手架(自动保存 + 移动设备安全保障):

public class AutoSaveManager : MonoBehaviour
{
    [SerializeField] private float autoSaveInterval = 60f; // seconds
    private float _timeSinceLastSave;

    void Update()
    {
        _timeSinceLastSave += Time.unscaledDeltaTime;
        if (_timeSinceLastSave >= autoSaveInterval)
        {
            _timeSinceLastSave = 0f;
            _ = PerformAutoSave(); // Fire-and-forget with error handling
        }
    }

    // Mobile: save on app background (OnApplicationQuit may not fire)
    void OnApplicationPause(bool paused)
    {
        if (paused)
            _ = PerformAutoSave();
    }

    void OnApplicationQuit()
    {
        // Synchronous save on quit (async may not complete)
        var data = GatherSaveData();
        SaveFileIO.SaveJson(data, "autosave.json");
    }

    async Awaitable PerformAutoSave()
    {
        try
        {
            await Awaitable.BackgroundThreadAsync();
            var data = GatherSaveData();
            SaveFileIO.SaveJson(data, "autosave.json");
            await Awaitable.MainThreadAsync();
        }
        catch (Exception e)
        {
            Debug.LogError($"Auto-save failed: {e.Message}");
        }
    }

    GameSaveData GatherSaveData()
    {
        var data = new GameSaveData();
        // Gather from all ISaveable systems
        return data;
    }
}

注意事项:保存受 I/O 限制。在移动设备上,请使用 OnApplicationPause(true)——操作系统终止应用时,OnApplicationQuit 可能不会触发。在 OnApplicationQuit 中使用同步 I/O(进程退出前异步操作可能无法完成)。切勿在 Update 中同步保存——请使用 BackgroundThreadAsync 或计时器。交叉参考:unity-lifecycle 涵盖退出流程。


反模式

反模式问题修复方法
BinaryFormatter存在安全漏洞,且已弃用使用 JsonUtility 或 Newtonsoft
在 PlayerPrefs 中存储游戏状态缺乏结构和版本控制,且可能发生键冲突使用 JSON 文件 + persistentDataPath
直接保存 MonoBehaviour无法序列化,且会使存档与场景耦合使用带 [Serializable] 的纯 C# DTO
存档数据中没有版本字段格式变更时无法迁移始终包含 int version
在 Update 中同步保存会造成帧卡顿,在移动设备上尤其明显使用 BackgroundThreadAsync 或计时器
没有备份或原子写入写入期间崩溃会导致存档损坏写入 .tmp 文件,再以原子方式重命名

相关技能

  • unity-lifecycle —— OnApplicationPause、OnApplicationQuit、退出序列时序
  • unity-packages-services —— Unity Cloud Save API(请勿重复)
  • unity-async-patterns —— 用于异步 I/O 的 BackgroundThreadAsync
  • unity-data-driven —— JsonUtility 与 Newtonsoft 的对比、配置数据的版本控制模式

其他资源

评论

支持 Markdown