lenec ru

← все посты

Save system в Unity без боли: формат, версионирование, миграции

11K

Систему сохранений я переписывал на каждом проекте, потому что каждый раз казалось, что в этот раз сделаю правильно. Реальность: правильной save system не существует, есть только save system, которая не разваливается через полгода, когда вышло три патча и три DLC. Расскажу, что в итоге работает в Unity 6 и почему «просто JsonUtility» — путь к боли.

Тут не про save для пет-проекта на пять часов геймплея. Тут про игру, у которой будут патчи, новые механики, ограниченные slot'ы и игроки, которые не хотят терять прогресс из-за смены поля в скрипте.

Что такое «нормальный» save

Минимальные требования, которые я сейчас закладываю в новые проекты:

  • Совместимость со старыми сейвами после обновления игры. Игрок проходит 30 часов, выходит патч — сейв должен продолжать работать.
  • Атомарность записи. Если игра упала во время сохранения, файл не должен быть наполовину битым.
  • Резервная копия. Если основной файл повреждён, восстанавливаемся с предыдущего успешного сейва.
  • Шифрование/обфускация (опционально). Не для криптографической стойкости, а чтобы простые читеры не редактировали JSON в блокноте.
  • Версионирование. Структура данных привязана к номеру версии, миграция явно прописана.
  • Платформенно-независимый формат. Работает на Windows, Mac, iOS, Android, консолях.

Если у тебя что-то из этого не закрыто, через год получишь либо «у игроков ничего не открывается после патча», либо «один баг — и сейв удалён».

Формат: JSON, BSON, бинарь, SQLite — что выбрать

Мой текущий выбор по умолчанию — JSON через System.Text.Json или Newtonsoft.Json (Unity-овский JsonUtility не использую, ниже почему).

JsonUtility — нет

  • Не сериализует Dictionary.
  • Не сериализует null-полей у reference-типов корректно.
  • Падает на полиморфизме (без хаков).
  • Нет custom converter'ов.

Подходит для крошечных конфигов, не для save'ов.

System.Text.Json

Унитёвский Mono/.NET 8 поддерживает. Быстрый, mainstream, нормально работает с record'ами и target-typed new. Минус — Unity AOT-сборка (IL2CPP) на iOS требует source-генерации (JsonSerializerContext), иначе reflection не работает.

Newtonsoft.Json (Unity-порт)

Установка через Package Manager (com.unity.nuget.newtonsoft-json). Полнофункциональный, работает везде, AOT-проблем нет. Чуть медленнее. Если нет жёстких требований по производительности — бери его, не парься.

MessagePack или Protobuf

Бинарные форматы, быстрее и компактнее JSON в 2–5 раз. Для save важно? Обычно нет: сейвы небольшие (десятки килобайт), читаются и пишутся раз в несколько минут. Бинарь усложняет дебаг (нельзя открыть в блокноте и посмотреть).

SQLite

Спорная штука. Хорошо, если у тебя сложная структура с миллионом записей (открытый мир с состоянием каждого NPC, инвентарём в каждом сундуке). Плохо для мелких казуальных save'ов: SQLite-файл больше, миграции сложнее, бэкапы громоздкие.

Личный rule: меньше тысячи записей в save — JSON. Больше — подумай про SQLite.

Структура данных: версия в корне

Самое важное решение — версионировать сейв с первого дня:

public sealed record SaveFile
{
    public int Version { get; init; } = 1;
    public DateTime CreatedAt { get; init; }
    public DateTime LastSavedAt { get; init; }
    public string GameVersion { get; init; } = string.Empty;
    public PlayerData Player { get; init; } = new();
    public WorldData World { get; init; } = new();
    public Dictionary<string, object> Custom { get; init; } = new();
}

Версия нужна не «когда понадобится», а с самого начала. Потом её добавлять — морока: придётся писать миграцию из «безверсионного» в «версия 1».

Поле GameVersion — для дебага. Знаешь, на каком билде сейв создан, и при репорте бага видишь сразу.

Миграции: явные, по версиям

Когда меняешь структуру save'а, не молчком переименовываешь поле и не правишь старые сейвы вручную. Делаешь миграцию.

public interface ISaveMigration
{
    int FromVersion { get; }
    int ToVersion { get; }
    JObject Migrate(JObject save);
}

public sealed class V1ToV2 : ISaveMigration
{
    public int FromVersion => 1;
    public int ToVersion => 2;

    public JObject Migrate(JObject save)
    {
        // в v2 рынок переехал из World.Market в Economy.Market
        var market = save["World"]?["Market"];
        if (market != null)
        {
            save["Economy"] = new JObject { ["Market"] = market };
            ((JObject)save["World"]!).Remove("Market");
        }
        save["Version"] = 2;
        return save;
    }
}

Менеджер прогоняет все миграции по очереди, пока версия не дойдёт до текущей:

public static class SaveMigrator
{
    private static readonly List<ISaveMigration> _migrations = new()
    {
        new V1ToV2(),
        new V2ToV3(),
        new V3ToV4(),
    };

    public static JObject Migrate(JObject save, int targetVersion)
    {
        var current = save["Version"]?.Value<int>() ?? 0;
        while (current < targetVersion)
        {
            var migration = _migrations.FirstOrDefault(m => m.FromVersion == current);
            if (migration == null)
                throw new SaveMigrationException($"No migration from v{current}");
            save = migration.Migrate(save);
            current = migration.ToVersion;
        }
        return save;
    }
}

Это лучше, чем условные блоки в коде типа «если поле X есть — это старый формат». Каждая миграция — атомарная единица, читается, тестируется, ревьюится отдельно.

Атомарная запись: write-then-rename

Прямая запись в файл — рецепт катастрофы. Игрок выключил питание во время сохранения — файл наполовину пустой, прогресс потерян.

Стандартный паттерн:

public static void SaveAtomically(string path, string content)
{
    var tempPath = path + ".tmp";
    var backupPath = path + ".bak";

    File.WriteAllText(tempPath, content);

    if (File.Exists(path))
    {
        if (File.Exists(backupPath))
            File.Delete(backupPath);
        File.Move(path, backupPath);
    }

    File.Move(tempPath, path);
}

Логика:

  1. Пишем во временный файл (.tmp).
  2. Текущий перемещаем в backup (.bak).
  3. Временный переименовываем в основной.

Если упало на шаге 1 — основной не тронут. Если на шаге 2 — есть и старый .tmp, можем восстановиться. Если на шаге 3 — у нас рабочий .bak.

При загрузке: пробуем основной, если не парсится — пробуем .bak. Если оба нулевые — стартуем с чистого save'а с явным предупреждением.

Где хранить файл

В Unity — Application.persistentDataPath. Не Application.dataPath, не Application.streamingAssetsPath. На каждой платформе путь свой:

  • Windows: %USERPROFILE%\AppData\LocalLow\Company\Game\
  • Mac: ~/Library/Application Support/Company/Game/
  • iOS: app sandbox Documents/
  • Android: /data/data/com.company.game/files/

Никогда не пиши в Resources или StreamingAssets в рантайме — они read-only после сборки.

Облако и slot'ы

Если игра уйдёт в Steam, на iOS, Google Play — будет облако. Steam Cloud, iCloud, Google Play Saves. Каждый со своими квотами и API.

Что важно знать заранее:

  • Конфликты. Игрок поиграл на ПК, потом на ноутбуке без интернета — два разных state'а. Steam Cloud попросит выбрать. Решение в API чаще всего — «приложение само должно мержить или предлагать выбор».
  • Размер. Steam Cloud по умолчанию ограничивает квоту, считай байты.
  • Slot'ы. Если у тебя несколько save-слотов — каждый отдельный файл, чтобы Cloud не синхронизировал всё одновременно при изменении одного.

Архитектурно: храни логические сейвы в Saves/Slot1/save.json, Saves/Slot2/save.json. И отдельно Saves/_meta.json с метаданными слотов (дата, имя, превьюшка). При загрузке списка слотов читаешь только meta — быстро.

Шифрование: когда нужно и как

Нужно? Часто — нет. Если игра однопользовательская и без taverна, читеры не помешают. Если есть лидерборды или мультиплеер — да, имеет смысл хотя бы обфусцировать.

