Почему я решил использовать Golang для разработки 2D-игры?

Начну с того, что я бизнес-аналитик, а не разработчик и не любитель TV-игр. Но я руководил несколькими командами разработчиков и немного разбираюсь в инкрементной технологии создания программного продукта. Как следует из отзывов о покупательских впечатлениях, люди были в восторге от продуктов, создаваемых под моим руководством.

Идею создания хобби-проекта нельзя назвать революционной. Я решил, что смогу быстрее освоить конструкции программирования, если буду избегать всего лишнего. Я создавал Android-приложения ради развлечения, но мне хотелось чего-то, что можно было бы изначально скомпилировать для Windows, не прибегая к эмуляторам и кроссплатформенной разработке.

Поэтому я остановился на Golang — простом, быстром и идиоматичном языке. Мне нужен был код, который естественно набирается, и, что не менее важно, позволяет выполнять отладку методом rubber duck (проще говоря, решать проблемы, озвучивая их неодушевленному помощнику — резиновому утенку). Таким образом, если бы мне пришлось поговорить с человеком о том, что происходит во время выполнения моей программы, у меня был бы шанс объяснить ему структуру, функции и многое другое.

Что именно я создал?

Я разработал 2D-игру под названием «Scorpions and Ferraris» («Скорпионы и Феррари»), вдохновленную видеоигрой «Frogger» и ее механикой. Концепция абсурдна, но я довольно быстро научился многому в разработке игр. Для этого проекта использовал библиотеку Ebiten, которая является исключительно игровым движком с открытым исходным кодом для Golang. В Ebiten задействовал простой API и функции, что позволило быстро и легко разработать игру, которую можно развернуть на нескольких платформах.

Я развернул игру на Windows, но вы можете использовать macOS или Linux. В конце концов, исполняемый файл при сборке выводится в ту же директорию, что и код. Это означает, что можно даже создать программу на Windows, а затем запустить ее на Linux, что очень здорово.

Как приступить к работе?

1) Установите Golang на свою операционную систему: All releases — The Go Programming Language.

2) Запустите пример Ebiten в качестве POC. Для быстрого старта я бы порекомендовал пример «Hello, world», который есть в Ebiten.

3) Следуйте приведенному ниже руководству!

Создание нового Go-проекта

Рекомендую использовать VS Code в качестве IDE для этого проекта. Дело в том, что в VS Code есть расширение для Go с такими функциями, как IntelliSense (автодополнение печатаемого кода), навигация по коду, поиск символов, тестирование, отладка и многое другое, что помогает в Go-разработке.

Откройте Git Bash или CMD:

mkdir yourgame cd yourgame go mod init foo # или github.com/ваше имя/название вашей игры или что-то другое
cd yourgame
Run "code ." within terminal to open VScode (Or open created directory in any ide)

Теперь нужно захватить модуль ebiten.

Команда, которую следует выполнить в терминале в директории, где находится файл go.mod:

go get github.com/hajimehoshi/ebiten/v2

Создание файла Main.go

Как вы наверняка догадались, здесь будет храниться основная логика программы.

Начнем с добавления пакета main и импортируемых элементов.

Для создания многократно используемого кода разрабатывается пакет, выполняющий роль общей библиотеки. Однако для создания исполняемых программ необходимо использовать пакет «main», который указывает компилятору Go, что пакет предназначен для компиляции в качестве исполняемого файла, а не общей библиотеки. Функция main в пакете «main» служит точкой входа для исполняемой программы.

Таким образом, в файл Main.go нужно добавить следующий код:

package main

import (
"fmt"
"image"
_ "image/png"
"log"
"math/rand"
"os"
"time"

"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
"github.com/hajimehoshi/ebiten/v2/vector"
"image/color"
)

Опытный программист сразу увидит несколько знакомых библиотек, которые имеют схожую функциональность в других языках программирования. Пакет fmt реализует форматированный ввод/вывод с помощью функций, аналогичных printf и scanf в C.

