Привет, Хабр! Меня зовут Игорь, и я 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.
Какие плюсы можно выделить, используя такой репозиторий:
-
Модульность. Можно повторно использовать класс в разных проектах
-
Поддерживаемость. Если вы захотите изменить способ сохранения, например, сохранять данные в файл или на сервер, то нужно всего лишь поменять строчки кода в методах
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:
Пример кода адаптера 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/
Добавить комментарий