Making an RPG in Go: part 0

One of the most frequently asked questions in our Go game development community – this is where to start.

In this series of articles we will study the engine Ebitengine and create an RPG in the process.

Introduction

What is expected of you:

This is not a Go programming course, and I won’t try to convince you that Go game development is great. However, if you are curious about this topic, then I have something to share with you.

Getting to know Ebitengine

Before we start using Ebitengine, I suggest cloning the repository and running the examples.

$ git clone --depth 1 https://github.com/hajimehoshi/ebiten.git
$ cd ebiten

Before we can run games, we need install dev dependencies. They are needed only for compiling games; players will not have to install anything.

After installing the dependencies, run these games while in the directory ebiten:

$ go run ./examples/blocks
$ go run ./examples/flappy
$ go run ./examples/2048
$ go run ./examples/snake

These games are quite simple, which is why they are good as objects for research: there is little code. There are about 80 examples in total and most often they concentrate on one topic (for example, a game camera).

Resources for these games are stored in ./examples/resources.

This is the traditional way to get started with Ebitengine – run the examples, read their code, modify these games. Whenever you want to take a break from following these articles, take a break from these examples.

The fact that examples almost never use third-party libraries is both a plus and a minus. This is good to better understand the basic functionality of the engine. But the amount of unnecessary code and some not-so-nice solutions can scare off new developers.

I’ll move on to third party libraries almost immediately. This will reduce the number of steps back when rewriting code.

Create a Project

Let’s start by creating a directory somewhere convenient for you.

$ mkdir mygame && cd mygame

Go games are regular applications, so the second step is to initialize the module.

$ go mod init github.com/quasilyte/ebitengine-hello-world

We will need Ebitengine immediately. You need to install the second version.

$ go get github.com/hajimehoshi/ebiten/v2

The main package is placed in cmd/mygame:

$ mkdir -p cmd/mygame
package main

import (
    "github.com/hajimehoshi/ebiten/v2"
    "github.com/hajimehoshi/ebiten/v2/ebitenutil"
)

func main() {
    g := &myGame{
        windowWidth:  320,
        windowHeight: 240,
    }

    ebiten.SetWindowSize(g.windowWidth, g.windowHeight)
    ebiten.SetWindowTitle("Ebitengine Quest")

    if err := ebiten.RunGame(g); err != nil {
        panic(err)
    }
}

type myGame struct {
    windowWidth  int
    windowHeight int
}

func (g *myGame) Update() error {
    return nil
}

func (g *myGame) Draw(screen *ebiten.Image) {
    ebitenutil.DebugPrint(screen, "Hello, World!")
}

func (g *myGame) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) {
    return g.windowWidth, g.windowHeight
}

Games in Ebitengine have separate logical ticks and rendering frames. The number of frames per second is FPS, the number of ticks per second is TPS.

Any rendering of graphics to the screen must occur in Draw. Game logic must be in Update.

If we run this game, we will get a black window with outrageously unique text:

$ go run ./cmd/mygame

Uploading Images

There are no multifunctional sprites in the engine, but the type ebiten.Image quite good as a starting point. For the test image, let’s take gopher.png from examples/resources.

We will place the picture of the gopher in the package. assets:

mygame/
  cmd/mygame/main.go
  internal/assets/
    _data/images/gopher.png

Some important assets can be stored directly in the game executable file using go:embed. Plastic bag assets will provide access to all game resources.

package assets

//go:embed all:_data
var gameAssets embed.FS

func OpenAsset(path string) io.ReadCloser {
    // Функция OpenAsset могла бы работать как с данными внутри бинарника,
    // так и с внешними. Для этого ей нужно распознавать ресурс по его пути.
    // Самым простым вариантом является использование префиксов в пути,
    // типа "$music/filename.ogg" вместо "filename.ogg", когда мы ищем
    // файл во внешнем каталоге (а не в бинарнике).
    //
    // Но на данном этапе у нас только один источник ассетов - бинарник.
    f, err := gameAssets.Open("_data/" + path)
    if err != nil {
        panic(err)
    }
    return f
}

To render an image on the screen, you need more than a readable asset. Need to decode the PNG and create an object ebiten.Image based on this. Similar steps must be performed for other types of resources – music (OGG), sound effects (WAV), fonts, and so on.

The library comes to the rescue ebitengine-resource. It will also be responsible for caching (we don’t want to decode the same resources several times).

All access to resources will be through numeric keys (IDs).

