Разработка 2D Fantasy Kingdom Sim с нуля. Часть 2. Bevy ECS, загрузка мира из LDTK (много примеров и кода на Rust)

Первую статью на позапрошлой неделе я посвятил выбору основных технологий для разработки 2D симулятора фэнтезийного королевства в духе RimWorld и Majesty и обрисовал общие *технические* задачи моего прототипа (т.е. не геймплейные, но с оглядкой на геймплей).

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

Слои мира игры в редакторе LDTK.

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

Логическая структура мира

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

В первой статье я указал что взял за основу LDTK. Это открытый редактор 2D миров на основе тайлов от создателя Dead Cells. Мир в LDTK состоит из уровней размером A*B пикселей, а каждый уровень состоит из слоев 2D сеток различных типов. В LDTK мы будем собирать и моделировать наш датасет, а на основе Bevy строить “интерпретатор” этого датасета. Наш датасет должен максимально подробно описывать мир игры, но не состояние этого мира. Перед тем как перейти к структуре проекта в LDTK, для начала я постараюсь описать как мир игры устроен логически.

В игре у нас будет один уровень или карта размером M x N квадратных тайлов. Размер тайла чисто условный, например 16x16 пикселей. Помимо ширины и длины, карта также имеет ландшафт состоящий из нескольких слоев. На данный момент у меня есть следующие слои ландшафта:

  • L_Terrain. Основной – например трава, глина, гравий и тп.
  • L_TerrainDetails. Детали – слой поверх основного ландшафта, например дороги, или вытоптанная трава.
  • L_TerrainDecals. Отличия – разные мелкие камни, ветки, листья и прочее.
Ландшафт с тремя слоями.
Ландшафт с тремя слоями.

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

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

  • L_Structures. Структуры c произвольной формой описанной мешем – камни, мебель, деревья, небольшие постройки и прочее.
  • L_GridStructures. Структуры имеющие размер одного тайла – стены, ресурсы, заборы и прочее.
  • L_Creatures. Существа – все остальные динамические сущности в игре – персонажы, монстры, фауна.
Структуры размером один тайл c квадратными коллайдерами.
Структуры размером один тайл c квадратными коллайдерами.
Структуры с произвольным размером спрайта и коллайдера (оранжевые круги).
Структуры с произвольным размером спрайта и коллайдера (оранжевые круги).

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

Структура проекта LDTK

Структура проекта в LDKT основана на логической структуре, но с некоторыми небольшими изменениями. Проект в LDTK, как я уже описывал выше, описывает мир игры состоящий из нескольких уровней. Каждый уровень имеет размер NxM пикселей и состоит из слоев. Слои бывают трех видов: 1) тайловая сетка (простая или с правилами) 2) числовая сетка 3) сущности.

Типы слоев в LDTK.
Типы слоев в LDTK.

В моем случае слои следуют логической структуре, но на каждый логический слой может быть несколько слоев в LDTK. Это сделано так поскольку каждый слой в LDTK может ссылаться только на один тайлсет. Поэтому если например детали ландшафта используют разные тайлсеты, то их придется размещать в разных слоях. Вот пример слоев из тестового уровня из простых ассетов c itch.io:

Существа и структуры представлены через слой с типом Entities. Сущность в LDTK может хранить разные атрибуты (числа, строки, массивы, точки на карте и ссылки на другие сущности), например здесь в типе Character указаны имя, базовая скорость, а также размер и трансляция коллайдера:

Пример атрибутов простого типа персонажа.
Пример атрибутов простого типа персонажа.

В целом, такого подхода вполне достаточно чтобы смоделировать данные для несложного работающего уровень с персонажами, монстрами и предметами. Из минусов LDTK – отсутсвие поддержки анимаций и более сложных структур. Например в моем случае конечный автомат и данные анимаций для существ жестко прописаны в коде, и задаются через значение типа перечисления (enum) в атрибуте сущности.

Bevy ECS

Движок Bevy это по сути набор относительно небольших библиотек лего-блоков для создания игры на Rust. В основе Bevy лежит ECS и плагины которые реализуют над ECS разные возможности.

