Как стать автором
Обновить

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

Уровень сложностиСредний
Время на прочтение30 мин
Количество просмотров12K

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

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

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

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

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

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


🎯 Назначение системы сохранений

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

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

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

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

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

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


⚒️ Задачи и возможности

Соответственно, система сохранений решает несколько основных задач:

  1. Сохранение накопленных игровых данных вне игры;

  2. Хранение игровых данных между игровыми сессиями;

  3. Загрузка в игру ранее сохранённых игровых данных.

В свою очередь, это открывает ряд полезных возможностей:

  1. Прерывание игры и возвращение к ней позже, продолжая прохождение с того же места;

  2. Возвращение к определённому моменту в игре для повторного прохождения;

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

  4. Предоставление состояния игры разработчикам для воспроизведения и оперативного устранения обнаруженной проблемы.


🚀 Современные особенности

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

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

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

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

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

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

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

3. Бэкап:

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

4. Интернет:

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

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

Также нужно уметь ещё и решать конфликты, если игрок без интернета накопил разного прогресса на разных устройствах.

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

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

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

6. Читерство:

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

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

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


🧩 Организация внутри приложения

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

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

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

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

  2. Слой логики: взаимодействие с данными и их изменение.

  3. Слой представления: реализация восприятия игры и получение ввода от пользователя.

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

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

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

В одном проекте может быть несколько систем сохранений:

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

  • Вторая – отвечать за настройки игры и работать только локально.

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

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


⚙️ Операции и триггеры

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

  1. Сохранение в хранилище данных постоянной памяти;

  2. Загрузка из хранилища данных постоянной памяти.

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

Примеры триггеров для сохранения:

  • Игрок нажал кнопку "Сохранить";

  • Игрок закрывает игру;

  • Игрок достиг чекпоинта;

  • Прошёл таймаут между сохранениями;

  • Внутриигровые данные изменились.

Примеры триггеров для загрузки:

  • Игрок нажал кнопку "Загрузить";

  • Игрок запускает игру;

  • Игрок отменяет совершённые действия, и игра откатывается к последнему сохранению;

  • Игра от внешних сервисов получает сигнал о необходимости загрузить данные.


🔗 Внутренние процессы

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

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

Получается, каждая операция является последовательностью определённых этапов:

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

  • Получение внутриигровых данных;

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

  • Преобразование внутриигровых данных в форму для записи;

  • Запись в постоянную память.

Схема процесса сохранения данных
Схема процесса сохранения данных

2. Загрузка:

  • Получение ключа-идентификатора для поиска и считывания;

  • Считывание информации из хранилища данных;

  • Преобразование считанной информации во внутриигровые данные;

  • Передача внутриигровых данных в оперативную память.

Схема процесса загрузки данных
Схема процесса загрузки данных

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

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

  • Идентификация;

  • Хранение данных.

И, в целом, это всё, что нужно знать о системе сохранений. Сказочно просто.

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

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

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

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

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

Чтобы реализовать систему сохранений, нужно сначала ответить на вопросы:

  • Какие внутриигровые данные будут передаваться через систему сохранений?

  • В каком виде внутриигровые данные будут сохранены в постоянной памяти?

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

  • Где расположена постоянная память и как организован к ней доступ?

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


💻 Базовая реализация

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

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

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);
}

Теперь нужно определиться с форматом сериализации. Есть человекочитаемые (текстовые) и машиночитаемые (бинарные). Первые удобны в использовании для человека, но имеют больший объём и медленнее обрабатываются программно. Вторые, соответственно, более компактны и быстрее в обработке, но прочитать такие данные без специальных инструментов не получится.

Таблица сравнения форматов сериализации
Таблица сравнения форматов сериализации

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

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

Сейчас для сохранений некогда популярный XML уступил место более эффективному JSON, который имеет очень широкую поддержку и большое количество готовых библиотек. Однако нередко применяются и разнообразные проприетарные in-house решения.

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

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

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:

В 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();
EnemyData enemyData = new(enemyHealthData);
WorldData worldData = new(enemyData);

await _saveSystem.SaveAsync(playerData);
await _saveSystem.SaveAsync(worldData);

Т.е. в большинстве случаев можно выстроить проект так, чтобы обеспечить уникальность всех типов, что удобно, ведь для системы сохранений потребуется передавать меньше данных.

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

Здесь в качестве варианта может подойти класс с константами:

public static class SaveDataKeys
{
    public const string PlayerData = "PlayerData";
    public const string WorldData = "WorldData";
    // ...
}

Или фасад для системы сохранений:

public sealed class SaveSystemFacade
{
    private readonly ISaveSystem _saveSystem;

    public UniTask SavePlayerDataAsync(PlayerData playerData) =>
        _saveSystem.SaveAsync("PlayerData", data);

    public UniTask<PlayerData> LoadPlayerDataAsync() =>
         _saveSystem.LoadAsync<PlayerData>("PlayerData");