package assets

import resource "github.com/quasilyte/ebitengine-resource"

const (
    ImageNone resource.ImageID = iota
    ImageGopher
)

The linking of identifiers with metadata is manual.

package assets

import (
    _ "image/png"
)

func registerImageResources(loader *resource.Loader) {
    imageResources := map[resource.ImageID]resource.ImageInfo{
        ImageGopher: {Path: "images/gopher.png"},
    }

    for id, res := range imageResources {
        loader.ImageRegistry.Set(id, res)
    }
}

ebitengine-resource requires package import image/png from the user’s side. This must be done exactly once, anywhere in the program. The best file for this is one that describes graphic resources.

A resource manager is created at the start of the program, and then forwarded as part of the context of the entire game. For the current example, you can place loader inside an object myGame.

package main

import (
    "github.com/quasilyte/ebitengine-hello-world"

    "github.com/hajimehoshi/ebiten/v2/audio"
    resource "github.com/quasilyte/ebitengine-resource"
)

func createLoader() *resource.Loader {
    sampleRate := 44100
    audioContext := audio.NewContext(sampleRate)
    loader := resource.NewLoader(audioContext)
    loader.OpenAssetFunc = assets.OpenAsset
    return loader
}

Now, anywhere in the program, we can use image ID access to get *ebiten.Image:

img := loader.LoadImage(assets.ImageGopher)

During the first key access, the resource manager will load the asset, decode it, and cache it. All subsequent calls will return an object already created for the resource.

If executed for each resource Load somewhere on the loading screen, you can warm up all the caches in advance.

Rendering an Image

Here is the new method code Draw games:

func (g *myGame) Draw(screen *ebiten.Image) {
    gopher := g.loader.LoadImage(assets.ImageGopher).Data
    var options ebiten.DrawImageOptions
    screen.DrawImage(gopher, &options)
}

Gopher is drawn in position {0,0}. We can change the position by performing a couple of manipulations with options. But to make it more interesting, we will introduce the player entity and assign an image to them.

Positions in 2D games are most often described as two-dimensional vectors. It’s time to import the next library.

package main

import "github.com/quasilyte/gmath"

type Player struct {
    pos gmath.Vec // {X, Y}
    img *ebiten.Image
}

Plastic bag gmath contains many mathematical functions useful in game development. Most of the API is the same as what can be found in Godot.

We will look at processing inputs in the next article, but today the player will move automatically. Since movement is logic and not rendering, we will execute this code inside Update.

// Так как теперь у нас есть объект, требующий инициализации,
// мы будем создавать его на старте игры.
// Метод init() нужно вызывать явно в main() до RunGame.
func (g *myGame) init() {
    gopher := g.loader.LoadImage(assets.ImageGopher).Data
    g.player = &Player{img: gopher}
}

func (g *myGame) Update() error {
    // В Ebitengine нет никаких time delta.
    // В интернете есть несколько постов на эту тему,
    // например этот: https://ebitencookbook.vercel.app/blog
    // Для нас TPS=60, отсюда 1/60.
    g.player.pos.X += 16 * (1.0 / 60.0)

    return nil
}

Rendering stays inside Draw:

func (g *myGame) Draw(screen *ebiten.Image) {
    var options ebiten.DrawImageOptions
    options.GeoM.Translate(g.player.pos.X, g.player.pos.Y)
    screen.DrawImage(g.player.img, &options)
}

This method of rendering images is too low-level, so in the next article we will start using wrappers that implement more convenient sprites.

Reinforcing what we have learned

  • The traditional way to learn Ebitengine is to research examples
  • Games on Ebitengine have separate cycles for Update And Draw
  • To download and cache resources − ebitengine-resource
  • For vector two-dimensional arithmetic – gmath
  • Ebitengine does not have time delta
  • Let’s remember the project structure that I entered (more to come)

The source code for this small project is in the repository ebitengine-hello-world.

Next time we will start creating the planned RPG. We’ll be adding scenes, sprites, and some advanced player input processing to the list of libraries we use.

The reason we didn’t start using sprites right away is because you’ll still be working with sprites from time to time. ebiten.Image as with a full-fledged object. For example, when the functionality of sprites does not cover your specific tasks. Moreover, the resource manager caches images exactly as ebiten.Image.

There will be quite a lot of articles, because there is a long road ahead of us.

Connect with us at telegram communityif the topic of game development on Go is interesting to you.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *