Создание мобильных игр при помощи Flutter и Flame для начинающих

Перевод урока Create a Mobile Game with Flutter and Flame – Beginner Tutorial

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

Если у вас возникнут какие-то проблемы, вы можете написать автору статьи на почту или присоединиться к его Discord.

Требования и рекомендации

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

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

Приложения, созданные на Flutter, могут быть скомпилированы и применены как на Android, так и на iOS. Эта статья будет сосредоточена на разработке для Android. Однако, как только вы закончите создание своего приложения, можно будет запустить другую версию команды build и играть уже на устройствах iOS.

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

  1. Microsoft Visual Studio Code — любой текстовый редактор или IDE будут работать, если вы профи и понимаете, что делаете. Если вы новичок — придерживайтесь VS Code. Загрузите его на официальном сайте, и установите плагины Flutter и Dart для VSCode.
  2. Android SDK — требуется для разработки приложений Android. Загрузите и установите Android Studio, чтобы получить все необходимое для создания приложений на этой ОС. Если вы не хотите устанавливать Android Studio целиком и заинтересованы только в SDK, прокрутите вниз до раздела «Command line tools only» на странице загрузки.
  3. Flutter SDK/Framework — это, а также плагин Flame нам понадобятся для разработки игр. Используйте это официальное руководство от Flutter и следуйте инструкциям вплоть до Test Drive Part.

Перейдем к созданию!

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

Весь код этого руководство доступен для просмотра и загрузки в этом репозитории GitHub.

Шаг 1: настройка приложения Flutter

Откройте терминал (интерфейс командной строки) и перейдите в каталог своих проектов. Оказавшись там, введите следующую команду:

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

Теперь вы можете либо открыть созданную папку boxgame в Visual Studio Code, либо сразу же запустить приложение при помощи команд:

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

Примечание: необходимо запустить эмулятор или подключить устройство Android со включенной отладкой по USB.

Посмотрите код этого шага здесь

Шаг 2: установка плагина Flame (и чистка проекта)

Примечание: с этого момента мы будем обозначать каталог проектов как ./. Таким образом, если ваш проект игры находится в /home/awesomeguy/boxgame, ./lib/main.dart ссылается на файл в /home/awesomeguy/boxgame/lib/main.dart.

Запустите Visual Studio Code и откройте каталог boxgame, созданный на предыдущем шаге.

Так как мы используем довольно-таки простой, но мощный плагин Flame, нам нужно добавить его в список пакетов Dart, на который будет опираться наше приложение. В левой части IDE вы увидите список файлов в папке вашего проекта. Откройте ./pubspec.yaml и добавьте следующую строку ниже строки cupertino_icons под dependencies (обратите внимание на отступы).

Должно получиться примерно так:

Если вы используете VS Code, IDE автоматически установит плагин при сохранении файла. Это можно сделать вручную, открыв терминал, перейдя в папку проекта и запустив

Следующий шаг — очистка основного кода путем удаления всего, что нам установил Flutter в файле ./lib/main.dart, и заменой этого всего на пустую программу.

Пустая программа состоит из всего одной строки: void main() {}. Вы можете заметить, что мы оставили import наверху. Также мы будем использовать библиотеку material при запуске метода runApp позже, при запуске игры.
У вас должно получиться:

Можно увидеть, что файл в папке ./test показывает ошибку. Если вы не используете VS Code, это, скорее всего, не будет отображаться, но ваше приложение все равно не будет работать. Тестирование (как и разработка через тестирование) вне рамок этого урока, так что, чтобы исправить ошибку, просто удалите всю папку test.

Просмотрите код для этого шага на GitHub

Шаг 3: настройка игрового цикла

Перейдем к настройке игрового цикла…
Но что же такое — игровой цикл?
Это сама суть игры, набор инструкций, которые компьютер запускает снова и снова.

В играх, как правило, есть показатель, называемый FPS, и он обозначает количество кадров в секунду (frames per second). Например, если FPS вышей игры равен 60 кадрам в секунду, то компьютер запускает игровой цикл 60 раз в секунду.

Проще говоря: один кадр = один проход игрового цикла.

Основной цикл состоит из двух частей: update и render (обновление и визуализация).

Первая часть, часть обновления (update), обрабатывает движения объектов (например, персонажей, врагов, препятствий и самой карты) и другие вещи, которые необходимо обновлять (к примеру, таймер). Большая часть действий происходит здесь. Например, вычисления, был ли враг поражен пулей, задел ли враг главного героя.

Вторая часть, рендеринг (render), отображает объекты на экране. Это отдельный процесс, так что все синхронизировано.

Зачем нужна синхронизация?

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

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

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

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

Использование Flame

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

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

Внутри функции main создайте экземпляр класса Util Flame. Затем вызовите функции fullscreen и setOrientation и убедитесь, что добавили к ним await (ожидание), т.к. эти функции возвращают Future.

Примечание: Futures (будущие результаты), async (асинхронность) и await (ожидание) — это модификаторы, позволяющие вам «ждать» завершения длительного процесса, не блокируя при этом все остальное. Если вы ходите узнать больше, вы можете прочитать статью на официальном сайте Dart.

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

Результат должен быть примерно таким:

Чтобы использовать заготовки игрового цикла, предоставляемые Flame, мы должны создать подкласс Flame Game. Чтобы это сделать, создайте новый файл под ./lib и назовите его box-game.dart.

Затем мы напишем класс BoxGame (вы можете использовать любой, если знаете, как они работают), который расширит класс Game.

И это — весь класс. Пока что.

