imageПривет Хабр!

Закончился конкурс от ВКонтакте и мой 2-х недельный марафон в интернете по поиску нужной информации. Хочу поделится небольшим опытом работы с графическим движком LibGDX. В интернете полно примеров, но большинство далеки от практики (нарисованный спрайт это далеко еще не игра) или уже устарели.

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

Вопросы, которые мне приходилось решать:

Вариант использования встроенного логгера
Я часто использую Timber из-за удобства. В core модуле он недоступен, потому написал простой вспомогательный класс с использованием имеющегося в LibGDX логгера, с которым приложение в релиз версии перестает писать логи

import com.badlogic.gdx.Gdx;

public class GdxLog {

 public static boolean DEBUG;

 @SuppressWarnings("all")
 public static void print(String tag, String message) {
  if (DEBUG) {
   Gdx.app.log(tag, message);
  }
 }

 @SuppressWarnings("all")
 public static void d(String tag, String message, Integer...values) {
  if (DEBUG) {
   Gdx.app.log(tag, String.format(message, values));
  }
 }

 @SuppressWarnings("all")
 public static void f(String tag, String message, Float...values) {
  if (DEBUG) {
   Gdx.app.log(tag, String.format(message.replaceAll("%f", "%.0f"), values));
  }
 }
}

//... вызов
GdxLog.d(TAG, "worldWidth: %d", worldWidth);

Плюс для удобства различные float значения 1.23456789 округляются

E/libEGL: call to OpenGL ES API with no current context (logged once per thread)
Поначалу сильно огорчала такая ошибка, ничего не мог понять. Она происходит, потому что графика GLSurfaceView отрисовывается в своем отдельном потоке, когда проиcходит попытка извне вмешаться в этот процесс. Как исправить:

Gdx.app.postRunnable(new Runnable() {
   @Override
   public void run() {
       // Здесь выполняется в самом потоке
   }
});

Впринципе аналогично, как и в случае, view.postInvalidate()

Я не любитель анонимных классов, поэтому написал такой простой метод для сокращения кода (иначе он просто становился не читаемым). Хотя с java 8 это уже не такая проблема, но из дополнительных плюсов то, что обрабатываются InvocationTargetException, когда, например, файл не найден, приложение уже не упадет по такой незначительной ошибке.

// null may be only String params
public void postRunnable(final String name, final Object...params) {
 Gdx.app.postRunnable(new Runnable() {
  @Override
  public void run() {
   Method method = null;
   Class[] classes = new Class[params.length];
   for (int i = 0; i < params.length; i++) {
    classes[i] = params[i] == null ? String.class : params[i].getClass();
   }
   try {
    method = World.class.getMethod(name, classes);
   } catch (SecurityException e) {
    GdxLog.print(TAG, e.toString());
   } catch (NoSuchMethodException e) {
    GdxLog.print(TAG, e.toString());
   }
   if (method == null) {
    return;
   }
   try {
    method.invoke(WorldAdapter.this, params);
   } catch (IllegalArgumentException e) {
    GdxLog.print(TAG, e.toString());
   } catch (IllegalAccessException e) {
    GdxLog.print(TAG, e.toString());
   } catch (InvocationTargetException e) {
    GdxLog.print(TAG, e.toString());
   }
  }
 });
}

Важно, чтобы параметры не были примитивами, а наследовали Object. И плюс здесь упрощение с null параметром (только от класса String)

Как использовать LibGDX с другими виджетами
Через фрагмент. Больше информации в wiki

Пример:

public class ActivityMain extends AppCompatActivity
    implements AndroidFragmentApplication.Callbacks {

protected FragmentWorld fragmentWorld;

@Override
protected void onCreate(Bundle savedInstanceState) {
 super.onCreate(savedInstanceState);
 // ...
 getSupportFragmentManager()
  .beginTransaction()
  .add(R.id.world, fragmentWorld, FragmentWorld.class.getSimpleName())
  .commitAllowingStateLoss();
}

@Override
public void exit() {}

И сам фрагмент:

public class FragmentWorld extends AndroidFragmentApplication {

 public World world;

 @Override
 public View onCreateView(LayoutInflater inflater, ViewGroup container,
  Bundle savedInstanceState) {
  int worldWidth = getResources().getDimensionPixelSize(R.dimen.world_width);
  int worldHeight = getResources().getDimensionPixelSize(R.dimen.world_height);
  world = new World(BuildConfig.DEBUG, worldWidth, worldHeight);
  return initializeForView(world);
 }
}


Как вытащить рендер мира или Pixmap в Bitmap
Я не хотел сохранять pixmap в файл и потом средствами Android вытаскивать Bitmap
Поэтому придумал такой лайфхак с OutputStream классом. Работает прекрасно и не требует медленных r/w операций

final Pixmap pixmap = getScreenshot();
Observable.fromCallable(new Callable <Boolean> () {
 @Override
 public Boolean call() throws Exception {
  PixmapIO.PNG writer = new PixmapIO.PNG((int)(pixmap.getWidth() * pixmap.getHeight() * 1.5 f));
  writer.setFlipY(false);
  ByteArrayOutputStream output = new ByteArrayOutputStream();
  try {
   writer.write(output, pixmap);
  } finally {
   StreamUtils.closeQuietly(output);
   writer.dispose();
   pixmap.dispose();
  }
  byte[] bytes = output.toByteArray();
  Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
  return true;
 }
}).subscribeOn(Schedulers.io()).subscribe();


Как работать с colors совместно с Android
На самом деле можно int, наверное, (Color класс в LibGDX довольно специфичен как и вся графика, по-моему мнению, т.е. нужно разбираться на уровне битов) но для простоты я предпочел hex хранить и передавать в String

Соотвественно понадобился парсер:

protected Color parseColor(String hex) {
 String s1 = hex.substring(0, 2);
 int v1 = Integer.parseInt(s1, 16);
 float f1 = 1 f * v1 / 255 f;
 String s2 = hex.substring(2, 4);
 int v2 = Integer.parseInt(s2, 16);
 float f2 = 1 f * v2 / 255 f;
 String s3 = hex.substring(4, 6);
 int v3 = Integer.parseInt(s3, 16);
 float f3 = 1 f * v3 / 255 f;
 return new Color(f1, f2, f3, 1 f);
}

Пример параметра «ffffff»

Как определить нажатие на actor
sticker.addListener() Все проще. Есть метод hit у сцены

Sticker sticker = (Sticker) stickersStage.hit(coordinates.x, coordinates.y, false);


Математика scale и rotation на событие pinch (два пальца)
Возможно это не лучшее решение, но работает хорошо. Поворот без резких скачков, плавный зум

@Override
public boolean pinch(Vector2 initialPointer1, Vector2 initialPointer2, Vector2 pointer1,
 Vector2 pointer2) {
 // initialPointer doesn't change
 // all vectors contains device coordinates
 Sticker sticker = getCurrentSticker();
 if (sticker == null) {
  return false;
 }

 Vector2 startVector = new Vector2(initialPointer1).sub(initialPointer2);
 Vector2 currentVector = new Vector2(pointer1).sub(pointer2);
 sticker.setScale(sticker.startScale * currentVector.len() / startVector.len());

 float startAngle = (float) Math.toDegrees(Math.atan2(startVector.x, startVector.y));
 float endAngle = (float) Math.toDegrees(Math.atan2(currentVector.x, currentVector.y));
 sticker.setRotation(sticker.startRotation + endAngle - startAngle);
 return false;
}

Единственно необходимо перед этим событием запоминать текущий зум и поворот

@Override
public boolean touchDown(float x, float y, int pointer, int button) {
 if (pointer == FIRST_FINGER) {
  Vector2 coordinates = stickersStage.screenToStageCoordinates(new Vector2(x, y));
  Sticker sticker = (Sticker) stickersStage.hit(coordinates.x, coordinates.y, false);
  if (sticker != null) {
   // здесь
   sticker.setPinchStarts();
   currentSticker = sticker.index;
  }
 }
 return false;
}

@Override
public void pinchStop() {
 Sticker sticker = getCurrentSticker();
 if (sticker != null) {
  // здесь
  sticker.setPinchStarts();
 }
}

И на время события pinch актер неподвижен в этом случае

Почему не происходит анимация, но action к актеру добавлен
Ключевой метод act у сцены. Боль, когда этого не знаешь)

