Создание казуальной 2D игры. Пошаговая инструкция при помощи Flame и Flutter (часть 1 из 5)

Перевод урока 2D Casual Mobile Game Tutorial – Step by Step with Flame and Flutter (Part 1 of 5)

Вы когда-нибудь задумывались о разработке видеоигр? Если да, то вы пришли по адресу! Эта статья расскажет и покажет, как создать свою собственную 2D мобильную игру.

Эта статья — продолжение предыдущей, которая является введением в разработку игр для разработчиков любой степени продвинутости. В этой же части мы будем создавать игру в стиле «убить крота».

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

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

В конце этого материала будет демо-видео.

Серия будет состоять из 6 частей:

  1. Введение в разработку игр
  2. Создание игры (этот перевод статьи)
  3. Графика и анимация
  4. Экраны и диалоговые окна
  5. Счет, хранилище и звук
  6. Завершение: сборка и публикация игры

Подготовка

Убедитесь, что ознакомились с первой частью материала во избежание возникновения проблем и вопросов.

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

Как и в предыдущей части, мы будем фокусироваться на разработке приложения для Android, но стоит помнить, что приложения Flutter могут быть скомпилированы и для iOS.

Список программного обеспечения, которое нам понадобится:

  1. Microsoft Visual Studio Code — любой текстовый редактор или IDE будут работать, если вы профи и понимаете, что делаете. Если вы новичок — придерживайтесь VS Code. Загрузите его на официальном сайте, и, желательно, установите плагины Flutter и Dart для VS Code.
  2. Android SDK— требуется для разработки приложений Android. Загрузите и установите Android Studio, чтобы получить все необходимое для создания приложений на этой ОС. Если вы не хотите устанавливать Android Studio целиком и заинтересованы только в SDK, прокрутите вниз до раздела «Command line tools only» на странице загрузки.
  3. Flutter SDK/Framework — это, а также плагин Flame, нам понадобятся для разработки игр. Используйте это официальное [руководство от Flutter] (https://flutter.dev/docs/get-started/install) и следуйте инструкциям вплоть до тест-драйва.

Полный код для этого туториала доступен для просмотра и загрузки на GitHub.

Давайте начнем!

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

Файлы будут ссылаться с использованием относительной точечной записи. К примеру, если ваш проект находится в /home/awesomegamedev/project, то ./lib/main.dart означает, что речь идет про /home/awesomegamedev/project/lib/main.dart. То же самое с Windows: если ваш проект находится в D:\Projects\SampleGame, ./lib/main.dart означает D:\Projects\SampleGame\lib\main.dart.

Flutter использует язык программирования Dart. Мы будем использовать Flutter 1.2, который использует Dart 2, в котором некоторые ключевые слова и аннотации, например, @override и new — опциональны, так что мы не будем их использовать.

Шаг 1: создание игры с помощью Flutter/Flame

Как и говорилось ранее, мы будет создавать игру под названием Langaw, однако вы можете дать своей версии любое другое, просто не забудьте заменить все упоминания langaw на свое название.

Создание проекта

Плагин Flutter для VS Code имеет команду для создания приложений Flutter. Просто нажмите Ctrl+Shift+P и напишите «Flutter». В появившемся меню нажмите Flutter: New Project, напишите имя своего проекта и выберете, куда вы хотите его поместить.

Или же вы можете инициализировать свой проект в VS Code. Просто откройте терминал (командную строку), перейдите в папку ваших проектов и впишите flutter create langaw:

Как только ваша команда будет выполнена, вы сможете открыть ее в VS Code и продолжать работать уже в нем.

Чистка кода

Как и в предыдущей части, мы не будем проводить тестирование (разработку через тестирование), так как это требует гораздо больших усилий. Так что мы просто удалим директорию ./test со всем, что в ней находится (файл с именем widget_test.dart).

Затем удалим весь тот код, с которым шла установка Flutter. Откройте файл ./lib/main.dart и удалите все строки ниже декларации void main. После этого измените декларацию основной функции, чтоб она стала пустой:

Весь файл должен выглядеть так:

Мы оставили инструкцию import для библиотеки material Flutter, так как позже она понадобится нам для доступа к функции runApp.

Плагин Flame

Теперь нам нужно установить плагин Flame. Это один из пакетов Dart, который можно очень просто добавить в свой проект: добавив строку к файлу ./pubspec.yaml.
Откроем ./pubspec.yaml и вставим flame: ^0.10.2 под строкой cupertino_icons: ^0.1.2.

Этот этап необязательный, но, если вы хотите, вы можете подчистить ./pubspec.yaml, удалив все комментарии (строки, начинающиеся с #).

После всей проделанной работы, файл должен выглядеть так (если вы удалили комментарии):

После добавления пакетов в ./pubspec.yaml, запустите в терминале flutter packages get, чтобы убедиться, что плагин загрузился в ваш компьютер:

Или же используйте для этого команду VS Code. Нажмите Ctrl+Shift+P, впишите «Flutter» и нажмите Flutter: Get Packages.

Примечание: с плагинами Dart и Flutter в VS Code, каждый раз, когда вы редактируете и сохраняете ./pubspec.yaml, эта команда автоматически запускается. Но для достоверности, запустите ее самостоятельно, это займет всего несколько секунд.

Инициализация игры

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

Мы сделаем это, используя класс Util из одноименной библиотеки Flame. Вернемся назад, к файлу ./lib/main.dart, импортируем библиотеку util с прилагаемым кодом. Также импортируем библиотеку services Flutter, это даст нам доступ к классу DeviceOrientation, который понадобится нам позже для уточнения ориентации. Все импортированные инструкции должны пойти вверх файла.

Затем мы конвертируем главную функцию в асинхронную, чтобы мы могли ожидать (await) длительный процесс. Просто введите ключевое слово async между () и {} в строке главной функции, а затем вставьте эту строчку кода внутрь главной функции:

Файл должен выглядеть так:

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

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

Игру можно запустить прямо из VS Code, нажав F5, а также из терминала, используя в директории проекта команду:

Должен появиться пустой экран, как на скриншоте.

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

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

Шаг 2: настройка класса игры с игровым циклом

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

Класс игры

Давайте создадим файл с названием ./lib/langaw-game.dart, и напишем в нем следующий код:

Краткое пояснение: нам нужен доступ к классам холста (Canvas) и размера (Size). Мы импортируем пакет ui от Dart. Также нам надо использовать уже существующий код Flame для игрового цикла, для этого мы импортируем библиотеку Flame game. Затем создаем класс, расширяющий класс Flame Game (в котором находится игровой цикл). В этом классе есть три метода, переопределяющие методы Game с одинаковым названием. У нас также есть переменная screenSize, которая определяет размер экрана.

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

Файл класса должен выглядеть так:

Соединение и запуск игрового класса

Давайте соединим класс игры с главной функцией, чтобы при запуске игры активировался экземпляр класса LangawGame. Перейдем обратно в ./lib/main.dart и импортируем наш новый файл класса.

Затем создадим экземпляр класса игры и вызовем функцию runApp. Ей понадобится Widget, так что мы передаем нашему экземпляру класса LangawGame свойство widget.

В конечном итоге, ./lib/main.dart должна выглядеть так:

Размер экрана

Теперь вернемся немного назад и отредактируем наш класс игры ./lib/langaw-game.dart. Подготовим его к рисованию, а затем и к движению объектов, определив размер экрана.

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

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

Холст, кстати, это то, на чем мы рисуем игровые объекты, включая фоновые рисунки, врагов и остальной интерфейс.
Под методом resize поместим следующий код:

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

Теперь файл класса игры должен выглядеть так:

Создание фонового рисунка

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

Чтобы наш фон не казался скучным, мы не будем делать его просто белым или черным. Мы будем использовать цвет под названием Fuel Town (#576574) из «Канадской палитры» сайта FlatUIColors.com. Вы же можете использовать любой другой цвет на свое усмотрение. Просто будьте аккуратны с выбором, некоторые цвета слишком ярки и тяжело воспринимаются глазом.

В метод render вставьте следующий блок:

Пояснение: создаем прямоугольник (экземпляр bgRect класса Rect) такого же размера, что и экран. Создаем покраску объекта (экземпляр bgPaint класса Paint), а затем назначаем ей цвет (в нашем случае, #576574). Созданный прямоугольник рисуется на холсте при помощи методов холста drawRect для определения размера и bgPaint для определения цвета.

Метод рендера должен выглядеть так:

И, при запуске игры, вы должны видеть следующее:

Поддержка телефонов разных размеров

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

Согласно этой статье в Quartz, на 2015 год было зарегистрировано 24000 уникальных моделей устройств, работающих на Android. Идет уже 2020 год, и логично предположить, что это число только выросло.

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

Пропорции — это соотношение высоты и ширины устройства. И, конечно, ширина и высота могут меняться значениями при повороте телефона.

Существует тысячи (если не больше) соотношений сторон экрана мобильных устройств, например, 3:2, 4:3, 8:5, 5:3, 16:9 и даже такие длинные, как 18.5:9. Самая распространенная пропорция — 16:9, поэтому мы будем ее использовать, как базу.

Так как наша игра запускается в портретном режиме, пропорции будут не 16:9, а 9:16. Нет, мы не всегда будем использовать это соотношение; мы возьмем одно значение и будем использовать его как основу. Возьмем ширину, равную 9. Тогда наша основа размеров будет 9:X.

Преобразовывая те примеры пропорций экрана, что упоминались выше, мы получим 9:13.5, 9:12, 9:14.4, 9:15, 9:16 и 9:18.5.

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

Что важнее, размер наших мух не зависит от значений соотношения сторон нашего устройства. Для заполнения всей ширины нашего экрана, понадобится 9 помещенных вплотную друг к другу мух.

Создание сетки карты тайлов

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

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

Класс игры должен выглядеть так:

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

Шаг 3: Создание компонента «fly»

И вот мы готовы к созданию первой составляющей нашей игры. Но что такое компонент?

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

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

Зачем нужны компоненты?

Как упоминалось в предыдущей части, игровой цикл — это сердце игры, без которого ее бы попросту не существовало. И зачем тогда компоненты?

Подумайте о какой-нибудь большой игре, в которую вы играли. В ней происходит одновременно большое множество вещей и событий. И представьте себе код этой игры, написанный в одном файле, в нашем случае, в игровом классе ./lib/langaw-game.dart. У нас были бы тысячи строк кода, что было бы крайне неудобно для разработки.

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

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

А теперь приступим к созданию одного из них.

Наш самый первый игровой компонент

Нам нужно место для хранения наших новых компонентов, поэтому создадим новую папку в ./lib и назовем ее components. Внутри нее создадим новый файл под названием fly.dart.

Открываем только что созданный файл компонента (./lib/components/fly.dart) и пишем класс компонента:

Пояснение: Сначала мы импортируем пакет ui для доступа к классу Canvas. Затем мы обозначаем класс под названием Fly с двумя методами: update и render.

Прямо как в игровом цикле.

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

Положение и размеры

Компонент fly должен помнить свою позицию и свой размер. Для этого мы создадим переменные экземпляра объекта.

Мы могли бы создать double x;, double y;, double width;, и double height; для запоминания положения, но это целые четыре переменные. Есть кое-что получше.

Существует множество вариантов типов данных, имеющих значения x/y или width/height: классы Dart Size и Offset, math Point и класс Flame Position. Но при их использовании нам все равно понадобятся две переменные. Одна для ** x/y** и вторая для width/height. Но мы все равно сможем использовать всего одну переменную.

Помните Rect, который мы использовали при рисовании фона? К моменту создания экземпляра (при помощи fromLTWH), вы должны были определить его левую сторону (или х), верх (у), ширину и высоту. Единственным недостатком является то, что экземпляры Rect являются неизменными. Это означает, что вы не можете изменять какие-либо его параметры (например, верх или левую часть) непосредственно устанавливая новое значение. Но это не важно, так как мы можем использовать методы shift и translate этой переменной.

Давайте добавим эту переменную экземпляра и назовем ее flyRect. Помните, что переменные экземпляра определяются в классе, чуть ниже строки определения класса.

Затем нам нужна ссылка на класс игры для доступа к таким свойствам, как screenSize. Сначала нам нужно импортировать:

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

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

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

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

Пояснение: мы определяем метод конструктора, используя имя класса как имя метода.

Этот конструктор принимает три параметра. Первый параметр (this.game) дает значение тому, что передается в свойство игры. Следующие две переменные (x и y) будут начальной позицией нового экземпляра.

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

Файл класса должен выглядеть так:

Рисуем fly (муху - квадрат или прямоугольник)

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

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

Добавьте следующую строку под определением flyRect:

Затем инициализируем flyPaint в конструкторе (прямо под инициализацией flyRect). Вновь используем цвет из FlatUIColors.com (Pure Apple #6ab04c из палитры Aussie).

Теперь мы готовы к рендерингу. Впишите эту строчку кода внутрь метода render:

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

Класс Fly должен выглядеть так:

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

Шаг 4: появление fly (мухи)

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

Если вы немного отступите назад и откроете ./lib/langaw-game.dart, вы увидите, что, когда метод рендеринга запускается, размер экрана (screenSize) уже установлен. Все потому, что методы вызываются в следующем порядке:

  1. Создается экземпляр класса (запускается конструктор, но мы пропускаем этот шаг, так как у нас его нет);
  2. Flutter вызывает метод изменения размера (resize) и устанавливает размер экрана (screenSize);
  3. Запускается игровой цикл;
  4. Игровой цикл вызывает обновление (update);
  5. Игровой цикл вызывает отображение (render);
  6. Игровой цикл завершается. Алгоритм возвращается к шагу 3.

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

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

Есть плохая сторона. И она заключается в том, что этот метод может быть заново вызван Flutter при изменении телефоном разрешения или при другом событии, когда телефон вынужден менять размер экрана (смена ориентации, например). И, если мы поместим наш код инициализации внутрь метода resize, он может запуститься несколько раз. Но код инициализации должен быть запущен лишь один раз. Представьте своего главного героя; вы переворачиваете телефон вверх ногами, метод resize вызван и запускает код инициализации (снова), создавая еще одного главного героя. Просто беспорядок!

Безобразие же состоит в том, что мы можем обойти этот беспорядок и все равно использовать метод resize как лаунчер для нашего кода инициализации. Мы могли бы объявить переменную экземпляра логического типа и назвать ее isInitialized, и назначить ей значение по умолчанию false. В функции resize мы могли бы проверить, является ли эта переменная false. Если все верно, мы могли бы запустить код инициализации и изменить значение на true.

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

Ожидание размеров при инициализации

Пока ./lib/langaw-game.dart открыта, напишем два метода внутрь класса LangawGame: конструктор и метод под именем initialize.

Класс конструктора будет содержать всего одну строку: вызов метода initialize.

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

Именно поэтому наш код инициализации не может быть прямо внутри конструктора и должен быть размещен в отдельном методе. Конструкторы Dart не могут быть асинхронными!

Введите следующий блок кода в класс, прямо под объявлениями переменных экземпляра:

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

Затем, внутри метода инициализации, мы пишем такую строку:

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

Наш метод resize принимает один параметр типа Size (размер). Функция утилиты initialDimensions возвращает Future<Size>, чтоб мы могли ожидать (await) завершения Future и получить размер (Size).

Как только мы получим значение Size, мы вставляем его в resize.

Мы могли бы вставить это значение напрямую в screenSize, но нам необходимо рассчитать размер тайла (tileSize). К тому же, нам нужно будет рассчитать некоторые другие вещи в будущем, так что лучше оставить их в методе resize, а затем просто вызвать их для перерасчета.

Теперь файл игры должен выглядеть так:

Готовимся к flies (мухам)

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

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

В Dart не существует массивов, но у нас есть доступ к List, что, в принципе, то же самое, только даже лучше. Добавим переменную экземпляра с именем flies:

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

Файл должен выглядеть так, если все синхронизировано:

Даже если у нас сейчас пока что нет мух, нужно убедиться, что, если мы их добавим в игру, они будут отрендерены и обновлены. Чтобы это сделать, нам нужно перебрать всех мух, используя метод List под названием forEach и вызвать соответствующие методы для update и render.

Также мы должны инициализировать переменную flies как можно раньше. Мы не можем вызвать forEach при значении null.

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

Строка ниже идет внутрь метода update:

Пояснение: вас может смутить forEach. Когда вы вызываете метод forEach, он требует function как параметр. Затем эта функция вызывается для каждого предмета в списке, передавая элемент в текущий итерации в качестве параметра.

Функция должна придерживаться определенного формата и принимать один параметр, который имеет тот же тип, что и каждый элемент в вызванном списке forEach — в нашем случае, это класс Fly.

Анонимные функции обозначаются () {}. Параметры идут внутрь круглых скобок, и, так как нам нужна функция, которая принимает только один параметр (типа Fly), мы пишем функцию как (Fly fly) {}. Наше тело анонимной функции состоит только из вызова единственной функции для параметра, переданного forEach, так что мы можем написать ее как (Fly fly) { fly.render(canvas); }.

Если тело нашей функции в Dart состоит только из одной строки, мы можем использовать большую стрелку записи, что еще больше сжимает функцию, делая ее пригодной для объявления анонимных функций в одну строку. Вместо фигурных скобок вы можете отобразить однострочное тело функции, поместив перед ней большую стрелку (=>), например: (Fly fly) => fly.render(canvas). То же самое с копией update.

В конечном итоге, файл должен выглядеть так:

Создание fly (мухи)

Нашей игре придется регулярно создавать новых fly (мух), так что давайте приготовим метод добавления новых экземпляров, который можно будет многократно использовать. Создание — это звучит как-то громко, так что назовем это просто spawnFly.

Добавим этот метод в класс игры:

Пояснение метода: начиная внутри круглых скобок add(), мы создаем новую переменную класса Fly. Как вы можете помнить, конструктор этого класса требует три параметра: переменную LangawGame, начальную позицию Х и начальную позицию Y.

В качестве переменной LangawGame мы будем использовать текущий экземпляр, с которым работаем, так что укажем this. Для начальной позиции, укажем захардкоженные 50,50 (hard-coded – константы указанные непосредственно в коде программы в месте их вызова).

Теперь мы можем вызвать этот метод внутри метода initialize. Введите следующие строки кода в метод инициализации после определения размеров экрана (вызова метода resize):

Теперь файл должен выглядеть так:

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

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

Для этого нам понадобиться класс Random из пакета math от Dart. Импортируем его, поместив следующее в верх файла:

Затем создадим еще одну переменную экземпляра типа Random под названием rnd. Мы делаем это, чтобы она была многоразовой, и нам не надо было создавать новый экземпляр Random каждый раз, как нам понадобится что-то непредсказуемое.

Добавим переменную экземпляра:

Затем инициализируем ее внутри метода initialize:

Файл должен выглядеть так:

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

Отредактируем метод создания fly так, чтобы значения позиций X и Y были случайны. Random имеет метод под названием nextDouble, который возвращает значение типа double между 0 (включая) и 1 (исключая).

Мы вызовем этот метод и умножим его значение, вычитая ширину тайла fly (мухи) из ширины экрана, и назначим его начальным X. То же самое мы сделаем с начальным Y, только используя значения высоты тайла fly (мухи) и экрана.

Наша муха будет простым квадратом, поэтому ее ширина и высота равны. Но, что еще важнее, ее размеры равны размеру тайла (tileSize). Таким образом, чтобы получить значение, которое нам нужно, мы просто вычтем размер тайла либо из ширины, либо из высоты экрана.

Вставим следующие строки перед тем, как добавим новую Fly в список flies.

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

Передадим эти значения х и у в качестве начальной позиции при создании нового экземпляра класса Fly.

Метод spawnFly должен выглядеть так:

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

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

Шаг 5: падающие мухи

Для того, чтобы наши мухи «летали», наша игра должна принимать данные от игрока. Во-первых, что произойдет, когда игрок нажмет на муху? Муха должна стать красного цвета и опуститься до нижней части экрана. Как только она исчезает за границами экрана, мы уничтожаем этот экземпляр объекта, чтобы телефон не тратил ресурсы процессора, пытаясь его обновить.

Нажатия игрока

Эта часть кода уже описывалась в предыдущей части, так что мы не будем сильно углубляться. Во-первых, нам нужна функция обработчика в классе игры. Мы будем обрабатывать события onTapDown, что подразумевает TapDownDetails как параметр. Нам понадобится библиотека Flutter gesture (жесты). Импортируем ее, введя строки кода в ./lib/langaw-game.dart:

Затем добавим метод в класс, например, под метод изменения размера:

Файл должен выглядеть так:

Быстренько перейдем в ./lib/main.dart и импортируем библиотеку жестов:

Затем создадим распознаватель жестов. Свяжем его свойство onTapDown с обработчиком onTapDown класса игры и зарегистрируем распознаватель, используя метод утилиты Flutter addGestureRecognizer.

Главный файл должен выглядеть так:

Мы будем много прыгать с места на место, поэтому давайте откроем ./lib/components/fly.dart, пока мы здесь. Добавим обработчик события, который будет срабатывать, только если на экземпляр Fly нажали.

Нам не нужны такие детали, как место нажатия. Мы просто доверимся классу игры: если этот разработчик вызывается, то экземпляр Fly был задействован.

Добавьте обработчик событий под методом обновления (update)

Файл должен выглядеть так:

Теперь перейдем обратно в ./lib/langaw-game.dart. Внутри обработчика касания мы должны циклически пройтись по всем существующим мухам и проверить, находится ли позиция касания внутри квадрата мухи.

У класса Rect есть полезный метод под названием contains (содержание). Он подразумевает Offset как параметр и возвращает значение true, если переданный Offset находится внутри границ вызванного объекта Rect. В противном случае, он возвращает false.

С экземпляром TapDownDetails (d), данным нам в обработчике, нам повезло, потому что позиция касания сохраняется в свойстве globalPosition, которое является Offset.

Таким образом, мы можем просто передать свойство globalPosition в метод rect contains и мы будем знать, когда касание задело муху.

Добавим блок кода в обработчик onTapDown:

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

Класс Fly имеет переменную экземпляра flyRect, которая является Rect и, следовательно, имеет метод contains. Мы проверим, находится ли globalPosition переданного TapDownDetails внутри прямоугольника, используя метод contains.

Если внутри, значит, экземпляр Fly на текущей итерации forEach был нажат, и мы уведомляем эту муху о том, что на нее нажали, вызывая ее обработчиком onTapDown.

Обработчик onTapDown класса игры должен выглядеть так:

Раздавливаем муху

Обработаем нажатия на мух, открыв ./lib/components/fly.dart.

Первая вещь, которую мы изменим — цвет. При отображении мухи мы используем Paint объекта под названием flyPaint. Она имеет цвет и назначает оттенок Pure Apple, пока муха еще не нажата. Если мы изменим этот цвет, это должно отобразиться при следующем вызове render (которое, при 60 кадрах в секунду, будет моментальным для глаза человека).

Наша цель — изменить цвет на красный. Мы будем использовать палитру Chinese palette на сайте FlatUIColor.com, а конкретно цвет Watermelon (#ff4757).

Поместите следующую строку в обработчик onTapDown класса Fly.

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

Файл должен выглядеть так:

Падение мухи

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

Метод обновления (update) создан для всего кода, который вносит какие-либо изменения в игру, не вызванные действиями игрока. На данный момент метод обновления fly уже вызывается методом обновления игрового цикла, как можно увидеть в 42 строке ./lib/langaw-game.dart.

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

Создайте переменную экземпляра в ./lib/components/fly.dart при помощи строки:

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

Примечание: ограничительный прямоугольник на самом деле является прямоугольником Rect, только immutable (иммутабельным - неизменным), и обладает соответствующими свойствами, только неизменными. Такие свойства не могут быть переопределены, поэтому нам нужно перестроить Rect, используя его методы shift или translate.

Затем в обработчике onTapDown меняем значение на true, так как касание мухи убивает ее.

Чтобы создать анимацию падения, добавим строки кода в метод обновления:

*Пояснение: каждый раз, когда update вызывается (примерно 60 раз в секунду), муха проверяет, имеет ли ее свойство isDead значение true. Если да, то мы создаем новый Rect из уже существующего прямоугольника, вызывая его методом translate. Затем мы присваиваем этот вновь созданный экземпляр Rect обратно flyRect. *

Что касается значений: мы оставляем значение Х равным нулю, так как не хотим, чтобы при падении муха двигалась вправо или влево. А для значения Y мы используем новую переменную double t.

На самом деле, эта переменная должна называться timeDelta, но редактор кода назвал ее t, так что мы тоже будем использовать это обращение. Я буду ссылаться на нее как на «дельту времени» или изменение времени.

Когда мы говорим «60 кадров в секунду», это все равно, что сказать, что каждый кадр занимает промежуток времени, равный 16.666666666… миллисекундам. Расчеты могут быть сделаны на основе этого фиксированного числа.

Но наш мир не идеален, даже в нашем случае.

Мобильное устройство, на самом деле, запускает не только игру, но также и операционную систему, и приложения, работающие в фоновом режиме. Эти приложения могут делать что-то, что может занимать либо больше, либо меньше времени в каждом цикле. Процессор пытается уделить всем приложениям одинаковое количество «внимания», но некоторым требуется больше, некоторым — меньше.

Здесь нам и пригодится переменная времени. Она содержит количество времени, прошедшее с момента последнего запуска обновления (update). Значение этого времени в секундах.

Используя это значение, мы можем высчитать количество движения, которое должно произойти. Скажем, что игра по какой-то причине работает с идеальной постоянной скоростью, равной одному кадру в секунду. Следовательно, значение дельты времени равно 1. Если вы собираетесь передвигать объект со скоростью десять тайлов в секунду, вы должны добавить (или вычесть) 10 (умноженное на значение размера тайла), умноженное на 1 (значение дельты времени) к измерению, в которое должен передвинуться ваш объект. Это даст движение десяти тайлов в секунду.

Теперь представим, что игра работает с постоянной скоростью в 4 кадра в секунду. Дельта времени всегда будет 0.25. Используя ту же скорость движения (10 тайлов в секунду), мы перемещаем объект 10размер тайла0.25 (2.5размер тайла) на кадр. Учитывая, что у нас четыре кадра в секунду, движение все равно будет составлять десять тайлов в секунду (2.54=10).

Применяем эту логику с формулой game.tileSize * 12 * t, не важно, каково значение дельты времени, мы все равно получим постоянное движение 12-ти game.tileSize.

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

Теперь файл должен выглядеть так:

Создаем больше fly (мух)

Было бы совсем неинтересно, будь в игре всего одна муха. Чтобы сделать игру увлекательной, нам нужно добавить появление других мух на экране при смерти одной.

В обработчик onTapDown добавляем следующую строку:

Теперь он должен выглядеть так:

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

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

Наша игра почти готова!

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

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

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

Затем внутрь метода обновления добавим следующий блок (после движения мухи):

Пояснение: мы проверяем, имеет ли top прямоугольника экземпляра большее значение, чем высота экрана. Если это так, меняем значение isOffScreen на true.

*Примечание: как вы могли заметить, но начало (0;0) декартовой плоскости экрана находится в верхнем левом углу, а положительное направление Y идет вниз. В верхней части экрана значение Y равно нулю, а в нижней части оно равно высоте экрана.

Файл класса Fly должен выглядеть так:

Наконец, мы должны уничтожить экземпляры мух, значение свойства isOffScreen которых равно true.

Это задача может быть решена с помощью всего одной строки кода. Используем метод removeWhere из списка Dart. Он похож на forEach, только removeWhere ожидает метод, который возвращает логическое значение. isOffScreen — это логическое значение, и мы просто возвращаем его.

Добавим эту строку в метод обновления в ./lib/langaw-game.dart:

Пояснение: мы создаем анонимную функцию, которая берет Fly как параметр, что немедленно возвращает свойство isOffScreen экземпляра Fly, который считает параметром.

Затем эта анонимная функцию передается как параметр в метод removeWhere списка flies, что запускает переданный метод каждому экземпляру Fly в списке, удаляя экземпляр, если функция возвращает true.

Файл должен выглядеть так:

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

Тестируем игру!

Пора вкусить плоды нашего труда. Запустите игру, у вас должно получиться что-то похожее на то, что представлено в видео:

Заключение

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

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

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

В следующей части мы будем накладывать графику на объекты и создавать еще больше анимации.
Увидимся!

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