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 heightMetrics.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. Давайте делиться там своими статьями, проектами, библиотеками и инструментами.
А в комментариях напишите, о чём ещё мне стоит рассказать в формате заметки на хабре.