Система сохранения на Unity для начинающих

от автора

Привет, Хабр! Меня зовут Игорь, и я 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-разработке.

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


ссылка на оригинал статьи https://habr.com/ru/companies/otus/articles/742400/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *