Привет, Хабр! Меня зовут Игорь, и я Unity Developer. В этой статье попытаюсь максимально просто рассказать и показать, как написать систему сохранения в игре в Unity. Скажу сразу, статья ориентирована для тех, кто только начинает свой путь в GameDev. Поэтому некоторые архитектурные принципы будут нарушены с целью упрощения кода. Ближе к концу статьи покажу как сохранять миссии, про которые писал ранее. Ну что ж, поехали...

Репозиторий данных

Прежде чем начать делать систему сохранения, нужно четко определить, что код-база игры и код-база сохранения — это разные вещи, которые между собой не должны быть связаны. Поэтому первым делом выделим класс для сохранения и загрузки данных с диска. Такой класс называется репозиторий:

using System.Collections.Generic;
using Newtonsoft.Json;
using UnityEngine;

public static class Repository
{
    private const string GAME_STATE_KEY = "GameState";

    private static Dictionary<string, string> currentState = new();

    //Загрузить данные с диска
    public static void LoadState()
    {
        if (PlayerPrefs.HasKey(GAME_STATE_KEY))
        {
            var serializedState = PlayerPrefs.GetString(GAME_STATE_KEY);
            currentState = JsonConvert.
              DeserializeObject<Dictionary<string, string>>(serializedState); 
        }
        else
        {
            currentState = new Dictionary<string, string>();
        }
    }

    //Сохранить данные на диск
    public static void SaveState()
    {
        var serializedState = JsonConvert.SerializeObject(currentState);
        PlayerPrefs.SetString(GAME_STATE_KEY, serializedState);
    }

    public static T GetData<T>()
    {
        var serializedData = currentState[typeof(T).Name];
        return JsonConvert.DeserializeObject<T>(serializedData);
    }

    public static void SetData<T>(T value)
    {
        var serializedData = JsonConvert.SerializeObject(value);
        currentState[typeof(T).Name] = serializedData;
    }

    public static bool TryGetData<T>(out T value)
    {
        if (currentState.TryGetValue(typeof(T).Name, out var serializedData))
        {
            value = JsonConvert.DeserializeObject<T>(serializedData);
            return true;
        }

        value = default;
        return false;
    }
}

Данный класс в методах LoadState() & SaveState() загружает и сохраняет данные на диск, используя PlayerPrefs от Unity. Для сериализации данных в формат JSON используется библиотека Json.NET, которая, в отличие от встроенной, умеет сериализовывать словари, массивы и более сложные структуры данных.

Чтобы хранить актуальное состояние игры для загрузки и сохранения, используется static поле currentState. Когда данные в игре будут меняться, например кол-во монет, то будет вызываться метод SetData<T>(), который будет обновлять currentState.

Какие плюсы можно выделить, используя такой репозиторий:

  1. Модульность. Можно повторно использовать класс в разных проектах

  2. Поддерживаемость. Если вы захотите изменить способ сохранения, например, сохранять данные в файл или на сервер, то нужно всего лишь поменять строчки кода в методах LoadState() & SaveState()

Пример сохранения денег

Давайте предположим, что в RPG игре есть деньги игрока, которые нужно сохранять.

Механика денег
Механика денег

И класс кошелька игрока выглядит так:

using System;
using UnityEngine;

//Кошелек игрока в RPG:
public sealed class MoneyStorage : MonoBehaviour
{
    public static MoneyStorage Instance; //Синглтон для начинающих...

    public event Action<int> OnMoneyChanged; //Событие изменения денег

    [SerializeField]
    private int currentMoney; //Текущее кол-во денег

    private void Awake()
    {
        Instance = this;
    }

    //Инициализирует кол-во денег
    public void SetupMoney(int money)
    {
        this.currentMoney = money;
    }

    //Возвращает текущее кол-во денег
    public int GetMoney()
    {
        return this.currentMoney;
    }

    //Добавляет деньги в кошелек
    public void EarnMoney(int range)
    {
        this.currentMoney += range;
        this.OnMoneyChanged?.Invoke(this.currentMoney);
    }

