Современное хранение игрового прогресса в контексте Unity

Современное хранение игрового прогресса в контексте Unity

Вроде бы в интернете и так много контента по этой теме, но я до сих пор часто встречаю вопросы на тему сохранения прогресса. Обычно все хотят получить быстрое готовое решение для своих текущих проблем и не разбираться с их сутью. Многие материалы в сети это и дают – конкретные сухие простые решения, которые ломаются о будущий внезапно изменившийся контекст, порождая новые итерации тех же вопросов, но уже с новыми вводными.

Часто начинающие разработчики мыслят слишком конкретно и решают конкретные задачи, пытаясь затем из частных случаев собрать что-то общее. И это не всегда оборачивается успехом.
Более опытные коллеги оперируют абстракциями, на которые наращивают уже те решения, которые потребует от них ситуация. Они стремятся не к универсальности, а к возможности быстро что-то поменять в своих решениях. Задача сохранения прогресса как раз из тех, где это очень кстати.

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

  • Для чего нужна система сохранений?

  • Какие задачи решает система сохранений?

  • Какие процессы происходят внутри?

  • Как реализовать систему программно?

  • Какие варианты реализаций существуют?

  • Как обеспечить гибкость и масштабируемость?

  • Зачем и когда нужны гибкость и масштабируемость?

В процессе ответов должно получиться решение, которое можно было бы использовать в качестве отправной точки для реализации системы сохранений на разнообразных проектах. Замечу, что речь не про какое-то универсальное решение. Скорее про набросок для дальнейшего развития.

⚠ Дисклеймер: ⚠

- Примеры реализаций, приведённые в статье, будут из контекста Unity и будут именно примерами: упрощёнными и укороченными.

- В примерах используются те типы данных, которые просто показались именно мне удобнее для примера. Я не заявляю, что условный int лучше string и наоборот.

Следить за выходом новых статей и другого контента можно в моём блоге на VK / Telegram / Dtf.

Необходимость в системе сохранений

Схема процессов запуска, работы и закрытия игры  

Запуская ранее установленную игру, ОС выделяет под эту игру определённый объём оперативной памяти. Для плавной работы игра "переносит" необходимый контент (текстуры, звуки, модели и т.д.) из медленной постоянной памяти в намного более быструю оперативную. Т.к. оперативной памяти сильно меньше, то "переносится" не весь контент сразу, а только актуальная в данный момент часть, заменяя собой предыдущую.

В процессе игры происходят различные вычисления, которые порождают внутриигровые данные. Эти данные хранятся в оперативной памяти, как и все прочие программные структуры: позиции игровых объектов, текущее количество здоровья у персонажа, количество полученного и нанесённого урона, и др.

Закрывая игру, ОС освобождает выделенную под игру оперативную память, чтобы перераспределить ресурсы другим приложениям. Т.е. всё, что игра хранила в оперативной памяти, очищается.

Статический контент, загруженный из постоянной памяти в оперативную, потерять не страшно — его оригиналы всё равно хранятся в постоянной памяти. Динамический контент, в виде накопленных данных и игрового прогресса, существует только в оперативной памяти.Если его потерять, то при следующем запуске игры придётся всё начинать с самого начала, в лучших традициях ретро-гейминга. Что для современных игр, которые могут требовать порой и 100ч для прохождения, будет непростительно.

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

Задачи системы сохранений

Основные задачи:

  • Сохранение игровых данных;
  • Хранение игровых данных между игровыми сессиями;
  • Загрузка в приложение ранее сохранённых игровых данных.

Вытекающие возможности:

  • Прерывание игры и возвращение к ней позже, продолжая прохождение с того же места;
  • Возвращение к определённому моменту в игре для повторного прохождения;
  • Продолжение прохождения игры на другом устройстве;
  • Предоставление состояния игры разработчикам для воспроизведения и оперативного устранения проблемы.

Современные особенности системы сохранений

1. Мульти-устройство:

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

2. Кросс-платформа:

Возможность играть в игру с разных платформ — пока не массовая история, но активно набирающая обороты. По дороге домой поиграл в игру на телефоне, а дома — уже раскинулся на диване за приставкой. Кайф для пользователя, но дополнительный геморрой для разработчика.

3. Бэкап:

Раньше о сохранности своих сохранений приходилось заботиться самому. Сейчас игры или площадки, на которых эти игры находятся, помогают не потерять данные, если игра была удалена или устройство, с которого игрок играл, было потеряно/сломано/и т.д. Войди под своим аккаунтом и продолжай играть в любимую игру.

4. Интернет:

Описанные выше возможности реализуются, в основном, благодаря облачным технологиям. И они невозможны при отсутствии интернета. Чтобы игрок, имеющий сложности с подключением, мог поиграть, нужно обеспечивать поддержку локальных сохранений, которые готовы отправиться в облако при первой появившейся возможности. И ещё нужно уметь решать конфликты, если игрок без интернета накопил разного прогресса на разных устройствах.

5. Версионность:

Сервисная и F2P модели очень популярны и требуют долгого цикла поддержки с постоянным добавлением нового контента и фичей. Новые версии выходят регулярно и достаточно часто. Также этим процессам свойственно A/B-тестирование. В игре с каждой новой версией могут происходить изменения в данных для сохранений. Не все игроки обновляют игру последовательно, поэтому они могут пропустить несколько версий. Вне зависимости от того, как пользователь обновляет игру, он не должен терять свой прогресс. Для этого необходима поддержка совместимости между версиями и возможность актуализации сохранений.

