Перевод статьи "Making Games in Go for Absolute Beginners".
Я часто слышу от разработчиков:
Раньше я любил программировать, потому что мне нравилось создавать что-то новое. Но работа на полную ставку убила мою страсть. Я провожу больше времени на совещаниях, споря о сроках и участвуя в обсуждениях, чем за работой с кодом. Я выгорел? Есть ли надежда или мне нужно новое хобби?
Звучит знакомо? Неудивительно, что мы с нетерпением ждем любую возможность использовать новую базу данных или фреймворк, чтобы нем было чуточку менее скучно. Мой совет как вернуть страсть к программированию: снова начните программировать и вернитесь к своему хобби. А что может быть увлекательней чем программирование игр?
Желание писать игры подтолкнуло меня к программированию 20 лет назад. Я стал делать это своей профессией, хотя и создал несколько небольших игр с использованием различных технологических стеков. Но я решил продолжить заниматься программированием игр, и для меня это самый увлекательный вид программирования. Особенно если делать это с друзьями на выходных во время геймджема.
Этот пост - моя попытка побудить вас писать игры с нуля. Почему Go? Мне он нравится, а Ebitengine отлично подходит для написания игр. Как и многие библиотеки Go, она помогает вам делать то, что вам нужно, и не мешает.
Конечно, более мощный игровой движок упрощает многие задачи, но при этом он становится громоздким и требует особого подхода. Одна из причин, почему создание игр приносит удовольствие - это возможность делать всё по-своему и не нужно беспокоиться о том, правильно ли вы поступаете. Кроме того, это отличный способ понять, как работают игры.
И тут важно предупредить: как только вы поймёте, как устроены игры, часть волшебства исчезнет. Вместо того чтобы наслаждаться игрой, вы начнёте задаваться вопросом: "Как это работает?". К счастью, теперь вы сможете получать удовольствие от создания игр.
Примечание: Отказ от ответственности: я разработчик игр-любителей. Я занимаюсь этим, потому что это очень весело, но я никогда не работал над профессиональными играми. Здесь вы увидите мой личный опыт. Я могу показать код, который в "настоящей" игре был бы бесполезен (например, с точки зрения производительности), но его использование на вашей совести. Напишите в комментариях, если найдёте какие-нибудь ошибки
Основы
Я помню, что мне было сложно понять устройство и принципы работы игры. Как сделать так, чтобы объекты в игре делали то, что нужно мне? Это значительно проще, если вы используете игровой движок. Но с чего начать если движка нет?
Все знают, что фильм — это очень длинная последовательность изображений, которые сменяют друг друга так быстро, что наш мозг не успевает их различать. Обычно это 24 или 60 изображений (кадров) в секунду (FPS).
В видеоигре используется тот же принцип, но изображения (кадры) не существуют заранее, поэтому их нужно генерировать (рисовать) на лету. И когда вы нажимаете на кнопки, то следующий кадр будет отображаться(рендериться) уже по другому.
Кажется что все очень просто. Но как заставить персонажа, например, прыгать? Вы перемещаете изображение вверх по экрану, а затем обратно вниз. И останавливаете его движение, когда оно "касается" других изображений. 2D-игры состоят из изображений, наложенных друг на друга.
Я был удивлён, узнав, что игры — это, по сути, бесконечные циклы. Вы просто рисуете изображения на экране каждую итерацию цикла.
for {
DrawFrame()
}
Помимо отрисовки, вам нужно обновить логику игры. Это может быть проверка нажатых кнопок, обновление состояния здоровья игрока и проверка столкновений. Логика определяет, какие изображения и где нужно отображать, а функция отрисовки делает это. Каждый раз, когда логика обновляется, это называется тактом(или тиком).
for {
// Один тик
UpdateLogic()
DrawFrame()
}
Я знаю, что это звучит не слишком захватывающе. Ваша игра делает обновление логики и отрисовку снова и снова, 60 раз в секунду. Всего один поток в котором крутится бесконечный цикл (пока забудьте о существовании горутин).
Несмотря на простоту идеи, поддерживать одинаковую скорость цикла на всех системах непросто. Ebitengine позаботится об этом за вас, и поддерживает 60 тиков в секунду.
Логика - это самая простая часть, это обычный код на Go. Рисование изображений — более сложный процесс, поэтому тут будет использоваться Ebitengine. Он помогает рисовать изображения, воспроизводить звуки и проверять вводимые данные. То, как работает логика, зависит уже от вас.
Ebitengine предоставляет интерфейс Game, который вам необходимо реализовать.
type Game interface {
Update() error
Draw(screen *ebiten.Image)
Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int)
}
Функции Update и Draw работают так же, как в приведённом выше примере с циклом. Layout используется для масштабирования игрового экрана — пока вам не нужно об этом беспокоиться.
Update вызывается при каждом тике перед обновлением кадра и обновляет игровую логику. Если возвращается ошибка, игра завершается.
Draw вызывается в каждом кадре. Аргумент screen - это структура, которая реализует изображение. Именно screen это изображение и будет вашим кадром, можно относиться к нему как к холсту
Настройка проекта
Для начала давайте займёмся скучными делами и создадим наш проект. В него добавим модуль движка Ebitengine. Раньше он назывался Ebiten, поэтому пакеты все еще так называются.
go mod init game
go get github.com/hajimehoshi/ebiten/v2
Вот код для минимальной игры. Это просто пустая реализация интерфейса Game. При запуске должно появиться пустое чёрное окно.
package main
import "github.com/hajimehoshi/ebiten/v2"
type Game struct{}
func (g *Game) Update() error {
return nil
}
func (g *Game) Draw(screen *ebiten.Image) {
}
func (g *Game) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) {
return outsideWidth, outsideHeight
}
func main() {
g := &Game{}
err := ebiten.RunGame(g)
if err != nil {
panic(err)
}
}
Примечание В этих примерах я довольно попустительски отношусь к обработке ошибок. Мы просто создаём прототип и развлекаемся, так что ничего страшного где-то выкинуть
panic. Для большинстве достаточно либо завершить игру, либо записать их в журнал и продолжить.
Загрузка ассетов
Если ваши графические навыки на таком же уровне как и у меня, возможно, при создании прототипа вы предпочтёте использовать готовые ресурсы. Лично мне нравится коллекция от Кенни. Я часто просматриваю там 2D-ресурсы и нахожу что-то для вдохновение, иногда это может стать основой для крутой игры. Для простого клона Asteroids я буду использовать набор ресурсов space shooter.
Для подгрузки ресурсов в игру очень удобно использовать embed, это инструмент будто бы специально создан для игр. Все пути указываются во время компиляции и с ними уже не может быть каких нибудь проблем. Не нужно беспокоиться о дистрибуции, поскольку ресурсы содержатся в одном бинарном файле. И это работает даже для мобильных приложений.
Скорее всего, у вас будет много ресурсов, поэтому сразу встраивайте весь каталог:
import "embed"
//go:embed assets/*
var assets embed.FS
Теперь вы можете загружать изображения из коллекции assets. Для загрузки я использую с must и для простоты сохраняю спрайты как глобальные переменные. Спрайт — это просто другое название для 2D-графики.
Обратите внимание на импорт _ "image/png", он нам нужен для декодирования изображения.
import (
"image"
_ "image/png"
"github.com/hajimehoshi/ebiten/v2"
)
var PlayerSprite = mustLoadImage("assets/player.png")
func mustLoadImage(name string) *ebiten.Image {
f, err := assets.Open(name)
if err != nil {
panic(err)
}
defer f.Close()
img, _, err := image.Decode(f)
if err != nil {
panic(err)
}
return ebiten.NewImageFromImage(img)
}
Рисование Изображений
Вся отрисовка кадра выполняется в методе Draw.
func (g *Game) Draw(screen *ebiten.Image) {
}
Аргумент screen — это изображение, которое отображается в каждом кадре. Ваша задача — нарисовать на нём другие изображения (или текст).
Ключевым методом ebiten.Image является DrawImage, с помощью которого на изображение screen накладываются другие изображения.
screen.DrawImage(PlayerSprite, nil)
Первый аргумент — это изображение, которое вы хотите нарисовать. Второй аргумент — это ebiten.DrawImageOptions структура, которая определяет, где и как будет нарисовано изображение. При передаче nil используются параметры по умолчанию.
Вот результат: космический корабль нарисован в левом верхнем углу экрана.

