Простейшая 3D игра на libGDX под Android в 200 строк кода

Я преподаю в IT школе Samsung программирование под Android для школьников. Программа обучения охватывает множество разнообразных тем. В числе прочих предусмотрен один урок, знакомящий учеников с основами 3D-графики под Android. Стандартный учебный материал этого урока показался мне очень бесполезным по нескольким причинам:

  1. Используется голый OpenGL, а поскольку на практике в программировании игр чаще всего используются готовые движки, то это мало полезно для школьников в контексте их собственных проектов. Кто-то может возразить, что увидеть в деле чистый OpenGL полезно для понимания основ, но здесь вступает в дело 2-й недостаток.
  2. Урок очень непонятный. У типичного школьника, пусть и разбирающегося в программировании, нет достаточной базы, чтобы понимать многое из того, что описано в уроке (например матрицы многие пройдут только уже в ВУЗе).
  3. В конце урока мы приходим к результату — отрисовка 3-х треугольников средствами OpenGL. Это настолько далеко от реальной 3D-игры, что легко может отбить интерес у школьника.


Поэтому я решил подготовить свой урок, описывающий основы использования libGDX под Android, а раз я все равно готовлю этот материал, заодно разместить его здесь — на хабре. В этом уроке мы сделаем наипростейшую 3D игру под Android, скриншот которой вы можете видеть во вступлении к статье. Итак, интересующиеся, добро пожаловать под кат.
c92f8467a60b4e4db6cae53e163d78b5.png
Почему именно libGDX? Во-первых код должен быть на Java, потому-что мы обучаем студентов именно Java-программированию. Это сужает выбор. Во-вторых libGDX оказался очень прост в освоении. В моих условиях это большое достоинство, перевешивающее прочие недостатки.

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

В ходе этого урока нам понадобится только Android Studio 1.5 (версия может отличаться, здесь я привел ту, с которой у меня все точно получилось).
Для начала нам нужно скачать мастер создания проекта от libGDX, который значительно облегчает задачу первоначальной настройки проекта (скачать можно по ссылке в инструкции на wiki проекта libGDX). Вот, что за настройки я туда вбил:
95a2cb36f47c4822b3a4ca032dc6e5b1.png
Импортируем получившийся проект в Android Studio и начинаем собственно работу с кодом. Основной код игры находится в файле MyGdxGame.java (если вы назвали этот класс так же, как и я). Удалим шаблонный код и начнем писать свое:

public class MyGdxGame extends ApplicationAdapter {
 public PerspectiveCamera cam;
 final float[] startPos = {150f, -9f, 0f};

 @Override
 public void create() {
  cam = new PerspectiveCamera(67, Gdx.graphics.getWidth(), Gdx.graphics.getHeight());
  cam.position.set(startPos[0], startPos[1], startPos[2]);
  cam.lookAt(0, 0, 0);
  cam.near = 1f;
  cam.far = 300f;
  cam.update();
 }
}


Здесь мы создаем новую камеру с углом обзора в 67 градусов (что является довольно часто используемым значением) и задаем соотношением сторон ширину и высоту экрана. Затем мы устанавливаем позицию камеры в точку (150, -9, 0) и указываем, что она будет смотреть на центр координат (поскольку именно там мы планируем разместить пирамидку, вокруг которой будет строиться геймплей). И наконец мы вызываем служебный метод update (), чтобы все наши изменения применились к камере.

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

public class MyGdxGame extends ApplicationAdapter {
 ...
 public Model model;
 public ModelInstance instance;

 @Override
 public void create() {
  ...
  ModelBuilder modelBuilder = new ModelBuilder();
  model = modelBuilder.createCone(20f, 120f, 20f, 3,
   new Material(ColorAttribute.createDiffuse(Color.GREEN)),
   VertexAttributes.Usage.Position | VertexAttributes.Usage.Normal);
  instance = new ModelInstance(model);
  instance.transform.setToRotation(Vector3.Z, 120);
 }

 @Override
 public void dispose() {
  model.dispose();
 }
}


