Views (экраны) и диалоговые окна — пошаговая инструкцию с Flame и Flutter (часть 3 из 5)

Перевод урока Views and Dialog Boxes Tutorial – Step by Step with Flame and Flutter (Part 3 of 5)

Когда только начинаешь изучать разработку игр, думаешь, что самое тяжелое — это начать, продумать логику геймплея, найти и создать графику. Но это не так.

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

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

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

Подготовка

  1. Перечень всего необходимого вы можете найти в предыдущих частях
  2. Еще больше графических ресурсов — пакет ресурсов уже имеется в этом туториале, но вы можете создать свой собственный или найти его на Open Game Art.

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

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

Новый пакет ресурсов

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

Его можно скачать по этой ссылке.

Важное примечание: этот пакет можно использовать только в рамках этого обучения. Он является частью проекта Langaw на GitHub, который имеет лицензию CC-BY-NC-ND.

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

Разработка. Создание кода.

В конце преыдущей части мы получили интерактивную игру с достойными графикой и анимацией.

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

Мы добавим новые графические ресурсы для узнаваемости, установим экран приветствия, обновим логику появления объектов, а также создадим диалоговые окна «как играть» и упоминание авторов.

Шаг 1: новые графические ресурсы

Скачайте пакет ресурсов и поместите его в директорию ./assets/images.

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

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

  1. Изображение с названием игры (предпочитаемое соотношение сторон 7:4; 7 на 4 плитки)
  2. Кнопка «старт» (соотношение 2:1; 6 на 3 плитки)
  3. «Вы проиграли» (7:5; 7 на 5 плиток)
  4. Диалоговое окно с титрами (3:2; 12 на 8 плиток)
  5. Диалоговое окно помощи (такого же размера, что и окно титров)
  6. Иконка титров ( желательно, квадрат, размером с 1 плитку)
  7. Иконка помощи (такого же размера, что иконка титров)

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

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

Сборка и предзагрузка во Flutter

Как и с предыдущими графическими активами в прошлой части, нам нужно сообщить Flutter, что мы хотим, чтобы эти новые файлы были задействованы при сборке приложения.
Чтобы это сделать, нам нужно добавить следующие строки в подраздел assets в ./pubspec.yaml:

Примечание: обратите внимание на отступы в ./pubspec.yaml. Разделы и подразделы определяются с помощью отступов, которые состоят из двух пробелов.

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

Откроем ./lib/main.dart и добавим следующие названия файлов в список строк, переданных для вызова Flame.images.loadAll:

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

Шаг 2: Views (экраны)

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

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

Подготовка заставки

В нашей игре будет всего три экрана:

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

Игре необходимо запомнить текущий экран. Мы можем это сделать с помощью целых чисел и пронумеровать экраны от 0 до 2 или от 1 до 3, а также можем запомнить их как строку.

Позвольте представить вам тип данных под названием enum (сокращение от enumerated type — тип перечисления).

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

Следуя условности «один класс — один файл», обговоренной в предыдущих частях (даже не смотря на то, что enum, в общем-то, не класс), поместим экран в новый файл.

Создайте файл, назовите его ./lib/view.dart и поместите в него этот блок кода:

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

Перед использованием заставок, импортируем их перечень, поэтому откроем ./lib/langaw-game.dart и добавим следующую строку кода в секцию импорта:

Теперь добавим переменную экземпляра. Назовем ее activeView и назначим ей тип View (тот, что мы объявили вместе с enum).

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

Домашний экран

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

Но что такое экран?

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

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

Но сначала создадим новую папку под ./lib и назовем ее views (экраны).

Внутри этой папки создадим файл под названием ./lib/views/home-view.dart и впишем следующие строки:

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

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

Класс содержит метод конструктора и два других метода, которые будут использоваться игровым циклом — render и update.

Внутри конструктора мы инициализируем переменные titleRect и titleSprite, чтобы они были готовы к использованию в методе рендеринга.

Пояснение: на самом деле, весь этот блок кода — всего лишь две строки. Первая передает значение в переменную titleRect, а вторая передает значение в переменную titleSprite.

Значение для переменной titleRect — это определение Rect, растянутое вертикально в две строки. Четыре средних линии сообщают параметры, необходимые фабричному конструктору .fromLTWH.

Мы уже знаем, что изображение с названием игры будет отображаться внутри прямоугольника размером 7 на 4 плитки. Поэтому мы передаем game.tileSize * 7 и game.tileSize * 4 последним двум параметрам, которые сообщаются с шириной и высоток прямоугольника.

Для левой стороны: мы вычитаем ширину прямоугольника с изображением названия игры (7 плиток) из ширины экрана (9 плиток) и получаем две плитки дополнительного пространства. Чтобы отцентровать изображение, равномерно распределяя эти две плитки слева и справа, сдвигая тем самым изображение на одну плитку. Поэтому мы передаем game.tileSize * 1 или, что имеет больше смысла, game.tileSize.

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

Теперь, когда мы инициализировали titleRect и titleSprite, мы можем написать код, который отрендерит изображение.

Внутри метода render вставим эту строку:

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

Перейдем в класс игры ./lib/langaw-game.dart и импортируем туда файл класса HomeView при помощи этой строки:

Затем добавим переменную экземпляра homeView с типом HomeView:

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

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

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

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

Пояснение: мы проверяем, является ли нынешний экран домашним. Если да, мы рендерим экземпляр homeView. Если нет, метод render просто пропустит эту строку, так что экземпляр homeView не отобразится.

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

Компонент кнопки «старт»

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

Как известно, чтобы начать саму игру, нужно нажать определенную кнопку — «старт».
Создадим компонент под названием StartButton. Затем создадим файл ./lib/components/start-button.dart.

Пояснение: определение этого класса точно такое же, как и у всех других, что мы создали, включая HomeView. Стоит только отметить, что здесь присутствует обработчик onTapDown. Он содержит код, который запустит игру.

Перейдем в конструктор и инициализируем переменные rect и sprite.

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

Главная разница, кроме размера 6 на 3 плитки, заключается в сдвигах левой стороны и верхней.

Кнопка «старт» занимает 6 плиток в ширину, что означает, что у нас осталось 3 свободные плитки от ширины экрана (9 плиток). Это дает нам полторы плитки отступа, поэтому вписываем game.tileSize * 1.5 в параметр левой стороны.