В структуре параметров отрисовки есть несколько полей. В первую очередь стоит обратить внимание на GeoM-матрицу. В документации подробно описана математика, лежащая в её основе, а математика очень полезна при создании игр. Но если бы я просто хотел создать игру, то алгебра это последнее, о чём я хотел бы читать. Поэтому я постараюсь упростить объяснение.
Вы наверняка в курсе, что при копировании и вставке изображения в графическом редакторе вставленная часть остаётся выделенной, и вы можете продолжать перемещать и преобразовывать её. Вот как я представляю себе DrawImage. Вы можете рассматривать DrawImageOptions как "курсор", с помощью которого можно перемещать и изменять изображение.
При рисовании используются координаты X и Y, при этом ось Y направлена вниз. По умолчанию всё рисуется в точке (0, 0), то есть в верхнем левом углу базового изображения.
Чтобы переместить изображение, используйте GeoM.Translate:
op := &ebiten.DrawImageOptions{}
op.GeoM.Translate(150, 200)
screen.DrawImage(PlayerSprite, op)
Вызов Translate перемещает изображение на 150 пикселей вправо и на 200 пикселей вниз. Можно использовать отрицательные числа, чтобы перемещаться влево и вверх соответственно. Если вы переместите PlayerSprite слишком далеко, то можете не увидеть изображение на экране.

