Flutter & games: пробуем Bonfire в роли игрового движка

в 19:29, , рубрики: Bonfire, flame, flutter, игры, производительность в играх, разработка игр, разработка мобильных приложений, разработка под windows

Всем привет! Продолжаю делиться своими раскопками в области использования Dart и Flutter не совсем по назначению. Хотя, учитывая недавний выход в релиз тулкита от Flutter для разработки казуальных игр (https://flutter.dev/games), можно сказать, что статья как раз тематическая. Ну, решайте сами, а я только скажу, что в этой статье будет рассмотрен фреймворк Bonfire для создания RPG игр, его плюсы и, к сожалению, довольно серьёзные минусы.

Первый взгляд на фреймворк

Автор сделал по фреймворку достаточное большое количество документации, в репозитории есть несколько довольно разных и объёмных примеров, а на своём родном языке (испанский? Я не уверен) он даже записывал видео-туториалы.

Если вкратце описать ключевые возможности, то получится вот такой список:

  1. Уже готовое управление – клавиатурой или экранным джостиком

  2. Уже настроенные сущности «Player», «Enemy», «Follower»

  3. Готовые сущности «снарядов». Механика «атак ближнего

  4. Примеси и функции для реализации движения NPC в сторону игрока для начала атаки

  5. Автоматическое построение маршрута персонажа до точки, на которую игрок кликнул мышкой

  6. Поддержка карт Tiled, поддержка анимированных тайлов, тайлов с данными о столкновениях. Чтение свойств тайлов и объектов. Размещение объектов по разным уровням высоты.

  7. Создание из объектов на карте кастомных классов в зависимости от имени объекта

  8. Освещение, полноэкранные цветовые эффекты

  9. Плавное перемещение камеры

  10. Пользовательский интерфейс, «диалоги»

  11. Мини-карта

  12. …что-то ещё, возможно, что либо я пропустил, либо успело появиться, пока я всё это писал.

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

После развертывания у нас действительно всё просто и понятно: есть объект, есть колбэки на получение урона, на столкновение с другими объектами, на движение и т.п. Только садись и пиши! Что я и попытался сделать…

Постановка задачи

Делать просто RPG в Tiled не очень интересно – это история больше про прорисовку и расстановку ассетов, а не про использование фреймворка. Да и потом, у меня был собственный план, который немного шел вразрез с «механиками по-умолчанию», которые были в Bonfire.

Олды тут? Помните «Battle City 1990» на приставке? Танчики на чёрном поле с кирпичными строениями, водоёмами и лесами защищают «орла» от других танчиков, при этом кирпичные стены разрушаемы.

Примерно так это было...
Примерно так это было...

Если в то время с теми средствами смогли сделать такую игру, то уж со всей мощью современных фреймворков такое должно получиться легко и просто! Да? Ведь да?..

1. Управление

Класс для настройки внешнего вида джостика есть. Но вот в моём случае игрок может двигаться и поворачиваться только на 4 направления. В Bonfire же реализовано движение в любом направлении под любым углом. Нужно было как-то урезать эту функциональность. Этот функционал реализуется примесью MovementByJoystick, которую по факту нам придётся полностью скопипастить в интересующий класс и убрать лишний код из методов. В этот момент пока ещё всё хорошо – ну подумаешь, один скопипащеный класс, жить можно! Идём дальше…

2. Готовая сущность Enemy

Здесь мы сталкиваемся с той же ситуацией: нам нужно ограничить движение до 4х направлений. Уже ничего копипастить не надо, но наступает путаница в обилии трейтов и в том, где вызывать нужный код: в update или в onMove? И как не спутать очерёдность вызовов, ведь есть ещё moveLeft, moveRight и подобные функции… Разобраться не проблема, вопрос в том, что на это потребуется время, чтобы утрясти все взаимозависимости и глубокую иерархию вызовов в классах, увешанных многочисленными примесями. Тот момент, когда кажущаяся простота системы преподносит первый сюрприз.

3. Готовые сущности снарядов, нанесение урона

Вот тут начинается интересное. В механике Bonfire снаряд реализован через FlyingAttackObject, который в момент столкновения с любым компонентом (у которого есть информация о коллизиях, конечно) будет взрываться. А урон будет наноситься только объектам типа Attackable. Звучит красиво, но давайте вспомним оригинальную игру:

  1. В ней есть танки, которые получают урон и к ним применима стандартная механика

  2. В ней есть разрушаемые стены, к которым уже сложно её применить в неизменном виде, т.к. они не сразу «взрываются», а удаляются отдельные блоки.

  3. Также, на карте есть вода, которая, несомненно, должна обрабатывать столкновения, т.к. плыть по ней нельзя, но и снаряды над ней должны свободно пролетать.  

Что делать? Рецепт следующий:

  1. С танками оставляем как есть.

  2. Для стен – вариант собрать стены из блочиков минимального размера отбрасываем – это у нас карта будет из 4-пиксельных тайлов, я молчу о нагрузке на систему, но даже рисовать это в редакторе будет довольно утомительно. Есть другой способ: при попадании снаряда определяем «направление прилёта» и уменьшаем тайл по этому вектору на половину от полного размера. Если размер стал нулевой – то удаляем объект из игры вообще. Супер, решение готово, только от встроенной механики придётся отказаться – переписываем onCollision под себя полностью.

  3. Для воды… придётся переписать снова onCollision, на этот раз уже внутри класса снаряда, и игнорировать объект, если он оказался тайлом с водой.

Выглядит просто, но вообще одна сложность тянет за собой другую: для Bonfire все тайлы одинаковы, и чтобы блок с водой отличался от блока с кирпичами или просто землёй, надо будет попотеть… об этом ниже, в п.7

4 и 5. Движение противника в сторону персонажа, построение маршрута до точки

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

Построение маршрута до точки тоже принесло сюрпризы. Во-первых, оно реализовано только для игрока. Для любого другого объекта его нужно переписывать. Здесь автором использовался сторонний пакет https://pub.dev/packages/a_star_algorithm , который достаточно прост, чтобы воспользоваться им напрямую. Но это автоматически порождает следующую проблему: на сложном рельефе и на далёкой дистанции алгоритм ощутимо замедляет FPS, поскольку крутится в одном потоке со всей остальной логикой. А если танков будет несколько, то… в общем, пришлось ограничиться реализацией алгоритма случайного движения и изменения маршрута на поворотах, плюс стрельба, если игрок на линии огня. Это, конечно, не то, что хотелось бы, но разобраться с ботом стало уже сложнее, чем в оригинальной игре.

6. Поддержка карт Tiled, разные уровни высоты для игровых объектов.

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

В Flame для этого есть параметр priority – целое число, чем оно больше, тем «выше» расположен объект. Как оказалось, Bonfire не предоставляет такой гибкости. Единственный способ влияния на высоту объектов на карте – это указать в редакторе Tiled в качестве типа тайла строку «above», тогда объект будет отображен поверх всех объектов на карте. В последних коммитах ещё появился какой-то dynamicAbove, но судя по коду всё равно вручную управлять высотой не получится.

Bonfire берёт это на себя, потому что ориентирован на игру с камерой, показывающей события «под углом». Соответственно, один объект может перекрывать другой, просто потому что стоит ниже по оси Y. Возможно, когда-нибудь, автор доработает фреймворк, чтобы была поддержка нескольких уровней высоты, а пока что они жестко «прошиты» в классе LayerPriority, и единственный способ добавить свой «слой» - залезть в код фреймворка.

Почему я вообще в это полез? В оригинальных танчиках фон чёрный, после разрушения стены просто исчезают. Я решил добавить вместо чёрного фона текстуру земли, а на месте уничтоженной стены – текстуру пепла. Соответственно, пепел должен лежать на самом низком уровне, чтобы любые другие объекты были гарантированно выше него, но только не земля. Увы, как я уже писал выше, единственная возможность как-то повлиять – это лезть в код фреймворка, так что в конечном итоге мне таки пришлось его форкнуть и дописать свои изменения, введя дополнительный слой объектов, расположенных «ниже всех».

Не очень приятная новость, когда ожидал прийти «на всё готовое».

7. Создание из объектов на карте кастомных классов в зависимости от имени объекта

Это следующий момент, который потребовал радикального вмешательства в код фреймворка.

Как оказалось, все тайлы для Bonfire на одно лицо. По типу друг от друга он отличает только «объекты», а это совсем другая сущность, которая в Tiled не имеет ни своей картинки, ни тем более анимации – а только точку на карте и форму (условно говоря – квадрат). Изначально я предполагал, что с помощью Bonfire у меня получится создать классы воды и кирпичей из объектов на нарисованной карте, но не тут-то было. По факту, подход в Bonfire оказался такой:

  • Все игровые объекты, с которыми мы взаимодействуем, должны создаваться в Tiled на уровне объектов. При загрузке карты они будут преобразованы в нужный класс, но вот загрузку спрайтов, анимации придётся писать уже самому.

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

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

В моём случае без этой возможности было крайне неприятно оставаться, потому что:

  • Нужно знать тип тайла, над которым пролетает снаряд, чтобы отличить воду от кирпичей

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

  • Плюс я хотел реализовать дополнительную механику, чтобы игрок мог «спрятаться» в лесу от вражеских танков

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

Как итог – в дополнение к objectBuilder были реализованы дополнительно tileBuilder и decorationBuilder, и теперь я мог сделать вот так:

Теперь у каждого кирпича или дерева будет свой класс с поведением.
Теперь у каждого кирпича или дерева будет свой класс с поведением.

Ну и вдовесок – получил ещё знаний о внутреннем устройстве Bonfire, которых, правда, мне вовсе не хотелось…

Бочка дёгтя в ложке мёда: производительность.

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

А дальше меня ждал сюрприз. В виде 20-30 FPS на десктопе и 12-15 FPS на мобилке.

Добавил 6 противников, большое озеро, лес - и приложение уже крутиться со скрипом...
Добавил 6 противников, большое озеро, лес - и приложение уже крутиться со скрипом...

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

«Может, я что-то криво нафоркал?» - подумал я и пошел компилировать и запускать на разных устройствах демки Bonfire от разработчика. И вот так сюрприз, оказалось, что и они выдают не более 30 FPS! Но там и карты довольно скромные по размерам, и количество объектов на них небольшое.

Проект немного попылился аж до выхода Flutter 3. На третьей версии картина стала лучше: иногда на почти пустой карте даже получалось увидеть 60 FPS. Но в основном таки 30...

Flutter & games: пробуем Bonfire в роли игрового движка - 4

Собственно, на этом я и закончил попытки сделать что-либо на данном фреймворке. Напоследок лишь ещё раз заглянул к нему «под капот» и сравнил увиденное с тем, как всё устроено в «ванильном» Flame. По итогам раскопок, перечислю причины, по которым, как мне кажется, Bonfire значительно уступает, и при текущей архитектуре и дальше будет уступать Flame в скорости:

Фоновые операции

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

В Bonfire в фоне постоянно крутятся операции:

  1. Определяются видимые и невидимые на экране объекты. То есть в цикле перебираются все сущности, которые были добавлены в игру. Честно, у меня вызывает большие сомнения необходимость этой операции, по идее задача не выполнять ненужный рендер должны реализовываться (и гораздо более производительно) на уровне графического движка, Skia…  

  2. Определяется priority для каждого компонента игры. Как я писал в п. 6, автоматический расчёт этого параметра нужен, чтобы реализовать «камеру под углом». Однако, можно было бы тут добавить какие-то оптимизации, например, не перерассчитывать, если ничего не сдвинулось, и в целом дать возможность создавать пользовательские «слои», чтобы объекты, порядок перекрытия которых никогда не изменится, даже не участвовали бы в расчёте. Но этого нет, обсчёт происходит для всех объектов.

  3. Производится расчёт столкновений. При этом автор попытался оптимизировать расчёт, выбрасывая из него те объекты, которые не попали к вам на экран. То есть, при таком поведении, ваши враги могут спокойно путешествовать сквозь стены, пока вы их не видите, и потом резко «застрять в текстурах», если невовремя попадут к вам в кадр…

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

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

Отрисовка тайлов.

Flame рисует тайловую карту, не создавая лишних объектов на каждый тайл. Вообще тайлы в Flame исключены из каких либо вычислений, кроме процесса рендера самих себя. Для этого используется SpriteBatch: https://pub.dev/documentation/flame/latest/sprite/SpriteBatch-class.html. Это ещё не вершина возможной оптимизации, но всё-таки значительно быстрее, чем то, как сделано в Bonfire….

А в Bonfire всё просто: каждый тайл – это объект. То есть каждый тайл рисует свой маленький кусочек спрайта «на общих основаниях», а также участвует в определении, видим ли он на экране или нет. Во-вторых, каждый тайл потенциально содержит коллизии, соответственно и в процессе определения столкновений участвует каждый тайл вашей карты.  

Обработка коллизий.

Обработка коллизий в ванильном Flame сделана следующим образом:

  1. Есть пассивные объекты, вроде тех же кирпичных стен, которые просто стоят на месте и сами ни в кого не врезаются, а также не контактируют между собой.

  2. Есть активные объекты, вроде танков или пуль, которые как раз таки активно перемещаются по карте, сталкиваются друг с другом и с пассивными объектами.

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

Что сделано в Bonfire: для этого движка «все равны». Коллизии будут одинаково просчитываться как между танком и тайлом, так и между одним тайлом и другим тайлом… будут вызываться соответствующие колбэки и т.д. Более того, Bonfire при движении объектов вызывает дополнительную проверку, а не столкнётся ли движущийся объект с чем-нибудь в следующий тик? Таким образом, мы получаем двойную проверку столкновений всех игровых объектов между всеми игровыми объектами. Даже теми, которые заведомо не пересекутся никогда. И всё это в одном потоке!

Загрузка анимаций

По сравнению с вышеперечисленным это капля в море, но всё же: автор Bonfire в основном грузит все анимации через Future, передавая на вход только имя файла, и дальше при создании каждой новой анимации весь процесс разбора изображения на кадры должен пройти заново. Хотя можно было бы после первой загрузки анимации все последующие «клонировать» из уже скомпилированной предустановленной, тем самым сократив процесс… ну и не гонять лишний раз данные через Future, а вернуть их синхронно.

Итоги.

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

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

Остаётся ли для Bonfire хоть какая-то область применения? Думаю, да, но при этом ваш проект не должен быть большим: маленькая карта, малое количество объектов на ней, максимально простая логика. Если понимаете, что 90% вашей работы над игрой – это подготовка ассетов и расстановка их в Tiled, тогда, пожалуй, берите.

Будет ли Flame более производительным? Однозначно да, но не думайте при этом, что движок вам простит создание и просчёт тысяч и тысяч объектов. Тут легко скатиться до того же, к чему пришел Bonfire. Понадобится ряд «ручных» оптимизаций, которые под Flame гораздо легче реализовать, и они дадут ощутимый буст производительности. Но подробнее об этом я, пожалуй, расскажу в следующий раз, и постараюсь со ссылкой на нормально оформленную либу с инструментами, которые позволят такое сделать.

Если кому-то вдруг интересно, исходный код проекта с экспериментами над Bonfire лежит тут: https://github.com/ASGAlex/battletank_game

Автор: Алексей Волков

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js