    //Списывает деньги из кошелька
    public void SpendMoney(int range)
    {
        this.currentMoney -= range;
        this.OnMoneyChanged?.Invoke(this.currentMoney);
    }
}

Для того, чтобы сохранять состояние кошелька, нужен специальный адаптер, который будет взаимодействовать с репозиторием. Такой адаптер назовем MoneySaveLoader:

Применение шаблона GRASP: Indirection
Применение шаблона GRASP: Indirection

Пример кода адаптера MoneySaveLoader:

//Данные для сохранения монет
[Serializable]
public struct MoneyData
{
    public int money;
}

public sealed class MoneySaveLoader
{
    //Загружает данные денег из репозитория в кошелек
    public void LoadData()
    {
        MoneyData data = Repository.GetData<MoneyData>();
        MoneyStorage.Instance.SetupMoney(data.money);
    }

    //Сохраняет данные из кошелька в репозиторий
    public void SaveData()
    {
        int money = MoneyStorage.Instance.GetMoney();
        var data = new MoneyData
        {
            money = money
        };
        Repository.SetData(data);
    }
}

MoneySaveLoader позволяет сохранять и загружать данные из игры в репозиторий и обратно, сохраняя независимость кошелька и хранилища друг от друга в проекте. Такая архитектура позволяет проще поддерживать код в долгосрочной перспективе.

Теперь давайте посмотрим, как будут происходить вызовы методов LoadData() & SaveData()...

Менеджер сохранений

Чтобы вызывать методы LoadData() & SaveData() в классе MoneySaveLoader, нужен менеджер сохранений. Менеджер сохранений — это класс, который будет выполнять команды загрузки и сохранения игры, управляя репозиторием и адаптером:

Менеджер сохранения
Менеджер сохранения

Команда загрузки будет состоять из двух пунктов:

1. Загрузить данные с диска в репозиторий

2. Сказать MoneySaveLoader, чтобы он загрузил данные из репозитория в кошелек

Команда сохранения будет состоять тоже из двух пунктов:

1. Сказать MoneySaveLoader, чтобы он сохранил данные из кошелька в репозиторий

2. Сохранить данные в репозитории на диск

В результате код менеджера сохранений будет выглядеть так:

public sealed class SaveLoadManager : MonoBehaviour
{
    private readonly MoneySaveLoader moneySaveLoader = new();
    // private readonly ResourceSaveLoader resourceSaveLoader = new();
    // private readonly MissionSaveLoader missionSaveLoader = new();
    
    [ContextMenu("Load Game")]
    public void LoadGame()
    {
        Repository.LoadState();
        this.moneySaveLoader.LoadData();
        //this.resourceSaveLoader.LoadData();
        //this.missionSaveLoader.LoadData();
    }

    [ContextMenu("Save Game")]
    public void SaveGame()
    {
        this.moneySaveLoader.SaveData();  
        //this.resourceSaveLoader.SaveData();
        //this.missionSaveLoader.SaveData();
        Repository.SaveState();
    }
}

На самом деле, такая архитектура класса будет хрупкая, потому что при добавлении новых сохраняемых фич в игру, придется везде прописывать строчки с их адаптерами...

Поэтому лучшим вариантом будет определить универсальный интерфейс ISaveLoader, который будет загружать и сохранять данные:

//Интерфейс адаптера
public interface ISaveLoader
{
    void LoadData();
    void SaveData();
}


//Теперь менеджер сохранений будет работать через интерфейс
public sealed class SaveLoadManager : MonoBehaviour
{
    private readonly ISaveLoader[] saveLoaders = {
        new MoneySaveLoader(), //В идеале юзать DI
    };
    
    public void LoadGame()
    {
        Repository.LoadState();

        foreach (var saveLoader in this.saveLoaders)
        {
            saveLoader.LoadData();
        }
    }

