Делаем RPG на Go: часть 0.5

В предыдущей статье мы начали знакомство с Ebitengine.

В этой части структура игры будет доработана и переведена на сцены.

m3ur0fwi5ldndirq4578onhvwkq.jpeg


Часть 0.5?

Это вторая pre-1 часть, в которой разрабатывается отдельный демо-проект.

Начинать делать RPG с нулевой базы было бы слишком сложно: я хочу использовать все свои любимые библиотеки и практики как можно раньше, при этом у меня не получилось придумать способа достаточно плавно вводить все составляющие на менее искусственном проекте.

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

i5szre0ot7hawfyrywyvtu06r54.jpeg


Управляем Гофером

В 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
}

tsix-wlnuvunvr-jz-1siikcxbe.gif

Это управление будет работать со всеми способами активации, которые мы задали в 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.NewWalksceneController(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 WalksceneController struct {
    ctx *game.Context
}

func NewWalksceneController(ctx *game.Context) *WalksceneController {
    return &WalksceneController{ctx: ctx}
}

func (c *WalksceneController) Init(s *gscene.SimpleRootScene) {
    os.Exit(0) // Пока что заглушка
}

func (c *WalksceneController) Update(delta float64) {
}

При запуске игры у нас будет чёрный экран (сцена splash), а после обработки confirm игра сразу закроется, перейдя в сцену walkscene.


Устанавливаем Graphics

$ 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)
 }


Объекты и Graphics

Пора добавить на сплэш-экран запрос на нажатие клавиши подтверждения.

Внутри метода 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.

chdwl6fwzbi4esc4ivhlpds-fra.png

Есть два типа сцен — корневая и обычная. Корневая (root) передаётся в инициализатор контроллера. Всем остальным объектам сцены передаётся некорневая сцена.

Основная причина разделения на два типа сцен — улучшение API. Корневая сцена напрямую интегрируется в игровой цикл, у неё есть методы Update и Draw. Сцена объектов же этих методов не имеет.

Сцена всегда параметризируется типом контроллера (или интерфейсом доступа к нему). Если же объектам доступ к контроллеру не нужен, то параметром можно выставить any.

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

// internal/scenes/walkscene/walkscene_controller.go

package walkscene

import "github.com/quasilyte/gscene"

// Этот псевдоним типа упростит сигнатуры внутри пакета.
type scene = gscene.Scene[*WalksceneController]

Гофер станет логическим объектом сцены:

// 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, который связан со сценой.
    // В данном случае это WalksceneController.
    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 *WalksceneController) Init(s *gscene.RootScene[*WalksceneController]) {
    g := newGopherNode(gmath.Vec{X: 64, Y: 64})
    s.AddObject(g)
}

Тег part0.5_scenes включает в себя описанные выше изменения.

Впереди ещё много кода, поэтому вот вам мем для разнообразия:

ezvwjl8gyue9po-c9gianzdkfco.jpeg


Добавляем Геймплея

В демо-проекте игровая механика будет очень простая — собирать квадраты, получать очки социального рейтинга.

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

Здесь есть два варианта: или хранить объект гофера прямо в контроллере и доступаться к нему через сцену, или вынести это в явное разделяемое состояние. Я предпочитаю второй вариант.

// internal/scenes/walkscene/scene_state.go

package walkscene

type sceneState struct {
    gopher *gopherNode
}

Объект sceneState хранится внутри контроллера и создаётся во время его инициализации.

 type WalksceneController struct {
    ctx *game.Context

+   state *sceneState
+   scene *gscene.RootScene[*WalksceneController]
 }
 func (c *WalksceneController) Init(s *gscene.RootScene[*WalksceneController]) {
+   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 WalksceneController struct {
+   scoreLabel *graphics.Label
+   score      int
    ...
// internal/scenes/walkscene/walkscene_controller.go

func (c *WalksceneController) 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 *WalksceneController) addScore(score int) {
    c.score += score
    c.scoreLabel.SetText(fmt.Sprintf("score: %d", c.score))
}
 func (c *WalksceneController) Init(s *gscene.RootScene[*WalksceneController]) {
    ...

+   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
 }

srdlcw39vjpbp8btt8l3wkk4zt0.gif

Весь код можно увидеть под тегом part0.5_pickups.


Полировка

Напоследок добавим несколько менее значительных особенностей.

Начнём с разворота спрайта гофера при движении в левую сторону. Делается это в пару строк:

 func (g *gopherNode) Update(delta float64) {
    ...

+   if !v.IsZero() {
+       g.sprite.SetHorizontalFlip(v.X < 0)
+   }

    g.pos = g.pos.Add(v)
 }

crlrdpfnnojuvxwov2a52mkqmty.gif

Я хочу продемонстрировать, как легко реализовать рестарт сцены с этим фреймворком:

// internal/scenes/walkscene/walkscene_controller.go

func (c *WalksceneController) Update(delta float64) {
    if c.ctx.Input.ActionIsJustPressed(controls.ActionRestart) {
        game.ChangeScene(c.ctx, NewWalksceneController(c.ctx))
    }
}

Всё, что нам нужно сделать — это заменить текущую сцену, используя новый экземпляр контроллера.

Финальную версию кода можно найти по тегу part0.5_final.


Закрепляем Изученное

Структура проекта на данный момент:

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


  • Используем ebitengine-input для обработки пользовательского ввода
  • Разделяемое между сценами состояние выносим в объект контекста
  • Используем сцены gscene для разделения игры на разные «экраны»
  • У каждой сцены есть контроллер (первый логический объект на сцене)
  • Контроллер работает с RootScene, объекты — со Scene
  • Сцену можно параметризировать как самим контроллером, так и интерфейсом
  • Строго разделяем графические и логические объекты сцены
  • Конвенция Dispose: объекты удаляют свои компоненты, вызывая их Dispose и так далее
  • Для графических объектов используем пакет ebitengine-graphics
  • Для связывания объектов пользуемся gsignal

Подключайтесь к нам в телеграм-сообщество, если тема геймдева на Go вам интересна.

© Habrahabr.ru