Unity 6.3
0 онлайн 88 гостей 3 в системе
Вход
Модульная игровая архитектура на ScriptableObjects Глава 4 из 12 Оригинал, стр. 17

Контейнеры данных

Контейнеры данных

Чаще всего объекты ScriptableObject используют как контейнеры общих статических данных, особенно конфигурации игры, которая не изменяется во время выполнения. Типичные варианты применения объектов ScriptableObject: - Инвентарь: тип предмета, значок, редкость, эффекты - Характеристики врагов, игроков и предметов: здоровье, урон, скорость, параметры ИИ - Наборы аудио: группы клипов для шагов, звуков UI и зацикленных фоновых звуков - Конфигурационные файлы: настройки сложности, кривые прогресса, таблицы появления объектов

- Данные диалогов Во время выполнения эти данные можно хранить в компонентах MonoBehaviour, но это не всегда эффективно. Как показало сравнение выше, компоненты MonoBehaviour создают дополнительные накладные расходы, поскольку им нужен объект GameObject и, по умолчанию, Transform. В результате ради хранения одного значения приходится создавать большой объем неиспользуемых данных.

Чтобы убедиться в этом, создайте новый GameObject с пустым MonoBehaviour. Затем откройте сериализованный объект в текстовом редакторе. Он будет выглядеть примерно так:

Новый GameObject с минимальным MonoBehaviour

Сравните его с пустым ScriptableObject: структура такого объекта заметно компактнее.

ScriptableObject сокращает накладные расходы на хранение данных.

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

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

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

Постоянные данные, которые нужно сохранять между сеансами, обычно записывают в другом формате, например JSON, XML, MessagePack или Protocol Buffers. Подробнее см. раздел «Двойная сериализация» ниже. В сборке игры данные ScriptableObject можно изменять во время выполнения, например с помощью переменных ScriptableObject и Runtime Set, но такие изменения временны. При запуске нового сеанса данные вернутся к состоянию, которое было зафиксировано во время сборки. Для задач постоянного хранения воспринимайте данные ScriptableObject как доступные только для чтения. Изменяемые постоянные данные следует хранить во внешнем хранилище с поддержкой чтения и записи.

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

объект

объект

объект

объект

объект

данные

данные

данные

данные

данные

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

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

объект

объект

объект

объект

объект

общие данные

Совместное использование данных множеством объектов через ScriptableObject

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

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

Устранение дублирования данных экономит ресурсы

Memory Profiler сравнивает расход памяти при дублировании данных (A) и совместном использовании данных (B).

Таким способом можно хранить большие объемы общих данных. Используйте ScriptableObject для следующих задач: - Сохранение данных в течение сеанса работы в редакторе Unity - Сохранение данных в виде ассета для использования во время выполнения В отличие от компонентов MonoBehaviour, объекты ScriptableObject нельзя прикреплять к GameObject. Вместо этого их сохраняют как ассеты проекта. Это особенно удобно, если префаб использует неизменяемые данные в своих компонентах MonoBehaviour.

Пример рефакторинга Рассмотрим компонент MonoBehaviour, который управляет здоровьем NPC. Его класс можно определить следующим образом:

C#
public class NPCHealthUnrefactored : MonoBehaviour {
    [Range(10, 100)] public int MaxHealth;
    [Range(10, 100)] public int HealthThreshold;

public int CurrentHealth; } Такой код работает, но содержит данные, которые не должны меняться во время выполнения. Если компонент NPCHealthUnrefactored прикреплен ко множеству объектов, в памяти возникает большое количество ненужных копий.

MonoBehaviour NPCHealth до рефакторинга

Данные, которые не требуется изменять, можно перенести в ScriptableObject:

C#
[CreateAssetMenu(fileName="NPCConfig")] public class NPCConfigSO : ScriptableObject {
    [Range(10, 100)] public int maxHealth;
    [Range(10, 100)] public int healthThreshold;

} Атрибут CreateAssetMenu настраивает команду меню. При необходимости в нем можно указать имя файла по умолчанию через fileName и порядок пункта меню через order.

Соглашения по оформлению кода в этом руководстве Для наглядности и простоты чтения многие примеры кода в этом руководстве упрощены, в частности используются открытые поля. В рабочем коде применяйте закрытые поля и открытые свойства, чтобы улучшить инкапсуляцию и гибкость. Атрибут SerializeField позволяет отображать закрытые поля в окне Inspector редактора Unity. Соглашение об именовании также помогает отличать скрипты ScriptableObject от скриптов MonoBehaviour. Например, к имени класса можно добавлять суффикс Data или SO. Это необязательно, но позволяет лучше организовать проект и избежать неоднозначности.

По мере роста кодовой базы рекомендуется создать руководство по стилю и последовательно ему следовать. Подробнее см. электронную книгу «Руководство по стилю C# для Unity 6». После рефакторинга компонент NPCHealth упрощается до следующего вида:

C#
public class NPCHealth: MonoBehaviour {
    // Ссылка на наш ScriptableObject

    public NPCConfigSO Config;
    public int CurrentHealth;
}

