Вторник, 2024-03-19, 4:18 PM

Поиск
Меню
Категории раздела
Поддержи проект!
Рекомендуем


Создание простой игры — часть 1

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


Будет рассмотрено:

  • Простейший доступ к файлам
  • Очистка экрана
  • Отрисовка изображений
  • Использование камеры
  • Основы обработки ввода
  • Воспроизведение звуковых эффектов


Настройка проекта

Следуйте инструкциям по настройке, запуску и отладке проекта. Будут использованы следующие имена:

  • Имя приложения: drop
  • Имя пакета: com.badlogic.drop
  • Game класс: Drop
После импорта в Eclipse вы получите 4 проекта: drop, drop-android, drop-desktop и drop-html5.


Игра

Идея игры очень простая:

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


Активы (Assets)

Для того чтобы игра выглядела хорошо, нужно несколько изображений и звуковых эффектов. Для графики будет использовано разрешение 800×480 пикселей (ландшафтная ориентация на Android). Игра запуститься на устройстве с другим разрешением экрана, то все просто масштабируется по границам экрана. 

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

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

Активы взяты из следующих источников:

Для того, чтобы активы были доступны в игре, их нужно поместить в папку assets проекта Android. Имена файлов следующие: drop.wav, rain.mp3, droplet.png и bucket.png. Поместите их в директорию drop-android/assets/. Проекты Desktop и HTML5 будут ссылаться на эту директорию, поэтому можно иметь только один набор активов.


Настройка Starter классов

Выполнив необходимые требования можно настроить Starter классы. Начнем с Desktop проекта. Откройте класс Main.java в директории drop-desktop/. Мы хотим, чтобы размер окна был 800×480 пикселей и заголовок был «Drop». Код должен выглядеть следующим образом:

package com.badlogic.drop;

import com.badlogic.gdx.backends.lwjgl.LwjglApplication;
import com.badlogic.gdx.backends.lwjgl.LwjglApplicationConfiguration;

public class Main {
   public static void main(String[] args) {
      LwjglApplicationConfiguration cfg = new LwjglApplicationConfiguration();
      cfg.title = "Drop";
      cfg.width = 800;
      cfg.height = 480;
      new LwjglApplication(new Drop(), cfg);
   }
}

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

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.badlogic.drop"
    android:versionCode="1"
    android:versionName="1.0" >

    <uses-sdk android:minSdkVersion="5" android:targetSdkVersion="15" />

    <application
       android:icon="@drawable/ic_launcher"
       android:label="@string/app_name" >
       <activity
            android:name=".MainActivity"
            android:label="@string/app_name"
            android:screenOrientation="landscape"
            android:configChanges="keyboard|keyboardHidden|orientation">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
       </activity>
    </application>

</manifest>

Программа установки уже заполнила для нас правильные значения, атрибут android:screenOrientation выставлен в «landscape». Если бы мы хотели, чтобы игра запускалась в портретном режиме, то нужно это атрибут установить в «portrait».

Мы также хотим экономить заряд батареи и отключить акселерометр и компас. Мы делаем это в файле MainActivity.java Android проекта, который выглядит примерно так:

package com.badlogic.drop;

import android.os.Bundle;

import com.badlogic.gdx.backends.android.AndroidApplication;
import com.badlogic.gdx.backends.android.AndroidApplicationConfiguration;

public class MainActivity extends AndroidApplication {
   @Override
   public void onCreate(Bundle savedInstanceState) {
      super.onCreate(savedInstanceState);
       
      AndroidApplicationConfiguration cfg = new AndroidApplicationConfiguration();
      cfg.useAccelerometer = false;
      cfg.useCompass = false;
       
      initialize(new Drop(), cfg);
   }
}

Мы не можем задать разрешение экрана Activity, так как оно устанавливается операционной системой Android. Как мы уже говорили ранее, мы просто масштабируем разрешение 800×480 до размеров экрана устройства.

Наконец, мы хотим убедиться, что HTML5 проект тоже использует область рисования размером 800×480 пикселей. Для этого нужно изменить файл GwtLauncher.java в проекте HTML5.

package com.badlogic.drop.client;

import com.badlogic.drop.Drop;

public class GwtLauncher extends GwtApplication {
   @Override
   public GwtApplicationConfiguration getConfig () {
      GwtApplicationConfiguration cfg = new GwtApplicationConfiguration(800, 480);
      return cfg;
   }

   @Override
   public ApplicationListener getApplicationListener () {
      return new Drop();
   }
}

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

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


Код

Мы разделим наш код на несколько частей. Для простоты мы будем держать все в файле Drop.java основного проекта.