Для поворота изображения используйте GeoM.Rotate:
op := &ebiten.DrawImageOptions{}
op.GeoM.Rotate(45.0 * math.Pi / 180.0)
screen.DrawImage(PlayerSprite, op)
Это код повернет изображение на 45° по часовой стрелке. Единица измерения — радианы. Если вы предпочитаете работать с градусами, используйте формулу degrees * math.Pi / 180.0.

Видите, как изображение частично выходит за пределы экрана? Это происходит потому, что точка поворота (точка, "вокруг" которой вращается изображение) находится в верхнем левом углу изображения. Чуть позже я покажу, как это исправить.
При использовании отрицательных значений изображение поворачивается против часовой стрелки.
Наконец, есть GeoM.Scale, который позволяет рисовать изображения меньшего или большего размера:
op := &ebiten.DrawImageOptions{}
op.GeoM.Scale(2, 2)
screen.DrawImage(PlayerSprite, op)
Вызов Scale увеличивает изображение в два раза по горизонтали и вертикали.

При передаче отрицательных значений изображение переворачивается по горизонтали или вертикали. Например, чтобы нарисовать изображение вверх ногами, используйте:
op.GeoM.Scale(1, -1)
Теперь вы знаете основные инструменты для управления изображениями в игре. Вы можете менять их положение, поворачивать и масштабировать.
Вы можете объединить все эти параметры в одном вызове DrawImage. Однако есть один нюанс: порядок имеет значение. Если вы сначала используете Translate, а затем применяете поворот или масштабирование, вы можете получить не тот результат, который ожидали, поскольку изменение будет применено к новой позиции.
Чтобы проиллюстрировать это, давайте посмотрим, как повернуть изображение вокруг его центра (распространённый сценарий использования).
Вам нужно переместить опорную точку в центр изображения. Найти центр можно просто разделив ширину и высоту изображения пополам. Расчёт параметров на основе размера изображения также очень распространён.
width := PlayerSprite.Bounds().Dx()
height := PlayerSprite.Bounds().Dy()
halfW := float64(width / 2)
halfH := float64(height / 2)
Сначала вам нужно сдвинуть изображение на отрицательные значения. Это нужно для того, чтобы центр изображения совпал с исходной точкой (0, 0). Затем примените поворот и сдвиньте изображение "назад" на ту же величину.
op.GeoM.Translate(-halfW, -halfH)
op.GeoM.Rotate(45.0 * math.Pi / 180.0)
op.GeoM.Translate(halfW, halfH)

Возможно, вам будет проще понять, как это работает, с помощью анимации всех этапов (увеличенной в три раза):

Меняющиеся цвета
Ещё одна полезная опция — изменение цвета спрайта. С её помощью можно нарисовать изображение, похожее на спрайт, но однотонное. Конечно, это имеет смысл только в том случае, если вы используете спрайты с прозрачным фоном.
Синтаксис похож, но вам нужно использовать недавно добавленный в движок пакет colorm.
op := &colorm.DrawImageOptions{}
cm := colorm.ColorM{}
cm.Translate(1.0, 1.0, 1.0, 0.0)
colorm.DrawImage(screen, PlayerSprite, cm, op)

Первые три аргумента cm.Translate — это значения красного, зеленого, и синего в диапазоне от 0.0 до 1.0 (от 0 % до 100 %). Последний аргумент — alpha канал (прозрачность).
Вы можете управлять значением прозрачности, чтобы сделать спрайты прозрачными. Это удобно использовать для создания разных эффектов, например, добавление тени. Чтобы оставить прозрачный фон как есть, используйте Scale вместо Translate. И укажите первые три аргумента равными 1.0, чтобы они не менялись.
op := &colorm.DrawImageOptions{}
cm := colorm.ColorM{}
cm.Scale(1.0, 1.0, 1.0, 0.5)
colorm.DrawImage(screen, PlayerSprite, cm, op)

Комбинирование вариантов
С DrawImageOptions можно сделать больше, но для старта в разработке игр этого должно быть достаточно.
Например, вот анимация эволюции, которую я создал в airplanes:

Мне очень нравится, как всё работает. Самое приятное, что я не использовал отдельные графические ресурсы для анимации. Всё сделано с помощью DrawImageOptions и выглядит так:
- Измените цвет спрайта на белый.
- Создайте новый спрайт (тоже белый) поверх текущего, установив масштаб (0, 0) (невидимый).
- Со временем масштабируйте новый спрайт до размера (1, 1).
- Со временем масштабируйте старый спрайт до масштаба (0, 0).
- Удалите белый цвет.
Вы заметили, как меняется тень? Та же идея, но с использованием серого спрайта и некоторой прозрачности.
Логика
Чтобы игра делала что-то интересное, вам нужно реализовать метод Update. Здесь вы размещаете всю логику, а метод Draw рисует изображения на экране.
Основная идея игровой логики проста: структура Game содержит некоторое состояние, а метод Update изменяет это состояние. Draw считывает состояние и на его основе рисует изображения.
Классическим примером состояния игры является позиция игрока. В 2D-играх это обычно пара значений (X, Y), также известная как вектор.
type Vector struct {
X float64
Y float64
}
type Game struct {
playerPosition Vector
}
Нулевое значение Vector — это (0, 0), как и у любой структуры в Go. Давайте инициализируем состояние игры с помощью заранее заданного значения:
g := &Game{
playerPosition: Vector{X: 100, Y: 100},
}
Осталось только обновить метод Draw, чтобы он рисовал спрайт игрока в заданной позиции. В этом нет ничего удивительного:
op := &ebiten.DrawImageOptions{}
op.GeoM.Translate(g.playerPosition.X, g.playerPosition.Y)
screen.DrawImage(PlayerSprite, op)
Обратите внимание, что мы вызываем Translate всегда с указанием текущего положения игрока. При каждом вызове Draw экран очищается, и изображения рисуются с нуля. Ebitengine может оптимизировать этот процесс, но сейчас нам это не нужно заморачиваться.
Движение
Чтобы переместить игрока вправо, продолжайте увеличивать позицию X в Update.
Помните, что Update вызывается в бесконечном цикле? Теперь это важно, так как вам нужно решить, с какой скоростью перемещать игрока.
func (g *Game) Update() error {
speed := 5.0
g.playerPosition.X += speed
return nil
}
speed — это количество пикселей, на которое изменяется положение за один тик (один Update вызов). При стандартной частоте 60 кадров в секунду изображение будет смещаться вправо на 300 пикселей в секунду.
Возможно, будет проще измерять скорость в "пикселях в секунду", а не в "пикселях за тик". В этом случае рассчитывайте её следующим образом:
speed := float64(300 / ebiten.TPS())
TPS - это ticks per second
Управление
Теперь давайте разрешим перемещать игрока с помощью клавиш со стрелками. Идея та же: нам нужно обновлять положение. Но не при каждом тике, а только при нажатии клавиши.
func (g *Game) Update() error {
speed := 5.0
if ebiten.IsKeyPressed(ebiten.KeyDown) {
g.playerPosition.Y += speed
}
if ebiten.IsKeyPressed(ebiten.KeyUp) {
g.playerPosition.Y -= speed
}
if ebiten.IsKeyPressed(ebiten.KeyLeft) {
g.playerPosition.X -= speed
}
if ebiten.IsKeyPressed(ebiten.KeyRight) {
g.playerPosition.X += speed
}
return nil
}
ebiten.IsKeyPressed возвращает true если указанная клавиша была нажата в текущем тике (помните, что по умолчанию Update вызывается примерно 60 раз в секунду).
Другая функция, которая может вам понадобиться, — это ebitenutil.IsKeyJustPressed, которая возвращает true только в том тике, когда игрок нажимает клавишу. Это разница между "удерживайте пробел, чтобы продолжать прыгать" и "даже если вы продолжаете удерживать пробел, вы прыгаете только один раз". Обратите внимание, что она находится в пакете ebitenutil, а не ebiten, в отличие от IsKeyPressed.
Обратите внимание, что мы не используем конструкцию if-else и благодаря этому игрок может перемещаться по диагонали, удерживая зажатыми одновременно две кнопки. Двигаться по диагонали таким образом быстрее, чем двигаться в одном направлении за раз. Если вам это не подходит, нужно внести коррективы.
var delta Vector
if ebiten.IsKeyPressed(ebiten.KeyDown) {
delta.Y = -speed
}
if ebiten.IsKeyPressed(ebiten.KeyUp) {
delta.Y = speed
}
if ebiten.IsKeyPressed(ebiten.KeyLeft) {
delta.X = -speed
}
if ebiten.IsKeyPressed(ebiten.KeyRight) {
delta.X = speed
}
if delta.X != 0 && delta.Y != 0 {
factor := speed / math.Sqrt(delta.X*delta.X+delta.Y*delta.Y)
delta.X *= factor
delta.Y *= factor
}
g.playerPosition.X += delta.X
g.playerPosition.Y += delta.Y
Таймеры
Очень распространённая потребность в играх — изменение логики с течением времени. Например, вы хотите, чтобы вражеский объект двигался вправо в течение двух секунд, а затем возвращался влево. Или чтобы босс произносил заклинание каждые пять секунд.
Вы не можете использовать обычный способ измерения времени с помощью time.Now() и time.Since() из-за особенностей работы метода Update (напомню, он выполняется с постоянной скоростью 60 тиков в секунду).
Идея состоит в том, чтобы продолжать считать тики, прошедшие с момента запуска таймера. Затем вы выполняете какое-то действие и сбрасываете таймер.
Мне нравится, когда таймер представляет собой отдельную структуру с удобным API.
type Timer struct {
currentTicks int
targetTicks int
}
func NewTimer(d time.Duration) *Timer {
return &Timer{
currentTicks: 0,
targetTicks: int(d.Milliseconds()) * ebiten.TPS() / 1000,
}
}
func (t *Timer) Update() {
if t.currentTicks < t.targetTicks {
t.currentTicks++
}
}
func (t *Timer) IsReady() bool {
return t.currentTicks >= t.targetTicks
}
func (t *Timer) Reset() {
t.currentTicks = 0
}
А вот небольшой пример его использования:
g := &Game{
attackTimer: NewTimer(5 * time.Second),
}
// ...
func (g *Game) Update() error {
g.attackTimer.Update()
if g.attackTimer.IsReady() {
g.attackTimer.Reset()
// ...
}
}
Игровые объекты
Сохранение позиции игрока непосредственно в структуре Game похоже на использование глобальных переменных. С таким подходом будет сложно создавать игры с более развесистой логикой
Хорошим решением будет инкапсуляция. У вас есть строительные блоки, которые можно использовать вместо того, чтобы разбираться в деталях. Простой игровой объект (или сущность, или как вы там его называете) может представлять собой структуру с информацией о положении в пространстве и спрайтом.
Существует множество подходов к организации игровой логики, и вам не обязательно себя ограничивать. Первый простой шаг - реализовать в каждом игровом объекте методы Update и Draw и вызывать их в Update и Draw объекта Game.
type Player struct {
position Vector
sprite *ebiten.Image
}
func NewPlayer() *Player {
return &Player{
position: Vector{X: 100, Y: 100},
sprite: PlayerSprite,
}
}
func (p *Player) Update() {
}
func (p *Player) Draw(screen *ebiten.Image) {
op := &ebiten.DrawImageOptions{}
op.GeoM.Translate(p.position.X, p.position.Y)
screen.DrawImage(p.sprite, op)
}
Теперь методы Game не не реализовывают логику для Player, а просто вызывают его методы:
type Game struct {
player *Player
}
func (g *Game) Update() error {
g.player.Update()
}
func (g *Game) Draw(screen *ebiten.Image) {
g.player.Draw(screen)
}
Давайте завершим реализацию игрока для этого клона Asteroids.
Игрок должен появляться в центре экрана. Это легко вычислить, если знать ширину и высоту экрана. Сначала определите размер экрана как константу.
const (
ScreenWidth = 800
ScreenHeight = 600
)
Пришло время воспользоваться третьим методом интерфейса Game.Layout. Он должен возвращать размер игрового окна.
func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
return ScreenWidth, ScreenHeight
}
Теперь вернёмся к конструктору NewPlayer, чтобы задать начальную позицию. Центр экрана находится на половине его ширины и высоты (по осям X и Y). Но помните, что верхний левый угол спрайта игрока будет нарисован в заданной позиции. Чтобы он находился точно в центре, мы должны сдвинуть его влево и вверх на половину ширины и высоты спрайта.
func NewPlayer() *Player {
sprite := PlayerSprite
bounds := sprite.Bounds()
halfW := float64(bounds.Dx()) / 2
halfH := float64(bounds.Dy()) / 2
pos := Vector{
X: ScreenWidth/2 - halfW,
Y: ScreenHeight/2 - halfH,
}
return &Player{
position: pos,
sprite: sprite,
}
}

