Современное хранение игрового прогресса в контексте Unity
Несмотря на то, что в интернете уже много контента по теме сохранений для игр, я всё ещё часто встречаю вопросы, связанные с этим. Чаще всего разработчики просто хотят получить быстрое готовое решение, которое закроет их текущие проблемы, не углубляясь в детали. Поэтому многие материалы в сети именно это и дают – конкретные решения, заточенные под конкретные проблемы, которые перестают работать, если контекст как-то существенно изменится. Что в геймдеве происходит постоянно. А это порождает новые итерации вопросов и новые порции контента.
Через несколько таких итераций появляется идея найти или создать инструмент, пригодный для использования в любой ситуации. Чтобы один раз и на всю жизнь. Эта крайность тоже не лишена своих недостатков: слишком универсальные инструменты имеют очень высокую сложность разработки, поддержки, масштабирования и использования.
Более опытные коллеги часто предпочитают работать больше с абстракциями, чем с конкретикой. Это позволяет сначала построить фундаментальное решение для проблемы, разобраться с самой сутью. А потом уже через конкретные реализации настраивать инструмент под конкретные условия использования. И, соответственно, менять, если условия поменяются.
Задача сохранения игрового прогресса очень проста в своей идее, но очень коварна, когда проект начинает бурно расти, развиваться и резко менять траектории своего развития. Такую задачу просто решить на уровне абстракций. И, на мой взгляд, этим нужно пользоваться.
Я хочу попробовать собрать весь свой накопленный за 9 лет карьеры в геймдеве опыт по этому вопросу и простым языком рассказать про все этапы реализации и развития системы сохранения прогресса в игровых проектах, учитывая современные реалии и потребности.
🎯 Назначение системы сохранений
Запуская установленную игру на неком устройстве, ОС выделяет под эту игру определённый объём оперативной памяти. Для плавной работы игра "переносит" необходимый контент (текстуры, звуки, модели и т.д.) из медленной постоянной памяти в намного более быструю оперативную. Т.к. оперативной памяти сильно меньше, то "переносится" не весь контент сразу, а только используемый в данный момент, заменяя собой потерявший актуальность.
В процессе игры происходят различные операции и вычисления, которые генерируют внутриигровые данные, отражающие все изменения. Эти данные хранятся в оперативной памяти, как и все прочие программные структуры: позиции игровых объектов, текущее количество здоровья у персонажа, количество полученного и нанесённого урона, и др.
Закрывая игру, ОС освобождает выделенную под игру оперативную память, чтобы предоставить ресурсы другим приложениям. Т.е. всё, что игра хранила в оперативной памяти, очищается. Статический контент, загруженный из постоянной памяти в оперативную, потерять не страшно: его оригиналы хранятся в постоянной памяти.
Динамический контент, в виде накопленных данных и игрового прогресса, существует только в оперативной памяти. Если его потерять, то при следующем запуске игры придётся всё начинать с самого начала, в лучших традициях ретро-гейминга. Что для большинства современных игр будет непростительно.
Система сохранений позволяет перенести накопленный игровой прогресс в постоянную память, чтобы при следующем запуске игра могла загрузить в оперативную память не только контент, но и последнее актуальное состояние игры.
⚒ Задачи и возможности
Соответственно, система сохранений решает несколько основных задач:
- Сохранение накопленных игровых данных вне игры;
- Хранение игровых данных между игровыми сессиями;
- Загрузка в игру ранее сохранённых игровых данных.
В свою очередь, это открывает ряд полезных возможностей:
- Прерывание игры и возвращение к ней позже, продолжая прохождение с того же места;
- Возвращение к определённому моменту в игре для повторного прохождения;
- Перенос сохранений на другое устройство с возможностью продолжения прохождения игры;
- Предоставление состояния игры разработчикам для воспроизведения и оперативного устранения обнаруженной проблемы.
🚀 Современные особенности
Постоянно возрастающая конкуренция между разработчиками игр вынуждает в борьбе за аудиторию активно внедрять новые технологии, повышающие комфорт и качество игрового опыта пользователей. Однако это же повышает планку технологичности и сложности современных проек��ов.
Необязательно следовать всем современным трендам. Однако отказ от тех или иных возможностей — это потенциальные потери какой-то части аудитории. Игроки быстро привыкают к удобству, и конкуренты с более комфортным сервисом могут переманить вашу аудиторию к себе.
Поэтому в своей нише стоит следить за рынком и, если не быть на "острие технологий", то хотя бы не отставать от "среднего предложения".
1. Мульти-устройство:
Сейчас многие пользователи владеют несколькими персональными игровыми устройствами. Двумя или тремя личными смартфонами уже вряд ли кого-то можно удивить. Но вот тем, что одна и та же игра будет иметь разный прогресс на разных телефонах, неприятно удивить всё же можно современного разбалованного пользователя.
2. Кросс-платформа:
Возможность играть в игру с разных платформ — ещё не массовая история, но активно набирающая обороты. Игрок может в дороге играть на телефоне, а дома уже насладиться полноценным 4К-ПК-геймингом. Устав, перейти на диван и продолжить за игровой приставкой.
3. Бэкап:
Раньше о сохранности своих данных игроку приходилось заботиться самостоятельно. Сломался HDD, а бэкап не сделан – сам виноват. Сейчас игры или площадки, на которых эти игры распространяются, помогают не потерять игровые данные. Достаточно войти под своим аккаунтом и продолжать играть в любимую игру, что бы ни случилось с игровым устройством.
4. Интернет:
Описанные выше возможности реализуются, в основном, благодаря облачным технологиям. И они невозможны при отсутствии интернета. Игроки хотят играть всегда, где бы они ни были. А разработчики хотят, чтобы игроки как можно больше времени проводили в их игре.
Чтобы игрок, едущий, например, в метро и имеющий сложности с подключением, мог играть, нужно обеспечивать поддержку локальных сохранений, которые готовы синхронизироваться с облаком при первой же появившейся возможности.
Также нужно уметь ещё и решать конфликты, если игрок без интернета накопил разного прогресса на разных устройствах.
5. Версионность:
Сервисная и F2P модели очень популярны и требуют долгого цикла поддержки с постоянным добавлением нового контента и фичей. Новые версии выходят регулярно и достаточно часто. Также этим процессам свойственно A/B-тестирование.
В игре с каждой новой версией могут происходить изменения в данных для сохранений. Не все игроки обновляют игру последовательно, поэтому они могут пропустить несколько версий. Вне зависимости от того, как пользователь обновляет игру, он не должен терять свой прогресс. Для этого необходима поддержка совместимости между версиями и возможность актуализации сохранений.
6. Читерство:
Борьба с нечестными игроками — очень большой и отдельный раздел, который не относится напрямую к системе сохранений, но всё же её задевает.
Во многих играх используется система валидации, когда все действия игрока перепроверяются на выделенном сервере. И пока сервер не подтвердит, что всё честно, данные не считаются достоверными и пригодными для финального сохранения.
Однако они всё равно должны быть сохранены в каком-то временном хранилище, чтобы аварийный перезапуск игры не уничтожил накопленный, но ещё не подтверждённый прогресс игрока.
🧩 Организация внутри приложения
С технической точки зрения игра – это просто данные и операции над ними. Всё, что есть в игре, описывается данными. Всё, что происходит в игре, описывается операциями над данными. Всё, с чем физически взаимодействует игрок, реализуется через разнообразные устройства ввода и вывода.
Поэтому игровой проект можно условно представить в виде трёх слоёв:
- Слой данных: непосредственно данные в оперативной памяти, которые полностью определяют текущее состояние игры.
- Слой логики: взаимодействие с данными и их изменение.
- Слой представления: реализация восприятия игры и получение ввода от пользователя.
Поскольку все изменения данных происходят в слое логики, именно этот слой предоставляет больше возможностей для контроля потока данных. Поэтому логичнее всего разместить рычаги управления и саму систему сохранений именно там. Тогда система сохранений станет проводником между логикой и хранилищем данных в постоянной памяти.
Если нужно что-то сохранить, логика собёрет нужные данные и через систему отправит в хранилище. Если нужно что-то загрузить, логика соберёт нужную информацию из хранилища и через систему разместит в слое данных.
Важно, чтобы система сохранений не выходила за пределы слоя логики. Ни данные, ни представление про систему сохранения не знают. Им это и не требуется. Такие ограничения сильно упростят поддержку и повысят отказоустойчивость, т.к. в худшем случае при проблемах с системой пострадает только один слой.
В одном проекте может быть несколько систем сохранений:
- Одна система может отвечать за данные внутриигрового прогресса и синхронизироваться через облако.
- Вторая – отвечать за настройки игры и работать только локально.
- Третья – отвечать за что-нибудь ещё. Например, она может сохранять какие-нибудь визуальные данные по типу "игрок открыл вкладку N раз", которые не влияют на геймплей, но важны для отрисовки UI только на текущем устройстве.
Возможно, в каких-то случаях именно для систем сохранений, которые отвечают только за визуал, может оказаться более удобным решением размещение в слое представления. В этой ситуации важно постараться эту систему ограничить одним слоем.
⚙ Операции и триггеры
Система сохранений выполняет две основные операции:
- Сохранение в хранилище данных постоянной памяти;
- Загрузка из хранилища данных постоянной памяти.
Примеры триггеров для сохранения:
- Игрок нажал кнопку "Сохранить";
- Игрок закрывает игру;
- Игрок достиг чекпоинта;
- Прошёл таймаут между сохранениями;
- Внутриигровые данные изменились.
Примеры триггеров для загрузки:
- Игрок нажал кнопку "Загрузить";
- Игрок запускает игру;
- Игрок отменяет совершённые действия, и игра откатывается к последнему сохранению;
- Игра от внешних сервисов получает сигнал о необходимости загрузить данные.
🔗 Внутренние процессы
Данные в оперативной и постоянной памяти имеют разную форму. Поэтому перед переносом данных из оперативной памяти в постоянную необходимо данные преобразовать в подходящий формат и идентифицировать эти данные так, чтобы была возможность запросить их же обратно позже.
Соответственно, при запросе данных нужно предоставить их идентификатор и конвертировать обратно в формат, пригодный для помещения в оперативную память.
Получается, каждая операция является последовательностью определённых этапов:
1. Сохранение:
- Получение внутриигровых данных;
- Получение ключа-идентификатора для записи в хранилище данных постоянной памяти;
- Преобразование внутриигровых данных в форму для записи;
- Запись в постоянную память.
2. Загрузка:
- Получение ключа-идентификатора для поиска и считывания;
- Считывание информации из хранилища данных;
- Преобразование считанной информации во внутриигровые данные;
- Передача внутриигровых данных в оперативную память.
Каждый отдельный этап реализуется одним из модулей. Всего таких модулей 3:
- Сериализация;
- Идентификация;
- Хранение данных.
И, в целом, это всё, что нужно знать о системе сохранений. Сказочно просто.
Конечно, эти модули весьма абстрактны, и почти для каждого хочется задать вопрос "а как именно". Ответ на этот вопрос для каждого модуля продиктуют проектные требования.
На место каждого модуля можно поставить сколько угодно сложную реализацию или даже цепочку реализаций. И это будет позволять решать вопросы сохранения игрового прогресса, которые встают именно перед вашим проектом. Как в начале проекта, так и на протяжении всего его срока жизни. Ведь сколько угодно сложные реализации можно будет заменить любыми другими, не менее сложными.
Такой абстрактный подход к проблеме обеспечивает достаточную гибкость и возможности для масштабирования.
В чём масштабируемость:
Нет необходимости пытаться прогнозировать и тратить ресурсы на решение всех возможных потенциальных проблем в самом начале. Систему можно дорабатывать постепенно, по мере расширения контекста использования и появления реальных рыночных требований.
В чём гибкость:
Игра может работать в разных режимах: продуктовый, для внутреннего тестирования, для внешнего тестирования, для работы в редакторе и т.д. Для каждого из этих режимов нужно разное поведение при работе с игровыми данными.
Чтобы реализовать систему сохранений, нужно сначала ответить на вопросы:
- Какие внутриигровые данные будут передаваться через систему сохранений?
- В каком виде внутриигровые данные будут сохранены в постоянной памяти?
- Как идентифицировать сохранения, чтобы загружать нужные данные?
- Где расположена постоянная память и как данные будут храниться в ней?
Далее попробуем дать возможные ответы на каждый из этих вопросов и собрать из этих ответов общую систему.
💻 Базовая реализация
Постоянная память медленнее, чем оперативная, поэтому взаимодействие с ней занимает значительно больше времени. Это нужно учесть при проектировании системы сохранений и предусмотреть возможность асинхронного выполнения основных операций. Такая реализация позволит избежать блокировки игры на время работы системы сохранений, оставив возможность игроку продолжать играть или хотя бы понимать, что игра не зависла.
Учитывая имеющиеся вводные, получаем следующую абстракцию:
Теперь создадим черновик реализации и наполним методы этапами выполнения:
⚠ Дисклеймер: в примерах далее используются типы данных, удобные для демонстрации. В своих реализациях вы можете применять любые другие типы данных, подходящие именно под ваши задачи.
📊 Внутриигровые данные
Внутриигровые данные — всё необходимое для того, чтобы игра полностью восстановила своё состояние при следующем запуске.
Примеры:
- Настройки игры;
- Прогресс игрока;
- Состояние игрового мира;
- Информация о действиях игрока;
- И др.
При сохранении данных важно не сохранять избыточные данные. Т.е. сохранять только те, которые невозможно восстановить из других данных. Это позволит уменьшить общий объём данных и избежать неприятных сложноуловимых багов. Похожий принцип работает и при синхронизации данных в мультиплеере, где размер передаваемых данных критически важен.
В оперативной памяти программно внутриигровые данные существуют как структуры данных. Их количество и наполнение определяется непосредственно разработчиком:
1. Все данные об игре хранятся в одной общей структуре и сохраняются вместе:
2. Все данные об игре сгруппированы в несколько независимых самостоятельных структур, которые сохраняются независимо друг от друга:
3. Каждый параметр игры является самостоятельной независимой от других структурой и сохраняется отдельно от других:
Идеального варианта нет. Где-то данных не так много и удобнее использовать одну структуру. Где-то данных слишком много, и каждый раз сохранять их все разом — накладно или даже невозможно из-за физических ограничений хранилища, куда происходит сохранение.
Второй вариант самый универсальный и компромиссный.
Чтобы в систему сохранений не попадало ничего лишнего, можно сохраняемые структуры маркировать специальным интерфейсом.
Тогда контракт системы сохранений можно доработать с учётом ограничений:
Использование интерфейсов также позволяет добавить контракт на реализацию обязательных свойств у таких структур.
Например, могут потребоваться следующие свойства:
- Version: номер версии структуры для систем патчинга (когда нужно поменять данные внутри структуры) и миграции (когда нужно поменять сигнатуру структуры).
- Timestamp: временная отметка последнего изменения в структуре для разрешения конфликтов между несколькими версиями сохранений (например, одна — локальная, другая — из облака).
📦 Сериализация
Сериализация — это процесс преобразования структуры данных в формат, который может быть записан в постоянную память или передан по сети. Это позволяет воссоздать (десериализовать) исходную структуру данных в другом месте или в другой момент времени.
Соответственно у модуля сериализации только две операции: сериализация и десериализация.
Обычно эти операции выполняются синхронно. Но потенциально могут потребоваться какие-нибудь тяжеловесные алгоритмы сериализации, которые будет выгоднее запускать асинхронно. Место использования модуля сериализации позволяет использовать его в асинхронном режиме без особых сложностей, поэтому я воспользуюсь этой возможностью. Но это совсем не обязательно (и очень просто дорабатывается по мере необходимости).
Для записи в постоянную память чаще всего используется строки. Как наиболее универсальный вариант, его и будем использовать дальше. Но это тоже не обязательно.
Учитывая эти условности, сформируем абстракцию для модуля:
Теперь нужно определиться с форматом сериализации. Есть человекочитаемые (текстовые) и машиночитаемые (бинарные). Первые удобны в использовании для человека, но имеют больший объём и медленнее обрабатываются программно. Вторые, соответственно, более компактны и быстрее в обработке, но прочитать такие данные без специальных инструментов не получится.
Стоит заметить, что бинарная сериализация — это не шифрование. Данные всё ещё может прочитать и изменить любой желающий – для этого только потребуется чуть больше усилий.
Поэтому в системах сохранения обычно используют текстовые форматы (из-за удобства чтения и отладки), тогда как бинарные применяют для передачи данных по сети между устройствами.
Сейчас для сохранений некогда популярный XML уступил место более эффективному JSON, который имеет очень широкую поддержку и большое количество готовых библиотек. Однако нередко применяются и разнообразные проприетарные in-house решения.
Способ сериализации — это то, что обычно меняется в зависимости от режима сборки. Для внутреннего тестирования удобнее использовать одни форматы. Для продуктовых версий — более защищённые варианты. Обеспечение гибкости в этом вопросе потребуется с наибольшей вероятностью.
Binary:
Из-за сложности чтения бинарного формата, его не принято использовать для тестовых окружений, где лёгкость чтения и изменения данных — важные критерии. Но для осложнения жизни любителям "вскрывать" данные игр в продуктовых версиях может применяться.
Пример реализации:
JSON:
В Unity есть встроенный инструмент JsonUtility, который доступен сразу "из коробки". Однако он достаточно ограничен и использует те же правила сериализации, что сериализатор для ассетов внутри движка.
Т.е. он умеет сериализовывать ровно то, что можно отрисовать в инспекторе. А значит многие сложные структуры данных типа Dictionary не пройдут (но это можно решить через ISerializationCallbackReceiver).
Пример реализации:
Популярной альтернативой является библиотека Newtonsoft. Использовать её так же просто, но она предоставляет значительно больше возможностей. Unity даже какое-то время назад добавили её в UPM. Но в последних версиях движка я её уже там не наблюдаю. Поэтому придётся добывать библиотеку из NuGet. К счастью, никаких лишних зависимостей она за собой не тянет.
Пример реализации:
Шифрование:
Шифрование сохранённых данных является популярной техникой для защиты данных от несанкционированного чтения или изменения.
Для реализации такого поведения можно использовать декорирование сериализатора. Т.е. сначала перегнать данные в JSON, а потом эти данные зашифровать.
Для дешифровки необходимо знать алгоритм и пароль, при помощи которых проводилось шифрование. Эти данные известны самому приложению или узлу, с которого клиент получает данные. Соответственно, ни игрок, ни кто-либо другой в рядовой ситуации не сможет ни прочитать, ни что-то с данными сделать. Если только нарушить целостность и "сломать".
Из-за нечитабельности выходных данных этот трюк используют обычно только для продуктовых версий.
Пример зашифрованных сохранений:
SF0KeJbP+sX207x4d78frBrNN20lPDUumXktlq5dnFk0xz+xcqWaqxOhj7xrtgQZ9irh/GiJNVVtktVT9pJh0VJN8rK2KX3W2LDPHCDxQwcA/g7epwVVhhlI8bwyTs8pETfOhFbSJ5rihCehqvecww==
Пример реализации:
🔑 Идентификация
Ключ-идентификатор предназначен для того, чтобы однозначно определять данные и отличать одни от других.
В качестве такого ключа может выступать название файла с сохранениями, путь до него или какое-то другое уникальное обозначение.
По указанному ключу система сохранений сохраняет входящие данные. И по этому же ключу обратно их возвращает.
Ключ можно передать в систему извне или сгенерировать его внутри системы на основе входных данных.
Передача ключа в систему извне:
Передача в систему ключа в качестве аргумента — самый простой способ:
Генерация же внутри системы чаще всего производится на основе типа передаваемых данных. Но в таком случае важно, чтобы все сохраняемые данные имели разный тип, иначе получится несколько одинаковых ключей и образуется коллизия.
Поэтому передача ключа через аргумент будет полезна в тех ситуациях, когда есть необходимость сохранять несколько независимых однотипных структур.
Например, нужно сохранить HealthData игрока и HealthData противника как отдельные структуры:
Однако обычно HealthData игрока не существует отдельно и, скорее всего, будет вложена в некую PlayerData. У противника HealthData тоже будет связана с EnemyData, которая может быть частью WorldData.
WorldData и PlayerData имеют разные типы и существуют в единственном экземпляре. Соответственно, ключи для них можно сгенерировать на основе типов и не передавать явно:
Т.е. в большинстве случаев можно выстроить проект так, чтобы обеспечить уникальность всех типов, что удобно, ведь для системы сохранений потребуется передавать меньше данных.
Но если есть необходимость или желание всё же передавать ключи в явном виде через аргумент, то нужно позаботиться об инкапсуляции этих ключей, чтобы минимизировать число опечаток и ошибок. Ведь ключи нужно будет передавать каждый раз при обращении к системе.
Здесь в качестве варианта может подойти класс с константами:
Или фасад для системы сохранений:
Или какой-то другой способ. Главное, чтобы можно было просто найти использования конкретного ключа и минимизировать ошибки при применении этих ключей.
Но стоит понимать, что при таком подходе мы лишаем себя возможности быстро поменять способ формирования ключа. Если есть 120% уверенность, что этого делать не придётся, можно оставить это как есть.
Стратегия формирования ключа:
Для обеспечения гибкости в переключении способов формирования ключа нужно выделить это как отдельный этап или стратегию.
Раз ключи мы заранее не задаём, значит их нужно формировать из входных данных. Но есть некоторое ограничение. У SaveAsync и LoadAsync разная сигнатура:
Если в первом случае мы имеем в распоряжении конкретный экземпляр данных и их тип, то во втором — у нас есть только тип. А ключ генерировать нужно в обоих сценариях. Значит, ключ мы можем генерировать, только основываясь на типе.
Учитывая эти ограничения, зададим такой контракт:
Использование типа в качестве ключа:
Самый простой способ сформировать ключ на основе типа — это взять название типа:
Просто и элегантно. Но не без подвоха. Если разработчик при рефакторинге случайно переименует тип, то у этих данных изменится ключ. А значит данные, сохранённые под старым ключом, не будут подхватываться новым.
Т.е. ключу хорошо бы обеспечить неизменяемость. Самый надёжный способ это сделать — смаппить тип к константным данным. От чего бежали, к тому и вернулись. Но этот путь был проделан не зря.
Маппинг ключей к типам:
Подготовим новую реализацию контракта на основе маппинга:
Не обязательно формировать словарь прямо в провайдере — его можно подготовить и где-то вне, так будет даже лучше.
Какие это даёт возможности:
- Явное выражение связи ключа с типом данных, но защищённое от переименования этого типа;
- Явное выражение формирования ключей как отдельного этапа в системе сохранения;
- Быстрая и простая подмена способа формирования ключей;
- Централизованное хранение используемых ключей;
- Группировка ключей по нескольким провайдерам для использования в разных контекстах или разных системах сохранения внутри одного приложения.
Декорирование провайдера ключей:
Выделение формирования ключа в отдельный этап позволяет не просто подменять реализации, но и комбинировать их.
Например, можно декорировать провайдер ключей каким-то префиксом:
В качестве префикса можно использовать:
- Id игрока;
- Id площадки;
- Путь до конкретной директории;
- И др.
Это позволяет реализовать:
- Поддержку нескольких игровых аккаунтов;
- Поддержку нескольких площадок с разными данными;
- Поддержку разных режимов одной механики;
- И др.
За счёт декорирования можно собирать очень сложные цепочки провайдеров, которые могут помочь решить очень широкий набор возникающих задач.
🗃 Хранение данных
Хранилище данных — это непосредственно место, где данные хранятся между сессиями вне оперативной памяти.
Хранилище может быть локальным или удалённым.
Локальное хранилище данных расположено непосредственно на самом устройстве, на котором запускается игра. Может представлять собой файловую систему ОС или Базу Данных.
Удалённое хранилище данных расположено где-то вне устройства, на котором запускается игра. Может представлять собой так же файловую систему ОС или Базу Данных, но на стороннем сервере, или специализированный облачный сервис типа Playfab, Unity CloudSave, GamePush и др.
Удалённые хранилища обеспечивают следующие преимущества:
- Возможность игры с разных устройств и платформ;
- Оперативная поддержка со стороны разработчиков, т.к. они могут получить доступ к данным и тут же их исправить, если проблема была в данных;
- Защита данных от потери, взлома или мошенничества.
Удалённые хранилища имеют некоторые особенности:
- Нужна авторизация, чтобы идентифицировать игроков.
Хотя бы в "гостевом" варианте по deviceId. Но это привязывает данные к конкретному устройству, что лишает возможности продолжать игру на других устройствах; - Операции с удалённым хранилищем намного более долгие;
- Нужен интернет;
- Аренда/покупка сервера или плата за облачный сервис с тарификацией на кол-во запросов и/или размер данных;
- Игрок не сможет сам сбрасывать прогресс, если в самой игре не предусмотрена такая опция.
Обычно для хранилища данных требуется следующий набор операций:
- Получение данных по ключу;
- Запись данных по ключу;
- Удаление данных по ключу;
- Проверка наличия данных по ключу.
Окончательный набор операций формируется для каждого проекта индивидуально. Где-то из операций достаточно только записи и получения, где-то требуются операции с коллекциями ключей, где-то нужна работа с разного рода мета-данными (права доступа, время изменения, время создания и пр.).
Операции с постоянной памятью любого типа довольно длительны. Поэтому эти операции стоит делать асинхронными.
Учитывая всё это, реализуем следующую абстракцию:
Для работы в редакторе часто не целесообразно использовать удалённое хранилище, особенно если его использование тарифицируется. Поэтому в этом режиме подключают локальное.
Для продуктовых сборок может использоваться комбинация из локального и удалённого хранилищ, чтобы обеспечивать игру без интернета и минимизировать общение с тарифицируемым удалённым хранилищем.
А для тестовых сборок, чтобы оперативно проверять данные в удалённом хранилище, подключают только удалённое.
Возможность использовать и комбинировать различные типы хранилищ сильно упрощает и удешевляет многие процессы.
Локальные хранилища данных в Unity. Файловая система:
Пример реализации:
- Подходящий вариант для объёмного User Generated Content.
- На разных платформах есть свои ограничения, которые необходимо учитывать.
- На ряде Android-смартфонов от пользователя требуется дополнительное разрешение на доступ к файловой системе. Пользователи такие разрешения давать не любят. Тогда прогресс не сохранится. А это — удаление игры и гневный отзыв.
- На WebGL нет прямого доступа к файловой системе. Но есть альтернативы типа LocalStorage или IndexDB на стороне JavaScript. Однако это уже не совсем работа с файловой системой.
- У Unity есть свойство Application.persistentDataPath — это предустановленный путь до директории, где можно хранить данные сохранений без опасений, что они будут удалены при обновлении или переустановки приложения. Для каждой платформы и для каждого проекта Unity автоматически генерирует это значение.
- В редакторе вместо Application.persistentDataPath удобнее использовать Application.dataPath — это путь до рабочего проекта. Но записывать туда можно только в режиме редактора. Важно это не пропустить в сборку.
Локальные хранилища данных в Unity. PlayerPrefs:
Пример реализации:
- Хранилище по типу ключ-значение.
- Универсальное, удобное и очень простое в использовании.
Поддерживает работу с типами int, float и string, но для сериализуемых данных достаточно только string.
- Для каждой платформы PlayerPrefs имеет свою реализацию, которую Unity внутри применяет автоматически.
- Не требует специальных разрешений на Android и других платформах.
- Есть ограничения на размер данных. Но этого достаточно для сохранений, особенно, если они разбиты на несколько ключей. В моей практике даже на насыщенных мидкорных проектах сохранения умещались в PlayerPrefs.
- Из-за ограничений на размер данных User Generated Content не стоит сохранять в PlayerPrefs.
Удалённые хранилища данных в Unity. CloudSave:
Сильно упрощённый пример реализации:
- Хранилище по типу ключ-значение.
- В первых версиях API было очень похоже на PlayerPrefs. Со временем постепенно усложняется, становясь всё более похожим на реализации из других облачных сервисов.
- Есть поддержка хранилищ данных, привязанных к конкретным пользователям, и общего хранилища для всего тайтла (если нужно сохранять общее игровое состояние для многопользовательского проекта).
- Есть free-tier достаточный для бесплатного использования в личных проектах.
Комбинация хранилищ данных:
Использование облачных технологий при сохранении прогресса предоставляет много полезных возможностей и для игроков, и для разработчиков. Особенно в WebGL, где игрок вообще не привязан к конкретному устройству или браузеру.
Однако для разработчиков активное использование облачных технологий может негативно сказаться на бюджете, а для игроков сильная зависимость от интернета может стать неудобством. Поэтому не стоит полностью полагаться на удалённые хранилища данных.
Комбинированный подход позволяет сгладить недостатки каждого типа хранилища. От способа комбинации зависит то, какие недостатки и какие достоинства каждого из хранилищ проявят себя сильнее.
Часто комбинированный подход реализуется так:
- Загрузка данных из удаленного хранилища при старте;
- Запись данных в локальное хранилище;
- Работа в игре с локальным хранилищем;
- Периодическая синхронизация с удаленным хранилищем.
Если игра закроется до того, как последние данные будут синхронизированы, при следующем запуске она сравнит, в каком хранилище данные более свежие, и будет использовать их.
Описанные процессы — весьма упрощены. В проектах разных масштабов используются разнообразные хитрости и усложнения. Обычно они направлены на защиту от разного рода жульничества со стороны игроков, но могут быть и нацелены на решение других проблем.
Наиболее частые способы синхронизации — это копирование данных в удалённое хранилище через каждые N операции и/или через каждые M секунд. Подобное поведение можно реализовать при помощи пакетной обработки или батчинга, накапливая операции с локальным хранилищем для последующего отложенного применения к удалённому хранилищу.
Батч — группа данных или операций, которые обрабатываются вместе как единое целое.
Хранилище, основанное на комбинации подобного рода, назовём BatchDataStorage. А базовую абстракцию с возможностью переопределения условия отправки батча можно представить в таком виде:
Соответственно, можно сделать вариант с накоплением батча за некоторое время:
Или вариант с накоплением батча фиксированным кол-вом операций:
Существует множество вариантов условий отправки батча или комбинаций использования локального и удалённого хранилищ. Главное, что потребуется, это настроенный контракт в виде IDataStorage, который можно заменить на локальное, удалённое или любое другое хранилище.
🔌Подключение модулей
Мы рассмотрели все модули, которые участвовали в процессах сохранения и загрузки. Теперь добавим эти модули в реализацию системы:
За счёт использования абстракций и принципа внедрения зависимостей есть возможность задавать различные варианты поведения, не меняя при этом саму систему, которая в целом сохранила свою простоту: в каждой операции по 3 действия с 3 модулями.
Комбинируя различные реализации формирования ключей, сериализации данных и способов их хранения можно гибко подогнать систему сохранений под очень больший список возможных требований, не теряя возможности адаптироваться к новым условиям.
Реализация конфигурирования для системы позволяет наделать несколько пресетов настроек под разные платформы и режимы сборок. Что на этапе запуска позволит динамически собрать систему из нужных модулей при помощи DI-фреймворка.
🏁 Заключение
Условия задачи построения системы сохранений на каждом проекте различаются. И в зависимости условий итоговая система может оказаться как сильно проще, так и, возможно даже, сильно сложнее.
Самый главный инструмент гибкости — это простота. Поэтому не нужно переусердствовать там, где это не требуется. Может проекту и не нужны ни облака, ни гибкость, ни масштабируемость, и прямого использования PlayerPrefs в пару строчек будет достаточно. И это нормально — не нужно искать подвоха. Простое решение — лучшее решение. Главное — правильно его оценить.
На мой взгляд, что точно делать не нужно — это пытаться строить универсальную систему на все случаи жизни. Получится дорогой в реализации и поддержки монстр (если вообще получится).
В этом материале я хотел поделиться не готовым решением, а последовательностью мыслей, которые привели к нему. Показать, из каких элементов состоит система, как они друг с другом взаимодействуют и как их можно использовать. А применение этому всему продиктуют уже конкретные проектные условия.
📚 Дополнительный контент
- Репозиторий с материалами из статьи: GitFlic / GitVerse
- Влияние оперативной памяти на производительность в играх: SkyPro
- Как работают сохранения в видеоиграх: Dzen.Кодзима Гений
- Understanding Serialization and Deserialization in C#: shekhall
- Data Serialization Comparison: JSON, YAML, BSON, MessagePack: SitePoint
- persistentDataPath Explained: Occa Software
- Сохранение игры в Unity3D: Habr
How to save your game states and settings: Unity Blog
- 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