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

ggdev ggdev 6 Сентября 2024

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

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

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

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

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

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

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

7a2f9062b9a29ec084ee4c034faff068.gif

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

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

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

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

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

Задачи системы сохранений
Основные задачи:

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

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

  • Прерывание игры и возвращение к ней позже, продолжая прохождение с того же места;
  • Возвращение к определённому моменту в игре для повторного прохождения;
  • Продолжение прохождения игры на другом устройстве;
  • Предоставление состояния игры разработчикам для воспроизведения и оперативного устранения проблемы.
    Современные особенности системы сохранений
  1. Мульти-устройство:
    У многих пользователей сейчас множество разнообразных гаджетов, с которых можно играть. Наличием нескольких личных смартфонов уже вряд ли кого-то можно удивить. Но вот тому, что на разных телефонах в одной и той же игре будет разный прогресс, пользователь может неприятно удивиться. А пользователя удивлять лучше только приятно.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

public sealed class SaveSystem : ISaveSystem  
{  
    public async UniTask SaveAsync(TData data)  
    {        
        string dataKey = GetKey();  
        string serializedData = await SerializeAsync(data);  
        await WriteToDataStorageAsync(dataKey, serializedData);  
    }  

    public async UniTask LoadAsync()  
    {        
        string dataKey = GetKey();  
        string serializedData = await ReadFromDataStorageAsync(dataKey);  
        return await DeserializeAsync(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);
}
  1. Все данные об игре сгруппированы в несколько независимых самостоятельных структур, которые сохраняются независимо друг от друга:
[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);
}
  1. Каждый параметр игры является самостоятельной независимой от других структурой и сохраняется отдельно от других:
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 data) where TData : ISaveData;
    UniTask LoadAsync() where TData : ISaveData;
}

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

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

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

Автор материала

Опубликовано в Туториалы по Unity
Для ответа вы можете авторизоваться