Save system в Unity без боли: формат, версионирование, миграции
Систему сохранений я переписывал на каждом проекте, потому что каждый раз казалось, что в этот раз сделаю правильно. Реальность: правильной 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);
}
Логика:
- Пишем во временный файл (
.tmp). - Текущий перемещаем в backup (
.bak). - Временный переименовываем в основной.
Если упало на шаге 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.
Тестирование сейвов
Что я тестирую перед каждым релизом:
- Сейв с предыдущей версии открывается на новой (берём билд, играем час, обновляем — продолжаем).
- Сейв пишется и читается на всех целевых платформах (Windows, Mac, Android, iOS — у меня всегда свой smoke-test).
- Корректное поведение при битом файле — игра не падает, показывает «save corrupted, recovering from backup».
- Несколько слотов — отдельность.
- Большой save (если игра допускает раздутые сейвы) — производительность.
Это часть acceptance-testing'а, не на дев'е. На дев'е добавляю баг-репорт-функцию: F12 копирует текущий save в clipboard для багрепортов.
Что не делать
- Не использовать
BinaryFormatter— устаревший, небезопасный, в .NET 8 deprecated. - Не сериализовать
MonoBehaviour'ы напрямую. Сериализуй data-классы, MonoBehaviour ставится из Save'а в рантайме. - Не складывать всё в один
PlayerPrefs— это не save, это настройки. - Не делать save «когда удобно» — делай явные точки сохранения, чтобы дизайнер мог их контролировать.
Что взять с собой
Если будешь стартовать save-систему сегодня, минимум:
- Newtonsoft.Json как сериализатор.
- Версия в корне save'а с первого дня.
- Атомарная запись с backup-файлом.
- Список миграций версий, по интерфейсу.
- Persistent Data Path, не другой.
- Async-запись на background thread.
Это минимально-достаточная архитектура для проекта, который ты планируешь поддерживать дольше двух месяцев. Дальше — slot'ы, шифрование, облако — добавляешь по необходимости. Главное — не врать себе, что «у меня маленькая игра, версионирование не нужно». Нужно. Всегда.