Давайте подытожим: мы импортируем библиотеку Dart:ui для создания класса Canvas, а затем и класса Size. Затем мы импортируем библиотеку Flame/game, которая включает в себя класс Game, который мы расширяем. Все остальное — определения класса двумя методами: update и render. Эти методы переопределяют методы родительского класса (также называемого суперклассом) с тем же именем.

Примечание: в Dart 2 аннотация @override опциональна, если вы ее искали. По этой же причине мы не будем использовать ключевое слово new.

Следующий шаг — создание образца класса BoxGame и передача его свойства widget в runApp.

Перейдем обратно в ./lib/main.dart и введем следующую строку в самый верх файла:

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

И вот, наше мобильное приложение стало игрой!

Но, если запустить нашу игру, вы ничего не увидите, кроме черного/пустого экрана, так как на нем еще ничего не было нарисовано.

Ваш main.dart файл должен выглядеть так:

Просмотреть код для этого шага на GitHub

Важное примечание: Последнее обновление Flutter сломало функцию main. Чтобы легко это исправить, запустите runApp перед установкой ориентации и полноэкранного режима. Примерно так:

Вам больше не нужно ключевое слово await для строк полного экрана и ориентации, поскольку оно будет работать параллельно с запуском игры. Также можно удалить ключевое слово async для функции main.

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

Очевидно, что если вы следуете коду на GitHub, он будет отличаться от вашей рабочей копии с этого момента в этой части кода.

Шаг 4: рисование экрана

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

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

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

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

Переменная screenSize будет инициализирована со значением null. Это будет полезно при проверке того, известен ли нам размер экрана во время рендеринга. Подробнее об этом — позже.

Затем добавим переопределение для функции resize в ./lib/box-game.dart

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

Примечание 2: переменные экземпляра – это переменные, доступные из всех методов/функций класса. Например, вы можете установить его на изменение размера, а затем получить его значение при рендеринге.*

Ваш код должен выглядеть примерно так:

Холст и фон

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

У нас есть доступ к Canvas (холст) из функции рендеринга. Flame заранее подготовил и предоставил нам холст. Canvas — практически то же самое, что и настоящий холст для рисования. После того, как мы нарисуем объекты для нашей игры (пока что только прямоугольник) на холсте, Flame берет их и отображает весь холст на экране.

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

Сначала мы нарисуем задний фон. В нашем случае, это будет просто черный экран. А нарисуем мы его следующим кодом:

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

Втора строка обозначает Paint (окраску) объекта, а за ней — строка Color, обозначающая цвет этой окраски. Формат цвета — 0xaarrggbb, что означает Alpha (непрозрачность) и значения RGB (красный, синий, зеленый).

Полностью непрозрачный белый — 0xffffffff, а полностью непрозрачный черный — 0xff000000. Чуть позже мы поговорим об остальных обозначениях цветов.

Последняя строка рисует прямоугольник на холсте (Canvas), используя прямоугольник (Rect) и краску (Paint), значения которых были определены нами в предыдущих строках.

Самое время протестировать!

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

Рисуем прямоугольник

Теперь мы рисуем основной прямоугольник в центре экрана:

Подытожим снова: первые две строчки определяют переменные, содержащие координаты центра экрана. Их значения составляют половину размера экрана. Double — это тип данных Dart для дробных чисел.

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

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

Остальная часть кода точно такая же, как при рисовании заднего фона. Функция рендеринга (render) теперь должна выглядеть так:

А при запуске игры вы должны видеть что-то примерно такое:

Посмотреть код для этого шага на GitHub

Шаг 5: обработка нажатия и условия победы в игре

Вот мы почти и закончили! Осталось только принять ввод игрока. Сначала, нам нужна библиотека жестов Flutter, ее надо импортировать. Добавим следующую строку в верх файла класса игры (./lib/box-game.dart) к остальным импортам.

Затем добавим функцию обработки нажатий, используя код:

Затем, в ./lib/main.dart, зарегистрируем GestureRecognizer и свяжем событие onTapDown с обработчиком onTapDown нашей игры. Помните, что нам нужно импортировать библиотеку жестов Flutter сверху, чтобы мы могли использовать в этом файле также класс GestureRecognizer.

Внутри функции main, прямо под инициализацией BoxGame, опишем TapGestureRecognizer и свяжем его событие onTapDown с обработчиком onTapDown игры. Наконец, после строки runApp, зарегистрируйте распознавание жестов, используя функцию Flame addGestureRecognizer.

Файл ./lib/main.dart должен выглядеть примерно так:

Перейдем обратно к классу игры ./lib/box-game.dart

Для того, чтобы выиграть игру, добавим еще одну переменную, которая будет определять, выиграл игрок или нет. Подойдет простая логическая переменная со значением по умолчанию false. Поместите следующее под screenSize:

Затем, в функции render напишем условие, которое назначит boxPaint зеленый цвет, если игрок выиграл; в противном случае белый цвет.

Замените строку назначения цвета на следующее:

Функция рендеринга должна выглядеть так:

Теперь обработаем событие касания (функцию onTapDown). Нужно убедится, что игрок нажал внутри квадрата. Если все верно, изменить значение переменной hasWon на true.

Как и с предыдущим блоком кода, давайте подытожим: первые две строки определяют координаты центральной точки экрана. Следующие пять строк — это выражение if (если), расположенное вертикально. Оно проверяет, находится ли точка касания внутри поля 150х150 логических пикселей по центру экрана.

Если нажатие произошло внутри, значение переменной hasWon меняется. Это изменение отражается при последующем вызове render.

Конечной формной обработчика onTapDown должно быть:

Показать код для этого шага на GitHub

Время протестировать игру!

Запустите свою игру, и, если вы все сделали верно, вы должны увидеть примерно то же, что и на этом видео:

Заключение

Это игра! И вы сами ее создали!

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

Подборка заметок