Положение в проекте системы сохранений

Схема проекта в виде трёх слоёв  
Схема проекта в виде трёх слоёв  

С технической точки зрения игра – это просто данные и операции над ними. Всё, что есть в игре, описывается данными. Всё, что происходит в игре, описывается операциями над данными. Всё, с чем физически взаимодействует игрок, реализуется через разнообразные устройства ввода и вывода.

Поэтому игровой проект можно условно представить в виде трёх слоёв:

  • Слой данных: непосредственно данные игры в оперативной памяти, которые полностью определяют текущее состояние игры.
  • Слой логики: взаимодействие с данными и их изменение.
  • Слой представления: реализация восприятия игры и получение ввода от пользователя.

Система сохранений находится в слое логики и является неким проводником между логикой и хранилищем данных в постоянной памяти.Если нужно что-то сохранить, то логика собирает нужные данные и через систему переправляет в хранилище.Если нужно что-то загрузить, то логика берёт нужную информацию из хранилища и, прогоняя её через систему, раскладывает в слое данных.

Важно отметить, что границы дозволенного у системы сохранений не выходят за пределы слоя логики. С другими слоями общение происходит через представителей слоя логики. Ни данные, ни представление про систему сохранения не знают. Тогда при форс-мажоре из-за системы пострадает только один слой.

В одном проекте систем сохранений может быть несколько. Одна система (самая важная) может отвечать за данные внутриигрового прогресса. Вторая – за настройки игры. Третья – за что-нибудь ещё.Например, она может сохранять какие-нибудь визуальные данные по типу "игрок открыл вкладку N раз", которые не влияют на геймплей, но важны для отрисовки.

Для удобства такую систему может захотеться перенести в слой представления. И порой это может оказаться даже хорошим решением – проекты бывают разные. В этой ситуации важно постараться эту систему ограничить одним слоем. Если систему было решено перенести в слой представления, то она должна оставаться только там и затрагивать как можно меньший контекст.

Операции системы сохранений

Непосредственно система сохранений выполняет всего две операции:

  • Сохранение в хранилище данных постоянной памяти;
  • Загрузка из хранилища данных постоянной памяти.

Триггеры для вызова операции сохранения:

  • Игрок нажал кнопку "Сохранить";
  • Игрок закрывает игру;
  • Игрок достиг чекпоинта;
  • Прошёл таймаут между сохранениями;
  • Внутриигровые данные изменились;
  • И др.

Триггеры для вызова операции загрузки:

  • Игрок нажал кнопку "Загрузить";
  • Игрок запускает игру;
  • Игрок отменяет совершённые действия, и игра откатывается к последнему сохранению;
  • Игра от внешних сервисов получает сигнал о необходимости загрузить данные;
  • И др.

Процессы внутри системы сохранений

Каждая операция является последовательностью определённых этапов:

1. Сохранение:

  • Получение внутриигровых данных;
  • Получение ключа-идентификатора для записи в хранилище данных постоянной памяти;
  • Преобразование внутриигровых данных в форму для записи;
  • Запись в постоянную память.
Схема процесса сохранения данных  
Схема процесса сохранения данных  

2. Загрузка:

  • Получение ключа-идентификатора для считывания из хранилища данных;
  • Считывание информации из хранилища данных;
  • Преобразование считанной информации в запрашиваемые внутриигровые данные;
  • Передача внутриигровых данных в оперативную память.
Схема процесса загрузки данных  
Схема процесса загрузки данных  

Выполнение каждого этапа можно делегировать отдельному модулю системы сохранений:

  • Внутриигровые данные;
  • Сериализация;
  • Провайдер ключей;
  • Хранилище данных.

То, как именно будут реализованы эти модули, зависит от требований проекта и предпочтений разработчика. Однако сама система сохранений включает в себя 2 операции и 4 модуля.

Реализуя систему в очередном проекте, необходимо ответить на вопросы:

  • Какие внутриигровые данные будут передаваться через систему сохранений?
  • В каком виде внутриигровые данные будут сохранены в постоянной памяти?
  • Как идентифицировать сохранения, чтобы загружать нужные данные?
  • Где расположена постоянная память и как данные будут храниться в ней?

Ответы на эти вопросы определят, как будут реализованы модули. Если проекту необходима гибкость или масштабируемость, модули следует разработать как подключаемые стратегии, которые можно будет изменять во время игры или в процессе разработки.

В чём заключается гибкость:
Игра может работать в разных режимах: продуктовый, для внутреннего тестирования, для внешнего тестирования, для работы в редакторе и т.д. Для каждого из этих режимов ответы на вышепоставленные вопросы могут быть разными. Поэтому в зависимости от режима может потребоваться подключение различных реализаций описанных модулей.

В чём заключается масштабируемость:
Игра со временем может расширять список поддерживаемых платформ. Или начать поддерживать несколько игровых аккаунтов. Или, ранее имея только локальные сохранения, начать использовать преимущества облаков. Или что-то ещё. Всё это вынуждает отвечать на поставленные ранее вопросы иным образом. А значит и использовать новые реализации.

Далее рассмотрим каждый модуль более подробно и попробуем их собрать в одну систему.

