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