Тут мы создаем экземпляр ModelBuilder, который предназначен для создания моделей в коде. Затем мы создаем простую модель конуса с размерами 20×120x20 и количество граней 3 (что в итоге и дает пирамиду) и задаем ему материал зеленого цвета. Когда мы создаем модель, необходимо задать как минимум Usage.Position. Usage.Normal добавляет к модели нормали, так что освещение сможет работать правильно.

Модель содержит все необходимое для отрисовки и управления собственными ресурсами. Однако она не содержит информации, где именно отрисовываться. Так что нам нужно создать ModelInstance. Он содержит данные о расположении, параметрах вращения и масштаба для отрисовки модели. По умолчанию рисуется в (0, 0, 0) так что мы просто создаем ModelInstance, который отрисуется в (0, 0, 0). Но кроме того, мы еще вызовом метода transform.setToRotation () поворачиваем нашу пирамидку на 120 градусов по оси Z (так ее лучше видно с позиции камеры).

Модель необходимо освобождать после использования, так что мы добавляем немного кода в наш метод Dispose ().

Теперь давайте отрисуем наш экземпляр модели:

public class MyGdxGame extends ApplicationAdapter {
 ...
 public ModelBatch modelBatch;

 @Override
 public void create() {
  modelBatch = new ModelBatch();
  ...
 }

 @Override
 public void render() {
  Gdx.gl.glViewport(0, 0, Gdx.graphics.getWidth(), Gdx.graphics.getHeight());
  Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT | GL20.GL_DEPTH_BUFFER_BIT);

  modelBatch.begin(cam);
  modelBatch.render(instance);
  modelBatch.end();
 }

 @Override
 public void dispose() {
  model.dispose();
  modelBatch.dispose();
 }
}

Здесь мы добавляем в метод create ModelBatch, который отвечает за отрисовку и инициализацию модели. В методе render мы очищаем экран, вызываем modelBatch.begin (cam), рисуем наш ModelInstance и затем вызываем modelBatch.end () чтобы завершить процесс отрисовки. Наконец, нам нужно освободить modelBatch чтобы удостовериться, что все ресурсы (например шейдеры, которые он использует) корректно освобождены.

e9ec91981dec4e21a4985f0a35fa7b8d.png

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

public class MyGdxGame extends ApplicationAdapter {
 ...
 public Environment environment;

 @Override
 public void create() {
  environment = new Environment();
  environment.set(new ColorAttribute(ColorAttribute.AmbientLight, 0.4f, 0.4f, 0.4f, 1f));
  environment.add(new DirectionalLight().set(0.8f, 0.8f, 0.8f, 10f, 10f, 20f));
  ...
 }

 @Override
 public void render() {
  ...
  modelBatch.begin(cam);
  modelBatch.render(instance, environment);
  modelBatch.end();
 }
}

Здесь мы добавляем экземпляр Environment. Мы создаем его и задаем окружающий (рассеянный) свет (0.4, 0.4, 0.4) (обратите внимание, что значение прозрачности игнорируется). Затем мы создаем DirectionalLight (направленный свет) с цветом (0.8, 0.8, 0.8) и направлением (10, 10, 20). Я предполагаю, что вы уже знакомы с источниками освещения в общем хотя здесь все итак довольно очевидно. Наконец, во время отрисовки мы передаем созданный environment (окружение) в обработчик моделей.

5ede45b1a2f44fa59456062a94611a26.png

Поскольку мы все-таки пишем игру, не мешало бы добавить немного динамики в статичную картинку. Сделаем, чтобы камера немножко сдвигалась при каждой прорисовке. Здесь уместно сказать о жизненном цикле libGDX-приложения. При старте вызывается метод create (), в котором уместно размещать всю инициализацию. Затем N раз в секунду вызывается метод render (), где N — ваш FPS. Этот метод отрисовывает текущий кадр. Поэтому чтобы добавить в приложение динамичности, нам просто нужно в render () как-то менять параметры наших игровых объектов.

public class MyGdxGame extends ApplicationAdapter {
 ...
 final float bound = 45f;
 float[] pos = {startPos[0], startPos[1], startPos[2]};
 float[] Vpos = new float[3];
 final float speed = 2f;
 
 private float getSpeed() {
  return speed * Math.signum((float) Math.random() - 0.5f) * Math.max((float) Math.random(), 0.5f);
 }

