Егор Долгов
Егор Долгов
(обновлено )
За все время: 1901 просмотр, 628 посетителей.
За последние 30 дней: 102 просмотра, 23 посетителя.

Создание игры «Косынка» (Kondike)

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

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

Перевод туториала https://docs.flame-engine.org/1.3.0/tutorials/klondike/klondike.html

1. Подготовка (Preparation)

Перевод туториала [https://docs.flame-engine.org/1.3.0/tutorials/klondike/step1.html)

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

Для начала совершите все приготовления, описанные в предыдущем разделе, заменив название проекта на klondike. В итоге у вас должен появиться файл main.dart со следующим содержанием:

Планирование (Planning)

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

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

При взгляде на этот набросок, уже можно представить сложную структуру этой игры. У нас будут такие классы как Card, Stock, Waste, Tableau (содержащий семь Pile), четыре Foundation и возможный Deck. Все эти составляющие буду связаны при помощи KlondikeGame, полученного из FlameGame.

Ассеты (Assets)

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

При замере обычной игральной карты мы увидим, что ее параметры составляют 63 мм на 88 мм, т.е. соотношение ее сторон равно примерно 10:14. Таким образом, внутриигровые карты будут отображаться в разрешении 1000×1400 пикселей.

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

Таким образом, вот графические ассеты для нашей игры (скачать):

Нажмите на изображение правой кнопкой мыши, выберите «Сохранить картинку как…» и сохраните ее в папке проекта assets/images. Теперь структура проекта должна выглядеть примерно так (тут только важные файлы):

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

Содержание спрайт-листа:

Также вы должны сообщить Flutter об этих изображениях (недостаточно просто хранить их в папке assets). Для этого нужно добавить строки в файл pubspec.yaml:

На этом мы заканчиваем с подготовкой и переходим непосредственно к написанию кода.

2. Каркас (Scaffolding)

Перевод туториала https://docs.flame-engine.org/1.3.0/tutorials/klondike/step2.html

В этом разделе мы наметим основные элементы игры — основной игровой класс и общий макет.

KlondikeGame

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

Создайте новый файл под названием klondike_game.dart внутри папки lib/ и объявите внутри класс KlondikeGame:

На данный момент мы только объявили метод onLoad, который представляет собой специальный обработчик, который вызывается, когда экземпляр игры впервые прикрепляется к дереву виджетов Flutter. Вы можете думать о нем как об отложенном асинхронном конструкторе. Пока что единственное, что делает onLoad, это загружает изображения спрайтов в игру; но мы добавим больше в ближайшее время. Любое изображение или другой ресурс, который вы хотите использовать в игре, необходимо сначала загрузить, что является относительно медленной операцией ввода-вывода, поэтому необходимо ключевое слово await.

Мы загружаем изображение в глобальный кеш Flame.images. Альтернативой может стать загрузка в кеш Game.images, но тогда будет сложнее получить доступ к этому изображению из других классов.

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

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

Давайте включим этот класс в проект: откройте файл main.dart и найдите строку final game = FlameGame(); и замените FlameGame на KlondikeGame. Вам также нужно будет импортировать класс. После того, как все сделано, файл должен выглядеть так:

Остальные классы (Other classes)

Теперь у нас есть основной класс KlondikeGame, и нам нужно создать объекты, которые мы будем добавлять в игру. Во Flame эти объекты называются компонентами, и при добавлении в игру они образуют «дерево игровых компонентов». Все составляющие, существующие в игре, должны быть компонентами.

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

А пока давайте создадим классы-контейнеры, как показано на скетче. Это Stock, Waste, Pile и Foundation. В директории вашего проекта создайте поддиректорию components, а затем файл components/stock.dart. В этот файл впишите:

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

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

На данный момент структура директории вашей игры должна выглядеть так:

Игровая структура (Game structure)

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

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

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

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

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

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

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

Для этой игры мы рисуем изображения, имея в голове размер одной карты в 1000×1400 пикселей. Таким образом, это служит эталонным размером для определения общего макета. Еще одним важным параметром, влияющим на макет, является расстояние между картами. Похоже, что оно должно быть где-то между 150 и 200 единицами (относительно ширины карты), поэтому мы объявим его как переменную cardGap, которую можно будет скорректировать позже при необходимости. Для простоты и вертикальное, и горизонтальное межкарточное расстояние будет одинаковым, а минимальный отступ между картами и краями экрана тоже будет равен cardGap.

Пришло время собрать все это вместе и реализовать наш класс KlondikeGame.

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

Затем мы создаем компоненты Stock, Waste, четыре Foundation и семь Pile, задав их размеры и положение в мире. Позиции рассчитываются с помощью простой арифметики. Все это должно происходить внутри метода onLoad после загрузки спрайт-листа:

Затем мы создаем главный компонент World, добавляем его к только что созданным компонентам,и, наконец, добавляем world в игру:

Примечание: Вам может быть интересно, когда вам нужно await результата add(), а когда нет. Короткий ответ: обычно ждать не нужно, но при желании можно. Если вы посмотрите документацию по методу .add(), вы увидите, что возвращаемое будущее ожидает только завершения загрузки компонента, а не его фактического монтирования в игре. Таким образом, вам нужно ждать будущего от .add() только в том случае, если ваша логика требует, чтобы компонент был полностью загружен, прежде чем он сможет продолжить работу. Это не очень распространено. Если не ждать будущего от .add(), то компонент все равно будет добавлен в игру, и через такое же количество времени.

Наконец, мы создаем объект камеры, чтобы смотреть на world. Внутри камера состоит из двух частей: viewport и viewfinder. Viewport по умолчанию — MaxViewport, который занимает весь доступный размер экрана, что нам как раз и необходимо. С другой стороны, viewfinder нужно настроить так, чтобы он правильно учитывал размеры окружающего мира.

Мы хотим, чтобы весь макет был виден на экране без прокрутки. Для этого мы указываем, что хотим, чтобы весь размер мира (7 * cardWidth + 8 * cardGap на 4 * cardHeight + 3 * cardGap) мог поместиться на экране. Параметр .visibleGameSize гарантирует, что независимо от размера устройства уровень масштабирования будет отрегулирован таким образом, чтобы указанный фрагмент игрового мира был виден.

Расчет размера игры получается так: всего имеется 7 карт и 6 промежутков между ними, добавляем еще 2 промежутка для учета отступов, и получаем ширину 7 * cardWidth + 8 * cardGap. По вертикали есть два ряда карт, но в нижнем ряду нам нужно дополнительное пространство, чтобы можно было отобразить высокую стопку — по грубым рассчетам, для этого достаточно тройной высоты карты — что дает общую высоту игрового мира как 4 * cardHeight + 3 * cardGap.

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

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

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

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

3. Карты (Cards)

Перевод туториала [https://docs.flame-engine.org/1.3.0/tutorials/klondike/step3.html)

В этой главе мы приступим к реализации наиболее заметного компонента игры — компонента карты, который соответствует одной реальной карте. Всего в игре будет 52 объекта Card.

У каждой карты есть ранг (rank) (от 1 до 13, где 1 — туз, а 13 — король) и масть (suit) (от 0 до 3: червы ♥, бубны ♦, трефы ♣ и пики ♠). Кроме того, каждая карта будет иметь логический флаг (faceUp) лицевой стороной вверх, который определяет, повернута карта в данный момент лицевой стороной вверх или вниз. Это свойство важно как для рендеринга, так и для некоторых аспектов логики геймплея.

Ранг и масть — это простые свойства карты, а не компоненты, поэтому нам нужно решить, как их представить. Есть несколько вариантов: либо как простое целое int, либо как перечисление enum, либо как объекты. Выбор будет зависеть от того, какие операции нам необходимо выполнить с ними. Что касается ранга, нам нужно будет определить, является ли один ранг на единицу выше/ниже другого ранга. Также нам нужно создать текстовую метку и спрайт, соответствующий заданному рангу. Для мастей нам нужно знать, являются ли две масти разного цвета, а также создать текстовую метку и спрайт. Учитывая эти требования, я решил представить и Rank, и Suit как классы.

Масть (Suit)

Создайте файл suit.dart и объявите в нем @immutable class Suit без родителя. Аннотация @immutable здесь — всего лишь намек на то, что объекты этого класса не должны модифицироваться после создания.

Затем мы определяем фабричный конструктор для класса: Suit.fromInt(i). Мы используем фабричный конструктор, чтобы применить одноэлементный шаблон для класса: вместо того, чтобы каждый раз создавать новый объект, мы возвращаем один из предварительно созданных объектов, которые мы храним в списке _singletons:

Затем идет приватный конструктор Suit._(). Он инициализирует основные свойства каждого объекта Suit: числовое значение, строковую метку и объект спрайта, который мы позже будем использовать для отрисовки символа масти на холсте. Объект спрайта инициализируется с помощью функции klondikeSprite(), которую мы создали в предыдущей главе:

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

Последние четыре числа в конструкторе — это координаты изображения спрайта в спрайт-листе klondike-sprites.png. Получить эти числа помог бесплатный онлайн-сервис spritecow.com — это удобный инструмент для поиска спрайтов в спрайт-листе.

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

Звания (Rank)

Класс Rank очень похож на класс Suit. Главная разница заключается в том, что Rank содержит два спрайта вместо одного, раздельно для званий «красного» и «черного» цветов. Полный код класса Rank:

Компонент Card

Теперь, когда у нас есть классы Rank и Suit, мы наконец-то можем начать реализацию компонента Card. Создайте файл components/card.dart и объявите класс Card, расширяющийся от PositionComponent:

Конструктор класса возьмет целочисленный ранг и масть и сделает карту изначально обращенной вниз. Также мы инициализируем размер компонента равным константе cardSize, определенной в классе KlondikeGame:

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

Наконец, добавим простую реализацию toString(), необходимую для отладки игры:

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

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

Рендеринг

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

renderBack()

Начнем мы с более простого рендера рубашки карты.

Метод render() для PositionComponent работает в локальной системе координат, а это означает, что нам не нужно беспокоиться о местоположении карты на экране. Эта локальная система координат имеет начало в верхнем левом углу компонента и простирается вправо по ширине width и вниз по высоте height пикселей.

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

Самой интересной частью является рендер спрайта: мы хотим визуализировать его посередине (size/2), и используем Anchor.center, чтобы сообщить движку, что центр спрайта должен находиться в этой точке.

Различные свойства, используемые в методе _renderBack(), определяются следующим образом:

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

renderFront()

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

Как и раньше, мы начинаем с объявления констант, которые будут использоваться для рендеринга. Фон карт будет черным, а цвет границ будет зависеть от того, «красной» масти карта или «черной»:

Также нам необходимы изображения для старших карт:

Спрайты называются redJack, redQueen и redKing. Поскольку имеющиеся изображения не очень хорошо смотрятся на картах черных мастей, мы подкрасим их голубоватым оттенком. Окрашивание спрайта может быть достигнуто с помощью краски с colorFilter, установленным на указанный цвет, и режимом наложения BlendMode
.srcATop
:

Теперь мы можем начать писать код самого метода рендеринга. Сначала нарисуйте фон и границу карты:

Чтобы нарисовать остальную часть карты, нам понадобится еще один вспомогательный метод. Этот метод нарисует предоставленный спрайт на холсте в указанном месте (местоположение зависит от размеров карты). Спрайт может быть дополнительно масштабирован. Кроме того, если передан флаг rotate=true, спрайт будет отрисовываться так, как если бы он был повернут на 180º вокруг центра карты:

Давайте нарисуем ранг и символ масти в уголках карты. Добавьте следующие строки к методу _renderFront():

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

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

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

Геймплей (Gameplay)

Перевод туториала https://docs.flame-engine.org/1.3.0/tutorials/klondike/step4.html

В этой главе мы реализуем основу игрового процесса Косынки: то, как карты перемещаются по разным стопкам, расположенным на игровом поле.

Прежде чем мы начнем, давайте уберем все те карты, которые мы оставили разбросанными по столу в предыдущей главе. Откройте класс KlondikeGame и сотрите цикл в нижней части onLoad(), который добавлял 28 карт на стол.

Стопки (The piles)

Еще одним небольшим рефакторингом станет переименование наших компонентов: StockStockPile, WasteWastePile, FoundationFoundationPile, и PileTableauPile. Это связано с тем, что эти компоненты имеют общие черты в обработке взаимодействий с картами, и было бы удобно, если бы все они реализовывали общий API. Мы будем называть интерфейс, который они все будут реализовывать классом Pile.

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

После изменения названия мы наконец-то можем начинать реализовывать каждый компонент.

Stock pile

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

  1. Возможность хранить не задействованные в игре карты лицом вниз:
  2. Нажатие на колоду должно показывать три верхние карты и перемещать их в стопку waste;
  3. Когда карты кончаются, должно появиться визуальное указание на то, что это стопка запаса карт;
  4. Когда карты заканчиваются, нажатие на место стопки запаса должно перемещать все карты обратно из стопки waste и переворачивать их лицом вниз.

Для начала нам нужно решить, кому будут принадлежать компоненты Card. Раньше мы добавляли их прямо на игровое поле, но теперь можно сказать, что карты относятся к компоненту Stock, или к waste, или pale, или foundations. И хотя такой подход может показаться заманчивым, он также может все усложнить, поскольку нам нужно будет перемещать карту из одного места в другое.

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

Помня об этом, приступим к имплементации компонента StockPile:

Здесь метод acquiCard() сохраняет предоставленную карту во внутренний список _cards; он также перемещает эту карту в позицию StockPile и регулирует приоритет карт, чтобы они отображались в правильном порядке. Однако этот метод не монтирует карту как дочернюю для компонента StockPile — она остается принадлежащей к игре верхнего уровня.

Кстати об игровом классе — давайте откроем KlondikeGame и добавим следующие строки, чтобы создать полную колоду из 52 карт и положить их в стопку (строки следует добавить в конце метода onLoad):

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

Waste pile

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

Начнем реализацию класса WastePile таким же способом, как мы делали с классом StockPile, только теперь карты должны быть повернуты лицом вверх:

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

Переменная _fanOffset помогает определить сдвиг между картами в веере — в нашем случае, это примерно 20% от ширины карты:

Теперь, когда стопка отложенных карт готова, мы можем вернуться к StockPile.

Раздача карт

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

Добавить функционал нажатия во Flame довольно просто: для начала мы добавляем миксин HasTappableComponents в наш игровой класс верхнего уровня:

А затем мы добавляем миксин TapCallbacks к компоненту, на который будет произведено нажатие:

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

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

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

Больше информации о функционале нажатия можно узнать здесь: Tap events.

Визуальное представление

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

В нашем случае это место будет иметь очертания карты с кружком посередине:

Где цвета определяются как:

А cardRRect в классе KlondikeGame как:

Теперь, когда вы раздадите все карты из стопки запаса, вы увидите плейсхолдер.

Восполнение из стопки отложенных карт

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

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

Теперь нужно реализовать метод WastePile.removeAllCards():

В принципе, на этом и заканчивается разработка функционала StockPile, и мы уже реализовали WastePile — остаются только два компонента FoundationPile и TableauPile. Начнем с первого, поскольку он чуть проще.

Foundation piles

Базовые стопки, или «дома» — это четыре стопки в правом верхнем углу игры. Именно туда игрок складывает карты по мастям в порядке возрастания, от туза до короля. Функционал этого класса схожа с StockPile и WastePile: ему надо содержать карты лицом вверх и у него должен присутствовать некий плейсхолдер места, куда должны будут складываться карты.

Для начала реализуем логику удержания карт:

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

Генерирующий базовые стопки код в классе KlondikeGame тоже необходимо соответствующе подстроить для передачи индекса масти каждой стопке.

Теперь код рендера для базовых стопок выглядит так:

Нам понадобится два объекта отрисовки, один для границы и один для масти:

Отрисовка масти использует BlendMode.luminosity для преобразования желтых и синих цветов спрайта в оттенки серого. «Цвет» отрисовки различается в зависимости от того, красная масть или черная, поскольку исходная яркость этих спрайтов различна. Поэтому мы выбираем два разных цвета, одинаково выглядящих в оттенках серого.

Tableau Piles

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

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

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

Теперь остается только перейти в KlondikeGame и убедиться в том, что игра будет раздавать карты в стопки TableauPile в начале игры. Обновите код в конце метода onLoad(), чтобы он выглядел следующим образом:

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

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

Перемещение карт

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

  1. Реализуем простое перемещение карты.
  2. Убедимся, что игрок может брать и перемещать только разрешенные правилами карты.
  3. Убедимся, что карты положены в нужном месте.
  4. Реализуем перетаскивание серии карт.

1. Простое перемещение

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

Затем переходим в класс Card и добавляем миксин DragCallbacks:

Следующим шагом станет реализация обратных вызовов события перетаскивания: onDragStart, onDragUpdate, и onDragEnd.

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

Событие onDragUpdate будет вызываться на протяжении всего действия перетаскивания. Используя этот обратный вызов, мы будем обновлять позицию карты так, чтобы она следовала за пальцем или курсором. Объект event, переданный этому обратному вызову, содержит последние координаты точки касания, а также свойство delta, которое является вектором смещения с момента предыдущего вызова onDragUpdate. Проблема заключается в том, что эта дельта измеряется в пикселях экрана, тогда как мы хотим, чтобы она была в единицах измерения игрового мира. Преобразование между ними определяется уровнем масштабирования камеры, поэтому мы добавим дополнительный метод для определения уровня масштабирования:

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

2. Перетаскивание только разрешенных карт

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

  1. Верхнюю карту веера стопки отложенных карт;
  2. Верхние карты базовых стопок;
  3. Любую карту настольной стопки, лежащую лицом вверх.

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

Начнем с определения абстрактного интерфейса Pile, который будет реализовывать все уже существующие стопки:

Позже мы расширим этот класс, а пока что удостоверимся, что классы StockPile, WastePile, FoundationPile и TableauPile помечены, как реализующие этот интерфейс:

Также мы должны дать знать каждой карте, в какой стопке она сейчас находится. Для этого добавим поле Pile? Pile в класс Card и установим его в метод acquireCard() каждой стопки таким образом:

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

Нам также нужно добавить сюда переменную логического типа _isDragging: определите ее, проверьте флаг в методе onDragUpdate() и установите его обратно как false в onDragEnd():

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

3. Складывание карт в соответствующих местах

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

Таким образом, первая попытка исправить обратный вызов onDragEnd выглядит следующим образом:

Он по-прежнему содержит несколько плейсхолдеров для еще нереализованного функционала.

Первая часть головоломки — проверка того, разрешено ли класть карту на определенное место. Чтобы реализовать эту проверку, перейдите в класс Pile и добавьте абстрактный метод canAcceptCard():

Теперь его нужно реализовать для каждого подкласса Pile:

Для методов StockPile и WastePile этот метод должен быть возвращен ложным, поскольку в эти стопки складывать карты нельзя.

Следующим шагом станет удаление карты из текущей стопки. Еще раз вернемся к классу Pile и добавим абстрактный метод removeCard():

Далее реализуем этот метод для всех четырех подклассов стопок:

Следующей частью будет добавление карты в новую стопку. Но это мы уже реализовали: это метод acquireCard(). Так что нам остается только объявить его в интерфейсе Pile:

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

Теперь метод onDragEnd для Card выглядит следующим образом:

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

4. Перемещение ряда карт

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

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

Так что перейдем в класс TableauPile и создадим новый метод layOutCards(), чья задача будет состоять в том, чтобы убедиться, что все карты в стопке занимают верные позиции:

Этот метод нужно вызвать в концах removeCard(), returnCard() и acquireCard() — заменив любую текущую логику, обрабатывающую позицию карты.

Еще одна проблема заключается в трудности помещения карты в более высокую стопку. Это связано с тем, что наша логика для определения того, в какую стопку ложится карта, проверяет, находится ли центр карты внутри какого-либо из компонентов TableauPile, но эти компоненты имеют размер только одной карты! Чтобы исправить это несоответствие, нам нужно объявить, что высота настольной стопки не меньше высоты всех карт в ней или даже выше. Добавьте эту строку в конец метода layOutCards():

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

Теперь можно вернуться к главной теме: перемещение ряда карт за одно действие.

Первым делом мы добавим список соединенных карт attachedCards для каждой карты. Этот список не будет пустым только тогда, когда карта перетаскивается, имея сверху другие карты. Добавьте следующее объявление в класс Card:

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

Также обновим в этом классе метод canMoveCard(), чтобы позволить перемещать карты, не обязательно находящиеся сверху:

Переходим обратно в класс Card, теперь мы можем использовать этот метод для заполнения списка attachedCards при начале перемещения карты:

Все, что нам осталось сделать — это удостовериться, что прикрепленные карты также перемещаются вместе с главной картой в метод onDragUpdate:

Вот практически и все. Осталось только нанести финальные штрихи. К примеру, мы не хотим, чтобы игрок мог положить стопку карт в одну из базовых стопок, так что перейдем в класс FoundationPile и соответственно изменим метод canAcceptCard():

Во-вторых, нам нужно должным образом позаботиться о стопке карт, когда она кладется в настольную стопку. Итак, вернитесь в класс Card и обновите его метод onDragEnd():

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

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