Загрузка активов

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

public class Drop implements ApplicationListener {
   Texture dropImage;
   Texture bucketImage;
   Sound dropSound;
   Music rainMusic;
   
   @Override
   public void create() {
      // Загрузка изображений капли и ведра, каждое размером 64x64 пикселей
      dropImage = new Texture(Gdx.files.internal("droplet.png"));
      bucketImage = new Texture(Gdx.files.internal("bucket.png"));
      
      // Загрузка звукового эффекта падающей капли и фоновой "музыки" дождя 
      dropSound = Gdx.audio.newSound(Gdx.files.internal("drop.wav"));
      rainMusic = Gdx.audio.newMusic(Gdx.files.internal("rain.mp3"));
      
      // Сразу же воспроизводиться музыка для фона
      rainMusic.setLooping(true);
      rainMusic.play();

      ... еще не все ...
   }

   // Остальная часть опущена для ясности

Для каждого актива есть поля в классе Drop, чтобы мы могли позже ссылаться на них. Первые две строки в методе create загружают изображения капли и ведра. Texture представляет загруженное изображение, которое храниться в видеопамяти. Texture загружается передачей в конструктор FileHandle соответствующего файла актива. Такие экземпляры FileHandle можно получить с помощью одного из методов Gdx.files. Существует различные типы файлов, мы используем внутренний (internal) тип файла для ссылки на актив. Внутренние файлы располагаются в директории assets Android проекта. Desktop и HTML5 проекты ссылаются на ту же директорию через связь в Eclipse.

В следующем шаге мы загружаем звуковой эффект и музыку для фона. Libgdx различает звуковые эффекты, которые хранятся в памяти, и музыку, которая воспроизводиться как поток из места ее расположения. Музыка обычно слишком объемная, чтобы полностью хранить ее в памяти, отсюда вытекают различия. Как правило, вы должны использовать экземпляр класса Sound, если продолжительность меньше чем 10 секунд, и экземпляр класса Music для более продолжительных сэмплов.

Загрузка экземпляров Sound и Music осуществляется через Gdx.app.newSound() и Gdx.app.newMusic(). Оба метода принимают FileHandle, так же как и конструктор Texture.

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


Камера и SpriteBatch

Далее мы создадим камеру и SpriteBatch. Мы используем такое же разрешение, чтобы убедиться в том, что можем делать визуализацию с разрешением 800×480, независимо от фактического разрешения экрана. SpriteBatch - это специальный класс, который используется для рисования 2D изображений, таких как текстуры, которые мы загрузили.

Мы добавим два новых свойства в классе и назовем их camera и batch

OrthographicCamera camera;
SpriteBatch batch;

В методе create() мы сначала создадим камеру, следующем образом:
camera = new OrthographicCamera();
camera.setToOrtho(false, 800, 480);

Это позволяет убедиться в том, что камера всегда показывает область мира игры, которая размером 800×480 единиц. Думайте об этом как о виртуальном окне в наш мир. В настоящее время мы интерпретировали единицы как пиксели, для облегчения жизни. Камера является очень мощным механизмом и позволяет делать очень много разных вещей, которые мы не будем рассматривать в этой базовой статье. Для получения дополнительной информации смотрите руководство разработчика libgdx.


Необходимо в том же методе create() создать SpriteBatch:

batch = new SpriteBatch();

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


Добавляем ведро

Пока еще отсутствуют такие сущности, как ведро и капли. Давайте подумаем о том, что нам нужно для их представить их в коде.