Черновая реализация системы сохранений

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

Учитывая это, имеем следующую абстракцию:

public interface ISaveSystem { UniTask SaveAsync<TData>(TData data); UniTask<TData> LoadAsync<TData>(); }

Учитывая описанные ранее процессы, реализуем черновик системы:

public sealed class SaveSystem : ISaveSystem { public async UniTask SaveAsync<TData>(TData data) { string dataKey = GetKey<TData>(); string serializedData = await SerializeAsync(data); await WriteToDataStorageAsync(dataKey, serializedData); } public async UniTask<TData> LoadAsync<TData>() { string dataKey = GetKey<TData>(); string serializedData = await ReadFromDataStorageAsync(dataKey); return await DeserializeAsync<TData>(serializedData); } ... }

Внутриигровые данные

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

Это могут быть:

  • Настройки игры;
  • Прогресс игрока;
  • Состояние игрового мира;
  • Информация о действиях игрока;
  • И др.

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

В оперативной памяти данные существуют как некоторые программные структуры. Их количество и наполнение определяется непосредственно разработчиком:

1. Все данные об игре хранятся в одной общей структуре и сохраняются вместе:

[System.Serializable] public sealed class GameData { public PlayerData Player; public CampaignData Campaign; } public async UniTask SaveGameAsync() { PlayerData playerData = new(); CampaignData campaignData = new(); GameData gameData = new(playerData, campaignData); await _saveSystem.SaveAsync(gameData); }

2. Все данные об игре сгруппированы в несколько независимых самостоятельных структур, которые сохраняются независимо друг от друга:

[System.Serializable] public sealed class PlayerData { public float Hp; public string Name; } [System.Serializable] public sealed class CampaignData { public int Level; public bool HardcoreMode; } public async UniTask SaveGameAsync() { PlayerData playerData = new(); CampaignData campaignData = new(); await _saveSystem.SaveAsync(playerData); await _saveSystem.SaveAsync(campaignData); }

3. Каждый параметр игры является самостоятельной независимой от других структурой и сохраняется отдельно от других:

public sealed class GameController { private float _playerHp; private string _playerName; private int _campaignLevel; private bool _campaignHardcoreMode; public async UniTask SaveGameAsync() { await _saveSystem.SaveAsync(_playerHp); await _saveSystem.SaveAsync(_playerName); await _saveSystem.SaveAsync(_campaignLevel); await _saveSystem.SaveAsync(_campaignHardcoreMode); } }

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

Чтобы в систему сохранений не попадало ничего лишнего, можно структуры, участвующие в процессе сохранения, маркировать специальным интерфейсом. Тогда контракт системы сохранений можно доработать с учётом ограничений:

public interface ISaveSystem { UniTask SaveAsync<TData>(TData data) where TData : ISaveData; UniTask<TData> LoadAsync<TData>() where TData : ISaveData; }

Использование интерфейсов также позволяет добавить контракт на реализацию обязательных свойств у таких структур.

Например, я часто использую свойства:

  • Version: номер версии структуры для систем патчинга (когда нужно поменять данные внутри структуры) и миграции (когда нужно поменять сигнатуру структуры).
  • Timestamp: временная отметка последнего изменения в структуре для разрешения конфликтов между несколькими версиями сохранений (например, одна — локальная, другая — из облака).
public interface ISaveData { int Version { get; } string Timestamp { get; } }

Сериализация

Сериализация — это процесс преобразования структуры данных в формат, который может быть записан в постоянную память или передан по сети. Это позволяет воссоздать (десериализовать) исходную структуру данных в другом месте или в другой момент времени.

Пример соответствия структуры её сериализованной версии  
Пример соответствия структуры её сериализованной версии  

Соответственно у модуля сериализации только две операции: сериализация и десериализация.Поскольку данных может быть много, а алгоритмы сериализации могут быть затратными, целесообразно считать эти операции асинхронными.Для записи в постоянную память чаще всего используется формат строки.

Учитывая эти условности, сформируем абстракцию для модуля:

public interface ISerializer { UniTask<string> SerializeAsync<TData>(TData data); UniTask<TData> DeserializeAsync<TData>(string serializedData); }

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

  • JSON;
  • YAML;
  • XML;
  • BSON;
  • Binary.

Есть другие. И есть проприетарные in-house решения.

Примеры форматов сериализации  
Примеры форматов сериализации  

Для популярных форматов существуют разнообразные готовые библиотеки. У каждой есть свои требования к сериализуемым данным. Но наиболее общим является использование атрибута [System.Serializable] для сериализуемых структур (он же используется и для инспектора в Unity).

Способ сериализации — это то, что обычно меняется в зависимости от режима сборки. Для внутреннего тестирования удобнее использовать одни форматы. Для продуктовых версий — более защищённые варианты. Обеспечение гибкости в этом вопросе потребуется с наибольшей вероятностью.

Binary:

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

Пример реализации:

public sealed class BinarySerializer : ISerializer { public UniTask<string> SerializeAsync<TData>(TData data) { BinaryFormatter formatter = new(); MemoryStream stream = new(); formatter.Serialize(stream, data); stream.Position = 0; byte[] buffer = new byte[stream.Length]; stream.Read(buffer, 0, buffer.Length); string serializedData = Encoding.UTF8.GetString(buffer); return UniTask.FromResult(serializedData); } public UniTask<TData> DeserializeAsync<TData>(string serializedData) { byte[] buffer = Encoding.UTF8.GetBytes(serializedData); MemoryStream stream = new(buffer); BinaryFormatter formatter = new(); var data = (TData)formatter.Deserialize(stream); return UniTask.FromResult(data); } }

