Making an RPG on Go: part 0.5 / Habr

Making an RPG on Go: part 0.5 / Habr

In the previous article, we started getting to know Ebitengine.

In this part, the structure of the game will be refined and translated into scenes.

Part 0.5?

This is the second pre-1 part, where a separate demo project is being developed.

Starting an RPG from scratch would be too difficult: I want to use all my favorite libraries and practices as early as possible, but I couldn’t think of a way to introduce all the ingredients smoothly enough in a less artificial project.

Perhaps the next article will become the “real” first part, but for now, let’s be patient and master the basic techniques of game development on Go.

We manage Gopher

Ebitengine has simple functions out of the box to handle player input. They work only at the level of specific buttons.

// github.com/hajimehoshi/ebiten/v2
ebiten.IsKeyPressed(ebiten.KeyEnter)

// github.com/hajimehoshi/ebiten/v2/inpututil
inpututil.IsKeyJustPressed(ebiten.KeyEnter)

Because of this API, you can’t abstract away from specific buttons and input devices, so I use the ebitengine-input package for my games. This library was inspired by Godot actions.

Instead of button clicks, this library checks for action activations. We define possible actions in the game through constants.

In the demo game, you will be able to move in four directions, so there will be at least four actions: MoveRight, MoveDown, MoveLeft, MoveUp.

I prefer to place Actions in a separate package 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
)

Actions need to be matched with activation triggers. The trigger can be pressing a button on a keyboard, controller, mouse, touch screen, and so on. I will call the set of these mappings a keymap.

Every game needs a default keymap. If desired, you can add support for external configs or perform remapping of specific actions inside the game. A static keymap is enough for our game demo.

// 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,
    },
}

It remains to create an input reader object bound to the given keymap. This object is created via input.Systemwhich is needed in a single copy for the entire game.

 type myGame struct {
    windowWidth  int
    windowHeight int

+   inputSystem input.System
    loader      *resource.Loader

    player *Player
 }

The input system needs a one-time initialization before starting the game:

g.inputSystem.Init(input.SystemConfig{
    DevicesEnabled: input.AnyDevice,
})

for each Update in the game, you need to call the method of the same name in the input system:

 func (g *myGame) Update() error {
+   g.inputSystem.Update()
    g.player.pos.X += 16 * (1.0 / 60.0)
    return nil
 }

After the system is integrated, you can create the input reading objects themselves. These objects in the library are called handlers. Each processor is tied to a Player ID, which is especially important for games with the ability to connect several controllers at the same time.

Only one processor with zero ID is enough for the demo game.

    inputSystem input.System
+   input       *input.Handler
    loader      *resource.Loader
g.input = g.inputSystem.NewHandler(0, controls.DefaultKeymap)

Now through g.input you can check the status of actions. We don’t have scenes yet, so all the logic will be focused on the basics 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
}

This control will work with all the activation methods we specified in the keymap: you can move with the arrows, WASD, and even through the controller.

The part0.5_controls tag contains the state of the demo game code after adding the control.

Refactoring

Before implementing scenes, it is worth refactoring.

First, I’ll bring the game context that exists between scenes into a package game. What will remain in the object myGamewill not be available to scenes directly.

 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
}

What exactly gets into the game context depends a lot on the game and your preferences.

The part0.5_game_context tag is the repository after this refactoring.

Introduction to Scenes

To divide the game into separate parts, it is convenient to have the concept of a scene.

Previously, the game already had an implicit scene – the whole game. The game starts myGame performs Update+Draw loop for this single scene. Explicit scenes change a lot and require additional code, but their benefits justify the investment pretty quickly.

Transitioning to explicit scenes looks something like this:

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

Note that I save the current scene in the game.Contextand not in the object myGame.

The transition from one scene to another occurs through substitution myGame.ctx.currentScene. The logic of the scene consists in its objects (scene.objects), and all graphics are implemented by graphic objects (scene.graphics).

Library gscene implements exactly this model:

$ go get github.com/quasilyte/gscene

Objects and graphics (so-called graphic objects) are interfaces.

type SceneObject interface {
    Init(*Scene)
    Update()
    IsDisposed() bool
}

type SceneGraphics interface {
    Draw(dst *ebiten.Image)
    IsDisposed() bool
}

Method IsDisposed you need to remove objects from the scene. Init is called on objects when added to the scene. Through the scene argument, these objects can add additional objects or graphics to the scene.

In my interpretation of scenes, it is very useful to have one special kind of object, one per scene – a controller. A scene contains objects and is their container, while the controller attached to the scene is the main object of that scene. It adds the first set of objects to the scene.

The controller implements the interface SceneObjectbut without a method IsDisposed.

Scene objects can access the controller object. Everything will become clearer with an example.

We create Scenes

We will have two scenes: a splash screen and a gameplay screen. The game starts on the splash screen, and after activating the confirm action, it switches to the gameplay scene.

Scene transition – replacing the current scene with a new one. The implementation of such a replacement can look like this:

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

