Доброго времени суток %username%. Не давно наткнулся на хороший туториал по созданию клона игры Flappy Bird используя LibGDX и этот туториал мне понравился своей простотой и детализацией.
Я отдаю себе отчет, что тема создания клонов данной игрушки изъела себя, но возможно кому-то пригодится еще один хороший туториал.
Туториал разбит на 12 дней, содержит множество картинок, полотен кода и исходный код разбит по дням. Кому интересно, добро пожаловать под кат.
Содержание
- День 1 — Flappy Bird — Глубокий анализ
- День 2 — Подготавливаем и настраиваем libGDX
- День 3 — Разбираемся с чем едят libGDX
- День 4 — GameWorld, GameRenderer и Orthographic камера
- День 5 — Полет мертвеца — Добавляем птицу
- День 6 — Добавляем графические элементы — Добро пожаловать в Некрополис
- День 7 — Трава, Птица и Труба с черепом
- День 8 — Обнаружение коллизий и звуковые эффекты
- День 9 — Завершаем Игровой процесс и базовый UI
- День 10 — GameStates и Лучший результат
- День 11 — Добавляем поддержку iOS/Android + SplashScreen, Меню и Tweening
- День 12 — Конечный вариант UI и Исходный код
День 1 — Flappy Bird — Глубокий анализ
Чтобы скопировать игру, мы должны отлично понимать ее логику, поведение. В этой секции мы разберем различные игровые механики и процессы Flappy Bird, чтобы мы могли сэмулировать игровой процесс достаточно точно.
Я собираюсь определить и расписать каждый элемент игрового процесса. Конечно, это все довольно приблизительно и в целом я могу полностью ошибаться, но опять-таки, мы должны очень точно описать игровой процесс, чтобы наша эмуляция удалась. Если будут происходить какие-то серьезные изменения по ходу пъесы — я уведомлю о них.
Все об игровом процессе — GamePlay
Чтобы повторить Flappy Bird или сделать даже лучше — нам стоит сфокусировать на геймплее. Два главных элемента геймплея, с которыми нам предстоит разобраться, это Птица и Трубы. Наша птица должна двигаться как Flappy Bird, а трубы должны генериться и двигаться как их зеленые «прародители».
Птиц
После быстрого анализа птицы, видно, что размеры оной 17 пикселей (ширина) х 12 пикселей (высота). Так же птичка использует всего 7 цветов и занимает всего 1/8 от ширины игрового экрана (на глаз ширина экрана примерно 135-136 пикселей). Так же птичка масштабируется, чтобы хорошо выглядеть на девайсах с различной шириной экрано. Еще у птицы есть три разные цветовые схемы, которые используется случайным образом.
Физика птицы
Сложновато было поэксперементировать с физикой в этой игре без множества смертей, но в итоге я выяснил следующее:
- Во время падения — птица ускоряется.
- Но есть ограничение — птица не может падать быстрее выставленного ограничителя.
- Если тыкнуть по экрану — птица подпрыгнет на одно и тоже значение по высоте, вне зависимости от скорости падения.
- Птица повернута в соответствующую сторону движения, т.е. падая — птица смотрит внизу, взлетая — вверх. Анимация (взмах крыльями) присутствует только когда птица летит вверх.
Нашей главной целью будет создание всего, и вся как можно близким к оригинальной игре. Весь геймплей зависит в основном от физики.
Обнаружение коллизий
Какие условия смерти нашей птицы? Я без понятия как это было реализовао в ориганальной игре. Но из того что я вижу, проверка коллизий по пикселям — это наш вариант. Мы создадим «hit box» для нашей птицы, и будем использовать его для определения коллизий с трубами.
Если сделать хит бокс слишком маленьким — игра будет очень легкой, а если большим, то люди будут злится из-за безосновательных смертей птички.
Я сделаю хитбокс с помощью Rectangle.
Трубы
Трубы, наверное, самая сложная часть которую нужно сделать должным образом, очень важно чтобы мы сделали все правильно.
Большая часть привлекательности этой игрушки — ее сложность. Если сложность нашего клона каким-то образом будет не такой же как в ориганальной игре, не правильно расчитанная скорость или непоследовательно сгенерированы трубы, у игрока будет отрицательные эмоции от игры. Не будет эффекта: разочарование-награда-зависимость.
В один момент времени мы должны генерировать 6 труб, в оригинальной игре никогда не видно более 6-ти труб. Трубы появляются с одним и тем же интервалом, так что растояние между трубами будет константой. Как только один набор труб скроется за левой границей экрана, мы переопределим высоту (подробнее — далее) труб и переместим их за правую границу экрана в правильное положение в очереди следующих труб.
Пустое пространство между трубами имеет разную позицию по высоте, но всегда один и тот же размер. Самый легкий способ — это реализовать — мы будем смещать по Y трубу на случайное значение, когда происходит перемещение по Х. Когда мы доберемся до создания логики наших труб, я более детально изучу паттерн, действительно ли труба смещается на случайное значение и на сколько сдвиг может быть вверх и вниз.
Анимации
Это невероятно простая игра. Статические элементы в ней — это задний фон и песок. Они никогда не меняются. Птица зафиксирована горизонтально, примерно 1/3 от ширины экрана. Трава(?) и трубы — это единственные элементы в игре которые необходимо скролить горизонтально, и они скролятся с одинаковой скоростью. Создать траву будет самым легким этапом, мы не будем обсуждать это тут.
Проблема с разными размерами экрана
На моем устройстве, птица отцентрирована вертикально (обратите внимание на красную линию на картинке слева). Смотря на это, я предположил, что игра растягивается равномерно вверх и вниз, так что размер (или соотношение) игрового пространства остается одним и тем же.
Я протестировал игру на iPhone с экраном 3,5 дюйма, я полагаю, что игра была изначально сделана под этот размер, и размер игровой зоны был такой же как на картинке слева. Так что, мы реализуем поддержку различных размеров экрана по следующим принципам:
- Как стандарт для нашего приложения, мы будем использовать Retina iPhone с экраном 3,6 дюйма
- Весь игровой процесс будет происходить в прямоугольнике, полученном из расчета используемого экрана
- Размер Птицы — 17 пикселей (масштабируется пропорционально)
- Ширина игры ~135 пикселей, пропорционально масштабируется (на коэффициент 4,75х на iPhone)
- Высота игры будет изменятся взависимости от устройства, но высота игрового поля (где происходит весь игровой процесс) будет (960/640) * 135 = 203 пикселей.
День 2 — Подготавливаем и настраиваем libGDX
В этой секции, мы настроим фреймворк libGDX, который в целом будет выполнять за нас кучу низко-уровненых задач, так что мы сможешь больше сфокусироваться на игровом процессе.
Перед тем как продолжить, посмотрите на Zombie Bird слева, творчество отдела художников Kilobolt. Zombie Bird — главный персонаж нашей игры. Как всегда, установка/настройка — самая скучная часть любого руководства. Спасибо команде libGDX, этот процесс быстрый и легкий!
Установите Java, скачайте ADT
Если у васне установлена Java и у вас нет Eclipse с Android Development Tools, заходим сюда и устанавливаем их (прим. пер.: в рамках данного перевода — статья по указанной ссылке не будет переведена).
Скачиваем libGDX и создаем проекты
LibGDX предоставляет кросс-платформенную разработку, так что мы пишем код единожды, а используем на множестве платформ. Это возможно благодаря архитектуре libGDX, у вас в наличии один главный Java проект, в котором вы пишите весь ваш первоклассный код (в частности используя разного рода интерфейсы).
Чтобы настроить главный Java проект и вспомогательные проекты для каждоый из платформ — выполним действия по списку:
- Намите сюда, чтобы скачать установку libGDX.
- Как скачается, нужно будет установить одним из следующих способов:
-
- На Mac — попробуйте дважду кликнуть на jar файл.
- На PC, скопируйте скачанный файл на рабочий стол и открой Терминал/Консоль. Наберите следующее:
cd path_to_desktop java -jar gdx-setup.jar
- Как только вы это проделаете, появится следующее окошко:
- Введите информацию, указанную ниже (так она присутствует и на картинке выше), вы можете сменить путь к папке проекта на любой другой:
Name: ZombieBird
Package: com.kilobolt.zombiebird
Game class: ZBGame
Destination: Ваш выбор. Запомните только этот путь.
Android SDK: Расположение Android SDK. Если вы используете ADT Bundle (Android Developer Tools: Eclipse + Android SDK) то sdk расположено внутри adt-bundle папки.Убедитесь, что проекты Desktop, Android, iOS и HTML выбраны, и отмените выбор всех Extensions (дополнительные классы с различной вспомогательной функкциональностью для libGDX).
Данная инсталяция автоматически создаст 5 Java проектов в папке, путь к которой вы указали в параметре Destination. Главный проект (core project) — это проект где мы будет писать весь код для нашей игры. Android, iOS и HTML проекты получат доступ к нашему главному проекту и выполнять его со специфической для каждой платформы имплементацией, это нужно, чтобы наша игра работала на всех платформах.
- Мы сгенерим Eclipse проект, нажав на Advanced и выбрав Eclipse
На заметку: libGDX использует сборщик который называется Gradle. Этот сборщик автоматизирует сборку вашего проекта, управление .JAR зависимостями, а также упрощает совместную работу с другими людьми над проектом. Grandle — это отдельная большая тема, вам необходимо иметь опыт работы с такими сборщиками как Ant и Maven. Как-нибудь позже, мы возможно опубликуем статью о работе с Gradle, но не в рамках этой статьи. - Как только вы будете готовы, со словами «Понеслось!», нажмите кнопку Generate
- Установщик скачает все необходимые файлы и сконфигурирует ваши проекты. Как только вы увидите следующее сообщение, вы можете закрыть Установщик.
- Теперь в папке, которую вы указали в настройках Установщика, появились 5 проектов, мы можем импортировать их в Eclipse. Откройте ваш Eclipse.
- Кликните правой кнопкой мышки в Package Explorer и выберите Import, как показано ниже.
- Выберите General > Existing Projects into Workspace
- Кликните на Browse правее от «select root directory»:
- Перейдите в папку проекта (путь который указывали на шестом шаге) и нажмите Open.
- Выберите все 5 проектов и нажмите «Finish»
- Ну все, мы импортировали наши проекты в Eclipse и теперь мы готовы начать писать код.
Появились сообщения об ошибках?
Если у вас Eclipse ругается на ошибки в ANDROID проекте, кликните правой кнопкой мышки на этом проекте, выберите Properties, кликниет Android и убедитесь, что у вас есть установленная версия Android. Если нет, кликните сюда и перейдите на шаг в котором написано:
II. Installing the Bundle: Eclipse/Android SDK/Eclipse ADT Plugin,
перед тем как продолжить с текущим уроком. - Чтобы убедиться, что у нас все верно настроено, откройте ZombieBird — desktop проект и перейдите в класс DesktopLauncher.java. Обновите его следующим образом:
package com.kilobolt.zombiebird.desktop; import com.badlogic.gdx.backends.lwjgl.LwjglApplication; import com.badlogic.gdx.backends.lwjgl.LwjglApplicationConfiguration; import com.kilobolt.zombiebird.ZBGame; public class DesktopLauncher { public static void main (String[] arg) { LwjglApplicationConfiguration config = new LwjglApplicationConfiguration(); config.title = "Zombie Bird"; config.width = 480; config.height = 320; new LwjglApplication(new ZBGame(), config); } }
- Кликаем правой кнопкой мышки по desktop проекту, выбираем Run и выбираем класс DesktopLauncher,
если все верно, вы увидите следующее:
- Если вы добрались до этого пункта — значит libGDX работает у вас корректно и мы можем продолжить дальше.
День 3 — Разбираемся с чем едят libGDX
В данной секции мы создадим вспомогательные классы и методы которые будем использовать во время создания нашей игры. Но прежде чем начать писать код, вы должны быть в курсе следующих технических нюансов.
libGDX используем лицензию Apache 2.0, которая позволяет вам свободно изменять и распространять код, с упоминанием автора оригинала. Все встроенные классы в libGDX имеют комментарий о лицензии Apache 2.0. Так как мы не собираемся в рамках данного урока модифицировать оригинальные файлы, а также файл с описанием лицензии включен уже в проект, нам не нужно беспокоиться о лицензии как таковой.
Но на всякий случай ознакомьтесь с лицензией тут (данный файлик присутствует в проекте): http://www.apache.org/licenses/LICENSE-2.0.html
Базовая структура (как мы будем проектировать, а так же создавать нашу игру)
Давайте потратим не много времени и обсудим, как мы будем создавать нашу игру. Ниже приведена диаграмма, которая в целом отображает наш проект.
Мы начнем работать с веткой ZBGame на диаграмме. Создадим Framework Helpers и Screen Classes (на диаграмме GameScreen).
GameScreen зависит от двух вспомогательных классов: World и Renderer. World будет взаимодействовать с Gameplay классами и по ходу пъесы будет создать объекты нашей игры.
Если все выше сказанное понятно — поехали дальше.
Внимание! Далее будет самая концептуально сложная часть во всем уроке.
Но… вы можете ее пропустить.
Можно вскольз просмотреть и попытаться понять максимум сколько сможете, задать вопросы и продолжить далее. Не задумывайтесь над данной частью урока, так как большая часть информации в данной секции — не важная. Нет смысла застопыриваться на не важных вещах.
Если вас что-то озадачивает, пролистывайте смело далее к "Пишем код" части. Мы все равно сможем создать нашу Zombie Bird игру.
Чувствуете себя уверенным? Продолжайте читать. Нервничаете? Пролистайте далее.
Расширяем и имплементируем (Можно пропустить)
Если вам необходимо освежить в памяти, что такое наследование, переходим сюда.
Напомню, что Interface — это список requirements, название методов без имплементации. Interface перечисляет список всех методов, которые какой то класс должен реализовать (предоставить описание метода, тело метода), в случае если этот класс должен стать такого же типа как Interface. Java библиотека содержит интерфейс с именем List, который сам по себе не предоставляет ни какой функциональности. Интерфейс List — это файл, в котором перечислены методы, которые другой класс должен реализовать, с целью быть причисленным к категории List объектов.
Например, среди всех методов интерфейса List есть следующие:
- list.get(int index), который возвращает item с указанным index.
- list.add(), который добавляет item в конец List.
- list.isEmpty(), который возвращает true, если List пустой.
Давайте создадим новый класс, который назовем ArrayList. Данный класс имплементирует интерфейс List, т.е. он должен реализовать все методы интерфейса List, такие как list.add() и list.get(int index).
После того как мы добавим реализацию методов интерфейса List в класс ArrayList, наш класс может образаться к себе как-будто бы он класс List, как показано ниже:
List<String> strings = new ArrayList<String>();
Заметьте, что переменная strings типа List, была созданна как ArrayList. Т.е. данная переменная может вести себя как List или как ArrayList взависимости от ваших нужд.
Так как мы знаем, что strings — это реализация интерфейса List, мы можем быть уверены, что strings содержит все методы данного интерфейса. Благодаря этому, если нам когда-то понадобится передать в какой-то метод объект типа List, мы смело сможем передать наш объект типа ArrayList с именем strings (полиморфизм).
public void printLastWordFrom(List<String> someList) {
if (someList.isEmpty()) {
System.out.println("Your list is empty.");
return;
}
String lastWord = someList.get(someList.size() - 1));
System.out.println("Your last word is" + lastWord);
}
Эти принципы необходимо знать и понимать, так как далее мы будем использовать их в процессе разработки.
Соглашения используемые в данном туториале (Важно прочитать!)
В уроке будет несколько раз встречаться упоминание встроенных классов в бибилиотеку libGDX, к примеру класс Game приведенный ниже. Данные классы встроенны в библиотеку и вам НЕ НАДО писаь их самостоятельно. Просто ссылайтесь на класс который я упомяну. Все эти классы под лицензией Apache 2.0, все авторы перечислены тут: https://github.com/libgdx/libgdx/blob/master/gdx/AUTHORS.
Для встроенных классов я буду в заголовке кода, в комментарии писать Built-in.
Просмотрите класс Game ниже, не надо его копировать или перенабирать, просто вскольз просмотрите его.
//Built-in
public abstract class Game implements ApplicationListener {
private Screen screen;
@Override
public void dispose () {
if (screen != null) screen.hide();
}
@Override
public void pause () {
if (screen != null) screen.pause();
}
@Override
public void resume () {
if (screen != null) screen.resume();
}
@Override
public void render () {
if (screen != null) screen.render(Gdx.graphics.getDeltaTime());
}
@Override
public void resize (int width, int height) {
if (screen != null) screen.resize(width, height);
}
/** Sets the current screen. {@link Screen#hide()} is called on any old screen, and {@link Screen#show()} is called on the new
* screen, if any.
* @param screen may be {@code null}
*/
public void setScreen (Screen screen) {
if (this.screen != null) this.screen.hide();
this.screen = screen;
if (this.screen != null) {
this.screen.show();
this.screen.resize(Gdx.graphics.getWidth(), Gdx.graphics.getHeight());
}
}
/** @return the currently active {@link Screen}. */
public Screen getScreen () {
return screen;
}
}
Исследуем класс Game (Можно пропустить)
Класс Game — это реализация интерфейса ApplicationListener, данный класс будет интерфейсом между нашим кодом и платформо-зависимым приложением, которое будет запускаться непосредственно на устройстве.
Например, когда Android запускает наш app, он будет проверит на наличие ApplicationListener. Мы со своей стороны может предоставить Game объект который реализует необходимый интерфейс.
Есть только маленькая особенность. Заметьте, что класс Game — абстрактный. Это значит, что класс Game реализует далеко не все методы из интерфейса ApplicationListener и нам придется сами это сделать.
Мы можем скопировать содержимое класса Game и реализовать отсутствующие методы, не хватает только метода create(). Но, чтоюбы этого не делать, мы создадим свой класс который наследует класс Game.
Наследование куда проще вещь, чем реализация интерфейсов. Мы просто берем абстрактный класс Game и создаем саб-класс, который наследует все публичные методы и переменные из класса Game, как будто бы они часть нашего саб-класса. И далее мы можем добавить наши собственные методы в наш саб-класс.
Давайте создадим наш класс.
Пишем код! (Наконецто!)
Откройте ZBGame.java который мы создали во время второго дня. Удалите все методы и все переменные внутри класса. Ваш код теперь должен выглядеть так:
package com.kilobolt.zombiebird;
public class ZBGame {
}
Расширим класс Game
Мы собираемся расширить базовый класс Game, который будет мостом между нашим кодом и плафтормо-независимым кодом (на iOS, Android и т.д.).
- Добавьте extends Game
- Добавьте следующий импорт:
import com.badlogic.gdx.Game;
Импорт — означает следующее: «Эй, Компилятор, вот тебе полный адрес к классу Game на который я ссылаюсь». Необходимо это сделать, потому как может присутствовать множество классов с именем Game, и мы хотим указать какой именно класс с именем Game использовать.
package com.kilobolt.ZombieBird;
import com.badlogic.gdx.Game;
public class ZBGame extends Game {
}
Eclipse выдаст следующее предупреждение:
Это означает, что для того, чтобы наш класс ZBGame смог стать классом Game, существует требование: наш класс должен реализовать метод create(). Просто кликните на «Add unimplemented methods,» и этот методо автоматически добавится в наш класс. Давайте добавим строчку кода в наш новый метод:
(На заметку, мы будем использовать Gdx.app.log вместо System.out.println(). Метод Gdx.app.log используется для вывода значений в консоль, и данный метод реализован под каждую платформу по-своему (на Android, этот метод будет использовать Log класс. На Java, он использует System.out.println(). В роли параметров для этого метода могут быть имя класса и тело сообщения)).
package com.kilobolt.zombiebird;
import com.badlogic.gdx.Game;
import com.badlogic.gdx.Gdx;
public class ZBGame extends Game {
@Override
public void create() {
Gdx.app.log("ZBGame", "created");
}
}
Давайте не много сбавим обороты на пару минут...
Для чего нам надо, чтобы наш класс ZBGame был объектом типа Game?
Причина #1:
Как я ранее упоминал, libGDX прячет от нас реализацию платформо-зависимого кода. Весь код, который мы должны были бы написать для iOS/Android/HTML/Windows/Mac уже написан за нас. Как разработчики игры, мы должны позаботится о нашей бизнес логике, и мы делаем это создав ApplicationInterface.
Расширяя класс Game (саб-класс ApplicationInterface), ZBGame становится интерфейсом между нашим кодом и платформой на которой будет работать наше приложение. Теперь весь код за сценой для Android, iOS, HTML и т.д. может общаться с нашим классом ZBGame и творить чудеса вместе.
Причина #2:
В дополнение к выше сказанному, ZBGame получает доступ ко всем полезным методам из класса Game (пролистайте выше, если запамятовали).
В общем то, это относится к первой причине. Эти методы будут дергаться кроссплатформенным кодом.
Теперь, когда мы запустим наше приложение на одной из платформ, кроссплатформенный код запустит метод create(), и «created.» выведется в консоли.
Давайте разберемся, что все это значит.
Мы собираемся создать наш первый Screen (который в дальнейшем станет нашим GameScreen из диаграммы) и используем его в ZBGame.
Саздание GameScreen
Кликните правой кнопкой мышки на папке src внутри главного (CORE) проекта ZombieBird и создайте новый Java пакет с именем com.kilobolt.screens.
Внутри него, создайте новый класс и импортируйте Screen класс:
package com.kilobolt.screens;
import com.badlogic.gdx.Screen;
public class GameScreen implements Screen {
}
Мы дожлны реализовать методы из интерфейса Screen. Вы можете использовать авто-генерацию (как мы уже делали в ZBGame) нажав на «Add unimplemented methods,» или добавить методы как я это сделал ниже. Добавьте в каждый метод Gdx.app.log():
package com.kilobolt.screens;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Screen;
import com.badlogic.gdx.graphics.GL20;
public class GameScreen implements Screen {
public GameScreen() {
Gdx.app.log("GameScreen", "Attached");
}
@Override
public void render(float delta) {
// Sets a Color to Fill the Screen with (RGB = 10, 15, 230), Opacity of 1 (100%)
Gdx.gl.glClearColor(10/255.0f, 15/255.0f, 230/255.0f, 1f);
// Fills the screen with the selected color
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
}
@Override
public void resize(int width, int height) {
Gdx.app.log("GameScreen", "resizing");
}
@Override
public void show() {
Gdx.app.log("GameScreen", "show called");
}
@Override
public void hide() {
Gdx.app.log("GameScreen", "hide called");
}
@Override
public void pause() {
Gdx.app.log("GameScreen", "pause called");
}
@Override
public void resume() {
Gdx.app.log("GameScreen", "resume called");
}
@Override
public void dispose() {
// Leave blank
}
}
Добавим использование GameScreen в наш ZBGame класс
Сделаем наш текущий screen в классе ZBGame объектом класса GameScreen, который мы только, что создали. Для этого вернемся в файл ZBGame.java.
- Добавим следующее в метод create():
setScreen(new GameScreen());
На заметку: Метод setScreen() доступен благодаря наследованию!
- Импортируем класс GameScreen:
import com.kilobolt.screens.GameScreen;
package com.kilobolt.zombiebird;
import com.badlogic.gdx.Game;
import com.badlogic.gdx.Gdx;
import com.kilobolt.screens.GameScreen;
public class ZBGame extends Game {
@Override
public void create() {
Gdx.app.log("ZBGame", "created");
setScreen(new GameScreen());
}
}
Теперь мы можем запустить нашу игру (для этого, как всегда, перейдем в проект ZombieBird — desktop и выполним DesktopLauncher). Вы увидите красивое синее окно.
Гляньте, что у нас вывелось в консоль:
Я понимаю, что это был не самый крутой урок, но пожалуйста, потратьте еще не много времени, и просмотрите ваш код, пройдя по всем строкам.
Важной вещью является то, что мы не вызываем эти методы сами. Мы предоставили эту работу libGDX сделать за нас.
Очень важно, чтобы вы понимали порядок выполнения каждого метода, так, что мы сможем создавать объекты в правильном отрезке времени и плавные переходы в нашей игре.
Если вы готовы, поехали дальше. В следующей части, мы начнем создавать игровой процесс.
Исходный код за день
Если вы вне настроения писать код самостоятельно, скачайте его отсюда:
zombiebird_day_3.zip
День 4 — GameWorld, GameRenderer и Orthographic камера
Добро пожаловать в Четвертый День! В данной секции, мы создадим два вспомогательных класса для нашего GameScreen, так, что мы сможем приступить к созданию игрового процесса. После мы добавим orthographic камеру и несколько фигур в нашу игру!
Быстрая напоминалка
У нас пять Java проектов, которые мы сгенерировали используя libGDX установщик. Но в целом мы будем использовать только три из них во время создания нашей игры:
- Если я прошу вас открыть класс или создать новый пакет, сделайте это в рамках проекта ZombieBird.
- Если я прошу запустить проект, вы откроете ZombieBird-desktop проект и выполните класс DesktopLauncher
- Если нам надо добавить картинки или звуки, мы добавим их в ZombieBird-android проект в папку assets. Все остальные проекты получат копию содержимого этой папки.
Исследуем класс GameScreen
Запустите Eclipse и откройте класс GameScreen. В третьем дне, мы обсуждали, как и когда каждый из методов этого класса запускается. Давайте внесем мелкие изменения в этот класс. Посмотрите на метод render(). У него один аргумент delta, тип float. Чтобы понять зачем он нужен, добавим в метод следующую строчку: Gdx.app.log(«GameScreen FPS», (1 / delta) + " ");:
@Override
public void render(float delta) {
// установим цвет бэкграцнда нашего экрана (RGB = 10, 15, 230), с прозрачностью 1 (100%)
Gdx.gl.glClearColor(10/255.0f, 15/255.0f, 230/255.0f, 1f);
// заполним экран указанным цветом
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
// выведем в консоль количество кадров в секунду
Gdx.app.log("GameScreen FPS", (1/delta) + "");
}
Попробуйте запустить игру (DesktopLauncher.java внутри вашего desktop проекта). Вы увидите следующее:
Float delta это количество секунд (обычно очень маленькое значение) которое прошло после последнего запуска метода render. Когда я попросил вас вывести в консоль значение 1/delta, это означало вывести сколько раз метод render будет вызван в течении одной секунды. Это значение равнозначно нашему FPS.
Такс, думаю теперь ясно, что наш метод render можно расценивать как наш игровой цикл. А в игровом цикле, мы будем делать две вещи:
Во-первых, мы будем обновлять все наши игровые объекты. Во-вторых, мы будет отрисовывать эти объекты.
Чтобы использовать ООП принципы и патерны проектирования, мы должны следовать следующим принципам:
- GameScreen должен делать одну вещь, так что…
- Обновление игровых объектов должно лежать на плечах вспомогательного класса.
- Отрисовка игровых объектов должно входить в обязанности другого вспомогательного класса.
Хорошо! Нам нужны два вспомогательных класса. Мы дадим им наглядные имена: GameWorld и GameRenderer.
Создайте новый пакет с именем com.kilobolt.gameworld и в нем создайте эти два класса. Оставим на время их пустыми:
GameWorld.java
|
GameRenderer.java
|
В нашем GameScreen, мы делегируем обновление и отрисовку нашим классам GameWorld и GameRenderer, соответственно. Чтобы это провернуть, сделаем следующее:
- Во время создания GameScreen, мы должны создать два новых объекта типа GameWorld и GameRenderer.
- Внутри render метода класса GameScreen, мы должны запросить выполнить обновление и отрисовку у классов GameWorld и GameRenderer соответственно.
Я попрошу вас сейчас сделать это самостоятельно, если вы застрянете на этом — пролистайте ниже.
1. Создание GameWorld и GameRenderer
Откройте GameScreen. Мы создадим объекты GameWorld и GameRenderer в конструкторе класса. Их методы мы будем вызывать в методе render(). Чтобы это сделать:
- Нам нужны две переменные для экземляров объектов (эти переменные должны быть доступны где угодно внутри нашего класса). Объявите следующее в заголовке нашего класса:
private GameWorld world; private GameRenderer renderer;
- GameScreen создан в конструкторе. Добавьте следующие строчки внутрь нашего конструктора, чтобы инициализировать наши переменные:
world = new GameWorld(); // initialize world renderer = new GameRenderer(); // initialize renderer
- Добавьте необходимые иморты:
import com.kilobolt.gameworld.GameRenderer; import com.kilobolt.gameworld.GameWorld;
2. Запросим GameWorld обновиться и GameRenderer отрисовать
Вся суть наличия классов GameWorld и GameRenderer в том, что GameScreen не должен делать обновления и отрисовку самолично. Он может запросить наши вспомогательные классы сделать это за него.
Замените весь код в методе render на следующий:
// Мы передаем delta в update метод, для того, чтобы мы могли сделать фреймо-зависимые вычисления
world.update(delta); // GameWorld updates
renderer.render(); // GameRenderer renders
Ваш GameScreen должен теперь выглядеть так:
package com.kilobolt.screens;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Screen;
import com.badlogic.gdx.graphics.GL20;
import com.kilobolt.gameworld.GameRenderer;
import com.kilobolt.gameworld.GameWorld;
public class GameScreen implements Screen {
private GameWorld world;
private GameRenderer renderer;
public GameScreen() {
Gdx.app.log("GameScreen", "Attached");
world = new GameWorld();
renderer = new GameRenderer();
}
@Override
public void render(float delta) {
world.update(delta);
renderer.render();
}
@Override
public void resize(int width, int height) {
}
@Override
public void show() {
Gdx.app.log("GameScreen", "show called");
}
@Override
public void hide() {
Gdx.app.log("GameScreen", "hide called");
}
@Override
public void pause() {
Gdx.app.log("GameScreen", "pause called");
}
@Override
public void resume() {
Gdx.app.log("GameScreen", "resume called");
}
@Override
public void dispose() {
// Оставьте пустым
}
}
Eclipse ругнется что у вас не объявлены методы update в GameWorld и render в GameRenderer. Давайте сделаем это:
GameWorld
|
GameRenderer
|
Попробуйте запустить игру (DesktopLauncher класс в desktop проекте).
Внимание: ваша игра может мерцать (мы ведь ничего не рисуем).
В консоли мы увидим следующее:
Великолепно. Подведем итоги, что мы сделали: мы делегировали два задания (обновление и отрисовку игры), так что наш GameScreen не должен беспокоиться об этом. Давайте глянем опять на нашу диаграмму (вы видите, где мы сейчас?):
Нам необходимо внести мелкие изменения. Наш GameRenderer должен иметь доступ к GameWorld, который он будет отрисовывать. Чтобы это сделать, спросим себя, «Кто имеет доступ к обоим: GameRenderer и GameWorld?». Если посмотреть на диаграмму, то видно, что это GameScreen. Давайте его откроем и внесем следующие изменения в его констуктор:
// Это конструктор, не объявление класса
public GameScreen() {
Gdx.app.log("GameScreen", "Attached");
world = new GameWorld();
renderer = new GameRenderer(world);
}
Упс, Eclipse ругается на не верное использование конструктора класса GameRenderer. Давайте его изменим.
Откройте GameRenderer класс. Нам необходимо сохранить world как переменную внутри нашего GameRenderer класса, так что в будущем, когда нам понадобится объект из GameWorld мы сможем использовать переменную world.
- Создайте переменную:
private GameWorld myWorld;
- Внутри конструктора, добавьте новый аргумент и его значение присвойте нашей переменной myWorld:
package com.kilobolt.gameworld; import com.badlogic.gdx.Gdx; public class GameRenderer { private GameWorld myWorld; public GameRenderer(GameWorld world) { myWorld = world; } public void render() { Gdx.app.log("GameRenderer", "render"); } }
Отвлечемся от написания код на минутку
Потратьте не много времени, чтобы понять, что мы только, что сделали. Просмотрите ваш код и убедитесь, что вы видите 3-х сторонюю взаимосвязь между тремя классами, с которыми работали. Я надеюсь, вы поняли роли классов GameScreen, GameWorld и GameRenderer и как они вместе работают.
Готовы продолжить?
Мы сделаем еще кое, что в этом уроке, чтобы показать вам как мы можем создавать GameObjects и как их реализовать. Но прежде всего мы поговорим об Orthographic Camera.
Orthographic Camera
libGDX — это фреймворк для создания 3D игр. Но, наша игра будет в 2D. Что это все значит для нас? В целом ничего, потому что мы сможем задействать одну вещь, которая называется orthographic camera.
Множество 2D игр, которые вы могли видеть, в реальности сделаны в 3D. Множество современных платформеров (даже те которые используют pixel art) отрисованы с помозью 3D движка, на котором разработчики создают сцены больше в 3D пространстве, чем в 2D.
На пример, посмотрите на Mario сделанную ее фанатом, в этой игре весь мир был построен используя 3D модели.
Играясь с Mario 2.5D выше, становится ясно, что игра в 3D. У персонажей есть «глубина».
Чтобы сделать эту игру в 2D, мы, возможно, должны повернуть камеру таким образом, чтобы мы смотрели на игру с лицевой стороны. Верьте мне или нет, но игра все равно останется в 3D. Попробуйте поиграться сами.
Почему так? Потому что в 3D окружении (осмотритесь вокруг), объекты которые отдалены выглядят маленькими для наблюдателя. Не смотря на это, в Mario мы смотрим с перпендикулярного угла, некоторые объекты в этом 3D мире, к примеру кирпичи/блоки, будут меньше чем те блоки, что ближе к нам (к камере).
Вот как раз в таких случаях и появляется на сцене orthographic camera. Когда мы используем ортографическую проекцию, все объекты на сцене, не зависимо от их отдаленности, спроекцированы под одну планку. Представьте себе большое полотно которым накрыли все объекты на сцене, и это объекты от соприкосновения с полотном станут плоскими, с зафиксированным размером изображения. Это то, что предоставляет для нас orthographic camera, и это то как мы можем создавать 2D игру в 3D пространстве.
А вот как игра выглядела, если бы использовали orthographic camera:
Используя orthographic camera, мы можем проецировать 3D в единую для просмотра плоскость.
Я надеюсь не испугал вас этими разговорами про 3D пространство и проекцию камеры. Вы все поймете, когда мы будем писать код, на самом деле все очень просто. Так что давайте добавим камеру в нашу игру.
Давайте добавим еще одно изменение в наш DesktopLauncher.java в Desktop Project (который мы используем, чтобы запускать игру). Мы изменим разрешение экрана:
package com.kilobolt.zombiebird.desktop;
import com.badlogic.gdx.backends.lwjgl.LwjglApplication;
import com.badlogic.gdx.backends.lwjgl.LwjglApplicationConfiguration;
import com.kilobolt.zombiebird.ZBGame;
public class DesktopLauncher {
public static void main (String[] arg) {
LwjglApplicationConfiguration config = new LwjglApplicationConfiguration();
config.title = "Zombie Bird";
config.width = 272;
config.height = 408;
new LwjglApplication(new ZBGame(), config);
}
}
Создание нашей Камеры
Откройте наш класс GameRenderer. В нем мы создадим новый объект Orthographic Camera.
- Объявите переменную в классе:
private OrthographicCamera cam;
- Добавьте импорт:
import com.badlogic.gdx.graphics.OrthographicCamera;
- Создайте экземляр объекта внутри конструктора:
cam = new OrthographicCamera(); cam.setToOrtho(true, 136, 204);
Три аргумента означают следующее:
- Хотим ли использовать Orthographic проекцию (мы хотим)
- Какая должна быть ширина
- Какой должна быть высота
Это размер нашего игрового мира. Позже мы внесем изменения в этой части кода. Пока, что этот код мы написали для примера. Помните, мы задали разешение для нашей игры в DesktopLauncher.java следующее 272 x 408. Это значит, что все что будет в нашей игре, мы будем масштабировать на коэфициент 2х в момент отрисовки.
Создание ShapeRenderer
Чтобы протестировать нашу камеру, мы создадим объект типа ShapeRenderer, который будет рисовать формы и линии для нас. Эта функциональность предоставлена libGDX!
Внутри GameRenderer:
- Объявите в классе переменную:
private ShapeRenderer shapeRenderer;
- Добавьте импорт:
import com.badlogic.gdx.graphics.glutils.ShapeRenderer;
- Инициализируйте shapeRenderer и привяжите его к нашей камере внутри конструктора класса:
shapeRenderer = new ShapeRenderer(); shapeRenderer.setProjectionMatrix(cam.combined);
В итоге у вас должно было получиться следующее:
package com.kilobolt.gameworld;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.graphics.glutils.ShapeRenderer;
public class GameRenderer {
private GameWorld myWorld;
private OrthographicCamera cam;
private ShapeRenderer shapeRenderer;
public GameRenderer(GameWorld world) {
myWorld = world;
cam = new OrthographicCamera();
cam.setToOrtho(true, 136, 204);
shapeRenderer = new ShapeRenderer();
shapeRenderer.setProjectionMatrix(cam.combined);
}
public void render() {
Gdx.app.log("GameRenderer", "render");
}
}
Наш ShapeRenderer готов, давайте создадим что-то, что сможем отрисовать! Мы можем создать квадратный объект внутри нашего GameRenderer, но это нарушает наши принципы проектирования. Мы должны создавать все Game Objects внутри нашего GameWorld и отрисовывать их в GameRenderer.
Откройте GameWorld и внесите следующие изменения:
package com.kilobolt.gameworld;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.math.Rectangle;
public class GameWorld {
private Rectangle rect = new Rectangle(0, 0, 17, 12);
public void update(float delta) {
Gdx.app.log("GameWorld", "update");
rect.x++;
if (rect.x > 137) {
rect.x = 0;
}
}
public Rectangle getRect() {
return rect;
}
}
Мы создали новый Rectangle и назвали его rect, а так же добавили импорт: import.com.badlogic.gdx.math.Rectangle. Заметьте, что мы не используем Java Rectangle, потому как он не доступен на некоторых платформах (реализация gdx.math.Rectangle создает правильный Rectangle в зависимости от платформы).
Так же мы добавили private видимоть нашему Rectangle, и добавили get метод, для доступа к нашему Rectangle вне GameWorld объекта (хорошее пояснение, почему мы использовали getter можно прочитать тут).
Далее мы добавили код, который перемещает наш rect вправо (и возвращает на стартовую позицию)!
Теперь мы можем вернуться в наш GameRenderer, так как наш Rectangle готов, чтобы его отрисовали. Откройте GameRenderer и добавьте изменения в render метод (я разбил метод на три главные секции. Пожалуйста, прочтите комментарии, чтобы понять, что происходит):
package com.kilobolt.gameworld;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.graphics.glutils.ShapeRenderer;
import com.badlogic.gdx.graphics.glutils.ShapeRenderer.ShapeType;
public class GameRenderer {
private GameWorld myWorld;
private OrthographicCamera cam;
private ShapeRenderer shapeRenderer;
public GameRenderer(GameWorld world) {
myWorld = world;
cam = new OrthographicCamera();
cam.setToOrtho(true, 136, 204);
shapeRenderer = new ShapeRenderer();
shapeRenderer.setProjectionMatrix(cam.combined);
}
public void render() {
Gdx.app.log("GameRenderer", "render");
/*
* 1. Мы рисуем черный задний фон, чтобы избавится от моргания и следов от передвигающихся объектов
*/
Gdx.gl.glClearColor(0, 0, 0, 1);
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
/*
* 2. Мы отрисовываем однотонный квадрат
*/
// Говорим shapeRenderer начинать отрисовывать формы
shapeRenderer.begin(ShapeType.Filled);
// Выбираем RGB Color 87, 109, 120, не прозрачный
shapeRenderer.setColor(87 / 255.0f, 109 / 255.0f, 120 / 255.0f, 1);
// Отрисовываем квадрат из myWorld (Используем ShapeType.Filled)
shapeRenderer.rect(myWorld.getRect().x, myWorld.getRect().y,
myWorld.getRect().width, myWorld.getRect().height);
// говорим shapeRenderer прекратить отрисовку
// Мы ДОЛЖНЫ каждый раз это делать
shapeRenderer.end();
/*
* 3. Мы отрисовываем рамку для квадрата
*/
// Говорим shapeRenderer нарисовать рамку следующей формы
shapeRenderer.begin(ShapeType.Line);
// Выбираем цвет RGB Color 255, 109, 120, не прозрачный
shapeRenderer.setColor(255 / 255.0f, 109 / 255.0f, 120 / 255.0f, 1);
// Отрисовываем квадрат из myWorld (Using ShapeType.Line)
shapeRenderer.rect(myWorld.getRect().x, myWorld.getRect().y,
myWorld.getRect().width, myWorld.getRect().height);
shapeRenderer.end();
}
}
Нет ошибок? Великолепно! Вы должны увидеть нечто, как на картинке ниже:
Потратьте не много времени, поэкспериментируйте с кодом. Если вы научились рисовать формы, значит вы можете рисовать картинки, чем мы и займемся далее.
Я знаю, прогресс очень медленный, но поверьте, мы наращиваем темп разработки нашей игры, теперь у нас есть базовый GameScreen!
Исходный код за день
Если вы вне настроения писать код самостоятельно, скачайте его отсюда:
zombiebird_day_4.zip
День 5 — Полет мертвеца — Добавляем птицу
В данной секции мы добавим Птичку в нашу игру.
Поговорим не много про нашего главного персонажа. Flaps — веселая красная птичка, которая летает по своим делам туда-сюда, пока кто-то ее не стукнул о канализационную трубу. Но сейчас, Flapps вернулся, и смотрит очень пристально за тобой! (Извини Kyle, я все еще считаю, что это самая уродская птица, которую я когда-либо видел).
Теперь вы знакомы с нашим главным персонажем, давайте научим его летать.
Освежим нашу память
У нас пять Java проектов, которые мы сгенерировали используя libGDX установщик. Но всю магию мы будем делать только в трех проектах:
- Если я когда-либо попрошу открыть класс или создать новый пакет, делаем это в ZombieBird проекте.
- Если я попрошу запустить ваш код, откройте проект ZombieBird desktop и запустите DesktopLauncher.java.
- Если мы добавляем картинки или звуки, мы добавляем их в проект ZombieBird-android в папку assets. Все остальные проекты имеют указатель на эту папку.
Система координат
Я забыл упомянуть (вы скорее всего уже и сами поняли), что мы будем использовать Y-Вниз систему координат. Это значит, что левый верзний угол имеет координаты (0, 0).
Что это значит? Если у нашей птички будет положительное ускорение по Y, то она будет лететь вниз.
Разрешение экрана
Наша игра будет работать на чем угодно, от iPhone до iPad и на многочисленных Android устройствах. Нам необходимо корректно обрабатывать разрешение экрана.
Чтобы этого добиться, мы установим для игры фиксированную ширину в 136 пикселей. Высота будет определяться динамически! После определения разрешения экрана для устройства, мы зададим высоту для нашей игры.
Создание класса Bird.java
Наш Flaps должен иметь свой собственный класс. Давайте этим и займемся.
Создайте новый пакет и назовите его com.kilobolt.gameobjects, а в нем создайте класс Bird.
Переменные в классе:
Наш Bird должен иметь следующие переменные: позиция, скорость и ускорение (подробнее об этом далее). Нам так же нужно хранить значение угла поворота нашей птички, а также ширну и высоту.
private Vector2 position;
private Vector2 velocity;
private Vector2 acceleration;
private float rotation; // Для обработки поворота птицы
private int width;
private int height;
Vector2 очень мощный класс, встроеный в libGDX. Если вы не сильны с векторами в математике — не переживайте! Тут мы будем использовать Vector2, как контейнер для двух переменных: x и y.
position.x — определяет позицию по оси X, а velocity.y отвечает за скорость по оси Y. acceleration — данный параметр управляет нашей velocity, чем больше acceleration, тем больше velocity.
Вся эта каша станет более прозрачной чуть позже.
Конструктор
Что нам необходимо для создания нашей Птички? Нам необходимы значения позиции, а также размеры нашей птички.
public Bird(float x, float y, int width, int height) {
this.width = width;
this.height = height;
position = new Vector2(x, y);
velocity = new Vector2(0, 0);
acceleration = new Vector2(0, 460);
}
Наш Bird объект будет храниться в GameWorld. Нам необходимы следующие методы:
- Методе update, который будет запускать во время обновления GameWorld
- onClick метод, который будет отрабатывать клики/касания по экрану
Так же нам необходимо создать методы доступа к некоторым переменным нашего Bird объекта:
package com.kilobolt.gameobjects;
import com.badlogic.gdx.math.Vector2;
public class Bird {
private Vector2 position;
private Vector2 velocity;
private Vector2 acceleration;
private float rotation; // For handling bird rotation
private int width;
private int height;
public Bird(float x, float y, int width, int height) {
this.width = width;
this.height = height;
position = new Vector2(x, y);
velocity = new Vector2(0, 0);
acceleration = new Vector2(0, 460);
}
public void update(float delta) {
velocity.add(acceleration.cpy().scl(delta));
if (velocity.y > 200) {
velocity.y = 200;
}
position.add(velocity.cpy().scl(delta));
}
public void onClick() {
velocity.y = -140;
}
public float getX() {
return position.x;
}
public float getY() {
return position.y;
}
public float getWidth() {
return width;
}
public float getHeight() {
return height;
}
public float getRotation() {
return rotation;
}
}
Логика для описанного выше очень простая. Каждый раз, когда метод update нашего класса Bird выполняется, мы делаем две вещи:
- Мы добавляем отмасштабированый вектор ускорения (мы к этому вернемся) к нашему вектору скорости. Так мы получаем нашу новую скорость. Так в принципе работает гравитация. Скорость притяжения увеличивается на 9,8 м/с каждумаю секунду.
- Помните, что физика Flappy Bird имеет ограничение по максимальной скорости. После экспериментов, я установил максимум для velocity.y в 200.
- Мы добавляем обновленное смасштабированное значение скорости к позиции нашей птицы (так мы получем новую позицию).
Что я подразумеваю под "отмасштабированый" в пунктах 1 и 3? Мы будем умножать наши ускорение и скорость на дельту, которая представляет из себя, сколько прошло времени, с прошлого раза, когда запускался метод update. Это эффект нормализации.
Если ваша игра, по каким-то причинам, начнем тормозить, ваша delta увеличится (ваш процессор выполнил прошлый цикл, или повторение, или итерацию за большее время). Масштабируя наши Vector'ы с помощью delta, мы можем добиться независимости от частоты кадров. Если метод update выполнялся вдвое больше, значит мы просто смещаем нашего персонажа на скорость увеличенную на 2 и так далее.
Мы применим эти принципы не много позже!
Наша птичка готова, выпустим ее в GameWorld!
Внимание
Каждый раз, как вы создаете новый Object, вы выделяете не много памяти в RAM для этого объекта (если быть точнее, то в Heap). Как только ваш Heap переполняется, подпрограмма, которая называется Garbage Collector (далее GC, Мусоросборщик/Сборщик), выходит на сцену и чистит вашу память, чтобы избежать ситуации с нехваткой памяти. Это круто, но не, когда вы создаете игру. Во время работы GC, ваша игра начинает притормаживать на несколько значительных милисекунд. Чтобы избежать частой работы GC, вам надо избегать создания новых объектов, по возможности.
Я недавно открыл для себя, что метод Vector2.cpy() создает новый экземляр типа Vector2, вместо того чтобы повторно использовать существующий экземпляр. Это означает, что при 60 FPS, вызывая Vector2.cpy(), вы создадите 60 новых объектов типа Vector2 каждую секунду, что в свою очеред заставить Java GC появляться на сцене очень часто.
Просто держите это в памяти. Мы решим эту проблему не много позже.
Откройте GameWorld класс
Давайте удалим Rect объект, который мы создали ранее. Вот, что у вас должно быть:
package com.kilobolt.gameworld;
public class GameWorld {
public void update(float delta) {
}
}
Если вы хотите, вы так же можете удалить логику отрисовки Rect объекта в GameRenderer, чтобы избавиться от ошибок в Eclipse. Мы это сделаем в слудющем дне.
Давайте сначала создадим конструктор нашего класса GameWorld:
public GameWorld() { }
Импортируйте класс Bird и создайте новую переменную типа Bird в классе GameWorld (не инициализируйте пока что ее). Вызовите метод update нашей птички в GameWorld.update(float delta) . Вот что мы получили:
package com.kilobolt.gameworld;
import com.kilobolt.gameobjects.Bird;
public class GameWorld {
private Bird bird;
public GameWorld() {
// инициализируйте Bird тут
}
public void update(float delta) {
bird.update(delta);
}
public Bird getBird() {
return bird;
}
}
Теперь нам необходимо создать нашу птичку. Какая информация нам понадобится? Координаты и размер (x, y, width, height — это те 4 переменные которые нам нужны, чтобы вызвать конструктор класса Bird).
Значение по X должно быть 33 (это то место, где птичка остается на протяжении всего игрового времени). Ширина должна быть 17. Высота 12.
А что насчет Y? Из моих соображений, это должно быть значение равное 5 пикселям над вертикальной серединой экрана (Помните, что мы масштабируем все до 137 х ??? разрешения экрана, где высота определяется с помощью коэффициента между высотой и шириной экрана, умножая его на 137).
Добавьте эту строчку в конструктор:
bird = new Bird(33, midPointY - 5, 17, 12);
Как мы получим midPointY? Мы запросим это значение из нашего GameScreen. Помните, что конструктор GameWorld вызван когда GameScreen создает объект типа GameWorld. Так что мы можем добавить новый аргумент в конструктор класса GameWorld и в GameScreen передать его.
Добавьте это в конструктор GameWorld: (int midPointY)
Вот, что у вас должно было получиться:
package com.kilobolt.gameworld;
import com.kilobolt.gameobjects.Bird;
public class GameWorld {
private Bird bird;
public GameWorld(int midPointY) {
bird = new Bird(33, midPointY - 5, 17, 12);
}
public void update(float delta) {
bird.update(delta);
}
public Bird getBird() {
return bird;
}
}
Теперь нам необходимо внести изменения в наш класс GameScreen. Давайте откроем его:
Как и ожидалось, у нас тут ошибка, в строке, где происзодит вызов конструктора GameWorld. Ошибка говорит следующее: "to create a new GameWorld, you must give us an integer"(чтобы создать новый GameWorld, вы должны передать integer), давайте это сделаем!
Но сначала, давайте расчитаем midPointY нашего экрана и после передадим это значение в конструктор GameWorld.
Когда я говорю midPointY, это то, что я имею ввиду. Помните, что наша игра будет иметь 136 единиц в ширину. Наш экран может быть 1080 пикселей в ширину, так что мы отмасштабируем все на 1/8. Чтобы получить высоту игры, мы должны взять высоту экрана и отмасштабировать ее на тот же фактор!
Чтобы получить высоту и ширину нашего экрана, мы можем использовать следующие методы: Gdx.graphics.getWidth() и Gdx.graphics.getHeight().
Давайте используем эту информацию, чтобы реализовать логику нашего конструктора:
package com.kilobolt.screens;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Screen;
import com.kilobolt.gameworld.GameRenderer;
import com.kilobolt.gameworld.GameWorld;
public class GameScreen implements Screen {
private GameWorld world;
private GameRenderer renderer;
// This is the constructor, not the class declaration
public GameScreen() {
float screenWidth = Gdx.graphics.getWidth();
float screenHeight = Gdx.graphics.getHeight();
float gameWidth = 136;
float gameHeight = screenHeight / (screenWidth / gameWidth);
int midPointY = (int) (gameHeight / 2);
world = new GameWorld(midPointY);
renderer = new GameRenderer(world);
}
@Override
public void render(float delta) {
world.update(delta);
renderer.render();
}
@Override
public void resize(int width, int height) {
}
@Override
public void show() {
Gdx.app.log("GameScreen", "show called");
}
@Override
public void hide() {
Gdx.app.log("GameScreen", "hide called");
}
@Override
public void pause() {
Gdx.app.log("GameScreen", "pause called");
}
@Override
public void resume() {
Gdx.app.log("GameScreen", "resume called");
}
@Override
public void dispose() {
// Leave blank
}
}
Теперь, когда мы создали нашу птицу, мы должны научиться контролировать ее. Давайте создадим наш input handler!
Создадим ZBHelpers
Диаграмма наносит ответный удар! Сейчас мы уделим больше внимания Framework Helpers на третьем уровне. ZBGame нуждается в функционале для работы с вводом, картинками, звуками и т.д.
Мы создадим два класса прямо сейчас.
Первым классом будет InputHandler, который, как предполагает имя, будет рагировать на различного рода входных действий, единственное за что мы должны беспокоиться это touch (касание) (на PC/Mac все клики конвертируются в касания).
Второй класс — это AssetLoader . Этот класс загрузит для нас картинки, анимации, звуки и т.д.
Мы вернемся к AssetLoader очень скоро. Для начала давайте реализуем нам InputHandler.
Создайте новый пакет com.kilobolt.zbHelpers, а в нем создайте новый класс InputHandler.
InputHandler очень легко реализовать. Нам всего лишь необходимо реализовать InputProcessor, который является интерфейсом между нашим кодом и кросплатформенным кодом. Когда наша платформа (Android, iOS, и т.д.) получает некий input, к примеру касание (touch), она вызовет метод в InputProcessor, который мы предоставим реализовав его.
Добавьте «implements InputProcessor» в строку объявления класса (а так же импортируйте этот класс). Появится ошибка, что нам необходимо добавить нереализованные методы. Давайте сделаем это:
У вас должно было получиться следующее:
package com.kilobolt.ZBHelpers;
import com.badlogic.gdx.InputProcessor;
import com.kilobolt.GameObjects.Bird;
public class InputHandler implements InputProcessor {
@Override
public boolean touchDown(int screenX, int screenY, int pointer, int button) {
return false;
}
@Override
public boolean keyDown(int keycode) {
return false;
}
@Override
public boolean keyUp(int keycode) {
return false;
}
@Override
public boolean keyTyped(char character) {
return false;
}
@Override
public boolean touchUp(int screenX, int screenY, int pointer, int button) {
return false;
}
@Override
public boolean touchDragged(int screenX, int screenY, int pointer) {
return false;
}
@Override
public boolean mouseMoved(int screenX, int screenY) {
return false;
}
@Override
public boolean scrolled(int amount) {
return false;
}
}
Как видите, у нас повилось много новых методов с которыми мы можем работать. Пока что, нам нужно позаботиться о методе touchDown().
TouchDown должен вызывать метод onClick в нашем классе Bird, но мы не добавили ссылку на наш объект Bird. Мы не сможем вызвать ни один метод из объекта Bird пока не будет ссылки на этот объект. Давайте спросим себя: кто имеет ссылку на объект нашей птички? Конечно же GameWorld, который принадлежит GameScreen! Так что мы попросим GameScreen передать Bird в InputHandler для нас.
Перед тем как вернуться в GameScreen, давайте закончим сначала наш класс InputHandler:
- Создайте переменную в классе InputHandler, чтобы хранить в ней ссылку на нашу птицу:
private Bird myBird;
- Нам необходимо запросить ссылку на Bird внутри конструктора InputHandler:
public InputHandler(Bird bird) { myBird = bird; }
- Теперь мы можем вызывать onClick нашей птички в методе touchDown:
myBird.onClick()
package com.kilobolt.zbhelpers;
import com.badlogic.gdx.InputProcessor;
import com.kilobolt.gameobjects.Bird;
public class InputHandler implements InputProcessor {
private Bird myBird;
// запрашиваем ссылку на Bird когда InputHandler создан.
public InputHandler(Bird bird) {
// myBird является ссылкой на bird в gameWorld.
myBird = bird;
}
@Override
public boolean touchDown(int screenX, int screenY, int pointer, int button) {
myBird.onClick();
return true; // Вернем true чтобы сообщим, что мы обработали касание.
}
@Override
public boolean keyDown(int keycode) {
return false;
}
@Override
public boolean keyUp(int keycode) {
return false;
}
@Override
public boolean keyTyped(char character) {
return false;
}
@Override
public boolean touchUp(int screenX, int screenY, int pointer, int button) {
return false;
}
@Override
public boolean touchDragged(int screenX, int screenY, int pointer) {
return false;
}
@Override
public boolean mouseMoved(int screenX, int screenY) {
return false;
}
@Override
public boolean scrolled(int amount) {
return false;
}
}
Теперь нам необходимо вернуться в GameScreen и создать новый InputHandler, а также прикрутить его к нашей игре!
Откройте GameScreen. Обновите конструктор следующим образом: В конце, мы говорим libGDX использовать наш новый InputHandler как его же собственный процессор.
public GameScreen() {
float screenWidth = Gdx.graphics.getWidth();
float screenHeight = Gdx.graphics.getHeight();
float gameWidth = 136;
float gameHeight = screenHeight / (screenWidth / gameWidth);
int midPointY = (int) (gameHeight / 2);
world = new GameWorld(midPointY);
renderer = new GameRenderer(world);
Gdx.input.setInputProcessor(new InputHandler(world.getBird()));
}
Gdx.input.setInputProcessor() принимает на вход объект типа InputProcessor. Так как мы реализовали InputProcessor в нашем InputHandler, мы можем передать наш InputHandler на вход.
Заметьте, что мы вызываем конструкторв, передавая ссылку на наш объект Bird, который мы получаем из World. Это упрощенное пояснение следующего:
Bird bird = world.getBird();
InputHandler handler = new InputHandler(bird);
Gdx.input.setInputProcessor(handler);
На каком свете мы сейчас?
Мы создали наш класс Bird, создали объект типа Bird внутри нашего GameWorld, и создали InputHandler который будет вызывать метод onClick в нашем классе Bird, благодаря этому наша птица будет взлетать вверх!
Присоединяйтесь ко мне в следующей части, в которой мы отрисуем нашу птичку и ее родной Некрополис.
Исходный код за день
Если вы вне настроения писать код самостоятельно, скачайте его отсюда:
zombiebird_day_5.zip
День 6 — Добавляем графические элементы — Добро пожаловать в Некрополис
Спасибо, что присоединились ко мне в шестом дне. Вы проделали большую работу настраивая framework, но после этой секции, вы увидите, что это стоило того.
Пришло время перевести Flaps в его родную среду обитания. В этом уроке мы создадим наш объект AssetLoader, загрузим анимацию и кучу текстур, а также используем наш Renderer, чтобы отрисовать птичку и ее зловещий город.
Класс AssetLoader
Мы начнем с создания класса AssetLoader в пакете com.kilobolt.zbhelpers (у вас должны быть ошибки в GameRenderer).
Мы создадим объекты следующих типов (все они входят в libGDX):
- Texture — считайте, что это файл с картикной. Мы объединим множество картинок в один файл и будем работать с этим файлом.
- TextureRegion — это квадратная область нашей Texture. Посмотрите на картинку ниже. На картинке присутствует множество областей с текстурами, включая задний фон, траву, Flaps и череп.
- Animation — мы можем взять множество областей с текстурами и создать объект Animation, который будет знать, как анимировать нашу птицу.
Не скачивайте картинку ниже! Она была увеличена в 4-ре раза, так что она не будет работать с нашим кодом. Вместо нее, скачайте файл, который я ниже укажу (спасибо художникам из Kilobolt за предоставленные изобрадения).
Полноценный AssetLoader класс:
package com.kilobolt.zbhelpers;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.Texture.TextureFilter;
import com.badlogic.gdx.graphics.g2d.Animation;
import com.badlogic.gdx.graphics.g2d.TextureRegion;
public class AssetLoader {
public static Texture texture;
public static TextureRegion bg, grass;
public static Animation birdAnimation;
public static TextureRegion bird, birdDown, birdUp;
public static TextureRegion skullUp, skullDown, bar;
public static void load() {
texture = new Texture(Gdx.files.internal("data/texture.png"));
texture.setFilter(TextureFilter.Nearest, TextureFilter.Nearest);
bg = new TextureRegion(texture, 0, 0, 136, 43);
bg.flip(false, true);
grass = new TextureRegion(texture, 0, 43, 143, 11);
grass.flip(false, true);
birdDown = new TextureRegion(texture, 136, 0, 17, 12);
birdDown.flip(false, true);
bird = new TextureRegion(texture, 153, 0, 17, 12);
bird.flip(false, true);
birdUp = new TextureRegion(texture, 170, 0, 17, 12);
birdUp.flip(false, true);
TextureRegion[] birds = { birdDown, bird, birdUp };
birdAnimation = new Animation(0.06f, birds);
birdAnimation.setPlayMode(Animation.PlayMode.LOOP_PINGPONG);
skullUp = new TextureRegion(texture, 192, 0, 24, 14);
// Create by flipping existing skullUp
skullDown = new TextureRegion(skullUp);
skullDown.flip(false, true);
bar = new TextureRegion(texture, 136, 16, 22, 3);
bar.flip(false, true);
}
public static void dispose() {
// Мы должны избавляться от текстур, когда заканчивает работать с объектом в котором есть текстуры
texture.dispose();
}
}
Давайте пройдемся по коду. Как вы видите, у нас множество статических методов и переменных, это значит, что мы не будем создавать экземляры класса Asset — у нас будет только одна копия.
Так же у нас есть два метода: load и dispose.
Метод load будет вызван, когда наша игра запускается, а метод dispose, когда игру закрывают.
Инспектируем метод load()
Texture
Метод load начинается с создания нового объекта типа Texture используя файл texture.png, который я предоставлю вам чуть ниже. Далее выставляются фильтры уменьшения и увеличения (используются, когда картинку увеличивают или уменьшают) используя enum константы: TextureFilter.Nearest. Это важно, потому что когда наша маленькая пиксель-арт картинка растягивается на больший размер, каждый пиксель сохранит свою форму, а не будет размытым!
TextureRegion
Мы можем использовать нашу текстуру, чтобы создать объекты типа TextureRegion, нам необходимы 5 аргументов: подходящий объект типа Texture и квадратные рамки необходимой области на этой текстуре. Мы передаем x, y, width и height начиная с левого верхнего угла нашей картинки, на пример задний фон будет иметь следующие параметры: 0, 0, 136, 43.
Все TextureRegion должны быть перевернуты, так как libGDX использует Y-вверх координаты по умолчанию. Мы используем Y-вниз систему координат, и должны переворачивать каждую картинку (за исключением skullUp, который может остаться вверх-ногами)!
Animation
Мы можем создать массив из объектов типа TextureRegion и передать его в конструктор нового объекта типа Animation:
TextureRegion[] birds = { birdDown, bird, birdUp }; // создаем массив из объектов TextureRegion
birdAnimation = new Animation(0.06f, birds); // Создаем новый объект типа Animation в котором каждый фрейм длиться 0.06 секунд, используя созданный массив.
birdAnimation.setPlayMode(Animation.PlayMode.LOOP_PINGPONG); // Выставляем режим проигрывания типа ping pong, анимация будет проигрываться вперед-назад.
Мы выделили на Animation 3 кадра. Смена кадров будет происходить каждые 0,06 секунд (вниз, середина, вверх, середина, вниз, ...).
Скачать файл с текстурой
Скачайте предоставленную нижу текстуру и поместите ее внутри ZombieBird-android проекта, в папку assets/data/! Это очень важно.
Заметка об использовании картинок: если вы будете когда-либо обновлять картинки (а вы будете, если испольуете свои собственные), вам необходимо почистить проект в Eclipse, чтобы обновления вступили в силу. Сделайте это сейчас, сразу после того как добавите скачанную текстуру, Project > Clean > Clean all projects.
Скачать файл
texture.png
Убедитесь, что вы поместили вашу картинку в правильную папку, как показано слева (заметьте, что мы внутри ZombieBird-android проекта. Вы можете удалить libgdx.png файл, который по умолчанию идет вместе с libGDX).
Если все правильно, не забудьте почистить проект и продолжим дальше.
Вызовем Load метод
Наш AssetLoader готов (и вы скачали картинку и поместили ее в правильную папку, а также почистили проект), мы откроем класс ZBGame, так что мы сможем добавить загруку всех картинок до инициализации GameScreen. Мы добавим следующую строчку в методе create (перед строчкой создания GameScreen):
AssetLoader.load(); (Import com.kilobolt.zbhelpers.AssetLoader)
Мы так же должны вызывать AssetLoader.dispose() когда метод dispose нашего класса ZBGame вызывается кроссплатформенным кодом. Чтобы это сделать, мы должны добавить переопределение существующего метода dispose в наш класс.
Кажется, не много запутанным и сложным, но на самом деле нам нужно сделать всего лишь следующее (полный пример кода):
package com.kilobolt.zombiebird;
import com.badlogic.gdx.Game;
import com.badlogic.gdx.Gdx;
import com.kilobolt.screens.GameScreen;
import com.kilobolt.zbhelpers.AssetLoader;
public class ZBGame extends Game {
@Override
public void create() {
Gdx.app.log("ZBGame", "created");
AssetLoader.load();
setScreen(new GameScreen());
}
@Override
public void dispose() {
super.dispose();
AssetLoader.dispose();
}
}
Теперь, когда все наши картинки загружены, мы можем начать отрисовывать их в GameRenderer!
Давайте откроем его.
Чтобы отрисовать TextureRegion, нам необходимо создать SpriteBatch (так же как мы это проделали с ShapeRenderer). SpriteBatch отрисовывает картинки за нас, используя переданные указатели (обычно это x, y, width и height). Давайте удалим весь не существенный код из GameRenderer и создадим SpriteBatch, как показано ниже.
package com.kilobolt.gameworld;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.graphics.glutils.ShapeRenderer;
public class GameRenderer {
private GameWorld myWorld;
private OrthographicCamera cam;
private ShapeRenderer shapeRenderer;
private SpriteBatch batcher;
public GameRenderer(GameWorld world) {
myWorld = world;
cam = new OrthographicCamera();
cam.setToOrtho(true, 137, 204);
batcher = new SpriteBatch();
// привяжите batcher к камере
batcher.setProjectionMatrix(cam.combined);
shapeRenderer = new ShapeRenderer();
shapeRenderer.setProjectionMatrix(cam.combined);
}
public void render() {
Gdx.gl.glClearColor(0, 0, 0, 1);
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
}
}
Мы должны изменить ширину нашей камеры на 136, а также изменить высоту на высоту игры определенную в GameScreen. Чтобы сделать это, мы изменим наш конструктор, чтобы получать на вход два аргумента gameHeight и midPointY.
Добавьте эти две новые переменные в класс (не удаляйте старые четыре) и измените конструктор следующим обращом (убедитесь, что вы изменяете width и height на 136 и на значение из gameHeight соответственно):
package com.kilobolt.gameworld;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.graphics.glutils.ShapeRenderer;
public class GameRenderer {
private GameWorld myWorld;
private OrthographicCamera cam;
private ShapeRenderer shapeRenderer;
private SpriteBatch batcher;
private int midPointY;
private int gameHeight;
public GameRenderer(GameWorld world, int gameHeight, int midPointY) {
myWorld = world;
// слово this ссылается на экземляр текущего класса
// мы задаем значения параметрам класса
// полченные из GameScreen.
this.gameHeight = gameHeight;
this.midPointY = midPointY;
cam = new OrthographicCamera();
cam.setToOrtho(true, 136, gameHeight);
batcher = new SpriteBatch();
batcher.setProjectionMatrix(cam.combined);
shapeRenderer = new ShapeRenderer();
shapeRenderer.setProjectionMatrix(cam.combined);
}
public void render() {
Gdx.gl.glClearColor(0, 0, 0, 1);
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
}
}
Далее, нам так же необходимо добавить аргумент в метод render:
public void render(float runTime) {
...
}
Этот аргумент необходим, чтобы определить какой фрейм из анимации птички нам следует отобразить. Объект Animation будет использовать это значение (и ранее заданное значение для длины кадра) чтобы определить какую область текстуры показать.
Из-за изменений, внесенных в конструктор, нам необходимо исправить появившеся ошибки в GameScreen.
Откройте класс GameScreen и замените следующую строчку:
renderer = new GameRenderer(world);
на эту:
renderer = new GameRenderer(world, (int) gameHeight, midPointY);
Нам так же необходимо создать дополнительную переменную с именем runTime, в которой будет храниться значение как долго шла игра. Мы передадим это значение в render метод класса GameRenderer!
Создайте переменную в классе с именем runTime и задайте ей стартовое значение 0.
private float runTime = 0;
Внутри метода render(float delta), увеличьте значение runTime на значение из delta и передайте новое значение в метод render (где мы будем использовать полученное значение для отрисовки анимации):
@Override
public void render(float delta) {
runTime += delta;
world.update(delta);
renderer.render(runTime);
}
Ваш класс GameScreen должен был получиться следующим:
package com.kilobolt.screens;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Screen;
import com.kilobolt.gameworld.GameRenderer;
import com.kilobolt.gameworld.GameWorld;
import com.kilobolt.zbhelpers.InputHandler;
public class GameScreen implements Screen {
private GameWorld world;
private GameRenderer renderer;
private float runTime;
// Это контруктор класса, не объявление класса
public GameScreen() {
float screenWidth = Gdx.graphics.getWidth();
float screenHeight = Gdx.graphics.getHeight();
float gameWidth = 136;
float gameHeight = screenHeight / (screenWidth / gameWidth);
int midPointY = (int) (gameHeight / 2);
world = new GameWorld(midPointY);
renderer = new GameRenderer(world, (int) gameHeight, midPointY);
Gdx.input.setInputProcessor(new InputHandler(world.getBird()));
}
@Override
public void render(float delta) {
runTime += delta;
world.update(delta);
renderer.render(runTime);
}
@Override
public void resize(int width, int height) {
}
@Override
public void show() {
Gdx.app.log("GameScreen", "show called");
}
@Override
public void hide() {
Gdx.app.log("GameScreen", "hide called");
}
@Override
public void pause() {
Gdx.app.log("GameScreen", "pause called");
}
@Override
public void resume() {
Gdx.app.log("GameScreen", "resume called");
}
@Override
public void dispose() {
// оставьте пустым
}
}
Я извиняюсь за все эти прыжки по файлам! Мы сфокусируемся на одном методе только чтобы вспомнить День 6. :)
Вернитесь в класс GameRenderer и измените методо render следующим образом:
package com.kilobolt.gameworld;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.graphics.glutils.ShapeRenderer;
import com.badlogic.gdx.graphics.glutils.ShapeRenderer.ShapeType;
import com.kilobolt.gameobjects.Bird;
import com.kilobolt.zbhelpers.AssetLoader;
public class GameRenderer {
private GameWorld myWorld;
private OrthographicCamera cam;
private ShapeRenderer shapeRenderer;
private SpriteBatch batcher;
private int midPointY;
private int gameHeight;
public GameRenderer(GameWorld world, int gameHeight, int midPointY) {
myWorld = world;
// слово this ссылается на экземляр текущего класса
// мы задаем значения параметрам класса
// полченные из GameScreen.
this.gameHeight = gameHeight;
this.midPointY = midPointY;
cam = new OrthographicCamera();
cam.setToOrtho(true, 136, gameHeight);
batcher = new SpriteBatch();
batcher.setProjectionMatrix(cam.combined);
shapeRenderer = new ShapeRenderer();
shapeRenderer.setProjectionMatrix(cam.combined);
}
public void render(float runTime) {
// мы уберем это из цикла далее, для улучшения производительности
Bird bird = myWorld.getBird();
// Заполним задний фон одним цветом
Gdx.gl.glClearColor(0, 0, 0, 1);
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
// Стартуем ShapeRenderer
shapeRenderer.begin(ShapeType.Filled);
// Отрисуем Background цвет
shapeRenderer.setColor(55 / 255.0f, 80 / 255.0f, 100 / 255.0f, 1);
shapeRenderer.rect(0, 0, 136, midPointY + 66);
// Отрисуем Grass
shapeRenderer.setColor(111 / 255.0f, 186 / 255.0f, 45 / 255.0f, 1);
shapeRenderer.rect(0, midPointY + 66, 136, 11);
// Отрисуем Dirt
shapeRenderer.setColor(147 / 255.0f, 80 / 255.0f, 27 / 255.0f, 1);
shapeRenderer.rect(0, midPointY + 77, 136, 52);
// Заканчиваем ShapeRenderer
shapeRenderer.end();
// Стартуем SpriteBatch
batcher.begin();
// Отменим прозрачность
// Это хорошо для производительности, когда отрисовываем картинки без прозрачности
batcher.disableBlending();
batcher.draw(AssetLoader.bg, 0, midPointY + 23, 136, 43);
// Птичке нужна прозрачность, поэтому включаем ее
batcher.enableBlending();
// Отрисуем птичку на ее координатах. Получим Animation объект из AssetLoader
// Передадим runTime переменную чтобы получить текущий кадр.
batcher.draw(AssetLoader.birdAnimation.getKeyFrame(runTime),
bird.getX(), bird.getY(), bird.getWidth(), bird.getHeight());
// Заканчиваем SpriteBatch
batcher.end();
}
}
Попробуйте запустить ваш код (DesktopLauncher.java) и начните кликать (иначе ваша птичка улетит в небытие)! У вас должно было получиться следующее:
Давайте вернемся обратно в наш render метод и посмотрим, что там за логика получилась. Мы всегда первым делом отрисовываем задний фон, потому что отрисовка всегда идет по слоям. Мы начинаем рисовать какие обычные цвета. Мы предпочли отрисовать заполненый одним цветом Shape, чем использовать TextureRegion для заполнения заднего фона.
Мы отрисовываем временный зеленый прямоугольник, чтобы показать где должна быть трава, и коричневый прямоугольник, где у нас будет грязь.
Далее мы стартуем SpriteBatch, опять-таки, начав отрисовывать картинку заднего фона — город. Далее мы получаем текущий TextureRegion из нашего объекта Animation используя runTime и отрисовываем птицу с включенным наложением(blending). Прочитайте комментарии которые я сделал внутри метода render()!
Они важны для вашего понимая.
Ну вот, можно сказать — мы начали! Наш персонаж Flaps опять летает, и игра начинает приобретать форму. Присоединяйтесь ко мне в следующем Дне 7, где мы добавим прокручиваемые элементы нашей игры: траву и трубы.
Исходный код за день
Если вы вне настроения писать код самостоятельно, скачайте его отсюда:
day_6.zip
День 7 — Трава, Птица и Труба с черепом
Добро пожаловать седьмой день! В сегодняшнем уроке мы изучим как вращать нашу птицу и как прокручивать траву и трубы с черепами. Нам предстоит внести изменения в класс Bird и создать новые классы, которые будут содержать логику для травы и труб.
Давайте начнем!
Вращая птицу
Прежде чем приступить к написанию кода, давайте исследуем как может вращаться наша птица. В Flappy Bird птичка имеет два главных состояния. Птица или взлетает после клика или падает. В этих двух состояних, птица вращается следующим образом:
Вращение контролируется используя скорость по Y. В нашей игре, ускоряется книзу благодаря гравитации (это значит, что скорость увеличивается). Когда наша скорость отрисательная (это означает, что наша птица движется кверху), птица начнет вращаться против часовой стрелки. Когда наша скорость больше чем некое положительное значение, птица начнет вращаться по часовой стрелке (мы не начинаем вращать птицу пока она не начнет ускоряться).
Анимации
Мы так же должны обратить внимание на анимацию. Птица не должна махать крыльями пока падает. Вместо этого, ее крылья должны вернуться на середину. Как только птица начнет взлетать, она опять начнет махать крыльями.
Давайте реализуем это в нашем коде:
- Мы начнем с создания двух методов. Мы будем использовать полученный опытным путем значения в этих методах. Создайте эти методу, где-нибудь в классе Bird:
public boolean isFalling() { return velocity.y > 110; } public boolean shouldntFlap() { return velocity.y > 70; }
Мы будем использовать метод isFalling, чтобы определить следует ли повернуть птицу вниз.
А метод shouldntFlap, чтобы определить, когда наша птица должна перестать махать крыльями. - Далее нам необходимо внести не много изменений в метод update. Помните, что у нас есть переменая типа float с именем rotation? Эта переменая будет хранить значение насколько наша птица должна быть повернута. Положительное значение — поворот по часовой стрелке, отрицательно — против часовой стрелки.
Добавьте эти два блока кода в конец метода update. Они позаботятся о часовом и противчасовом вращении (взлет и падение).
// повернуть против часовой стрелки
if (velocity.y < 0) {
rotation -= 600 * delta;
if (rotation < -20) {
rotation = -20;
}
}
// Повернуть по часовой стрелке
if (isFalling()) {
rotation += 480 * delta;
if (rotation > 90) {
rotation = 90;
}
}
Помните, мы увеличиваем наш rotation на delta, так что птица будет вращаться с той же скоростью, даже если игра начнет тормозить (или начнет работать быстрее).
Обе эти проверки имеют своего рода ограничение по вращению. Если мы перестараемся с поворотом, наша игра исправит это для нас.
Вот как должен выглядеть ваш Bird класс:
package com.kilobolt.gameobjects;
import com.badlogic.gdx.math.Vector2;
public class Bird {
private Vector2 position;
private Vector2 velocity;
private Vector2 acceleration;
private float rotation; // For handling bird rotation
private int width;
private int height;
public Bird(float x, float y, int width, int height) {
this.width = width;
this.height = height;
position = new Vector2(x, y);
velocity = new Vector2(0, 0);
acceleration = new Vector2(0, 460);
}
public void update(float delta) {
velocity.add(acceleration.cpy().scl(delta));
if (velocity.y > 200) {
velocity.y = 200;
}
position.add(velocity.cpy().scl(delta));
// Повернуть против часовой стрелки
if (velocity.y < 0) {
rotation -= 600 * delta;
if (rotation < -20) {
rotation = -20;
}
}
// Повернуть по часовой стрелке
if (isFalling()) {
rotation += 480 * delta;
if (rotation > 90) {
rotation = 90;
}
}
}
public boolean isFalling() {
return velocity.y > 110;
}
public boolean shouldntFlap() {
return velocity.y > 70;
}
public void onClick() {
velocity.y = -140;
}
public float getX() {
return position.x;
}
public float getY() {
return position.y;
}
public float getWidth() {
return width;
}
public float getHeight() {
return height;
}
public float getRotation() {
return rotation;
}
}
Великолепно. Когда мы это все добавили, нам остается только перейти в GameRenderer и использовать метод shouldntFlap, чтобы определить надо ли анимировать нашу птицу или нет.
Внимание
Ранее упоминалось в Дне 5 (повторяется тут, потому что это важно!).
Каждый раз, как вы создаете новый Object, вы выделяете не много памяти в RAM для этого объекта (если быть точнее, то в Heap). Как только ваш Heap переполняется, подпрограмма, которая называется Garbage Collector (далее GC, Мусоросборщик/Сборщик), выходит на сцену и чистит вашу память, чтобы избежать ситуации с нехваткой памяти. Это круто, но не, когда вы создаете игру. Во время работы GC, ваша игра начинает притормаживать на несколько значительных милисекунд. Чтобы избежать частой работы GC, вам надо избегать создания новых объектов, по возможности.
Я недавно открыл для себя, что метод Vector2.cpy() создает новый экземляр типа Vector2, вместо того чтобы повторно использовать существующий экземпляр. Это означает, что при 60 FPS, вызывая Vector2.cpy(), вы создадите 60 новых объектов типа Vector2 каждую секунду, что в свою очеред заставить Java GC появляться на сцене очень часто.
Просто держите это в памяти. Мы решим эту проблему не много позже.
Почистим GameRenderer
Чтобы достигнуть высокой производительности в играх, вы должны минимизировать работу которая выполняется в игровом цикле. В шестом дне, мы написали код который нарушает этот принцип. Посмотрите в метод render. У нас там следующая строка:
Bird bird = myWorld.getBird();
Каждый раз как вызывается метод render (около 60 раз в секунду), мы просим нашу игру найти наш myWorld, далее найти объект Bird и вернуть его в GameRenderer и поставить ее в стек как локальную переменную.
Мы изменим этот код таким образом, чтобы получать объект Bird один раз, когда GameRenderer впервые создается и сохранить объект Bird как переменную класса GameRenderer.
Мы собираемся это проделать так же с TextureRegions из AssetLoader и со всеми новыми объектами, которые мы когда-либо создадим.
- Начните с добавления следующих переменных в класс GameRenderer (так же сделайте импорт классов если необходимо, используйте для импорта анимации com.badlogic.gdx.graphics.g2d.Animation):
// Game Objects private Bird bird; // Game Assets private TextureRegion bg, grass; private Animation birdAnimation; private TextureRegion birdMid, birdDown, birdUp; private TextureRegion skullUp, skullDown, bar;
Далее, мы их проинициализируем в конструкторе. Но лучше чем засорят конструктор, давайте создадим два вспомогательных метода, которые проинициализируют эти переменные.
- Добавьте следующие два метода в GameRenderer:
private void initGameObjects() { bird = myWorld.getBird(); } private void initAssets() { bg = AssetLoader.bg; grass = AssetLoader.grass; birdAnimation = AssetLoader.birdAnimation; birdMid = AssetLoader.bird; birdDown = AssetLoader.birdDown; birdUp = AssetLoader.birdUp; skullUp = AssetLoader.skullUp; skullDown = AssetLoader.skullDown; bar = AssetLoader.bar; }
Далее мы вызовем эти методы в конструкторе:
public GameRenderer(GameWorld world, int gameHeight, int midPointY) {
myWorld = world;
this.gameHeight = gameHeight;
this.midPointY = midPointY;
cam = new OrthographicCamera();
cam.setToOrtho(true, 136, gameHeight);
batcher = new SpriteBatch();
batcher.setProjectionMatrix(cam.combined);
shapeRenderer = new ShapeRenderer();
shapeRenderer.setProjectionMatrix(cam.combined);
// Вызовем вспомогательные методы, чтобы проиницилизировать переменные класса
initGameObjects();
initAssets();
}
Наконец, мы должны внести изменения в render метод. А если быть точнее, то мы удалим все упоминания об AssetLoader и удалим строку:
Bird bird = myWorld.getBird().
Далее мы модифицируем методы которые отрисовывают нашу птичку, так что мы сможем использовать rotation. Так же изменим все упоминания о AssetLoader (в частности AssetLoader.bg). Вот как должен выглядеть результат вашей работы:
public void render(float runTime) {
Gdx.gl.glClearColor(0, 0, 0, 1);
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
shapeRenderer.begin(ShapeType.Filled);
// Заливаем задний фон
shapeRenderer.setColor(55 / 255.0f, 80 / 255.0f, 100 / 255.0f, 1);
shapeRenderer.rect(0, 0, 136, midPointY + 66);
// Рисуем Grass
shapeRenderer.setColor(111 / 255.0f, 186 / 255.0f, 45 / 255.0f, 1);
shapeRenderer.rect(0, midPointY + 66, 136, 11);
// Рисуем Dirt
shapeRenderer.setColor(147 / 255.0f, 80 / 255.0f, 27 / 255.0f, 1);
shapeRenderer.rect(0, midPointY + 77, 136, 52);
shapeRenderer.end();
batcher.begin();
batcher.disableBlending();
batcher.draw(bg, 0, midPointY + 23, 136, 43);
batcher.enableBlending();
if (bird.shouldntFlap()) {
batcher.draw(birdMid, bird.getX(), bird.getY(),
bird.getWidth() / 2.0f, bird.getHeight() / 2.0f,
bird.getWidth(), bird.getHeight(), 1, 1, bird.getRotation());
} else {
batcher.draw(birdAnimation.getKeyFrame(runTime), bird.getX(),
bird.getY(), bird.getWidth() / 2.0f,
bird.getHeight() / 2.0f, bird.getWidth(), bird.getHeight(),
1, 1, bird.getRotation());
}
batcher.end();
}
А ваш класс GameRenderer должен выглядеть так:
package com.kilobolt.gameworld;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.graphics.g2d.Animation;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.graphics.g2d.TextureRegion;
import com.badlogic.gdx.graphics.glutils.ShapeRenderer;
import com.badlogic.gdx.graphics.glutils.ShapeRenderer.ShapeType;
import com.kilobolt.gameobjects.Bird;
import com.kilobolt.zbhelpers.AssetLoader;
public class GameRenderer {
private GameWorld myWorld;
private OrthographicCamera cam;
private ShapeRenderer shapeRenderer;
private SpriteBatch batcher;
private int midPointY;
private int gameHeight;
// Объеты игры
private Bird bird;
// Assets игры
private TextureRegion bg, grass;
private Animation birdAnimation;
private TextureRegion birdMid, birdDown, birdUp;
private TextureRegion skullUp, skullDown, bar;
public GameRenderer(GameWorld world, int gameHeight, int midPointY) {
myWorld = world;
this.gameHeight = gameHeight;
this.midPointY = midPointY;
cam = new OrthographicCamera();
cam.setToOrtho(true, 136, gameHeight);
batcher = new SpriteBatch();
batcher.setProjectionMatrix(cam.combined);
shapeRenderer = new ShapeRenderer();
shapeRenderer.setProjectionMatrix(cam.combined);
// Вызовем вспомогательные методы, чтобы проиницилизировать переменные класса
initGameObjects();
initAssets();
}
public void render(float runTime) {
Gdx.gl.glClearColor(0, 0, 0, 1);
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
shapeRenderer.begin(ShapeType.Filled);
// Заливаем задний фон
shapeRenderer.setColor(55 / 255.0f, 80 / 255.0f, 100 / 255.0f, 1);
shapeRenderer.rect(0, 0, 136, midPointY + 66);
// Рисуем Grass
shapeRenderer.setColor(111 / 255.0f, 186 / 255.0f, 45 / 255.0f, 1);
shapeRenderer.rect(0, midPointY + 66, 136, 11);
// Рисуем Dirt
shapeRenderer.setColor(147 / 255.0f, 80 / 255.0f, 27 / 255.0f, 1);
shapeRenderer.rect(0, midPointY + 77, 136, 52);
shapeRenderer.end();
batcher.begin();
batcher.disableBlending();
batcher.draw(bg, 0, midPointY + 23, 136, 43);
batcher.enableBlending();
if (bird.shouldntFlap()) {
batcher.draw(birdMid, bird.getX(), bird.getY(),
bird.getWidth() / 2.0f, bird.getHeight() / 2.0f,
bird.getWidth(), bird.getHeight(), 1, 1, bird.getRotation());
} else {
batcher.draw(birdAnimation.getKeyFrame(runTime), bird.getX(),
bird.getY(), bird.getWidth() / 2.0f,
bird.getHeight() / 2.0f, bird.getWidth(), bird.getHeight(),
1, 1, bird.getRotation());
}
batcher.end();
}
private void initGameObjects() {
bird = myWorld.getBird();
}
private void initAssets() {
bg = AssetLoader.bg;
grass = AssetLoader.grass;
birdAnimation = AssetLoader.birdAnimation;
birdMid = AssetLoader.bird;
birdDown = AssetLoader.birdDown;
birdUp = AssetLoader.birdUp;
skullUp = AssetLoader.skullUp;
skullDown = AssetLoader.skullDown;
bar = AssetLoader.bar;
}
}
Попробуйте запустить ваш код! Ваша птица будет махать крыльями и крутиться, как мы и предполагали.
Создаем класс Scrollable
Сейчас мы займемся созданием Травы и Труб в нашей игре:
Трава и трубы имеют одинаковую скорость передвижения справа на лево. Мы будет работать со следующими параметрами:
- У нас будет 3 трубы (каждая колона – это одна труба)
- Мы будем использовать две длинные полоски травы, соединенные друг с другом горизонтально (в длину)
- Когда труба или трава оказываются полностью не видимыми – мы сбрасываем их положение
- Чтобы сбросить положение травы, мы просто переместим ее вправо и присоединим к концу второй полосы с травой
- Чтобы сбросить положение трубы, мы поместим ее в конец очереди труб, сразу после третьей трубы, а также мы изменим высоту трубы
Заметьте, что трубы и трава имеют одинаковое поведение. Мы выделим одинаковую логику в отдельный класс Scrollable и будем его наследовать дочерними классами, такими как Pipe и Grass.
Часть, где мы сбрасываем параметры объектов может показаться не много запутанной.
Вот пример логики, если у нас 3 трубы:
- Труба 1, при сбросе положения, должна вставть сразу после Трубы 3
- Труба 2, при сбросе положения, должна вставть сразу после Трубы 1
- Труба 3, при сбросе положения, должна вставть сразу после Трубы 2
Вместо того чтобы свзяывать объекты между собой, и четко знать какой объект встанет за каким объектом, мы создадим объект ScrollHandler который облегчит перемещение объектов.
Мы начнем с создания класса Scrollable (внутри пакета com.kilobolt.gameobjects) который будет использовать доступные параметры объектов Pipe и Grass.
Pipe и Grass будут иметь следующие параметры:
Параметры класса
position, velocity, width, height и isScrolledLeft типа boolean, чтобы определить когда объект типа Scrollable более не виден и его параметры нужно сбросить.
Методы:
update и reset и методы доступа к различным переменым класса.
Полный пример кода (он очень прост и прямолинеен). В нем нет ничего нового, чего мы не видели ранее, за исключением метода reset. Пожалуйста, прочтите комментарии:
package com.kilobolt.gameobjects;
import com.badlogic.gdx.math.Vector2;
public class Scrollable {
// Protected похож private, но позволяет наследоваться в дочерних классах.
protected Vector2 position;
protected Vector2 velocity;
protected int width;
protected int height;
protected boolean isScrolledLeft;
public Scrollable(float x, float y, int width, int height, float scrollSpeed) {
position = new Vector2(x, y);
velocity = new Vector2(scrollSpeed, 0);
this.width = width;
this.height = height;
isScrolledLeft = false;
}
public void update(float delta) {
position.add(velocity.cpy().scl(delta));
// Если объект Scrollable более не виден:
if (position.x + width < 0) {
isScrolledLeft = true;
}
}
// Reset: Нужно переопределять в дочернем классе, если необходимо описать
// другое поведение
public void reset(float newX) {
position.x = newX;
isScrolledLeft = false;
}
// Методы доступа к переменым класса
public boolean isScrolledLeft() {
return isScrolledLeft;
}
public float getTailX() {
return position.x + width;
}
public float getX() {
return position.x;
}
public float getY() {
return position.y;
}
public int getWidth() {
return width;
}
public int getHeight() {
return height;
}
}
Теперь, как у нас есть базовый класс Scrollable, мы можем создать дочерние классы, чтобы наследовать его. Создайте следующие два класса, так же в пакете com.kilobolt.gameobjects:
Pipe
package com.kilobolt.gameobjects;
import java.util.Random;
public class Pipe extends Scrollable {
private Random r;
// Когда констуктор Pipe вызван – вызовите конструтор родителя (Scrollable)
public Pipe(float x, float y, int width, int height, float scrollSpeed) {
super(x, y, width, height, scrollSpeed);
// Иницилизируйте объект типа Random для генерации случайных чисел
r = new Random();
}
@Override
public void reset(float newX) {
// вызовите reset метод в родительском классе (Scrollable)
super.reset(newX);
// Измените высоту на случайное значение
height = r.nextInt(90) + 15;
}
}
Grass
package com.kilobolt.gameobjects;
public class Grass extends Scrollable {
// Когда констуктор Grass вызван – вызовите конструтор родителя (Scrollable)
public Grass(float x, float y, int width, int height, float scrollSpeed) {
super(x, y, width, height, scrollSpeed);
}
}
Оба приведеных выше класса, будут использовать в качестве родителя класс Scrollable (благодаря наследованию), они так же добавят свои параметры и методы (в случае с Pipe – новый аргумент в конструкторе и новый метод).
Что такое Override и super?
В наследовании, дочерние классы имеют доступ к методам родительского класса. Это значит, что Pipe и Grass смогут использовать метод reset даже не переопределяя его у себя. Это потому, что они, дочерние классы, являются свеого рода расширителями функционала для класса Scrollable, который уже имеет метод reset.
Например, мы можем сделать, что-то в этом духе:
Grass g = new Grass(...);
g.reset(); // метод reset вызыван из Scrollable.
Если нам понадобится более специфическая логика для какого-то метода из родительского класса, мы можем использовать Override, который говорит компилятору следующее: используй этот метод reset из дочернего класса, вместо метода reset в родительском классе. Мы переопределяем метод reset в дочернем классе Pipe.
Так что когда мы делаем это:
Pipe p = new Pipe(...);
p.reset(); // метод reset вызван из класса Pipe.
А что же такое слово super?
Даже во время переопределения, дочерний класс имеет доступ к оригинальному методу в родительском классе. Вызывая super.reset(...) внутри переопределенного метода reset, означает, что оба метода, переопределеный и родительский, будут вызываны.
Зачем нам класс Grass?
На данный момент, класс Grass – бессмысленный, потому что в нем нет собственных параметром. Но позже мы добавим параметры для определения колизии и вот из-за этого мы создали отдельный класс для травы.
Теперь, когда наши Scrollable классы готовы, мы можем реализовать логику для ScrollHandler, который возьмет на себя создание Grass и Pipe объектов, обновление их, и обработку сброса параметров.
Создайте новый класс ScrollHandler внутри пакета com.kilobolt.gameobjects.
Мы начнем с легких вещей:
- Нам надо передать в конструктор значение координаты по оси Y, чтобы знать, где создать нашу землю (где будут трава и нижние трубы)
- Нам так же нужны 5 переменных в классе: 2 для объектов типа Grass и 3 для объектов типа Pipe (на данный момент, мы будем полагать, что одна колона это один объект Pipe)
- Нам нужмы методы доступа ко всем этим объектам
- А так же нам нужен метод update
package com.kilobolt.gameobjects;
public class ScrollHandler {
// ScrollHandler создаст все необходимые нам объекты
private Grass frontGrass, backGrass;
private Pipe pipe1, pipe2, pipe3;
// ScrollHandler будет использовать следующие константы
// чтобы определить, как быстро на перемещать объекты
// и какой промежуток между трубами
// заглавные буквы используются по договоренности об именовании переменных
public static final int SCROLL_SPEED = -59;
public static final int PIPE_GAP = 49;
// конструктор получает значение по Y оси, где нам необходимо создать наши
// Grass и Pipe объекты.
public ScrollHandler(float yPos) {
}
public void update(float delta) {
}
// методы доступа к переменным класса
public Grass getFrontGrass() {
return frontGrass;
}
public Grass getBackGrass() {
return backGrass;
}
public Pipe getPipe1() {
return pipe1;
}
public Pipe getPipe2() {
return pipe2;
}
public Pipe getPipe3() {
return pipe3;
}
}
Теперь нам необходимо сосредоточиться на конструкторе и методе update. Внутри конструктора мы инициализируем все Scrollable объекты:
frontGrass = new Grass(0, yPos, 143, 11, SCROLL_SPEED);
backGrass = new Grass(frontGrass.getTailX(), yPos, 143, 11, SCROLL_SPEED);
pipe1 = new Pipe(210, 0, 22, 60, SCROLL_SPEED);
pipe2 = new Pipe(pipe1.getTailX() + PIPE_GAP, 0, 22, 70, SCROLL_SPEED);
pipe3 = new Pipe(pipe2.getTailX() + PIPE_GAP, 0, 22, 60, SCROLL_SPEED);
Логика тут проста. Помните, что конструктор объектов типа Scrollable просит передать в него: x, y, width, height и скорость прокрутки. Мы передаем каждый из этих параметров.
backGrass объект должен состыковываться с хвостом frontGrass объекта, так что мы создаем его в хвосте frontGrass.
Объекты типа Pipe создаются по аналогичной схеме, за исключением, что мы добавляем PIPE_GAP чтобы создать промежуток между трубами в 49 пиксилей (вычислено экспериментальным путем).
Сейчас мы закончим наш метод update, в котором мы вызываем метод update для всех пяти объектов. В дополнение, мы используем простую логику, для одного из наших конструкторов, чтобы сбросить параметры наших объектов. Код должен получиться следующим:
package com.kilobolt.gameobjects;
public class ScrollHandler {
private Grass frontGrass, backGrass;
private Pipe pipe1, pipe2, pipe3;
public static final int SCROLL_SPEED = -59;
public static final int PIPE_GAP = 49;
public ScrollHandler(float yPos) {
frontGrass = new Grass(0, yPos, 143, 11, SCROLL_SPEED);
backGrass = new Grass(frontGrass.getTailX(), yPos, 143, 11,
SCROLL_SPEED);
pipe1 = new Pipe(210, 0, 22, 60, SCROLL_SPEED);
pipe2 = new Pipe(pipe1.getTailX() + PIPE_GAP, 0, 22, 70, SCROLL_SPEED);
pipe3 = new Pipe(pipe2.getTailX() + PIPE_GAP, 0, 22, 60, SCROLL_SPEED);
}
public void update(float delta) {
// обновляем все объекты
frontGrass.update(delta);
backGrass.update(delta);
pipe1.update(delta);
pipe2.update(delta);
pipe3.update(delta);
// проверяем кто из объектов за левой границей
// и соответственно сбрасываем параметры этого объекта
if (pipe1.isScrolledLeft()) {
pipe1.reset(pipe3.getTailX() + PIPE_GAP);
} else if (pipe2.isScrolledLeft()) {
pipe2.reset(pipe1.getTailX() + PIPE_GAP);
} else if (pipe3.isScrolledLeft()) {
pipe3.reset(pipe2.getTailX() + PIPE_GAP);
}
// то-же самое с травой
if (frontGrass.isScrolledLeft()) {
frontGrass.reset(backGrass.getTailX());
} else if (backGrass.isScrolledLeft()) {
backGrass.reset(frontGrass.getTailX());
}
}
public Grass getFrontGrass() {
return frontGrass;
}
public Grass getBackGrass() {
return backGrass;
}
public Pipe getPipe1() {
return pipe1;
}
public Pipe getPipe2() {
return pipe2;
}
public Pipe getPipe3() {
return pipe3;
}
}
Наши объекты типа Scrollable полностью настроены! Наши следующие шаги будут:
- Создать объект ScrollHandler внутри GameWorld (это действие автоматически создаст наши 5 объектов)
- Отрисовать ScrollHandler объекты внутри GameRenderer
1. Создание объекта ScrollHandler
Откройте GameWorld. Мы внесем мелкие изменения.
- Добавьте переменную в класс (так же добавьте импорт)
private ScrollHandler scroller
- Инициализируйте прокрутку в конструторе (включая Y координату, где трава должна начинаться, 66 пиксилей ниже midPointY):
scroller = new ScrollHandler(midPointY + 66);
- Вызовите update метод нашего Scroller внутри update метода GameWorld
scroller.update(delta);
- Создайте метод доступа к объекту Scroller (возвращайте ScrollHandler scroll)
Исходный код для этого этапа следующий:
package com.kilobolt.gameworld;
import com.kilobolt.gameobjects.Bird;
import com.kilobolt.gameobjects.ScrollHandler;
public class GameWorld {
private Bird bird;
private ScrollHandler scroller;
public GameWorld(int midPointY) {
bird = new Bird(33, midPointY - 5, 17, 12);
// трава должна начинаться на 66 пикселей ниже midPointY
scroller = new ScrollHandler(midPointY + 66);
}
public void update(float delta) {
bird.update(delta);
scroller.update(delta);
}
public Bird getBird() {
return bird;
}
public ScrollHandler getScroller() {
return scroller;
}
}
2. Отрисовка объектов ScrollHandler’a
Мы начнем с создания 6-ти переменных в классе:
1 для ScrollHandler
5 для 3-х труб + 2 для травы
- Добавьте следующее сразу после объявления Bird объекта:
// Game Objects private Bird bird; private ScrollHandler scroller; private Grass frontGrass, backGrass; private Pipe pipe1, pipe2, pipe3;
- Добавьте следующие импорты:
import com.kilobolt.gameobjects.Bird; import com.kilobolt.gameobjects.Grass; import com.kilobolt.gameobjects.Pipe;
- Теперь нам надо инициализировать эти переменные внутри метода initGameObjects:
private void initGameObjects() { bird = myWorld.getBird(); scroller = myWorld.getScroller(); frontGrass = scroller.getFrontGrass(); backGrass = scroller.getBackGrass(); pipe1 = scroller.getPipe1(); pipe2 = scroller.getPipe2(); pipe3 = scroller.getPipe3(); }
Далее мы просто должны отрисовать эти объекты в методе render. Так как объект Pipe еще не готов, мы добавим пустой метод отрисовки, и заменим его позже. Давайте создадим вспомогательные методы, чтобы наш код был более ни менее читабельным:
Значения переменных widths/heights/etc, которые вы видите, были получены аккуратным вычислением. Я подумал, вы предпочтете готовые зачения, чем сами будете высчитывать их!
private void drawGrass() {
// отрисуем траву
batcher.draw(grass, frontGrass.getX(), frontGrass.getY(),
frontGrass.getWidth(), frontGrass.getHeight());
batcher.draw(grass, backGrass.getX(), backGrass.getY(),
backGrass.getWidth(), backGrass.getHeight());
}
private void drawSkulls() {
// Временный код, извините за кашу :)
// Мы это починим, как только закончим с Pipe классом.
batcher.draw(skullUp, pipe1.getX() - 1,
pipe1.getY() + pipe1.getHeight() - 14, 24, 14);
batcher.draw(skullDown, pipe1.getX() - 1,
pipe1.getY() + pipe1.getHeight() + 45, 24, 14);
batcher.draw(skullUp, pipe2.getX() - 1,
pipe2.getY() + pipe2.getHeight() - 14, 24, 14);
batcher.draw(skullDown, pipe2.getX() - 1,
pipe2.getY() + pipe2.getHeight() + 45, 24, 14);
batcher.draw(skullUp, pipe3.getX() - 1,
pipe3.getY() + pipe3.getHeight() - 14, 24, 14);
batcher.draw(skullDown, pipe3.getX() - 1,
pipe3.getY() + pipe3.getHeight() + 45, 24, 14);
}
private void drawPipes() {
// Временный код, извините за кашу :)
// Мы это починим, как только закончим с Pipe классом.
batcher.draw(bar, pipe1.getX(), pipe1.getY(), pipe1.getWidth(),
pipe1.getHeight());
batcher.draw(bar, pipe1.getX(), pipe1.getY() + pipe1.getHeight() + 45,
pipe1.getWidth(), midPointY + 66 - (pipe1.getHeight() + 45));
batcher.draw(bar, pipe2.getX(), pipe2.getY(), pipe2.getWidth(),
pipe2.getHeight());
batcher.draw(bar, pipe2.getX(), pipe2.getY() + pipe2.getHeight() + 45,
pipe2.getWidth(), midPointY + 66 - (pipe2.getHeight() + 45));
batcher.draw(bar, pipe3.getX(), pipe3.getY(), pipe3.getWidth(),
pipe3.getHeight());
batcher.draw(bar, pipe3.getX(), pipe3.getY() + pipe3.getHeight() + 45,
pipe3.getWidth(), midPointY + 66 - (pipe3.getHeight() + 45));
}
Теперь осталось вызвать эти методы в правильном порядке в методе render. Я добавил метки ко всем изменениям (1… 2… 3…). Пример всего кода:
package com.kilobolt.gameworld;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.graphics.g2d.Animation;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.graphics.g2d.TextureRegion;
import com.badlogic.gdx.graphics.glutils.ShapeRenderer;
import com.badlogic.gdx.graphics.glutils.ShapeRenderer.ShapeType;
import com.kilobolt.gameobjects.Bird;
import com.kilobolt.gameobjects.Grass;
import com.kilobolt.gameobjects.Pipe;
import com.kilobolt.gameobjects.ScrollHandler;
import com.kilobolt.zbhelpers.AssetLoader;
public class GameRenderer {
private GameWorld myWorld;
private OrthographicCamera cam;
private ShapeRenderer shapeRenderer;
private SpriteBatch batcher;
private int midPointY;
private int gameHeight;
// Game Objects
private Bird bird;
private ScrollHandler scroller;
private Grass frontGrass, backGrass;
private Pipe pipe1, pipe2, pipe3;
// Game Assets
private TextureRegion bg, grass;
private Animation birdAnimation;
private TextureRegion birdMid, birdDown, birdUp;
private TextureRegion skullUp, skullDown, bar;
public GameRenderer(GameWorld world, int gameHeight, int midPointY) {
myWorld = world;
this.gameHeight = gameHeight;
this.midPointY = midPointY;
cam = new OrthographicCamera();
cam.setToOrtho(true, 136, gameHeight);
batcher = new SpriteBatch();
batcher.setProjectionMatrix(cam.combined);
shapeRenderer = new ShapeRenderer();
shapeRenderer.setProjectionMatrix(cam.combined);
// Вызываем вспомогательные методы для инициализации объектов
initGameObjects();
initAssets();
}
private void initGameObjects() {
bird = myWorld.getBird();
scroller = myWorld.getScroller();
frontGrass = scroller.getFrontGrass();
backGrass = scroller.getBackGrass();
pipe1 = scroller.getPipe1();
pipe2 = scroller.getPipe2();
pipe3 = scroller.getPipe3();
}
private void initAssets() {
bg = AssetLoader.bg;
grass = AssetLoader.grass;
birdAnimation = AssetLoader.birdAnimation;
birdMid = AssetLoader.bird;
birdDown = AssetLoader.birdDown;
birdUp = AssetLoader.birdUp;
skullUp = AssetLoader.skullUp;
skullDown = AssetLoader.skullDown;
bar = AssetLoader.bar;
}
private void drawGrass() {
// Отрисовываем траву
batcher.draw(grass, frontGrass.getX(), frontGrass.getY(),
frontGrass.getWidth(), frontGrass.getHeight());
batcher.draw(grass, backGrass.getX(), backGrass.getY(),
backGrass.getWidth(), backGrass.getHeight());
}
private void drawSkulls() {
// Временный код, извините за кашу :)
// Мы это починим, как только закончим с Pipe классом.
batcher.draw(skullUp, pipe1.getX() - 1,
pipe1.getY() + pipe1.getHeight() - 14, 24, 14);
batcher.draw(skullDown, pipe1.getX() - 1,
pipe1.getY() + pipe1.getHeight() + 45, 24, 14);
batcher.draw(skullUp, pipe2.getX() - 1,
pipe2.getY() + pipe2.getHeight() - 14, 24, 14);
batcher.draw(skullDown, pipe2.getX() - 1,
pipe2.getY() + pipe2.getHeight() + 45, 24, 14);
batcher.draw(skullUp, pipe3.getX() - 1,
pipe3.getY() + pipe3.getHeight() - 14, 24, 14);
batcher.draw(skullDown, pipe3.getX() - 1,
pipe3.getY() + pipe3.getHeight() + 45, 24, 14);
}
private void drawPipes() {
// Временный код, извините за кашу :)
// Мы это починим, как только закончим с Pipe классом.
batcher.draw(bar, pipe1.getX(), pipe1.getY(), pipe1.getWidth(),
pipe1.getHeight());
batcher.draw(bar, pipe1.getX(), pipe1.getY() + pipe1.getHeight() + 45,
pipe1.getWidth(), midPointY + 66 - (pipe1.getHeight() + 45));
batcher.draw(bar, pipe2.getX(), pipe2.getY(), pipe2.getWidth(),
pipe2.getHeight());
batcher.draw(bar, pipe2.getX(), pipe2.getY() + pipe2.getHeight() + 45,
pipe2.getWidth(), midPointY + 66 - (pipe2.getHeight() + 45));
batcher.draw(bar, pipe3.getX(), pipe3.getY(), pipe3.getWidth(),
pipe3.getHeight());
batcher.draw(bar, pipe3.getX(), pipe3.getY() + pipe3.getHeight() + 45,
pipe3.getWidth(), midPointY + 66 - (pipe3.getHeight() + 45));
}
public void render(float runTime) {
Gdx.gl.glClearColor(0, 0, 0, 1);
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
shapeRenderer.begin(ShapeType.Filled);
// Отрисовываем задний фон
shapeRenderer.setColor(55 / 255.0f, 80 / 255.0f, 100 / 255.0f, 1);
shapeRenderer.rect(0, 0, 136, midPointY + 66);
// Отрисовываем Grass
shapeRenderer.setColor(111 / 255.0f, 186 / 255.0f, 45 / 255.0f, 1);
shapeRenderer.rect(0, midPointY + 66, 136, 11);
// Отрисовываем Dirt
shapeRenderer.setColor(147 / 255.0f, 80 / 255.0f, 27 / 255.0f, 1);
shapeRenderer.rect(0, midPointY + 77, 136, 52);
shapeRenderer.end();
batcher.begin();
batcher.disableBlending();
batcher.draw(bg, 0, midPointY + 23, 136, 43);
// 1. Отрисовываем Grass
drawGrass();
// 2. Отрисовываем Pipes
drawPipes();
batcher.enableBlending();
// 3. Отрисовываем Skulls (необходима прозрачность)
drawSkulls();
if (bird.shouldntFlap()) {
batcher.draw(birdMid, bird.getX(), bird.getY(),
bird.getWidth() / 2.0f, bird.getHeight() / 2.0f,
bird.getWidth(), bird.getHeight(), 1, 1, bird.getRotation());
} else {
batcher.draw(birdAnimation.getKeyFrame(runTime), bird.getX(),
bird.getY(), bird.getWidth() / 2.0f,
bird.getHeight() / 2.0f, bird.getWidth(), bird.getHeight(),
1, 1, bird.getRotation());
}
batcher.end();
}
}
Мы многое сделали в течении седьмого дня! Давайте посмотрим, как выглядит теперь наша игра:
Спасибо, что потратили время на чтение и мои поздравления! У нас теперь есть все, что нужно для отрисовки. Пришло время поработать над колизией. Присоединяйтесь ко мне в следующем дне!
Исходный код за день
Если вы вне настроения писать код самостоятельно, скачайте его отсюда:
day_7.zip
День 8 — Обнаружение коллизий и звуковые эффекты
С возвращением в ваш libGDX урок по созданию клона игры Flappy Bird! Сегодня мы собираемся добавить логику для определения колизий, впоследствии мы будем использовать ее для определения, когда наша птичка должна умирать.
В Flappy Bird птичка может умереть двумя способами: Птичка ударилась о землю или столкнулась с одной из труб.
Мы реализуем второй тип смерти в сегодняшнем уроке, а также мы добавим звуковой эффект, который будет проигрываться во время столкновения! Давайте начнем. Я начну с того на чем мы остановились в седьмом дне. Если вы хотите продолжить с сегодняшнего дня, скачайте исходный код из седьмого дня и возвращайтесь.
Избавимся от ряда ошибок
Если вы использовали мой исходный код, у вас 100% должны быть ошибки :). Я случайно продублировал один и тот же if… then, так же нам нужно будет избавиться от лишнего.
Откройте класс Bird, пролистайте вниз к методу update и удалите одно из следующих повторений (если вы сами писали свой код, у вас этот блок кода отсутствует):
if (velocity.y > 200) {
velocity.y = 200;
}
if (velocity.y > 200) {
velocity.y = 200;
}
Обсудим колизию
Изначально я обдумывал использовать вращающийся полигон, чтобы определять колизию, но после экспериментов, я понял, что создать круг намного проще и это будет более эффективно. Так что мы реализуем самое простое решение.
Идея в том, чтобы круг всегда был отцентрирован в одной и той же точке. Ему не нужно вращаться. Круг всегда будет покрывать область головы птички. Потому что птичка всегда летит вперед, нам нет нужды задумывать о колизии спины птицы с трубами. Так что мы не будем заморачиваться с проверками колизий в этой области.
Объект Pipe, в нашей игре, создержит обе трубы: верхнюю и нижнюю. Каждая из этих труб будет создаа используя прямоугольники. Один прямоугольник покроет череп, другие покроют основную часть трубы.
Во время проверки колизии, мы будем использовать встроенный класс Intersector, у которого есть метод для проверки колизий между прямоугольником и кругом. Как только мы наткнемся на колизию, мы уведомим нашу игру о данном событии и скажем всем нашим движущимся объектам остановиться.
Класс Bird – технический круг
Мы начнем с создания рамки в виде круга для нашей птицы. Откройте класс Bird:
- Добавьте переменную в класс типа Circle и добавьте импорт класса import com.badlogic.gdx.math.Circle; и назоваите ее boundingCircle:
private Circle boundingCircle;
- Инициализируйте ее в конструкторе класса:
boundingCircle = new Circle();
Мы должны изменять координы круга каждый раз как наша птичка перемещается. Птица перемещается когда мы добавляем velocity к нашей position, так что добавьте сразу после этой строчки position.add(velocity.cpy().scl(delta)) следующее:
boundingCircle.set(position.x + 9, position.y + 6, 6.5f);
- Добавьте метод доступа к нашей новой переменной boundingCircle.
Вот как теперь должен выглядеть ваш класс Bird:
package com.kilobolt.gameobjects;
import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.Vector2;
public class Bird {
private Vector2 position;
private Vector2 velocity;
private Vector2 acceleration;
private float rotation;
private int width;
private int height;
private Circle boundingCircle;
public Bird(float x, float y, int width, int height) {
this.width = width;
this.height = height;
position = new Vector2(x, y);
velocity = new Vector2(0, 0);
acceleration = new Vector2(0, 460);
boundingCircle = new Circle();
}
public void update(float delta) {
velocity.add(acceleration.cpy().scl(delta));
if (velocity.y > 200) {
velocity.y = 200;
}
position.add(velocity.cpy().scl(delta));
// Выставите координаты круга следующими (9, 6) относительно птицы.
// Установите радиус круга равным 6.5f;
boundingCircle.set(position.x + 9, position.y + 6, 6.5f);
// Повернуть против часовой стрелки
if (velocity.y < 0) {
rotation -= 600 * delta;
if (rotation < -20) {
rotation = -20;
}
}
// Повернуть по часовой стрелке
if (isFalling()) {
rotation += 480 * delta;
if (rotation > 90) {
rotation = 90;
}
}
}
public boolean isFalling() {
return velocity.y > 110;
}
public boolean shouldntFlap() {
return velocity.y > 70;
}
public void onClick() {
velocity.y = -140;
}
public float getX() {
return position.x;
}
public float getY() {
return position.y;
}
public float getWidth() {
return width;
}
public float getHeight() {
return height;
}
public float getRotation() {
return rotation;
}
public Circle getBoundingCircle() {
return boundingCircle;
}
}
Далее мы убедимся, что наш Circle правильно спозиционирован. Откройте класс GameRenderer и добавьте следующий код в самый низ метода render. Мы собираемся отрисовать наш объект boundingCircle:
shapeRenderer.begin(ShapeType.Filled);
shapeRenderer.setColor(Color.RED);
shapeRenderer.circle(bird.getBoundingCircle().x, bird.getBoundingCircle().y, bird.getBoundingCircle().radius);
shapeRenderer.end();
Запустите игру, она должна выглядеть следующим образом:
Класс Pipe – технические прямоугольники
Теперь, когда у нас есть круг для птицы, нам необходимо создать объекты типа Rectangle чтобы отрисовать наши объекты типа Pipe как показано ниже. Откройте класс Pipe.
Подходящая Математика
Далее по ходу пъесы будет использован ряд визуально похожих значений ширины и высоты в пиксилях, так что используйте диаграмму ниже. А так же прочтите внимательно оставленные мною комментарии в коде, так что вы сможете ухватить мою мысль.
Мы собираемся реализовать все, что нарисовано на диаграмме сверху.
- Создайте следующие переменные в классе и добавьте импорт (import com.badlogic.gdx.math.Rectangle):
private Rectangle skullUp, skullDown, barUp, barDown;
- Так же нам нужна константа которая будет хранить размер промежутка между трубами в 45 пикселей и некоторые другие константы:
public static final int VERTICAL_GAP = 45; public static final int SKULL_WIDTH = 24; public static final int SKULL_HEIGHT = 11;
- Нам так же необходимо знать, где начинается земля, так что мы сможем использовать размер по высоте нижней полосы (как показано на диаграме выше). Создайте еще одну переменную в классе:
private float groundY;
- Добавьте следующие изменения в конструктор, так мы сможем проинициализировать переменные skullUp, skullDown, barUp, barDown и groundY:
public Pipe(float x, float y, int width, int height, float scrollSpeed, float groundY) { super(x, y, width, height, scrollSpeed); //инициализируйте объект типа Random для генерации случайных чисел r = new Random(); skullUp = new Rectangle(); skullDown = new Rectangle(); barUp = new Rectangle(); barDown = new Rectangle(); this.groundY = groundY; }
- Добавьте методы доступа к новым переменным, мы будем использовать их, чтобы проверять колизию:
public Rectangle getSkullUp() { return skullUp; } public Rectangle getSkullDown() { return skullDown; } public Rectangle getBarUp() { return barUp; } public Rectangle getBarDown() { return barDown; }
Как и в случае boundingCircle для нашей птицы, нам необходимо обновлять все четыре прямоугольника, когда позиция Pipe изменяется. В нашем классе Pipe отсутствует метод update. Но этот класс наследует метод update из класса Scrollable. Мы могли бы попытаться обновить наши прямоугольники в классе Scrollable, но легче это сделать с помощью Override метода update (просмотрите седьмой день на эту тему если запамятовали).
Вызывая super, мы вызываем исходный методо update который принадлежит классу Scrollable. Любой код который идет после вызова super – это дополнительная функциональность. В таком случае, мы легко обновим наши четыре прямоугольника.
С помощью Math и вычислений, описанных на картинке выше, мы внесем следующие изменения в метод update (вот как должен выглядеть сейчас класс Pipe):
package com.kilobolt.gameobjects;
import java.util.Random;
import com.badlogic.gdx.math.Rectangle;
public class Pipe extends Scrollable {
private Random r;
private Rectangle skullUp, skullDown, barUp, barDown;
public static final int VERTICAL_GAP = 45;
public static final int SKULL_WIDTH = 24;
public static final int SKULL_HEIGHT = 11;
private float groundY;
// Когда конструктор класса Pipe вызван – вызовите конструктор super (Scrollable)
public Pipe(float x, float y, int width, int height, float scrollSpeed,
float groundY) {
super(x, y, width, height, scrollSpeed);
// Создайте обхект типа Random для генерации случайных чисел
r = new Random();
skullUp = new Rectangle();
skullDown = new Rectangle();
barUp = new Rectangle();
barDown = new Rectangle();
this.groundY = groundY;
}
@Override
public void update(float delta) {
// вызываем update метод в родительском классе (Scrollable)
super.update(delta);
// Метод set() позволяет выставить координаты верзнего лего угла - x, y
// вместе с width и height прямоугольника
barUp.set(position.x, position.y, width, height);
barDown.set(position.x, position.y + height + VERTICAL_GAP, width,
groundY - (position.y + height + VERTICAL_GAP));
// Ширина черепа 24 пикселя. Ширина трубы всего 22 пикселя. Так что череп
// должен быть смещен на 1 пиксель влево (так что череп будет отцентрирован
// относительно трубы).
// Смещение равнозначно: (SKULL_WIDTH - width) / 2
skullUp.set(position.x - (SKULL_WIDTH - width) / 2, position.y + height
- SKULL_HEIGHT, SKULL_WIDTH, SKULL_HEIGHT);
skullDown.set(position.x - (SKULL_WIDTH - width) / 2, barDown.y,
SKULL_WIDTH, SKULL_HEIGHT);
}
@Override
public void reset(float newX) {
// вызовите метод reset в родительском классе (Scrollable)
super.reset(newX);
// измените высоты на случайное значение
height = r.nextInt(90) + 15;
}
public Rectangle getSkullUp() {
return skullUp;
}
public Rectangle getSkullDown() {
return skullDown;
}
public Rectangle getBarUp() {
return barUp;
}
public Rectangle getBarDown() {
return barDown;
}
}
Обновление класса ScrollHandler
Мы изменили конструктор нашего класса Pipe, теперь, когда мы создаем новый объект типа Pipe, необходимо добавить еще один аргумент на вход. Внесите в следующие строки:
pipe1 = new Pipe(210, 0, 22, 60, SCROLL_SPEED);
pipe2 = new Pipe(pipe1.getTailX() + PIPE_GAP, 0, 22, 70, SCROLL_SPEED);
pipe3 = new Pipe(pipe2.getTailX() + PIPE_GAP, 0, 22, 60, SCROLL_SPEED);
Эти изменения:
pipe1 = new Pipe(210, 0, 22, 60, SCROLL_SPEED, yPos);
pipe2 = new Pipe(pipe1.getTailX() + PIPE_GAP, 0, 22, 70, SCROLL_SPEED, yPos);
pipe3 = new Pipe(pipe2.getTailX() + PIPE_GAP, 0, 22, 60, SCROLL_SPEED, yPos);
Давайте вернемся к GameRenderer и добавим пару строк кода в конец метода render. Это временный код для теста. Просто скопируйте его и вставьте как показано ниже.
public void render(float runTime) {
Gdx.gl.glClearColor(0, 0, 0, 1);
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
shapeRenderer.begin(ShapeType.Filled);
// отрисовка заднего фона
shapeRenderer.setColor(55 / 255.0f, 80 / 255.0f, 100 / 255.0f, 1);
shapeRenderer.rect(0, 0, 136, midPointY + 66);
// отрисовка Grass
shapeRenderer.setColor(111 / 255.0f, 186 / 255.0f, 45 / 255.0f, 1);
shapeRenderer.rect(0, midPointY + 66, 136, 11);
// отрисовка Dirt
shapeRenderer.setColor(147 / 255.0f, 80 / 255.0f, 27 / 255.0f, 1);
shapeRenderer.rect(0, midPointY + 77, 136, 52);
shapeRenderer.end();
batcher.begin();
batcher.disableBlending();
batcher.draw(bg, 0, midPointY + 23, 136, 43);
// 1. Отрисуем Grass
drawGrass();
// 2. Отрисуем Pipes
drawPipes();
batcher.enableBlending();
// 3. Отрисуем Skulls (требуется включить прозрачность)
drawSkulls();
if (bird.shouldntFlap()) {
batcher.draw(birdMid, bird.getX(), bird.getY(),
bird.getWidth() / 2.0f, bird.getHeight() / 2.0f,
bird.getWidth(), bird.getHeight(), 1, 1, bird.getRotation());
} else {
batcher.draw(birdAnimation.getKeyFrame(runTime), bird.getX(),
bird.getY(), bird.getWidth() / 2.0f,
bird.getHeight() / 2.0f, bird.getWidth(), bird.getHeight(),
1, 1, bird.getRotation());
}
batcher.end();
shapeRenderer.begin(ShapeType.Filled);
shapeRenderer.setColor(Color.RED);
shapeRenderer.circle(bird.getBoundingCircle().x,
bird.getBoundingCircle().y, bird.getBoundingCircle().radius);
/*
* Извините за беспорядок ниже. Временный код для теста границ
* прямоугольников.
*/
// Верхний блок для труб 1, 2 и 3
shapeRenderer.rect(pipe1.getBarUp().x, pipe1.getBarUp().y,
pipe1.getBarUp().width, pipe1.getBarUp().height);
shapeRenderer.rect(pipe2.getBarUp().x, pipe2.getBarUp().y,
pipe2.getBarUp().width, pipe2.getBarUp().height);
shapeRenderer.rect(pipe3.getBarUp().x, pipe3.getBarUp().y,
pipe3.getBarUp().width, pipe3.getBarUp().height);
// Нижний блок для труб 1, 2 и 3
shapeRenderer.rect(pipe1.getBarDown().x, pipe1.getBarDown().y,
pipe1.getBarDown().width, pipe1.getBarDown().height);
shapeRenderer.rect(pipe2.getBarDown().x, pipe2.getBarDown().y,
pipe2.getBarDown().width, pipe2.getBarDown().height);
shapeRenderer.rect(pipe3.getBarDown().x, pipe3.getBarDown().y,
pipe3.getBarDown().width, pipe3.getBarDown().height);
// Черепа для верхних труб 1, 2 и 3
shapeRenderer.rect(pipe1.getSkullUp().x, pipe1.getSkullUp().y,
pipe1.getSkullUp().width, pipe1.getSkullUp().height);
shapeRenderer.rect(pipe2.getSkullUp().x, pipe2.getSkullUp().y,
pipe2.getSkullUp().width, pipe2.getSkullUp().height);
shapeRenderer.rect(pipe3.getSkullUp().x, pipe3.getSkullUp().y,
pipe3.getSkullUp().width, pipe3.getSkullUp().height);
// Черепа для нижних труб 1, 2 and 3
shapeRenderer.rect(pipe1.getSkullDown().x, pipe1.getSkullDown().y,
pipe1.getSkullDown().width, pipe1.getSkullDown().height);
shapeRenderer.rect(pipe2.getSkullDown().x, pipe2.getSkullDown().y,
pipe2.getSkullDown().width, pipe2.getSkullDown().height);
shapeRenderer.rect(pipe3.getSkullDown().x, pipe3.getSkullDown().y,
pipe3.getSkullDown().width, pipe3.getSkullDown().height);
shapeRenderer.end();
}
Запустим нашу игру! Игра должна выглядеть прям как на картинке ниже:
У нас теперь есть необходимые блоки для проверки колизии. Сейчас мы просто добавим логику для обработки колизий.
Определяем колизию между объектами
Определение колизии задействует несколько классов.
- Класс ScrollHandler имеет доступ ко всем трубам и их техническим прямоугольникам, так что по сути, это тот класс где мы должны проверять на колизию.
- Класс GameWorld должен быть в курсе, когда происходит колизия, так что он сможет корректно ее обработать (получить текущий счет, оставновить птицу, прекратить играть музыку и т.д.).
- Класс GameRenderer, как только птица умирает, должен смочь отреагировать (показать счет, показать вспышку).
Мы начнем с класса GameWorld. Добавим следующее в метод update:
public void update(float delta) {
bird.update(delta);
scroller.update(delta);
if (scroller.collides(bird)) {
// Clean up on game over
scroller.stop();
}
}
Далее создадиим два метода в классе ScrollHandler: метод stop и метод collides. Давайте перейдем в класс ScrollHandler и добавим эти методы:
public void stop() {
frontGrass.stop();
backGrass.stop();
pipe1.stop();
pipe2.stop();
pipe3.stop();}
// вернуть True если какая-нибудь из труб коснулась птицы
public boolean collides(Bird bird) {
return (pipe1.collides(bird) || pipe2.collides(bird) || pipe3.collides(bird));
}
Полный код класса:
<spoiler title=«ScrollHandler.java>
package com.kilobolt.gameobjects;
public class ScrollHandler {
private Grass frontGrass, backGrass;
private Pipe pipe1, pipe2, pipe3;
public static final int SCROLL_SPEED = -59;
public static final int PIPE_GAP = 49;
public ScrollHandler(float yPos) {
frontGrass = new Grass(0, yPos, 143, 11, SCROLL_SPEED);
backGrass = new Grass(frontGrass.getTailX(), yPos, 143, 11,
SCROLL_SPEED);
pipe1 = new Pipe(210, 0, 22, 60, SCROLL_SPEED, yPos);
pipe2 = new Pipe(pipe1.getTailX() + PIPE_GAP, 0, 22, 70, SCROLL_SPEED,
yPos);
pipe3 = new Pipe(pipe2.getTailX() + PIPE_GAP, 0, 22, 60, SCROLL_SPEED,
yPos);
}
public void update(float delta) {
// Обновление наших объектов
frontGrass.update(delta);
backGrass.update(delta);
pipe1.update(delta);
pipe2.update(delta);
pipe3.update(delta);
// Проверим если хоть одна из наших труб уехала влево
// и сбросим ее позицию
if (pipe1.isScrolledLeft()) {
pipe1.reset(pipe3.getTailX() + PIPE_GAP);
} else if (pipe2.isScrolledLeft()) {
pipe2.reset(pipe1.getTailX() + PIPE_GAP);
} else if (pipe3.isScrolledLeft()) {
pipe3.reset(pipe2.getTailX() + PIPE_GAP);
}
// Тоже самое с травой
if (frontGrass.isScrolledLeft()) {
frontGrass.reset(backGrass.getTailX());
} else if (backGrass.isScrolledLeft()) {
backGrass.reset(frontGrass.getTailX());
}
}
public void stop() {
frontGrass.stop();
backGrass.stop();
pipe1.stop();
pipe2.stop();
pipe3.stop();
}
public boolean collides(Bird bird) {
return (pipe1.collides(bird) || pipe2.collides(bird) || pipe3
.collides(bird));
}
public Grass getFrontGrass() {
return frontGrass;
}
public Grass getBackGrass() {
return backGrass;
}
public Pipe getPipe1() {
return pipe1;
}
public Pipe getPipe2() {
return pipe2;
}
public Pipe getPipe3() {
return pipe3;
}
}
А теперь, чтобы исправить ошибки, необходимо внести два изменения. Наши объекты типа Scrollable (Pipe и Grass) должны иметь возможность быть остановленными, так что добавим метод stop внутри класса Scrollable.
- Откройте класс Scrollable и добавьте следующий метод:
public void stop() { velocity.x = 0; }
- Далее, наши объекты Pipe должны пройти индивидуальную проверку на колизию с нашей птицей, так что добавим такой метод. Откройте класс Pipe и добавьте следующее:
public boolean collides(Bird bird) { if (position.x < bird.getX() + bird.getWidth()) { return (Intersector.overlaps(bird.getBoundingCircle(), barUp) || Intersector.overlaps(bird.getBoundingCircle(), barDown) || Intersector.overlaps(bird.getBoundingCircle(), skullUp) || Intersector .overlaps(bird.getBoundingCircle(), skullDown)); } return false; }
В этом методе, мы начинаем с проверки, если position.x меньше чем bird.getX + bird.getWidth, в другом случае колизия не возможна. Это очень дешевая проверка (не повлияет на производительность игры). В большинстве случаев, это условие вернет false, и нам не нужно будет производить высоконагруженные проверки.
Если это условие вернет true, мы выполним «дорогой» вызов Intersector.overlaps() (который вернет true если круг пересекается с прямоугольником). Мы вернем true если любой из четырех прямоугольников пересекается с техническим кругом нашей птицы.
Полный код обновленных классов Pipe и Scrollable:
<spoiler title=»Обновленный Pipe.java>
package com.kilobolt.gameobjects;
import java.util.Random;
import com.badlogic.gdx.math.Intersector;
import com.badlogic.gdx.math.Rectangle;
public class Pipe extends Scrollable {
private Random r;
private Rectangle skullUp, skullDown, barUp, barDown;
public static final int VERTICAL_GAP = 45;
public static final int SKULL_WIDTH = 24;
public static final int SKULL_HEIGHT = 11;
private float groundY;
// Когда конструктор класса Pipe вызван – вызовите родительский (Scrollable)
public Pipe(float x, float y, int width, int height, float scrollSpeed,
float groundY) {
super(x, y, width, height, scrollSpeed);
// Инициализируйте объект типа Random для генерации случайных чисел
r = new Random();
skullUp = new Rectangle();
skullDown = new Rectangle();
barUp = new Rectangle();
barDown = new Rectangle();
this.groundY = groundY;
}
@Override
public void update(float delta) {
// Вызовите метод update в родительском классе (Scrollable)
super.update(delta);
// Метод set() повзоляет нам выставить координаты левого верхнего угла - x, y
// вместе с width и height
barUp.set(position.x, position.y, width, height);
barDown.set(position.x, position.y + height + VERTICAL_GAP, width,
groundY - (position.y + height + VERTICAL_GAP));
// Ширина черепа 24 пикселя. Ширина трубы всего 22 пикселя. Так что череп
// должен быть смещен на 1 пиксель влево (так что череп будет отцентрирован
// относительно трубы).
// This shift is equivalent to: (SKULL_WIDTH - width) / 2
skullUp.set(position.x - (SKULL_WIDTH - width) / 2, position.y + height
- SKULL_HEIGHT, SKULL_WIDTH, SKULL_HEIGHT);
skullDown.set(position.x - (SKULL_WIDTH - width) / 2, barDown.y,
SKULL_WIDTH, SKULL_HEIGHT);
}
@Override
public void reset(float newX) {
//Вызовите метод reset в родительском классе (Scrollable)
super.reset(newX);
// Измените высоту на случайное значение
height = r.nextInt(90) + 15;
}
public Rectangle getSkullUp() {
return skullUp;
}
public Rectangle getSkullDown() {
return skullDown;
}
public Rectangle getBarUp() {
return barUp;
}
public Rectangle getBarDown() {
return barDown;
}
public boolean collides(Bird bird) {
if (position.x < bird.getX() + bird.getWidth()) {
return (Intersector.overlaps(bird.getBoundingCircle(), barUp)
|| Intersector.overlaps(bird.getBoundingCircle(), barDown)
|| Intersector.overlaps(bird.getBoundingCircle(), skullUp) || Intersector
.overlaps(bird.getBoundingCircle(), skullDown));
}
return false;
}
}
<spoiler title=«Обновленный Scrollable.java>
package com.kilobolt.gameobjects;
import com.badlogic.gdx.math.Vector2;
public class Scrollable {
// Protected очень похоже на private, но позволяет наследование в дочерних
// класссах.
protected Vector2 position;
protected Vector2 velocity;
protected int width;
protected int height;
protected boolean isScrolledLeft;
public Scrollable(float x, float y, int width, int height, float scrollSpeed) {
position = new Vector2(x, y);
velocity = new Vector2(scrollSpeed, 0);
this.width = width;
this.height = height;
isScrolledLeft = false;
}
public void update(float delta) {
position.add(velocity.cpy().scl(delta));
// Если объект Scollable более не виден:
if (position.x + width < 0) {
isScrolledLeft = true;
}
}
// Reset: Должен переопределять родительский для специфического поведения.
public void reset(float newX) {
position.x = newX;
isScrolledLeft = false;
}
public void stop() {
velocity.x = 0;
}
// Методы доступа к переменным класса
public boolean isScrolledLeft() {
return isScrolledLeft;
}
public float getTailX() {
return position.x + width;
}
public float getX() {
return position.x;
}
public float getY() {
return position.y;
}
public int getWidth() {
return width;
}
public int getHeight() {
return height;
}
}
Тестируем наш код!
Теперь, когда мы закончили написание кода для проверки всех колизий, запустите вашу игру и убедитесь, что все работает! Мы добавим более изысканную смерть в девятом дне. Но сейчас, давайте попробуем проиграть звук из файла, когда наша птица умирает.
Скачайте звуковой файл
Этот звуковой файл я создал с помощью bfxr
Скачать файл dead.wav
Скачайте и поместите этот файл в проект ZombieBird-android в папку assets/data.
Убедитесь, что вы скопировали файл сюда, а не создали ссылку.
Обновим класс AssetLoder
Наконец-то у нас есть звуковой файл, и мы можем создать объект типа Sound в нашем классе AssetLoader. Звуковые объекты сохраняются в памяти и загружаются всего лишь раз и очень легко их использовать (это относится к файлам маленького размера, коротким звукам).
- Создайте следующую переменную в классе AssetLoader:
public static Sound dead;
- Инициализируйте ее в методе load следующим образом:
dead = Gdx.audio.newSound(Gdx.files.internal("data/dead.wav"));
Пример полного кода:
package com.kilobolt.zbhelpers;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.audio.Sound;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.Texture.TextureFilter;
import com.badlogic.gdx.graphics.g2d.Animation;
import com.badlogic.gdx.graphics.g2d.TextureRegion;
public class AssetLoader {
public static Texture texture;
public static TextureRegion bg, grass;
public static Animation birdAnimation;
public static TextureRegion bird, birdDown, birdUp;
public static TextureRegion skullUp, skullDown, bar;
public static Sound dead;
public static void load() {
texture = new Texture(Gdx.files.internal("data/texture.png"));
texture.setFilter(TextureFilter.Nearest, TextureFilter.Nearest);
bg = new TextureRegion(texture, 0, 0, 136, 43);
bg.flip(false, true);
grass = new TextureRegion(texture, 0, 43, 143, 11);
grass.flip(false, true);
birdDown = new TextureRegion(texture, 136, 0, 17, 12);
birdDown.flip(false, true);
bird = new TextureRegion(texture, 153, 0, 17, 12);
bird.flip(false, true);
birdUp = new TextureRegion(texture, 170, 0, 17, 12);
birdUp.flip(false, true);
TextureRegion[] birds = { birdDown, bird, birdUp };
birdAnimation = new Animation(0.06f, birds);
birdAnimation.setPlayMode(Animation.PlayMode.LOOP_PINGPONG);
skullUp = new TextureRegion(texture, 192, 0, 24, 14);
// Создадим перевернув существующий skullUp
skullDown = new TextureRegion(skullUp);
skullDown.flip(false, true);
bar = new TextureRegion(texture, 136, 16, 22, 3);
bar.flip(false, true);
dead = Gdx.audio.newSound(Gdx.files.internal("data/dead.wav"));
}
public static void dispose() {
// Когда завершаем работу необходимо освобождать текстуры, вызывая метод dispose.
texture.dispose();
}
}
Програем звуковой файл
Теперь, когда у нас есть объект типа Sound, мы можем воспроизвести его в игре. Откройте класс GameWorld и создайте следующую переменную в классе:
private boolean isAlive = true;
И внесите следующие изменения в метод update:
public void update(float delta) {
bird.update(delta);
scroller.update(delta);
if (isAlive && scroller.collides(bird)) {
scroller.stop();
AssetLoader.dead.play();
isAlive = false;
}
}
Теперь, когда наша птица будет умирать, будет воспроизводиться звуковой файл (только один раз, без повтора)! Обновленный класс GameWorld. Попробуйте запустить игру!
package com.kilobolt.gameworld;
import com.kilobolt.gameobjects.Bird;
import com.kilobolt.gameobjects.ScrollHandler;
import com.kilobolt.zbhelpers.AssetLoader;
public class GameWorld {
private Bird bird;
private ScrollHandler scroller;
private boolean isAlive = true;
public GameWorld(int midPointY) {
bird = new Bird(33, midPointY - 5, 17, 12);
//травадолжна начинаться на 66 пикселей ниже midPointY
scroller = new ScrollHandler(midPointY + 66);
}
public void update(float delta) {
bird.update(delta);
scroller.update(delta);
if (scroller.collides(bird) && isAlive) {
scroller.stop();
AssetLoader.dead.play();
isAlive = false;
}
}
public Bird getBird() {
return bird;
}
public ScrollHandler getScroller() {
return scroller;
}
}
Наконецто вы можете удалить все технические прямоугольники и круги из GameRenderer, наша логика определения колизии работает правильно. Вот пример вашего класса GameRenderer:
package com.kilobolt.gameworld;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.graphics.g2d.Animation;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.graphics.g2d.TextureRegion;
import com.badlogic.gdx.graphics.glutils.ShapeRenderer;
import com.badlogic.gdx.graphics.glutils.ShapeRenderer.ShapeType;
import com.kilobolt.gameobjects.Bird;
import com.kilobolt.gameobjects.Grass;
import com.kilobolt.gameobjects.Pipe;
import com.kilobolt.gameobjects.ScrollHandler;
import com.kilobolt.zbhelpers.AssetLoader;
public class GameRenderer {
private GameWorld myWorld;
private OrthographicCamera cam;
private ShapeRenderer shapeRenderer;
private SpriteBatch batcher;
private int midPointY;
private int gameHeight;
// Игровые объекты
private Bird bird;
private ScrollHandler scroller;
private Grass frontGrass, backGrass;
private Pipe pipe1, pipe2, pipe3;
// Игровые Assets
private TextureRegion bg, grass;
private Animation birdAnimation;
private TextureRegion birdMid, birdDown, birdUp;
private TextureRegion skullUp, skullDown, bar;
public GameRenderer(GameWorld world, int gameHeight, int midPointY) {
myWorld = world;
this.gameHeight = gameHeight;
this.midPointY = midPointY;
cam = new OrthographicCamera();
cam.setToOrtho(true, 136, gameHeight);
batcher = new SpriteBatch();
batcher.setProjectionMatrix(cam.combined);
shapeRenderer = new ShapeRenderer();
shapeRenderer.setProjectionMatrix(cam.combined);
// Вызываем вспомогательные методы, чтобы проинициализировать переменные класса
initGameObjects();
initAssets();
}
private void initGameObjects() {
bird = myWorld.getBird();
scroller = myWorld.getScroller();
frontGrass = scroller.getFrontGrass();
backGrass = scroller.getBackGrass();
pipe1 = scroller.getPipe1();
pipe2 = scroller.getPipe2();
pipe3 = scroller.getPipe3();
}
private void initAssets() {
bg = AssetLoader.bg;
grass = AssetLoader.grass;
birdAnimation = AssetLoader.birdAnimation;
birdMid = AssetLoader.bird;
birdDown = AssetLoader.birdDown;
birdUp = AssetLoader.birdUp;
skullUp = AssetLoader.skullUp;
skullDown = AssetLoader.skullDown;
bar = AssetLoader.bar;
}
private void drawGrass() {
// Отрисовка травы
batcher.draw(grass, frontGrass.getX(), frontGrass.getY(),
frontGrass.getWidth(), frontGrass.getHeight());
batcher.draw(grass, backGrass.getX(), backGrass.getY(),
backGrass.getWidth(), backGrass.getHeight());
}
private void drawSkulls() {
// Временный код, измените за кашу :)
// Мы исправим это когда закончим с классом Pipe.
batcher.draw(skullUp, pipe1.getX() - 1,
pipe1.getY() + pipe1.getHeight() - 14, 24, 14);
batcher.draw(skullDown, pipe1.getX() - 1,
pipe1.getY() + pipe1.getHeight() + 45, 24, 14);
batcher.draw(skullUp, pipe2.getX() - 1,
pipe2.getY() + pipe2.getHeight() - 14, 24, 14);
batcher.draw(skullDown, pipe2.getX() - 1,
pipe2.getY() + pipe2.getHeight() + 45, 24, 14);
batcher.draw(skullUp, pipe3.getX() - 1,
pipe3.getY() + pipe3.getHeight() - 14, 24, 14);
batcher.draw(skullDown, pipe3.getX() - 1,
pipe3.getY() + pipe3.getHeight() + 45, 24, 14);
}
private void drawPipes() {
// Временный код, измените за кашу :)
// Мы исправим это когда закончим с классом Grass.
batcher.draw(bar, pipe1.getX(), pipe1.getY(), pipe1.getWidth(),
pipe1.getHeight());
batcher.draw(bar, pipe1.getX(), pipe1.getY() + pipe1.getHeight() + 45,
pipe1.getWidth(), midPointY + 66 - (pipe1.getHeight() + 45));
batcher.draw(bar, pipe2.getX(), pipe2.getY(), pipe2.getWidth(),
pipe2.getHeight());
batcher.draw(bar, pipe2.getX(), pipe2.getY() + pipe2.getHeight() + 45,
pipe2.getWidth(), midPointY + 66 - (pipe2.getHeight() + 45));
batcher.draw(bar, pipe3.getX(), pipe3.getY(), pipe3.getWidth(),
pipe3.getHeight());
batcher.draw(bar, pipe3.getX(), pipe3.getY() + pipe3.getHeight() + 45,
pipe3.getWidth(), midPointY + 66 - (pipe3.getHeight() + 45));
}
public void render(float runTime) {
Gdx.gl.glClearColor(0, 0, 0, 1);
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
shapeRenderer.begin(ShapeType.Filled);
// Отрисуем задний фон
shapeRenderer.setColor(55 / 255.0f, 80 / 255.0f, 100 / 255.0f, 1);
shapeRenderer.rect(0, 0, 136, midPointY + 66);
// Отрисуем техническую Grass
shapeRenderer.setColor(111 / 255.0f, 186 / 255.0f, 45 / 255.0f, 1);
shapeRenderer.rect(0, midPointY + 66, 136, 11);
// Отрисуем техническую Dirt
shapeRenderer.setColor(147 / 255.0f, 80 / 255.0f, 27 / 255.0f, 1);
shapeRenderer.rect(0, midPointY + 77, 136, 52);
shapeRenderer.end();
batcher.begin();
batcher.disableBlending();
batcher.draw(bg, 0, midPointY + 23, 136, 43);
// 1. Отрисовка Grass
drawGrass();
// 2. Отрисовка Pipes
drawPipes();
batcher.enableBlending();
// 3. Отрисовка Skulls (требуется включить прозрачность)
drawSkulls();
if (bird.shouldntFlap()) {
batcher.draw(birdMid, bird.getX(), bird.getY(),
bird.getWidth() / 2.0f, bird.getHeight() / 2.0f,
bird.getWidth(), bird.getHeight(), 1, 1, bird.getRotation());
} else {
batcher.draw(birdAnimation.getKeyFrame(runTime), bird.getX(),
bird.getY(), bird.getWidth() / 2.0f,
bird.getHeight() / 2.0f, bird.getWidth(), bird.getHeight(),
1, 1, bird.getRotation());
}
batcher.end();
}
}
Мы почти закончили!
Наша игра почти, что законена. В следующем дне, девятом, мы закончим остальные части нашего игрового мира и начнем добавлять пользовательский интерфейс. Крепитесь! Некрополись близко!
Исходный код за день
Если вы вне настроения писать код самостоятельно, скачайте его отсюда:
day_8.zip
День 9 — Завершаем Игровой процесс и базовый UI
Добро пожаловать в девятый день. Сегодня мы собираемся закончить с игровым процессом добавив определение колизии с землей и улучшим смерть птички. Далее мы реализуем ведение счета и настроим использование BitmapFont, чтобы отображать счет!
Мы продолжим прямо с того момента, где мы закончили в предыдущем дне. Если у вас отсутствуют исходники, скачайте их перейдя в восьмой день.
Добавим еще не много звуковых эффектов
Нам нужно добавить звук полета нашей птички, а также звук увеличения счета.
- Создайте две новые переменные в классе и назовите их flap и coin, и проиницилизируйте их внутри метода load:
flap = Gdx.audio.newSound(Gdx.files.internal("data/flap.wav")); coin = Gdx.audio.newSound(Gdx.files.internal("data/coin.wav"));
- Так же в метод dispose добавьте:
dead.dispose(); flap.dispose(); coin.dispose();
Скачайте и поместите следующие файлы в папку assets/data в проекте ZombieGame-android:
Добавим текст!
Только ради того, чтобы казаться очень активными, мы добавим .font файл сгенерированый используя Hiero. Hiero конвертирует текстовый файл в .png Texture картинку, аналогично текстуре в нашей игре. Так же Hiero создает.fnt файл с настройками которые libGDX умеет читать и распознавать, где какая буква на картинке.
Я создал эти файлы для вас и их можно скачать ниже. Я покажу как можно использовать их в нашей игре.
Шрифт называется 04b_19, он используется в Flappy Bird и он бесплатный.
Скачайте следующие 4 файла:
shadow.fnt
text.fnt
shadow.png
text.png
Я настаиваю, чтобы вы открыли каждый из этих файлов и посмотрели, как они устроены! Поместите все четыре файла внутрь папки assets/data в проекте ZombieGame-android.
Мы можем использовать эти пары. fnt и .png файлов, чтобы создать объект типа BitmapFont, который позволит нам рисовать строки с помощью SpriteBatch в нашем GameRenderer, без необходимости создавать новые String каждый раз. BitmapFont использует .fnt для определения где каждая буква и цифра находится в TextureRegion. Так что в целом, нам не нужно делать лишнию работу, все сделают за нас.
Сделайте следующее, чтобы создать эти шрифты в нашем AssetLoader:
- Создайте новые переменные в классе:
public static BitmapFont font, shadow;
- Добавьте следующее в метод load.
font = new BitmapFont(Gdx.files.internal("data/text.fnt")); font.setScale(.25f, -.25f); shadow = new BitmapFont(Gdx.files.internal("data/shadow.fnt")); shadow.setScale(.25f, -.25f);
Этим мы загрузим файлы и сменим их размер на нужный нам.
- Так же добавьте следующее в метод dispose:
font.dispose(); shadow.dispose();
Вот как должен выглядеть ваш AssetLoader:
package com.kilobolt.ZBHelpers;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.audio.Sound;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.Texture.TextureFilter;
import com.badlogic.gdx.graphics.g2d.Animation;
import com.badlogic.gdx.graphics.g2d.BitmapFont;
import com.badlogic.gdx.graphics.g2d.TextureRegion;
public class AssetLoader {
public static Texture texture;
public static TextureRegion bg, grass;
public static Animation birdAnimation;
public static TextureRegion bird, birdDown, birdUp;
public static TextureRegion skullUp, skullDown, bar;
public static Sound dead, flap, coin;
public static BitmapFont font, shadow;
public static void load() {
texture = new Texture(Gdx.files.internal("data/texture.png"));
texture.setFilter(TextureFilter.Nearest, TextureFilter.Nearest);
bg = new TextureRegion(texture, 0, 0, 136, 43);
bg.flip(false, true);
grass = new TextureRegion(texture, 0, 43, 143, 11);
grass.flip(false, true);
birdDown = new TextureRegion(texture, 136, 0, 17, 12);
birdDown.flip(false, true);
bird = new TextureRegion(texture, 153, 0, 17, 12);
bird.flip(false, true);
birdUp = new TextureRegion(texture, 170, 0, 17, 12);
birdUp.flip(false, true);
TextureRegion[] birds = { birdDown, bird, birdUp };
birdAnimation = new Animation(0.06f, birds);
birdAnimation.setPlayMode(Animation.LOOP_PINGPONG);
skullUp = new TextureRegion(texture, 192, 0, 24, 14);
// Создаем перевернув существующий skullUp
skullDown = new TextureRegion(skullUp);
skullDown.flip(false, true);
bar = new TextureRegion(texture, 136, 16, 22, 3);
bar.flip(false, true);
dead = Gdx.audio.newSound(Gdx.files.internal("data/dead.wav"));
flap = Gdx.audio.newSound(Gdx.files.internal("data/flap.wav"));
coin = Gdx.audio.newSound(Gdx.files.internal("data/coin.wav"));
font = new BitmapFont(Gdx.files.internal("data/text.fnt"));
font.setScale(.25f, -.25f);
shadow = new BitmapFont(Gdx.files.internal("data/shadow.fnt"));
shadow.setScale(.25f, -.25f);
}
public static void dispose() {
// Когда текстура нам больше не нужна – мы должны освободить ресурсы
texture.dispose();
// Освобождаем русурсы звуков
dead.dispose();
flap.dispose();
coin.dispose();
font.dispose();
shadow.dispose();
}
Земля причиняет боль
Перейдем в класс GameWorld.
- Начнем с удаления переменной isALive. Мы пересмотрим как логику факта смерти нашей птицы.
- Мы хотим, чтобы наша птичка умирала, когда ударяется о землю. Сейчас мы этим и займемся.
Вместо того чтобы создавать прямоугольники для определения колизии с объектами травы и обновлять их, мы определим земплю как статический бокс, для простоты.
- Начните с создания новой переменной в классе:
(import com.badlogic.gdx.math) private Rectangle ground;
- Инициализируйте ее в конструкторе как:
ground = new Rectangle(0, midPointY + 66, 136, 11);
- Далее мы изменим наш update метод на следующее:
public void update(float delta) { // Добавим лимит для нашей delta, так что если игра начнет тормозить // при обновлении, мы не нарушим нашу логику определения колизии if (delta > .15f) { delta = .15f; } bird.update(delta); scroller.update(delta); if (scroller.collides(bird) && bird.isAlive()) { scroller.stop(); bird.die(); AssetLoader.dead.play(); } if (Intersector.overlaps(bird.getBoundingCircle(), ground)) { scroller.stop(); bird.die(); bird.decelerate(); } }
Починим нашу птичку
Теперь необходимо исправить наши ошибки в классе Bird.
- Нам надо создать новую переменную в классе:
private boolean isAlive
- Инициализировать ее в конструкторе:
isAlive = true;
- Добавить метод доступа к этой переменной:
public boolean isAlive() { return isAlive; }
- Теперь когда у нас есть isAlive, мы внесем маленькие изменения в наш метод shouldntFlap. Мы не хотим, чтобы наша птичка шевелилась, когда она мертва (да я понимаю, что она зомби, и что все это очень странно звучит):
public boolean shouldntFlap() { return velocity.y > 70 || !isAlive; }
Этот метод вернет true если одно из условий верно (дословно, если у нашей птички слишком большое ускорение по Y или она мертва – мы перестаем махать крыльями).
- Так же внесем мелкие изменения в метод onClick, который должен работать только когда наша птичка жива:
public void onClick() { if (isAlive) { AssetLoader.flap.play(); velocity.y = -140; } }
Вам надо будет добавить импорт: com.kilobolt.ZBHelpers.AssetLoader;
- Добавьте два новых метода: die и decelerate. Все очень просто:
public void die() { isAlive = false; velocity.y = 0; } public void decelerate() { // Нам надо чтобы птичка перестала падать вниз когда умерла acceleration.y = 0; }
- И на последок, добавьте новое условие в последний if в методе update:
if (isFalling() || !isAlive) { ... }
Так что если наша птичка взлетая вверх ударится, она направит свой нос в направлении земли, прям как птичка из игры Flappy Bird.
Полный класс Bird:
package com.kilobolt.GameObjects;
import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.Vector2;
import com.kilobolt.ZBHelpers.AssetLoader;
public class Bird {
private Vector2 position;
private Vector2 velocity;
private Vector2 acceleration;
private float rotation;
private int width;
private int height;
private boolean isAlive;
private Circle boundingCircle;
public Bird(float x, float y, int width, int height) {
this.width = width;
this.height = height;
position = new Vector2(x, y);
velocity = new Vector2(0, 0);
acceleration = new Vector2(0, 460);
boundingCircle = new Circle();
isAlive = true;
}
public void update(float delta) {
velocity.add(acceleration.cpy().scl(delta));
if (velocity.y > 200) {
velocity.y = 200;
}
position.add(velocity.cpy().scl(delta));
// Установим центр круга в координату (9, 6) в отношении к птице
// Установим радиус круга равным 6.5f;
boundingCircle.set(position.x + 9, position.y + 6, 6.5f);
// Развернуть против часовой стрелки
if (velocity.y < 0) {
rotation -= 600 * delta;
if (rotation < -20) {
rotation = -20;
}
}
// Развернуть по часовой стрелке
if (isFalling() || !isAlive) {
rotation += 480 * delta;
if (rotation > 90) {
rotation = 90;
}
}
}
public boolean isFalling() {
return velocity.y > 110;
}
public boolean shouldntFlap() {
return velocity.y > 70 || !isAlive;
}
public void onClick() {
if (isAlive) {
AssetLoader.flap.play();
velocity.y = -140;
}
}
public void die() {
isAlive = false;
velocity.y = 0;
}
public void decelerate() {
acceleration.y = 0;
}
public float getX() {
return position.x;
}
public float getY() {
return position.y;
}
public float getWidth() {
return width;
}
public float getHeight() {
return height;
}
public float getRotation() {
return rotation;
}
public Circle getBoundingCircle() {
return boundingCircle;
}
public boolean isAlive() {
return isAlive;
}
}
Запустите ваш код!
Игра должна быть играбельной, с определением колизий и смертью. Далее мы реализуем систему ведения счета.
Ведение счета
В Flappy Bird вы получаете очко, когда птичка пролетает около половины каждого пролета труб. Мы сэмулируем это поведение и будем тоже вести счет. Нам надо создать переменную типа integer, в которой мы будем хранить счет игрока. Мы сделаем это в классе GameWorld.
Откройте класс GameWorld.
- Создайте новую переменную в классе:
private int score = 0;
- Далее, создайте метод доступа к этой переменной и метод который будет увеличивать счет:
public int getScore() { return score; } public void addScore(int increment) { score += increment; }
Ваш класс GameWorld должен выглядеть следующим образом:
package com.kilobolt.GameWorld;
import com.badlogic.gdx.math.Intersector;
import com.badlogic.gdx.math.Rectangle;
import com.kilobolt.GameObjects.Bird;
import com.kilobolt.GameObjects.ScrollHandler;
import com.kilobolt.ZBHelpers.AssetLoader;
public class GameWorld {
private Bird bird;
private ScrollHandler scroller;
private Rectangle ground;
private int score = 0;
public GameWorld(int midPointY) {
bird = new Bird(33, midPointY - 5, 17, 12);
//трава должна начинаться на 66 писеля ниже midPointY
scroller = new ScrollHandler(midPointY + 66);
ground = new Rectangle(0, midPointY + 66, 137, 11);
}
public void update(float delta) {
// добавим лимит по дельте, так что если будут тормоза
// во время обновленя, то работа с колизией не будет нарушена
if (delta > .15f) {
delta = .15f;
}
bird.update(delta);
scroller.update(delta);
if (scroller.collides(bird) && bird.isAlive()) {
scroller.stop();
bird.die();
AssetLoader.dead.play();
}
if (Intersector.overlaps(bird.getBoundingCircle(), ground)) {
scroller.stop();
bird.die();
bird.decelerate();
}
}
public Bird getBird() {
return bird;
}
public ScrollHandler getScroller() {
return scroller;
}
public int getScore() {
return score;
}
public void addScore(int increment) {
score += increment;
}
}
Увеличте Счет
Логика отвечающая за увеличение счета будет в нашем классе ScrollHandler. Давайте откроем его.
Нам нужна ссылка на GameWorld, чтобы мы могли оперировать счетом. Так что передадим в конструктор ссылку на объект GameWorld, и сохраним ее в переменной класса с именем gameWorld. Убедитесь, что вы добавили импорт GameWorld класса (com.kilobolt.GameWorld.GameWorld).
Наш новый конструктор класса ScrollHandler:
public ScrollHandler(GameWorld gameWorld, float yPos) {
this.gameWorld = gameWorld;
frontGrass = new Grass(0, yPos, 143, 11, SCROLL_SPEED);
backGrass = new Grass(frontGrass.getTailX(), yPos, 143, 11,
SCROLL_SPEED);
pipe1 = new Pipe(210, 0, 22, 60, SCROLL_SPEED, yPos);
pipe2 = new Pipe(pipe1.getTailX() + PIPE_GAP, 0, 22, 70, SCROLL_SPEED,
yPos);
pipe3 = new Pipe(pipe2.getTailX() + PIPE_GAP, 0, 22, 60, SCROLL_SPEED,
yPos);
}
Перейдите в класс GameWorld и обновите создание нашего ScrollHandler следующим образом:
scroller = new ScrollHandler(this, midPointY + 66);
В целом, логика, следующая:
- Если середина трубы по отношению к Х меньше, чем клюв птицы, мы добавляем 1 очко к счету.
- Так как мы не хотим, чтобы это действие повторялось для одной и той же трубы, мы добавим в трубу новую перменную isScored тиа Boolean. Только если isScored равно false мы увелим счет (Устанавливаем isScore в true во время процесса. Нам надо вернуть isScored обратно в false после того как сбрасываем положение трубы).
Это наша новая логика колизии, которая описана ниже:
public boolean collides(Bird bird) {
if (!pipe1.isScored()
&& pipe1.getX() + (pipe1.getWidth() / 2) < bird.getX()
+ bird.getWidth()) {
addScore(1);
pipe1.setScored(true);
AssetLoader.coin.play();
} else if (!pipe2.isScored()
&& pipe2.getX() + (pipe2.getWidth() / 2) < bird.getX()
+ bird.getWidth()) {
addScore(1);
pipe2.setScored(true);
AssetLoader.coin.play();
} else if (!pipe3.isScored()
&& pipe3.getX() + (pipe3.getWidth() / 2) < bird.getX()
+ bird.getWidth()) {
addScore(1);
pipe3.setScored(true);
AssetLoader.coin.play();
}
return (pipe1.collides(bird) || pipe2.collides(bird) || pipe3
.collides(bird));
}
Чтобы избавится от всех ошибок, которые мы сейчас наплодили, нам необходимо сделать следующее:
- Добаьте импорт класса AseetLoader (com.kilobolt.ZBHelpers.AssetLoader).
- Создайте следующий метод addScore:
private void addScore(int increment) { gameWorld.addScore(increment); }
- Перейти в класс Pipe и добавить переменную типа Boolean с именем isScored.
Полный пример класса ScrollHandler:
package com.kilobolt.GameObjects;
import com.kilobolt.GameWorld.GameWorld;
import com.kilobolt.ZBHelpers.AssetLoader;
public class ScrollHandler {
private Grass frontGrass, backGrass;
private Pipe pipe1, pipe2, pipe3;
public static final int SCROLL_SPEED = -59;
public static final int PIPE_GAP = 49;
private GameWorld gameWorld;
public ScrollHandler(GameWorld gameWorld, float yPos) {
this.gameWorld = gameWorld;
frontGrass = new Grass(0, yPos, 143, 11, SCROLL_SPEED);
backGrass = new Grass(frontGrass.getTailX(), yPos, 143, 11,
SCROLL_SPEED);
pipe1 = new Pipe(210, 0, 22, 60, SCROLL_SPEED, yPos);
pipe2 = new Pipe(pipe1.getTailX() + PIPE_GAP, 0, 22, 70, SCROLL_SPEED,
yPos);
pipe3 = new Pipe(pipe2.getTailX() + PIPE_GAP, 0, 22, 60, SCROLL_SPEED,
yPos);
}
public void update(float delta) {
// Обновим наши объекты
frontGrass.update(delta);
backGrass.update(delta);
pipe1.update(delta);
pipe2.update(delta);
pipe3.update(delta);
// Проверим если какая, то из труб оказалась за левой границей экрана
// и сбросим ее положение
if (pipe1.isScrolledLeft()) {
pipe1.reset(pipe3.getTailX() + PIPE_GAP);
} else if (pipe2.isScrolledLeft()) {
pipe2.reset(pipe1.getTailX() + PIPE_GAP);
} else if (pipe3.isScrolledLeft()) {
pipe3.reset(pipe2.getTailX() + PIPE_GAP);
}
// Аналогично с травой
if (frontGrass.isScrolledLeft()) {
frontGrass.reset(backGrass.getTailX());
} else if (backGrass.isScrolledLeft()) {
backGrass.reset(frontGrass.getTailX());
}
}
public void stop() {
frontGrass.stop();
backGrass.stop();
pipe1.stop();
pipe2.stop();
pipe3.stop();
}
public boolean collides(Bird bird) {
if (!pipe1.isScored()
&& pipe1.getX() + (pipe1.getWidth() / 2) < bird.getX()
+ bird.getWidth()) {
addScore(1);
pipe1.setScored(true);
AssetLoader.coin.play();
} else if (!pipe2.isScored()
&& pipe2.getX() + (pipe2.getWidth() / 2) < bird.getX()
+ bird.getWidth()) {
addScore(1);
pipe2.setScored(true);
AssetLoader.coin.play();
} else if (!pipe3.isScored()
&& pipe3.getX() + (pipe3.getWidth() / 2) < bird.getX()
+ bird.getWidth()) {
addScore(1);
pipe3.setScored(true);
AssetLoader.coin.play();
}
return (pipe1.collides(bird) || pipe2.collides(bird) || pipe3
.collides(bird));
}
private void addScore(int increment) {
gameWorld.addScore(increment);
}
public Grass getFrontGrass() {
return frontGrass;
}
public Grass getBackGrass() {
return backGrass;
}
public Pipe getPipe1() {
return pipe1;
}
public Pipe getPipe2() {
return pipe2;
}
public Pipe getPipe3() {
return pipe3;
}
}
Класс Pipe – isScored boolean
- Создайте новую переменную в классе:
private boolean isScored = false;
Эта переменная будет выставлена в true, когда пользователя наградят очком за прохождение текущего объекта Pipe.
- Обновите метод reset, выставите isScored в false, когда сбрасывается положение трубы.
@Override public void reset(float newX) { super.reset(newX); height = r.nextInt(90) + 15; isScored = false; }
Далее создайте методы доступа для этой переменной:
public boolean isScored() { return isScored; } public void setScored(boolean b) { isScored = b; }
Вот как должен выглядеть ваш класс Pipe:
package com.kilobolt.GameObjects;
import java.util.Random;
import com.badlogic.gdx.math.Intersector;
import com.badlogic.gdx.math.Rectangle;
public class Pipe extends Scrollable {
private Random r;
private Rectangle skullUp, skullDown, barUp, barDown;
public static final int VERTICAL_GAP = 45;
public static final int SKULL_WIDTH = 24;
public static final int SKULL_HEIGHT = 11;
private float groundY;
private boolean isScored = false;
// Когда конструктор класса Pipe вызван – вызовите конструктор родительского класса (Scrollable)
public Pipe(float x, float y, int width, int height, float scrollSpeed,
float groundY) {
super(x, y, width, height, scrollSpeed);
// Инициализируйте объект типа Random, для получения случайных чисел
r = new Random();
skullUp = new Rectangle();
skullDown = new Rectangle();
barUp = new Rectangle();
barDown = new Rectangle();
this.groundY = groundY;
}
@Override
public void update(float delta) {
// Вызовите метод update в родительском классе (Scrollable)
super.update(delta);
barUp.set(position.x, position.y, width, height);
barDown.set(position.x, position.y + height + VERTICAL_GAP, width,
groundY - (position.y + height + VERTICAL_GAP));
// Это смещение эквивалентно этому: (SKULL_WIDTH - width) / 2
skullUp.set(position.x - (SKULL_WIDTH - width) / 2, position.y + height
- SKULL_HEIGHT, SKULL_WIDTH, SKULL_HEIGHT);
skullDown.set(position.x - (SKULL_WIDTH - width) / 2, barDown.y,
SKULL_WIDTH, SKULL_HEIGHT);
}
@Override
public void reset(float newX) {
// Вызовите метод reset в родительском классе (Scrollable)
super.reset(newX);
// Измените высоту на случайное значение
height = r.nextInt(90) + 15;
isScored = false;
}
public Rectangle getSkullUp() {
return skullUp;
}
public Rectangle getSkullDown() {
return skullDown;
}
public Rectangle getBarUp() {
return barUp;
}
public Rectangle getBarDown() {
return barDown;
}
public boolean collides(Bird bird) {
if (position.x < bird.getX() + bird.getWidth()) {
return (Intersector.overlaps(bird.getBoundingCircle(), barUp)
|| Intersector.overlaps(bird.getBoundingCircle(), barDown)
|| Intersector.overlaps(bird.getBoundingCircle(), skullUp) || Intersector
.overlaps(bird.getBoundingCircle(), skullDown));
}
return false;
}
public boolean isScored() {
return isScored;
}
public void setScored(boolean b) {
isScored = b;
}
}
Запустите код!
Вы должны слышать, как проигрывается Coin.wav каждый раз как получаете очко за прохождение пары трубы. Но мы хотим не просто слышать, как наш счет растет. Мы хотим видеть, как наш счет изменяется!
Мы собираемся отрисовывать текст на экране для отображения нашего счета.
Отображение Score в GameRenderer
Отображение текста – это простая задача. Между нашими batcher.begin() и batcher.end() вызовами, нам надо добавить следующую строчку:
AssetLoader.shadow.draw(batcher, "hello world", x, y);
Объект типа BitmapFont, как показано выше, имеет метод draw который получает на вход SpriteBatch, строку, и координаты x и y где отрисовать эту строку.
- Добавьте следующие строки кода перед batcher.end() вызовом в методе render:
AssetLoader.shadow.draw(batcher, "hello world", x, y);
// Конвертирование integer в String
String score = myWorld.getScore() + „“;// Отрисуем сначала тень
AssetLoader.shadow.draw(batcher, „“ + myWorld.getScore(), (136 / 2) — (3 * score.length()), 12);
// Отрисуем сам текст
AssetLoader.font.draw(batcher, „“ + myWorld.getScore(), (136 / 2) — (3 * score.length() — 1), 11);
batcher.end();
Сначала мы переводим Integer в String, чтобы мы могли отрисовать значение с помощью нашего BitmapFont. Мы расчитываем подходящее значение X координаты исходя из длины счета, так что мы сможем хорошо отцентрировать текст по центру экрана.
Полный пример класса GameRenderer:
package com.kilobolt.GameWorld;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.graphics.g2d.Animation;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.graphics.g2d.TextureRegion;
import com.badlogic.gdx.graphics.glutils.ShapeRenderer;
import com.badlogic.gdx.graphics.glutils.ShapeRenderer.ShapeType;
import com.badlogic.gdx.math.Rectangle;
import com.kilobolt.GameObjects.Bird;
import com.kilobolt.GameObjects.Grass;
import com.kilobolt.GameObjects.Pipe;
import com.kilobolt.GameObjects.ScrollHandler;
import com.kilobolt.ZBHelpers.AssetLoader;
public class GameRenderer {
private GameWorld myWorld;
private OrthographicCamera cam;
private ShapeRenderer shapeRenderer;
private SpriteBatch batcher;
private int midPointY;
private int gameHeight;
// Игровые объекты
private Bird bird;
private ScrollHandler scroller;
private Grass frontGrass, backGrass;
private Pipe pipe1, pipe2, pipe3;
// Ресурсы игры
private TextureRegion bg, grass;
private Animation birdAnimation;
private TextureRegion birdMid, birdDown, birdUp;
private TextureRegion skullUp, skullDown, bar;
public GameRenderer(GameWorld world, int gameHeight, int midPointY) {
myWorld = world;
this.gameHeight = gameHeight;
this.midPointY = midPointY;
cam = new OrthographicCamera();
cam.setToOrtho(true, 136, gameHeight);
batcher = new SpriteBatch();
batcher.setProjectionMatrix(cam.combined);
shapeRenderer = new ShapeRenderer();
shapeRenderer.setProjectionMatrix(cam.combined);
// Вызовим вспомогательные методы, чтобы инициализировать переменные класса
initGameObjects();
initAssets();
}
private void initGameObjects() {
bird = myWorld.getBird();
scroller = myWorld.getScroller();
frontGrass = scroller.getFrontGrass();
backGrass = scroller.getBackGrass();
pipe1 = scroller.getPipe1();
pipe2 = scroller.getPipe2();
pipe3 = scroller.getPipe3();
}
private void initAssets() {
bg = AssetLoader.bg;
grass = AssetLoader.grass;
birdAnimation = AssetLoader.birdAnimation;
birdMid = AssetLoader.bird;
birdDown = AssetLoader.birdDown;
birdUp = AssetLoader.birdUp;
skullUp = AssetLoader.skullUp;
skullDown = AssetLoader.skullDown;
bar = AssetLoader.bar;
}
private void drawGrass() {
// Отрисуем траву
batcher.draw(grass, frontGrass.getX(), frontGrass.getY(),
frontGrass.getWidth(), frontGrass.getHeight());
batcher.draw(grass, backGrass.getX(), backGrass.getY(),
backGrass.getWidth(), backGrass.getHeight());
}
private void drawSkulls() {
batcher.draw(skullUp, pipe1.getX() - 1,
pipe1.getY() + pipe1.getHeight() - 14, 24, 14);
batcher.draw(skullDown, pipe1.getX() - 1,
pipe1.getY() + pipe1.getHeight() + 45, 24, 14);
batcher.draw(skullUp, pipe2.getX() - 1,
pipe2.getY() + pipe2.getHeight() - 14, 24, 14);
batcher.draw(skullDown, pipe2.getX() - 1,
pipe2.getY() + pipe2.getHeight() + 45, 24, 14);
batcher.draw(skullUp, pipe3.getX() - 1,
pipe3.getY() + pipe3.getHeight() - 14, 24, 14);
batcher.draw(skullDown, pipe3.getX() - 1,
pipe3.getY() + pipe3.getHeight() + 45, 24, 14);
}
private void drawPipes() {
batcher.draw(bar, pipe1.getX(), pipe1.getY(), pipe1.getWidth(),
pipe1.getHeight());
batcher.draw(bar, pipe1.getX(), pipe1.getY() + pipe1.getHeight() + 45,
pipe1.getWidth(), midPointY + 66 - (pipe1.getHeight() + 45));
batcher.draw(bar, pipe2.getX(), pipe2.getY(), pipe2.getWidth(),
pipe2.getHeight());
batcher.draw(bar, pipe2.getX(), pipe2.getY() + pipe2.getHeight() + 45,
pipe2.getWidth(), midPointY + 66 - (pipe2.getHeight() + 45));
batcher.draw(bar, pipe3.getX(), pipe3.getY(), pipe3.getWidth(),
pipe3.getHeight());
batcher.draw(bar, pipe3.getX(), pipe3.getY() + pipe3.getHeight() + 45,
pipe3.getWidth(), midPointY + 66 - (pipe3.getHeight() + 45));
}
public void render(float runTime) {
Gdx.gl.glClearColor(0, 0, 0, 1);
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
shapeRenderer.begin(ShapeType.Filled);
// Отрисуем цвет заднего фона
shapeRenderer.setColor(55 / 255.0f, 80 / 255.0f, 100 / 255.0f, 1);
shapeRenderer.rect(0, 0, 136, midPointY + 66);
// Отрисуем траву
shapeRenderer.setColor(111 / 255.0f, 186 / 255.0f, 45 / 255.0f, 1);
shapeRenderer.rect(0, midPointY + 66, 136, 11);
// Отрисуем грязь
shapeRenderer.setColor(147 / 255.0f, 80 / 255.0f, 27 / 255.0f, 1);
shapeRenderer.rect(0, midPointY + 77, 136, 52);
shapeRenderer.end();
batcher.begin();
batcher.disableBlending();
batcher.draw(bg, 0, midPointY + 23, 136, 43);
// 1. Рисуем траву
drawGrass();
// 2. Рисуем трубы
drawPipes();
batcher.enableBlending();
// 3. Рисуем черепа(нужна прозрачность)
drawSkulls();
if (bird.shouldntFlap()) {
batcher.draw(birdMid, bird.getX(), bird.getY(),
bird.getWidth() / 2.0f, bird.getHeight() / 2.0f,
bird.getWidth(), bird.getHeight(), 1, 1, bird.getRotation());
} else {
batcher.draw(birdAnimation.getKeyFrame(runTime), bird.getX(),
bird.getY(), bird.getWidth() / 2.0f,
bird.getHeight() / 2.0f, bird.getWidth(), bird.getHeight(),
1, 1, bird.getRotation());
}
// Переводим integer в String
String score = myWorld.getScore() + "";
// Сначала отрисовываем тень
AssetLoader.shadow.draw(batcher, "" + myWorld.getScore(), (136 / 2)
- (3 * score.length()), 12);
// Отрисуем сам текст
AssetLoader.font.draw(batcher, "" + myWorld.getScore(), (136 / 2)
- (3 * score.length() - 1), 11);
batcher.end();
}
}
Теперь у нас есть работающая система очков и мы даже отобразили какой-то текст на экране! В следующем дне, мы реализуем GameStates, благодаря чему мы сможем перестартовывать игру. А после этого, мы попытаемся добавить какой-никакой UI!
Исходный код за день
Если вы вне настроения писать код самостоятельно, скачайте его отсюда:
day_9.zip
День 10 — GameStates и Лучший результат
В данной секции мы обсудим GameStates, которые в итоге реализуем: “старт на касании/клике», пауза и рестарт. После этого мы воспользуемся libGDX функционалом для работы с «предпочтениями», чтобы сохранять «лучший счет». В конце десятого дня у вас на руках будет полноценный клон игры Flappy Bird. В одинадцатом дне мы добавим несколько последних изменений.
Если вы готовы, приступим!
Быстрая правка – Колизия с потолком
Забыл добавить проверку на колизию с потолком. Обновите метод update внутри класса Bird!
public void update(float delta) {
velocity.add(acceleration.cpy().scl(delta));
if (velocity.y > 200) {
velocity.y = 200;
}
// проверяем потолок
if (position.y < -13) {
position.y = -13;
velocity.y = 0;
}
position.add(velocity.cpy().scl(delta));
// Устанавливаем центр круга (9, 6) по отношению к птице.
// Устанавливаем радиус круга равным 6.5f;
boundingCircle.set(position.x + 9, position.y + 6, 6.5f);
// Повернем против часовой стрелки
if (velocity.y < 0) {
rotation -= 600 * delta;
if (rotation < -20) {
rotation = -20;
}
}
// Повернем по часовой стрелке
if (isFalling() || !isAlive) {
rotation += 480 * delta;
if (rotation > 90) {
rotation = 90;
}
}
}
Добавим GameStates
Суть использования GameState в том, чтобы разбить нашу игру на несколько состояний, таких как “RUNNING" или “GAMEOVER". Так же мы будем использовать IF или SWITCH, чтобы управлять ходом игры в зависимости от текущего состояния.
Легкий вариант реализации GameState – это создать Enum, это всего лишь переменная, которая может принимать только те значения, что мы указали для нее. Подробнее про Enum вы можете почитать тут, если вам интересно. Если вы предпочитаете увидеть код, продолжайте читать!
Мы добавим enum в наш GameWorld. Добавьте следующий код куда нибудь внутри класса GameWorld:
public enum GameState {
READY, RUNNING, GAMEOVER
}
- Теперь мы можем создать переменую типа GameState так же как мы создаем другие переменные. Создайте переменную внутри класса:
private GameState currentState;
- Проинициализируйте ее в конструкторе:
currentState = GameState.READY;
Далее нам надо внести пачку изменений в наш метод update. В сущности, мы переименуем этот метод в updateRunning.
Как только вы внесете эти изменения, создайте новый метод update и метод updateReady как показано ниже:
public void update(float delta) {
switch (currentState) {
case READY:
updateReady(delta);
break;
case RUNNING:
default:
updateRunning(delta);
break;
}
}
private void updateReady(float delta) {
// Пока что ничего не делаем
}
Теперь метод update проверяет текущее состояние игры перед тем как запустить нужную логику обновления.
Далее нам нужно изменить наш GameState, на случай когда наша птичка умирает. Будем считать, что птичка полностью умерла, когда коснулась земли. Внутри метода updateRunning (переименованный ранее метод update), добавьте следующую строчку в последний if:
currentState = GameState.GAMEOVER;
И наконец, добавьте следующие методы, которые помогут управлять GameState.
isReady будет возвращать true когда curentState равен GameState.READY.
start изменяет currentState на GameState.RUNNING.
restart более интересный чем предыдущие методы – он обнуляет все переменные в объекте, которые подверглись изменениям во время игры. Вот и вся наша логика для рестарта игры.
Когда метод restart вызыван, мы пробежимся по всем зависимым объектам и повызываем их метод onRestart. Результатом будет полное обнуление до значений по умолчанию у всех объектов.
Какие аргументы мы передаем в методы onRestart? Это значения по умолчанию для переменных которые возможно поменялись во время игры. Например, у нашей птички есть значение позиции по Y, так что мы передадим стартовое значение для позиции по Y.
public void restart() {
currentState = GameState.READY;
score = 0;
bird.onRestart(midPointY - 5);
scroller.onRestart();
currentState = GameState.READY;
}
Нам так же нужен доступ к переменной midpoint, которая на данный момент не сохраняется в конструкторе класса. Давайте изменим это.
Добавьте следующую переменную в класс:
public int midPointY;
Инициализируйте ее в конструкторе класса:
this.midPointY = midPointY;
На случай если вы запутались, вот полный код GameWorld класс:
package com.kilobolt.GameWorld;
import com.badlogic.gdx.math.Intersector;
import com.badlogic.gdx.math.Rectangle;
import com.kilobolt.GameObjects.Bird;
import com.kilobolt.GameObjects.ScrollHandler;
import com.kilobolt.ZBHelpers.AssetLoader;
public class GameWorld {
private Bird bird;
private ScrollHandler scroller;
private Rectangle ground;
private int score = 0;
private int midPointY;
private GameState currentState;
public enum GameState {
READY, RUNNING, GAMEOVER
}
public GameWorld(int midPointY) {
currentState = GameState.READY;
this.midPointY = midPointY;
bird = new Bird(33, midPointY - 5, 17, 12);
// Трава должна начинаться на 66 пиксилей ниже чем знаение midPointY
scroller = new ScrollHandler(this, midPointY + 66);
ground = new Rectangle(0, midPointY + 66, 137, 11);
}
public void update(float delta) {
switch (currentState) {
case READY:
updateReady(delta);
break;
case RUNNING:
default:
updateRunning(delta);
break;
}
}
private void updateReady(float delta) {
// Пока ничего не делаем
}
public void updateRunning(float delta) {
if (delta > .15f) {
delta = .15f;
}
bird.update(delta);
scroller.update(delta);
if (scroller.collides(bird) && bird.isAlive()) {
scroller.stop();
bird.die();
AssetLoader.dead.play();
}
if (Intersector.overlaps(bird.getBoundingCircle(), ground)) {
scroller.stop();
bird.die();
bird.decelerate();
currentState = GameState.GAMEOVER;
}
}
public Bird getBird() {
return bird;
}
public ScrollHandler getScroller() {
return scroller;
}
public int getScore() {
return score;
}
public void addScore(int increment) {
score += increment;
}
public boolean isReady() {
return currentState == GameState.READY;
}
public void start() {
currentState = GameState.RUNNING;
}
public void restart() {
currentState = GameState.READY;
score = 0;
bird.onRestart(midPointY - 5);
scroller.onRestart();
currentState = GameState.READY;
}
public boolean isGameOver() {
return currentState == GameState.GAMEOVER;
}
}
Теперь давайте добавим метод onRestart в наши классы Bird и Scroller. Давайтей начнем с самого легкого класса – Bird.
Обнуляем Птицу
Создайте метод onRestart в нашей птичке. Внутри метода нам надо вернуть значения по умолчанию для всех переменных класса:
public void onRestart(int y) {
rotation = 0;
position.y = y;
velocity.x = 0;
velocity.y = 0;
acceleration.x = 0;
acceleration.y = 460;
isAlive = true;
}
Ну вот и все! У вас должно было получиться в итоге нечто следующее:
package com.kilobolt.GameObjects;
import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.Vector2;
import com.kilobolt.ZBHelpers.AssetLoader;
public class Bird {
private Vector2 position;
private Vector2 velocity;
private Vector2 acceleration;
private float rotation;
private int width;
private int height;
private boolean isAlive;
private Circle boundingCircle;
public Bird(float x, float y, int width, int height) {
this.width = width;
this.height = height;
position = new Vector2(x, y);
velocity = new Vector2(0, 0);
acceleration = new Vector2(0, 460);
boundingCircle = new Circle();
isAlive = true;
}
public void update(float delta) {
velocity.add(acceleration.cpy().scl(delta));
if (velocity.y > 200) {
velocity.y = 200;
}
position.add(velocity.cpy().scl(delta));
boundingCircle.set(position.x + 9, position.y + 6, 6.5f);
// Вращаем против часовой стрелки
if (velocity.y < 0) {
rotation -= 600 * delta;
if (rotation < -20) {
rotation = -20;
}
}
// Вращаем по часовой стрелке
if (isFalling() || !isAlive) {
rotation += 480 * delta;
if (rotation > 90) {
rotation = 90;
}
}
}
public boolean isFalling() {
return velocity.y > 110;
}
public boolean shouldntFlap() {
return velocity.y > 70 || !isAlive;
}
public void onClick() {
if (isAlive) {
AssetLoader.flap.play();
velocity.y = -140;
}
}
public void die() {
isAlive = false;
velocity.y = 0;
}
public void decelerate() {
acceleration.y = 0;
}
public void onRestart(int y) {
rotation = 0;
position.y = y;
velocity.x = 0;
velocity.y = 0;
acceleration.x = 0;
acceleration.y = 460;
isAlive = true;
}
public float getX() {
return position.x;
}
public float getY() {
return position.y;
}
public float getWidth() {
return width;
}
public float getHeight() {
return height;
}
public float getRotation() {
return rotation;
}
public Circle getBoundingCircle() {
return boundingCircle;
}
public boolean isAlive() {
return isAlive;
}
}
OnRestart — ScrollHandler
Теперь нам надо перейти в класс ScrollHandler и создать похожий метод, который будет сбрасывать значения переменных класса! Заметьте, что мы вызываем не существующий метод onRestart у нескольких объектов. Мы продолжим дальше и добавим их.
public void onRestart() {
frontGrass.onRestart(0, SCROLL_SPEED);
backGrass.onRestart(frontGrass.getTailX(), SCROLL_SPEED);
pipe1.onRestart(210, SCROLL_SPEED);
pipe2.onRestart(pipe1.getTailX() + PIPE_GAP, SCROLL_SPEED);
pipe3.onRestart(pipe2.getTailX() + PIPE_GAP, SCROLL_SPEED);
}
OnRestart — Grass
Это легко сделать. Нам просто надо вернуть траву в ее изначальное положение, а скорость изменить на SCROLL_SPEED.
package com.kilobolt.GameObjects;
public class Grass extends Scrollable {
public Grass(float x, float y, int width, int height, float scrollSpeed) {
super(x, y, width, height, scrollSpeed);
}
public void onRestart(float x, float scrollSpeed) {
position.x = x;
velocity.x = scrollSpeed;
}
}
OnRestart — Pipe
Метод onRestart у трубы не много сложнее чем у травы. Добавьте следующий метод:
public void onRestart(float x, float scrollSpeed) {
velocity.x = scrollSpeed;
reset(x);
}
Полный пример класса трубы:
package com.kilobolt.GameObjects;
import java.util.Random;
import com.badlogic.gdx.math.Intersector;
import com.badlogic.gdx.math.Rectangle;
public class Pipe extends Scrollable {
private Random r;
private Rectangle skullUp, skullDown, barUp, barDown;
public static final int VERTICAL_GAP = 45;
public static final int SKULL_WIDTH = 24;
public static final int SKULL_HEIGHT = 11;
private float groundY;
private boolean isScored = false;
public Pipe(float x, float y, int width, int height, float scrollSpeed,
float groundY) {
super(x, y, width, height, scrollSpeed);
r = new Random();
skullUp = new Rectangle();
skullDown = new Rectangle();
barUp = new Rectangle();
barDown = new Rectangle();
this.groundY = groundY;
}
@Override
public void update(float delta) {
super.update(delta);
barUp.set(position.x, position.y, width, height);
barDown.set(position.x, position.y + height + VERTICAL_GAP, width,
groundY - (position.y + height + VERTICAL_GAP));
skullUp.set(position.x - (SKULL_WIDTH - width) / 2, position.y + height
- SKULL_HEIGHT, SKULL_WIDTH, SKULL_HEIGHT);
skullDown.set(position.x - (SKULL_WIDTH - width) / 2, barDown.y,
SKULL_WIDTH, SKULL_HEIGHT);
}
@Override
public void reset(float newX) {
super.reset(newX);
height = r.nextInt(90) + 15;
isScored = false;
}
public void onRestart(float x, float scrollSpeed) {
velocity.x = scrollSpeed;
reset(x);
}
public Rectangle getSkullUp() {
return skullUp;
}
public Rectangle getSkullDown() {
return skullDown;
}
public Rectangle getBarUp() {
return barUp;
}
public Rectangle getBarDown() {
return barDown;
}
public boolean collides(Bird bird) {
if (position.x < bird.getX() + bird.getWidth()) {
return (Intersector.overlaps(bird.getBoundingCircle(), barUp)
|| Intersector.overlaps(bird.getBoundingCircle(), barDown)
|| Intersector.overlaps(bird.getBoundingCircle(), skullUp) || Intersector
.overlaps(bird.getBoundingCircle(), skullDown));
}
return false;
}
public boolean isScored() {
return isScored;
}
public void setScored(boolean b) {
isScored = b;
}
}
Ну вот мы и закончили! Давайте пройдемся по тому, что мы сделали. Мы начали с добавления GameState в класс GameWorld. Потом мы добавили restart метод, который вызывал методы restart в классах Bird и ScrollHandler, что по цепочке вызывало методы reset в объектах типа Pipe и Grass. Теперь все что нам осталось сделать – это добавить логику которая скажет игре, что нужно запустить restart метод.
Перейдите в InputHandler
Наш InputHandler должем иметь ссылку на объект GameWorld, так что он сможет проверять текущий GameState и правильно обрабатывать касания/клики. Вместо добавления еще одного аргумента в конструктор, я изменю существующий констркутор следующим образом:
public InputHandler(GameWorld myWorld) {
this.myWorld = myWorld;
myBird = myWorld.getBird();
}
- Создайте переменую в классе:
private GameWorld myWorld;
- Не забудьте добавить импорт класса!
Далее мы обновим метод touchDown:
@Override
public boolean touchDown(int screenX, int screenY, int pointer, int button) {
if (myWorld.isReady()) {
myWorld.start();
}
myBird.onClick();
if (myWorld.isGameOver()) {
// Обнулим все перменные, перейдем в GameState.READ
myWorld.restart();
}
return true;
}
Полный код класса:
package com.kilobolt.ZBHelpers;
import com.badlogic.gdx.InputProcessor;
import com.kilobolt.GameObjects.Bird;
import com.kilobolt.GameWorld.GameWorld;
public class InputHandler implements InputProcessor {
private Bird myBird;
private GameWorld myWorld;
// Запросим ссылку на объект Bird когда InputHandler создан.
public InputHandler(GameWorld myWorld) {
// myBird это объект Bird в gameWorld.
this.myWorld = myWorld;
myBird = myWorld.getBird();
}
@Override
public boolean touchDown(int screenX, int screenY, int pointer, int button) {
if (myWorld.isReady()) {
myWorld.start();
}
myBird.onClick();
if (myWorld.isGameOver()) {
// Обнулим все перменные, перейдем в GameState.READ
myWorld.restart();
}
return true;
}
@Override
public boolean keyDown(int keycode) {
return false;
}
@Override
public boolean keyUp(int keycode) {
return false;
}
@Override
public boolean keyTyped(char character) {
return false;
}
@Override
public boolean touchUp(int screenX, int screenY, int pointer, int button) {
return false;
}
@Override
public boolean touchDragged(int screenX, int screenY, int pointer) {
return false;
}
@Override
public boolean mouseMoved(int screenX, int screenY) {
return false;
}
@Override
public boolean scrolled(int amount) {
return false;
}
}
Исправляем GameScreen
Конечно, из-за наших испарвлений в конструкторе InputHandler, нам необходимо обновить наш GameScreen, а точнее инициализацию InputHandler.
Измените эту строчку:
Gdx.input.setInputProcessor(new InputHandler(world.getBird()));
На эту:
Gdx.input.setInputProcessor(new InputHandler(world));
Полный пример класса:
package com.kilobolt.Screens;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Screen;
import com.kilobolt.GameWorld.GameRenderer;
import com.kilobolt.GameWorld.GameWorld;
import com.kilobolt.ZBHelpers.InputHandler;
public class GameScreen implements Screen {
private GameWorld world;
private GameRenderer renderer;
private float runTime;
public GameScreen() {
float screenWidth = Gdx.graphics.getWidth();
float screenHeight = Gdx.graphics.getHeight();
float gameWidth = 136;
float gameHeight = screenHeight / (screenWidth / gameWidth);
int midPointY = (int) (gameHeight / 2);
world = new GameWorld(midPointY);
renderer = new GameRenderer(world, (int) gameHeight, midPointY);
Gdx.input.setInputProcessor(new InputHandler(world));
}
@Override
public void render(float delta) {
runTime += delta;
world.update(delta);
renderer.render(runTime);
}
@Override
public void resize(int width, int height) {
System.out.println("GameScreen - resizing");
}
@Override
public void show() {
System.out.println("GameScreen - show called");
}
@Override
public void hide() {
System.out.println("GameScreen - hide called");
}
@Override
public void pause() {
System.out.println("GameScreen - pause called");
}
@Override
public void resume() {
System.out.println("GameScreen - resume called");
}
@Override
public void dispose() {
// оставьте пустым
}
}
Изменим GameRenderer
Мы закончили с кодом для рестарта. Теперь, когда игра стартует, она стартанет в статусе READY, в котором ничего не будет происходить. Мы должны клацнуть по экрану, чтобы игра началась. Когда наша птичка умирает, мы переведем игру в статус GAMEOVER, в котором мы можем клацнуть по экрану, чтобы игра началась с начала.
Пока что без кнопок, но это только начало!
Теперь, чтобы сделать весь процесс более интуитивным, мы внесем некоторые изменения в GameRenderer, отобразим полезную информацию.
Измените в классе GameRenderer метод render как показано ниже:
package com.kilobolt.GameWorld;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.graphics.g2d.Animation;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.graphics.g2d.TextureRegion;
import com.badlogic.gdx.graphics.glutils.ShapeRenderer;
import com.badlogic.gdx.graphics.glutils.ShapeRenderer.ShapeType;
import com.badlogic.gdx.math.Rectangle;
import com.kilobolt.GameObjects.Bird;
import com.kilobolt.GameObjects.Grass;
import com.kilobolt.GameObjects.Pipe;
import com.kilobolt.GameObjects.ScrollHandler;
import com.kilobolt.ZBHelpers.AssetLoader;
public class GameRenderer {
private GameWorld myWorld;
private OrthographicCamera cam;
private ShapeRenderer shapeRenderer;
private SpriteBatch batcher;
private int midPointY;
private int gameHeight;
private Bird bird;
private ScrollHandler scroller;
private Grass frontGrass, backGrass;
private Pipe pipe1, pipe2, pipe3;
private TextureRegion bg, grass;
private Animation birdAnimation;
private TextureRegion birdMid, birdDown, birdUp;
private TextureRegion skullUp, skullDown, bar;
public GameRenderer(GameWorld world, int gameHeight, int midPointY) {
myWorld = world;
this.gameHeight = gameHeight;
this.midPointY = midPointY;
cam = new OrthographicCamera();
cam.setToOrtho(true, 136, gameHeight);
batcher = new SpriteBatch();
batcher.setProjectionMatrix(cam.combined);
shapeRenderer = new ShapeRenderer();
shapeRenderer.setProjectionMatrix(cam.combined);
initGameObjects();
initAssets();
}
private void initGameObjects() {
bird = myWorld.getBird();
scroller = myWorld.getScroller();
frontGrass = scroller.getFrontGrass();
backGrass = scroller.getBackGrass();
pipe1 = scroller.getPipe1();
pipe2 = scroller.getPipe2();
pipe3 = scroller.getPipe3();
}
private void initAssets() {
bg = AssetLoader.bg;
grass = AssetLoader.grass;
birdAnimation = AssetLoader.birdAnimation;
birdMid = AssetLoader.bird;
birdDown = AssetLoader.birdDown;
birdUp = AssetLoader.birdUp;
skullUp = AssetLoader.skullUp;
skullDown = AssetLoader.skullDown;
bar = AssetLoader.bar;
}
private void drawGrass() {
batcher.draw(grass, frontGrass.getX(), frontGrass.getY(),
frontGrass.getWidth(), frontGrass.getHeight());
batcher.draw(grass, backGrass.getX(), backGrass.getY(),
backGrass.getWidth(), backGrass.getHeight());
}
private void drawSkulls() {
batcher.draw(skullUp, pipe1.getX() - 1,
pipe1.getY() + pipe1.getHeight() - 14, 24, 14);
batcher.draw(skullDown, pipe1.getX() - 1,
pipe1.getY() + pipe1.getHeight() + 45, 24, 14);
batcher.draw(skullUp, pipe2.getX() - 1,
pipe2.getY() + pipe2.getHeight() - 14, 24, 14);
batcher.draw(skullDown, pipe2.getX() - 1,
pipe2.getY() + pipe2.getHeight() + 45, 24, 14);
batcher.draw(skullUp, pipe3.getX() - 1,
pipe3.getY() + pipe3.getHeight() - 14, 24, 14);
batcher.draw(skullDown, pipe3.getX() - 1,
pipe3.getY() + pipe3.getHeight() + 45, 24, 14);
}
private void drawPipes() {
batcher.draw(bar, pipe1.getX(), pipe1.getY(), pipe1.getWidth(),
pipe1.getHeight());
batcher.draw(bar, pipe1.getX(), pipe1.getY() + pipe1.getHeight() + 45,
pipe1.getWidth(), midPointY + 66 - (pipe1.getHeight() + 45));
batcher.draw(bar, pipe2.getX(), pipe2.getY(), pipe2.getWidth(),
pipe2.getHeight());
batcher.draw(bar, pipe2.getX(), pipe2.getY() + pipe2.getHeight() + 45,
pipe2.getWidth(), midPointY + 66 - (pipe2.getHeight() + 45));
batcher.draw(bar, pipe3.getX(), pipe3.getY(), pipe3.getWidth(),
pipe3.getHeight());
batcher.draw(bar, pipe3.getX(), pipe3.getY() + pipe3.getHeight() + 45,
pipe3.getWidth(), midPointY + 66 - (pipe3.getHeight() + 45));
}
public void render(float runTime) {
Gdx.gl.glClearColor(0, 0, 0, 1);
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
shapeRenderer.begin(ShapeType.Filled);
shapeRenderer.setColor(55 / 255.0f, 80 / 255.0f, 100 / 255.0f, 1);
shapeRenderer.rect(0, 0, 136, midPointY + 66);
shapeRenderer.setColor(111 / 255.0f, 186 / 255.0f, 45 / 255.0f, 1);
shapeRenderer.rect(0, midPointY + 66, 136, 11);
shapeRenderer.setColor(147 / 255.0f, 80 / 255.0f, 27 / 255.0f, 1);
shapeRenderer.rect(0, midPointY + 77, 136, 52);
shapeRenderer.end();
batcher.begin();
batcher.disableBlending();
batcher.draw(bg, 0, midPointY + 23, 136, 43);
drawGrass();
drawPipes();
batcher.enableBlending();
drawSkulls();
if (bird.shouldntFlap()) {
batcher.draw(birdMid, bird.getX(), bird.getY(),
bird.getWidth() / 2.0f, bird.getHeight() / 2.0f,
bird.getWidth(), bird.getHeight(), 1, 1, bird.getRotation());
} else {
batcher.draw(birdAnimation.getKeyFrame(runTime), bird.getX(),
bird.getY(), bird.getWidth() / 2.0f,
bird.getHeight() / 2.0f, bird.getWidth(), bird.getHeight(),
1, 1, bird.getRotation());
}
// ВРЕМЕННЫЙ КОД! Изменим позже:
if (myWorld.isReady()) {
// Отрисуем сначала тень
AssetLoader.shadow.draw(batcher, "Touch me", (136 / 2)
- (42), 76);
// Отрисуем сам текст
AssetLoader.font.draw(batcher, "Touch me", (136 / 2)
- (42 - 1), 75);
} else {
if (myWorld.isGameOver()) {
AssetLoader.shadow.draw(batcher, "Game Over", 25, 56);
AssetLoader.font.draw(batcher, "Game Over", 24, 55);
AssetLoader.shadow.draw(batcher, "Try again?", 23, 76);
AssetLoader.font.draw(batcher, "Try again?", 24, 75);
}
String score = myWorld.getScore() + "";
AssetLoader.shadow.draw(batcher, "" + myWorld.getScore(), (136 / 2)
- (3 * score.length()), 12);
AssetLoader.font.draw(batcher, "" + myWorld.getScore(), (136 / 2)
- (3 * score.length() - 1), 11);
}
batcher.end();
}
}
Законченный Игровой процесс!
Вот и все, наш геймплей закончен. Давайте приведем вещи в порядок и реализуем ведение счета!
Реализация Лучшего счета
Легкий путь хранения маленького количества данных для игры, написанной с помозью LibGDX это использовать класс Preferences. Этот класс связывает пары ключ-значение. Это значит, что вы можете созранить какой-то ключ и соответствующее ему значение, а также по ключу вы можете получать значения!
Давайте рассмотрим пример:
// Указываем имя файла для Preferences
Preferences prefs = Gdx.app.getPreferences("PreferenceName"); // Сохраним значение 10 с ключем "highScore"
prefs.putInteger("highScore", 10);
prefs.flush(); // Эта строчка сохраняет содержимое Preferences в файл
Неделю спустя, вы попробуете выполнить:
System.out.println(prefs.getInteger("highScore"));
Результатом будет вывод в консоли значения 10!
Итак, три важных метода которые вы должны знать:
- put…
- get…
- и flush (для сохранения)
Вы можете сохранять различные типы данных следующим образом:
putBoolean("soundEnabled", true); // getBoolean("soundEnabled") получаем boolean.
putString("playerName", "James");
Давайте применим эти знания для сохранения Лучшего счета.
Откройте класс AssetLoader
Создайте статическую перменную в классе:
public static Preferences prefs;
Внутри метода load добавьте следующие строчки кода:
// Создайте (или получите ранее созданный) файл preferences
prefs = Gdx.app.getPreferences("ZombieBird");
// Создадим переменую для хранения лучшего счета со значением по умолчанию 0
if (!prefs.contains("highScore")) {
prefs.putInteger("highScore", 0);
}
Теперь мы можем достучаться до переменной prefs из любого места в нашей игре! Давайте создадим вспомогательные методы, которые будут выоплнять работу с preferences внутри AssetsLoader.
Добавьте следующие методы:
// Ролучает на вход значение для hishScore и сохраняет в файл
public static void setHighScore(int val) {
prefs.putInteger("highScore", val);
prefs.flush();
}
// Возвращает текущее значение hishScore
public static int getHighScore() {
return prefs.getInteger("highScore");
}
Полный пример класса:
package com.kilobolt.ZBHelpers;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Preferences;
import com.badlogic.gdx.audio.Sound;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.Texture.TextureFilter;
import com.badlogic.gdx.graphics.g2d.Animation;
import com.badlogic.gdx.graphics.g2d.BitmapFont;
import com.badlogic.gdx.graphics.g2d.TextureRegion;
public class AssetLoader {
public static Texture texture;
public static TextureRegion bg, grass;
public static Animation birdAnimation;
public static TextureRegion bird, birdDown, birdUp;
public static TextureRegion skullUp, skullDown, bar;
public static Sound dead, flap, coin;
public static BitmapFont font, shadow;
private static Preferences prefs;
public static void load() {
texture = new Texture(Gdx.files.internal("data/texture.png"));
texture.setFilter(TextureFilter.Nearest, TextureFilter.Nearest);
bg = new TextureRegion(texture, 0, 0, 136, 43);
bg.flip(false, true);
grass = new TextureRegion(texture, 0, 43, 143, 11);
grass.flip(false, true);
birdDown = new TextureRegion(texture, 136, 0, 17, 12);
birdDown.flip(false, true);
bird = new TextureRegion(texture, 153, 0, 17, 12);
bird.flip(false, true);
birdUp = new TextureRegion(texture, 170, 0, 17, 12);
birdUp.flip(false, true);
TextureRegion[] birds = { birdDown, bird, birdUp };
birdAnimation = new Animation(0.06f, birds);
birdAnimation.setPlayMode(Animation.LOOP_PINGPONG);
skullUp = new TextureRegion(texture, 192, 0, 24, 14);
skullDown = new TextureRegion(skullUp);
skullDown.flip(false, true);
bar = new TextureRegion(texture, 136, 16, 22, 3);
bar.flip(false, true);
dead = Gdx.audio.newSound(Gdx.files.internal("data/dead.wav"));
flap = Gdx.audio.newSound(Gdx.files.internal("data/flap.wav"));
coin = Gdx.audio.newSound(Gdx.files.internal("data/coin.wav"));
font = new BitmapFont(Gdx.files.internal("data/text.fnt"));
font.setScale(.25f, -.25f);
shadow = new BitmapFont(Gdx.files.internal("data/shadow.fnt"));
shadow.setScale(.25f, -.25f);
// Получим (или создадим) preferences
prefs = Gdx.app.getPreferences("ZombieBird");
if (!prefs.contains("highScore")) {
prefs.putInteger("highScore", 0);
}
}
public static void setHighScore(int val) {
prefs.putInteger("highScore", val);
prefs.flush();
}
public static int getHighScore() {
return prefs.getInteger("highScore");
}
public static void dispose() {
texture.dispose();
dead.dispose();
flap.dispose();
coin.dispose();
font.dispose();
shadow.dispose();
}
}
Давайте вернемся в GameWorld и добавим логику сохранения/обновления Лучшего счета!
Начнем с добавления четвертой enum константы HIGHSCORE:
public enum GameState {
READY, RUNNING, GAMEOVER, HIGHSCORE
}
Расширим логику обработки события смерти нашей птички (метод update, там, где мы проверяем колизию между птичкой и землей). Мы просто добавим проверку если наш новый счет больше чем сохраненный ранее Лучший счет, и если да, то обновим Лучший счет новым значением:
if (Intersector.overlaps(bird.getBoundingCircle(), ground)) {
scroller.stop();
bird.die();
bird.decelerate();
currentState = GameState.GAMEOVER;
if (score > AssetLoader.getHighScore()) {
AssetLoader.setHighScore(score);
currentState = GameState.HIGHSCORE;
}
}
Добавьте еще один метод в котором мы проверим находится ли игра в состоянии HIGHSCORE:
public boolean isHighScore() {
return currentState == GameState.HIGHSCORE;
}
Вернемся к нашему GameRenderer и добавим логику для состояния HIGHSCORE в gameState, а также отображение Лучшего счета по окончанию игры:
Обновленный класс UpdateRenderer:
Внимание: это временный код, чтобы наглядно продемонстрировать логику:
package com.kilobolt.GameWorld;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.graphics.g2d.Animation;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.graphics.g2d.TextureRegion;
import com.badlogic.gdx.graphics.glutils.ShapeRenderer;
import com.badlogic.gdx.graphics.glutils.ShapeRenderer.ShapeType;
import com.badlogic.gdx.math.Rectangle;
import com.kilobolt.GameObjects.Bird;
import com.kilobolt.GameObjects.Grass;
import com.kilobolt.GameObjects.Pipe;
import com.kilobolt.GameObjects.ScrollHandler;
import com.kilobolt.ZBHelpers.AssetLoader;
public class GameRenderer {
private GameWorld myWorld;
private OrthographicCamera cam;
private ShapeRenderer shapeRenderer;
private SpriteBatch batcher;
private int midPointY;
private int gameHeight;
private Bird bird;
private ScrollHandler scroller;
private Grass frontGrass, backGrass;
private Pipe pipe1, pipe2, pipe3;
private TextureRegion bg, grass;
private Animation birdAnimation;
private TextureRegion birdMid, birdDown, birdUp;
private TextureRegion skullUp, skullDown, bar;
public GameRenderer(GameWorld world, int gameHeight, int midPointY) {
myWorld = world;
this.gameHeight = gameHeight;
this.midPointY = midPointY;
cam = new OrthographicCamera();
cam.setToOrtho(true, 136, gameHeight);
batcher = new SpriteBatch();
batcher.setProjectionMatrix(cam.combined);
shapeRenderer = new ShapeRenderer();
shapeRenderer.setProjectionMatrix(cam.combined);
initGameObjects();
initAssets();
}
private void initGameObjects() {
bird = myWorld.getBird();
scroller = myWorld.getScroller();
frontGrass = scroller.getFrontGrass();
backGrass = scroller.getBackGrass();
pipe1 = scroller.getPipe1();
pipe2 = scroller.getPipe2();
pipe3 = scroller.getPipe3();
}
private void initAssets() {
bg = AssetLoader.bg;
grass = AssetLoader.grass;
birdAnimation = AssetLoader.birdAnimation;
birdMid = AssetLoader.bird;
birdDown = AssetLoader.birdDown;
birdUp = AssetLoader.birdUp;
skullUp = AssetLoader.skullUp;
skullDown = AssetLoader.skullDown;
bar = AssetLoader.bar;
}
private void drawGrass() {
batcher.draw(grass, frontGrass.getX(), frontGrass.getY(),
frontGrass.getWidth(), frontGrass.getHeight());
batcher.draw(grass, backGrass.getX(), backGrass.getY(),
backGrass.getWidth(), backGrass.getHeight());
}
private void drawSkulls() {
batcher.draw(skullUp, pipe1.getX() - 1,
pipe1.getY() + pipe1.getHeight() - 14, 24, 14);
batcher.draw(skullDown, pipe1.getX() - 1,
pipe1.getY() + pipe1.getHeight() + 45, 24, 14);
batcher.draw(skullUp, pipe2.getX() - 1,
pipe2.getY() + pipe2.getHeight() - 14, 24, 14);
batcher.draw(skullDown, pipe2.getX() - 1,
pipe2.getY() + pipe2.getHeight() + 45, 24, 14);
batcher.draw(skullUp, pipe3.getX() - 1,
pipe3.getY() + pipe3.getHeight() - 14, 24, 14);
batcher.draw(skullDown, pipe3.getX() - 1,
pipe3.getY() + pipe3.getHeight() + 45, 24, 14);
}
private void drawPipes() {
batcher.draw(bar, pipe1.getX(), pipe1.getY(), pipe1.getWidth(),
pipe1.getHeight());
batcher.draw(bar, pipe1.getX(), pipe1.getY() + pipe1.getHeight() + 45,
pipe1.getWidth(), midPointY + 66 - (pipe1.getHeight() + 45));
batcher.draw(bar, pipe2.getX(), pipe2.getY(), pipe2.getWidth(),
pipe2.getHeight());
batcher.draw(bar, pipe2.getX(), pipe2.getY() + pipe2.getHeight() + 45,
pipe2.getWidth(), midPointY + 66 - (pipe2.getHeight() + 45));
batcher.draw(bar, pipe3.getX(), pipe3.getY(), pipe3.getWidth(),
pipe3.getHeight());
batcher.draw(bar, pipe3.getX(), pipe3.getY() + pipe3.getHeight() + 45,
pipe3.getWidth(), midPointY + 66 - (pipe3.getHeight() + 45));
}
public void render(float runTime) {
Gdx.gl.glClearColor(0, 0, 0, 1);
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
shapeRenderer.begin(ShapeType.Filled);
shapeRenderer.setColor(55 / 255.0f, 80 / 255.0f, 100 / 255.0f, 1);
shapeRenderer.rect(0, 0, 136, midPointY + 66);
shapeRenderer.setColor(111 / 255.0f, 186 / 255.0f, 45 / 255.0f, 1);
shapeRenderer.rect(0, midPointY + 66, 136, 11);
shapeRenderer.setColor(147 / 255.0f, 80 / 255.0f, 27 / 255.0f, 1);
shapeRenderer.rect(0, midPointY + 77, 136, 52);
shapeRenderer.end();
batcher.begin();
batcher.disableBlending();
batcher.draw(bg, 0, midPointY + 23, 136, 43);
drawGrass();
drawPipes();
batcher.enableBlending();
drawSkulls();
if (bird.shouldntFlap()) {
batcher.draw(birdMid, bird.getX(), bird.getY(),
bird.getWidth() / 2.0f, bird.getHeight() / 2.0f,
bird.getWidth(), bird.getHeight(), 1, 1, bird.getRotation());
} else {
batcher.draw(birdAnimation.getKeyFrame(runTime), bird.getX(),
bird.getY(), bird.getWidth() / 2.0f,
bird.getHeight() / 2.0f, bird.getWidth(), bird.getHeight(),
1, 1, bird.getRotation());
}
// ВРЕМЕННЫЙ КОД! Исправим чуть позже!
if (myWorld.isReady()) {
// Отрисуем сначала тень
AssetLoader.shadow.draw(batcher, "Touch me", (136 / 2) - (42), 76);
// А теперь сам текст
AssetLoader.font
.draw(batcher, "Touch me", (136 / 2) - (42 - 1), 75);
} else {
if (myWorld.isGameOver() || myWorld.isHighScore()) {
if (myWorld.isGameOver()) {
AssetLoader.shadow.draw(batcher, "Game Over", 25, 56);
AssetLoader.font.draw(batcher, "Game Over", 24, 55);
AssetLoader.shadow.draw(batcher, "High Score:", 23, 106);
AssetLoader.font.draw(batcher, "High Score:", 22, 105);
String highScore = AssetLoader.getHighScore() + "";
AssetLoader.shadow.draw(batcher, highScore, (136 / 2)
- (3 * highScore.length()), 128);
AssetLoader.font.draw(batcher, highScore, (136 / 2)
- (3 * highScore.length() - 1), 127);
} else {
AssetLoader.shadow.draw(batcher, "High Score!", 19, 56);
AssetLoader.font.draw(batcher, "High Score!", 18, 55);
}
AssetLoader.shadow.draw(batcher, "Try again?", 23, 76);
AssetLoader.font.draw(batcher, "Try again?", 24, 75);
// Конвертируем integer в String
String score = myWorld.getScore() + "";
AssetLoader.shadow.draw(batcher, score,
(136 / 2) - (3 * score.length()), 12);
AssetLoader.font.draw(batcher, score,
(136 / 2) - (3 * score.length() - 1), 11);
}
String score = myWorld.getScore() + "";
AssetLoader.shadow.draw(batcher, "" + myWorld.getScore(), (136 / 2)
- (3 * score.length()), 12);
AssetLoader.font.draw(batcher, "" + myWorld.getScore(), (136 / 2)
- (3 * score.length() - 1), 11);
}
batcher.end();
}
}
Давайте изменим метод update в классе GameWorld как показано ниже:
public void update(float delta) {
switch (currentState) {
case READY:
updateReady(delta);
break;
case RUNNING:
updateRunning(delta);
break;
default:
break;
}
}
Самая последня вещь которую нам надо сделать, это изменить одну строчку в классе InputHandler:
if (myWorld.isGameOver() || myWorld.isHighScore()) {
// Reset all variables, go to GameState.READ
myWorld.restart();
}
На этом все! Мы продолжим нашу стройку века в следующем дне. Спасибо за чтение.
Исходный код за день
Если вы вне настроения писать код самостоятельно, скачайте его отсюда:
day_10.zip
День 11 — Добавляем поддержку iOS/Android + SplashScreen, Меню и Tweening
Добро пожаловать в День 11! Теперь, когда у нас готов Игровой процесс, мы займемся UI, создадим дополнительные экраны и добавим переходы используя Tween Engine от Aurelien Ribon. В дне 2 я попросил вас скачать с помощью установщика libGDX пакет Universal Tween Engine. Я забыл выбрать эту опцию в своем проекте, так что я использую опять установщик libGDX, чтобы обновить мой проект.
Добавление Tween Engine Library в наш libGDX проект
Чтобы убедиться, что у вас правильно настроена библиотека Tween Engine, надо проверить core проект в Eclipse. У вас должны быть эти два файла, которые подсвечены на картинке ниже:
Если ваш проект включает в себя эти файлы – значит вы счастливый человек и у вас все хорошо! Если же нет, то нам надо будет добавить их используя установщик libGDX. Чтобы это сделать, проделайте следующие шаги (или просто скачайте файл day_11_starting.zip в конце этой секции):
- Найдите, где физически находится ваш core проект. Это можно выяснить нажав правую кнопку мышки (Control + клик на Mac) на проекте и выбрав Properties. Запомните значение в Location.
- Откройте gdx-setup-ui.jar, как мы проделали это в Дне 2. Если надо, то скачайте этот файл отсюда
- Укажите путь к core проекту как показано на картинке ниже
- Убедитесь, что выбрана опция «Universal Tween Engine».
- Справа нажмите «Open the update screen»
- Вы должны увидеть экран как на картинке ниже. Нажмите Launch!
Вот и все. Теперь наш Eclipse проект может использовать tween-engine-api.
Обновленный исходный код
Настройка Android проекта
Сейчас мы настроим наш Android проект, так что вы сможете тестировать игру на Android устройствах. Откройте проект ZombieGame-android.
- Измените иконку.
Чтобы поменять иконку, откройте папку res -> drawable-hdpi. Замените ic_launcher.png вашей картинкой, к примеру, такой:
Скачать ic_launcher.png - Измените ориентацию экрана.
По умолчанию в libGDX ориентация экрана – landscape. А нам нужен portrait. Откройте файл AndroidManifest.xml и измените эту строчку:android:screenOrientation="landscape"
На эту
android:screenOrientation="portrait"
- И на последок, измените имя приложения.
Откройте файл values -> string.xml и измените имя как показано ниже:
Ну вот, теперь наш Android проект готов! Вы можете поиграться с проектом на вашем мобильном устройстве или в виртуальной среде, запустив проект как Android application.
Настройка iOS проекта
Чтобы протесировать ваш проект на iOS вам необходимо иметь Intel-based Mac. Если у вас в этом плане все хорошо, тогда продолжим с настройкой RoboVM на вашем компьютере. Перейдите по ссылке http://www.robovm.org/docs#start и скачайте JDK 7, XCode, а так же RoboVM для Eclipse.
Чтобы добавить RoboVM в Eclipse, откройте Help -> Install New Software и введите следующий адрес:
download.robovm.org/eclipse/
Как скачивание и установка завершатся, перезагрузите ваш Eclipse.
Перед тем как продолжить, откройте XCode.
Далее, убедитесь, что Eclipse использует JDK 7. Откройте настройки Eclipse и проверьте что у вас выбран путь к JDK 7:
Перезагрузите Eclipse еще раз.
Вы можете изменять ваши иконки в папке data. В ней хранятся иконки для различных размеров экрана, в соответствии с Apple Human Interface. Подробнее можно прочитать тут.
Когда вы запускаете мобильное приложение на iOS, показывается изображение по умолчанию для приложения, что создают илюзию быстрой загрузки приложения. Вы можете так же добавить изображение по умолчанию в папку data.
Далее изменим ориентацию экрана. Откройте файл Info.plist.xml. Найдите следующие ключи:
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
И измените их на это:
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
Осталось только поменять имя приложения. Откройте файл robovm.properties и внесите следующие изменения:
#Fri May 31 13:01:40 CEST 2013
app.version=1.0
app.id=com.kilobolt.ZombieBird
app.main.kilobolt.ZombieBird.RobovmLauncher
app.executable=ZBGame
app.build=1
app.name=Zombie Bird
Теперь вы можете запустить ваше приложение как iPhone Simulator Application. Этот процесс займет много времени, наберитесь терпения.
Как только вы запустили ваше приложение, оно должно загрузитья в вашем iOS симуляторе как показано ниже! Если вы видите libGDX картинку, это картинка по умолчанию, так что все в порядке.
Скачайте обновленные файлы картинок:
logo.png
texture.png
Объект SimpleButton
Создайте новый пакет с именем com.kilobolt.ui. Внутри создайте новый класс SimpleButton. Мы будем использовать его для простого UI. Просмотрите код ниже, он понятен без дополнительного объяснения.
package com.kilobolt.ui;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.graphics.g2d.TextureRegion;
import com.badlogic.gdx.math.Rectangle;
public class SimpleButton {
private float x, y, width, height;
private TextureRegion buttonUp;
private TextureRegion buttonDown;
private Rectangle bounds;
private boolean isPressed = false;
public SimpleButton(float x, float y, float width, float height,
TextureRegion buttonUp, TextureRegion buttonDown) {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
this.buttonUp = buttonUp;
this.buttonDown = buttonDown;
bounds = new Rectangle(x, y, width, height);
}
public boolean isClicked(int screenX, int screenY) {
return bounds.contains(screenX, screenY);
}
public void draw(SpriteBatch batcher) {
if (isPressed) {
batcher.draw(buttonDown, x, y, width, height);
} else {
batcher.draw(buttonUp, x, y, width, height);
}
}
public boolean isTouchDown(int screenX, int screenY) {
if (bounds.contains(screenX, screenY)) {
isPressed = true;
return true;
}
return false;
}
public boolean isTouchUp(int screenX, int screenY) {
// Мы будем учитывать только touchUp в нажатом состоянии.
if (bounds.contains(screenX, screenY) && isPressed) {
isPressed = false;
return true;
}
// Когда палец с кнопки уберут, мы очистим флаг, что кнопка нажатая.
isPressed = false;
return false;
}
}
Обновите AssetLoader
Так как мы обновили старую текстуру и добавили новую картинку, нам надо внести эти изменения в наш AssetLoader:
package com.kilobolt.ZBHelpers;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Preferences;
import com.badlogic.gdx.audio.Sound;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.Texture.TextureFilter;
import com.badlogic.gdx.graphics.g2d.Animation;
import com.badlogic.gdx.graphics.g2d.BitmapFont;
import com.badlogic.gdx.graphics.g2d.TextureRegion;
public class AssetLoader {
public static Texture texture, logoTexture;
public static TextureRegion logo, zbLogo, bg, grass, bird, birdDown,
birdUp, skullUp, skullDown, bar, playButtonUp, playButtonDown;
public static Animation birdAnimation;
public static Sound dead, flap, coin;
public static BitmapFont font, shadow;
private static Preferences prefs;
public static void load() {
logoTexture = new Texture(Gdx.files.internal("data/logo.png"));
logoTexture.setFilter(TextureFilter.Linear, TextureFilter.Linear);
logo = new TextureRegion(logoTexture, 0, 0, 512, 114);
texture = new Texture(Gdx.files.internal("data/texture.png"));
texture.setFilter(TextureFilter.Nearest, TextureFilter.Nearest);
playButtonUp = new TextureRegion(texture, 0, 83, 29, 16);
playButtonDown = new TextureRegion(texture, 29, 83, 29, 16);
playButtonUp.flip(false, true);
playButtonDown.flip(false, true);
zbLogo = new TextureRegion(texture, 0, 55, 135, 24);
zbLogo.flip(false, true);
bg = new TextureRegion(texture, 0, 0, 136, 43);
bg.flip(false, true);
grass = new TextureRegion(texture, 0, 43, 143, 11);
grass.flip(false, true);
birdDown = new TextureRegion(texture, 136, 0, 17, 12);
birdDown.flip(false, true);
bird = new TextureRegion(texture, 153, 0, 17, 12);
bird.flip(false, true);
birdUp = new TextureRegion(texture, 170, 0, 17, 12);
birdUp.flip(false, true);
TextureRegion[] birds = { birdDown, bird, birdUp };
birdAnimation = new Animation(0.06f, birds);
birdAnimation.setPlayMode(Animation.LOOP_PINGPONG);
skullUp = new TextureRegion(texture, 192, 0, 24, 14);
skullDown = new TextureRegion(skullUp);
skullDown.flip(false, true);
bar = new TextureRegion(texture, 136, 16, 22, 3);
bar.flip(false, true);
dead = Gdx.audio.newSound(Gdx.files.internal("data/dead.wav"));
flap = Gdx.audio.newSound(Gdx.files.internal("data/flap.wav"));
coin = Gdx.audio.newSound(Gdx.files.internal("data/coin.wav"));
font = new BitmapFont(Gdx.files.internal("data/text.fnt"));
font.setScale(.25f, -.25f);
shadow = new BitmapFont(Gdx.files.internal("data/shadow.fnt"));
shadow.setScale(.25f, -.25f);
prefs = Gdx.app.getPreferences("ZombieBird");
if (!prefs.contains("highScore")) {
prefs.putInteger("highScore", 0);
}
}
public static void setHighScore(int val) {
prefs.putInteger("highScore", val);
prefs.flush();
}
public static int getHighScore() {
return prefs.getInteger("highScore");
}
public static void dispose() {
texture.dispose();
dead.dispose();
flap.dispose();
coin.dispose();
font.dispose();
shadow.dispose();
}
}
TweenEngine
Мы добавили библиотеку Tween Engine в наш проект. Давайте разберемся, зачем она нужна.
Tween Engine позволяет производить математическую интерполяцию между первым значением и вторым.
Например, у нас есть float перменная x со значением 0. Я хочу экспоненциально изменить это значение на 1 (постепенно увеличивая от 0 до 1 с увеличением скорости изменения). Давайте еще это сделаем точно за 2,8 секунды.
Это как раз то, что Tween Engine умеет.
В общем смысле Tween Engine работает следующим образом:
У вас есть класс Point, у которого значения такие по умолчанию:
float x = 0;
float y = 0;
Чтобы использовать Tween Engine, чтобы математически интерполировать х в 1, а y в 5, вам надо будет создать TweenAccessor с именем PointAccessor.
У этого класса будет два ваших метода. Первый метод это getter. Он получает все параметры, которые вы хотите изменить в объекте Point и сохраняет их внутри массива.
Далее, Tween Engine получит эти значения и изменит их. Измененные значения мы получим из второго метода setter, в котором вы сможете передать в ваш объект Point.
Давайте рассмотрим пример.
Создайте новый пакет и назовите его com.kilobolt.TweenAccessors и создайте класс SpriteAccessor:
package com.kilobolt.TweenAccessors;
import aurelienribon.tweenengine.TweenAccessor;
import com.badlogic.gdx.graphics.g2d.Sprite;
public class SpriteAccessor implements TweenAccessor<Sprite> {
public static final int ALPHA = 1;
@Override
public int getValues(Sprite target, int tweenType, float[] returnValues) {
switch (tweenType) {
case ALPHA:
returnValues[0] = target.getColor().a;
return 1;
default:
return 0;
}
}
@Override
public void setValues(Sprite target, int tweenType, float[] newValues) {
switch (tweenType) {
case ALPHA:
target.setColor(1, 1, 1, newValues[0]);
break;
}
}
}
Класс выше это реализация TweenAccessor для класса Sprite. Как я упоминал ранее, любые классы, которые вы хотите изменять с помощью TweenEngine должны иметь свой собственный Accessor.
Наш TweenAccessor изменяет всего одно значение (прозрачность). Если нам нужно изменить больше параметров, мы создадим больше констант, чтобы обозначить другие параметры которые наш Accessor способен менять (к примеру угол поворота).
Все TweenAccessor’ы должны иметь два метода: getValues и setValues, каждый из них нацелен на специфический класс для изменений, в нашем случае это Sprite.
Ваша роль в создании TweenAccessor очень простая: 1. Получить значения, которые вы хотите изменить из вашего объекта и поместить их в массив. Пусть Tween Engine делает свою работу. 2. Далее, вы получаете измененные значения и передаете их в свой объект.
Давайте рассмотрим, как методы ниже выполняют свои роли:
1. В getValues методе, вы должны получить все значения, которые вы хотите изменить в объекте типа Sprite и сохранить их в массив с именем returnValues. В нашем случае мы меняем только одно значение, так что мы можем созранить его за первым индексом массива returnValues:
returnValues[0]
Далее, это значение будет изменено автоматом с помощью логики TweenEngine.
2. После этой магии, изменное значение передается в метод setValues (в том же порядке что вы и оставили в методе getValues). Что-либо вы поместили в returnValues[0] теперь доступно в newValues[0]. Далее вы просто передаете это значение в объект типа Sprite.
Эти методы вызываются автоматически. Вам необходимо передать только начальное значение, и значение, к которому его необходимо привести. Думаю все написанное будет иметь больше смысла когда мы увидите Accessor в действии.
Создайте новый класс SplashScreen внутри com.kilobolt.Screens как показано ниже:
package com.kilobolt.Screens;
import aurelienribon.tweenengine.BaseTween;
import aurelienribon.tweenengine.Tween;
import aurelienribon.tweenengine.TweenCallback;
import aurelienribon.tweenengine.TweenEquations;
import aurelienribon.tweenengine.TweenManager;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Screen;
import com.badlogic.gdx.graphics.GL10;
import com.badlogic.gdx.graphics.g2d.Sprite;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.kilobolt.TweenAccessors.SpriteAccessor;
import com.kilobolt.ZBHelpers.AssetLoader;
import com.kilobolt.ZombieBird.ZBGame;
public class SplashScreen implements Screen {
private TweenManager manager;
private SpriteBatch batcher;
private Sprite sprite;
private ZBGame game;
public SplashScreen(ZBGame game) {
this.game = game;
}
@Override
public void show() {
sprite = new Sprite(AssetLoader.logo);
sprite.setColor(1, 1, 1, 0);
float width = Gdx.graphics.getWidth();
float height = Gdx.graphics.getHeight();
float desiredWidth = width * .7f;
float scale = desiredWidth / sprite.getWidth();
sprite.setSize(sprite.getWidth() * scale, sprite.getHeight() * scale);
sprite.setPosition((width / 2) - (sprite.getWidth() / 2), (height / 2)
- (sprite.getHeight() / 2));
setupTween();
batcher = new SpriteBatch();
}
private void setupTween() {
Tween.registerAccessor(Sprite.class, new SpriteAccessor());
manager = new TweenManager();
TweenCallback cb = new TweenCallback() {
@Override
public void onEvent(int type, BaseTween<?> source) {
game.setScreen(new GameScreen());
}
};
Tween.to(sprite, SpriteAccessor.ALPHA, .8f).target(1)
.ease(TweenEquations.easeInOutQuad).repeatYoyo(1, .4f)
.setCallback(cb).setCallbackTriggers(TweenCallback.COMPLETE)
.start(manager);
}
@Override
public void render(float delta) {
manager.update(delta);
Gdx.gl.glClearColor(1, 1, 1, 1);
Gdx.gl.glClear(GL10.GL_COLOR_BUFFER_BIT);
batcher.begin();
sprite.draw(batcher);
batcher.end();
}
@Override
public void resize(int width, int height) {
}
@Override
public void hide() {
}
@Override
public void pause() {
}
@Override
public void resume() {
}
@Override
public void dispose() {
}
}
Изменим ZBGame
Перед тем как обсудить, что делает наш новый SplashScreen, мы заменим им GameScreen в классе ZBGame, так что теперь первым будет появляться SplashScreen, а не GameScreen. Давайте откроем наш ZBGame и внесем изменения:
package com.kilobolt.ZombieBird;
import com.badlogic.gdx.Game;
import com.kilobolt.Screens.SplashScreen;
import com.kilobolt.ZBHelpers.AssetLoader;
public class ZBGame extends Game {
@Override
public void create() {
AssetLoader.load();
setScreen(new SplashScreen(this));
}
@Override
public void dispose() {
super.dispose();
AssetLoader.dispose();
}
}
Теперь вернитесь в класс SpashScreen.
Первым делом давайте сфокусируемся на методе setupTween, потому как код в этом методе не требует пояснений.
privateTween.registerAccessor(Sprite.class, new SpriteAccessor());
1. Эта строчка регистрирует новый Accessor. Если дословно, то мы говорим, «Я хочу изменять Sprite используя Tween Engine. Вот мой Accessor, я создал его используя твою спецификацию (мы должны иметь методы getValues и setValues)».
manager = new TweenManager();
2. Чтобы Tween Engine заработала, нам нужен TweenManager, в который мы будем передавать в методе render новую delta. Этот менеджер будет производить интерполяцию используя наш SpriteAccessor.
TweenCallback cb = new TweenCallback() {
@Override
public void onEvent(int type, BaseTween<?> source) {
game.setScreen(new GameScreen());
}
};
3. Мы можем создать объект TweenCallback, методы которого будут вызываться, когда Tweening окончено. Мы создадим новый TweenCallback с именем cb, чей метод onEvent (который мы вызовем, когда Tweening закончился), перенаправит нас на GameScreen.
Перед тем как перейти к более важным вещам, давайте посмотрим, что мы пытаемся выполнить. Мы берем наш logo спрайт, выставляем его прозрачность в 0, далее увеличиваем прозрачность до 1 (100%) и возвращаем обратно в 0.
Давайте посмотрим на этот большой кусок кода в деталях:
Tween.to(sprite, SpriteAccessor.ALPHA, .8f).target(1).ease(TweenEquations.easeInOutQuad).repeatYoyo(1, .4f) .setCallback(cb).setCallbackTriggers(TweenCallback.COMPLETE) .start(manager);
Tween.to(sprite, SpriteAccessor.ALPHA, .8f).target(1)
— Мы хотим изменить наш объект типа sprite, используя tweenType ALPHA из нашего SpriteAccessor. Мы хотим, чтобы эта операция длилась .8 секунд. Мы хотим изменить стартовое значение (это указано в классе SpriteAccessor) в новое, равное 1.
.ease(TweenEquations.easeInOutQuad).repeatYoyo(1, .4f)
— Мы хотим использовать квадратичную интерполяцию (вы увидите, что это значит), и повторить это действие один раз как Yoyo (за .4 секунды между повторениями).
.setCallback(cb).setCallbackTriggers(TweenCallback.COMPLETE)
— Используй callback который мы ранее создали и назвали как cb, и уведомь его когда Tweening закончилось.
.start(manager);
— Ну и наконец, мы указываем какой менеджер выполнит всю эту работу.
Теперь перейдите в метод render и посмотрите, что делает менеджер и выполните ваш код.
Возможно, это очень запутанно! И тяжко все это понять сразу, но надо просто поэкспериментировать. Перед тем как продолжить, советую вам поиграться с различными опциями и эффектами.
Нам надо больше TweenAccessor’ов
Теперь, когда вы знаете как TweenAccessors работает, создайте два новых класса внутри пакета com.kilobolt.TweenAccessors.
Первый класс это Value класс, который будет оберткой для float переменных. Мы будем использовать для этого класс, потому что только объекты могут быть использованы в Tween Engine (с примитивами ничего не получится). Так что, чтобы изменить float нам нужен для этого класс.
package com.kilobolt.TweenAccessors;
public class Value {
private float val = 1;
public float getValue() {
return val;
}
public void setValue(float newVal) {
val = newVal;
}
}
Класс ValueAccessor поможет нам изменять val переменную в Value классе:
package com.kilobolt.TweenAccessors;
import aurelienribon.tweenengine.TweenAccessor;
public class ValueAccessor implements TweenAccessor<Value> {
@Override
public int getValues(Value target, int tweenType, float[] returnValues) {
returnValues[0] = target.getValue();
return 1;
}
@Override
public void setValues(Value target, int tweenType, float[] newValues) {
target.setValue(newValues[0]);
}
}
ValueAccessor будет использоваться когда мы захотим интерполировать float переменную. Например, если мы хотим сделать вспышку на экране изменяя прозрачность у квадрата, мы создадим новый обхект типа Value и передадим в обработку нашему ValueAccessor. Фактично, мы используем эту логику чтобы плавно перейти от SpalshScreen к GameScreen.
Внесем несколько изменений в наш класс GameScreen, который состоит из: InputHandler, GameWorld и GameRenderer.
Чтобы не растягивать День 11 до трех раздельных уроков, я объясню только главные изменения в коде. В большинстве случаев, изменения легкие и понятные, и вы точно все поймете когда сами поэкспериментируете.
Начнем с изменений в классе InputHandler:
package com.kilobolt.ZBHelpers;
import java.util.ArrayList;
import java.util.List;
import com.badlogic.gdx.Input.Keys;
import com.badlogic.gdx.InputProcessor;
import com.kilobolt.GameObjects.Bird;
import com.kilobolt.GameWorld.GameWorld;
import com.kilobolt.ui.SimpleButton;
public class InputHandler implements InputProcessor {
private Bird myBird;
private GameWorld myWorld;
private List<SimpleButton> menuButtons;
private SimpleButton playButton;
private float scaleFactorX;
private float scaleFactorY;
public InputHandler(GameWorld myWorld, float scaleFactorX,
float scaleFactorY) {
this.myWorld = myWorld;
myBird = myWorld.getBird();
int midPointY = myWorld.getMidPointY();
this.scaleFactorX = scaleFactorX;
this.scaleFactorY = scaleFactorY;
menuButtons = new ArrayList<SimpleButton>();
playButton = new SimpleButton(
136 / 2 - (AssetLoader.playButtonUp.getRegionWidth() / 2),
midPointY + 50, 29, 16, AssetLoader.playButtonUp,
AssetLoader.playButtonDown);
menuButtons.add(playButton);
}
@Override
public boolean touchDown(int screenX, int screenY, int pointer, int button) {
screenX = scaleX(screenX);
screenY = scaleY(screenY);
System.out.println(screenX + " " + screenY);
if (myWorld.isMenu()) {
playButton.isTouchDown(screenX, screenY);
} else if (myWorld.isReady()) {
myWorld.start();
}
myBird.onClick();
if (myWorld.isGameOver() || myWorld.isHighScore()) {
myWorld.restart();
}
return true;
}
@Override
public boolean touchUp(int screenX, int screenY, int pointer, int button) {
screenX = scaleX(screenX);
screenY = scaleY(screenY);
if (myWorld.isMenu()) {
if (playButton.isTouchUp(screenX, screenY)) {
myWorld.ready();
return true;
}
}
return false;
}
@Override
public boolean keyDown(int keycode) {
if (keycode == Keys.SPACE) {
if (myWorld.isMenu()) {
myWorld.ready();
} else if (myWorld.isReady()) {
myWorld.start();
}
myBird.onClick();
if (myWorld.isGameOver() || myWorld.isHighScore()) {
myWorld.restart();
}
}
return false;
}
@Override
public boolean keyUp(int keycode) {
return false;
}
@Override
public boolean keyTyped(char character) {
return false;
}
@Override
public boolean touchDragged(int screenX, int screenY, int pointer) {
return false;
}
@Override
public boolean mouseMoved(int screenX, int screenY) {
return false;
}
@Override
public boolean scrolled(int amount) {
return false;
}
private int scaleX(int screenX) {
return (int) (screenX / scaleFactorX);
}
private int scaleY(int screenY) {
return (int) (screenY / scaleFactorY);
}
public List<SimpleButton> getMenuButtons() {
return menuButtons;
}
}
Большое изменение для InputHandler заключается в том, что мы будем генерировать кнопки тут. Это не лучший способ, но так как кнопки вплотную зависят от ввода, я создал их тут.
Роль InputHandler заключается в том, чтобы создать Button’ы и обработать взаимодействие с ними, как показано выше.
Я так же создал методы, с помощью которых мы будем масштабировать касания (которые сейчас зависят от размера экрана) к размеру нашего экрана, вне зависимости от ширины и высоты игрового мира. Теперь, координаты касания будут транслированы как координаты GameWorld.
Так же я добавил возможность использовать Spacebar(Пробел) для тех, кто хочет использовать клавиатуру.
Чтобы наши изменения сработали, нам необходимо обновить класс GameScreen. Эти изменения слишком мелкие, так что попробуйте сами их заметить :) (Обратите внимание на измененный вызов renderer.render()).
package com.kilobolt.Screens;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Screen;
import com.kilobolt.GameWorld.GameRenderer;
import com.kilobolt.GameWorld.GameWorld;
import com.kilobolt.ZBHelpers.InputHandler;
public class GameScreen implements Screen {
private GameWorld world;
private GameRenderer renderer;
private float runTime;
public GameScreen() {
float screenWidth = Gdx.graphics.getWidth();
float screenHeight = Gdx.graphics.getHeight();
float gameWidth = 136;
float gameHeight = screenHeight / (screenWidth / gameWidth);
int midPointY = (int) (gameHeight / 2);
world = new GameWorld(midPointY);
Gdx.input.setInputProcessor(new InputHandler(world, screenWidth / gameWidth, screenHeight / gameHeight));
renderer = new GameRenderer(world, (int) gameHeight, midPointY);
}
@Override
public void render(float delta) {
runTime += delta;
world.update(delta);
renderer.render(delta, runTime);
}
@Override
public void resize(int width, int height) {
}
@Override
public void show() {
}
@Override
public void hide() {
}
@Override
public void pause() {
}
@Override
public void resume() {
}
@Override
public void dispose() {
}
}
В классе GameWorld мы так же внесли мелкие изменения (обратите внимание на GameState и несколько новых методов):
package com.kilobolt.GameWorld;
import com.badlogic.gdx.math.Intersector;
import com.badlogic.gdx.math.Rectangle;
import com.kilobolt.GameObjects.Bird;
import com.kilobolt.GameObjects.ScrollHandler;
import com.kilobolt.ZBHelpers.AssetLoader;
public class GameWorld {
private Bird bird;
private ScrollHandler scroller;
private Rectangle ground;
private int score = 0;
private float runTime = 0;
private int midPointY;
private GameState currentState;
public enum GameState {
MENU, READY, RUNNING, GAMEOVER, HIGHSCORE
}
public GameWorld(int midPointY) {
currentState = GameState.MENU;
this.midPointY = midPointY;
bird = new Bird(33, midPointY - 5, 17, 12);
scroller = new ScrollHandler(this, midPointY + 66);
ground = new Rectangle(0, midPointY + 66, 137, 11);
}
public void update(float delta) {
runTime += delta;
switch (currentState) {
case READY:
case MENU:
updateReady(delta);
break;
case RUNNING:
updateRunning(delta);
break;
default:
break;
}
}
private void updateReady(float delta) {
bird.updateReady(runTime);
scroller.updateReady(delta);
}
public void updateRunning(float delta) {
if (delta > .15f) {
delta = .15f;
}
bird.update(delta);
scroller.update(delta);
if (scroller.collides(bird) && bird.isAlive()) {
scroller.stop();
bird.die();
AssetLoader.dead.play();
}
if (Intersector.overlaps(bird.getBoundingCircle(), ground)) {
scroller.stop();
bird.die();
bird.decelerate();
currentState = GameState.GAMEOVER;
if (score > AssetLoader.getHighScore()) {
AssetLoader.setHighScore(score);
currentState = GameState.HIGHSCORE;
}
}
}
public Bird getBird() {
return bird;
}
public int getMidPointY() {
return midPointY;
}
public ScrollHandler getScroller() {
return scroller;
}
public int getScore() {
return score;
}
public void addScore(int increment) {
score += increment;
}
public void start() {
currentState = GameState.RUNNING;
}
public void ready() {
currentState = GameState.READY;
}
public void restart() {
currentState = GameState.READY;
score = 0;
bird.onRestart(midPointY - 5);
scroller.onRestart();
currentState = GameState.READY;
}
public boolean isReady() {
return currentState == GameState.READY;
}
public boolean isGameOver() {
return currentState == GameState.GAMEOVER;
}
public boolean isHighScore() {
return currentState == GameState.HIGHSCORE;
}
public boolean isMenu() {
return currentState == GameState.MENU;
}
public boolean isRunning() {
return currentState == GameState.RUNNING;
}
}
Осталось внести мелкие изменения в классы Bird и ScrollHandler:
package com.kilobolt.GameObjects;
import com.badlogic.gdx.math.Circle;
import com.badlogic.gdx.math.Vector2;
import com.kilobolt.ZBHelpers.AssetLoader;
public class Bird {
private Vector2 position;
private Vector2 velocity;
private Vector2 acceleration;
private float rotation;
private int width;
private float height;
private float originalY;
private boolean isAlive;
private Circle boundingCircle;
public Bird(float x, float y, int width, int height) {
this.width = width;
this.height = height;
this.originalY = y;
position = new Vector2(x, y);
velocity = new Vector2(0, 0);
acceleration = new Vector2(0, 460);
boundingCircle = new Circle();
isAlive = true;
}
public void update(float delta) {
velocity.add(acceleration.cpy().scl(delta));
if (velocity.y > 200) {
velocity.y = 200;
}
if (position.y < -13) {
position.y = -13;
velocity.y = 0;
}
position.add(velocity.cpy().scl(delta));
boundingCircle.set(position.x + 9, position.y + 6, 6.5f);
if (velocity.y < 0) {
rotation -= 600 * delta;
if (rotation < -20) {
rotation = -20;
}
}
if (isFalling() || !isAlive) {
rotation += 480 * delta;
if (rotation > 90) {
rotation = 90;
}
}
}
public void updateReady(float runTime) {
position.y = 2 * (float) Math.sin(7 * runTime) + originalY;
}
public boolean isFalling() {
return velocity.y > 110;
}
public boolean shouldntFlap() {
return velocity.y > 70 || !isAlive;
}
public void onClick() {
if (isAlive) {
AssetLoader.flap.play();
velocity.y = -140;
}
}
public void die() {
isAlive = false;
velocity.y = 0;
}
public void decelerate() {
acceleration.y = 0;
}
public void onRestart(int y) {
rotation = 0;
position.y = y;
velocity.x = 0;
velocity.y = 0;
acceleration.x = 0;
acceleration.y = 460;
isAlive = true;
}
public float getX() {
return position.x;
}
public float getY() {
return position.y;
}
public float getWidth() {
return width;
}
public float getHeight() {
return height;
}
public float getRotation() {
return rotation;
}
public Circle getBoundingCircle() {
return boundingCircle;
}
public boolean isAlive() {
return isAlive;
}
}
package com.kilobolt.GameObjects;
import com.kilobolt.GameWorld.GameWorld;
import com.kilobolt.ZBHelpers.AssetLoader;
public class ScrollHandler {
private Grass frontGrass, backGrass;
private Pipe pipe1, pipe2, pipe3;
public static final int SCROLL_SPEED = -59;
public static final int PIPE_GAP = 49;
private GameWorld gameWorld;
public ScrollHandler(GameWorld gameWorld, float yPos) {
this.gameWorld = gameWorld;
frontGrass = new Grass(0, yPos, 143, 11, SCROLL_SPEED);
backGrass = new Grass(frontGrass.getTailX(), yPos, 143, 11,
SCROLL_SPEED);
pipe1 = new Pipe(210, 0, 22, 60, SCROLL_SPEED, yPos);
pipe2 = new Pipe(pipe1.getTailX() + PIPE_GAP, 0, 22, 70, SCROLL_SPEED,
yPos);
pipe3 = new Pipe(pipe2.getTailX() + PIPE_GAP, 0, 22, 60, SCROLL_SPEED,
yPos);
}
public void updateReady(float delta) {
frontGrass.update(delta);
backGrass.update(delta);
if (frontGrass.isScrolledLeft()) {
frontGrass.reset(backGrass.getTailX());
} else if (backGrass.isScrolledLeft()) {
backGrass.reset(frontGrass.getTailX());
}
}
public void update(float delta) {
frontGrass.update(delta);
backGrass.update(delta);
pipe1.update(delta);
pipe2.update(delta);
pipe3.update(delta);
if (pipe1.isScrolledLeft()) {
pipe1.reset(pipe3.getTailX() + PIPE_GAP);
} else if (pipe2.isScrolledLeft()) {
pipe2.reset(pipe1.getTailX() + PIPE_GAP);
} else if (pipe3.isScrolledLeft()) {
pipe3.reset(pipe2.getTailX() + PIPE_GAP);
}
if (frontGrass.isScrolledLeft()) {
frontGrass.reset(backGrass.getTailX());
} else if (backGrass.isScrolledLeft()) {
backGrass.reset(frontGrass.getTailX());
}
}
public void stop() {
frontGrass.stop();
backGrass.stop();
pipe1.stop();
pipe2.stop();
pipe3.stop();
}
public boolean collides(Bird bird) {
if (!pipe1.isScored()
&& pipe1.getX() + (pipe1.getWidth() / 2) < bird.getX()
+ bird.getWidth()) {
addScore(1);
pipe1.setScored(true);
AssetLoader.coin.play();
} else if (!pipe2.isScored()
&& pipe2.getX() + (pipe2.getWidth() / 2) < bird.getX()
+ bird.getWidth()) {
addScore(1);
pipe2.setScored(true);
AssetLoader.coin.play();
} else if (!pipe3.isScored()
&& pipe3.getX() + (pipe3.getWidth() / 2) < bird.getX()
+ bird.getWidth()) {
addScore(1);
pipe3.setScored(true);
AssetLoader.coin.play();
}
return (pipe1.collides(bird) || pipe2.collides(bird) || pipe3
.collides(bird));
}
private void addScore(int increment) {
gameWorld.addScore(increment);
}
public Grass getFrontGrass() {
return frontGrass;
}
public Grass getBackGrass() {
return backGrass;
}
public Pipe getPipe1() {
return pipe1;
}
public Pipe getPipe2() {
return pipe2;
}
public Pipe getPipe3() {
return pipe3;
}
public void onRestart() {
frontGrass.onRestart(0, SCROLL_SPEED);
backGrass.onRestart(frontGrass.getTailX(), SCROLL_SPEED);
pipe1.onRestart(210, SCROLL_SPEED);
pipe2.onRestart(pipe1.getTailX() + PIPE_GAP, SCROLL_SPEED);
pipe3.onRestart(pipe2.getTailX() + PIPE_GAP, SCROLL_SPEED);
}
}
Самое большое количество изменений выпало на душу класса GameRenderer, но опять-таки, они все мелкие.
package com.kilobolt.GameWorld;
import java.util.List;
import aurelienribon.tweenengine.Tween;
import aurelienribon.tweenengine.TweenEquations;
import aurelienribon.tweenengine.TweenManager;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.GL10;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.graphics.g2d.Animation;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.graphics.g2d.TextureRegion;
import com.badlogic.gdx.graphics.glutils.ShapeRenderer;
import com.badlogic.gdx.graphics.glutils.ShapeRenderer.ShapeType;
import com.kilobolt.GameObjects.Bird;
import com.kilobolt.GameObjects.Grass;
import com.kilobolt.GameObjects.Pipe;
import com.kilobolt.GameObjects.ScrollHandler;
import com.kilobolt.TweenAccessors.Value;
import com.kilobolt.TweenAccessors.ValueAccessor;
import com.kilobolt.ZBHelpers.AssetLoader;
import com.kilobolt.ZBHelpers.InputHandler;
import com.kilobolt.ui.SimpleButton;
public class GameRenderer {
private GameWorld myWorld;
private OrthographicCamera cam;
private ShapeRenderer shapeRenderer;
private SpriteBatch batcher;
private int midPointY;
private Bird bird;
private ScrollHandler scroller;
private Grass frontGrass, backGrass;
private Pipe pipe1, pipe2, pipe3;
private TextureRegion bg, grass, birdMid, skullUp, skullDown, bar;
private Animation birdAnimation;
private TweenManager manager;
private Value alpha = new Value();
private List<SimpleButton> menuButtons;
public GameRenderer(GameWorld world, int gameHeight, int midPointY) {
myWorld = world;
this.midPointY = midPointY;
this.menuButtons = ((InputHandler) Gdx.input.getInputProcessor())
.getMenuButtons();
cam = new OrthographicCamera();
cam.setToOrtho(true, 136, gameHeight);
batcher = new SpriteBatch();
batcher.setProjectionMatrix(cam.combined);
shapeRenderer = new ShapeRenderer();
shapeRenderer.setProjectionMatrix(cam.combined);
initGameObjects();
initAssets();
setupTweens();
}
private void setupTweens() {
Tween.registerAccessor(Value.class, new ValueAccessor());
manager = new TweenManager();
Tween.to(alpha, -1, .5f).target(0).ease(TweenEquations.easeOutQuad)
.start(manager);
}
private void initGameObjects() {
bird = myWorld.getBird();
scroller = myWorld.getScroller();
frontGrass = scroller.getFrontGrass();
backGrass = scroller.getBackGrass();
pipe1 = scroller.getPipe1();
pipe2 = scroller.getPipe2();
pipe3 = scroller.getPipe3();
}
private void initAssets() {
bg = AssetLoader.bg;
grass = AssetLoader.grass;
birdAnimation = AssetLoader.birdAnimation;
birdMid = AssetLoader.bird;
skullUp = AssetLoader.skullUp;
skullDown = AssetLoader.skullDown;
bar = AssetLoader.bar;
}
private void drawGrass() {
batcher.draw(grass, frontGrass.getX(), frontGrass.getY(),
frontGrass.getWidth(), frontGrass.getHeight());
batcher.draw(grass, backGrass.getX(), backGrass.getY(),
backGrass.getWidth(), backGrass.getHeight());
}
private void drawSkulls() {
batcher.draw(skullUp, pipe1.getX() - 1,
pipe1.getY() + pipe1.getHeight() - 14, 24, 14);
batcher.draw(skullDown, pipe1.getX() - 1,
pipe1.getY() + pipe1.getHeight() + 45, 24, 14);
batcher.draw(skullUp, pipe2.getX() - 1,
pipe2.getY() + pipe2.getHeight() - 14, 24, 14);
batcher.draw(skullDown, pipe2.getX() - 1,
pipe2.getY() + pipe2.getHeight() + 45, 24, 14);
batcher.draw(skullUp, pipe3.getX() - 1,
pipe3.getY() + pipe3.getHeight() - 14, 24, 14);
batcher.draw(skullDown, pipe3.getX() - 1,
pipe3.getY() + pipe3.getHeight() + 45, 24, 14);
}
private void drawPipes() {
batcher.draw(bar, pipe1.getX(), pipe1.getY(), pipe1.getWidth(),
pipe1.getHeight());
batcher.draw(bar, pipe1.getX(), pipe1.getY() + pipe1.getHeight() + 45,
pipe1.getWidth(), midPointY + 66 - (pipe1.getHeight() + 45));
batcher.draw(bar, pipe2.getX(), pipe2.getY(), pipe2.getWidth(),
pipe2.getHeight());
batcher.draw(bar, pipe2.getX(), pipe2.getY() + pipe2.getHeight() + 45,
pipe2.getWidth(), midPointY + 66 - (pipe2.getHeight() + 45));
batcher.draw(bar, pipe3.getX(), pipe3.getY(), pipe3.getWidth(),
pipe3.getHeight());
batcher.draw(bar, pipe3.getX(), pipe3.getY() + pipe3.getHeight() + 45,
pipe3.getWidth(), midPointY + 66 - (pipe3.getHeight() + 45));
}
private void drawBirdCentered(float runTime) {
batcher.draw(birdAnimation.getKeyFrame(runTime), 59, bird.getY() - 15,
bird.getWidth() / 2.0f, bird.getHeight() / 2.0f,
bird.getWidth(), bird.getHeight(), 1, 1, bird.getRotation());
}
private void drawBird(float runTime) {
if (bird.shouldntFlap()) {
batcher.draw(birdMid, bird.getX(), bird.getY(),
bird.getWidth() / 2.0f, bird.getHeight() / 2.0f,
bird.getWidth(), bird.getHeight(), 1, 1, bird.getRotation());
} else {
batcher.draw(birdAnimation.getKeyFrame(runTime), bird.getX(),
bird.getY(), bird.getWidth() / 2.0f,
bird.getHeight() / 2.0f, bird.getWidth(), bird.getHeight(),
1, 1, bird.getRotation());
}
}
private void drawMenuUI() {
batcher.draw(AssetLoader.zbLogo, 136 / 2 - 56, midPointY - 50,
AssetLoader.zbLogo.getRegionWidth() / 1.2f,
AssetLoader.zbLogo.getRegionHeight() / 1.2f);
for (SimpleButton button : menuButtons) {
button.draw(batcher);
}
}
private void drawScore() {
int length = ("" + myWorld.getScore()).length();
AssetLoader.shadow.draw(batcher, "" + myWorld.getScore(),
68 - (3 * length), midPointY - 82);
AssetLoader.font.draw(batcher, "" + myWorld.getScore(),
68 - (3 * length), midPointY - 83);
}
public void render(float delta, float runTime) {
Gdx.gl.glClearColor(0, 0, 0, 1);
Gdx.gl.glClear(GL10.GL_COLOR_BUFFER_BIT);
shapeRenderer.begin(ShapeType.Filled);
shapeRenderer.setColor(55 / 255.0f, 80 / 255.0f, 100 / 255.0f, 1);
shapeRenderer.rect(0, 0, 136, midPointY + 66);
shapeRenderer.setColor(111 / 255.0f, 186 / 255.0f, 45 / 255.0f, 1);
shapeRenderer.rect(0, midPointY + 66, 136, 11);
shapeRenderer.setColor(147 / 255.0f, 80 / 255.0f, 27 / 255.0f, 1);
shapeRenderer.rect(0, midPointY + 77, 136, 52);
shapeRenderer.end();
batcher.begin();
batcher.disableBlending();
batcher.draw(bg, 0, midPointY + 23, 136, 43);
drawGrass();
drawPipes();
batcher.enableBlending();
drawSkulls();
if (myWorld.isRunning()) {
drawBird(runTime);
drawScore();
} else if (myWorld.isReady()) {
drawBird(runTime);
drawScore();
} else if (myWorld.isMenu()) {
drawBirdCentered(runTime);
drawMenuUI();
} else if (myWorld.isGameOver()) {
drawBird(runTime);
drawScore();
} else if (myWorld.isHighScore()) {
drawBird(runTime);
drawScore();
}
batcher.end();
drawTransition(delta);
}
private void drawTransition(float delta) {
if (alpha.getValue() > 0) {
manager.update(delta);
Gdx.gl.glEnable(GL10.GL_BLEND);
Gdx.gl.glBlendFunc(GL10.GL_SRC_ALPHA, GL10.GL_ONE_MINUS_SRC_ALPHA);
shapeRenderer.begin(ShapeType.Filled);
shapeRenderer.setColor(1, 1, 1, alpha.getValue());
shapeRenderer.rect(0, 0, 136, 300);
shapeRenderer.end();
Gdx.gl.glDisable(GL10.GL_BLEND);
}
}
}
Конечно, осталось еще много работы по улучшению нашего UI. И вот как мы поступим. Я выложу код примера для законченной игры как День 12, этот пример будет включать в себя законченный UI. И это будет окончание Секции 1, цель которой была скопировать поведение Flappy Bird.
Исходный код за день
Если вы вне настроения писать код самостоятельно, скачайте его отсюда:
day_11.zip
День 12 — Конечный вариант UI и Исходный код
Добро пожаловать в День 12. Ниже вы найдете финальный вариант кода, который включает в себя следующее:
- Законченный UI
- Добавлен упрощенный способ применения транзиций
- Добавлен экран Game Over
- Добавлено отображение рейтинга в виде звезд на экране Game Over
- Добавлен новый звуковой эффект (падение птички)
- Изменена логика для звуковых эффектов
- Исправлены проблемы с громкостью
- Многочисленные улучшения в коде
Так как я не добавил новых концептов, День 12 не будет пояснять перечисленные изменения. Просмотрите код и если у вас появятся вопросы, задайте их мне!
Исходный код за день
Скачайте последнюю версию примера. Распакуйте архив и импортируйте проект в Eclipse.
day12.zip
Так же тут вы можете поиграться с тем, что у нас получилось.
Автор: eliotik