Идея сделать игру под 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
<?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/src/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/src/main/res/values(-night)/themes.xml:

<style name="Theme.Project" parent="Theme.MaterialComponents.DayNight.NoActionBar">

Укажите отображаемое название проекта в файле app/src/main/res/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. А телеграм-канала у меня нет ¯\_(ツ)_/¯

Комментарии (6)


  1. HemulGM
    17.01.2025 08:40

    И ни одного скрина? Даже в репозиториях.


    1. a1emax Автор
      17.01.2025 08:40

      Справедливо.

      Вот скрины из игры про ракету

      Драконья игра - это скорее демонстрация кода, в визуальном плане она пока не очень (графики нет).


      1. qeeveex
        17.01.2025 08:40

        Это чистый Golang? Без JS и прочего мусора?


        1. a1emax Автор
          17.01.2025 08:40

          Да. На последнем скрине звёздное небо - это шэйдер на Ebitengine Kage.


  1. evgeniy_kudinov
    17.01.2025 08:40

    Хорошее начинание. Если будет свободное время, попробую на Андроид поставить)
    В репозитории поглядел бегло, и сразу в глаза попалась java-подобная конструкция https://github.com/a1emax/youngine/blob/3fb8c3ce78aa0838e81e8c5c87b205bdb3118ec2/asset/mapper.go#L23

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