Go в GUI, я создал
Идея сделать игру под Android на Go была неоднозначной, но интересной. Я легко мог представить Go не только в привычной для него сервисной нише, но и в прикладной — его кросс-платформенность и близость к системному уровню в сочетании с простотой пришлись бы там очень кстати. И вот мы здесь — игру мечты я пока не создал, но пару игр попроще сделать удалось.
В этой статье я хочу рассказать об инструментах, появившихся по ходу работы. Сами инструменты я объединил в библиотеку Youngine и опубликовал на GitHub. Там же я опубликовал небольшую игру драконово-змеиной тематики по новогоднему случаю как пример основанного на библиотеке проекта.
Youngine
Обратный Node.js в экосистеме Go олицетворяет не так уж много проектов. Для моей же задачи выбор чуть ли не однозначно свёлся к Ebitengine. Есть и другие впечатляющие штуки вроде Fyne, Gio, giu, go-flutter –, но я в итоге не стал их использовать по разным причинам: явная экспериментальность, ограниченные возможности для мобильных платформ (в том числе с учётом публикации и интеграций), для игр (в том числе с учётом сложной графики и шэйдеров), обилие Cgo и прочее субъективное.
Что касается Ebitengine, то про dead simple — это не шутка. С одной стороны, там есть аж целый Go-подобный язык шейдеров, изображения автоматически укладываются в атласы, есть поддержка шрифтов и аудио, из коробки проекты компилируются в виде библиотек для Android и iOS (используется доработанный gomobile). С другой же — это фактически драйвер окна: цикл обновления/отображения, сырой ввод — и всё, никаких тебе ассетов, виджетов, физики и прочей лирики. Есть наработки сообщества, но их не то чтобы много.
Короче говоря, у меня на руках был мощный драйвер, но каркас приложения поверх него предстояло построить самостоятельно.
Обработка ошибок (пакет fault)
В ином языке пакета fault не было бы вовсе, как и связанного с ним раздела статьи. Но в Go придётся сделать лирическое отступление и объяснить принцип, которого я решил придерживаться в рамках этой разработки — это не снимет вопросы, но хотя бы добавит понимания.
Итак, есть ошибки окружения и ошибки программиста.
К первому типу относятся проблемы с сериализацией и десериализацией, сетевым взаимодействием, обращениями к файловой системе, воспроизведением звука и так далее. Такие ошибки возвращаются в виде значения типа error, что позволяет обработать их на месте — например, повторить операцию через какое-то время, использовать альтернативный механизм или даже просто проигнорировать с записью в лог.
Ко второму типу относятся проблемы с кодом — например, передача nil там, где это не предусмотрено (и в теории могло бы быть исключено системой типов). Для таких ошибок вызывается паника со значением типа error, сопровождаемым трассировкой стэка — никакого адекватного способа их обработки на месте не существует, можно лишь сообщить о них и аварийно завершить неработоспособную программу. В работоспособной же программе они возникнуть не могут, что делает бессмысленными соответствующие проверки по всему стэку, как для первого типа.
Ко второму же типу относятся и паники, вызываемые рантаймом. Повлиять на этот механизм, конечно, нельзя, но тем не менее принцип с ним согласуется.
На самом верхнем уровне происходит восстановление из паники, если она возникла, и возврат ошибки (например, в ebiten.Game.Update
) либо её обработка (например, в main). После этого программа аварийно завершается.
Логическое время (пакет clock)
Анимации, физические расчёты, обработка ввода, игровые события — всё это основано на времени. Всегда хочется добиться плавности происходящего на экране, но в то же время требуется сохранять синхронность всех частей приложения.
Если полагаться лишь на реальные часы, никак их не абстрагируя, то просто из-за природы компьютерных вычислений все взаимодействующие объекты будут находиться (и отображаться) каждый в собственном моменте времени, и происходящее будет напоминать то ли «Терминатор: Генезис», то ли «Шоу Бенни Хилла» — во всяком случае, будет непросто избежать ошибок. Для решения этой проблемы достаточно зафиксировать момент времени перед началом обновления приложения — тогда состояние всех объектов до обновления будет соответствовать предыдущему такому моменту, а после — текущему, а время станет логическим.
Ещё одним артефактом компьютерной симуляции является то, что одна и та же операция даже на одном компьютере не обязательно занимает одинаковое время, из-за чего приложение может работать с разной скоростью. Причём речь не только про очевидные тормоза, но и про слишком высокую скорость на более мощных машинах, которая делает приложение ничуть не менее непригодным к использованию. Эта проблема решается разными способами, но конкретно Ebitengine использует фиксированный временной шаг, ограничивая сверху частоту обновления приложения (то есть вызовов ebiten.Game.Update
) значением TPS (ticks per second), по умолчанию равным 60.
Пакет clock вводит абстрактные тики в качестве единиц измерения логического времени — они могут представлять как итерации обновления приложения, так и наносекунды, в зависимости от необходимости (главное, чтобы всегда и везде в одном приложении они интерпретировались одинаково). Часы возвращают текущее логическое время, которое должно увеличиваться в начале каждой итерации и не должно изменятся до её окончания.
Драйвер ebitenclock реализует часы, увеличивающие текущее логическое время на один тик в начале каждой итерации.
Обработка ввода (пакет input)
Системы обработки ввода, с которыми я сталкивался, можно условно разделить на два непересекающихся класса — основанные на событиях и предоставляющие прямой доступ к текущему состоянию. Первые удобны при построении GUI — стандартные события (например, key press или mouse move) привязываются к его элементам и скрывают логику отнесения положения мыши, обработки фокуса клавиатуры и так далее. Вторые же удобны для реализации игрового управления, позволяя не размазывать нестандартную логику по обработчикам событий (если она вообще в них впишется). Поскольку в играх есть и интерфейс, и нестандартное управление, игровые движки обычно реализуют оба класса, так или иначе связывая их между собой.
В пакете input описан протокол, реализуемый драйвером ebiteninput на основе возможностей Ebitengine. Этот протокол предполагает фиксацию состояния ввода в начале обновления приложения (сразу после обновления часов) и в ходе итерации предоставляет доступ к нему для последующей обработки. Если на каком-то её шаге часть состояния применяется, она должна быть помечена (Mark), чтобы не быть применённой повторно на следующих шагах — можно сказать, что таким образом шаги обработки «поглощают» части ввода.
История о том, как моя дочь нашла то ли баг, то ли фичу
Когда я только разрабатывал первую игру и описываемые инструменты, моя дочь, играя в неё на телефоне, столкнулась с неожиданной проблемой: если одновременно коснуться экрана больше чем двумя пальцами, а потом одновременно же их убрать, то закончатся только два тача, а об остальных система продолжит докладывать, хотя экран уже никто не трогает — другими словами, они зависнут, и чтобы от них избавиться, нужно трогать экран хитрым способом, который не всегда срабатывает. Эта багофича находится где-то на границе драйвера сенсорного экрана, используемого Ebitengine, и для неё есть костыль внешняя корректировка в ebiteninput. Если кто-то в курсе, с чем связано такое странное поведение — напишите пожалуйста в комментариях, интересно.
Протокол (без знания о конкретном драйвере) используется контроллерами элементов ввода. На каждой итерации контроллер должен быть активирован (Actuate), если он готов к обработке ввода, либо подавлен (Inhibit), если он не должен его обрабатывать. Контроллеры стандартных элементов, генерирующие события, реализованы в пакетах группы element, но предполагается, что при необходимости разработчик может реализовать собственные с произвольной логикой. Стандартные контроллеры поддерживают иерархию, что позволяет произвольно их комбинировать — например, можно обрабатывать движения мыши только если нажата определённая клавиша клавиатуры, либо наоборот — реагировать на клавиатурный ввод только если мышь находится в определённых границах.
Предполагается, что настройку контроллеров и их активацию/подавление, а также обработку их событий, выполняет GUI, о котором я расскажу дальше.
Таким образом, элементы GUI взаимодействуют с контроллерами интересующих их элементов ввода, а те, в свою очередь, обрабатывают интересующие их части ввода по протоколу, реализуемому драйвером. Разработчик может полагаться на стандартные элементы интерфейса, при необходимости (которая в играх гарантированно возникнет) реализуя собственные на основе либо стандартных элементов ввода, либо реализованных так же самостоятельно.
Пример кода контроллера кнопки
dragon/pkg/window/common/button/controller_desktop.go
//go:build !android && !ios
package button
import (
"github.com/a1emax/youngine/basic"
"github.com/a1emax/youngine/input"
"github.com/a1emax/youngine/input/element/mousebutton"
"github.com/a1emax/youngine/input/element/mousecursor"
)
func (b *buttonImpl[R]) initController(config Config) {
b.controller = mousecursor.NewController(mousecursor.ControllerConfig[basic.None]{
Cursor: config.Input.Mouse().Cursor(),
HitTest: func(position basic.Vec2) bool {
return b.region.Rect().Contains(position)
},
Slave: mousebutton.NewController(mousebutton.ControllerConfig[mousecursor.Background[basic.None]]{
Button: config.Input.Mouse().Button(input.MouseButtonCodeLeft),
Clock: config.Clock,
OnPress: func(event mousebutton.PressEvent[mousecursor.Background[basic.None]]) {
b.isPressed = true
if config.OnPress != nil {
config.OnPress(PressEvent{
Duration: event.Duration,
})
}
},
OnUp: func(event mousebutton.UpEvent[mousecursor.Background[basic.None]]) {
b.isPressed = false
if config.OnClick != nil {
config.OnClick(ClickEvent{})
}
},
OnGone: func(event mousebutton.GoneEvent) {
b.isPressed = false
},
}),
})
}
dragon/pkg/window/common/button/controller_mobile.go
//go:build android || ios
package button
import (
"github.com/a1emax/youngine/basic"
"github.com/a1emax/youngine/input/element/touchscreentouch"
)
func (b *buttonImpl[R]) initController(config Config) {
b.controller = touchscreentouch.NewController(touchscreentouch.ControllerConfig[basic.None]{
Touchscreen: config.Input.Touchscreen(),
Clock: config.Clock,
HitTest: func(position basic.Vec2) bool {
return b.region.Rect().Contains(position)
},
OnHover: func(event touchscreentouch.HoverEvent[basic.None]) {
b.isPressed = true
if config.OnPress != nil {
config.OnPress(PressEvent{
Duration: event.Duration,
})
}
},
OnEnd: func(event touchscreentouch.EndEvent[basic.None]) {
b.isPressed = false
if config.OnClick != nil {
config.OnClick(ClickEvent{})
}
},
OnGone: func(event touchscreentouch.GoneEvent) {
b.isPressed = false
},
})
}
Интерфейс пользователя (пакет scene)
GUI в пакете scene реализован вполне типовым способом — в виде дерева элементов. На каждой итерации основного цикла приложения элементы проходят следующие стадии:
Актуализация (Refresh). Наступает первой для всех элементов. Логика этой стадии должна быть максимально простой — в большинстве случаев это обновление настроек элемента.
Исключение (Exclude). Является предпоследней для скрытых элементов — например, неактивных, исходя из настроек, или находящихся на неактивной странице. На этой стадии элемент обычно освобождает временные ресурсы, если только что стал скрытым, и ничего не делает, если был скрытым и раньше.
Подготовка (Prepare). Наступает только для видимых элементов. На этой стадии элемент выполняет предварительные вычисления — например, может на основе настроек определить свои примерные габариты (Outline), которые учтёт при его размещении контейнер.
Размещение (Arrange). Как и стадия подготовки, наступает только для видимых элементов. На этой стадии определяются итоговые местоположение и размер элемента. Только после этого ограничивающий прямоугольник элемента (Region.Rect) становится действителен.
Активация (Actuate). Наступает только для взаимодействующих элементов. На этой стадии элемент обычно активирует контроллер ввода, если он предусмотрен. В общем случае все видимые элементы считаются взаимодействующими, а скрытые — невзаимодействующими, но контейнер может решить иначе — например, если проигрывает анимацию переключения страниц, во время которой элемент отображается, но на ввод не реагирует.
Подавление (Inhibit). Наступает только для невзаимодействующих элементов (в том числе для скрытых, являясь для них последней). На этой стадии элемент обычно подавляет контроллер ввода, если он предусмотрен.
Обновление (Update). Наступает только для видимых элементов и является для них последней перед отображением.
Отображение (Draw). Наступает только для видимых элементов, причём перед ней последовательность стадий от актуализации до обновления может быть повторена несколько раз — из-за фиксированного временного шага в Ebitengine перед одним вызовом
ebiten.Game.Draw
может быть выполнено несколько вызововebiten.Game.Update
.
Каждый элемент связан с некоторым регионом, который определяет его ограничивающий прямоугольник (Rect). Конкретный тип региона задаёт контейнер элемента — через него он расширяет настройки элемента и позиционирует его на стадии размещения. Например, контейнер flexbox с помощью региона позволяет указать для содержащихся в нём элементов специфичные для алгоритма настройки basis, grow, shrink и align-self.
Тип экрана, на котором отображается элемент, может быть произвольным. Для виджетов, имеющих графическое представление, экраном за неимением альтернатив является *ebiten.Image
, но для абстрактных элементов (например, контейнеров) этот тип обычно не имеет значения, и нет смысла привязывать их к конкретной графической платформе.
Стандартные элементы интерфейса, независимые от платформы, находятся в пакетах группы element: контейнеры flexbox и overlay, враппер padding, переключатель страниц pageset и пустой элемент nothing. Поскольку пока что все виджеты, даже кнопки, оказывались специфичными как минимум стилистически для моих проектов, я не стал делать их стандартными — реализую такие отдельно в ближайшем будущем, если Youngine будет интересен аудитории. В реализации же собственных виджетов помогут пакеты x/colors, x/bitmap, x/roundrect и x/textview.
Пример кода виджета с отладочной информацией
dragon/pkg/window/debuginfo/debuginfo.go
package debuginfo
import (
"fmt"
"runtime"
"github.com/a1emax/youngine/basic"
"github.com/a1emax/youngine/scene"
"github.com/a1emax/youngine/scene/element/flexbox"
"github.com/a1emax/youngine/scene/element/overlay"
"github.com/hajimehoshi/ebiten/v2"
"dragon/pkg/global/assets"
"dragon/pkg/global/vars"
"dragon/pkg/window/common"
"dragon/pkg/window/common/colorarea"
"dragon/pkg/window/common/label"
)
type DebugInfo[R scene.Region] interface {
common.Element[R]
}
func New[R scene.Region](region R) DebugInfo[R] {
var maxMemSys uint64
var maxMemPauseNs uint64
return overlay.New(region, overlay.Config{
StateFunc: func(state overlay.State) overlay.State {
state = overlay.State{}
state.SetHeight(24.0)
return state
},
},
colorarea.New(overlay.NewRegion(overlay.RegionConfig{
StateFunc: func(state overlay.RegionState) overlay.RegionState {
state = overlay.RegionState{}
return state
},
}), colorarea.Config{
StateFunc: func(state colorarea.State) colorarea.State {
state = colorarea.State{}
state.Color = assets.Colors.DebugInfoBackground
return state
},
}),
flexbox.New(overlay.NewRegion(overlay.RegionConfig{
StateFunc: func(state overlay.RegionState) overlay.RegionState {
state = overlay.RegionState{}
return state
},
}), flexbox.Config{
StateFunc: func(state flexbox.State) flexbox.State {
state = flexbox.State{}
state.Direction = flexbox.DirectionRow
state.JustifyContent = flexbox.JustifySpaceBetween
state.AlignItems = flexbox.AlignCenter
return state
},
},
label.New(flexbox.NewRegion(flexbox.RegionConfig{
StateFunc: func(state flexbox.RegionState) flexbox.RegionState {
state = flexbox.RegionState{}
return state
},
}), label.Config{
StateFunc: func(state label.State) label.State {
state = label.State{}
state.SetWidth(150.0)
state.Text = fmt.Sprintf("%.2f / %.2f", ebiten.ActualFPS(), ebiten.ActualTPS())
state.TextFontFace = assets.FontFaces.DebugInfoText
state.TextColor = assets.Colors.DebugInfoText
return state
},
}),
label.New(flexbox.NewRegion(flexbox.RegionConfig{
StateFunc: func(state flexbox.RegionState) flexbox.RegionState {
state = flexbox.RegionState{}
return state
},
}), label.Config{
StateFunc: func(state label.State) label.State {
state = label.State{}
state.SetWidth(150.0)
state.Text = fmt.Sprintf("%dx%d / %dx%d",
vars.Ebiten.ScreenWidth, vars.Ebiten.ScreenHeight,
vars.Ebiten.OutsideWidth, vars.Ebiten.OutsideHeight,
)
state.TextFontFace = assets.FontFaces.DebugInfoText
state.TextColor = assets.Colors.DebugInfoText
return state
},
}),
label.New(flexbox.NewRegion(flexbox.RegionConfig{
StateFunc: func(state flexbox.RegionState) flexbox.RegionState {
state = flexbox.RegionState{}
return state
},
}), label.Config{
StateFunc: func(state label.State) label.State {
state = label.State{}
var mem runtime.MemStats
runtime.ReadMemStats(&mem)
if mem.Sys > maxMemSys {
maxMemSys = mem.Sys
}
memPauseNs := mem.PauseNs[(mem.NumGC+255)%256]
if memPauseNs > maxMemPauseNs {
maxMemPauseNs = memPauseNs
}
state.SetWidth(150.0)
state.Text = fmt.Sprintf("%.2f MiB (%.2f ms)",
basic.Float(maxMemSys)/(1024*1024),
basic.Float(maxMemPauseNs)/1_000_000,
)
state.TextFontFace = assets.FontFaces.DebugInfoText
state.TextColor = assets.Colors.DebugInfoText
return state
},
}),
),
)
}
Пример кода отображения кнопки
dragon/pkg/window/common/button/shape.go
package button
import (
"github.com/a1emax/youngine/basic"
"github.com/a1emax/youngine/x/roundrect"
"github.com/hajimehoshi/ebiten/v2"
)
type shapePart struct {
shapeKey basic.Opt[shapeKey]
shape *ebiten.Image
}
type shapeKey struct {
size basic.Vec2
cornerRadius basic.Float
}
func (b *buttonImpl[R]) setupShape() {
r := b.region.Rect()
var cornerRadius basic.Float
if b.state.CornerRadius.IsSet() {
cornerRadius = b.state.CornerRadius.Get()
} else {
cornerRadius = r.Height() / 2
}
key := shapeKey{
size: r.Size,
cornerRadius: cornerRadius,
}
if b.shapeKey.IsSet() && b.shapeKey.Get() == key {
return
}
b.disposeShape()
bmp := roundrect.Fill(key.size.X(), key.size.Y(), key.cornerRadius, key.cornerRadius)
img := ebiten.NewImage(bmp.Width(), bmp.Height())
img.WritePixels(bmp.Data())
b.shapeKey = basic.SetOpt(key)
b.shape = img
}
func (b *buttonImpl[R]) drawShape(screen *ebiten.Image) {
if !b.shapeKey.IsSet() {
return
}
clr := b.color(b.state.PrimaryColor, b.state.PressedColor)
if clr == nil {
return
}
r := b.region.Rect()
op := &ebiten.DrawImageOptions{}
op.GeoM.Translate(r.Left(), r.Top())
op.ColorScale.ScaleWithColor(clr)
screen.DrawImage(b.shape, op)
}
func (b *buttonImpl[R]) disposeShape() {
if !b.shapeKey.IsSet() {
return
}
b.shape.Deallocate()
b.shapePart = shapePart{}
}
dragon/pkg/window/common/button/text.go
package button
import (
"math"
"github.com/a1emax/youngine/basic"
"github.com/a1emax/youngine/x/textview"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/text"
"golang.org/x/image/font"
)
type textPart struct {
textKey basic.Opt[textKey]
text textview.SingleLine
}
type textKey struct {
width basic.Float
fontFace font.Face
text string
}
func (b *buttonImpl[R]) setupText() {
r := b.region.Rect()
key := textKey{
width: r.Width(),
fontFace: b.state.TextFontFace,
text: b.state.Text,
}
if b.textKey.IsSet() && b.textKey.Get() == key {
return
}
b.disposeText()
if key.fontFace == nil || key.text == "" {
return
}
b.textKey = basic.SetOpt(key)
b.text = textview.NewSingleLine(key.width, key.fontFace, key.text)
}
func (b *buttonImpl[R]) drawText(screen *ebiten.Image) {
if !b.textKey.IsSet() {
return
}
clr := b.color(b.state.TextPrimaryColor, b.state.TextPressedColor)
if clr == nil {
return
}
r := b.region.Rect()
left := r.Left() + (r.Width()-b.text.Width())/2
top := r.Top() + (r.Height()-b.text.Height())/2
b.text.Draw(textview.StringDrawerFunc(func(s string, x, y basic.Float, fontFace font.Face) {
text.Draw(screen, s, fontFace, int(math.Floor(left+x)), int(math.Floor(top+y)), clr)
}))
}
func (b *buttonImpl[R]) disposeText() {
b.textPart = textPart{}
}
Загрузка ассетов (пакет asset)
С ассетами главный вопрос всегда в том, где их взять. Если же они есть, то их загрузка может быть как тривиальной для небольших игр с десятком изображений и шрифтов, так и весьма сложной для больших проектов — связанной со множеством источников (файловая система, сеть), каскадной, динамической и какой угодно ещё.
В пакете asset ассеты однозначно идентифицируются произвольными строками URI и классифицируются по типам, связанным с провайдерами. Стандартные провайдеры, реализованные в группе пакетов format, выполняют только декодирование ассетов, используя внешний механизм для получения исходных данных. Стандартные механизмы получения реализованы в пакетах группы host — пока там только файловая система, которую я использовал в своих проектах, но я планирую расширить этот набор и дополнить его системой протоколов (URI вида local:path/to/asset
), позволяющей переключаться между несколькими механизмами получения в рамках одного провайдера.
Загрузчик позволяет как загружать ассеты, так и выгружать их. В нём используется подсчёт ссылок — запрос ассета у провайдера и его кэширование происходят только при его первой загрузке, а освобождение связанных с ним ресурсов и его удаление из кэша — только при его последней выгрузке. Загрузчик — первый из описываемых в этой статье механизмов, поддерживающий конкурентный доступ (используемые провайдеры тоже должны его поддерживать) — запрос ассета блокирует только связанные с ним загрузки, и все они заканчиваются одновременно с окончанием запроса.
При работе с ассетами может также пригодиться пакет x/scope, реализующий что-то вроде оператора defer
, не ограниченного одной функцией.
Пример кода инициализации загрузчика
dragon/pkg/global/tools/asset.go
package tools
import (
"github.com/a1emax/youngine/asset"
"github.com/a1emax/youngine/asset/format/image"
"github.com/a1emax/youngine/asset/format/kage"
"github.com/a1emax/youngine/asset/format/mp3"
"github.com/a1emax/youngine/asset/format/rgba"
"github.com/a1emax/youngine/asset/format/sfnt"
"github.com/a1emax/youngine/asset/format/text"
"github.com/a1emax/youngine/asset/format/wav"
"github.com/a1emax/youngine/asset/host/filesystem"
"github.com/a1emax/youngine/x/scope"
"dragon/res"
)
var AssetMapper asset.Mapper
var AssetLoader asset.Loader
func initAsset(lc scope.Lifecycle) {
mapper := asset.NewMapper()
loader := asset.NewLoader(mapper)
fetcher := filesystem.NewFetcher(res.FS)
asset.Map[image.Asset](mapper, image.NewProvider(fetcher))
asset.Map[kage.Asset](mapper, kage.NewProvider(fetcher))
asset.Map[mp3.Asset](mapper, mp3.NewProvider(fetcher, 0))
asset.Map[rgba.Asset](mapper, rgba.NewProvider(fetcher))
asset.Map[sfnt.Asset](mapper, sfnt.NewProvider(fetcher))
asset.Map[sfnt.FaceAsset](mapper, sfnt.NewFaceProvider(fetcher, loader)) // Использует ассеты типа sfnt.Asset
asset.Map[text.Asset](mapper, text.NewProvider(fetcher))
asset.Map[wav.Asset](mapper, wav.NewProvider(fetcher, 0))
AssetMapper = mapper
AssetLoader = loader
}
Пример кода загрузки начертаний шрифта
dragon/pkg/global/assets/fontfaces.go
package assets
import (
"github.com/a1emax/youngine/asset/format/sfnt"
"github.com/a1emax/youngine/x/scope"
)
var FontFaces struct {
DebugInfoText sfnt.FaceAsset // Использует ассет fonts/open-sans-regular.ttf
GameOverButtonText sfnt.FaceAsset // Использует ассет fonts/seymour-one-regular.ttf
MainMenuButtonText sfnt.FaceAsset // Использует ассет fonts/seymour-one-regular.ttf
}
func initFontFaces(lc scope.Lifecycle) {
FontFaces.DebugInfoText = load[sfnt.FaceAsset](lc, "font-faces/debug-info-text.sff")
FontFaces.GameOverButtonText = load[sfnt.FaceAsset](lc, "font-faces/game-over-button-text.sff")
FontFaces.MainMenuButtonText = load[sfnt.FaceAsset](lc, "font-faces/main-menu-button-text.sff")
}
Долговременные данные (пакет store)
Пакет store реализует ещё один механизм, поддерживающий конкурентный доступ и созданный, по большому счёту, только ради него — хранение данных (например, настроек и прогресса игры) между запусками приложения. Это базовый механизм для удобного начала разработки — не стоит использовать его, скажем, для хранения мегабайтов состояния открытого мира.
Данные описываются любой простой структурой — такой, которая может быть полностью скопирована через присваивание. Один из экземпляров этой структуры содержится в локере, а два других — в буфере для быстрого неконкурентного доступа и синхронизаторе для конкурентного взаимодействия с местом хранения (например, файлом). В принципе, можно обойтись без буфера, работая напрямую с локером, но тогда каждая операция доступа может вызвать пусть короткую, но блокировку, которая негативно скажется на производительности — буфер же позволяет чтению вовсе не зависеть от блокировок, а запись выполнять хоть и с блокировками, но пакетно и в подходящий момент.
Стандартные механизмы доступа к месту хранения реализованы в группе пакетов host — сейчас там только локальный YAML-файл, и у меня нет никаких конкретных идей по расширению этого набора (разве что сеть, но я не уверен, что в данном случае это имеет смысл).
Вся эта кухня нужна, поскольку сохранение данных чаще всего требуется выполнять вне основного потока. Даже если делать это по кнопке, выполнение такой операции в основном цикле выглядит не очень хорошей идеей, поскольку может зафризить интерфейс. А есть и специальные случаи — например, уход приложения в фон на Android, после которого оно может быть выгружено без возобновления работы.
Пример кода инициализации хранилища
dragon/pkg/global/tools/store.go
package tools
import (
"context"
"fmt"
"path"
"time"
"github.com/a1emax/youngine/store"
"github.com/a1emax/youngine/store/host/file"
"github.com/a1emax/youngine/x/scope"
"dragon/pkg/global/vars"
)
const storeFileName = "dragon.yml"
type StoreData struct {
MuteAudio bool `yaml:"mute_audio"`
}
var StoreBuffer store.Buffer[StoreData]
var StoreSyncer store.Syncer[StoreData]
func initStore(lc scope.Lifecycle) {
locker := store.NewLocker[StoreData]()
filePath := path.Join(vars.Extern.FilesDir, storeFileName)
accessor := file.NewAccessor[StoreData](filePath)
Logger.Debug("store file: " + filePath)
syncer := store.NewSyncer(locker, accessor)
err := syncer.Load(context.Background())
if err != nil {
Logger.Error(fmt.Sprintf("%+v", err))
}
buffer := store.NewBuffer(locker)
buffer.Pull()
stop := make(chan struct{})
done := make(chan struct{})
go func() {
defer close(done)
t := time.NewTicker(10 * time.Second)
defer t.Stop()
select {
case <-t.C:
err := syncer.Save(context.Background())
if err != nil {
Logger.Error(fmt.Sprintf("%+v", err))
} else {
Logger.Debug("store file is updated in background")
}
case <-stop:
return
}
}()
lc.Defer(func() {
close(stop)
<-done
err := syncer.Save(context.Background())
if err != nil {
Logger.Error(fmt.Sprintf("%+v", err))
} else {
Logger.Debug("store file is updated on exit")
}
})
StoreBuffer = buffer
StoreSyncer = syncer
}
Шаблон проекта
Чтобы было удобнее начинать работу с Youngine, я опишу шаблон проекта на его основе. Воспринимайте его как некую отправную точку — по ходу работы он может и должен изменяться. Удобно начать с глобальных сущностей, но когда контуры проекта будут очерчены и станет примерно понятно, как он работает, лучше перейти к инъекции зависимостей. Части проекта со временем могут быть выделены в библиотеки и переиспользованы в других разработках. Новые платформы (например, никак пока не охваченная мной iOS) наверняка потребуют новых подходов и инструментов. В общем, я лишь нарисую пару кругов на пустом листе —, а сову дорисуйте (пример готового проекта).
Структура
Последовательность пакетов отражает возможные зависимости между ними — нижние пакеты могут зависеть от верхних, но не наоборот.
res — встраиваемая через
go:embed
файловая система с ресурсами (ассетами, конфигурациями и так далее).pkg — подключаемые пакеты.
domain — доменная логика.
global — глобальные сущности.
vars — произвольные переменные.
Extern.FilesDir
— директория для чтения и записи. На Android это не то же самое, что рабочая директория.Kernel.IsTerminated
— флаг завершения приложения. Если он выставлен, приложение завершит работу в начале следующего обновления.Window.Page
— активная страница окна (см. youngine/scene/element/pageset).
tools — инструменты Youngine, логгер, генератор случайных чисел и подобное.
assets — статические ассеты.
window — интерфейс пользователя.
kernel — управляющее ядро.
EbitenGame
возвращает синглтонebiten.Game
, реализующий основной цикл, для передачи вebiten.RunGame
(desktop) либоmobile.SetGame
(android_intern).Activate
завершает внешнюю инициализацию. До вызова этой функции основной цикл будет простаивать. После её вызова во время ближайшего обновления будет выполнена внутренняя инициализация и приложение начнёт выполняться.Close
финализирует приложение, освобождая ресурсы, если необходимо.IfRunning
вызывает переданную функцию только если приложение выполняется и не финализировано. ФлагKernel.IsTerminated
при этом не учитывается — только вызовыActivate
иClose
.
cmd — компилируемые сервисные пакеты (если они есть).
app — компилируемые прикладные пакеты.
desktop — main для Windows, Linux и macOS.
android_intern — библиотека для Android (из неё получится AAR).
SetFilesDir
вызывается во времяMainActivity.onCreate
и устанавливает переменнуюExtern.FilesDir
.Activate
вызывается в концеMainActivity.onCreate
и вызываетkernel.Activate
.Suspend
вызывается во времяMainActivity.onPause
. Эту функцию можно использовать например для сохранения долговременных данных.Resume
вызывается во времяMainActivity.onResume
.
android — проект Android Studio.
Запуск на Android
Для запуска приложения на базе Youngine на Android потребуется отдельный проект в Android Studio. Если вы с ней хорошо знакомы, вам будет интересно только подключение android_intern
, а остальное можно смело читать наискосок. Если же вы со студией раньше дела не имели, то ниже по шагам описано всё, что нужно сделать с сухого старта до запуска на устройстве.
Сразу упомяну, почему подключаемый AAR, а не сразу готовый APK
Во-первых, студия в любом случае необходима для установки SDK. Во-вторых, она специально предназначена для разработки под Android и делает многие вещи проще — настройку, отладку, эмуляцию, запуск на устройствах, сборку, публикацию и прочее. В-третьих, такой подход позволяет при необходимости расширить приложение, используя все возможности существующей экосистемы Android.
Установка SDK
Запустите студию, перейдите в раздел Customize и выберите All settings. В открывшемся окне во вкладке SDK Platforms отметьте интересующие вас версии Android (в большинстве случаев достаточно самой свежей), а во вкладке SDK Tools — NDK. После подтверждения будет установлено то, что вы отметили.
Создание проекта
Перейдите в раздел Projects и выберите New Project. В открывшемся окне выберите Phone and Tablet, No Activity и заполните поля на следующей странице:
Name — project (название вашего проекта)
Package name — com.github.username.project (корневой пакет Java)
Save location — /path/to/youngine/project/app/android
Language — Java (с Kotlin я не проверял, но тоже должно работать)
Minimum SDK — API 24 (просто следуйте рекомендациям студии)
Build configuration language — Kotlin DSL
После подтверждения проект будет сгенерирован и открыт. Пока, чтобы видеть его реальную файловую структуру (я буду указывать пути от корня), переключитесь в режим отображения Project (слева вверху, где изначально выбран режим Android).
Подключение android_intern
Создайте директорию intern
. Затем откройте терминал и, находясь в корне проекта Youngine, выполните команды:
# только в первый раз
go install "github.com/hajimehoshi/ebiten/v2/cmd/ebitenmobile@v2.8.6"
# добавьте эту директорию в .gitignore
mkdir -p ".local"
ebitenmobile bind \
-target "android" \
-androidapi 24 \ # то, что вы указали как Minimum SDK при создании проекта
-javapkg "com.github.username.project.go" \ # обратите внимание на go в конце
-o ".local/project-android-intern.aar" \
"project/app/android_intern" # project - имя модуля Go
cp ".local/project-android-intern.aar" "app/android/intern/default.aar"
После этого создайте файл intern/build.gradle.kts
:
configurations.maybeCreate("default")
artifacts.add("default", file("default.aar"))
а также измените файл settings.gradle.kts
:
include(":app")
include(":intern") // +
и файл app/build.gradle.kts
:
implementation(project(":intern")) // +
implementation(libs.appcompat)
Студия предложит выполнить синхронизацию с файлами Gradle — сделайте это.
Создание MainActivity
Откройте контекстное меню директории app
, выберите New, Activity, Empty Views Activity и заполните поля в открывшемся окне:
Activity Name — MainActivity
Generate a Layout File — отмечено
Layout Name — activity_main
Launcher Activity — не отмечено
Package name — com.github.username.project
Source Language — Java
Target Source Set — main
После подтверждения будет сгенерирован необходимый код. Измените его, чтобы задействовать android_intern
.
Итоговое содержание файла app/src/main/res/layout/activity_main.xml
Итоговое содержание файла app/src/main/java/com/github/username/project/MainActivity.java
package com.github.username.project;
import android.os.Bundle;
import android.util.Log;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.core.view.WindowInsetsControllerCompat;
import com.github.username.project.go.ebitenmobileview.Ebitenmobileview;
import com.github.username.project.go.intern.EbitenView;
import com.github.username.project.go.intern.Intern;
import java.util.Objects;
import go.Seq;
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
// Hide system bars.
WindowInsetsControllerCompat windowInsetsController =
WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView());
windowInsetsController.setSystemBarsBehavior(
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
);
windowInsetsController.hide(WindowInsetsCompat.Type.systemBars());
// Get directory with both read and write access.
java.io.File externalFilesDir = getExternalFilesDir(null);
String externalFilesDirPath = Objects.requireNonNull(externalFilesDir).getPath();
try {
Intern.setFilesDir(externalFilesDirPath);
Intern.activate();
} catch (Exception e) {
logGoError(e);
}
Seq.setContext(getApplicationContext());
}
// EbitenView.suspendGame and EbitenView.resumeGame should be called in onPause and onResume
// respectively. However, it sometimes leads to a bug that causes the application to restart
// when resuming, so for now it's enough to call the corresponding Ebitenviewmobile methods.
private EbitenView getEbitenView() {
return (EbitenView) this.findViewById(R.id.ebitenview);
}
@Override
protected void onPause() {
super.onPause();
try {
Intern.suspend();
Ebitenmobileview.suspend();
} catch (final Exception e) {
logGoError(e);
}
}
@Override
protected void onResume() {
super.onResume();
try {
Ebitenmobileview.resume();
Intern.resume();
} catch (final Exception e) {
logGoError(e);
}
}
private void logGoError(Exception e) {
Log.e("go", e.toString());
}
}
После этого сконфигурируйте MainActivity в файле app/src/main/AndroidManifest.xml
:
Финальные штрихи
Уберите action bar из темы в файлах app/src/main/res/values(-night)/themes.xml
:
Укажите отображаемое название проекта в файле app/src/main/res/values/strings.xml
:
Project Title
Добавьте иконку приложения, открыв контекстное меню директории app
и выбрав New, Image Asset.
Запуск на устройстве
После того, как все описанные выше шаги выполнены, приложение готово к запуску. Повторять их, когда вы вносите изменения в код Go, не нужно — достаточно выполнить в терминале команды из описания подключения android_intern
.
Чтобы с компьютера запустить приложение на устройстве, нужно сначала перевести его в режим разработчика и активировать на нём отладку по USB. Я опишу этот процесс для оболочки MIUI моего телефона — для других оболочек и чистого Android он мало чем отличается. В настройках перейдите в раздел «О телефоне» и 7 раз нажмите на пункт «Версия MIUI» — если всё сделано правильно, вы увидите сообщение «Вы стали разработчиком!» (а вы говорите, курсы). Затем перейдите в раздел «Расширенные настройки» и в появившемся там подразделе «Для разработчиков» активируйте переключатели «Отладка по USB» и «Установка через USB». Я, когда заканчиваю отладку, обычно отключаю режим разработчика во избежание —, но это уже на ваше усмотрение.
Когда всё сделано, подключайте устройство к компьютеру — и нажимайте Run.
Несколько слов в конце
Работы ещё непаханое поле. Я хочу довести до ума демонстрационную игру — решить перманентную проблему с поиском графики, составить уровни, добавить прогресс и настройки и опубликовать, чтобы уж сделать всё до конца. Ещё нужно разработать стандартные виджеты для Youngine и сделать его документацию подробнее. Я решил пока ненадолго остаться в нулевых версиях, чтобы до первой релизной и самому себе оставить пространство для манёвра, и вашу обратную связь учесть — делитесь ей в комментариях, она очень мне пригодится.
Что до игр — то буду их делать. Самое главное, что я вынес из уже проделанной работы, это то, что делать их на Go не только возможно, но и удобно. Мне бы хотелось, чтобы язык развивался в сторону прикладной ниши, и я постараюсь внести в это свой вклад.
Спасибо за ваше внимание!
P.S. А телеграм-канала у меня нет ¯\_(ツ)_/¯