Теперь MonoBehaviour содержит ссылку на новый ScriptableObject. После рефакторинга содержимое окна Inspector выглядит почти так же, но данные разделены между объектами.

Ссылка на ScriptableObject

Рефакторинг разделяет данные между MonoBehaviour и ScriptableObject

Пользовательские окна Inspector

При переносе данных в ScriptableObject они оказываются в двух местах: в ассете ScriptableObject и в MonoBehaviour, который на него ссылается. Чтобы упростить работу с компонентами MonoBehaviour, можно создать пользовательский редактор Unity. Ниже приведен пример для NPCHealth.

Пользовательское окно Inspector отображает переменные ScriptableObject внутри MonoBehaviour.

Так можно просматривать переменные NPCConfig вместе с остальными свойствами MonoBehaviour. Если выбрать исходный префаб NPCHealth, значения обоих объектов легко изменить в одном месте. Для пользовательского редактора Unity достаточно нескольких строк кода: - Создайте класс, производный от Editor, и сохраните его в папке Editor. Примените атрибут CustomEditor, указав тип NPCHealth. - Подготовьте временный экземпляр Editor для ScriptableObject NPCConfig. - В методе OnInspectorGUI создайте Editor для компонента NPCHealth. - Отрисуйте содержимое окна Inspector базового класса и нового пользовательского окна Inspector.

C#
using UnityEditor;
[CustomEditor(typeof(NPCHealth))] public class NPCHealthEditor : Editor {
    private Editor editorInstance;
    private void OnEnable() {
        // Сбросить экземпляр Editor editorInstance = null;

    }
    public override void OnInspectorGUI() {
        // Проверяемый целевой компонент NPCHealth npcHealth = (NPCHealth)target;

        if (editorInstance == null) editorInstance = Editor.CreateEditor(npcHealth.config);
        // Показать переменные MonoBehaviour base.OnInspectorGUI();
C#
// Отрисовать окно Inspector для ScriptableObject editorInstance.DrawDefaultInspector();

}
}

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

Архитектурные преимущества ScriptableObject позволяет четко разделить общие и индивидуальные данные. Все уникальные динамические данные конкретного экземпляра GameObject остаются в MonoBehaviour, а общие данные хранятся в ScriptableObject. Однако преимущества архитектуры на основе ScriptableObject не ограничиваются экономией памяти.

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

При совместной работе с данными заранее определите понятный процесс взаимодействия между командами. Четкие договоренности и грамотно установленные границы ответственности помогут избежать проблем. Также могут потребоваться дополнительные проверки ошибок или валидация данных, например атрибут Range или метод OnValidate для ограничения допустимых значений. - Общие данные редактируются быстрее и с меньшим риском ошибок: теперь все изменения вносятся централизованно. Например, чтобы изменить параметр всех NPC, достаточно скорректировать его в одном месте, после чего новое значение применится ко всем затронутым компонентам во всех сценах.

Это уменьшает вероятность ошибок, возникающих при ручном массовом редактировании множества отдельных объектов GameObject. Перенос данных в ScriptableObject также упрощает контроль версий и помогает предотвращать конфликты слияния, когда участники команды работают с одной сценой или префабом. - Сохраняйте изменения игрового процесса, сделанные в режиме Play: режим Play в редакторе Unity позволяет дизайнерам экспериментировать с игровым процессом и настройками. Однако изменения в MonoBehaviour теряются после выхода из режима Play, поскольку Unity удаляет временную копию сцены.

ScriptableObject является ассетом, поэтому изменения его значений сохраняются независимо от того, находится ли Unity в режиме Play. Это удобно, если параметры требуется настраивать во время выполнения. Однако это может стать недостатком, если изменения понадобится отменить. Используйте Unity Version Control или другую систему контроля версий, чтобы при необходимости всегда можно было восстановить прежнее состояние проекта. Подробнее см. руководство «Рекомендации по контролю версий и организации проекта (издание для Unity 6)». - Сокращайте время загрузки сцен: при сохранении сцены или префаба Unity сериализует все их содержимое, включая каждый GameObject, все прикрепленные к нему компоненты и каждое открытое поле. При этом Unity не проверяет данные на дублирование.

Перенос данных в ScriptableObject может уменьшить размер сцен и префабов и заметно ускорить их загрузку и сохранение.

Переменные ScriptableObject Общие контейнеры данных можно сделать еще более специализированными: один ScriptableObject будет представлять всего одно значение. Например, можно создать класс ScriptableObject с именем IntVariable и единственным открытым полем value:

C#
using UnityEngine;
[CreateAssetMenu(menuName = "Variables/Int", order = 1)] public class IntVariableSO : ScriptableObject { public int value; }
Затем IntVariable можно использовать в MonoBehaviour. Например, класс PlayerHealth будет выглядеть так: public class PlayerHealth : MonoBehaviour { public IntVariableSO health; }