Вместо того чтобы перемещать космический корабль, игрок должен иметь возможность поворачивать его. Мы можем сохранить значение поворота как float64 в структуре Player.
func (p *Player) Update() {
speed := math.Pi / float64(ebiten.TPS())
if ebiten.IsKeyPressed(ebiten.KeyLeft) {
p.rotation -= speed
}
if ebiten.IsKeyPressed(ebiten.KeyRight) {
p.rotation += speed
}
}
Напомню, что единицей измерения поворота являются радианы. 2π — это полный поворот. Я буду использовать π / TPS, это означает, что игрок может поворачиваться на 180° в секунду.
Теперь нам нужно обновить метод Draw, чтобы учесть поворот (используя трюк с поворотом изображения вокруг его центра).
func (p *Player) Draw(screen *ebiten.Image) {
bounds := p.sprite.Bounds()
halfW := float64(bounds.Dx()) / 2
halfH := float64(bounds.Dy()) / 2
op := &ebiten.DrawImageOptions{}
op.GeoM.Translate(-halfW, -halfH)
op.GeoM.Rotate(p.rotation)
op.GeoM.Translate(halfW, halfH)
op.GeoM.Translate(p.position.X, p.position.Y)
screen.DrawImage(p.sprite, op)
}
Порождаем объекты
Теперь у нас теперь есть игровые объекты, давайте создадим объект Meteor. Мы можем загрузить множество ресурсов и выбрать один случайным образом. Это сделает игру чуть более интересной.
var MeteorSprites = mustLoadImages("assets/meteors/*.png")
type Meteor struct {
position Vector
sprite *ebiten.Image
}
func NewMeteor() *Meteor {
sprite := MeteorSprites[rand.Intn(len(MeteorSprites))]
return &Meteor{
position: Vector{},
sprite: sprite,
}
}
Метод Draw практически не отличается от аналогичного метода в Player. Как вы понимаете, здесь есть возможности для улучшения, чтобы хранить общий код в одном месте — подробнее об этом позже.
Теперь Game должен отслеживать метеоры. Это та же идея, что и с Player, только теперь нам нужен слайс объектов. Давайте еще добавим таймер для их появления появления.
type Game struct {
player *Player
meteorSpawnTimer *Timer
meteors []*Meteor
}
В методах Game.Update и Game.Draw перебирают все метеоры и вызывают соответствующие методы. И каждый раз, когда срабатывает таймер, в срез добавляется новый метеор.
func (g *Game) Update() error {
g.player.Update()
g.meteorSpawnTimer.Update()
if g.meteorSpawnTimer.IsReady() {
g.meteorSpawnTimer.Reset()
m := NewMeteor()
g.meteors = append(g.meteors, m)
}
for _, m := range g.meteors {
m.Update()
}
return nil
}
func (g *Game) Draw(screen *ebiten.Image) {
g.player.Draw(screen)
for _, m := range g.meteors {
m.Draw(screen)
}
}
Обратите внимание, что порядок отрисовки имеет значение. Объекты, которые вы отрисовываете позже в Game.Draw, будут отображаться поверх тех, что были отрисованы раньше (если их позиции пересекаются).
Улучшаем метеориты
Метеор должен появляться на краю экрана. Вот один из способов сделать это.