Простейший вариант — XOR с константным ключом. Не криптография, но 95% игроков не полезут разбирать. Для серьёзных проектов — AES-128 с ключом, прошитым в коде (понимая, что человек с дизассемблером всё равно достанет).

public static byte[] Obfuscate(byte[] data, byte[] key)
{
    var result = new byte[data.Length];
    for (var i = 0; i < data.Length; i++)
        result[i] = (byte)(data[i] ^ key[i % key.Length]);
    return result;
}

Не забудь про checksum — иначе править файл и подделывать значения по-прежнему легко. SHA-256 от данных + соль, хранится в самом файле, проверяется при загрузке.

Полиморфизм: ScriptableObject и тип-теги

В save часто хранятся ссылки на ScriptableObject'ы (виды оружия, талантов, врагов). Не сохраняй сам объект — он же в Resources/Addressables. Сохраняй id или адрес, при загрузке резолви в реальный объект.

public sealed record InventoryItem
{
    public string ItemId { get; init; } = "";
    public int Count { get; init; }
    public Dictionary<string, object> Modifiers { get; init; } = new();
}

Где ItemId — например, "weapons/sword_iron". При загрузке достаём ScriptableObject через свой ItemDatabase или Addressables.

Если нужен полиморфизм для абилок/эффектов:

[JsonConverter(typeof(EffectConverter))]
public abstract record Effect
{
    public abstract string TypeTag { get; }
}

public sealed record DamageOverTime : Effect
{
    public override string TypeTag => "dot";
    public float DamagePerTick { get; init; }
    public float Duration { get; init; }
}

Свой converter в Newtonsoft читает поле TypeTag и выбирает класс. Это вместо $type по полному имени, потому что переименование класса — ад.

Корутины, async и WebGL-сюрпризы

В Unity есть соблазн делать запись в Update'е — не делай. Сериализация большого save'а — десятки миллисекунд, лагает фрейм.

Используй Awaitable или Task:

public async Awaitable SaveAsync(SaveFile save)
{
    await Awaitable.BackgroundThreadAsync();
    var json = JsonConvert.SerializeObject(save);
    SaveAtomically(_path, json);
    await Awaitable.MainThreadAsync();
}

На WebGL это не работает — там нет потоков, и файловая система другая (IndexedDB через emscripten). Готовь отдельный backend для WebGL: PlayerPrefs для меленьких save'ов или idbfs через jslib.

Тестирование сейвов

Что я тестирую перед каждым релизом:

  1. Сейв с предыдущей версии открывается на новой (берём билд, играем час, обновляем — продолжаем).
  2. Сейв пишется и читается на всех целевых платформах (Windows, Mac, Android, iOS — у меня всегда свой smoke-test).
  3. Корректное поведение при битом файле — игра не падает, показывает «save corrupted, recovering from backup».
  4. Несколько слотов — отдельность.
  5. Большой save (если игра допускает раздутые сейвы) — производительность.

Это часть acceptance-testing'а, не на дев'е. На дев'е добавляю баг-репорт-функцию: F12 копирует текущий save в clipboard для багрепортов.

Что не делать

  • Не использовать BinaryFormatter — устаревший, небезопасный, в .NET 8 deprecated.
  • Не сериализовать MonoBehaviour'ы напрямую. Сериализуй data-классы, MonoBehaviour ставится из Save'а в рантайме.
  • Не складывать всё в один PlayerPrefs — это не save, это настройки.
  • Не делать save «когда удобно» — делай явные точки сохранения, чтобы дизайнер мог их контролировать.

Что взять с собой

Если будешь стартовать save-систему сегодня, минимум:

  1. Newtonsoft.Json как сериализатор.
  2. Версия в корне save'а с первого дня.
  3. Атомарная запись с backup-файлом.
  4. Список миграций версий, по интерфейсу.
  5. Persistent Data Path, не другой.
  6. Async-запись на background thread.

Это минимально-достаточная архитектура для проекта, который ты планируешь поддерживать дольше двух месяцев. Дальше — slot'ы, шифрование, облако — добавляешь по необходимости. Главное — не врать себе, что «у меня маленькая игра, версионирование не нужно». Нужно. Всегда.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.