    public void SaveGame()
    {
        foreach (var saveLoader in this.saveLoaders)
        {
            saveLoader.SaveData();
        }
        
        Repository.SaveState();
    }
}

Таким образом, менеджер будет работать через интерфейс ISaveLoad, тем самым соблюдать принцип Open-Closed, а адаптер сохранения денег будет реализовывать этот интерфейс:

public sealed class MoneySaveLoader : ISaveLoader {
    //Same code...
}

Сохранение миссий

Теперь давайте предположим, что в нашей игре добавились миссии, которые тоже нужно сохранять и загружать. Аналогично логике сохранения денег, диаграмма сохранения миссий будет идентичной:

MissionsSaveLoader — точно такой же адаптер, который будет обеспечивать взаимодействие загрузки и сохранения с репозиторием.

//Данные для сохранения одной миссии
[Serializable]
public struct MissionData
{
    public string id;
    public MissionDifficulty difficulty;
    public float progress;
}

//Адаптер для сохранения миссий:
public sealed class MissionsSaveLoader : ISaveLoader
{
    void ISaveLoader.SaveData()
    {
        //Берем активные миссии из игры:
        Mission[] missions = MissionsManager.Instance.GetMissions();
        int count = missions.Length;

        //Преобразуем миссии в данные:
        MissionData[] dataSet = new MissionData[count]; 
        for (int i = 0; i < count; i++)
        {
            Mission mission = missions[i];
            dataSet[i] = new MissionData
            {
                id = mission.Id,
                difficulty = mission.Difficulty,
                progress = mission.GetProgress()
            };
        }

        //Сохраняем массив миссий в репозиторий:
        Repository.SetData(dataSet);
    }

    void ISaveLoader.LoadData()
    {
        //Загружаем сохраненные миссии в виде данных:
        MissionData[] dataSet = Repository.GetData<MissionData[]>();
        int count = dataSet.Length;
        
        for (int i = 0; i < count; i++)
        {
            MissionData data = dataSet[i];

            //Создаем миссию на основе данных
            Mission mission = MissionFactory.Instance.Create(data);

            //Вставляем миссию в игру
            MissionsManager.Instance.SetMission(data.difficulty, mission);
        }
    }
}

Подключаем MissionsSaveLoader в менеджер миссий:

public sealed class SaveLoadManager : MonoBehaviour
{
    private readonly ISaveLoader[] saveLoaders = {
        new MoneySaveLoader(),
        new MissionsSaveLoader() //Добавляем адаптер миссий
    };

    //Same code...
}

Собрав систему у себя в проекте, убедился, что все работает.

Таким образом, постарался показать, как можно сделать универсальную систему сохранения данных в игре. Самое приятное, что классы Repository, SaveLoaderManager и ISaveLoader можно переиспользовать в других проектах, особенно в простых казуалках. Если у вас возникло желание пощупать получившееся решение, то вы можете скачать архив с проектом.

На этом у меня все. Спасибо за внимание :)


Рекомендую посетить вторую часть занятия, посвещенного соданию Top-Down шутера на Unity с нуля. На ней участники добавят NPC и интерактивные объекты на уровень. Записаться можно на странице курса базового курса по Unity-разработке.

Первая часть мастер-класса доступна в записи по ссылке.

Комментарии (3)


  1. Suvitruf
    19.06.2023 16:42

    Система сохранения на Unity

    PlayerPrefs

    Настоятельно не рекомендую этого делать. Для сейвов используйте Application.persistentDataPath.


    1. StarKRE Автор
      19.06.2023 16:42

      Почему не использовать PlayerPrefs?


      1. Suvitruf
        19.06.2023 16:42
        +4

        1. Сами разработчики Unity не рекомендуют. PlayerPrefs для небольших вещей ок, но не для сейвов.

        2. Не настроить на них тот же Стимовский клауд сейв.

        3. Если нужно будет попросить юзера скинуть сейв в случае проблем, то замучаетесь объяснять.

        4. А если у вас webgl игра, то там ещё и ограничение на 1mb.