// Определите целевую позицию - в данном случае, центр экрана.
target := Vector{
X: ScreenWidth / 2,
Y: SreenHeight / 2,
}
// Расстояние от центра, на котором должен появиться метеор — половина ширины.
r := ScreenWidth / 2.0
// Выберите случайный угол — 2π это 360° — таким образом, это даст значение от 0° до 360°.
angle := rand.Float64() * 2 * math.Pi
// Определите позицию появления, переместившись на r пикселей от цели под выбранным углом.
pos := Vector{
X: target.X + math.Cos(angle)*r,
Y: target.Y + math.Sin(angle)*r,
}
Как я уже упоминал, математика часто помогает в создании игр. Вам не нужно досконально разбираться в том, как она работает, достаточно знать основные операции, например "сдвинуть на 100 пикселей из положения (100, 200) под углом 30°". И нет ничего постыдного в том, чтобы каждый раз обращаться за помощью к интернету или ИИ
Затем метеор должен продолжить движение в сторону игрока (в центр экрана).
// Случайная скорость
velocity := 0.25 + rand.Float64()*1.5
// Направление определяется как вектор от текущей позиции к цели.
direction := Vector{
X: target.X - pos.X,
Y: target.Y - pos.Y,
}
// Нормализуйте вектор — получите только направление без длины.
normalizedDirection := direction.Normalize()
// Multiply the direction by velocity
movement := Vector{
X: normalizedDirection.X * velocity,
Y: normalizedDirection.Y * velocity,
}
В конструкторе NewMeteor можно сохранить полученный movement в структуре, и тогда Update станет тривиальным:
func (m *Meteor) Update() {
m.position.X += m.movement.X
m.position.Y += m.movement.Y
}
Последний штрих — добавление случайного поворота для каждого метеора. Его можно рассчитать в конструкторе:
rotationSpeed := -0.02 + rand.Float64()*0.04,
И добавьте к перемещению метеора еще и вращение
func (m *Meteor) Update() {
m.position.X += m.movement.X
m.position.Y += m.movement.Y
m.rotation += m.rotationSpeed
}

Выстрелы пулями
Реализация стрельбы уже не должна вызывать сложности, поэтому я не буду описывать ее подробно. Полный исходный код можно посмотреть в репозитории.
Кратко, что нам нужно:
- Установите таймер с задержкой между выстрелами.
- При нажатии кнопки появляется новая пуля.
- Поверните пулю и продолжайте двигать её в том направлении, куда она смотрит.
p.shootCooldown.Update()
if p.shootCooldown.IsReady() && ebiten.IsKeyPressed(ebiten.KeySpace) {
p.shootCooldown.Reset()
// Spawn the bullet
}
Одно из нововведений заключается в том, что пули будет создавать Player, а не Game. Но Game должен отслеживать пули, как и метеоры.
Самый простой способ — передать Game в конструктор Player и сохранить его в структуре. Затем создайте метод AddBullet в Game. Да, это не идеальный вариант, так как он создаёт перекрёстную зависимость между Player и Game, но на данный момент это не так важно.
Я решил вычислить точку появления пули на стороне Player и сделать так, чтобы конструктор Bullet принимал её в качестве аргумента.
bulletSpawnOffset := 50.0
bounds := p.sprite.Bounds()
halfW := float64(bounds.Dx()) / 2
halfH := float64(bounds.Dy()) / 2
spawnPos := Vector{
p.position.X + halfW + math.Sin(p.rotation)*bulletSpawnOffset,
p.position.Y + halfH + math.Cos(p.rotation)*-bulletSpawnOffset,
}
bullet := NewBullet(spawnPos, p.rotation)
p.game.AddBullet(bullet)

