Как стать автором
Обновить

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

Java *Разработка игр *Разработка под Android *

Привет, Хабр! 👋 В этом топике хочу поговорить о незаслуженно забытом, бесплатном фреймворке для разработки кросс-платформенных игр - 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, почти нет интеграции с экосистемой. Внутриигровые покупки реализованы в движке, остальное - головная боль

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

Теги:
Хабы:
Всего голосов 16: ↑16 и ↓0 +16
Просмотры 4.9K
Комментарии Комментарии 13