Пакеты image, image/png необходимы для загрузки и декодирования изображений, а math/rand — для генерации случайных чисел.

Однако сейчас все это не так важно для понимания логики программирования.

Программисту-новичку достаточно знать следующее: берем из другого источника лицензированный код многократного использования и импортируем эту функциональность в программу в начало файла. Таким образом, когда программа будет выполняться и ей понадобится, скажем, декодировать изображение, она сможет это сделать, потому что введен этот код.

Объявление целочисленных констант

Теперь определим константы: размеры экрана, размер сетки, скорость игрока, количество дорожек и параметры автомобиля (скорость, зазор).

Напомню, что я создал игру, похожую на «Frogger».

В моей игре нужно управлять персонажем (скорпионом), чтобы избежать движущихся машин, которые каким-то образом пересекают водное шоссе на большой скорости. Цель — не попасть под пиксельный «Феррари» и добраться до другого края экрана за определенное время.

Итак, первый шаг — создание констант. Не беспокойтесь о конкретных числах. Можете задать любое число для своей программы. Я просто выбрал то, что мне показалось правильным, и эта часть потребовала тонкой настройки.


const (
screenWidth = 640
screenHeight = 480
gridSize = 32
gridWidth = screenWidth / gridSize
gridHeight = screenHeight / gridSize
playerSpeed = 5
numLanes = 5
numCarsPerLane = 6 // Меньшее число для более четкого расстояния
carSpeedMin = 1.5
carSpeedMax = 3.0
minCarGap = 3 // Минимальный зазор между автомобилями в единицах сетки
maxCarGap = 6 // Максимальный зазор между автомобилями в единицах сетки
)

Структуры

Go, который не является чисто объектно-ориентированным языком, вместо классов предоставляет структуры. Методы могут быть связаны со структурами, что позволяет объединять данные и методы, которые работают с ними, подобно классу.

Структура GameObject: представляет игровые объекты с такими свойствами, как положение, скорость, изображение, размер и направление.

Структура Game: содержит игрока, фоновое изображение, изображения объектов, автомобили, текущее время, время последнего обновления и состояние игры.

type GameObject struct {
x, y float64
speed float64
image *ebiten.Image
width int
height int
isRight bool
}

type Game struct {
player *GameObject
background *ebiten.Image
objects map[string]*ebiten.Image
cars []*GameObject
currentTime int
lastUpdateTime time.Time
gameState string
}

Функция NewGame

Функция NewGame инициализирует новую игру со значениями по умолчанию, загружает изображения и устанавливает начальное состояние игры.

func NewGame() *Game {
g := &Game{
currentTime: 60,
lastUpdateTime: time.Now(),
objects: make(map[string]*ebiten.Image),
gameState: "playing",
player: &GameObject{}, // Инициализация игрока
}
g.loadImages()
g.initializeGame()
return g
}

Метод InitializeGame

Метод InitializeGame задает начальную позицию игрока и заполняет дорожки автомобилями со случайными позициями и скоростями.

Эта часть заняла больше всего времени для точного определения того, сколько места понадобится для игрока-скорпиона, чтобы пробраться между машинами при заданном объеме и скорости.

func (g *Game) initializeGame() {
// Настройка начального положения игрока
g.player.x = float64(gridWidth / 2 * gridSize)
g.player.y = float64((gridHeight - 1) * gridSize)

// Очистка существующих автомобилей
g.cars = []*GameObject{}

// Инициализация автомобилей на каждой полосе
for lane := 0; lane < numLanes; lane++ {
lastCarX := -float64(gridSize) // Стартовая позиция перед экраном
for i := 0; i < numCarsPerLane; i++ {
// Расчет зазора между автомобилями
minGap := lastCarX + float64(minCarGap*gridSize)
maxGap := lastCarX + float64(maxCarGap*gridSize)
carX := minGap + rand.Float64()*(maxGap-minGap)

g.cars = append(g.cars, &GameObject{
x: carX,
y: float64(5+lane) * gridSize, // Разные полосы для автомобилей
speed: carSpeedMin + rand.Float64()*(carSpeedMax-carSpeedMin),
image: g.objects["car"],
width: gridSize * 2,
height: gridSize,
isRight: rand.Intn(2) == 0,
})

lastCarX = carX
}
}
}

