Перед вами первая заметка на тему разработки игр на Go с использованием библиотеки ebiten (также известный как Ebitengine). Сегодня мы будем разбираться, как выполняется позиционирование текста. Как центрировать его, менять межстрочный интервал и так далее. Официальная документация и примеры содержат почти всё необходимое, но чтобы свести всё воедино и понять все концепции можно потратить несколько вечеров. Я постараюсь сэкономить ваше время.  Мы будем придерживаться следующей структуры проекта: В директории _assets будут располагаться все ресурсы: спрайты, шрифты, звуки и всё остальное. Эти ресурсы будут встроены в исполняемый файл через go:embed. Поскольку наши примеры будут довольно простыми, всё приложение будет описано в одном файле main пакета (main.go). Наш тестовый экран будет состоять из сетки на белом фоне. Так будет проще оценивать размеры и качество выравнивания. Создадим реализацию ebiten.Game, которая будет рисовать подобную сцену: Функция main может выглядеть как-то так: Результат запуска программы:  Чтобы было интереснее, мы не будем использовать моноширинный шрифт. Для своих примеров я возьму шрифт Zack and Sarah. Сначала нам понадобится создать объект font.Face из файла _assets/font.ttf. В настоящей игре загрузкой и инициализацией подобных ресурсов будет заниматься загрузчик (resource loader), но сейчас мы рассмотрим пример загрузки шрифта с самого начала.  Будем считать, что созданный fontFace нам всегда будет доступен через ctx.GetFontFace("font.ttf").  Добавим в метод Game.Draw эти строки:  Ожидали такой результат? Так или иначе, сейчас будем разбираться, что это за позиционирование такое и как с этим жить. Мы вызвали DrawWithOptions, указав позицию отрисовки (64, 64). При этом даже сама документация явно подчёркивает, что это не левый верхний угол, а некий dot position. Перед тем, как мы начнём осваивать эти детали, посмотрим на результат ещё раз и проведём дополнительный эксперимент.  Парочка наблюдений: Сделаем предположение, что текст растёт наверх. Проверим это, добавив перенос строки.  Это предположение было неверным. Где-то в этот момент можно признать, что без более детального изучения документации нам не продвинуться. Нам будет полезна следующая иллюстрация:  Dot position из документации — это baseline. Позиция y задаёт стартовую позицию отрисовки, через эту ось будет проходить baseline. Часть текста, что оказалась ниже baseline — это descent. Когда мы выводим несколько строк текста через один вызов text.DrawWithOptions, ebiten будет спускаться на line height пикселей вниз для каждого символа \n. Теперь обратим внимание на font.Face. Для нас очень полезен метод font.Face.Metrics(), который возвращает font.Metrics. Если мы посмотрим на поля font.Metrics, то увидим связь. Функция text.BoundString позволяет измерить область, которую займёт некий текст, отрисованный выбранным шрифтом. Так мы можем узнать высоту и ширину результата. Итак, чтобы отрисовать текст в координате (x, y) так, чтобы это было левым верхним углом, нам достаточно смещать текст по оси y на выбранную величину. Этой выбранной величиной может быть Metrics.Ascent или Metrics.CapHeight.  Если применять CapHeight, часть текста может выходить за пределы y, но обычно это ожидаемое поведение. Если хочется этого избежать, стоит использовать Ascent для смещения, но тогда будет сложнее реализовать vertical align center. Фреймворки для создания игр часто предоставляют компонент типа Label. В ebiten нет такого компонента, поэтому нам нужно будет создавать его самостоятельно. Особенности нашего Label: Другими полезными возможностями могут быть автоматические переносы слов на новую строку (вместо расширения блока) и обрезание текста, а также отступы (padding). Введём некоторые типы и константы, а затем приступим к реализации самих алгоритмов: По умолчанию получаем следующее поведение: Прямоугольник нам будет полезен для отладки, чтобы визуально видеть область, занимаемую компонентом Label. Использоваться объекты типа Label будут так: кто-то, кто управляет текущей сценой, будет вызывать метод Draw() у каждого графического элемента. Графические элементы могут храниться в виде слайса из интерфейсов с этим методом, но мы можем представить, что в Game хранится слайс *Label (вернёмся к этому конце статьи). Первая версия Draw(), без поддержки выравнивания, будет выглядеть так: Рабочий размер Label (width, height) делает выравнивание текста более предсказуемым. Вместо того чтобы вычислять размеры исходя от позиции и размера отображаемого текста, мы можем иметь фиксированное пространство, относительно которого мы выполняем преобразования. В случае, если width и height равны нулю, то рабочим размером будет считаться результат text.BoundString. Иногда заданный рабочий размер может быть меньше, чем фактический размер отображаемого текста, поэтому мы введём термин контейнер для обозначения размера, который вычислен на основе всех опций Label. Пример: Label имеет размеры width=128, height=32. Если мы выводим текст, который умещается в этом пространстве, то никакого расширения не будет. Однако, иногда мы хотим разрешить расширение в одной или более сторон, либо явно его запретить и иную стратегию (например, сокращать текст). Добавим следующий код в функцию Label.Draw: В реальном мире у вас будет какая-то библиотека для работы с 2D векторами и прямоугольниками, поэтому код будет почище. Однако таких библиотек больше, чем одна, поэтому я не хотел бы привязываться ни к одной из них. Имея координаты и размеры контейнера, мы можем отрисовать прямоугольный фон: Начнём с центрирования по вертикали. Метод Label.estimateHeight: Здесь важно использовать такую формулу подсчёта высоты текста, которая будет выдавать одинаковые результаты для одинакового numLines. Её можно выразить через CapHeight и LineHeight. Если использовать boundsHeight, то мы будем получать не очень красивый результат, где выравнивание разного текста будет то выше, то ниже, так как содержимое самой строки будет влиять на высоту отрисованного текста. До этого момента нам не приходилось особым образом обрабатывать текст из нескольких строк. Максимум, что мы делали, это вычисляли высоту многострочного текста. Выравнивание по горизонтали требует построчной обработки, ведь для каждой строки смещение будет разным. В качестве небольшой оптимизации, мы будем обрабатывать выравнивание по левому краю, как и раньше. Мы так же могли бы оптимизировать частный случай, когда центрирование по горизонтали (или правому краю) применяется к однострочному тексту. Давайте вспомним, как выглядел код отрисовки текста ранее: Я добавил проверку на пустую строку, чтобы она нам не мешалась далее, когда мы будем рассматривать построчную обработку. Весь код ниже может ожидать, что у нас есть как минимум одна строка текста. Обновлённый код будет выглядеть так: Создадим интерфейс Drawer; этот интерфейс будут реализовывать все компоненты и графические эффекты. Проще говоря, Drawer — это такой объект сцены, у которого есть Draw(), но нет метода Update(). Добавим объекты Label в Game: Где-то в инициализации Game (или в процессе игры) мы добавляем новые графические элементы в этот слайс. После этого они будут отрисовываться в методе Game.Draw. Добавим первый Label на сцену:  Поменяем некоторые параметры, уберём фон:  В процессе игры мы можем динамически менять отображаемый текст через изменение поля Label.Text; все остальные параметры отрисовки так же можно изменять на лету, без повторного создания объекта Label. Единственное ограничение, которое мы ввели через наше API — это привязку к font.Face, но и его можно, при желании, убрать (например, добавив метод SetFontFace). Компонент Label не позволяет как-либо регулировать кегль шрифта или межстрочный интервал. Если нам нужен другой размер текста, мы создаём Label, передав в функцию-конструктор font.Fact`, созданный с нужными параметрами. Объект opentype.FaceOptions даёт конфигурировать многие параметры, но не межстрочный интервал. ebiten экспортирует функцию text.FaceWithLineHeight решает как раз эту задачу. Рекомендация: используйте округлённое значение для LineHeight. Значения вроде 17.9 будут выдавать очень странные результаты. На скриншоте ниже показано сравнение трёх разных значений для lineSpacing.   Сегодня мы разобрались, что такое dot position (он же baseline). Мы научились позиционировать текст нужным нам образом, с предсказуемым origin и ориентацией. Созданный нами компонент Label достаточно хорош для простых игр или прототипов. Вы можете доработать его под свои нужды или написать свой SuperLabel, опираясь на полученные знания. На сегодняшний день разработка игр на Go — не очень распространённая практика. Русскоязычных сообществ и материалов довольно мало. Я приглашаю вас в телеграм канал go_gamedev, в котором можно обсуждать всё, что связанно с тематикой разработки игр на языке Go. Давайте делиться там своими статьями, проектами, библиотеками и инструментами. Making developer tools & video games