 @Override
 public void create () {
  ...
  // initialize speed
  for (int i = 0; i < 3; i++){
   Vpos[i] = getSpeed();
  }
 }

 @Override
 public void render() {
  ...
  for (int i = 0; i < 3; i++) {
   pos[i] += Vpos[i];
   if (pos[i] <= startPos[i] - bound) {
    pos[i] = startPos[i] - bound;
    Vpos[i] = getSpeed();
   }
   if (pos[i] >= startPos[i] + bound) {
    pos[i] = startPos[i] + bound;
    Vpos[i] = getSpeed();
   }
  }
  cam.position.set(pos[0], pos[1], pos[2]);
  cam.update();

  modelBatch.begin(cam);
  modelBatch.render(instance, environment);
  modelBatch.end();
 }

}

Здесь мы создаем иллюзию того, что наша пирамидка двигается, хотя на самом деле движется камера, посредством которой мы смотрим на нее. В начале игры в методе create () случайным образом выбирается значение приращения Vpos[i] для каждой координаты (скорость). На каждой перерисовке сцены в методе render () к координатам прибавляется значение шага изменения. Если мы вышли за установленные границы изменения координат, то возвращаем координаты в эти границы и генерируем новые скорости, чтобы камера начала двигаться в другую сторону. cam.position.set () собственно и устанавливает камеру в новые координаты, рассчитанные по описанному выше закону, а cam.update () завершает процесс изменения параметров камеры.

Можно отметить, что на разных устройствах скорость движения пирамидки будет разной из-за разницы в FPS и, соответственно, количестве вызовов render () в секунду. По хорошему здесь бы добавить зависимость приращения координат от времени между кадрами, тогда скорости были бы везде одинаковыми. Но мы этим заниматься не будем, чтобы не усложнять проект.

1934584b29ac49ff83c33d5b0915ae28.gif

А теперь сделаем игровой HUD:

public class MyGdxGame extends ApplicationAdapter {
 ...
 protected Label label;
 protected Label crosshair;
 protected BitmapFont font;
 protected Stage stage;

 protected long startTime;
 protected long hits;

 @Override
 public void create() {
  ...
  instance.transform.setToRotation(Vector3.Z, 90).translate(-5,0,0);

  font = new BitmapFont();
  label = new Label(" ", new Label.LabelStyle(font, Color.WHITE));
  crosshair = new Label("+", new Label.LabelStyle(font, Color.RED));
  crosshair.setPosition(Gdx.graphics.getWidth() / 2 - 3, Gdx.graphics.getHeight() / 2 - 9);

  stage = new Stage();
  stage.addActor(label);
  stage.addActor(crosshair);

  startTime = System.currentTimeMillis();
 }

 @Override
 public void render() {
  ...
  StringBuilder builder = new StringBuilder();
  builder.append(" FPS: ").append(Gdx.graphics.getFramesPerSecond());
  long time = System.currentTimeMillis() - startTime;
  builder.append("| Game time: ").append(time);
  builder.append("| Hits: ").append(hits);
  builder.append("| Rating: ").append((float) hits/(float) time);
  label.setText(builder);
  stage.draw();
 }

 @Override
 public void resize(int width, int height) {
  stage.getViewport().update(width, height, true);
 }

}

Обратите внимание, что параметры вращения и сдвига (метод translate (x, y, z))пирамидки изменены так, чтобы она находилась в центре экрана и направлена туда же, куда смотрит наша камера. То есть на старте игры мы находимся с противником на одном курсе и смотрим ему прямо в двигатели.

Здесь мы создаем 2 текстовых метки. Метка label предназначена для отображения внутриигровой информации (FPS, время игры и статистика попаданий). Метка crosshair рисуется красным цветом и содержит в себе только один символ — »+». Это показывает игроку середину экрана — его прицел. Для каждой из них в конструкторе new Label (<ТЕКСТ>, new Label.LabelStyle (font, <ЦВЕТ>)) задается стиль, включающий в себя шрифт и цвет надписи. Метки передаются в объект Stage методом addActor (), и, соответственно, отрисовываются автоматически когда отрисовывается Stage.

Кроме того для метки crosshair методом setPosition () задается позиция — середина экрана. Здесь мы используем размеры экрана (Gdx.graphics.getWidth (), …getHeight ()) чтобы рассчитать, куда нужно поместить наш плюсик, чтобы он оказался в середине. Еще имеет место небольшой грязный хак: setPosition () задает координаты левого нижнего угла надписи. Чтобы в центре экрана оказался именно центр плюсика, я вычитаю из полученного значения эмпирически (то есть наугад) полученные константы 3 и 9. Не используйте такой подход в полноценных играх. Просто плюсик в середине экрана — это несерьезно. Если вам нужно перекрестье прицела, вы можете использовать спрайты.

При каждой отрисовке мы создаем текст через StringBuilder, куда кладем все то, что хотим вывести внизу экрана: FPS, время в игре, количество попаданий и рейтинг. Метод setText () позволяет задать метке текст, что мы и делаем в render () раз за разом.

0b52442604fc46f5b0b749991e5fa71b.png

Правда стрелять то мы пока не можем. Самое время исправить этот недостаток.

public class MyGdxGame extends InputAdapter implements ApplicationListener {
 ...
 final float zone = 12f;
 boolean isUnder = false;
 long underFire;

 @Override
 public void create() {
  ...
  Gdx.input.setInputProcessor(new InputMultiplexer(this));
 }

 @Override
 public void render() {
  if (Math.abs(pos[1] - startPos[1]) < zone &&
   Math.abs(pos[2] - startPos[2]) < zone) {
   isUnder = true;
   crosshair.setColor(Color.RED);
  } else {
   isUnder = false;
   crosshair.setColor(Color.LIME);
   underFire = 0;
  }
  ...
 }

 @Override
 public void pause() {}

 @Override
 public void resume() {}

 @Override
 public boolean touchDown(int screenX, int screenY, int pointer, int button) {
  if (isUnder) {
   underFire = System.currentTimeMillis();
  } else {
   hits /= 2;
  }
  return true;
 }

 @Override
 public boolean touchUp(int screenX, int screenY, int pointer, int button) {
  if (isUnder && underFire != 0) {
   hits += System.currentTimeMillis() - underFire;
   underFire = 0;
  } else {
   hits /= 2;
  }
  return false;
 }
}

Обратите внимание, что описание класса MyGdxGame теперь изменилось. Здесь мы наследуемся от InputAdapter и реализуем интерфейс ApplicationListener. Такая структура позволит нам сохранить наш код в неизменном виде, но дополнить его возможностью обработки пользовательского ввода. В методе create () добавляется строка, регистрирующая наш класс как обработчик ввода. Методы pause () и resume () мы просто обязаны реализовать, поскольку у InputAdapter он абстрактные.

Вся математика расчета попадания находится в render (). Мы проверяем, находятся ли координаты камеры в той зоне, чтобы наш противник был в центре экрана на одном с нами курсе (находятся ли координаты Y и Z в пределах start ± zone). Если мы на одном курсе, значит стрелять можно: устанавливаем isUnder = true и делаем прицел более яркого красного цвета. Опять-таки эта простота определения попадания — это хитрость, основанная на тупости простоте, некоторой условности игрового процесса. Вообще же в libGDX есть средства для определения, какие 3D-модели попали в область касания в общем случае.

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

Дополнение: модель истребителя вместо пирамидки


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

Модель поставляется в 4-х форматах разных 3D-редакторов. Однако libGDX использует свой бинарный формат моделей, в который их нужно конвертировать, чтобы использовать в игре. Для этого предусмотрена специальная утилита — fbx-conv. Скачиваем собранные бинарники и распаковываем в какую-нибудь папку. Там есть версия под Windows, Linux и MacOS. Windows-версия запустится без лишних телодвижений, а для Linux и MacOS нужно предварительно выполнить команду

export LD_LIBRARY_PATH=/folder/where/fbx-conv/extracted/


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

./fbx-conv-lin64 -f space_frigate_6/space_frigate_6.3DS


Конечно, вам надо указать свой путь до модели и использовать бинарник для вашей ОС. В итоге у вас получится файл space_frigate_6.g3db, который надо положить в папку проект android/assets/data (папка с ресурсами приложения для платформы Android).

