Чему я научился делая игры на LibGDX

от автора

Привет, Хабр! ? В этом топике хочу поговорить о незаслуженно забытом, бесплатном фреймворке для разработки кросс-платформенных игр — LibGDX. Поделиться секретами своей кухни и решениями, которые я использую при разработке своих игр-головоломок. Ворнинг! Много кода под катом.


Экран

Начать, наверное, нужно с вьюпорта — того как игра будет выглядеть и масштабироваться на различных устройствах. Моя основная цель — это мобильные устройства, Android / iOS. Соответственно, актуальные соотношения сторон экрана будут плавать между 19.5:9 и 4:3. Узкие и более квадратные экраны, смартфоны и планшеты, проще говоря.

В LibGDX есть несколько видов вьюпортов. Нас интересует FillViewport, потому что он сохраняет соотношение сторон, не растягивая и не сжимая игровой мир на экране устройства. Как это работает? Да просто картинка обрезается сверху-снизу, когда реальное соотношение сторон экрана не соответствует «базовому». То есть на планшете мы будем видеть полную картину, больше декораций, а на смартфоне такую же по ширине, но несколько обрезанную по высоте.

Обратите внимание на кнопку выхода
Обратите внимание на кнопку выхода

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

Настала пора разбавить текст кодом. Основной класс игры, наследуемый от ApplicationAdapter, отвечает за отрисовку каждого кадра, в нем крутится и «игровой цикл» — код оживляющий мир, передвигающий объекты, меняющий кадры анимации и т.д. Все это происходит в методе render().