JSON:

Этот формат сыскал наибольшую популярность. Он более эффективен и удобен для чтения, чем XML. И имеет больше готовых решений, чем YAML.

В Unity есть встроенный инструмент JsonUtility, который доступен сразу "из коробки". Однако он достаточно ограничен и использует те же правила сериализации, что сериализатор для ассетов внутри движка.
Т.е. он умеет сериализовывать ровно то, что можно отрисовать в инспекторе. А значит многие сложные структуры данных типа Dictionary не пройдут (но это можно решить через ISerializationCallbackReceiver).

Пример реализации:

public sealed class JsonUtilitySerializer : ISerializer { public UniTask<string> SerializeAsync<TData>(TData data) { string json = JsonUtility.ToJson(data); return UniTask.FromResult(json); } public UniTask<TData> DeserializeAsync<TData>(string json) { var data = JsonUtility.FromJson<TData>(json); return UniTask.FromResult(data); } }

Популярной альтернативой является библиотека Newtonsoft, которая предоставляет значительно больше возможностей. Unity даже какое-то время назад добавили её в UPM. Но сейчас я её уже там не нашёл, поэтому придётся добывать библиотеку из NuGet, как раньше. К счастью, никаких лишних зависимостей она за собой не тянет.

Newtonsoft в UPM  
Newtonsoft в UPM  

Пример реализации:

public sealed class NewtonsoftSerializer : ISerializer { public UniTask<string> SerializeAsync<TData>(TData data) { string json = JsonConvert.SerializeObject(data); return UniTask.FromResult(json); } public UniTask<TData> DeserializeAsync<TData>(string json) { var data = JsonConvert.DeserializeObject<TData>(json); return UniTask.FromResult(data); } }

Шифрование:

Шифрование сохранённых данных является популярной техникой для защиты данных от несанкционированного чтения или изменения.

Часто этот алгоритм используют как декоратор над другим сериализатором. Т.е. сначала перегоняем в JSON, а потом эти данные шифруем.

Для дешифровки необходимо знать алгоритм и пароль, при помощи которых проводилось шифрование. Эти данные известны самому приложению или узлу, с которого клиент получает данные. Соответственно, ни игрок, ни кто-либо другой в рядовой ситуации не сможет ни прочитать, ни что-то с данными сделать. Если только нарушить целостность и "сломать".

Само собой из-за дополнительных вычислительных издержек и нечитабельности этот трюк используют обычно только для продуктовых версий.

Пример зашифрованных сохранений:

SF0KeJbP+sX207x4d78frBrNN20lPDUumXktlq5dnFk0xz+xcqWaqxOhj7xrtgQZ9irh/GiJNVVtktVT9pJh0VJN8rK2KX3W2LDPHCDxQwcA/g7epwVVhhlI8bwyTs8pETfOhFbSJ5rihCehqvecww==

Пример реализации:

public sealed class AesSerializer : ISerializer { private readonly ISerializer _baseSerializer; private readonly IPasswordProvider _passwordProvider; public AesSerializer(ISerializer baseSerializer, IPasswordProvider passwordProvider) { _baseSerializer = baseSerializer; _passwordProvider = passwordProvider; } public async UniTask<string> SerializeAsync<TData>(TData data) { string password = _passwordProvider.Provide<TData>(); string serializeData = await _baseSerializer.SerializeAsync(data); return AesEncryption.Encrypt(serializeData, password); } public UniTask<TData> DeserializeAsync<TData>(string encryptedData) { string password = _passwordProvider.Provide<TData>(); string decryptedData = AesEncryption.Decrypt(encryptedData, password); return _baseSerializer.DeserializeAsync<TData>(decryptedData); } }

Ключ-идентификатор

Чтобы отличать одни данные от других, нужен некий идентификатор, он же ключ. В качестве такого ключа может выступать название файла с сохранениями, путь до него или какое-то другое уникальное обозначение.

По указанному ключу система сохранений сохраняет входящие данные. И по этому же ключу обратно их возвращает.

Самый простой способ указать ключ — это передавать его в качестве аргумента:

public interface ISaveSystem { UniTask SaveAsync<TData>(string key, TData data); UniTask<TData> LoadAsync<TData>(string key); }

Это подойдёт, если есть необходимость сохранять несколько независимых однотипных структур, которые нужно как-то друг от друга отличать.
Например, нужно сохранить HealthData игрока и HealthData противника как отдельные структуры:

HealthData playerHealthData = new(); HealthData enemyHealthData = new(); await _saveSystem.SaveAsync("PlayerHealthData", playerHealthData); await _saveSystem.SaveAsync("EnemyHealthData", enemyHealthData);

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

В примере выше можно тоже обойтись без явной передачи ключа. HealthData игрока не существует отдельно от самого игрока, у которого наверняка есть и другие параметры. Все эти параметры можно объединить в PlayerData.
HealthData противника тоже наверняка связана с неким EnemyData, который является частью WorldData.
WorldData и PlayerData, в обобщённом случае, существуют в единственном экземпляре. Поэтому можно их и сохранить:

