LibGDX + Scene2d (программируем на Kotlin). Часть 1

    И снова здравствуйте. По результатам прошлой публикации, я пришел к выводу что опять совершаю ошибки. Высокие темп публикации неудобен ни мне, ни вам. И попробую еще подсократить теорию, но приводить больше примеров кода.

    Небольшое лирическое отступление. LibGDX в значительной части представляет из себя простую обертку над OpenGL. Просто работа с текстурами. Все что мы делаем — это указываем порядок и способ отрисовки текстур. Базовый инструмент для рисования текстур — Drawable.

    Drawable


    Drawable, это такая штука, которая встречается в Scene2d буквально на каждом шагу. Картинки, кнопки, фоны элементов, всякие элементы слайдеров, панелей прокруток и т.д. — все они используют Drawable для отображения себя на экране. С практической точки зрения, нас не сильно заботит как он устроен внутри. Потому что работать мы будем с тремя конкретными реализациями Drawable. Это TextureRegionDrawable, TiledDrawable и NinePatchDrawable. Вот текстура которую мы хотим нарисовать на экране:


    А вот три варианта Drawable на основе этой текстуры


    Первый вариант — TextureRegionDrawable. Он просто растягивает текстуру под заданные координаты. Второй вариант — TiledDrawable. Текстура повторяется множество раз, при этом масштаб не меняется. И третий вариант, это 9-Box или 9-Patch. Чем же он хорош и когда его следует использовать?

    9-Patch


    9-Patch сохраняет внешние элементы в том виде, в котором они определены вне зависимости от размера центрального объекта. Широко используется для кнопок, диалоговых окон, панелей и т.п. Представьте если бы у одной панели внешняя рамка была бы в 2 раза толще или тоньше чем соседняя.

    Table Layout


    Как я упоминал вчера, сцена это иерархический набор элементов (потомки класса Actor). Все акторы делятся на две группы — Widget и WidgetGroup. Widget это листья дерева, которые не могут содержать дочерних элементов. WidgetGroup — это узлы. То есть все их различие заключается в том, что WidgetGroup умеют «раскладывать» дочерние элементы в определенном порядке. Все обучение Scene2d сводится к умению комбинировать эти объекты. К примеру кнопка в LibGDX это WidgetGroup, наследник Table. Она может содержать и текст, и изображение. Ну и любую другую верстку как любая другая таблица.

    Изображение


    Kotlin
    class TableStage : Stage() {
    
        init {
    
            val stageLayout = Table()
            addActor(stageLayout.apply {
                debugAll()
                setFillParent(true)
    
                pad(AppConstants.PADDING)
                defaults().expand().space(AppConstants.PADDING)
    
                row().let {
                    add(Image(uiSkin.getDrawable("sample")))
                    add(Image(uiSkin.getDrawable("sample"))).top().right()
                    add(Image(uiSkin.getDrawable("sample"))).fill()
                }
    
                row().let {
                    add(Image(uiSkin.getTiledDrawable("sample"))).fillY().left().colspan(2)
                    add(Image(uiSkin.getTiledDrawable("sample"))).width(64f).height(64f).right().bottom()
                }
    
                row().let {
                    add(Image(uiSkin.getDrawable("sample")))
                    add(Image(uiSkin.getTiledDrawable("sample"))).fill().pad(AppConstants.PADDING)
                    add(Image(uiSkin.getDrawable("sample"))).width(64f).height(64f)
                }
            })
        }
    }


    Код использует Атлас Текстур/Шкурки для улучшения читаемости. Как настраивать лучше посмотреть в репозитории. Описывать принципы их работы это на целую отдельную статью.

    Что мы видим в коде:

            ... 
            val stageLayout = Table()
            addActor(stageLayout.apply { // добавление таблицы в сцену
                debugAll() // Включаем дебаг для всех элементов таблицы
                setFillParent(true) // Указываем что таблица принимает размеры родителя
    
                pad(AppConstants.PADDING)
                defaults().expand().space(AppConstants.PADDING)
    
                row().let {
                    add(Image(uiSkin.getDrawable("sample")))
                    add(Image(uiSkin.getDrawable("sample"))).top().right()
                    add(Image(uiSkin.getDrawable("sample"))).fill()
                }
    

    Все, что внутри .apply применяется к объекту, на котором apply был вызван. Метод setFillParent(true) правильно использовать только один раз при добавлении корневого элемента в сцену. Так как он используется очень редко, я про него постоянно забываю и не сразу понимаю почему сцена у меня пустая.

    Самая распространенная ошибка: забыть добавить setFillParent(true) в корневой элемент

    Тот же пример на java

            ... 
            Table stageLayout = new Table();
            stageLayout.debugAll();
            stageLayout.setFillParent(true);
    
            stageLayout.pad(AppConstants.PADDING);
            stageLayout.defaults().expand().space(AppConstants.PADDING);
    
            stageLayout.row();
            stageLayout.add(Image(uiSkin.getDrawable("sample")));
            stageLayout.add(Image(uiSkin.getDrawable("sample"))).top().right();
            stageLayout.add(Image(uiSkin.getDrawable("sample"))).fill();
    
            addActor(stageLayout);
    

    Самое важное отличие — отсутствие форматирования кода согласно логике вложения. Вся портянка элемента выровнена по левому краю и очень легко запутаться, т.к. большинство методов общие на уровне Widget/WidgetGroup.

    В Kotlin'e я применял к row() функцию сокрытия видимости .let, которую я еще ни разу не видел чтобы использовали как функцию сокрытия видимости. Самый распространенный вариант ее использования — null check. Внутри let поле будет доступно как it и гарантированно не-нулл.

    var name: String? = ...
    name?.let { 
        if (it == "Alex") ...
    }
    

    Методы разметки таблицы




    add — добавляет ячейку в ряд. Возвращает Cell к которому можно применять модификаторы
    row — добавляет row. Возвращает default Cell для ряда. Модификаторы, примененные к default Cell будут автоматически применены ко все ячейкам этого ряда.

    expand/expandX/expandY — «пружинки». Меняют размер ячеек (но не содержимого). По умолчанию содержимое ячеек расположено в центре

    width/height — задает размер ячейки фиксировано или в процентном соотношении.

    .width(40f)
    .width(Value.percentWidth(.4f, stageLayout)
    

    fill/fillX/fillY — заставляет содержимое ячейки принять размер ячейки

    left/right/top/bottom — если содержимое ячейки меньше размеров, указывает способ выравнивания

    Делаем верстку первого экрана:



    Я сделал набор иконок которые поясняют примененные модификаторы к ячейкам
    Пружинки — expand/expandX/expandY (раздвигают ячейку)
    Стрелки — fill/fillX/fillY (содержимое ячейки заполняет ячейку)
    Швеллер — фиксированный размер ширины/высоты (фиксирует размеры ячейки по ширине/высоте)

    Container<> Layout


    Контейнер может иметь только один Widget. Имеет Drawable background. Поэтому мы будем использовать его чтобы нарисовать на экране header и footer (панель ресурсов/командная панель).

    val stageLayout = Table()
    addActor(stageLayout.apply {
    ...
        row().let {
            val headerContainer = Container<WidgetGroup>()
            add(headerContainer.apply {
                background = TextureRegionDrawable(TextureRegion(Texture("images/status-bar-background.png")))
                // здесь в следующей части мы добавим панель ресурсов
            }).height(100f).expandX()
        }
    

    Полный код главной сцены
    val stageLayout = Table()
    addActor(stageLayout.apply {
        setFillParent(true)
    
        defaults().fill()
    
        row().let {
            val headerContainer = Container<WidgetGroup>()
            add(headerContainer.apply {
                background = TextureRegionDrawable(TextureRegion(Texture("images/status-bar-background.png")))
            }).height(100f).expandX()
        }
    
        row().let {
            add(Image(Texture("backgrounds/main-screen-background.png")).apply {
                setScaling(Scaling.fill)
            }).expand()
        }
    
        row().let {
            val footerContainer = Container<WidgetGroup>()
            add(footerContainer.apply {
                background = TextureRegionDrawable(TextureRegion(Texture("images/status-bar-background.png")))
                fill()
    
                actor = CommandPanel()
            }).height(160f).expandX()
        }
    })
    


    Верстка Loading Screen


    Попробуем подойти аналогичным образом к верстке загрузочного экрана:

    Прототип верстки


    Пример кода:

    val stageLayout = Table()
    addActor(stageLayout.apply {
        setFillParent(true)
        background = TextureRegionDrawable(TextureRegion(Texture("backgrounds/loading-logo.png")))
    })
    

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

    val stageLayout = Table()
    val backgroundImage = Image(Texture("backgrounds/loading-logo.png"))
    addActor(backgroundImage.apply {
        setFillParent(true)
        setScaling(Scaling.fill)
    })
    

    Допустим вот такой вариант. Мы используем изображение, и говорим что масштабировать его надо сохраняя пропорции до тех пор пока меньшая сторона упрется в край. При этом большая сторона будет обрезана. Другой вариант — Scaling.fit. Масштабирование будет идти пока большая часть не упрется в край, меньшая часть будет иметь незаполненные участки (letterbox).

    Но что делать если мы, к примеру, хотим разместить Progress Bar где-то в 20% пространства снизу и чтобы он занимал 60% экрана. Никто не запрещает добавить в сцену несколько actor'ов верхнего уровня. Будет так:

    Экран


    Код
    init {
        val backgroundImage = Image(Texture("backgrounds/loading-logo.png"))
        addActor(backgroundImage.apply {
            setFillParent(true)
            setScaling(Scaling.fill)
        })
    
        val stageLayout = Table()
        addActor(stageLayout.apply {
            setFillParent(true)
    
            row().let {
                add().width(Value.percentWidth(.6f, stageLayout)).height(Value.percentHeight(.8f, stageLayout))
            }
    
            row().let {
                add(progressBar).height(40f).fill() // про progressBar будет в следующих частях
            }
        })
    }
    


    На этом на сегодня все. Пожалуйста оставляйте комментарии что бы вы хотели увидеть более подробно и/или предложения как улучшить подачу материала.

    P.S. На итоговом экране есть командная панель с 4 кнопками. Используя материал из данной статьи вы можете самостоятельно ее реализовать. Ответ в репозитории. Следующая статья через неделю.

    Результат части 1

    Share post

    Comments 2

      +1
      Спасибо за разбор виджетов, жалко что нет какого-то визуального редактора для этого. Пытался использовать Vis, но он какой-то недоделанный.
      > Ответ в репозитории
      В этом, я полагаю?
        0
        Да, в этой ссылке. Сейчас добавлю ее в статью. Спасибо!

        Касательно WYSIWYG редактора, то боюсь его ждать бессмысленно. В LibGDX нет разделения разметки и логики. Но есть Actions — трансформации элементов.То есть на выходе такого редактора должен быть набор классов что само по себе может быть решено. Но фактически это приведет к образованию над-фреймворка. Со своими правилами, подходами и багами. Он будет тяжеловеснее и/или урезаннее по возможностям по сравнению с LibGDX.

        Можно сравнить с html + css + визуальными редакторами. Да, они позволяют нарисовать страничку. Но тот вариант что они предлагают на выходе сильно упрощенный и не оптимальный код. Поддерживать его можно только в визуальном редакторе. А html по сравнению с LibGDX жестко стандартизован и прост.

      Only users with full accounts can post comments. Log in, please.