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

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

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


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


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

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

    И хотя я большой противник преждевременной оптимизации, работа с атласом текстур дает большие преимущества как в плане скорости работы приложения, так и в плане читаемости. Игнорировать атлас текстур выходит себе дороже. У нас в проекте уже есть класс 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, когда отжата — центральная панель очищается.
    Верстка плашки местности


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


    Для верстки плашки местности мы будем использовать два новых 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
    ...


    Результат



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

    Update:
    Немного забавного оффтопика
    Поделиться публикацией
    Похожие публикации
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама
    Комментарии 7
    • +1
      // зачем мейн класть в объект, а потом делать его статическим
      // через костыль обратной совместимости
      object AtlasGenerator {
          @JvmStatic fun main(args: Array<String>) {
              // ...
          }
      }
      // если вот так вот отлично работает:
      fun main(args: Array<String>) {
              // ...
      }
      //  и как раз объявляет статический методв в терминах джавы?
      
      • –1
        Ммм… а не подскажите как запустить эту функцию как приложение в Android Studio? Я просто скопировал класс из java в kotlin и не трогал его больше. Сейчас попробовал заменить class AtlasGenerator на статическую main функцию и возможность запуска ее как Application пропала. Ругается нет класса.
        • 0
          класс в котором Котлин создаёт эту функцию называется по имени файла. Например, если файл `main.kt` то класс будет `MainKt` в том пакете, который у вас в этом файле объявлен.
      • 0
        Вроде бы всё было хорошо, по чему шкурки? Это звучит не плохо, возможно лучше было бы назвать их внешней оболочкой, ну или просто оболочкой?:)
        • 0
          Будет ли продолжение?
          • 0
            Прошу прощения за задержку, просто я переехал работать в Прагу. Осваиваюсь в новом коллективе. Постараюсь в следующие выходные написать следующую часть.
            • 0
              Удачи на новом месте!

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