HealthData playerHealthData = new(); PlayerData playerData = new(playerHealthData); HealthData enemyHealthData = new(); WorldData worldData = new(enemyHealthData); await _saveSystem.SaveAsync(playerData); await _saveSystem.SaveAsync(worldData);

Тогда сформировать ключ можно следующим образом:

private static string GetKey<TData>() => typeof(TData).Name;

Неизменяемость ключа:

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

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

1. Свойство:

В абстракцию ISaveData можно добавить свойство Key:

public interface ISaveData { string Key { get; } }

Тогда для получения ключа нужно иметь какой-то экземпляр структуры. А такой экземпляр есть только при сохранении (см. аргумент ISaveSystem.SaveAsync). На загрузке есть только тип.

Можно создавать заглушечные экземпляры (и даже кэшировать их), но это лишние аллокации и даже звучит как "костыль". Также не любые экземпляры можно легко и просто создать вручную из-за доп. зависимостей, приватных конструкторов или иных осложнений.

Из плюсов — не удастся забыть указать ключ, ведь компилятор об этом тут же напомнит, т.к. это свойство требует интерфейс.

2. Константа:

Для каждой реализации ISaveData можно объявить внутреннюю константу:

[Serializable] public sealed class GameData : ISaveData { public const string Key = "GameData"; public PlayerData PlayerData; public CampaignData CampaignData; }

Но тогда для её получения нужно заранее знать тип, из которого эту константу можно достать. Есть простые системы, где под сохранение каждой структуры заведён отдельный метод — для таких решений константа подойдёт как нельзя кстати.

В этой статье мы рассматриваем обобщённый случай, где тип узнаем динамически, во время выполнения. И здесь не удастся обойтись без рефлексии, а это не желательно для Runtime-кода игры.

private static string GetKey<TData>() { Type type = typeof(TData); FieldInfo fieldInfo = type.GetField("Key", BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy)!; return (string)fieldInfo.GetValue(null); }

Если такую константу забыть завести, то узнать об этом удастся только на этапе выполнения (если нет специализированного статического анализа кода), когда приложение выбросит исключение, что константа "Key" не найдена.

3. Атрибут:

Для каждой реализации ISaveData можно указать кастомный атрибут:

[Serializable, SaveDataKey("game_data")] public sealed class GameData : ISaveData { public PlayerData PlayerData; public CampaignData CampaignData; }

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

4. Провайдер:

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

public interface IKeysProvider { string Provide<TType>(); IEnumerable<string> ProvideAll(); } public sealed class SaveDataKeysProvider: IKeysProvider { private readonly IReadOnlyDictionary<Type, string> _map = new Dictionary<Type, string> { { typeof(PlayerData), "PlayerData" }, { typeof(CampaignData), "CampaignData" }, }; public string Provide<TData>() => _map[typeof(TData)]; public IEnumerable<string> ProvideAll() => _map.Values; }

По началу такое решение может показаться оверинженирингом. Но в непосредственно использовании так не ощущается и даёт некоторый прирост в гибкости. Если в проекте про гибкость речи не идёт и рефлексия не внесёт импакта, то, конечно, можно обойтись и без этого.

Провайдер позволяет:

  • Явно выразить намерение хранить ключ как отдельную от типа, но связанную с ним, строку;
  • Явно выразить получение ключа как отдельный этап и модуль в системе сохранения;
  • Централизованно хранить и получать список всех ключей;
  • Удобно и динамически подменять стратегии формирования ключей, например, при смене типа хранилища данных;
  • Разграничивать доступ к ключам через наличие нескольких провайдеров, каждый из которых используется в своей специализированной системе сохранений, если их несколько.

Мульти-пользователь:

Одной из фич, которую позволяет реализовать провайдер ключей, является реализация поддержки нескольких пользователей.

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

Решение: задекорировать провайдер ключей и модифицировать ключ идентификатором пользователя. Грубо говоря, к обычному ключу приклеить лычку конкретного игрока. Если хранилище данных — это файловая система, то таким образом можно хранить сохранения пользователей в разных папках.

Пример реализации декорированием префиксом:

public sealed class KeysProviderPrefixDecorator : IKeysProvider { private readonly string _prefix; private readonly IKeysProvider _baseProvider; public KeysRepositoryPrefixDecorator(string prefix, IKeysProvider baseProvider) { _prefix = prefix; _baseProvider = baseProvider; } public string Provide<TType>() => _prefix + _baseProvider.Provide<TType>(); public IEnumerable<string> ProvideAll() => _baseProvider.ProvideAll().Select(key => _prefix + key); }

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

Хранилище данных

Это непосредственно место, где данные хранятся между сессиями вне оперативной памяти. Оно бывает локальным и удалённым.

Примеры локальных сохранений  
Примеры локальных сохранений  

Локальные хранилища данных:

  • Расположены непосредственно на самом устройстве, на котором запускается игра.
  • Файловая система ОС или разнообразные реализации Баз Данных на устройстве.

Удалённые хранилища данных:

  • Расположены где-то вне устройства, на котором запускается игра.
  • Файловая система ОС или разнообразные реализации Баз Данных на сервере или специализированный облачный сервис типа Playfab, Unity CloudSave, GamePush и др.