Обычно ScriptableObject воспринимается как хранилище неизменяемых значений, однако в него можно добавить методы, которые обновляют данные во время выполнения и восстанавливают начальное значение после выхода из режима Play. Таким образом, ScriptableObject может фактически выполнять роль переменной и хранить целые числа, числа с плавающей точкой, логические значения и другие данные. После этого дизайнеры смогут самостоятельно задавать данные для игровой логики, не обращаясь каждый раз к разработчику. Но такой подход требует предварительного планирования. Вместе с дизайнерами определите, как распределить ответственность за создание данных игрового процесса. Главное - установить четкие границы совместной работы. Например, команда программистов может подготовить объекты ScriptableObject для системы инвентаря, а команда дизайнеров затем заполнит характеристики и поведение каждого игрового предмета. Дополнительные скрипты редактора Unity могут сделать этот процесс почти бесшовным. Например, можно дать пользователю возможность переключать поля в окне Inspector между общим значением из ScriptableObject и константой. Тогда команда гейм-дизайна получит больше свободы и сможет переопределять данные ScriptableObject для отдельных экземпляров.

Пример IntVariable на основе ScriptableObject из проекта Unity Atoms с открытым исходным кодом

О реализации такого поведения в собственных проектах рассказывается в докладе «Игровая архитектура с ScriptableObject» с конференции Unite Austin. Также можно скачать проект Unity Atoms с открытым исходным кодом и изучить рабочую реализацию переменных ScriptableObject.

Двойная сериализация В Unity можно сочетать разные способы сериализации. Например, работать с объектами ScriptableObject в редакторе Unity, а затем сохранять их данные в другом месте, например в файле JSON или XML. Такой подход позволяет использовать сильные стороны каждого формата. Форматы JSON и XML подходят для постоянных данных, например сохранений игры или настроек. С ними не всегда удобно работать в редакторе Unity, зато их легко изменять вне Unity в любом текстовом редакторе. ScriptableObject, напротив, удобен в редакторе Unity и легко заменяется обычным перетаскиванием. Однако изменять его вне Unity и распространять среди сообщества игроков сложнее. Сочетание форматов сериализации открывает дополнительные возможности, например редактирование уровней и поддержку модификаций. Во время сборки скрипт может преобразовать внешние файлы в объекты ScriptableObject, которые загружаются быстрее обычного текста. Конфиденциальные данные, например сведения о виртуальной валюте и учетных записях, следует надежно хранить на серверах. В то же время доступ сообщества к части игровых данных может обогатить игровой процесс и позволить пользователям экспериментировать с уровнями-песочницами.

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

C#
[CreateAssetMenu(fileName ="LevelLayout")] public class LevelLayout : ScriptableObject {
    public Vector3[] wallPositions = new Vector3[2];
    public Vector3[] playerPositions = new Vector3[2];
    public Vector3[] goalPositions = new Vector3[2];
    public Vector3 ballPosition;
}
Эти данные определяют компоновку уровня. Скрипты управления уровнем считывают сведения из объекта LevelLayout, а затем создают экземпляры префабов в нужных местах. Пользовательский скрипт может экспортировать те же данные на диск с помощью JsonUtility. В результате за пределами редактора Unity создается текстовый файл, который пользователи смогут изменять сторонними инструментами. Чтобы загрузить измененный пользователем уровень, метод ScriptableObject.CreateInstance может создать ScriptableObject во время выполнения. Затем данные из файла JSON считываются в этот объект. Ниже показан пример метода LoadLevelFromJson: using System.IO;
public class LevelManager : MonoBehaviour {
    public ScriptableObject levelLayout;
    public void LoadLevelFromJson(string jsonFile) {
        if (levelLayout == null) {
            levelLayout = ScriptableObject. CreateInstance<LevelLayout>();
        }
        var importedFile = File.ReadAllText(jsonFile);
        JsonUtility.FromJsonOverwrite(importedFile, levelLayout);
    }
}

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

Настройки по умолчанию

Игровой процесс

Экземпляр времени выполнения

Настройки или моды

JSON-файл

Сочетайте форматы сериализации для большей гибкости

Примечание. При десериализации JSON в объекты ScriptableObject с помощью JsonUtility необходимо использовать метод FromJsonOverwrite. JsonUtility не создает новый объект для загрузки данных JSON, а записывает их в уже существующий объект. Значения в классах и объектах обновляются без дополнительных выделений памяти.

Защита данных Простой пример модификации выше показывает один из вариантов применения ScriptableObject. Однако, открывая игровые данные для изменений, соблюдайте осторожность, чтобы игроки не могли вмешаться в работу остальных частей приложения. Распространенные способы защиты данных, которые не должны изменяться модификациями: - Шифрование: зашифруйте файлы данных, чтобы их было сложнее прочитать или изменить. Это затруднит вмешательство пользователей в критически важные данные. - Цифровые подписи: алгоритм вычисления цифрового отпечатка позволяет проверить, не были ли файлы данных подменены или изменены. - Проверка на стороне сервера: если игра использует данные, хранящиеся на сервере, проверяйте их на сервере до передачи в игру и отклоняйте любые сведения с признаками вмешательства. Ни один метод не обеспечивает абсолютной защиты, поэтому обычно стоит сочетать несколько подходов. Это поможет не допустить, чтобы изменения игроков привели к ошибкам или уязвимостям в игре.