Коллизии
Обнаружение коллизий — сложная задача, хотя на базовом уровне это простая концепция: нужно перебрать все объекты и проверить, пересекаются ли они.
Чтобы упростить задачу, давайте введём структуру Rect, которая представляет собой прямоугольник и позволяет легко проверять пересечения.
type Rect struct {
X float64
Y float64
Width float64
Height float64
}
func NewRect(x, y, width, height float64) Rect {
return Rect{
X: x,
Y: y,
Width: width,
Height: height,
}
}
func (r Rect) MaxX() float64 {
return r.X + r.Width
}
func (r Rect) MaxY() float64 {
return r.Y + r.Height
}
func (r Rect) Intersects(other Rect) bool {
return r.X <= other.MaxX() &&
other.X <= r.MaxX() &&
r.Y <= other.MaxY() &&
other.Y <= r.MaxY()
}
Далее каждый игровой объект предоставляет метод Collider() Rect.
func (p *Player) Collider() Rect {
bounds := p.sprite.Bounds()
return NewRect(
p.position.X,
p.position.Y,
float64(bounds.Dx()),
float64(bounds.Dy()),
)
}
В методе Game.Update переберите все объекты и проверьте, нет ли между ними коллизий.
for i, m := range g.meteors {
for j, b := range g.bullets {
if m.Collider().Intersects(b.Collider()) {
// Метеор столкнулся с пулей
}
}
}
for _, m := range g.meteors {
if m.Collider().Intersects(g.player.Collider()) {
// Метеор столкнулся с игроком
}
}
Когда пуля сталкивается с метеором, мы хотим уничтожить оба объекта. Для этого мы просто удаляем их с помощью операций слайсов. Если их нет в списке, игра не будет вызывать их методы Update и Draw, поэтому они фактически исчезают, а сборщик мусора делает всё остальное за нас.
if m.Collider().Intersects(b.Collider()) {
g.meteors = append(g.meteors[:i], g.meteors[i+1:]...)
g.bullets = append(g.bullets[:j], g.bullets[j+1:]...)
}
Столкновение метеорита с игроком означает, что игра окончена. В таком случае давайте начнём игру заново.
func (g *Game) Reset() {
g.player = NewPlayer(g)
g.meteors = nil
g.bullets = nil
}
Пользовательский интерфейс
Рисование пользовательского интерфейса похоже на рисование других спрайтов. Вот два основных правила:
- Обычно это последний слой, который вы рисуете, поэтому он всегда остаётся сверху.
- Иногда вам может понадобиться разместить его в отдельной части экрана. В этом случае вам нужно будет нарисовать игру не прямо на экране, а на холсте меньшего размера, который в конечном итоге будет расположен рядом с пользовательским интерфейсом. Вы можете создать пустое изображение с помощью
ebiten.NewImageи использовать его в качестве "панели".
Классический пример пользовательского интерфейса — очки, которые игрок получает за сбитый метеорит. Мы можем сохранить счет очков как целое число в структуре Game, увеличивать его при столкновении метеорита с пулей и обнулять в конце игры.
Осталось только отобразить счёт на экране. Сначала нужно загрузить шрифт по аналогии с загрузкой спрайтов. (Я использую Kenney Fonts)
var ScoreFont = mustLoadFont("font.ttf")
func mustLoadFont(name string) font.Face {
f, err := assets.ReadFile(name)
if err != nil {
panic(err)
}
tt, err := opentype.Parse(f)
if err != nil {
panic(err)
}
face, err := opentype.NewFace(tt, &opentype.FaceOptions{
Size: 48,
DPI: 72,
Hinting: font.HintingVertical,
})
if err != nil {
panic(err)
}
return face
}
Затем вызовите text.Draw в Game.Draw:
text.Draw(screen, fmt.Sprintf("%06d", g.score), ScoreFont, ScreenWidth/2-100, 50, color.White)
Если вам нужно точное положение, используйте font.BoundString для получения границ. В приведённом выше примере я просто оценил, насколько хорошо это выглядит.

