Идея сделать игру под 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 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
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black"
android:keepScreenOn="true"
tools:context=".MainActivity">
<com.github.username.project.go.intern.EbitenView
android:id="@+id/ebitenview"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:focusable="true" />
</RelativeLayout>
Итоговое содержание файла 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
: main/AndroidManifest.xml
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="portrait"> <!-- установите подходящий вам вариант -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
Финальные штрихи
Уберите action bar из темы в файлах app
: main values(-night)/themes.xml
<style name="Theme.Project" parent="Theme.MaterialComponents.DayNight.NoActionBar">
Укажите отображаемое название проекта в файле app
: main values/strings.xml
<string name="app_name">Project Title</string>
Добавьте иконку приложения, открыв контекстное меню директории app
и выбрав New, Image Asset.
Запуск на устройстве
После того, как все описанные выше шаги выполнены, приложение готово к запуску. Повторять их, когда вы вносите изменения в код Go, не нужно — достаточно выполнить в терминале команды из описания подключения android_intern
.
Чтобы с компьютера запустить приложение на устройстве, нужно сначала перевести его в режим разработчика и активировать на нём отладку по USB. Я опишу этот процесс для оболочки MIUI моего телефона — для других оболочек и чистого Android он мало чем отличается. В настройках перейдите в раздел «О телефоне» и 7 раз нажмите на пункт «Версия MIUI» — если всё сделано правильно, вы увидите сообщение «Вы стали разработчиком!» (а вы говорите, курсы). Затем перейдите в раздел «Расширенные настройки» и в появившемся там подразделе «Для разработчиков» активируйте переключатели «Отладка по USB» и «Установка через USB». Я, когда заканчиваю отладку, обычно отключаю режим разработчика во избежание — но это уже на ваше усмотрение.
Когда всё сделано, подключайте устройство к компьютеру — и нажимайте Run.
Несколько слов в конце
Работы ещё непаханое поле. Я хочу довести до ума демонстрационную игру — решить перманентную проблему с поиском графики, составить уровни, добавить прогресс и настройки и опубликовать, чтобы уж сделать всё до конца. Ещё нужно разработать стандартные виджеты для Youngine и сделать его документацию подробнее. Я решил пока ненадолго остаться в нулевых версиях, чтобы до первой релизной и самому себе оставить пространство для манёвра, и вашу обратную связь учесть — делитесь ей в комментариях, она очень мне пригодится.
Что до игр — то буду их делать. Самое главное, что я вынес из уже проделанной работы, это то, что делать их на Go не только возможно, но и удобно. Мне бы хотелось, чтобы язык развивался в сторону прикладной ниши, и я постараюсь внести в это свой вклад.
Спасибо за ваше внимание!
P.S. А телеграм-канала у меня нет ¯_(ツ)_/¯
Автор: a1emax