Метод Update

Метод Update обрабатывает обновления игры, включая движение игрока, передвижение автомобилей и обновления времени.

func (g *Game) Update() error {
if g.gameState != "playing" {
if ebiten.IsKeyPressed(ebiten.KeySpace) {
g.initializeGame()
g.currentTime = 60
g.gameState = "playing"
}
return nil
}

now := time.Now()
elapsed := now.Sub(g.lastUpdateTime).Seconds()
g.lastUpdateTime = now

if ebiten.IsKeyPressed(ebiten.KeyArrowLeft) {
g.player.x -= gridSize * elapsed * playerSpeed
}
if ebiten.IsKeyPressed(ebiten.KeyArrowRight) {
g.player.x += gridSize * elapsed * playerSpeed
}
if ebiten.IsKeyPressed(ebiten.KeyArrowUp) {
g.player.y -= gridSize * elapsed * playerSpeed
}
if ebiten.IsKeyPressed(ebiten.KeyArrowDown) {
g.player.y += gridSize * elapsed * playerSpeed
}

g.player.x = clamp(g.player.x, 0, screenWidth-gridSize)
g.player.y = clamp(g.player.y, 0, screenHeight-gridSize)

// Обновление автомобилей
for _, car := range g.cars {
if car.isRight {
car.x += car.speed * elapsed * gridSize
if car.x > screenWidth {
car.x = -float64(car.width)
}
} else {
car.x -= car.speed * elapsed * gridSize
if car.x < -float64(car.width) {
car.x = screenWidth
}
}
}

Метод checkCollisions

Метод checkCollisions проверяет, не столкнулся ли игрок с какими-либо машинами, и соответствующим образом обновляет состояние игры.

Этот метод очень важен — без него скорпион не встретил бы никакого сопротивления и скользил бы по виртуальной дороге над «Феррари».

func (g *Game) checkCollisions() {
playerRect := image.Rect(int(g.player.x), int(g.player.y), int(g.player.x)+g.player.width, int(g.player.y)+g.player.height)

// Проверка столкновения с автомобилями
for _, car := range g.cars {
carRect := image.Rect(int(car.x), int(car.y), int(car.x)+car.width, int(car.y)+car.height)
if playerRect.Overlaps(carRect) {
g.gameState = "lose"
return
}
}
}

Метод Draw

Метод Draw отрисовывает игру, включая фон, машины, игрока, время и сообщения о состоянии игры.

Для более глубокого понимания изучите раздел документации Ebiten, посвященный геометрическим матрицам (Geometry Matrices).

unc (g *Game) Draw(screen *ebiten.Image) {
// Отрисовка фона
op := &ebiten.DrawImageOptions{}
screen.DrawImage(g.background, op)

// Отрисовка автомобилей
for _, car := range g.cars {
op := &ebiten.DrawImageOptions{}
op.GeoM.Translate(car.x, car.y)
screen.DrawImage(car.image, op)
}

// Отрисовка игрока
if g.player.image != nil {
op := &ebiten.DrawImageOptions{}
op.GeoM.Translate(g.player.x, g.player.y)
screen.DrawImage(g.player.image, op)
} else {
log.Println("Player image is nil, cannot draw")
// Отрисовка плейсхолдера прямоугольной формы для игрока
vector.DrawFilledRect(screen,
float32(g.player.x),
float32(g.player.y),
float32(g.player.width),
float32(g.player.height),
color.RGBA{255, 0, 0, 255},
false)
}

// Отрисовка времени
ebitenutil.DebugPrint(screen, fmt.Sprintf("Time: %d", g.currentTime))

// Отрисовка состояния игры
if g.gameState == "win" {
ebitenutil.DebugPrint(screen, "\n\nYou Win! Press SPACE to restart")
} else if g.gameState == "lose" {
ebitenutil.DebugPrint(screen, "\n\nGame Over! Press SPACE to restart")
}
}

Метод Layout

Все, что делает метод Layout, — это возвращает размеры экрана.

func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
return screenWidth, screenHeight
}