I recommend keeping all scenes in a package scenes. For the simplest scenes, such as a splash screen, one file is enough within scenesAnd for more complex cases, it is worth creating nested packages, one for each similar scene.

Optionally, prefix these nested scenes scene*so that there are no conflicts with other packages (it is quite common to have a package battle for the scene and for some general gameplay definitions).

For logical objects scene I prefer to add a suffix *node (both in file names and in type names).

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

The controller for the screen saver will be a stub, because it will not be possible to display the text on the screen beautifully (we will add the necessary library later). All it will do is switch to the main scene after processing the confirm activation.

// 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) {
}

When starting the game, we will have a black screen (splash scene), and after processing confirm, the game will close immediately, going to the walkscene.

Install Graphics

$ go get github.com/quasilyte/ebitengine-graphics

Most constructors with graphics require the transfer of an object *graphics.CacheTherefore, for convenience, this cache should be hidden inside. game.Context. Many games will find it useful to wrap graphics object constructors in context methods to reduce the number of arguments when called.

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

Installing Fonts

To reproduce the text, you need a font in ttf or otf format. Download DejavuSansMono.ttf and save it in internal/assets/_data/fonts.

By analogy with graphic resourcesfont resources should be registered.

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

IN RegisterResources challenge is attached registerFontResources:

 func RegisterResources(loader *resource.Loader) {
    registerImageResources(loader)
+   registerFontResources(loader)
 }

Objects and Graphics

It’s time to add a confirmation keypress request to the splash screen.

Inside the method SplashController.Init a label with the required text is added:

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 is a graphic object, so it is added to the scene via AddGraphics.

There are two types of scenes – root and normal. Root is passed to the initializer of the controller. All other scene objects are assigned the non-root scene.

The main reason for the division into two types of scenes is to improve the API. The root scene is directly integrated into the game loop, has methods Update and Draw. There are no scene objects for these methods.

A scene is always parameterized by a controller type (or access interface to it). If the objects do not need access to the controller, the parameter can be set any.

This binding helps any object to access the controller through the scene. In order not to specify a generic type within the package, you can specify an alias:

// internal/scenes/walkscene/walkscene_controller.go

package walkscene

import "github.com/quasilyte/gscene"

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

Gopher will stand logical object of the scene:

// 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.
}

A gopher sprite is its graphical component. Package graphics uses the Pos type to bind the position of a graphic object to its owner. The gopher’s position is a piece of logic, and the sprite just peeks at the value via the pointer.

type Pos struct {
    Base   *Vec
    Offset Vec
}

When creating your games, you almost never need to create your own graphic types. Sprite.

A gopher is created and added to the scene internally Init controller method.

func (c *WalksceneController) Init(s *gscene.RootScene[*WalksceneController]) {
    g := newGopherNode(gmath.Vec{X: 64, Y: 64})
    s.AddObject(g)
}

The part0.5_scenes tag includes the changes described above.

There’s still a lot of code to come, so here we have it for a change:

Add Gameplay

In the demo project, the game mechanics will be very simple – collect squares, get social rating points.

Since I will not analyze collisions and physics in this article, the squares will check their distance to the player and, if it is below the threshold, points will be awarded.

There are two options here: either store the gopher object directly in the controller and access it through the scene, or expose it to an explicit separable state. I prefer the second option.

// internal/scenes/walkscene/scene_state.go

package walkscene

type sceneState struct {
    gopher *gopherNode
}

Object sceneState stored inside the controller and created during its initialization.

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

This state object can be accessed as across the stage, and explicitly passing the state object to the constructor. A matter of taste and beliefs.

It would be boring without a random number generator, so let’s add it to the context of the game.

 type Context struct {
+   Rand gmath.Rand
    ...

You can initialize random in main:

ctx.Rand.SetSeed(time.Now().Unix())

Let’s add a pickup object:

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

Here I’ve added the gsignal package, which implements something similar to signals from Godot.

It remains to add the code for creating objects that are selected on the stage. This code is added to the controller.

 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
 }

All the code can be seen under the part0.5_pickups tag.

Polishing

Finally, we will add a few less significant features.

Let’s start by flipping the gopher sprite while moving to the left. This is done in a couple of lines:

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

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

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

I want to demonstrate how easy it is to implement a scene restart with this framework:

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

All we need to do is replace the current scene using the new controller instance.

The final version of the code can be found under the part0.5_final tag.

Consolidate what has been learned

The structure of the project at the moment:

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

  • We use ebitengine-input to process user input
  • The state shared between scenes is transferred to the context object
  • We use gscene scenes to divide the game into different “screens”
  • Each scene has a controller (the first logical object on the scene)
  • The controller works with RootSceneobjects – from Scene
  • The scene can be parameterized both by the controller itself and by the interface
  • We strictly separate the graphic and logical objects of the scene
  • Convention Dispose: objects remove their components by calling them Dispose and so on
  • For graphic objects, we use the ebitengine-graphics package
  • We use gsignal to link objects

Join us in the Telegram community if you are interested in the topic of game development on Go.

Related posts