  • Ведро и капля имеют x и y координаты в мире 800 на 480.
  • Ведро и капля имеют ширину и высоту, выраженные в единицах нашего мира.
  • Ведро и капля имеют графическое представление, у нас оно уже есть в форме экземпляров Texture.
Для описания ведра и капли необходимо сохранить их позицию и размер. Libgdx предоставляет класс Rectangle, который можно использовать для этой цели. Давайте начнем с создания Rectangle, который будет представлять ведро. Добавим новое свойство:
Rectangle bucket;

В методе create() создается Rectangle и указываются начальные значения. Мы хотим, чтобы ведро было на 20 пикселей выше нижней границы экрана и центровано по горизонтали.
bucket = new Rectangle();
bucket.x = 800 / 2 - 64 / 2;
bucket.y = 20;
bucket.width = 64;
bucket.height = 64;

Мы центруем ведро по горизонтали и размещаем на 20 пикселей выше нижней границы экрана. Возникает вопрос — почему координата bucket.y установлена в 20, разве она не должна рассчитываться как 480-20? По умолчанию, вся визуализация в libgdx (и в OpengGL) осуществляет вверх по Y-оси. Координаты x/y bucket определяют нижний левый угол ведра, нулевые координаты находятся в нижнем левом углу. Ширина и высота прямоугольника устанавливается в 64×64.

Примечание: Можно изменить эти установки, чтобы Y-ось уходила вниз и начальные координаты были в верхнем левом углу экрана. OpenGL и камера настолько гибки, что можно иметь угол обзора какой вы захотите, в 2D и в 3D.


Визуализация ведра

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

@Override
public void render() {
   Gdx.gl.glClearColor(0, 0, 0.2f, 1);
   Gdx.gl.glClear(GL10.GL_COLOR_BUFFER_BIT);

   ... еще не все ...
}

Эти две строки кода, то единственное, что вам нужно знать о OpenGL, если вы используете классы высокого уровня, такие как Texture и SpriteBatch. Первый вызов установит цвет очистки в синий цвет. Аргументами являются красный, зеленый, синий и альфа-компонент цвета, каждый в диапазоне [0, 1]. Следующий вызов заставляет OpenGL очистить экран.

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

camera.update();


Теперь можно нарисовать ведро:

batch.setProjectionMatrix(camera.combined);
batch.begin();
batch.draw(bucketImage, bucket.x, bucket.y);
batch.end();

В первой строке мы сообщаем SpriteBatch, что требуется использовать систему координат камеры. Как отмечалось ранее, это делается с помощью, так называемой, матрицы, а если быть более точным, то матрицей проекции. Поле camera.combined является такой матрицей. SpriteBatch нарисует все что будет находиться в заданных координатах.

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

В этом OpenGL помогает класс SpriteBatch. Он будет накапливать все команды рисования между SpriteBatch.begin() и SpriteBatch.end(). Как только мы вызываем метод SpriteBatch.end(), он выдаст все накопленные запросы рисования, повышая скорость визуализации. Все это может выглядеть непонятным вначале, но это то, что обеспечивает разницу между рисованием 500 спрайтов при 60 кадрах в секунду и рисованием 100 спрайтов при 20 кадрах в секунду.


Делаем ведро подвижным (прикосновение/мышь)

Время, чтобы позволить пользователю управлять ведром. Ранее мы сказали, что мы будем позволять пользователю перетаскивать ведро. Давайте сделаем задачу немного легче. Если пользователь прикасается к экрану (или нажимает кнопку мыши), то ведро центрируется по горизонтали, относительно точки прикосновения. Добавление следующего кода в конец метода render() позволит нам это осуществить:

if(Gdx.input.isTouched()) {
   Vector3 touchPos = new Vector3();
   touchPos.set(Gdx.input.getX(), Gdx.input.getY(), 0);
   camera.unproject(touchPos);
   bucket.x = touchPos.x - 64 / 2;
}

Сначала вызывается метод Gdx.input.isTouched(), который спрашивает модуль ввода: есть ли на данный момент прикосновение к экрану (или нажатие кнопки мыши)? Далее идет преобразование координат прикосновения/мыши в систему координат камеры. Это необходимо, поскольку система координат прикосновения/мыши может отличаться от используемой нами системы для представления объектов в мире.

Gdx.input.getX() и Gdx.input.getY() возвращают текущую позицию прикосновения/мыши (libgdx поддерживает multi-touch). Для преобразования этих координат в систему координат нашей камеры, мы должны вызвать метод camera.unproject(), в который передается трехмерный вектор Vector3. Мы создаем вектор, устанавливаем текущие координаты прикосновения/мыши и вызываем соответствующий метод. Теперь вектор содержит координаты прикосновения/мыши в такой же системе координат как и ведро. Далее изменяется позиция ведра так чтобы центр находился в координатах прикосновения/мыши.

Примечание: очень и очень плохо, когда постоянно создается экземпляр нового объекта, в нашем случает экземпляр Vector3 класса. Причиной этого является сборщик мусора, который должен часто освобождать память этих недолго живущих объектов. На Desktop это не так уж и важно, но на Android сборщик мусора может вызвать паузу на несколько миллисекунд, что приведет к затормаживанию. Чтобы решить эту проблему, в данном случае можно сделать touchPos свойством в классе Drop, вместо постоянного создания объекта.

Примечание: touchPos является трехмерным вектором. Вы можете удивиться, почему это так, если мы работаем только в 2D. OrthographicCamera на самом деле 3D-камера, которая также принимает во внимание Z-координату.


Делаем ведро подвижным (клавиатура)

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

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

if(Gdx.input.isKeyPressed(Keys.LEFT)) bucket.x -= 200 * Gdx.graphics.getDeltaTime();
if(Gdx.input.isKeyPressed(Keys.RIGHT)) bucket.x += 200 * Gdx.graphics.getDeltaTime();

Метод Gdx.input.isKeyPressed() сообщает о нажатии определенной клавиши. Перечисление Keys содержит все коды клавиш, которые поддерживает libgdx. Метод Gdx.graphics.getDeltaTime() возвращает время, прошедшее между последним и текущим кадром в секундах. Все что нам нужно сделать, это изменить X-координаты ведра путем добавления/удаления 200 единиц умноженных на дельту времени в секундах.

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

if(bucket.x < 0) bucket.x = 0;
if(bucket.x > 800 - 64) bucket.x = 800 - 64;

Добавляем каплю

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

Array raindrops;

Класс Array - это специальный класс для использования вместо стандартных Java коллекций, таких как ArrayList. Проблема с ними в том, что они производят мусор различными способами. Класс Array пытается минимизировать мусор в максимально возможной степени. Libgdx предлагает и другие сборщики мусора для коллекций хэш-таблиц и множеств.

Так же необходимо отслеживать последние появление капли, так что добавим еще одно поле.

long lastDropTime;

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

Для облегчения создания капли мы напишем метод, называемый spawnRaindrop(), который создает новый Rectangle, устанавливает его в случайной позиции в верхней части экрана и добавляет его в массив raindrops.

private void spawnRaindrop() {
   Rectangle raindrop = new Rectangle();
   raindrop.x = MathUtils.random(0, 800-64);
   raindrop.y = 480;
   raindrop.width = 64;
   raindrop.height = 64;
   raindrops.add(raindrop);
   lastDropTime = TimeUtils.nanoTime();
}

Метод должен быть довольно очевидным. Класс MathUtils - это класс libgdx, предлагающий статические методы, связанные с математикой. В этом случае возвращается случайное число, между нулем и 800 — 64. Класс TimeUtils это другой класс libgdx, который предоставляет очень простые статические методы связанные со временем. В этом случае мы записываем текущее время в наносекундах, основываясь на том, что следует ли порождать новую каплю или нет.

В методе create() сейчас создается экземпляр массива капель и порождается первая капля.

raindrops = new Array();
spawnRaindrop();

Затем добавляем несколько строк в метод render(), который будет проверять сколько времени прошло с тех пор, как была создана новая капля и если необходимо создать еще одну новую каплю.
if(TimeUtils.nanoTime() - lastDropTime > 1000000000) spawnRaindrop();

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

Iterator iter = raindrops.iterator();
while(iter.hasNext()) {
   Rectangle raindrop = iter.next();
   raindrop.y -= 200 * Gdx.graphics.getDeltaTime();
   if(raindrop.y + 64 < 0) iter.remove();
}

Капли нужно отобразить на экране. Мы добавим это в код визуализации, который выглядит сейчас так:
batch.begin();
batch.draw(bucketImage, bucket.x, bucket.y);
for(Rectangle raindrop: raindrops) {
   batch.draw(dropImage, raindrop.x, raindrop.y);
}
batch.end();

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

if(raindrop.overlaps(bucket)) {
   dropSound.play();
   iter.remove();
}

Метод Rectangle.overlaps() проверяет пересечение прямоугольника с другим прямоугольником. В нашем случае воспроизводится звук и капля удаляется из массива.


Очистка

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

Любые классы libgdx классы, которые реализуют интерфейс Disposable и имеют метод dispose(), должны быть освобождены вручную, если они больше не используются. В нашем примере это относится к текстурам, звукам, музыке и SpriteBatch. Будучи добропорядочными гражданами, мы реализуем метод  ApplicationListener.dispose() следующим образом:

@Override
public void dispose() {
   dropImage.dispose();
   bucketImage.dispose();
   dropSound.dispose();
   rainMusic.dispose();
   batch.dispose();
}

После освобождения ресурса вы не должны больше обращаться к нему.

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


Обработка паузы и возобновления

Android имеет особенность приостанавливать и возобновлять приложения всякий раз, когда пользователю звонят или при нажатии кнопки home. Libgdx для таких случаев делает много вещей автоматически, например перезагружает изображения, которые могут быть потеряны (потеря OpenGL контекста), останавливает и возобновляет потоковую музыку и так далее.

В нашей игре нет реальной необходимости в обработки паузы и возобновления. Как только пользователь заходит обратно в приложение, игра продолжается с того момента где она и была. Обычно можно реализовать экран паузы и просить пользователя прикоснуться к экрану, чтобы продолжить игру. Это останется в качестве упражнения для читателя. Смотрите методы ApplicationListener.pause() и ApplicationListener.resume().