Что касается верхней стороны, то подсчеты покажут, что центр по вертикали будет ровно в три четверти высоты экрана (`.75) от верха до низа.

После инициализации переменных rect and sprite, нам нужно отрендерить изображение, так что поместим такую строку внутрь функции render:

Теперь ./lib/components/start-button.dart должна выглядеть так:

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

Затем добавим эту переменную экземпляра к остальным переменным:

Инициализируем переменную startButton с новым экземпляром класса StartButton после того, как размер экрана был определен.

Эта строка должна пойти в метод render:

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

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

Пора запустить игру и увидеть нашу новую кнопку старта:

Обработка нажатий на кнопку

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

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

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

Назовем эту переменную isHandled. Создадим ее в начале обработчика onTapDown и установим начальное значение false.

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

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

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

*Примечание: все эти состояния могут быть прописаны внутри одного заявления if. Однако, его строка была бы настолько длинная, что ее пришлось бы растянуть вертикально для удобочитаемости (как в случае с определением Rect). Но, если честно, читать такой код было бы все равно неудобно, и выглядел бы он не очень привлекательно. Поэтому оставим только два заявления if. *

Следующий шаг — связать обработчик нажатия на муху с проверкой isHandled:

Пояснение: в принципе, это тот же блок кода, что мы только что писали, только с двумя изменениями. Первое — обработчик связан с проверкой isHandled, что запускает блок кода только если касание еще не было обработано (в этом случае — кнопка старта). Второе — смена значения переменной isHandled на true, если хотя бы одна муха была нажата.

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

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

Наконец, нам надо вернуться к файлу кнопки «старт» (./lib/components/start-button.dart) и вписать код, который будет обрабатывать касание.

Когда onTapHandler кнопки старта вызывается, нам нужно настроить activeView игры на View.playing. Для этого нам нужно импортировать файл, где определен View enum.

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

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

Наконец, мы можем запустить игру и проверить, работает ли кнопка «старт» и, если надо, исправить ошибки.

Проигрыш

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

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

Откроем ./lib/langaw-game.dart и поместим внутрь обработчика onTapDown следующее объявление переменной (перед циклом мух):

В цикле мух поместим следующую строку внутрь блока if, которая будет проверять, задело ли касание муху. Желательно разместить эту строку перед или после смены значения переменной isHandled на true:

Затем, сразу после цикла forEach, мы проверяем, находимся ли мы сейчас на «игровом» экране и не задело ли касание муху.

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

Если оба эти условия совпали, мы даем activeView значение ** View.lost**, которое сообщается с экраном «вы проиграли» («you lost»).
Обработчик onTapDown должен выглядеть так:

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

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

Экран «вы проиграли»

Этот экран будет практически идентичным домашнему экрану. Единственное отличие — само изображение.
Создадим новый файл заставки в ./lib/views и назовем его lost-view.dart:

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

Как и в случае с домашним экраном, нам нужно перейти в файл класса игры./lib/langaw-game.dart, создать экземпляр класса LostView и отрендерить его.

Импортируйте файл экрана проигрыша:

Создайте переменную экземпляра:

Инициализируйте объект LostView и свяжите его внутри метода initialize с переменной lostView (после того, как размер экрана определен).

Затем, в методе render, отрендерите его:

Пояснение: эти строки — стандарт для добавления компонента или экрана в класс игры.

Примечание: желательно добавить каждую из этих строк к их двойникам из домашнего экрана.

Если вы запустите игру, нажмете сначала на кнопку старта, а затем куда-то в пустое пространство, где нет мухи, вы должны увидеть надпись «you lose»:

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

Шаг 3: переписываем контроллер появления объектов

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

С технической точки зрения, когда цикл проходит List, используя forEach, код не должен модифицировать список (как и добавлять/удалять из него предметы). Однако, именно этим наша игра и занимается.

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

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

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

Контроллер появления

Для этого напишем контроллер. Помните, что контроллеры — просто компоненты без позиции или какого-либо графического представления.

Создаем новую папку в ./lib и называем ее controllers. Внутри нее создаем файл ./lib/controllers/spawner.dart.

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

Как и в случае с прочими компонентами и заставками, мы сохраняем отсылку к экземпляру LangawGame внутри финальной переменной game и запрашиваем значение этой переменной как параметр для конструктора.

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

Затем добавим следующую строку в методе killAll:

Эта строка перебирает всех «существующих» мух в списке flies game (если таковые имеются) и присваивает значение true их свойству isDead, тем самым убивая их.

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

Добавьте эти финальные переменные экземпляров в класс:

Затем, прямо под ними, добавьте еще две переменные:

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

Пояснение: начнем с первой переменной константы maxSpawnInterval. Это константа — верхний передел времени появления мух. Когда игра начинается, значение currentInterval равняется maxSpawnInterval 3000 миллисекундам или же попросту трем секундам.

Вторая константа, minSpawnInterval, является полной противоположностью первой. Каждый раз, когда новая муха появляется, переменная currentInterval уменьшается, но только до точки в 250 миллисекунд — или ¼ секунды.

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

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

Переменная currentInterval хранит количество времени, добавляемое с текущего времени при планировании следующего появления.

Последняя переменная nextSpawn — это фактическое количество времени, которое запланировано для следующего появления. Эта переменная содержит значение, которое измеряет время в миллисекундах с начала эпохи Unix (1 января 1970 года, 12 ночи GMT).

В методе start мы сначала убиваем всех мух с помощью вызова метода killAll(), а затем переустанавливаем currentInterval на максимальное значение (maxSpawnInterval) и используем это значения для планирования следующего создания мухи в следующей строке при помощи DateTime.now().millisecondsSinceEpoch и добавленного к нему значения currentInterval.

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

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

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

Это происходит в таком порядке потом, что если мы создадим муху сначала, то start() будет вызывать killAll() и просто-напросто убьет самую первую муху, которая появится в игре.

Теперь основная часть логики появления объектов находится в методе update. Поместим в него этот блок кода:

Пояснение: первая строка содержит фактическое время (количество миллисекунд с начала эпохи Unix).

Следующий блок кода считает количество живых мух в списке (game.flies). Код проходит циклом по списку и, если муха не мертва, добавляет ее в livingFlies.

Затем идет блок чуть больше, он заходит в блок if и проверяет, прошло ли фактическое время значение nextSpawn, а также является ли количество живых мух меньшим, чем константа maxFliesOnScreen.

Если все условия совпали, мы создает одну муху. После мы уменьшаем значение currentInterval на значение в константе intervalChange, плюс два процента значения currentInterval, но только если его значение больше минимального интервала minSpawnInterval.

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

Может, это и излишне, но вот весь код файла ./lib/controllers/spawner.dart:

Интеграция контроллера в класс игры

Для интеграции контроллера появления объектов в класс игры, первым делом мы убираем старые вызовы метода spawnFly.

В ./lib/langaw-game.dart удалим следующую строку внутри метода initialize.

Затем в ./lib/components/fly.dart уберите внутри обработчика onTapDown эту строку:

Все это должно устранить технический баг, который обсуждался ранее.

Вернемся назад в ./lib/langaw-game.dart и создадим экземпляр контролера появления объектов и сохраним его в переменной экземпляра.

Сначала мы импортируем класс:

Затем создаем переменную экземпляра:

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

Затем, внутрь метода обновления:

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

Последним кусочком пазла станет вызов метода spawner.start.

Откройте ./lib/components/start-button.dart и поместите следующую строку внутрь обработчика onTapDown:

Время тестового запуска!

Запустим нашу игру и попробуем нажать на кнопку старта, а затем проиграть. Теперь у нас есть игра с полным циклом геймплея: старт, проигрыш, новый старт.

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

Шаг 4: диалоговые окна

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

Начнем с кнопок

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

Создадим следующие компоненты:
./lib/components/help-button.dart

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

Нам нужна кнопка «help» в левом нижнем углу экрана, поэтому Left установлен на game.tileSize * .25 (четверть размера плитки) от левого края экрана. Ее Top равен высоте экрана минус game.tileSize * 1.25 (одна целая одна четвертая размера плитки). Таким образом, низ кнопки располагается ровно на четверть плитки выше нижней грани экрана.

Кнопка титров использует все те же подсчеты для параметров Top и Left — разве что значение для Left использует ширину экрана, располагая кнопку в правом нижнем углу экрана.

Обе кнопки имеют размер одной квадратной плитки.

Также стоит отметить обработчик onTapDown. Как только эти обработчики были вызваны, значение свойства класса игры activeView устанавливается на верное. View.help для диалогового окна помощи и View.credits для титров.

Теперь нам нужно добавить эти кнопки в класс игры (./lib/langaw-game.dart) путем импорта их файлов класса.

Затем создадим переменные экземпляра:

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

Нужно их отрендерить, так что поместим следующие строки кода в метод render. Их нужно поместить внутрь блока if, который проверяет, установлен ли activeView либо на View.home, либо на View.lost (над или под рендерингом кнопки старт).

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

Пояснение: комментарии (строки, начинающиеся с //) необязательны, они просто делают код чистым, удобочитаемым и легким для работы. Этот код похож на код для кнопки старта.
Он проверяет три условия:
1. Нажатие еще не должно быть обработано !isHandled;
2. Нажатие произошло внутри свойства rect кнопки;
3. activeView должен быть установлен либо на View.help, либо на View.credits

Как только все условия совпали, мы вызываем обработчик onTapDown для кнопки и меняем значение isHandled на true, чтобы оповестить следующие обработчики о том, что это касание уже обработано.

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

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

Диалоговые окна

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

Создайте следующие экраны:
./lib/views/help-view.dart

./lib/views/credits-view.dart

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

Оба диалоговых окна имеют размер 8 на 12 плиток. Чтобы отцентровать диалоговое окно, мы устанавливаем параметр Left на половину плитки. Для параметра Top мы берем половину высоты экрана и вычитаем из нее половину диалогового окна.

Мы почти закончили с этой частью серии статей, осталось добавить экран в класс игры. Сначала импортируем файлы экранов:

Затем создаем переменные экземпляров, которые будут их содержать:

Внутри метода initialize создаем экземпляры новых экранов и сохраняем их в переменных экземпляров.

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

Внутри обработчика onTapDown мы проверяем, что нажатие еще не обработано, а затем, находимся ли мы внутри заставки «help» или титров. Если там, мы меняем заставку на домашний экран и меняем значение переменной isHandled на true, предотвращая действия последующих обработчиков.

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

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

Время тест-драйва!

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

Завершение

Еще один длинный туториал — и наша игра стала еще на шаг ближе к своему финальному результату.

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

Что дальше?

В следующей части мы чем-то новым и совершенно неизведанным — музыкой и звуками.

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

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