public class GdxGame extends ApplicationAdapter {      private OrthographicCamera camera;     private Viewport viewport;     private SimpleStage stage;     private AssetManager manager;     private Snd sound;      public static GdxGame self() {         return (GdxGame) Gdx.app.getApplicationListener();     }      @Override     public void create() {         camera = new OrthographicCamera();         viewport = new FillViewport(GdxViewport.WORLD_WIDTH, GdxViewport.WORLD_HEIGHT, camera);         manager = new AssetManager();         sound = new Snd();                final SimpleStage splash = new Splash(viewport);         splash.load();          setStage(splash);     }      public void setStage(SimpleStage stage) {         this.stage = stage;     }      @Override     public void render() {         Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);// очищаем экран          if (stage != null) {             stage.act();             stage.draw();         }     }      @Override     public void resize(int width, int height) {         viewport.update(width, height, true);         GdxViewport.resize(width, height);     } }

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

Событие resize() вызывается один раз при запуске приложения и его я использую как раз для того чтобы получить реальные TOP и BOTTOM экрана в игровых координатах. Обратите внимание на размеры игрового мира — 1280х960, исходя из этого разрешения подготавливается и вся графика. Такого разрешения, на мой взгляд, вполне достаточно как компромисса между качеством графики и разумным размером текстурных атласов.

public class GdxViewport {      public static final float WORLD_WIDTH = 1280f;     public static final float WORLD_HEIGHT = 960f;     public static float TOP;     public static float BOTTOM;     public static float HEIGHT;      public static void resize(int width, int height) {         float ratio = (float) height / width;         float viewportHeight = WORLD_WIDTH * ratio;          BOTTOM = (WORLD_HEIGHT - viewportHeight) / 2;         TOP = BOTTOM + viewportHeight;         HEIGHT = TOP - BOTTOM;     } }

Сцена

Каждый игровой такт, метод render() вызывает у текущий сцены методы act() и draw(). Первый дает возможность игровым объектам двигаться, а не оставаться статичным изображением, второй — отрисовывает содержимое сцены на экран.

Я использую один базовый класс для всех сцен игры — SimpleStage. Он реализует события для загрузки / выгрузки ресурсов и размещения объектов на сцене. Здесь же переход между сценами и работа со всплывающими диалогами (подтверждение выхода, найден предмет, использовать предмет и тому подобное). Они у меня в игре повсеместно, поэтому вынесены в базовый класс для всех сцен.

public class SimpleStage extends Stage {      private Label loading;     private boolean ready;      public SimplePopup popup;     public Actor blind;     public Group content;      public SimpleStage(Viewport viewport) {         super(viewport);          content = new Group();         content.setSize(GdxViewport.WORLD_WIDTH, GdxViewport.WORLD_HEIGHT);          blind = new SimpleActor((int) GdxViewport.WORLD_WIDTH, (int) GdxViewport.WORLD_HEIGHT, new Color(0, 0, 0, 1));          loading = new Label(Loc.getString(Loc.LOADING), GdxGame.self().getFontStyle());         loading.setAlignment(Align.right);         loading.setPosition(GdxViewport.WORLD_WIDTH - loading.getWidth() - 15f, GdxViewport.BOTTOM + 10f);          addActor(content);         addActor(blind);         addActor(loading);     }      public void openPopup(SimplePopup nPopup) {         if (popup != null) {             return;         }          popup = nPopup;         popup.setPosition(GdxViewport.WORLD_WIDTH / 2 - popup.getWidth() / 2, GdxViewport.WORLD_HEIGHT / 2 - popup.getHeight() / 2);          blind.addAction(Actions.sequence(                 Actions.alpha(.6f, .3f),                 Actions.run(new Runnable() {                     @Override                     public void run() {                         addActor(popup);                     }                 })         ));     }      public void closePopup(final int onCloseAction) {         if (popup != null) {             popup.clear();             popup.remove();             popup = null;         }          blind.addAction(Actions.sequence(                 Actions.alpha(0f, .3f),                 Actions.run(new Runnable() {                     @Override                     public void run() {                         onPopupClose(onCloseAction);                     }                 })         ));     }      public void onPopupClose(int action) {     }      public void load() {         Gdx.app.log(GdxGame.TAG, "Load stage: " + getClass().getSimpleName());     }      public void unload() {         Gdx.app.log(GdxGame.TAG, "Unload stage: " + getClass().getSimpleName());     }      public void populate() {         Gdx.app.log(GdxGame.TAG, "Populate stage: " + getClass().getSimpleName());     }      public void transitionTo(final SimpleStage stage) {         Gdx.input.setInputProcessor(null);          stage.load();          blind.addAction(Actions.sequence(                 Actions.alpha(1, .4f),                 Actions.run(new Runnable() {                     @Override                     public void run() {                         unload();                         dispose();                          GdxGame.self().setStage(stage);                     }                 })         ));     }      private void show() {         Gdx.app.log(GdxGame.TAG, "Show stage: " + getClass().getSimpleName());          populate();          blind.addAction(Actions.sequence(                 Actions.alpha(0, .4f),                 Actions.run(new Runnable() {                     @Override                     public void run() {                         onFocus();                     }                 })));     }      public void onFocus() {         Gdx.input.setInputProcessor(this);     }      @Override     public void act(float delta) {         super.act(delta);          if (!ready && GdxGame.getManager().update()) {             ready = true;             loading.setVisible(false);              show();         }     } }

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

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

Для этого я использую параметры командной строки, которые анализирую в DesktopLauncher классе отвечающем за запуск игры на ПК. Здесь мы можем запускать игру в окне 16:9 / 4:3, либо в полноэкранном режиме, выводить/не выводить FPS, ну и собственно параметр -stage отвечающий за то, какая сцена будет инициализирована после splash screen.

public class DesktopLauncher {      private static final String FULL_SIZE = "-full";     private static final String WINDOWED_MODE = "-windowed";     private static final String STAGE = "-stage";      public static void main(String[] arg) {         LwjglApplicationConfiguration config = new LwjglApplicationConfiguration();          // 16:9 (default)         config.width = 800;         config.height = 450;         boolean windowed = false;          for (int i = 0; i < arg.length; i++) {             if (arg[i].equals(FULL_SIZE)) {                 // 4:3                 config.height = 600;             } else if (arg[i].equals(WINDOWED_MODE)) {                 windowed = true;             } else if (arg[i].equals(STAGE)) {                 if (i + 1 < arg.length) Prefs.STAGE = arg[i + 1];             }         }          if (!windowed) {             config.width = LwjglApplicationConfiguration.getDesktopDisplayMode().width;             config.height = LwjglApplicationConfiguration.getDesktopDisplayMode().height;             config.fullscreen = true;         }          new LwjglApplication(new GdxGame(new DesktopPlatform()), config);     } }

Осталось добавить обработку этого параметра в сцене Splash:

@Override public void onFocus() {    super.onFocus();     addAction(Actions.sequence(            Actions.delay(1.8f),            Actions.run(new Runnable() {                @Override                public void run() {                    SimpleStage stage = new Intro(getViewport());                     if (Prefs.STAGE != null) {                        try {                            Class<?> roomClass = Class.forName("com.puzzle.stage." + Prefs.STAGE);                            Constructor<?> constructor = roomClass.getConstructor(Viewport.class);                            stage = (SimpleStage) constructor.newInstance(getViewport());                        } catch (Exception e) {                            e.printStackTrace();                        }                    }                     transitionTo(stage);                }            })    )); }

Ну а дальше, просто настраиваем нужные нам конфигурации запуска. Кстати, забыл сказать что для разработки используется Android Studio. У меня это окно выглядит вот так:

Мотор!.. То есть Актер 🙂

В LibGDX все объекты на сцене являются наследниками класса Actor. Но он совсем базовый и почти ничего не умеет. Поэтому я сделал собственное его расширение, от которого уже и наследуются все объекты в игре. По традиции, я назвал его SimpleActor. Вы уже могли заметить его использование в SimpleStage выше. Основная его функция — рисовать спрайт на сцене, либо примитив — квадрат, линию заданного цвета и т.п.

public class SimpleActor extends Actor {      public final TextureRegion region;     private Rectangle clipBounds;      public SimpleActor(TextureRegion region) {         this.region = region;          setSize(region.getRegionWidth(), region.getRegionHeight());         setBounds(0, 0, getWidth(), getHeight());     }      public SimpleActor(int width, int height, Color color) {         Pixmap pixmap = new Pixmap(width, height, Pixmap.Format.RGBA4444);         pixmap.setColor(color);         pixmap.fillRectangle(0, 0, width, height);         Texture texture = new Texture(pixmap);         texture.setFilter(Texture.TextureFilter.Linear, Texture.TextureFilter.Linear);         region = new TextureRegion(texture);         pixmap.dispose();          setSize(width, height);         setBounds(0, 0, width, height);     }      public void enableClipping(Rectangle clipBounds) {         this.clipBounds = clipBounds;     }      public Polygon getHitbox() {         final Polygon polygon = new Polygon(new float[]{0, 0, getWidth(), 0, getWidth(), getHeight(), 0, getHeight()});         polygon.setPosition(getX(), getY());         polygon.setOrigin(getOriginX(), getOriginY());         polygon.setScale(getScaleX(), getScaleY());         polygon.setRotation(getRotation());          return polygon;     }      @Override     public void draw(Batch batch, float parentAlpha) {         Color color = getColor();         batch.setColor(color.r, color.g, color.b, color.a * parentAlpha);          if (clipBounds != null) {             Rectangle scissors = new Rectangle();             final Viewport viewport = getStage().getViewport();             ScissorStack.calculateScissors(getStage().getCamera(), viewport.getScreenX(), viewport.getScreenY(), viewport.getScreenWidth(), viewport.getScreenHeight(), batch.getTransformMatrix(), clipBounds, scissors);             ScissorStack.pushScissors(scissors);         }          batch.draw(region, getX(), getY(), getOriginX(), getOriginY(), getWidth(), getHeight(), getScaleX(), getScaleY(), getRotation());          if (clipBounds != null) {             batch.flush();             ScissorStack.popScissors();         }     } }

Из интересного: метод getHitbox() для проверки коллизий (столкновений с другими объектами класса SimpleActor). Вообще, решение создавать каждый раз полигон для этого — спорное. Но в моих играх, проверка коллизий идет во взаимодействиях типа drag-and-drop, проверяем поставил ли игрок предмет на нужное место для его использования, например. То есть получение хитбокса не очень активно вызывается, поэтому такое решение приемлемо. В результате, код на проверку коллизии выглядит так:

if (Intersector.overlapConvexPolygons(battery.getHitbox(), box.getHitbox())) { // some actions }

Второе — это метод enableClipping() — маска, правда, только прямоугольная. Говоря образно, это прорезь в границах которой, спрайт будет отрисовываться, а вне ее, будет не виден. Бывает полезно, когда надо сделать какой-нибудь выдвигающийся, например объект, не подкладывая спрайты друг под друга.

Прочие полезности

Еще одна, необходимая почти в любой игре вещь — это локализация. Я храню все строковые ресурсы в xml файлах с именами типа strings_lang_code.xml. В моих играх язык можно менять динамически, в настройках игры. Это, конечно, разрушает концепцию Android App Bundle с загрузкой из стора только нужных ресурсов для конкретного устройства, локации и т.д., но позволяет пользователю иметь более гибкие языковые настройки.

public static void loadStringsAndFont() {    final String langCode = Prefs.getLanguage();    final AssetManager manager = GdxGame.getManager();    final FileHandleResolver resolver = new InternalFileHandleResolver();    manager.setLoader(FreeTypeFontGenerator.class, new FreeTypeFontGeneratorLoader(resolver));    manager.setLoader(BitmapFont.class, ".ttf", new FreetypeFontLoader(resolver));     final FreetypeFontLoader.FreeTypeFontLoaderParameter size2Params = new FreetypeFontLoader.FreeTypeFontLoaderParameter();    final FontParams params = FontParams.BY_CODE.get(langCode);     size2Params.fontFileName = "font/" + params.fontFileName;    size2Params.fontParameters.size = params.size;    size2Params.fontParameters.characters = params.characters;     if (!manager.isLoaded(params.fontFileName)) {        manager.load(params.fontFileName, BitmapFont.class, size2Params);        manager.finishLoading();        Gdx.app.log(GdxGame.TAG, "Loaded font: " + params.fontFileName);    }     VALUES.clear();    String langFile = ("xml/strings_" + langCode + ".xml").toLowerCase();     try {        XmlReader reader = new XmlReader();        XmlReader.Element root = reader.parse(Gdx.files.internal(langFile).reader("UTF-8"));         for (int i = 0; i < root.getChildCount(); ++i) {            XmlReader.Element element = root.getChild(i);            VALUES.put(element.getAttribute("name"), element.getText());        }         Gdx.app.log(GdxGame.TAG, "Loaded strings from file: " + langFile);    } catch (Exception e) {        Gdx.app.log(GdxGame.TAG, "Error loading strings file: " + langFile);    } }

При старте игры, определяем код языка из настроек устройства, либо берем ранее установленный игроком вручную код языка (он имеет более высокий приоритет). Читаем соответствующий xml файл и помещаем строки в HashMap. Из кода, установка какой-нибудь надписи выглядит примерно так:

final Label text = new Label(Loc.getString(Loc.EXIT_CONFIRM), GdxGame.self().getFontStyle());

Настройки параметров шрифта, я храню в классе FontParams. Он ничем особо не примечателен, просто класс для хранения связки «код языка» — «файл шрифта, размер, алфавит».

Ну и последнее, что я хотел бы показать в рамках этого топика — это работа со звуком. Класс для работы со звуком умеет плавно включать / выключать музыку, автоматически проигрывать разные семплы из одного набора звуков, например, шаги или нажатия. Для этого достаточно в ресурсы поместить все однотипные звуки, добавив счетчик в конце: «glass_tap_1», «glass_tap_2» и т.д. Я использую формат звуковых файлов mp3 для iOS и ogg на всех остальных платформах, метод getPath() нужен для того чтобы правильно определить расширение файла.

public class Snd {      private static final HashMap<String, Float> VOLUME = new HashMap<String, Float>() {         {             put(mus_puzzle, .7f);         }     };      private static final HashMap<String, Integer> COUNTER_MAX = new HashMap<String, Integer>() {         {             put(glitch, 3);         }     };      private HashMap<String, Integer> counterMap = new HashMap<String, Integer>() {         {             put(glitch, 1);         }     };      private HashMap<String, Music> musicMap = new HashMap<String, Music>();      public static String getPath(String name) {         if (Gdx.app.getType() == Application.ApplicationType.iOS) return "mp3/" + name + ".mp3";         return "ogg/" + name + ".ogg";     }      private void musicFadeIn(final Music music, final float volume) {         Timer.schedule(new Timer.Task() {             @Override             public void run() {                 if (music.getVolume() < volume)                     music.setVolume(music.getVolume() + .01f);                 else {                     this.cancel();                 }             }         }, 0f, .01f);     }      private void musicFadeOut(final Music music, final String path) {         Timer.schedule(new Timer.Task() {             @Override             public void run() {                 if (music.getVolume() >= .01f)                     music.setVolume(music.getVolume() - .01f);                 else {                     music.stop();                     musicMap.remove(path);                     this.cancel();                 }             }         }, 0f, .01f);     }      public void playSound(String name) {         if (counterMap.containsKey(name)) {             int counter = counterMap.get(name);             String fullName = name + "_" + counterMap.get(name);              counter++;             if (counter > COUNTER_MAX.get(name)) {                 counter = 1;             }              counterMap.put(name, counter);             name = fullName;         }          GdxGame.getManager().get(getPath(name), Sound.class).play();     }      public void playMusic(String name, boolean force, boolean once) {         final String path = getPath(name);         final AssetManager manager = GdxGame.getManager();          Music music = musicMap.get(path);          if (music == null) {             music = manager.get(path, Music.class);             musicMap.put(path, music);         }          if (music.isPlaying()) return;          music.setLooping(!once);         music.setVolume(0);         music.play();          float volume = VOLUME.containsKey(name) ? VOLUME.get(name) : 1;          if (force) {             music.setVolume(volume);         } else {             musicFadeIn(music, volume);         }     }      public void stopMusic(String name, boolean force) {         final String path = getPath(name);          if (!musicMap.containsKey(path)) return;          if (force) {             musicMap.get(path).stop();             musicMap.remove(path);         } else {             musicFadeOut(musicMap.get(path), path);         }     } }

По коду, наверное, все. Можно еще рассказать про listener, типа перетаскивания или нажатий. Но не хочется скатываться в детали, характерные только для моих игр. Задавайте вопросы в комментах, с удовольствием покажу как у меня устроен тот или иной аспект!

В последнее время, я работаю в жанре point-and-click. Наверное, называть квестом мою игру будет слишком громко, скорее набор головоломок в 2D. Вот так выглядит типичный геймплей (поэтому, рассказать что-то про физику или 3D в LibGDX — не смогу, к сожалению).

Заключение

В заключение, приведу субъективные плюсы и минусы LibGDX как движка для разработки видеоигр.

Плюсы:

  • Бесплатный (безусловно)

  • Небольшой размер билда (это не очень касается ПК, где нужно добавлять JRE в сборку)

  • Java, разработка в Android Studio

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

  • Для Android не нужно плагинов, есть доступ ко всем возможностям Android SDK

Минусы:

  • Нет визуального редактора. Я знаю про VisEditor, но лично у меня он не прижился, не особо удобный, да и редактор — это не только размещение объектов на сцене. Должна быть какая-нибудь система сообщений для последующего их взаимодействия

  • Базовые классы движка совсем базовые, для многих вещей нужно делать свою реализацию

  • Сложная реализация платформо-зависимых функций на iOS, готовых решений катастрофически не хватает. По факту, в моих играх на iOS, почти нет интеграции с экосистемой. Внутриигровые покупки реализованы в движке, остальное — головная боль

  • Нет (?) порта на консоли. Для меня этот момент не особо актуален, так высоко я не летаю 🙂


ссылка на оригинал статьи https://habr.com/ru/articles/578884/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *