Один из самых частых вопросов в нашем сообществе разработке игр на Go — это с чего начать.
В этой серии статей мы будем изучать движок Ebitengine и создадим RPG в процессе.
Вступление
Что от вас ожидается:
- Вам интересна разработка игр на Go
- Вы уже владеете этим языком программирования
- Никаких шуточек про название движка Ebitengine
Это не курс по программированию на Go, а я не буду убеждать вас, что разработка игр на Go — нечто великолепное. Однако, если вам любопытна эта тема, то мне есть, чем с вами поделиться.
Знакомимся с Ebitengine
До того, как мы начнём использовать Ebitengine, я предлагаю склонировать репозиторий и позапускать примеры.
$ git clone --depth 1 https://github.com/hajimehoshi/ebiten.git
$ cd ebiten
Перед тем, как мы сможем запускать игры, нужно установить dev зависимости. Они нужны только для компиляции игр, игрокам ставить ничего не придётся.
После установки зависимостей, запустите эти игры, находясь в директории ebiten
:
$ go run ./examples/blocks
$ go run ./examples/flappy
$ go run ./examples/2048
$ go run ./examples/snake
Эти игры довольно простые, тем и хороши как объекты для исследования: там мало кода. Всего примеров около 80 и чаще всего они концентрируются на одной теме (например, на игровой камере).
Ресурсы для этих игр хранятся в ./examples/resources
.
Это традиционный способ начать знакомство с Ebitengine — запускать примеры, читать их код, модифицировать эти игры. Всякий раз, когда захочется сделать перерыв от следования этим статьям, отвлекитесь на эти примеры.
То, что примеры почти никогда не используют сторонние библиотеки — это одновременно и плюс, и минус. Это хорошо, чтобы получше понять базовый функционал движка. Но количество лишнего кода и некоторых не очень красивых решений может отпугнуть новых разработчиков.
Я перейду ко сторонним библиотекам почти сразу. Это уменьшит количество шагов назад с переписыванием кода.
Создаём Проект
Начнём с создания директории где-нибудь в удобном для вас месте.
$ mkdir mygame && cd mygame
Игры на Go — это обычные приложения, поэтому вторым шагом будет инициализация модуля.
$ go mod init github.com/quasilyte/ebitengine-hello-world
Нам сразу же потребуется Ebitengine. Ставить нужно вторую версию.
$ go get github.com/hajimehoshi/ebiten/v2
Пакет main размещаем в cmd/mygame
:
$ mkdir -p cmd/mygame
// cmd/mygame.main.go
package main
import (
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
)
func main() {
g := &myGame{
windowWidth: 320,
windowHeight: 240,
}
ebiten.SetWindowSize(g.windowWidth, g.windowHeight)
ebiten.SetWindowTitle("Ebitengine Quest")
// RunGame ожидает реализации трёх методов:
// Update, Draw и Layout; они определены ниже.
if err := ebiten.RunGame(g); err != nil {
panic(err)
}
}
type myGame struct {
windowWidth int
windowHeight int
}
func (g *myGame) Update() error {
return nil
}
func (g *myGame) Draw(screen *ebiten.Image) {
ebitenutil.DebugPrint(screen, "Hello, World!")
}
func (g *myGame) Layout(w, h int) (int, int) {
// Layout - тема для продвинутых, поэтому нам пока
// достаточно считать, что screen size = window size.
return g.windowWidth, g.windowHeight
}
Игры в Ebitengine имеют разделённые логические тики и фреймы отрисовки. Количество кадров в секунду — FPS, количестко тиков в секунду — TPS.
Любая отрисовка графики на экран должна происходить в Draw
. Игровая логика должна находиться в Update
. После вызова RunGame наша игра попадает в game loop, управляемый движком.
На вход в Draw
мы получаем ebiten.Image
, который по конвенции обычно называют screen. Ожидается, что на каждый вызов Draw
мы будем заполнять этот image нужными пикселями. Каждый объект, который должен быть виден в игровом окне, должен быть отрисован на screen. Чаще всего это делается через метод DrawImage, который позволяет отрисовать одну текстуру на другой.
Если мы запустим эту игру, то получим чёрное окно с возмутительно уникальным текстом:
$ go run ./cmd/mygame
Загрузка Изображений
Многофункциональных спрайтов в движке нет, но тип ebiten.Image весьма хорош как стартовая точка. Для тестового изображения возьмём gopher.png из examples/resources
.
Мы можем считать, что ebiten.Image
— это абстракция над набором пикселей с методами отрисовки. Когда мы перейдём к абстракции спрайтов, image будет для нас чем-то вроде уровня текстур.
Изображение гофера мы разместим в пакете assets
:
mygame/
cmd/mygame/main.go
internal/assets/
_data/images/gopher.png
Часть важных ассетов можно хранить прямо в исполняемом файле игры с помощью go:embed
. Пакет assets
будет предоставлять доступ ко всем ресурсам игры.
// internal/assets/assets.go
package assets
import (
"embed"
"io"
)
//go:embed all:_data
var gameAssets embed.FS
func OpenAsset(path string) io.ReadCloser {
// Функция OpenAsset могла бы работать как с данными внутри бинарника,
// так и с внешними. Для этого ей нужно распознавать ресурс по его пути.
// Самым простым вариантом является использование префиксов в пути,
// типа "$music/filename.ogg" вместо "filename.ogg", когда мы ищем
// файл во внешнем каталоге (а не в бинарнике).
//
// Но на данном этапе у нас только один источник ассетов - бинарник.
f, err := gameAssets.Open("_data/" + path)
if err != nil {
panic(err)
}
return f
}
Чтобы отрендерить изображение на экране, нужно большее, чем доступный на чтение ассет. Нужно декодировать PNG и создать объект ebiten.Image
на основе этого. Аналогичные шаги нужно выполнять для остальных видов ресурсов — музыки (OGG), звуковых эффектов (WAV), шрифтов и так далее.
На помощь приходит библиотека ebitengine-resource. Она же будет ответственна за кеширование (мы не хотим декодировать одинаковые ресурсы несколько раз).
Все доступы к ресурсам будут проходить через числовые ключи (ID). Связка идентификаторов с метаданными ручная (см. registerImageResources
).
// internal/assets/images.go
package assets
import (
resource "github.com/quasilyte/ebitengine-resource"
_ "image/png"
)
const (
ImageNone resource.ImageID = iota
ImageGopher
)
func registerImageResources(loader *resource.Loader) {
imageResources := map[resource.ImageID]resource.ImageInfo{
ImageGopher: {Path: "images/gopher.png"},
}
for id, res := range imageResources {
loader.ImageRegistry.Set(id, res)
}
}
ebitengine-resource
требует импорта пакетаimage/png
со стороны пользователя. Делать это нужно ровно один раз, в любом месте программы. Лучше всего для этого подходит файл, который описывает графические ресурсы.
У каждого вида ресурсов будет своя регистрирующая функция. Вызываться они будут из общей функции RegisterResources
. В свою очередь, RegisterResources
вызывается из main
функции игры.
// internal/assets/assets.go
func RegisterResources(loader *resource.Loader) {
registerImageResources(loader)
}
Создаётся менеджер ресурсов на старте программы, а далее пробрасывается как часть контекста всей игры. Для текущего примера можно разместить loader
внутри объекта myGame
.
// cmd/mygame/main.go
// resource - "github.com/quasilyte/ebitengine-resource"
// audio - "github.com/hajimehoshi/ebiten/v2/audio"
// assets - "github.com/quasilyte/ebitengine-hello-world/internal/assets"
func createLoader() *resource.Loader {
sampleRate := 44100
audioContext := audio.NewContext(sampleRate)
loader := resource.NewLoader(audioContext)
loader.OpenAssetFunc = assets.OpenAsset
return loader
}
Теперь в любом месте программы мы можем использовать доступ по ID изображения, чтобы получить *ebiten.Image
:
img := loader.LoadImage(assets.ImageGopher)
Во время первого доступа по ключу, менеджер ресурсов загрузит ассет, декодирует его и закеширует. Все следующие обращения будут возвращать уже созданный для ресурса объект.
Если выполнять для каждого ресурса Load
где-нибудь на экране загрузки, то можно заранее прогреть все кеши.
Отрисовка Изображения
Вот новый код метода Draw
игры:
// cmd/mygame/main.go
func (g *myGame) Draw(screen *ebiten.Image) {
gopher := g.loader.LoadImage(assets.ImageGopher).Data
var options ebiten.DrawImageOptions
screen.DrawImage(gopher, &options)
}
Гофер рисуется в позиции {0,0}
. Мы можем поменять позицию, выполнив пару манипуляций с options
. Но чтобы было интереснее, мы введём сущность player и закрепим изображение за ними.
Позиции в 2D играх чаще всего описываются как двумерные вектора. Настало время импортировать следующую библиотеку.
// cmd/mygame/main.go
package main
import "github.com/quasilyte/gmath"
type Player struct {
pos gmath.Vec // {X, Y}
img *ebiten.Image
}
Пакет gmath содержит множество полезных в геймдеве математических функций. Большая часть API повторяет то, что можно найти в Godot.
Обработку инпутов мы рассмотрим в следующей статье, а сегодня игрок будет перемещаться автоматически. Так как перемещение — это логика, а не рендеринг, исполнять этот код мы будем внутри Update
.
// cmd/mygame/main.go
// Так как теперь у нас есть объект, требующий инициализации,
// мы будем создавать его на старте игры.
// Метод init() нужно вызывать явно в main() до RunGame.
func (g *myGame) init() {
gopher := g.loader.LoadImage(assets.ImageGopher).Data
g.player = &Player{img: gopher}
}
func (g *myGame) Update() error {
// В Ebitengine нет никаких time delta.
// Подробнее почитать об этом можно тут:
// https://github.com/tinne26/tps-vs-fps
// По умолчанию, TPS=60, отсюда 1/60.
g.player.pos.X += 16 * (1.0 / 60.0)
return nil
}
Рендеринг остаётся внутри Draw
:
// cmd/mygame/main.go
func (g *myGame) Draw(screen *ebiten.Image) {
var options ebiten.DrawImageOptions
options.GeoM.Translate(g.player.pos.X, g.player.pos.Y)
screen.DrawImage(g.player.img, &options)
}
Такой способ отрисовки изображений слишком низкоуровневый, поэтому уже в следующей статье мы начнём использовать обёртки, реализующие более удобные спрайты.
Закрепляем Изученное
- Традиционный способ изучать Ebitengine — исследовать examples
- В играх на Ebitengine разделённые циклы для
Update
иDraw
- Поверхностно познакомились с ebiten.Image
- Для загрузки и кширования ресурсов — ebitengine-resource
- Для векторной двумерной арифметики — gmath
- В Ebitengine нет time delta
- Запоминаем структуру проекта, которую я ввёл (дальше — больше)
Исходные коды этого небольшого проекта находятся в репозитории ebitengine-hello-world (ссылка на тег part0
).
В следующий раз мы добавим в список используемых библиотек сцены, спрайты и кое-что для продвинутой обработки ввода игрока.
Причина, по которой мы не сразу начали использовать спрайты — время от времени вы всё равно будете работать с ebiten.Image
как с полноценным объектом. Например, когда функционал спрайтов не покрывает ваши специфичные задачи. Тем более что менеджер ресурсов кеширует изображения именно как ebiten.Image
.
Статей будет достаточно много, потому что впереди нас ждёт долгий путь.
Подключайтесь к нам в телеграм-сообщество, если тема геймдева на Go вам интересна.