О подходе ECS и DOD наверно слышали уже все кто немного интересуется темой разработки игр в последние лет 10. Если хочется углубится в историю и технические детали таких подходов, то рекомендую прекрасное видео от Майка Актона (Mike Acton) который в данный момент является VP DOTS в Юнити, а до этого возглавлял разработку движка в Insomniac Games. Суть подхода в том что игра представляется как набор систем трансформирующих некоторые данные – сущности. Сущности состоят из компонентов (атрибуты персонажа, модели, анимации, материалы, позиции, скорость, направление и прочее), а системы как правило обрабатывают небольшой набор этих атрибутов независимо друг от друга). Объективно такой подход позволяет иметь программу с более предсказуемыми параметрами производительности, субъективно как мне кажется является куда более гибким и менее громоздким чем “традиционный” ООП. Движок Bevy крайне активно использует подход ECS, и программа в Bevy в целом представляет два просчитываемых ECS мира где отдельно обрабатывается логика игры и графика. Для передачи данных в “графический” мир, существует явно описанный процесс “извлечения” (extraction) ассетов.

На практике Bevy также представляет не только грамотно спроектированную версию ECS, но и большое количество функций для работы с графикой и различными форматами, систему ресурсов, аудио, ввод/вывод, и систему плагинов для организации кода. Большим плюсом движка является относительная простота и легковесность. В добавок к сущностям, системам и компонентам, важной частью местного ECS являются ресурсы. Обычно ресурсы это общие глобальные или уникальные данные (ассеты, устройства ввода и вывода, считыватели событий, глобальный таймер, и тому подобное). Как пример в дополнение к тому что встроено в Bevy, в моей игре ресурсами являются навигационная сетка или рамка выбора (SelectionBox). Для иллюстрации ECS опишу то как это реализовано как раз на примере рамки для выделения объектов

(в реальности код сложнее, здесь же я привел немного упрощенную версию)

Для создания компонента в Bevy достаточно добавить макрос Component к типу в Rust:

Компонент <b>Selectable</b> который добавляется ко всем сущностям которые можно выделять.
Компонент Selectable который добавляется ко всем сущностям которые можно выделять.

Система которая рассчитывает состояние рамки выглядит так:

Расчет состояния и координат рамки выделения объектов.
Расчет состояния и координат рамки выделения объектов.

В данной системе мы запрашиваем сущность с компонентом Camera2d, ресурс SelectBox, ввод мыши и свойства окна. Далее, если мышь имеет координаты для текущего окна игры, то мы вычисляем сначала нормализованные координаты курсора в окне (Normalized Device Coordinates, NCD), а затем преобразуем их в координаты внутри мира игры используя обратную трансформацию из проекции нашей 2D камеры. После этого, в зависимости от состояния кнопок мыши, мы обновляем состояние рамка выбора. Нужно отметить что камера и математика это встроенные в Bevy компоненты, нам лишь нужно их инициализировать и запросить в систему, а Bevy сам сгенерирует код для их передачи в систему согласно нашему запросу. Отличие Раст от большинства других языков в том что код будет сгенерирован и проверен на корректность статически до процесса компиляции, а не в рантайме.

В другой системе, мы запрашиваем ресурс SelectBox и проверяем его на пересечение со всеми сущностями у которых задан есть компонент Selectable и WorldObject (атрибуты мира) через простой AABB тест (Axis Aligned Bounding Box) :

Проверка пересечения <b>Selectable </b>объекта в отдельной системе.
Проверка пересечения Selectable объекта в отдельной системе.

Для отрисовки рамки выбора мы просто меняем атрибуты transform и is_visible для заранее созданного компонента спрайта.

Обновление координат квада рамки выделения.
Обновление координат квада рамки выделения.

Для этого у нас заранее был сгенерирована текстура размером в один пиксель из PNG закодированного в Base64 (есть и более простые способы конечно).

Загрузка текстуры из программно созданного PNG.
Загрузка текстуры из программно созданного PNG.

Замечу что вместо флага is_selected, можно использовать другой подход и добавлять/удалять дополнительный компонент Selected. Возможно так даже быстрее потому что все Selectable сущности обходятся только один раз.

Теперь если добавить нашу систему в Bevy, а также компонент Selectable к, например, сущности дерева, то можно увидеть как работает рамка выбора:

Добавление компонента к сущности.
Добавление компонента к сущности.

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

Пример выделяемой сущности.

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

Добавление систем в приложение.
Добавление систем в приложение.

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

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

Инспектор.

Загрузка из LDTK (много кода)

Файл LDTK представляет собой довольно простую структуру в виде дерева закодированную в JSON в котором содержатся уровни, слои, экземпляры сущностей и определения.

Структура файла LDTK.
Структура файла LDTK.

