Всем привет. Сегодня я расскажу об атласе текстур, шкурках, пройдемся еще раз по работе с версткой. Далее интернационализация и в заключение пара тонкостей по работе с цветом. И в следующем уроке перейдем к модели игры и связыванию игровой логики и элементов UI.
Одним из важнейших параметров «комфортности» приложения является время загрузки. Узким звеном в этом плане является считывание с накопителя. Если мы используем везде вот такие конструкции
Для оптимизации работы с текстурами, нам гораздо дешевле один раз загрузить одну большую картинку и использовать ее фрагменты в своей работе. Этот подход и называется атлас текстур.
В нашем приложении загрузка атласа реализована несколько иначе, используя AssetManager но в данный момент это не имеет значения.
Одной из особенностей библиотеки LibGDX является жесткое сцепление кода логики и представления. Мы создаем элементы, указываем размеры, положение, цвет прямо в коде. При этом визуальный стиль требует множественного повторения одних и тех же строчек кода (нарушение принципа DRY). Это очень дорого по затратам. Даже не сама копи-паста, а синхронизация изменений. К примеру вы захотели изменить цвет текста с черного на бронзовый. И в случае хардкода нужно пройтись по всему приложению, поменять один цвет на другой. Часть вы пропустите, часть измените там где измениться не должно было бы. Для решения этой проблемы в LibGDX реализован механизм шкурок. Вот пример нашей:
Как же это работает внутри? До банального просто. Внутри шкурки живет ObjectMap<Class, ObjectMap<String, Object>> resources = new ObjectMap(); Для каждого класса хранятся именованные наборы экземпляров. Json выше как раз заполняет эту мапу значениями. Через рефлекшн создается объект и также через рефлекшн заполняются поля. Вот пример создания и работы шкурки:
Результатом сегодняшней работы станет появление панели экспедиции при нажатии на кнопку «Сапог». На этом примере мы посмотрим как расширять верстку приложения сохраняя базовую идею, добавление/удаление акторов в сцену, пару-тройку новых layout-контейнеров. Итак наш прошлый код:
Для верстки плашки местности мы будем использовать два новых layout-контейнера. VerticalGroup и HorizontalGroup. Это «облегченные» варианты таблицы, которые, среди всего прочего обладают одним достоинством. Удаление элемента из них приводит к удалению ряда/колонки. Это не верно для таблицы. Даже если у вас таблица однорядная, удаление элемента в колонке просто делает ячейку пустой. Также модификаторы expand/fill/space/pad для Container, VerticalGroup, HorizontalGroup применяются сразу ко всем элементам. Для таблицы эти значения применяются к каждой ячейке.
А вот плашка местности:
Кто работал с интернационализацией хоть в каком-либо виде, для тех не будет ничего нового. Интернационализация работает совершенно однотипно. Есть базовый файл .properties в котором сохранены пары ключ-значение. Есть вспомогательные файлы xxx_ru.properties, xxx_en.properties, xxx_fr.properties. В зависимости от локали устройства загружается подходящий вспомогательный файл (если определен) или базовый (при отсутствии совпадений). В нашем случае файлы интернационализации выглядят так:
Я вынес имя i18n в глобальное пространство имен
В шкурках очень хочется использовать цветовые константы. Но если вы попробуете написать так, то программа вылетит с ошибкой.
Это пример с использованием AssetManager'a. Можно сделать и так (главное делать до загрузки skin.json файла):
И напоследок. Label можно «красить» двумя способами. Правильно и неправильно.
Второй вариант работает отлично. Шрифт генерируется белый, в моем случае с полупрозрачной темной обводкой.
В конечном примере нет нормальной верстки для плашек действия. Вы можете попробовать реализовать ее самостоятельно по аналогии с TerrainPane.
Update:
Предыдущие части
Атлас текстур
Одним из важнейших параметров «комфортности» приложения является время загрузки. Узким звеном в этом плане является считывание с накопителя. Если мы используем везде вот такие конструкции
то мы создаем избыточную задержки. В данном случае текстура «backgrounds/main-screen-background.png» будет считана с накопителя в синхронном режиме. Это не всегда является злом. Как правило загрузка одной фоновой картинки не портит впечатления от работы с программой. Но если мы будет каждый элемент нашей сцены считывать таким образом, скорость и плавность приложения могут серьезно просесть.Image(Texture("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-контейнеров. Итак наш прошлый код:
В центре окна мы разместили картинку. Теперь же нам хочется использовать эту центральную часть как контейнер. Есть два варианта. Использовать Container с указанием background или использовать Stack. Stack это layout-контейнер который все свои дочерние элементы рисует поверх себя в том порядке как добавляли. Размеры элементов всегда устанавливаются как размеры Stack'a. Мы остановимся на первом варианте, т.к. картинка это снова «заглушка». В итоговой версии мы будем использовать TiledMapRenderer для рисования карты.row().let { add(Image(Texture("backgrounds/main-screen-background.png")).apply { setScaling(Scaling.fill) }).expand() }
В данном случае мы объявляем переменную centralPanel за пределами row().let {...} т.к. мы будем передавать ее в виде параметра. Идея такая, CommandPanel (панель с кнопками внизу) не должна знать где она располагается и куда в общей сцене ей вставлять новые элементы. Поэтому мы в конструктор передаем centralPanel и внутри CommandPanel вешаем обработчик на кнопку:val centralPanel = Container<WidgetGroup>() row().let { add(centralPanel.apply { background = TextureRegionDrawable(TextureRegion(Texture("backgrounds/main-screen-background.png"))) fill() pad(AppConstants.PADDING * 2) }).expand() }
Так как в конструкторе у параметра есть ключевое слово val, это финальное поле будет доступно во любом месте класса. Если бы его не было, то этот параметр был бы доступен только в блоке init {… }. Вместо if-then я использовал when (аналог java-switch) т.к. он дает лучшую читаемость. Когда кнопка нажата в панель встраивается ExplorePanel, когда отжата — центральная панель очищается.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() } } }) })
Верстка плашки местности
Верстка панели экспедиции
Для верстки плашки местности мы будем использовать два новых layout-контейнера. VerticalGroup и HorizontalGroup. Это «облегченные» варианты таблицы, которые, среди всего прочего обладают одним достоинством. Удаление элемента из них приводит к удалению ряда/колонки. Это не верно для таблицы. Даже если у вас таблица однорядная, удаление элемента в колонке просто делает ячейку пустой. Также модификаторы expand/fill/space/pad для Container, VerticalGroup, HorizontalGroup применяются сразу ко всем элементам. Для таблицы эти значения применяются к каждой ячейке.
В данном случае ExplorePanel реализована через таблицу, но никто не мешает сделать через VerticalGroup. Это в принципе дело вкуса. Самым нижним элементом идет добавление пустой ячейки с модификатором expand. Эта ячейка старается занять максимальное пространство, тем самым «подпружинивая» первые элементы вверх.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() // для подпружинивания элементов } } }
А вот плашка местности:
Пока сделайте «развидеть» интернационализацию (i18n) и просто обратите внимание на верстку. WoodenPane это фактически Table (на самом деле Button, который как я уже упоминал является наследником Table). В нем добавляются два актора. Картинка местности и вертикальная группа. В вертикальной группе одна ячейка текст, вторая ячейка — горизонтальная группа из пяти картинок. Аналогичным образом сделаны плашки действий — Поиск, Передвижение и Возврат в город. Как я уже упоминал, навешивать логику и связывать с моделью данных будем в следующей части.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() } }
Интернационализация
Кто работал с интернационализацией хоть в каком-либо виде, для тех не будет ничего нового. Интернационализация работает совершенно однотипно. Есть базовый файл .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
Опять-таки загрузка идет через менеджер ассетов. Классический вариант загрузки I18NBundle выглядит так:class Assets { val i18n: I18NBundle by lazy { manager.get(i18nDescriptor) }
В дальнейшем, вместо текста мы просто вставляем i18n.get(«имя.ключа»)val i18n = I18NBundle.createBundle(Gdx.files.internal("i18n/fifteen-puzzle"), Locale.getDefault())
Пара тонкостей при работе с цветом
В шкурках очень хочется использовать цветовые константы. Но если вы попробуете написать так, то программа вылетит с ошибкой.
Дело даже не в том, что LibGDX ничего не знает про цвет «мангуст», шкурки по умолчанию не знают даже про «black» & «white». Но при создании шкурки, мы можем передать параметром ObjectMap<String, Any>(), в который и поместить ходовые цвета и базовые цвета палитры приложения. Выглядит это так:{ "com.badlogic.gdx.scenes.scene2d.ui.Label$LabelStyle": { "pane-caption": { "font": "large-font", "fontColor": "color-mongoose" } } }
Добавление текстовых идентификаторов цвета
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:
Немного забавного оффтопика
Имя цвета по HEX коду
Paletton.com — мне понравился
Статья с хабра
Подбор палитры цветов
Кулер. Классика жанраPaletton.com — мне понравился
Статья с хабра
