Делаем RPG на Go: часть 0.5
- среда, 13 марта 2024 г. в 00:00:15
В предыдущей статье мы начали знакомство с Ebitengine.
В этой части структура игры будет доработана и переведена на сцены.
Это вторая pre-1 часть, в которой разрабатывается отдельный демо-проект.
Начинать делать RPG с нулевой базы было бы слишком сложно: я хочу использовать все свои любимые библиотеки и практики как можно раньше, при этом у меня не получилось придумать способа достаточно плавно вводить все составляющие на менее искусственном проекте.
Возможно, уже следующая статья станет "настоящей" первой частью, а пока запасаемся терпением и осваиваем базовые приёмы разработки игр на Go.
В Ebitengine из коробки есть простенькие функции для обработки ввода игрока. Работают они на уровне конкретных кнопок.
// github.com/hajimehoshi/ebiten/v2
ebiten.IsKeyPressed(ebiten.KeyEnter)
// github.com/hajimehoshi/ebiten/v2/inpututil
inpututil.IsKeyJustPressed(ebiten.KeyEnter)
Через такое API нельзя абстрагироваться от конкретных кнопок и устройств ввода, поэтому для своих игр я использую пакет ebitengine-input. Вдохновением для этой библиотеки были Godot actions.
Вместо нажатий на кнопки, этой библиотекой проверяются активации действий. Возможные действия в нашей игре мы определяем через константы.
В демо-игре можно будет перемещаться в четырёх направлениях, поэтому действий будет как минимум четыре: MoveRight
, MoveDown
, MoveLeft
, MoveUp
.
Actions я предпочитаю размещать в отдельном пакете controls
:
mygame/
cmd/mygame/main.go
internal/
assets/
_data/images/gopher.png
controls/actions.go
// internal/controls/actions.go
package controls
import (
input "github.com/quasilyte/ebitengine-input"
)
const (
ActionNone input.Action = iota
ActionMoveRight
ActionMoveDown
ActionMoveLeft
ActionMoveUp
// Эти действия понадобятся позднее.
ActionConfirm
ActionRestart
)
Действиям нужно сопоставить триггеры активации. Триггером может быть нажатие на кнопку клавиатуры, контроллера, мыши, сенсорного экрана и так далее. Совокупность этих отображений я буду называть keymap.
Каждой игре нужен keymap по умолчанию. При желании, можно добавлять поддержку внешних конфигов или выполнять ремапинг конкретных действий внутри игры. Для нашей демо-игры достаточно статического keymap.
// internal/controls/default_keymap.go
package controls
import (
input "github.com/quasilyte/ebitengine-input"
)
var DefaultKeymap = input.Keymap{
ActionMoveRight: {
input.KeyRight, // Кнопка [>] на клавиатуре
input.KeyD, // Кнопка [D] на клавиатуре
input.KeyGamepadRight, // Кнопка [>] на крестовине контроллера
},
ActionMoveDown: {
input.KeyDown,
input.KeyS,
input.KeyGamepadDown,
},
ActionMoveLeft: {
input.KeyLeft,
input.KeyA,
input.KeyGamepadLeft,
},
ActionMoveUp: {
input.KeyUp,
input.KeyW,
input.KeyGamepadUp,
},
ActionConfirm: {
input.KeyEnter,
input.KeyGamepadStart,
},
ActionRestart: {
input.KeyWithModifier(input.KeyR, input.ModControl),
input.KeyGamepadBack,
},
}
Осталось создать объект считывания ввода, привязанный к заданному keymap. Этот объект создаётся через input.System
, который нужен в единственном экземпляре на всю игру.
type myGame struct {
windowWidth int
windowHeight int
+ inputSystem input.System
loader *resource.Loader
player *Player
}
Системе ввода требуется разовая инициализация до запуска игры:
g.inputSystem.Init(input.SystemConfig{
DevicesEnabled: input.AnyDevice,
})
На каждый Update
в игре нужно вызывать одноимённый метод в системе ввода:
func (g *myGame) Update() error {
+ g.inputSystem.Update()
g.player.pos.X += 16 * (1.0 / 60.0)
return nil
}
После интеграции системы, можно создавать те самые объекты считывания ввода. Эти объекты в библиотеке называются handlers. Каждый обработчик привязан к player ID, что особенно важно для игр с возможностью подключить несколько контроллеров одновременно.
Для демо-игры достаточно лишь одного обработчика с нулевым ID.
inputSystem input.System
+ input *input.Handler
loader *resource.Loader
g.input = g.inputSystem.NewHandler(0, controls.DefaultKeymap)
Теперь через g.input
можно проверять состояние действий. Сцен у нас пока нет, поэтому вся логика будет сосредоточена в основном Update
.
func (g *myGame) Update() error {
g.inputSystem.Update()
speed := 64.0 * (1.0 / 60)
var v gmath.Vec
if g.input.ActionIsPressed(controls.ActionMoveRight) {
v.X += speed
}
if g.input.ActionIsPressed(controls.ActionMoveDown) {
v.Y += speed
}
if g.input.ActionIsPressed(controls.ActionMoveLeft) {
v.X -= speed
}
if g.input.ActionIsPressed(controls.ActionMoveUp) {
v.Y -= speed
}
g.player.pos = g.player.pos.Add(v)
return nil
}
Это управление будет работать со всеми способами активации, которые мы задали в keymap: можно перемещаться на стрелочках, WASD, и даже через контроллер.
Тег part0.5_controls содержит состояние кода демо-игры после добавления управления.
Перед тем, как внедрять сцены, стоит произвести рефакторинг.
Для начала, я вынесу контекст игры, существующий между сценами, в пакет game
. То, что останется в объекте myGame
, будет недоступно для сцен напрямую.
type myGame struct {
- windowWidth int
- windowHeight int
inputSystem input.System
- input *input.Handler
- loader *resource.Loader
player *Player // Это вынесем позже, в сцену
}
// internal/game/context.go
package game
import (
input "github.com/quasilyte/ebitengine-input"
resource "github.com/quasilyte/ebitengine-resource"
)
type Context struct {
Input *input.Handler
Loader *resource.Loader
WindowWidth int
WindowHeight int
}
Что именно попадает в игровой контекст сильно зависит от игры и ваших предпочтений.
Тег part0.5_game_context — это репозиторий после данного рефакторинга.
Чтобы разделить игру на отдельные части, удобно иметь понятие сцены.
Ранее в игре уже была неявная сцена — вся игра целиком. Игра запускается, myGame
исполняет Update+Draw
цикл для этой единственной сцены. Явные сцены меняют многое и требуют дополнительного кода, но их преимущества довольно быстро оправдывают эти инвестиции.
Переход на явные сцены выглядит примерно так:
type myGame struct {
ctx *game.Context
}
func (g *myGame) Update() error {
g.ctx.InputSystem.Update()
g.ctx.CurrentScene().Update()
return nil
}
func (g *myGame) Draw(screen *ebiten.Image) {
g.ctx.CurrentScene().Draw(screen)
}
type Scene struct {
// ...
}
func (s *Scene) Update() {
for _, o := range s.objects {
o.Update()
}
}
func (s *Scene) Draw(screen *ebiten.Image) {
for _, g := range s.graphics {
o.Draw(screen)
}
}
Обратите внимание, текущую сцену я храню вgame.Context
, а не в объектеmyGame
.
Переход из одной сцены в другую происходит через замену myGame.ctx.currentScene
. Логика сцены заключена в её объектах (scene.objects), а вся графика реализована графическими объектами (scene.graphics).
Библиотека gscene
реализует именно эту модель:
$ go get github.com/quasilyte/gscene
Объекты и графика (т.н. графические объекты) — это интерфейсы.
type SceneObject interface {
Init(*Scene)
Update()
IsDisposed() bool
}
type SceneGraphics interface {
Draw(dst *ebiten.Image)
IsDisposed() bool
}
Метод IsDisposed
потребуется для удаления объектов со сцены. Init
вызывается на объектах при их добавлении на сцену. Через аргумент-сцену эти объекты могут добавить на сцену дополнительные объекты или графику.
В моей интерпретации сцен очень полезно иметь один особенный вид объекта, по одному на сцену — контроллер. Сцена содержит объекты и является их контейнером, в то время как контроллер, закреплённый за сценой, является главным объектом этой сцены. Он же добавляет на сцену первый набор объектов.
Контроллер реализует интерфейс SceneObject
, но без метода IsDisposed
.
Объекты сцены могут получить доступ к объекту-контроллеру. Всё станет понятнее на примере.
У нас будет две сцены: экран-заставка и экран с геймплеем. Игра стартует на экране заставки, а после активации действия confirm переходит на сцену с геймплеем.
Переход сцены — это замена текущей сцены на новую. Реализация такой замены может выгдядеть так:
// internal/game/context.go
// Реализуем как свободную функцию, потому что иначе не получится
// параметризовать функцию для разных T.
func ChangeScene[T any](ctx *Context, c gscene.Controller[T]) {
s := gscene.NewRootScene[T](c)
ctx.scene = s
}
// Заметим, что CurrentScene возвращает интерфейс GameRunner,
// а не сцену. Это позволяет унифицировать
// разные Scene[T] с точки зрения игрового цикла,
// ведь там достаточно иметь Update+Draw и ничего более.
func (ctx *Context) CurrentScene() gscene.GameRunner {
return ctx.scene
}
Все сцены рекомендую хранить в пакете scenes
. Для простейших сцен, типа сплеш-экрана, достаточно одного файла внутри scenes
, а для более сложных случаев стоит создавать вложенные пакеты, по одному на каждую подобную сцену.
По желанию — давать этим вложенным сценам префикс scene*
, чтобы не было конфликтов с другими пакетами (вполне обычная ситуация — иметь пакет battle
для сцены и для каких-то общих геймплейных определений).
Для логических объектов сцены я предпочитаю добавлять суффикс *node
(как в именах файлов, так и в именах типов).
mygame/
cmd/mygame/main.go
internal/
assets/
_data/images/gopher.png
controls/actions.go
scenes/
splash_controller.go
walkscene/
walkscene_controller.go
gopher_node.go
Контроллер для экрана заставки будет заглушкой, так как красиво показать текст на экране пока не выйдет (нужную библиотеку добавим позже). Всё, что он будет делать — это переключаться на основную сцену после обработки активации confirm.
// internal/scenes/splash_controller.go
package scenes
import (
"github.com/quasilyte/ebitengine-hello-world/internal/controls"
"github.com/quasilyte/ebitengine-hello-world/internal/game"
"github.com/quasilyte/ebitengine-hello-world/internal/scenes/walkscene"
"github.com/quasilyte/gscene"
)
type SplashController struct {
ctx *game.Context
}
func NewSplashController(ctx *game.Context) *SplashController {
return &SplashController{ctx: ctx}
}
func (c *SplashController) Init(s *gscene.SimpleRootScene) {
// В заглушке никакого текста вроде "press [Enter] to continue"
// мы показывать не будем. Вернёмся к этому немного позднее.
}
func (c *SplashController) Update(delta float64) {
if c.ctx.Input.ActionIsJustPressed(controls.ActionConfirm) {
game.ChangeScene(c.ctx, walkscene.NewController(c.ctx))
}
}
// internal/scenes/walkscene/walkscene_controller.go
package walkscene
import (
"os"
"github.com/quasilyte/ebitengine-hello-world/internal/game"
"github.com/quasilyte/gscene"
)
type Controller struct {
ctx *game.Context
}
func NewController(ctx *game.Context) *Controller {
return &Controller{ctx: ctx}
}
func (c *Controller) Init(s *gscene.SimpleRootScene) {
os.Exit(0) // Пока что заглушка
}
func (c *Controller) Update(delta float64) {
}
При запуске игры у нас будет чёрный экран (сцена splash), а после обработки confirm игра сразу закроется, перейдя в сцену walkscene.
$ go get github.com/quasilyte/ebitengine-graphics
Большая часть конструкторов из graphics требует передачи объекта *graphics.Cache
, поэтому для удобства этот кеш следует спрятать внутри game.Context
. Во многих играх будет полезно обернуть конструкторы графических объектов в методы контекста, чтобы сократить количество аргументов при вызове.
// internal/game/context.go
func NewContext() *Context {
return &Context{
graphicsCache: graphics.NewCache(),
}
}
func (ctx *Context) NewLabel(id resource.FontID) *graphics.Label {
fnt := ctx.Loader.LoadFont(id)
return graphics.NewLabel(ctx.graphicsCache, fnt.Face)
}
func (ctx *Context) NewSprite(id resource.ImageID) *graphics.Sprite {
s := graphics.NewSprite(ctx.graphicsCache)
if id == 0 {
return s
}
img := ctx.Loader.LoadImage(id)
s.SetImage(img.Data)
return s
}
Для отрисовки текста потребуется шрифт в формате ttf или otf. Скачиваем DejavuSansMono.ttf и сохраняем его в internal/assets/_data/fonts
.
По аналогии с графическими ресурсами, ресурсы-шрифты нужно зарегистрировать.
// internal/assets/fonts.go
package assets
import (
resource "github.com/quasilyte/ebitengine-resource"
)
const (
FontNone resource.FontID = iota
FontNormal
FontBig
)
func registerFontResources(loader *resource.Loader) {
fontResources := map[resource.FontID]resource.FontInfo{
FontNormal: {Path: "fonts/DejavuSansMono.ttf", Size: 10},
FontBig: {Path: "fonts/DejavuSansMono.ttf", Size: 14},
}
for id, res := range fontResources {
loader.FontRegistry.Set(id, res)
loader.LoadFont(id)
}
}
В RegisterResources
добавляется вызов registerFontResources
:
func RegisterResources(loader *resource.Loader) {
registerImageResources(loader)
+ registerFontResources(loader)
}
Пора добавить на сплэш-экран запрос на нажатие клавиши подтверждения.
Внутри метода SplashController.Init
добавляется label с нужным текстом:
func (c *SplashController) Init(s *gscene.SimpleRootScene) {
l := c.ctx.NewLabel(assets.FontBig)
l.SetAlignHorizontal(graphics.AlignHorizontalCenter)
l.SetAlignVertical(graphics.AlignVerticalCenter)
l.SetSize(c.ctx.WindowWidth, c.ctx.WindowHeight)
l.SetText("Press [Enter] to continue")
s.AddGraphics(l)
}
Label — это графический объект, поэтому на сцену добавляется через AddGraphics
.
Есть два типа сцен — корневая и обычная. Корневая (root) передаётся в инициализатор контроллера. Всем остальным объектам сцены передаётся некорневая сцена.
Основная причина разделения на два типа сцен — улучшение API. Корневая сцена напрямую интегрируется в игровой цикл, у неё есть методы Update
и Draw
. Сцена объектов же этих методов не имеет.
Сцена всегда параметризируется типом контроллера (или интерфейсом доступа к нему). Если же объектам доступ к контроллеру не нужен, то параметром можно выставить any.
Такое связывание помогает любому объекту получить доступ к контроллеру через объект сцены. Чтобы в рамках пакета не указывать генерик-тип, можно задать псевдоним:
// internal/scenes/walkscene/walkscene_controller.go
package walkscene
import "github.com/quasilyte/gscene"
// Этот псевдоним типа упростит сигнатуры внутри пакета.
type scene = gscene.Scene[*Controller]
Гофер станет логическим объектом сцены:
// internal/scenes/walkscene/gopher_node.go
package walkscene
import (
graphics "github.com/quasilyte/ebitengine-graphics"
"github.com/quasilyte/ebitengine-hello-world/internal/assets"
input "github.com/quasilyte/ebitengine-input"
"github.com/quasilyte/gmath"
)
type gopherNode struct {
input *input.Handler
pos gmath.Vec
sprite *graphics.Sprite
}
func newGopherNode(pos gmath.Vec) *gopherNode {
return &gopherNode{pos: pos}
}
func (g *gopherNode) Init(s *scene) {
// Controller() возвращает тип T, который связан со сценой.
// В данном случае это walkscene.Controller.
ctx := s.Controller().ctx
g.input = ctx.Input
g.sprite = ctx.NewSprite(assets.ImageGopher)
g.sprite.Pos.Base = &g.pos
s.AddGraphics(g.sprite)
}
func (g *gopherNode) IsDisposed() bool {
return false
}
func (g *gopherNode) Update(delta float64) {
// Здесь код, который раньше был в myGame Update.
}
Спрайт гофера — это его графический компонент. Пакет graphics
использует тип Pos для привязки позиции графического объекта к его владельцу. Позиция гофера — это часть логики, а спрайт лишь подглядывает на значение через указатель.
type Pos struct {
Base *Vec
Offset Vec
}
Во время создания своих игр вам почти никогда не придётся создавать собственные графические типы вроде Sprite
.
Гофер создаётся и добавляется на сцену внутри Init
метода контроллера.
func (c *Controller) Init(s *gscene.RootScene[*Controller]) {
g := newGopherNode(gmath.Vec{X: 64, Y: 64})
s.AddObject(g)
}
Тег part0.5_scenes включает в себя описанные выше изменения.
Впереди ещё много кода, поэтому вот вам мем для разнообразия:
В демо-проекте игровая механика будет очень простая — собирать квадраты, получать очки социального рейтинга.
Так как коллизии и физику разбирать в этой статье я не буду, квадраты будут проверять свою дистанцию до игрока и, если она ниже порога, будет происходить начисление баллов.
Здесь есть два варианта: или хранить объект гофера прямо в контроллере и доступаться к нему через сцену, или вынести это в явное разделяемое состояние. Я предпочитаю второй вариант.
// internal/scenes/walkscene/scene_state.go
package walkscene
type sceneState struct {
gopher *gopherNode
}
Объект sceneState
хранится внутри контроллера и создаётся во время его инициализации.
type Controller struct {
ctx *game.Context
+ state *sceneState
+ scene *gscene.RootScene[*Controller]
}
func (c *Controller) Init(s *gscene.RootScene[*Controller]) {
+ c.scene = s
g := newGopherNode(gmath.Vec{X: 64, Y: 64})
s.AddObject(g)
+ c.state = &sceneState{gopher: g}
}
Доступ к этому state-объекту можно выполнять как через сцену, так и явно передавая state-объект в конструктор. Дело вкуса и вероисповеданий.
Без генератора случайных чисел будет скучно, поэтому добавляем его в контекст игры.
type Context struct {
+ Rand gmath.Rand
...
Инициализировать рандом можно в main
:
ctx.Rand.SetSeed(time.Now().Unix())
Добавим объект-подбирашки:
// internal/scenes/walkscene/pickup_node.go
package walkscene
import (
graphics "github.com/quasilyte/ebitengine-graphics"
"github.com/quasilyte/gmath"
"github.com/quasilyte/gsignal"
)
type pickupNode struct {
pos gmath.Vec
rect *graphics.Rect
scene *scene
score int
disposed bool
EventDestroyed gsignal.Event[int]
}
func newPickupNode(pos gmath.Vec) *pickupNode {
return &pickupNode{pos: pos}
}
func (n *pickupNode) Init(s *scene) {
n.scene = s
ctx := s.Controller().ctx
// Количество очков-награды за подбор объекта
// будет в случайном диапазоне от 5 до 10.
n.score = ctx.Rand.IntRange(5, 10)
n.rect = ctx.NewRect(16, 16)
n.rect.Pos.Base = &n.pos
n.rect.SetFillColorScale(graphics.ColorScaleFromRGBA(200, 200, 0, 255))
s.AddGraphics(n.rect)
}
func (n *pickupNode) IsDisposed() bool {
// Можно было бы использовать n.rect.IsDisposed(),
// но я рекомендую не привязывать логику объектов
// к состоянию графических компонентов.
return n.disposed
}
func (n *pickupNode) Update(delta float64) {
g := n.scene.Controller().state.gopher
if g.pos.DistanceTo(n.pos) < 24 {
n.pickUp()
}
}
func (n *pickupNode) pickUp() {
n.EventDestroyed.Emit(n.score)
n.dispose()
}
func (n *pickupNode) dispose() {
// Каждый объект должен вызывать методы Dispose
// у своих компонентов в явном виде.
n.rect.Dispose()
n.disposed = true
}
Здесь я добавил пакет gsignal, который реализует что-то вроде сигналов из Godot.
Осталось добавить код создания подбираемых объектов на сцене. Этот код добавляется в контроллер.
type Controller struct {
+ scoreLabel *graphics.Label
+ score int
...
// internal/scenes/walkscene/walkscene_controller.go
func (c *Controller) createPickup() {
p := newPickupNode(gmath.Vec{
X: c.ctx.Rand.FloatRange(0, float64(c.ctx.WindowWidth)),
Y: c.ctx.Rand.FloatRange(0, float64(c.ctx.WindowHeight)),
})
p.EventDestroyed.Connect(nil, func(score int) {
c.addScore(score)
c.createPickup()
})
c.scene.AddObject(p)
}
func (c *Controller) addScore(score int) {
c.score += score
c.scoreLabel.SetText(fmt.Sprintf("score: %d", c.score))
}
func (c *Controller) Init(s *gscene.RootScene[*Controller]) {
...
+ c.scoreLabel = c.ctx.NewLabel(assets.FontNormal)
+ c.scoreLabel.Pos.Offset = gmath.Vec{X: 4, Y: 4}
+ s.AddGraphics(c.scoreLabel)
+ c.createPickup()
+ c.addScore(0) // Установит текст у scoreLabel
}
Весь код можно увидеть под тегом part0.5_pickups.
Напоследок добавим несколько менее значительных особенностей.
Начнём с разворота спрайта гофера при движении в левую сторону. Делается это в пару строк:
func (g *gopherNode) Update(delta float64) {
...
+ if !v.IsZero() {
+ g.sprite.SetHorizontalFlip(v.X < 0)
+ }
g.pos = g.pos.Add(v)
}
Я хочу продемонстрировать, как легко реализовать рестарт сцены с этим фреймворком:
// internal/scenes/walkscene/walkscene_controller.go
func (c *Controller) Update(delta float64) {
if c.ctx.Input.ActionIsJustPressed(controls.ActionRestart) {
game.ChangeScene(c.ctx, NewController(c.ctx))
}
}
Всё, что нужно сделать — это заменить текущую сцену, используя новый экземпляр контроллера.
В Go запрещены циклические импорты пакетов.
В игре возможна ситуация, когда из сцены A есть переход в сцену Б, а из Б можно вернуться в А. Так как смена сцены требует создания контроллера, может возникнуть запрещённый импорт в случае, если контроллеры для А и Б определены в разных пакетах.
Универсального и лучшего ответа на эту ситуацию у меня пока нет. Начну с самого простого лайфхака, который позволит использовать указанную выше структуру проекта.
С помощью интерфейсов в Go можно стереть прямую зависимость. Используя тип gscene.GameRunner можно принять любой объект контроллера как аргумент конструктора. Контроллер А сохранит в себе контроллер Б и, когда нужно будет сменить сцену, воспользуется заранее созданным объектом.
func NewController(ctx *game.Context, back gscene.GameRunner) *Controller {
return &Controller{
ctx: ctx,
back: back,
}
}
Из пакета scenes вызов будет выглядеть так:
backController := NewSplashController(c.ctx)
controller := walkscene.NewController(c.ctx, backController)
game.ChangeScene(c.ctx, controller)
В момент, когда нужно "вернуться" в сцену splash, мы используем переданный ранее контроллер:
game.ChangeScene(c.ctx, c.back)
Для более сложных случаев можно применить подход со scene registry.
// internal/game/scene_registry.go
type SceneRegistry {
NewSplashController(*Context) gscene.GameRunner
NewWalksceneController(*Context) gscene.GameRunner
}
Реестр сцен добавляется как поле контекста. Связывание происходит в main
:
ctx.Scenes.NewSplashController = scenes.NewSplashController
ctx.Scenes.NewWalksceneController = walkscene.NewController
В момент смены сцены мы вызываем конструктор через реестр:
game.ChangeScene(c.ctx, c.ctx.Scenes.NewSplashController(c.ctx))
Третьим вариантом является описание всех контроллеров в одном пакете scenes
. Тогда из каждого контроллера будет возможность создать любой другой. Логика сложных сцен всё так же будет выноситься в отдельные пакеты, но переход между сценами будет обрабатываться внутри контроллера.
Финальную версию кода можно найти по тегу part0.5_final2.
Структура проекта на данный момент:
mygame/
cmd/
mygame/
main.go
internal/
assets/
_data/
images/
gopher.png
fonts/
DejavuSansMono.ttf
assets.go
fonts.go
images.go
controls/
actions.go
default_keymap.go
game/
context.go
scenes/
splash_controller.go
walkscene/
walkscene_controller.go
scene_state.go
gopher_node.go
pickup_node.go
RootScene
, объекты — со Scene
Dispose
: объекты удаляют свои компоненты, вызывая их Dispose
и так далееПодключайтесь к нам в телеграм-сообщество, если тема геймдева на Go вам интересна.