Другие концепции
Эта статья и так довольно длинная, но я хочу упомянуть несколько идей, которые могут оказаться полезными, когда вы начнёте создавать игры.
Сцены
Сцена — это отдельная часть вашей игры. Например, экран приветствия, главное меню или сама игра.
В проекте meteors есть одна Game сцена. Её можно расширить, чтобы можно было переключаться между сценами, и у каждой из них были бы свои Update и Draw методы. И свои игровые объекты.
Камера
Поначалу меня пугала концепция камеры, но оказалось, что это простая идея. Ваш игровой "мир" может быть очень большим, и зачастую вы не хотите показывать его целиком. С помощью концепции камеры вы можете отображать только часть большого изображения. Вы можете перемещать камеру, заставлять её следовать за игроком или увеличивать и уменьшать масштаб для достижения эффекта масштабирования.
Для тривиального примера нужны всего две вещи:
- Структура
Cameraдля хранения позиции. А также, при необходимости, масштаба и других полей. - Изображение
offscreenинициализированное с помощьюebiten.NewImage.
type Game struct {
camera *Camera
offscreen *ebiten.Image
player *Player
enemies []*Enemy
}
func (g *Game) Draw(screen *ebiten.Image){
g.offscreen.Clear()
// Отрисуй игру в оффскрин-изображении
g.player.Draw(g.offscreen)
for _, e := range g.enemies {
e.Draw(g.offscreen)
}
// Отрисуй оффскрин-изображение на экране
op := &ebiten.DrawImageOptions{}
op.GeoM.Translate(-g.camera.X, -g.camera.Y)
screen.DrawImage(g.offscreen, op)
}
По мере изменения положения камеры будет меняться и часть изображения за пределами экрана.
Анимация
Анимация - это изменение спрайта объекта с течением времени. Иногда всего два или три спрайта могут создать анимацию, которая сделает игру более живой.
Чтобы реализовать анимацию, создайте набор спрайтов, таймер и индекс текущего спрайта анимации. Затем увеличивайте индекс по мере сброса таймера.
func (p *Player) Update() {
p.timer.Update()
if p.timer.IsReady() {
p.timer.Reset()
p.index++
if p.index >= len(p.sprites) {
p.index = 0
}
p.sprite = p.sprites[p.index]
}
}
Спрайты анимации часто представляют собой один файл, в котором кадры расположены рядом друг с другом. С помощью метода SubImage можно извлечь один кадр из загруженного изображения.
Вы также можете "оживить" другие параметры объекта, например цвет или масштаб спрайта.
Публикация в Интернете
С помощью WebAssembly можно легко опубликовать свою игру и разместить ее где угодно, на любой странице в интернете.
Подробнее см. в документации и на GitHub.
Система компонентов объекта
Вы, вероятно, заметили между игровыми объектами Player, Meteor, и Bullet много общего. Возникает соблазн выделить общий код, чтобы упростить создание новых объектов. И в этом определённо есть смысл.
Можно создать универсальную структуру GameObject, от которой будут наследоваться другие игровые объекты. В Go можно использовать встраивание структур.
Система компонентов сущностей (Entity Component System, ECS) — это ещё один подход, в котором предпочтение отдаётся композиции, а не наследованию. Идея состоит в том, чтобы хранить общие компоненты, такие как положение, спрайт и коллайдер, в виде отдельных структур, а затем создавать игровые объекты (сущности), комбинируя их. Вся логика хранится в системах, которые перебирают все сущности и обновляют их.
Для Go существует множество библиотек ECS. Я выбрал donburi, и моя игра airplanes основана на ней.
Отладка
Как и в случае с любым другим кодом, иногда вы будете сталкиваться с ошибками, которые сложно отладить. Хотя использование отладочных сообщений иногда помогает, мне нравится добавлять визуальные подсказки, своего рода "режим отладки". Они часто очень наглядно показывают где и в чём проблема.
Вы можете сделать так, чтобы отладочный режим включался и отключался по нажатию клавиши, тогда он не будет мешать обычному игровому процессу. Вот пример режима отладки, который я добавил в airplanes:

Ebitengine предоставляет несколько утилит для этого:
ebitenutil.DebugPrintAt— чтобы быстро нарисовать текст.- В пакете vector есть такие функции, как
StrokeLine,StrokeRect, иStroneCircle.
Время разработки
Всё это лишь малая часть от большой и интересного мира разработки игр. Я выбрал темы, которые помогут вам начать создавать игры и освоить основы.
Чтобы узнать больше о Ebitengine, начните с официальных примеров.
Мой пример проекта находится на GitHub, и вы можете попробовать поиграть онлайн. Не стесняйтесь создавать форки репозитория и вносить в него изменения.
Вот несколько идей для реализации:
- Добавьте поддержку управления с помощью мыши или геймпада.
- Пусть большие метеоры "разбиваются" на маленькие, а не исчезают.
- Метеоры могут сбрасывать бонусы, которые может собрать игрок.
- Добавить переключение оружия.
- Добавьте вражеские космические корабли с искусственным интеллектом, которые будут двигаться по более сложной траектории, чем просто лететь в сторону игрока.
- Добавьте больше сцен, например главное меню и таблицу рекордов.
- Добавьте звуковые эффекты. (Это действительно меняет дело!)
Если вы не можете понять, как реализовать придуманную вами механику (а такое будет случаться часто), попробуйте разобраться в каждом необходимом шаге. Мне часто помогает набросок ручкой на бумаге.
А теперь пора пойти и что-нибудь напрограмировать. Удачи!