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

в 13:52, , рубрики: android, kotlin, libgdx, разработка игр, Разработка под android, я пиарюсь

Всем привет. Сегодня я расскажу об атласе текстур, шкурках, пройдемся еще раз по работе с версткой. Далее интернационализация и в заключение пара тонкостей по работе с цветом. И в следующем уроке перейдем к модели игры и связыванию игровой логики и элементов UI.

Предыдущие части

Атлас текстур

Одним из важнейших параметров «комфортности» приложения является время загрузки. Узким звеном в этом плане является считывание с накопителя. Если мы используем везде вот такие конструкции

Image(Texture("backgrounds/main-screen-background.png"))

то мы создаем избыточную задержки. В данном случае текстура «backgrounds/main-screen-background.png» будет считана с накопителя в синхронном режиме. Это не всегда является злом. Как правило загрузка одной фоновой картинки не портит впечатления от работы с программой. Но если мы будет каждый элемент нашей сцены считывать таким образом, скорость и плавность приложения могут серьезно просесть.

Для оптимизации работы с текстурами, нам гораздо дешевле один раз загрузить одну большую картинку и использовать ее фрагменты в своей работе. Этот подход и называется атлас текстур.

Пример атласа

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

И хотя я большой противник преждевременной оптимизации, работа с атласом текстур дает большие преимущества как в плане скорости работы приложения, так и в плане читаемости. Игнорировать атлас текстур выходит себе дороже. У нас в проекте уже есть класс AtlasGenerator, который сам может объединить картинки из папки в атлас. Вот его код:

object AtlasGenerator {

    @JvmStatic fun main(args: Array<String>) {
        val settings = TexturePacker.Settings()
        settings.maxWidth = 2048
        settings.maxHeight = 2048
        TexturePacker.process(settings, "images", "atlas", "game")
    }
}

В принципе все просто. Параметры: название папки с исходниками, название папки для размещения атласа и собственно название атласа. В больших приложениях имеет смысл делать несколько атласов. К примеру уровень «древний египет» — одни картинки, уровень «космос» — другие. Одновременно они не используются. Гораздо быстрее по времени загрузить только ту часть, которая нужна в данный момент. Но в нашем приложении графики будет минимум, можно обойтись одним атласом. Загрузка атласа и чтение текстуры выглядит так:

val atlas = TextureAtlas(Gdx.files.internal("atlas/game.atlas"))
atlas.findRegion("texture-name")

В нашем приложении загрузка атласа реализована несколько иначе, используя AssetManager но в данный момент это не имеет значения.

Шкурки

Одной из особенностей библиотеки LibGDX является жесткое сцепление кода логики и представления. Мы создаем элементы, указываем размеры, положение, цвет прямо в коде. При этом визуальный стиль требует множественного повторения одних и тех же строчек кода (нарушение принципа DRY). Это очень дорого по затратам. Даже не сама копи-паста, а синхронизация изменений. К примеру вы захотели изменить цвет текста с черного на бронзовый. И в случае хардкода нужно пройтись по всему приложению, поменять один цвет на другой. Часть вы пропустите, часть измените там где измениться не должно было бы. Для решения этой проблемы в LibGDX реализован механизм шкурок. Вот пример нашей:

{
  "com.badlogic.gdx.scenes.scene2d.ui.Label$LabelStyle": {
    "default": {
      "font": "regular-font"
    },
    "large": {
      "font": "large-font"
    },
    "small": {
      "font": "small-font"
    },
    "pane-caption": {
      "font": "large-font",
      "fontColor": "color-mongoose"
    }
  }
}

А вот пример использования шкурки

Label("some text here", uiSkin, "pane-caption")

Как же это работает внутри? До банального просто. Внутри шкурки живет ObjectMap<Class, ObjectMap<String, Object>> resources = new ObjectMap(); Для каждого класса хранятся именованные наборы экземпляров. Json выше как раз заполняет эту мапу значениями. Через рефлекшн создается объект и также через рефлекшн заполняются поля. Вот пример создания и работы шкурки:


val atlas = TextureAtlas(Gdx.files.internal("atlas/game.atlas"))
val skin = Skin(atlas)
skin.getDrawable("texture-name")
skin.get("default", Label.LabelStyle::class.java)
Label("some text here", skin , "pane-caption")

Верстка

Результатом сегодняшней работы станет появление панели экспедиции при нажатии на кнопку «Сапог». На этом примере мы посмотрим как расширять верстку приложения сохраняя базовую идею, добавление/удаление акторов в сцену, пару-тройку новых layout-контейнеров. Итак наш прошлый код:

row().let {
    add(Image(Texture("backgrounds/main-screen-background.png")).apply {
        setScaling(Scaling.fill)
    }).expand()
}

В центре окна мы разместили картинку. Теперь же нам хочется использовать эту центральную часть как контейнер. Есть два варианта. Использовать Container с указанием background или использовать Stack. Stack это layout-контейнер который все свои дочерние элементы рисует поверх себя в том порядке как добавляли. Размеры элементов всегда устанавливаются как размеры Stack'a. Мы остановимся на первом варианте, т.к. картинка это снова «заглушка». В итоговой версии мы будем использовать TiledMapRenderer для рисования карты.

val centralPanel = Container<WidgetGroup>()
row().let {
    add(centralPanel.apply {
        background = TextureRegionDrawable(TextureRegion(Texture("backgrounds/main-screen-background.png")))
        fill()
        pad(AppConstants.PADDING * 2)
    }).expand()
}

В данном случае мы объявляем переменную centralPanel за пределами row().let {...} т.к. мы будем передавать ее в виде параметра. Идея такая, CommandPanel (панель с кнопками внизу) не должна знать где она располагается и куда в общей сцене ей вставлять новые элементы. Поэтому мы в конструктор передаем centralPanel и внутри CommandPanel вешаем обработчик на кнопку:

class CommandPanel(val centralPanel: Container<WidgetGroup>) : Table() {
...
add(Button(uiSkin.getDrawable("command-move")).apply {
    addListener(object : ChangeListener() {
        override fun changed(event: ChangeEvent?, actor: Actor?) {
            when (isChecked) {
                false -> centralPanel.actor = null
                true -> centralPanel.actor = ExplorePanel()
            }
        }
    })
})

Так как в конструкторе у параметра есть ключевое слово val, это финальное поле будет доступно во любом месте класса. Если бы его не было, то этот параметр был бы доступен только в блоке init {… }. Вместо if-then я использовал when (аналог java-switch) т.к. он дает лучшую читаемость. Когда кнопка нажата в панель встраивается ExplorePanel, когда отжата — центральная панель очищается.

Верстка плашки местности

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

Верстка панели экспедиции

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

Для верстки плашки местности мы будем использовать два новых layout-контейнера. VerticalGroup и HorizontalGroup. Это «облегченные» варианты таблицы, которые, среди всего прочего обладают одним достоинством. Удаление элемента из них приводит к удалению ряда/колонки. Это не верно для таблицы. Даже если у вас таблица однорядная, удаление элемента в колонке просто делает ячейку пустой. Также модификаторы expand/fill/space/pad для Container, VerticalGroup, HorizontalGroup применяются сразу ко всем элементам. Для таблицы эти значения применяются к каждой ячейке.

class ExplorePanel : Table() {

    init {
        background = uiSkin.getDrawable("panel-background")
        pad(AppConstants.PADDING)

        row().let {
            add(TerrainPane())
        }

        row().let {
            add(SearchPane())
        }

        row().let {
            add(MovePane())
        }

        row().let {
            add(TownPortalPane())
        }

        row().let {
            add().expand() // для подпружинивания элементов
        }
    }
}

В данном случае ExplorePanel реализована через таблицу, но никто не мешает сделать через VerticalGroup. Это в принципе дело вкуса. Самым нижним элементом идет добавление пустой ячейки с модификатором expand. Эта ячейка старается занять максимальное пространство, тем самым «подпружинивая» первые элементы вверх.

А вот плашка местности:

class TerrainPane : WoodenPane() {