spriteBatch.begin();
stickersStage.act();
stickersStage.getRoot().draw(spriteBatch, 1);
spriteBatch.end();


Градиент в LibGDX
Насколько я понял, можно задать только левый верхний и нижний правый цвета. При этом есть не задавать остальные (transparent), то между ними будет пробел. Т.е. остальные определяются как сумма этих двух цветов на данном расстоянии, если речь идет о линейном градиенте. Сказать, что своеобразно, ничего не сказать

gradientTopLeftColor = parseColor(topLeftColor);
gradientBottomRightColor = parseColor(bottomRightColor);
gradientBlendedColor = new Color(gradientTopLeftColor).add(gradientBottomRightColor);


Хитрости обработки движения актера (событие pan)
Вот этот обработчик

@Override
public boolean pan(float x, float y, float deltaX, float deltaY) {
 if (currentSticker != Sticker.INDEX_NONE) {
  Sticker sticker = getCurrentSticker();
  if (sticker != null) {
   sticker.moveBy(deltaX * worldDensity, -deltaY * worldDensity);
  }
 }
 return false;
}

worldDensity это разница между перемещением пальца в экранных координатах и актера в игровых. Без этого параметра актер будет отрываться от пальца

@Override
public void resize(int width, int height) {
 if (height > width) {
  worldDensity = 1f * worldWidth / width;
 } else {
  worldDensity = 1f * worldHeight / height;
 }
 viewport.update(width, height, true);
}

И если сделать привязку touch input через sticker.addListener, то поступающие координаты будут относительного самого актера к текущему положению пальца. Лучше так не делать, потому что при малом размере актера (зум) он задергается и вылетит из сцены (как было у меня)

Как лучше работать с анимациями актеров (Action)
Использовать Pool класс. В моем проекте есть пример более детально реализации дополнительной

public void onAppear() {
 ScaleToAction scaleToAction = scaleToPool.obtain();
 scaleToAction.setPool(scaleToPool);
 scaleToAction.setScale(startScale);
 scaleToAction.setDuration(ANIMATION_TIME_APPEAR);
 addAction(scaleToAction);
}


Наверное все, что из интересного. Сам не нашел решение проблемы увеличения viewport. Камера zoom помогает только с приближением сцены, и получается, что сцена сокращается больше чем надо (область видимости неизменная).

Другой вопрос это сохранение рендера мира. На выходе он соотвествует размеру экрана, но мне нужен определенный размер. Пробовал с framebuffer, но не получилось вытащить с него pixmap (присутствуют какие-то баги с инициализацией класса Texture)

Еще недостаток в движке, что не позволяет, например, полностью отключить ввод с клавиатуры. Получалось так, что он перехватывал фокус с другого виджета (но он на это и не рассчитан собственно, хотя было бы неплохо. Go pull request, одним словом)

Но в целом, все очень даже хорошо. Развивайся дальше LibGDX)

> Ссылка на проект

Комментарии (5)


  1. IgeNiaI
    21.09.2017 15:13

    Мне тоже движок очень понравился. Удобней, чем те, которые пробовал раньше, да и написан на Java.
    Правда, мне не понравилась их система Actor с по сути табличной вёрсткой, поэтому написал свою библиотеку, которая ближе к системе из Android.


    1. Lamaster
      21.09.2017 17:25

      Вы про систему UI виджетов? Потому что актор это любой объект на сцене, включая игровые.

      поэтому написал свою библиотеку
      Не поделитесь?


      1. IgeNiaI
        21.09.2017 17:44

        Вы про систему UI виджетов?

        Да, я о ней.

        Не поделитесь?

        Можно. Однако, я её делал для собственных нужд и пока что применил лишь в одном проекте, так что там не хватает очень многих виджетов и совершенно нет документации.

        Скину в личку как приду домой.


  1. vladfaust
    21.09.2017 16:15

    Желаю автору удачи, но, имхо, вк не станет инклюдить в своё Android-приложение такую серьезную зависимость, как LibGDX ради, по сути, одной функции.


    1. mr-cpp Автор
      21.09.2017 16:18

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