    public UniTask SaveWorldDataAsync(WorldData data) =>
        _saveSystem.SaveAsync("WorldData", data);

    public UniTask<WorldData> LoadWorldDataAsync() =>
        _saveSystem.LoadAsync<WorldData>("WorldData");

    // ...
}

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

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

Стратегия формирования ключа:

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

Раз ключи мы заранее не задаём, значит их нужно формировать из входных данных. Но есть некоторое ограничение. У SaveAsync и LoadAsync разная сигнатура:

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

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

Учитывая эти ограничения, зададим такой контракт:

public interface IKeyProvider
{
    string Provide<TData>();
    IEnumerable<string> ProvideAll();
}

Использование типа в качестве ключа:

Самый простой способ сформировать ключ на основе типа — это взять название типа:

public sealed class TypeKeyProvider : IKeyProvider
{
    public string Provide<TData>() =>
        typeof(TType).Name;

    public IEnumerable<string> ProvideAll() =>
        ReflectionUtils.FindAllOfType<ISaveData>()
            .Select(t => t.Name);
}

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

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

Маппинг ключей к типам:

Подготовим новую реализацию контракта на основе маппинга:

public sealed class MapKeyProvider : IKeyProvider
{
    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 KeyProviderPrefixDecorator : IKeyProvider
{
    private readonly string _prefix;
    private readonly IKeyProvider _baseProvider;
  
    public KeyRepositoryPrefixDecorator(
        string prefix, IKeyProvider baseProvider)  
    {
	    _prefix = prefix;
        _baseProvider = baseProvider;
    }

    public string Provide<TData>() =>
	    _prefix + _baseProvider.Provide<TData>();

	public IEnumerable<string> ProvideAll() =>
		_baseProvider.ProvideAll().Select(key => _prefix + key);
}

В качестве префикса можно использовать:

  • Id игрока;

  • Id площадки;

  • Путь до конкретной директории;

  • И др.

Это позволяет реализовать:

  • Поддержку нескольких игровых аккаунтов;

  • Поддержку нескольких площадок с разными данными;

  • Поддержку разных режимов одной механики;

  • И др.

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


🗃️ Хранение данных

Хранилище данных — это непосредственно место, где данные хранятся между сессиями вне оперативной памяти.

Хранилище может быть локальным или удалённым.

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

Локальное хранилище данных расположено непосредственно на самом устройстве, на котором запускается игра. Может представлять собой файловую систему ОС или Базу Данных.

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

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

  • Возможность игры с разных устройств и платформ;

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

  • Защита данных от потери, взлома или мошенничества.

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

  • Нужна авторизация, чтобы идентифицировать игроков.
    Хотя бы в "гостевом" варианте по 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 или IndexDB на стороне 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);
	}
}

  • Хранилище по типу ключ-значение.

  • Универсальное, удобное и очень простое в использовании.

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

  • Для каждой платформы PlayerPrefs имеет свою реализацию, которую Unity внутри применяет автоматически.

  • Не требует специальных разрешений на Android и других платформах.

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

  • Из-за ограничений на размер данных User Generated Content не стоит сохранять в PlayerPrefs.

Удалённые хранилища данных в 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 секунд. Подобное поведение можно реализовать при помощи пакетной обработки или батчинга, накапливая операции с локальным хранилищем для последующего отложенного применения к удалённому хранилищу.

Батч — группа данных или операций, которые обрабатываются вместе
как единое целое.

Batch:
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. А базовую абстракцию с возможностью переопределения условия отправки батча можно представить в таком виде:

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<bool> ExistsAsync(string key) =>
        _hotStorage.ExistsAsync(key);

    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();
        });

    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();
    }
}

Соответственно, можно сделать вариант с накоплением батча за некоторое время:

DelayedBatchDataStorage:
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 async void OnBatchUpdated()
    {
        if (!_batchDelayed)
        {
            _batchDelayed = true;
            await UniTask.WaitForSeconds(_batchDelay);
            await CommitBatchAsync();
            _batchDelayed = false;
        }
    }
}

Или вариант с накоплением батча фиксированным кол-вом операций:

LimitedBatchDataStorage:
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 модулями.

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

Реализация конфигурирования для системы позволяет наделать несколько пресетов настроек под разные платформы и режимы сборок. Что на этапе запуска позволит динамически собрать систему из нужных модулей при помощи 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

Теги:
Хабы:
Всего голосов 19: ↑18 и ↓1+18
Комментарии8

Публикации

Истории

Работа

Ближайшие события

19 марта – 28 апреля
Экспедиция «Рэйдикс»
Нижний НовгородЕкатеринбургНовосибирскВладивостокИжевскКазаньТюменьУфаИркутскЧелябинскСамараХабаровскКрасноярскОмск
24 апреля
VK Go Meetup 2025
Санкт-ПетербургОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань
14 мая
LinkMeetup
Москва
5 июня
Конференция TechRec AI&HR 2025
МоскваОнлайн
20 – 22 июня
Летняя айти-тусовка Summer Merge
Ульяновская область