~2 мин чтения

Save / Load — сохранение прогресса

PlayerPrefs, JSON, бинарная сериализация, шифрование, версионирование сейвов.

Save/Load — стандартная задача любой игры. В Unity есть 3 основных пути, и выбор зависит от размера и сложности сохраняемых данных.

Три пути

ПодходКогдаПлатформы
PlayerPrefsМаленькие primitive-значения (настройки, высокий счёт)Все
JSON + File.WriteAllTextСтруктурированные сейвы, человекочитаемыеВсе, кроме WebGL без plugin
Binary SerializationБольшие/сложные сейвы, защита от tamperingВсе
Веб

localStorage для маленького (5MB лимит), IndexedDB для большого. В Node — обычная файловая система.

Unity

PlayerPrefs ≈ localStorage. JSON + File — это уже “пользовательский файл на диске” — в Application.persistentDataPath (varies per platform).

PlayerPrefs — для настроек

// Сохранение
PlayerPrefs.SetInt("HighScore", 1500);
PlayerPrefs.SetFloat("MasterVolume", 0.8f);
PlayerPrefs.SetString("PlayerName", "Konstantin");
PlayerPrefs.Save(); // явно записать на диск (autoSave при выходе)

// Чтение
int score = PlayerPrefs.GetInt("HighScore", 0); // 0 — default если ключ не найден
float volume = PlayerPrefs.GetFloat("MasterVolume", 1.0f);

// Удаление
PlayerPrefs.DeleteKey("HighScore");
PlayerPrefs.DeleteAll(); // осторожно — стирает все ключи

Где хранится:

  • Windows: HKCU\Software\[Company]\[Product] в Registry.
  • macOS: ~/Library/Preferences/[bundle identifier].plist.
  • Linux: ~/.config/unity3d/[Company]/[Product]/prefs.

PlayerPrefs не зашифрован. Пользователь может открыть и поправить — для настроек это OK, для прогресса игры — небезопасно.

PlayerPrefs не для большого прогресса

Запись каждого вызова Save синхронно сериализует ВСЕ preferences. Если у вас 1000 ключей с массивами строк — производительность пострадает. PlayerPrefs — для десятков ключей, не тысяч.

JSON-сохранения

Для структурированных данных используйте JSON. Unity имеет встроенный JsonUtility (без внешних зависимостей).

using UnityEngine;
using System.IO;

[System.Serializable]
public class SaveData
{
    public string playerName;
    public int level;
    public float playTimeSeconds;
    public Vector3 position;
    public InventoryData inventory;
}

[System.Serializable]
public class InventoryData
{
    public int gold;
    public string[] items;
}

public class SaveSystem : MonoBehaviour
{
    private const string FILENAME = "save.json";

    private string GetSavePath() {
        return Path.Combine(Application.persistentDataPath, FILENAME);
    }

    public void SaveGame(SaveData data) {
        string json = JsonUtility.ToJson(data, prettyPrint: true);
        File.WriteAllText(GetSavePath(), json);
        Debug.Log($"Saved to {GetSavePath()}");
    }

    public SaveData LoadGame() {
        string path = GetSavePath();
        if (!File.Exists(path)) {
            return new SaveData(); // default values
        }
        string json = File.ReadAllText(path);
        return JsonUtility.FromJson<SaveData>(json);
    }
}

Application.persistentDataPath:

  • Windows: C:\Users\[user]\AppData\LocalLow\[Company]\[Product]
  • macOS: ~/Library/Application Support/[Company]/[Product]
  • iOS: Documents/
  • Android: /data/data/[bundle]/files/ (sandboxed)
JsonUtility ограничения

Встроенный JsonUtility НЕ поддерживает: словари (Dictionary<TKey, TValue>), nullable types, абстрактные классы (полиморфизм). Для них берите Newtonsoft.Json (отдельный package com.unity.nuget.newtonsoft-json) или System.Text.Json для .NET-style.

Сериализация Dictionary с Newtonsoft

using Newtonsoft.Json;
// ...

public Dictionary<string, int> inventory = new() {
    { "sword", 1 },
    { "potion", 5 },
};

string json = JsonConvert.SerializeObject(inventory, Formatting.Indented);
File.WriteAllText(path, json);

var loaded = JsonConvert.DeserializeObject<Dictionary<string, int>>(File.ReadAllText(path));

Шифрование (или просто obfuscation)