О сложностях конвертирования моделей для libGDX для тех, кто захочет использовать другие модели
Вообще связка libGDX+fbx-conv очень проблемная. Я перепробовал около десятка бесплатных моделей космических кораблей с http://tf3dm.com/ и http://www.turbosquid.com/ прежде чем у меня получилось найти эту, которая заработала. Сложности самые разные. Иногда модель в игре получается без текстур, иногда она загружается нормально, но просто не отображается, а иногда (это чаще всего) при загрузке модели игра выпадает с OutOfMemoryError. Я, конечно, понимаю, что это мобильная платформа. Но игры в Play Market показывают и намного более сложную графику и памяти им для этого хватает. Даже та модель, которую я в конечном счете использовал, доставила проблем. Из obj нормально не конвертировалась, а вот из 3ds получилось. В свете этого можно сказать, что пока еще у libGDX с поддержкой моделей туговато. Можно использовать этот движок для простеньких игр, если старательно подбирать модели или же делать их самостоятельно с оглядкой на совместимость с libGDX. Или же использовать более продвинутые движки типа jMonkeyEngine.

Ну, а теперь подключим это в игре:

public class MyGdxGame extends InputAdapter implements ApplicationListener {
 ...
 public AssetManager assets;
 public boolean loading;

 @Override
 public void create() {
  ...
  assets = new AssetManager();
  assets.load("space_frigate_6.g3db", Model.class);
  loading = true;
 }

 @Override
 public void render() {
  if (loading)
   if (assets.update()) {
    model = assets.get("space_frigate_6.g3db", Model.class);
    instance = new ModelInstance(model);
    loading = false;
   } else {
    return;
   }
  ...
 }

 @Override
 public void dispose() {
  model.dispose();
  modelBatch.dispose();
 }

}

Здесь мы создаем экземпляр класса AssetManager, который отвечает за загрузку игровых ресурсов и указываем ему загрузить нашу модельку. На каждой отрисовке мы проверяем, не загрузил ли еще AssetManager модель (метод update (), возвращающий boolean). Если загрузил, то пихаем в instance вместо набившей оскомину пирамидки наш няшный самолетик и устанавливаем loading = false, чтобы это создание inctance не повторялось на каждом кадре, а то ведь assets.update () будет возвращать true дальше на протяжении всего времени работы приложения.

При запуске получим исключение java.io.FileNotFoundException: SPACE_FR.PNG . Значит файл модели не включает текстуры, их нужно засовывать отдельно. Берем из 4-х представленных понравившуюся текстуру, переименовываем в SPACE_FR.PNG, кладем в assets, запускаем. В итоге получаем то, что во вступительной картинке. Ну, а на закуску — гифка с игровым процессом:

ddb0c095a0c040c8a4eb672b70e70fcf.gif

Итог: мы написали очень простую, но почти полноценную с точки зрения использованных средств (освещение, движение, HUD, касания, модели) игру, уложившись всего в 200 строк кода. Конечно, тут есть многое, что можно улучшить: нормальный прицел, skybox (небо или космос вокруг), звуки выстрелов и полета, игровое меню, нормальное определение попадания и т. п. Тем не менее игра уже содержит самую базу игрового процесса и наглядно показывает самые основные моменты разработки игр на libGDX. Надеюсь этот урок будет способствовать появлению многих новых интересных игр на Android как от моих студентов, так и от аудитории хабра.

Источники:


  1. https://libgdx.badlogicgames.com/nightlies/docs/api/overview-summary.html
  2. http://www.todroid.com/android-gdx-game-creation-part-i-setting-up-up-android-studio-for-creating-games/
  3. https://xoppa.github.io/blog/basic-3d-using-libgdx/
  4. http://stackoverflow.com/questions/19699801/dewitters-game-loop-in-libgdx
  5. http://stackoverflow.com/questions/21286055/run-libgdx-application-on-android-with-unlimited-fps
  6. https://xoppa.github.io/blog/interacting-with-3d-objects/
  7. https://xoppa.github.io/blog/loading-models-using-libgdx/

P. S.: вот код на github и apk-файл игры.

© Habrahabr.ru