Go ebiten: разбираемся с рендерингом и позиционированием текста

от автора

Перед вами первая заметка на тему разработки игр на Go с использованием библиотеки ebiten (также известный как Ebitengine).

Сегодня мы будем разбираться, как выполняется позиционирование текста. Как центрировать его, менять межстрочный интервал и так далее. Официальная документация и примеры содержат почти всё необходимое, но чтобы свести всё воедино и понять все концепции можно потратить несколько вечеров. Я постараюсь сэкономить ваше время.

Перед тем, как мы приступим

Мы будем придерживаться следующей структуры проекта:

example/   _assets/*   main.go   go.mod   go.sum

В директории _assets будут располагаться все ресурсы: спрайты, шрифты, звуки и всё остальное. Эти ресурсы будут встроены в исполняемый файл через go:embed.

//go:embed all:_assets var gameAssets embed.FS

Поскольку наши примеры будут довольно простыми, всё приложение будет описано в одном файле main пакета (main.go).

Подготовка сцены

Наш тестовый экран будет состоять из сетки на белом фоне. Так будет проще оценивать размеры и качество выравнивания.

Создадим реализацию ebiten.Game, которая будет рисовать подобную сцену:

const (     windowWidth  = 32 * 14     windowHeight = 32 * 8 )  type Game struct {}  func (g *Game) Update() error { return nil }  func (g *Game) Draw(screen *ebiten.Image) {     // Локальные сокращения, чтобы уменьшить код по ширине     // (Формат статьи накладывает свои ограничения)     const w = windowWidth     const h = windowHeight      ebitenutil.DrawRect(screen, 0, 0, w, h, color.White)      // Рисуем сетку (32x32 и 64x64)     gridColor64 := &color.RGBA{A: 50}     gridColor32 := &color.RGBA{A: 20}     for y := 0.0; y < h; y += 32 {         ebitenutil.DrawLine(screen, 0, y, w, y, gridColor32)     }     for y := 0.0; y < h; y += 64 {         ebitenutil.DrawLine(screen, 0, y, w, y, gridColor64)     }     for x := 0.0; x < w; x += 32 {         ebitenutil.DrawLine(screen, x, 0, x, h, gridColor32)     }     for x := 0.0; x < w; x += 64 {         ebitenutil.DrawLine(screen, x, 0, x, h, gridColor64)     } }  func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {     return windowWidth, windowHeight }

Функция main может выглядеть как-то так:

func main() {     ctx := newContext()     game := &Game{ctx: ctx}     ebiten.SetWindowTitle("Text Rendering")     ebiten.SetWindowSize(windowWidth, windowHeight)     if err := ebiten.RunGame(game); err != nil {         ctx.Critical(err)     } }

Результат запуска программы:

Выводим простой текст

Чтобы было интереснее, мы не будем использовать моноширинный шрифт. Для своих примеров я возьму шрифт Zack and Sarah.

Сначала нам понадобится создать объект font.Face из файла _assets/font.ttf. В настоящей игре загрузкой и инициализацией подобных ресурсов будет заниматься загрузчик (resource loader), но сейчас мы рассмотрим пример загрузки шрифта с самого начала.

// Считайте эти переменные параметрами создания шрифта var (     fontSize = 18     filename = "font.ttf" )  // В embed FS разделителем является "/", даже на Windows r, err := gameAssets.Open("_assets/" + filename) if err != nil {     ctx.Critical(err) } defer func() {     if err := r.Close(); err != nil {         ctx.Warnf("closing %q: font reader: %v", filename, err)     } }() fontData, err := io.ReadAll(r) if err != nil {     ctx.Criticalf("reading %q font data: %v", filename, err) } tt, err := opentype.Parse(fontData) if err != nil {     ctx.Criticalf("parsing %q font: %v", filename, err) } fontFace, err := opentype.NewFace(tt, &opentype.FaceOptions{     Size:   float64(fontSize),     DPI:     96,     Hinting: font.HintingFull, }) if err != nil {     ctx.Criticalf("creating a font face for %q: %v", filename, err) } return fontFace

Будем считать, что созданный fontFace нам всегда будет доступен через ctx.GetFontFace("font.ttf").

Довольно часто из одного файла шрифта создаётся несколько экземпляров font.Face, поэтому одного лишь пути к файлу недостаточно для уникального ключа созданного объекта. Чтобы не усложнять код, мы будем использовать отображение один к одному.

Добавим в метод Game.Draw эти строки:

s := "Dangan Ronpa!" fontFace := ctx.GetFontFace("font.ttf") var opts ebiten.DrawImageOptions opts.ColorM.ScaleWithColor(color.RGBA{A: 255}) opts.GeoM.Translate(64, 64) text.DrawWithOptions(screen, s, fontFace, &opts)

Ожидали такой результат? Так или иначе, сейчас будем разбираться, что это за позиционирование такое и как с этим жить.

Dot position

Мы вызвали DrawWithOptions, указав позицию отрисовки (64, 64). При этом даже сама документация явно подчёркивает, что это не левый верхний угол, а некий dot position. Перед тем, как мы начнём осваивать эти детали, посмотрим на результат ещё раз и проведём дополнительный эксперимент.

Парочка наблюдений:

  • Текст почти целиком расположен над y=64
  • Часть текста расположена ниже y=64

Сделаем предположение, что текст растёт наверх. Проверим это, добавив перенос строки.

- s := "Dangan Ronpa!" + s := "Dangan Ronpa!\n~~Sore wa chigau yo!"

Это предположение было неверным. Где-то в этот момент можно признать, что без более детального изучения документации нам не продвинуться.

ebiten использует пакет golang.org/x/image/font. Именно оттуда копируется часть документации для работы с текстом.

Нам будет полезна следующая иллюстрация:

Dot position из документации — это baseline. Позиция y задаёт стартовую позицию отрисовки, через эту ось будет проходить baseline.

Часть текста, что оказалась ниже baseline — это descent.

Когда мы выводим несколько строк текста через один вызов text.DrawWithOptions, ebiten будет спускаться на line height пикселей вниз для каждого символа \n.

Теперь обратим внимание на font.Face. Для нас очень полезен метод font.Face.Metrics(), который возвращает font.Metrics.

Если мы посмотрим на поля font.Metrics, то увидим связь.

  • Metrics.Height — line height
  • Metrics.Descent — выше упомянутый descent
  • и так далее

Функция text.BoundString позволяет измерить область, которую займёт некий текст, отрисованный выбранным шрифтом. Так мы можем узнать высоту и ширину результата.

Итак, чтобы отрисовать текст в координате (x, y) так, чтобы это было левым верхним углом, нам достаточно смещать текст по оси y на выбранную величину. Этой выбранной величиной может быть Metrics.Ascent или Metrics.CapHeight.

ypos := 64.0 // Значение CapHeight может быть отрицательным, поэтому // лучше всегда брать модуль от этого числа ypos += math.Abs(float64(fontFace.Metrics().CapHeight.Floor()))

Если применять CapHeight, часть текста может выходить за пределы y, но обычно это ожидаемое поведение. Если хочется этого избежать, стоит использовать Ascent для смещения, но тогда будет сложнее реализовать vertical align center.

Компонент Label

Фреймворки для создания игр часто предоставляют компонент типа Label.

В ebiten нет такого компонента, поэтому нам нужно будет создавать его самостоятельно.

Особенности нашего Label:

  • Интуитивное позиционирование (без всяких dot position)
  • Выбор цвета для текста и для содержащего его прямоугольника
  • Настройка выравнивания текста по вертикали и горизонтали
  • Выбор стратегии по расширению (grow directions)

Другими полезными возможностями могут быть автоматические переносы слов на новую строку (вместо расширения блока) и обрезание текста, а также отступы (padding).

Введём некоторые типы и константы, а затем приступим к реализации самих алгоритмов:

type AlignVertical uint8  const (     AlignVerticalTop AlignVertical = iota     AlignVerticalCenter     AlignVerticalBottom )  type AlignHorizontal uint8  const (     AlignHorizontalLeft AlignHorizontal = iota     AlignHorizontalCenter     AlignHorizontalRight )  type GrowVertical uint8  const (     GrowVerticalDown GrowVertical = iota     GrowVerticalUp     GrowVerticalNone )  type GrowHorizontal uint8  const (     GrowHorizontalRight GrowHorizontal = iota     GrowHorizontalLeft     GrowHorizontalNone )  type Label struct {     X float64     Y float64      Width float64     Height float64      Text string      Color           color.RGBA     BackgroundColor color.RGBA      AlignVertical   AlignVertical     AlignHorizontal AlignHorizontal     GrowVertical    GrowVertical     GrowHorizontal  GrowHorizontal      Visible bool      fontFace   font.Face     capHeight  float64     lineHeight float64 }  func NewLabel(fontFace font.Face) *Label {     m := fontFace.Metrics()     capHeight := math.Abs(float64(m.CapHeight.Floor()))     lineHeight := float64(m.Height.Floor())     return &Label{         fontFace:   fontFace,         capHeight:  capHeight,         lineHeight: lineHeight,         Color:      color.RGBA{A: 0xff},         Visible:    true,     } }

По умолчанию получаем следующее поведение:

  • Выравнивание по левому верхнему углу
  • Расширение области текста вправо и вниз
  • Чёрный цвет текста, а прямоугольник с фоном — прозрачный

Прямоугольник нам будет полезен для отладки, чтобы визуально видеть область, занимаемую компонентом Label.

Использоваться объекты типа Label будут так: кто-то, кто управляет текущей сценой, будет вызывать метод Draw() у каждого графического элемента. Графические элементы могут храниться в виде слайса из интерфейсов с этим методом, но мы можем представить, что в Game хранится слайс *Label (вернёмся к этому конце статьи).

Первая версия Draw(), без поддержки выравнивания, будет выглядеть так:

func (l *Label) Draw(screen *ebiten.Image) {     if !l.Visible {         return     }     posX := l.X     posY := l.Y + l.capHeight     var opts ebiten.DrawImageOptions     opts.ColorM.ScaleWithColor(l.Color)     opts.GeoM.Translate(posX, posY)     text.DrawWithOptions(screen, l.Text, l.fontFace, &opts) }

Grow directions

Рабочий размер Label (width, height) делает выравнивание текста более предсказуемым. Вместо того чтобы вычислять размеры исходя от позиции и размера отображаемого текста, мы можем иметь фиксированное пространство, относительно которого мы выполняем преобразования. В случае, если width и height равны нулю, то рабочим размером будет считаться результат text.BoundString.

Иногда заданный рабочий размер может быть меньше, чем фактический размер отображаемого текста, поэтому мы введём термин контейнер для обозначения размера, который вычислен на основе всех опций Label.

Пример: Label имеет размеры width=128, height=32. Если мы выводим текст, который умещается в этом пространстве, то никакого расширения не будет. Однако, иногда мы хотим разрешить расширение в одной или более сторон, либо явно его запретить и иную стратегию (например, сокращать текст).

Добавим следующий код в функцию Label.Draw:

// image.Rectangle имеет целочисленные поля, как и image.Point, // поэтому мы будем использовать отдельные компоненты, где // x0, y0 - min; x1, y1 - max var (     containerX0 float64     containerY0 float64     containerX1 float64     containerY1 float64 ) bounds := text.BoundString(l.fontFace, l.Text) boundsWidth := float64(bounds.Dx()) boundsHeight := float64(bounds.Dy()) if l.Width == 0 && l.Height == 0 {     // Автоматическое проставление рабочей области     containerX0 = posX     containerY0 = posY     containerX1 = posX + boundsWidth     containerY1 = posY + boundsHeight } else {     containerX0 = posX     containerY0 = posY     containerX1 = posX + l.Width     containerY1 = posY + l.Height     if delta := boundsWidth - l.Width; delta > 0 {         switch l.GrowHorizontal {         case GrowHorizontalRight:             containerX1 += delta         case GrowHorizontalLeft:             containerX0 -= delta         case GrowHorizontalNone:             // Ничего не делаем         }     }     if delta := boundsHeight - l.Height; delta > 0 {         switch l.GrowVertical {         case GrowVerticalDown:             containerY1 += delta         case GrowVerticalUp:             containerY0 -= delta             posY -= delta         case GrowVerticalNone:             // Ничего не делаем         }     } } var (     containerWidth  float64 = containerX1 - containerX0     containerHeight float64 = containerY1 - containerY0 )

В реальном мире у вас будет какая-то библиотека для работы с 2D векторами и прямоугольниками, поэтому код будет почище. Однако таких библиотек больше, чем одна, поэтому я не хотел бы привязываться ни к одной из них.

Имея координаты и размеры контейнера, мы можем отрисовать прямоугольный фон:

if l.BackgroundColor.A != 0 {     // Пытаюсь уместить вызов DrawRect по ширине...     x0 := containerX0     y0 := containerY0 - l.capHeight     w := containerWidth     h := containerHeight     ebitenutil.DrawRect(screen, x0, y0, w, h, l.BackgroundColor) }

Выравнивание текста по центру

Начнём с центрирования по вертикали.

numLines := strings.Count(l.Text, "\n") + 1 switch l.AlignVertical { case AlignVerticalTop:     // Ничего не делаем case AlignVerticalCenter:     posY += (containerHeight - l.estimateHeight(numLines)) / 2 case AlignVerticalBottom:     posY += containerHeight - l.estimateHeight(numLines) }

Метод Label.estimateHeight:

func (l *Label) estimateHeight(numLines int) float64 {     // Начинаем с высоты, которая нам потребуется для первой строки     estimatedHeight := l.capHeight     if numLines >= 2 {         // Добавляем высоту для всех остальных строк         estimatedHeight += (float64(numLines) - 1) * l.lineHeight     }     return estimatedHeight }

Здесь важно использовать такую формулу подсчёта высоты текста, которая будет выдавать одинаковые результаты для одинакового numLines. Её можно выразить через CapHeight и LineHeight. Если использовать boundsHeight, то мы будем получать не очень красивый результат, где выравнивание разного текста будет то выше, то ниже, так как содержимое самой строки будет влиять на высоту отрисованного текста.

Укрощаем многострочный текст

До этого момента нам не приходилось особым образом обрабатывать текст из нескольких строк. Максимум, что мы делали, это вычисляли высоту многострочного текста.

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

В качестве небольшой оптимизации, мы будем обрабатывать выравнивание по левому краю, как и раньше. Мы так же могли бы оптимизировать частный случай, когда центрирование по горизонтали (или правому краю) применяется к однострочному тексту.

Давайте вспомним, как выглядел код отрисовки текста ранее:

if l.Text == "" {     return } var opts ebiten.DrawImageOptions opts.ColorM.ScaleWithColor(l.Color) opts.GeoM.Translate(posX, posY) text.DrawWithOptions(screen, l.Text, l.fontFace, &opts)

Я добавил проверку на пустую строку, чтобы она нам не мешалась далее, когда мы будем рассматривать построчную обработку. Весь код ниже может ожидать, что у нас есть как минимум одна строка текста.

Обновлённый код будет выглядеть так:

var opts ebiten.DrawImageOptions opts.ColorM.ScaleWithColor(l.Color)  if l.AlignHorizontal == AlignHorizontalLeft {     opts.GeoM.Translate(posX, posY)     text.DrawWithOptions(screen, l.Text, l.fontFace, &opts)     return } // Нужно обрабатывать текст построчно, выравнивая каждую // строку отдельно textRemaining := l.Text offsetY := 0.0 for {     nextLine := strings.IndexByte(textRemaining, '\n')     lineText := textRemaining     if nextLine != -1 {         lineText = textRemaining[:nextLine]         textRemaining = textRemaining[nextLine+len("\n"):]     }     lineBounds := text.BoundString(l.fontFace, lineText)     lineBoundsWidth := float64(lineBounds.Dx())     offsetX := 0.0     switch l.AlignHorizontal {     case AlignHorizontalCenter:         offsetX = (containerWidth - lineBoundsWidth) / 2     case AlignHorizontalRight:         offsetX = containerWidth - lineBoundsWidth     }     opts.GeoM.Reset()     opts.GeoM.Translate(posX+offsetX, posY+offsetY)     text.DrawWithOptions(screen, lineText, l.fontFace, &opts)     if nextLine == -1 {         break     }     offsetY += l.lineHeight }

Создаём и размещаем объекты

Создадим интерфейс Drawer; этот интерфейс будут реализовывать все компоненты и графические эффекты. Проще говоря, Drawer — это такой объект сцены, у которого есть Draw(), но нет метода Update().

type Drawer interface {     Draw(dst *ebiten.Image) }

Заметим, что на практике полезно иметь метод типа IsDisposed() почти во всех интерфейсах объектов сцены. Например, если какой-то элемент нужно удалить, у него будет вызван Dispose() после чего IsDisposed() будет возвращать false и объект будет удалён со сцены в следующем логическом кадре.

Добавим объекты Label в Game:

  type Game struct {     ctx     *context +   drawers []Drawer   }

Где-то в инициализации Game (или в процессе игры) мы добавляем новые графические элементы в этот слайс. После этого они будут отрисовываться в методе Game.Draw.

// Добавляем цикл отрисовки объектов в метод Game.Draw for _, d := range g.drawers {     d.Draw(screen) }

Добавим первый Label на сцену:

l := NewLabel(ctx.GetFontFace("font.ttf")) l.X = 64 * 2 l.Y = 64 l.Width = 64 * 3 l.Height = 64 * 2 l.AlignVertical = AlignVerticalCenter l.AlignHorizontal = AlignHorizontalCenter l.Text = "ebiten\nis great" l.Color = color.RGBA{G: 100, B: 255, A: 255} l.BackgroundColor = color.RGBA{R: 100, G: 200, B: 100, A: 160}  game.drawers = append(game.drawers, l)

Поменяем некоторые параметры, уберём фон:

l := NewLabel(ctx.GetFontFace("font.ttf")) l.X = 64 * 2 l.Y = 64 l.Width = 64 * 3 l.Height = 64 * 2 l.AlignVertical = AlignVerticalBottom l.AlignHorizontal = AlignHorizontalRight l.Text = "This text\nis so majestic"

В процессе игры мы можем динамически менять отображаемый текст через изменение поля Label.Text; все остальные параметры отрисовки так же можно изменять на лету, без повторного создания объекта Label. Единственное ограничение, которое мы ввели через наше API — это привязку к font.Face, но и его можно, при желании, убрать (например, добавив метод SetFontFace).

Межстрочный интервал

Компонент Label не позволяет как-либо регулировать кегль шрифта или межстрочный интервал.

Если нам нужен другой размер текста, мы создаём Label, передав в функцию-конструктор font.Fact`, созданный с нужными параметрами.

Объект opentype.FaceOptions даёт конфигурировать многие параметры, но не межстрочный интервал. ebiten экспортирует функцию text.FaceWithLineHeight решает как раз эту задачу.

// lineSpacing - коэффициент увеличения межстрочного интервала; // для 1.0 мы не выполняем избыточного заворачивания if lineSpacing != 1 {     h := float64(fontFace.Metrics().Height.Round()) * lineSpacing     fontFace = text.FaceWithLineHeight(fontFace, math.Round(h)) }

Рекомендация: используйте округлённое значение для LineHeight. Значения вроде 17.9 будут выдавать очень странные результаты.

На скриншоте ниже показано сравнение трёх разных значений для lineSpacing.

Заключение

Сегодня мы разобрались, что такое dot position (он же baseline). Мы научились позиционировать текст нужным нам образом, с предсказуемым origin и ориентацией.

Созданный нами компонент Label достаточно хорош для простых игр или прототипов. Вы можете доработать его под свои нужды или написать свой SuperLabel, опираясь на полученные знания.

На сегодняшний день разработка игр на Go — не очень распространённая практика. Русскоязычных сообществ и материалов довольно мало. Я приглашаю вас в телеграм канал go_gamedev, в котором можно обсуждать всё, что связанно с тематикой разработки игр на языке Go. Давайте делиться там своими статьями, проектами, библиотеками и инструментами.

А в комментариях напишите, о чём ещё мне стоит рассказать в формате заметки на хабре.


ссылка на оригинал статьи https://habr.com/ru/post/671556/