Функция Main — самая важная функция в игре

Функция Main устанавливает размер окна и заголовок, а также запускает игру с помощью функции RunGame от Ebiten.


func main() {
ebiten.SetWindowSize(screenWidth, screenHeight)
ebiten.SetWindowTitle("Scorpions and Ferraris")

if err := ebiten.RunGame(NewGame()); err != nil {
log.Fatal(err)
}
}

Функция Clamp

Функция Clamp обеспечивает сохранение значений в заданном диапазоне.

func clamp(value, min, max float64) float64 {
if value < min {
return min
}
if value > max {
return max
}
return value
}

Финальный код

Полный код и readme.md приведен в репозитории на Github!

Единственное, что отсутствует в этом руководстве, — это файлы изображений, которые можно извлечь и поместить в свой каталог. Рекомендую подобрать свои изображения фона, объектов и игроков (см. раздел ниже): это позволит узнать кое-что о пикселях, редактировании изображений, столкновениях и многом другом.

Выбор собственных изображений и Paint.net

Эта часть, разумеется, необязательна.

Рекомендую установить версию Paint.net, опубликованную на GitHub.

Paint.net — бесплатное программное обеспечение, которое позволяет изменять растровые графические изображения.

Это необходимо для уменьшения изображений до пикселей и применения их как 2D-объектов.

Для фонового изображения я выбрал пустой фон (blank background) из игры «Frogger», показанный ниже.

В Paint.net фоновое изображение должно быть не меньше размера окна, определенного ранее.

 screenWidth     = 640
screenHeight = 480

В моей программе это задано таким образом:

Только вы можете определить то, что кажется естественным для выбранного вами фона.

Размеры игрока и машины

Исходя из размера окна, определенного в программе, размеры игрока и объектов должны быть пропорциональны размерам сетки и экрана. Тогда игра будет сбалансированной и визуально целостной.

Размеры игрока

Ширина и высота: размер игрока должен соответствовать размеру сетки, чтобы обеспечить хорошую видимость и маневренность; поскольку размер сетки составляет 32 пикселя, ширина и высота игрока могут быть примерно 32×32 пикселя.

Позиционирование: убедитесь, что начальная позиция игрока и его движение соответствуют параметрам сетки, чтобы избежать неожиданного наложения на другие объекты.

Размеры автомобилей

Ширина: автомобиль может быть шире, чем размер сетки, чтобы создать ощущение масштаба и сложности; ширина в 64 пикселя (2 ячейки сетки) — подходящий размер, уже заданный в коде.

Высота: высота может быть такой же, как размер сетки, то есть 32 пикселя, чтобы обеспечить соответствие параметрам дорожки.

Рекомендация по работе с Paint.net

Уменьшите объекты игрока и автомобиля до рекомендуемого размера с помощью кнопки изменения размера, которая показана выше.

В своей игре, как видите, я удалил не все белое пространство вокруг объекта.

На этом можно остановиться. Paint.net предлагает отличную документацию и руководства. Могу порекомендовать выделить пробельные участки лассо или прямоугольником, а затем применить функцию обрезки для выделенных областей. Это увеличит шансы на тщательную очистку изображения.

А лучший способ проверить картинку с выбранным размером — просто запустить программу и посмотреть, как она будет выглядеть. Возможно, это будет комично!

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Sam A: I Built a 2D Game in 40 Minutes with Ebiten

Предыдущая статьяНам нужно визуальное программирование. Нет, не то, о котором вы подумали
Следующая статьяПриложение React Native с поддержкой Apple Watch и виджетов