Удалённые хранилища обеспечивают следующие преимущества:

  • Возможность игры с разных устройств и платформ;
  • Оперативная поддержка со стороны разработчиков, т.к. они могут получить доступ к данным и тут же их исправить, если проблема была в данных;
  • Защита данных от потери, взлома или мошенничества.

Удалённые хранилища имеют некоторые особенности:

  • Нужна авторизация (хотя бы по deviceId), чтобы идентифицировать игроков;
  • Значительно более долгие операции с хранилищем;
  • Нужен интернет;
  • Аренда/покупка сервера или тарификация на запросы и размер данных у сервисов;
  • Игрок не сможет сам сбрасывать прогресс, если в самой игре не предусмотрена такая опция.

Базовый набор операций:

  • Загрузить по ключу;
  • Записать данные по ключу;
  • Удалить данные по ключу;
  • Проверить наличие данных по ключу.

Окончательный набор операций формируется для каждого проекта индивидуально. Где-то из операций вовсе нужно только запись и чтение, где-то требуются реализации для множества ключей сразу, где-то идёт работа с разного рода мета-данными (права доступа, время изменения, время создания и пр.).

Операции с хранилищем данных, особенно удалённым, затратны по времени, поэтому их стоит делать асинхронными по умолчанию.

Учитывая всё это, получаем следующую абстракцию:

public interface IDataStorage { UniTask<string> ReadAsync(string key); UniTask WriteAsync(string key, string serializedData); UniTask DeleteAsync(string key); UniTask<bool> ExistsAsync(string key); }

Разные платформы имеют разные ограничения и особенности, поэтому для разных платформ могут требоваться различные типы хранилища.
Для работы в редакторе часто не целесообразно использовать удалённое хранилище, особенно если его использование тарифицируется. Поэтому в этом режиме подключается только локальное.
Для продуктовых сборок используется комбинация из локального и удалённого.
А для тестовых, чтобы оперативно тестировать данные в удалённом хранилище, подключают только удалённые.

Возможность менять реализацию хранилища данных перед сборкой — очень полезное следствие гибкости.

Локальные хранилища данных в Unity. Файловая система:

Пример реализации:

public sealed class FileSystemDataStorage : IDataStorage { private readonly string _folderPath; private readonly string _fileExtension; public FileSystemDataStorage(string folderPath, string fileExtension) { _folderPath = folderPath; _fileExtension = fileExtension; } public UniTask<bool> ExistsAsync(string key) { string filePath = GetFilePath(key); bool exists = File.Exists(filePath); return UniTask.FromResult(exists); } public UniTask DeleteAsync(string key) { string filePath = GetFilePath(key); File.Delete(filePath); return UniTask.CompletedTask; } public async UniTask<string> ReadAsync(string key) { string filePath = GetFilePath(key); return await File.ReadAllTextAsync(filePath); } public async UniTask WriteAsync(string key, string serializedData) { string filePath = GetFilePath(key); await File.WriteAllTextAsync(filePath, serializedData); } private string GetFilePath(string key) => Path.Combine(_folderPath, key) + "." + _fileExtension; }
  • Подходящий вариант для объёмного User Generated Content.
  • На ряде Android-смартфонов от пользователя требуется дополнительное разрешение на доступ к файловой системе. Пользователи такие разрешения давать не любят. Тогда прогресс не сохранится. А это — удаление игры и гневный отзыв.
  • Для WebGL этот вариант не подойдёт, т.к. доступа к файловой системе браузер не предоставляет (или я пока о таких хитростях ещё не знаю). Есть вариант с LocalStorage на стороне JavaScript, но это уже другая история.
  • У Unity есть свойство Application.persistentDataPath — это предустановленный путь до директории, где можно легально хранить данные, которые не будут удалены при обновлении или переустановки приложения. Для каждой платформы и проекта это свойство имеет свой вариант пути, и Unity сам подставит финальную реализацию.
  • В самом редакторе вместо Application.persistentDataPath удобнее использовать Application.dataPath — это путь до рабочего проекта. Но записывать туда можно только в режиме редактора. Так что при сборке путь нужно заменить.
string storageFolder = Application.isEditor ? Application.dataPath : Application.persistentDataPath; IDataStorage storage = new FileSystemDataStorage(storageFolder);

Локальные хранилища данных в Unity. PlayerPrefs:

Пример реализации:

public sealed class PlayerPrefsDataStorage : IDataStorage { public UniTask<string> ReadAsync(string key) { string serializedData = PlayerPrefs.GetString(key); return UniTask.FromResult(serializedData); } public UniTask WriteAsync(string key, string serializedData) { PlayerPrefs.SetString(key, serializedData); return UniTask.CompletedTask; } public UniTask DeleteAsync(string key) { PlayerPrefs.DeleteKey(key); return UniTask.CompletedTask; } public UniTask<bool> ExistsAsync(string key) { bool exists = PlayerPrefs.HasKey(key); return UniTask.FromResult(exists); } }
  • Хранилище по типу ключ-значение.
  • Универсальное и очень простое в использовании.
  • Для каждой платформы PlayerPrefs имеет свою реализацию, и Unity это всё разруливает самостоятельно без участия разработчика.
  • Поддерживает работу с типами int, float и string, но для сериализуемых данных достаточно только string.
  • Не требует специальных разрешений на Android и других платформах.
  • Есть ограничения на размер данных. Но этого достаточно для сохранений, особенно, если они разбиты на несколько ключей. В моей практике даже на насыщенных мидкорных проектах сохранения умещались в PlayerPrefs.
  • Из-за ограничений на размер данных User Generated Content не желательно направлять в данное хранилище.
  • Не везде стабильно работает в WebGL. Для этой платформы Unity внутри использует браузерную IndexedDB. При необходимости можно сделать своё аналогичное более контролируемое решение.

