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