    init {

        add(Image(uiSkin.getDrawable("terrain-meadow"))).width(160f).height(160f).top()

        add(VerticalGroup().apply {
            space(AppConstants.PADDING)

            addActor(Label(i18n["terrain.meadow"], uiSkin, "pane-caption"))

            addActor(HorizontalGroup().apply {
                space(AppConstants.PADDING)

                addActor(Image(uiSkin.getDrawable("herbs-01")))
                addActor(Image(uiSkin.getDrawable("herbs-unidentified")))
                addActor(Image(uiSkin.getDrawable("herbs-unidentified")))
                addActor(Image(uiSkin.getDrawable("herbs-unidentified")))
                addActor(Image(uiSkin.getDrawable("herbs-unidentified")))
            })
        }).expandX().fill()
    }
}

Пока сделайте «развидеть» интернационализацию (i18n) и просто обратите внимание на верстку. WoodenPane это фактически Table (на самом деле Button, который как я уже упоминал является наследником Table). В нем добавляются два актора. Картинка местности и вертикальная группа. В вертикальной группе одна ячейка текст, вторая ячейка — горизонтальная группа из пяти картинок. Аналогичным образом сделаны плашки действий — Поиск, Передвижение и Возврат в город. Как я уже упоминал, навешивать логику и связывать с моделью данных будем в следующей части.

Интернационализация

Кто работал с интернационализацией хоть в каком-либо виде, для тех не будет ничего нового. Интернационализация работает совершенно однотипно. Есть базовый файл .properties в котором сохранены пары ключ-значение. Есть вспомогательные файлы xxx_ru.properties, xxx_en.properties, xxx_fr.properties. В зависимости от локали устройства загружается подходящий вспомогательный файл (если определен) или базовый (при отсутствии совпадений). В нашем случае файлы интернационализации выглядят так:
medieval-tycoon.properties
medieval-tycoon_en.properties
medieval-tycoon_ru.properties
... содержимое ...
explore.move=Идти
explore.search=Искать
explore.town-portal=Портал в Город
terrain.forest=Лес
terrain.meadow=Луг
terrain.swamp=Болото

Я вынес имя i18n в глобальное пространство имен

val i18n: I18NBundle
    get() = assets.i18n

class MedievalTycoonGame : Game() {
    lateinit var assets: Assets

class Assets {
    val i18n: I18NBundle by lazy {
        manager.get(i18nDescriptor)
    }

Опять-таки загрузка идет через менеджер ассетов. Классический вариант загрузки I18NBundle выглядит так:


val i18n = I18NBundle.createBundle(Gdx.files.internal("i18n/fifteen-puzzle"), Locale.getDefault())

В дальнейшем, вместо текста мы просто вставляем i18n.get(«имя.ключа»)

Пара тонкостей при работе с цветом

В шкурках очень хочется использовать цветовые константы. Но если вы попробуете написать так, то программа вылетит с ошибкой.

{
  "com.badlogic.gdx.scenes.scene2d.ui.Label$LabelStyle": {
    "pane-caption": {
      "font": "large-font",
      "fontColor": "color-mongoose"
    }
  }
}

Дело даже не в том, что LibGDX ничего не знает про цвет «мангуст», шкурки по умолчанию не знают даже про «black» & «white». Но при создании шкурки, мы можем передать параметром ObjectMap<String, Any>(), в который и поместить ходовые цвета и базовые цвета палитры приложения. Выглядит это так:

Добавление текстовых идентификаторов цвета


private val skinResources = ObjectMap<String, Any>()
private val skinDescriptor = AssetDescriptor("default-ui-skin.json", Skin::class.java,
        SkinLoader.SkinParameter("atlas/game.atlas", skinResources))
...
loadColors()
manager.load(skinDescriptor)
...
private fun loadColors() {
    skinResources.put("color-mongoose", Color.valueOf("BAA083"))

    skinResources.put("clear", Color.CLEAR)
    skinResources.put("black", Color.BLACK)

    skinResources.put("white", Color.WHITE)
    skinResources.put("light_gray", Color.LIGHT_GRAY)
    skinResources.put("gray", Color.GRAY)
    skinResources.put("dark_gray", Color.DARK_GRAY)

    skinResources.put("blue", Color.BLUE)
    skinResources.put("navy", Color.NAVY)
    skinResources.put("royal", Color.ROYAL)
    skinResources.put("slate", Color.SLATE)
    skinResources.put("sky", Color.SKY)
    skinResources.put("cyan", Color.CYAN)
    skinResources.put("teal", Color.TEAL)

    skinResources.put("green", Color.GREEN)
    skinResources.put("chartreuse", Color.CHARTREUSE)
    skinResources.put("lime", Color.LIME)
    skinResources.put("forest", Color.FOREST)
    skinResources.put("olive", Color.OLIVE)

    skinResources.put("yellow", Color.YELLOW)
    skinResources.put("gold", Color.GOLD)
    skinResources.put("goldenrod", Color.GOLDENROD)
    skinResources.put("orange", Color.ORANGE)

    skinResources.put("brown", Color.BROWN)
    skinResources.put("tan", Color.TAN)
    skinResources.put("firebrick", Color.FIREBRICK)

    skinResources.put("red", Color.RED)
    skinResources.put("scarlet", Color.SCARLET)
    skinResources.put("coral", Color.CORAL)
    skinResources.put("salmon", Color.SALMON)
    skinResources.put("pink", Color.PINK)
    skinResources.put("magenta", Color.MAGENTA)

    skinResources.put("purple", Color.PURPLE)
    skinResources.put("violet", Color.VIOLET)
    skinResources.put("maroon", Color.MAROON)
}

Это пример с использованием AssetManager'a. Можно сделать и так (главное делать до загрузки skin.json файла):

uiSkin.add("black", Color.BLACK)
uiSkin.load(Gdx.files.internal("uiskin.json"))

И напоследок. Label можно «красить» двумя способами. Правильно и неправильно.


color = Color.BLACK // неправильно
style.fontColor = Color.BLACK // правильно

У меня не хватает знаний чтобы объяснить механику отрисовки. На пальцах это примерно так: любой актор можно нарисовать с оттенком. Берете картинку выполненную в оттенках бело-серого, задаете цвет и вместо бело-серого изображения получаете к примеру желтый-темно-желтый или красный-темно-красный. Проблема в том что финальный оттенок идет «умножением». И если вместо бело-серой основы будет красная картинка, а оттенок синий, то результат получится черный. Фактически это очень плохой и трудоемкий вариант получения хорошего результата. Подобрать интенсивность серого чтобы красно-зелено-желто-синие варианты смотрелись достоверно очень непросто. Плюс, если я не ошибаюсь, там есть какая-то проблема с сохранением прозрачности.

Второй вариант работает отлично. Шрифт генерируется белый, в моем случае с полупрозрачной темной обводкой.

val largeFont = FreetypeFontLoader.FreeTypeFontLoaderParameter()
largeFont.fontFileName = "fonts/Merriweather-Bold.ttf"
...
largeFont.fontParameters.borderColor = Color.valueOf("00000080")
largeFont.fontParameters.borderWidth = 4f
...
Результат

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

В конечном примере нет нормальной верстки для плашек действия. Вы можете попробовать реализовать ее самостоятельно по аналогии с TerrainPane.

Автор: -

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js