JSON-файл — это plain text. Игрок откроет блокнотом и поменяет "gold": 100 на "gold": 999999. Решения от простого к сильному:

Уровень 1 — Base64 (не защита, просто “не глаз режет”)

string encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(json));

Уровень 2 — XOR с константой (легко взломать, но скрывает текст)

const byte KEY = 0x5A;
byte[] data = Encoding.UTF8.GetBytes(json);
for (int i = 0; i < data.Length; i++) data[i] ^= KEY;
File.WriteAllBytes(path, data);

Уровень 3 — AES-шифрование (для серьёзных проектов с прогрессом)

using System.Security.Cryptography;
using System.Text;

public static byte[] Encrypt(string text, string password) {
    using var aes = Aes.Create();
    aes.Key = SHA256.HashData(Encoding.UTF8.GetBytes(password));
    aes.IV = new byte[16];
    using var encryptor = aes.CreateEncryptor();
    var plain = Encoding.UTF8.GetBytes(text);
    return encryptor.TransformFinalBlock(plain, 0, plain.Length);
}

public static string Decrypt(byte[] data, string password) {
    using var aes = Aes.Create();
    aes.Key = SHA256.HashData(Encoding.UTF8.GetBytes(password));
    aes.IV = new byte[16];
    using var decryptor = aes.CreateDecryptor();
    var plain = decryptor.TransformFinalBlock(data, 0, data.Length);
    return Encoding.UTF8.GetString(plain);
}

⚠️ Важно: пароль хранится в вашем коде → reverse-engineering найдёт его. AES — защита от казуальных читеров, не от serious crackers. Для multiplayer game-state валидация — на сервере, не у клиента.

Версионирование сейвов

Когда вы релизите v2.0 игры с новыми полями, старые сейвы должны работать. Стратегия:

[System.Serializable]
public class SaveData
{
    public int version = 2;            // <-- ВЕРСИЯ
    public string playerName;
    public int level;
    public InventoryData inventory;
    public List<QuestProgress> activeQuests; // <-- НОВОЕ в v2
}

public SaveData LoadGame() {
    var data = JsonUtility.FromJson<SaveData>(File.ReadAllText(path));

    if (data.version < 2) {
        // Миграция v1 → v2
        data.activeQuests = new List<QuestProgress>(); // дефолт
        data.version = 2;
    }

    return data;
}

Версионируйте С САМОГО НАЧАЛА. Добавить версию позже, когда у пользователей уже есть сейвы, сильно сложнее.

Async-сохранение для крупных файлов

Если сейв большой (несколько мегабайт):

public async Awaitable SaveGameAsync(SaveData data) {
    string json = JsonUtility.ToJson(data);
    // File.WriteAllText блокирует main thread → используем async
    await File.WriteAllTextAsync(GetSavePath(), json);
}

Это спасает от 100ms stutter при автосохранении.

Auto-save паттерн

public class AutoSave : MonoBehaviour
{
    [SerializeField] private float intervalSeconds = 60f;
    [SerializeField] private SaveSystem saveSystem;
    [SerializeField] private GameState gameState;

    private void Start() {
        InvokeRepeating(nameof(DoAutoSave), intervalSeconds, intervalSeconds);
    }

    private async void DoAutoSave() {
        var data = gameState.CaptureState();
        await saveSystem.SaveGameAsync(data);
        // показать "💾 Сохранено" toast
    }

    private void OnApplicationPause(bool paused) {
        if (paused) DoAutoSave(); // на mobile при сворачивании
    }

    private void OnApplicationQuit() {
        DoAutoSave();
    }
}

Cloud Saves — Steam, Unity Cloud Save

Для cross-device синхронизации:

  • Steam Cloud — через Steamworks API; работает прозрачно, кладёт ваш сейв в их облако.
  • Unity Cloud Save (UGS пакет) — официальный, привязан к Unity Authentication.
  • Google Play Games Services — для Android.

Все три — отдельная тема, требуют backend-настройки. Для single-player без cross-device локальный сейв обычно достаточен.

Подводные камни

  1. Не использовать Application.dataPath для записи — это путь к асету проекта, в билде read-only. Только persistentDataPath.
  2. Не сохранять Transform напрямуюTransform не сериализуется. Извлекайте Vector3 position, Quaternion rotation, Vector3 scale руками.
  3. Не забывать про async — синхронный File.WriteAllText блокирует main thread → stutter.
  4. Тестируйте миграцию сейвов — добавили поле → запустите со старым сейвом → не падает?