Для загрузки проекта в игру, первым делом нам нужно распарсить файл LDTK. Для JSON в Расте как правило используют библиотеку serde, но для LDTK уже есть написанная на основе serde более высокоуровневая библиотека моделей LDTK со всеми аннотациями – rust_ldtk. Далее, нам нужно добавить обработчик для менеджера ассетов Bevy который будет асинхронно загружать проект и генерировать ассеты. Для этого в прототипе есть LDTK плагин который при старте вызывает ассет менеджер и создает сущность проекта в ECS с указанием ссылки на ресурс который будет потом загружен:

Загрузка ассета.
Загрузка ассета.

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

let project: ldtk_rust::Project = serde_json::from_slice(bytes)?; ... let dependencies: Vec<(i64, AssetPath)> = project.defs .tilesets .iter() .filter(|tileset| { ... }) .map(|tileset| { ... (tileset.uid, asset_path) }) .collect(); ... load_context .set_default_asset( loaded_asset.with_dependencies( dependencies.iter() .map(...) .collect() ) );

После того как наша система будет запущена, менеджер ассетов Bevy асинхронно подгрузит все зависимости с текстурами и cгенерирует событие о загрузки LDTK ассета. Далее мы просто добавляем обработчик события (обычная система) который будет создавать мир после получения события о загрузке проекта. В обработчике помимо проекта LDTK, нам понадобятся текстуры, шрифты и другие ресурсы которые были загруженны заранее:

Перехват события окончания загрузки ассета.
Перехват события окончания загрузки ассета.

Создание мира довольно длинная операция, которую условно можно поделить на две стадии. Сначала мы создаем контекст с индексами LDTK в котором по ключам UID у нас будут храниться хендлы (Handle) ресурсов (атласов, анимаций и пр) и определений сущностей. Также на этой стадии будет удобно нарезать и проиндексировать атласы спрайтов (встроенный тип в Bevy) для всех сущностей и тайлов:

Индексирование структуры LDTK.
Индексирование структуры LDTK.

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

Контекст создателя мира.
Контекст создателя мира.

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

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

Для каждого слоя мы также генерируем сущность родителя чтобы разделить сцену на слои в иерархии Bevy что удобно при отладке. Для этого ID сущностей будут записываться в буфер layer_entities_buf который потом будет передаваться в конструктор как список детей сущности. Далее вызов кода который создает существ выглядит примерно так:

Обработка слоя с существами и добавление его в иерархию мира.
Обработка слоя с существами и добавление его в иерархию мира.

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

Обход слоя.
Обход слоя.

Упрощенный код создания компонентов персонажа. В данном случае мы просто добавляем компоненты размера, параметры скорости, коллайдер, маркер слоя, и маркер персонажа.

Генерация компонентов существа.
Генерация компонентов существа.

Навигация

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

Для начала просто создаем сетку с заданным шагом и соединяем все узлы с соседями. Для каждого узла также сохраним его координаты в мире игры:

Генерация сетки.
Генерация сетки.

Размер шага – произвольный, например 8 пикселей. Библиотека petgraph довольно общая и не обязатально самая производительная, но для просчета сотен путей в кадре сгодится. Для отладки также удобно отрисовать структуру сетки в радиусе курсора:

Далее, с помощью простого AABB теста мы вырезаем из сетки узлы пересекающие структуры мира. В очень упрощенном виде это выглядит так (я надеюсь это более подробно раскрыть в статье о ИИ):

Вырезание препятствий из сетки.
Вырезание препятствий из сетки.

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

Пример навигации.

Заключение

На этом пока что все. Надеюсь из статьи более менее понятно как устроен мир и его загрузка из LDTK в Bevy.

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

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

-SNG

5959 показов
3.5K3.5K открытий
11 репост
8 комментариев
Ответить

Комментарий недоступен

Ответить
Автор

Спс, будут вопросы по расту или Bevy – пиши.

Ответить

Ну штош, будем посмотрец!

Хотя вброшу такую тему, что героям стоит добавить генетический алгоритм для ИИ (ну или что-то), при этом работающий в 2-3 инстансах на одного персонажа.
Т.е.: сраные рейнджеры из Majesty, у которых глаза наливались кровью и они гибли от того что их загрызала любая хрень со спины. Если бы были трусливые, у которых первым делом было бы "сделать сибас" - они не были бы основным наполнителем кладбища.

2-3 инстанса нужны чтобы персонаж обучался "на лету", т.е. 1 условно поколение проходит за 1 эвент.

Ответить
Автор

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

Ответить

Живой пользователь bevy! Я как раз его доку читаю, но руки потрогать не дошли

Ответить

появилась безумная идея, пописать на раст игру:)

Ответить