Удалённые хранилища данных в Unity. CloudSave:

Сильно упрощённый пример реализации:

public sealed class CloudSaveDataStorage : IDataStorage { private static IPlayerDataService PlayerService => CloudSaveService.Instance.Data.Player; public async UniTask<string> ReadAsync(string key) { var requestData = new HashSet<string> { key }; Dictionary<string, Item> responseData = await PlayerService.LoadAsync(requestData); return responseData[key].Value.GetAsString(); } public async UniTask WriteAsync(string key, string serializedData) { var requestData = new Dictionary<string, object> { { key, serializedData } }; await PlayerService.SaveAsync(requestData); } public async UniTask DeleteAsync(string key) { await PlayerService.DeleteAsync(key); } public async UniTask<bool> ExistsAsync(string key) { List<ItemKey> responseData = await PlayerService.ListAllKeysAsync(); return responseData.Select(d => d.Key).Any(k => k == key); } }
  • Хранилище по типу ключ-значение.
  • В первых версиях API было очень похоже на PlayerPrefs. Со временем сильно усложнилось, став похожим на другие облачные сервисы.
  • Есть поддержка хранилищ данных, привязанных к конкретным пользователям, и общего хранилища для всего тайтла (если нужно сохранять общее игровое состояние для многопользовательского проекта).
  • Есть достаточный free-tier по кол-ву запросов для бесплатного использования в личных проектах.

Комбинация хранилищ данных:

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

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

Комбинированный подход позволяет сгладить недостатки каждого типа хранилища. От способа комбинации зависит то, какие недостатки и какие достоинства каждого из хранилищ проявят себя сильнее.

Часто под комбинацией имеется в виду подобная схема:

  • На старте приложения получаем актуальные данные из удалённого хранилища;
  • Помещаем данные в локальное хранилище;
  • Работаем в игре с локальным хранилищем;
  • Через N сек или M операций или по спец. событиям отправляем накопленный прогресс в удалённое хранилище.

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

Описанные процессы — весьма упрощены. В проектах разных масштабов используются разнообразные хитрости и усложнения. Обычно они направлены на защиту от разного рода жульничества со стороны игроков, но могут быть и нацелены на решение других проблем.

Накопление чего-то с целью последующей обработки одним большим пакетом — это батчинг. А сам пакет — батч.

public sealed class Batch { private readonly Dictionary<string, string> _writeArgs = new(); private readonly HashSet<string> _deleteArgs = new(); public IEnumerable<KeyValuePair<string, string>> WriteArgs => _writeArgs; public IEnumerable<string> DeleteArgs => _deleteArgs; public void CollectWriteOp(string key, stirng serializedData) { _deleteArgs.Remove(key); _writeArgs[key] = serializedData; } public void CollectDeleteOp(string key) { _writeArgs.Remove(key); _deleteArgs.Add(key); } public void Clear() { _writeArgs.Clear(); _deleteArgs.Clear(); } }

Хранилище, основанное на комбинации подобного рода, будем называть BatchDataStorage. А базовую абстракцию с возможностью переопределения условия отправки батча можно представить в таком виде:

public abstract class BatchDataStorage : IDataStorage { private readonly IDataStorage _hotStorage; private readonly IDataStorage _coldStorage; private readonly Batch _batch = new(); protected BatchDataStorage( IDataStorage hotStorage, IDataStorage coldStorage) { _hotStorage = hotStorage; _coldStorage = coldStorage; } public async UniTask InitializeAsync() { Dictionary<string, string> allData = await _coldStorage.LoadAllAsync(); await _hotStorage.WriteAsync(allData); } public UniTask<string> ReadAsync(string key) => _hotStorage.ReadAsync(key); public UniTask WriteAsync(string key, string serializedData) => _hotStorage.WriteAsync(key, serializedData).ContinueWith(() => { _batch.CollectWriteOp(key, serializedData); OnBatchUpdated(); }); public UniTask DeleteAsync(string key) => _hotStorage.DeleteAsync(key).ContinueWith(() => { _batch.CollectDeleteOp(key); OnBatchUpdated(); }); public UniTask<bool> ExistsAsync(string key) => _hotStorage.ExistsAsync(key); protected abstract void OnBatchUpdated(); protected async UniTask CommitBatchAsync() { foreach ((string key, string serializedData) in _batch.WriteArgs) await _coldStorage.WriteAsync(key, serializedData); foreach (string key in _batch.DeteleArgs) await _coldStorage.DeleteAsync(key); _batch.Clear(); } }

Соответственно из этого можно сделать вариант с накоплением батча за счёт некоторой задержки:

public sealed class DelayedBatchDataStorage : BatchDataStorage { private readonly float _batchDelay; private bool _batchDelayed; public BatchDataStorageWithDelay(IDataStorage hotStorage, IDataStorage coldStorage, float batchDelay) : base(hotStorage, coldStorage) => _batchDelay = batchDelay; protected override void OnBatchUpdated() { if (!_batchDelayed) DelayBatchAsync(); } private async UniTask DelayBatchAsync() { _batchDelayed = true; await UniTask.WaitForSeconds(_batchDelay); await CommitBatchAsync(); _batchDelayed = false; } }

Или вариант с отправкой батча через N операций:

public sealed class LimitedBatchDataStorage : BatchDataStorage { private readonly int _updatesLimit; private int _updatesCounter; public BatchDataStorageWithLimit(IDataStorage hotStorage, IDataStorage coldStorage, int updatesLimit) : base(hotStorage, coldStorage) => _updatesLimit = updatesLimit; protected override void OnBatchUpdated() { _updatesCounter++; if (_updatesCounter >= _updatesLimit) { _updatesCounter = 0; CommitBatchAsync().Forget(); } } }

Существует множество вариантов условий отправки батча или комбинаций использования локального и удалённого хранилищ. Главное, что потребуется, это настроенный контракт в виде IDataStorage, который можно заменить на локальное, удалённое или любое другое хранилище.

Собираем всё вместе

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

public sealed class SaveSystem : ISaveSystem { private readonly ISerializer _serializer; private readonly IDataStorage _dataStorage; private readonly IKeysProvider _keysProvider; public SaveSystem(ISerializer serializer, IDataStorage dataStorage, IKeysProvider keysProvider) { _serializer = serializer; _dataStorage = dataStorage; _keysProvider = keysProvider; } public async UniTask SaveAsync<TData>(TData data) where TData : ISaveData { string dataKey = _keysProvider.Provide<TData>(); string serializedData = await _serializer.SerializeAsync(data); await _dataStorage.WriteAsync(dataKey, serializedData); } public async UniTask<TData> LoadAsync<TData>() where TData : ISaveData { string dataKey = _keysProvider.Provide<TData>(); string serializedData = await _dataStorage.ReadAsync(dataKey); return await _serializer.DeserializeAsync<TData>(serializedData); } }

За счёт использования абстракций и принципа внедрения зависимостей есть возможность задавать различные варианты поведения, не меняя при этом саму систему, которая в целом сохранила свою простоту: в каждой операции по 3 действия с 3 модулями на 3 строчки.

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

Реализация какого-нибудь инструмента конфигурирования для системы позволяет наделать несколько пресетов зависимостей под различные режимы сборки и на этапе загрузки DI-фреймворком это всё собрать и запустить.

Примеры конфигурирования режимов сборки  
Примеры конфигурирования режимов сборки  

Заключение

Приведённый вариант реализации — тепличный и демонстрационный. Можно ли его заиспользовать — можно. Но нужно ли?

Условия задачи построения системы сохранений на каждом проекте различаются. В зависимости от этих условий итоговая система может оказаться как сильно проще, так и сильно сложнее.
Может проекту и не нужны ни облака, ни гибкость, ни масштабируемость, и прямого использования PlayerPrefs хватает "за глаза". И это нормально, если условия позволяют. Простое решение — лучшее решение.

Пытаться строить универсальную систему на все случаи жизни — на мой взгляд, дело гиблое. Получится дорогой монстр (если вообще получится).
В этой статье я хотел поделиться не готовым решением, а последовательностью мыслей, которые привели к итоговому решению.

Важно, из каких элементов состоит создаваемая система, как они друг с другом взаимодействуют и как их можно использовать для удовлетворения условий. А условия уже сами продиктуют реализацию.

The end

Контент по теме

  • Репозиторий с материалами из статьи: GitFlic
  • Влияние оперативной памяти на производительность в играх: SkyPro
  • Как работают сохранения в видеоиграх: Dzen.Кодзима Гений
  • Understanding Serialization and Deserialization in C#: shekhall
  • Data Serialization Comparison: JSON, YAML, BSON, MessagePack: SitePoint
  • persistentDataPath Explained: Occa Software
  • Сохранение игры в Unity3D: Habr
  • Implementing Cloud-Based Save Systems: Medium
  • Система сохранения на Unity для начинающих: Habr.Otus
  • Сохранение игры в unity: Youtube.Leksay's Development
  • Unity: Save & Load Data in 5 Minutes: YouTube.Navarone
  • Simple Saving and Loading from a File: YouTube.CodeMonkey
  • Гибкая и расширяемая система сохранений на Unity: YouTube.Otus
1818
11
11
2 комментария

А как же сохранение прогресса в .ini файле?)

Да хоть в `.жпг` 😁

Это всё относится к работе с файловой системой (пример с FileSystemDataStorage). Название или целый путь до файла — это ключ, по которому происходит запись.

Сформированный путь до файла может храниться как готовый ключ в KeysProvider (типа "Game/Save/Data.ini").

Но если помимо этого хранилища в проекте используются другие, то скорее всего ключ будет иметь укороченный формат (типа "Data"), и тогда для реализации с файловой системой потребуется ключ как-то декорировать, чтобы получить полный путь.

Декорировать нужно как: префиксом к ключу (корневая директория) и постфиксом (расширение файла).

Это можно сделать в самой FileSystemDataStorage (см. метод GetFilePath).

Или наделать декораторов для IKeysProvider (пример с KeysProviderPrefixDecorator).

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

1