Making an RPG on Go: part 0 / Habr

Making an RPG on Go: part 0 / Habr

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

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

Introduction

What is expected of you:

  • You are interested in game development on Go
  • You already know this programming language
  • No joke about the engine name Ebitengine

This isn’t a Go programming course, and I’m not going to convince you that Go game development is something great. However, if you are interested in this topic, I have something to share with you.

Let’s get to know Ebitengine

Before we start using Ebitengine, I suggest you lean the repository and run the examples.

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

Before we can run the games, we need to install the dev dependencies. They are needed only to compile 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 objects for research: there is little code. There are about 80 examples in total and most often focus on one topic (for example, a game camera).

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

This is the traditional way to start getting to know Ebitengine – run examples, read their code, modify these games. Whenever you feel like taking a break from following these articles, turn to these examples.

The fact that the 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 redundant code and some not-so-good solutions can scare away new developers.

I will switch to third-party libraries almost immediately. This will reduce the number of steps back with rewriting the code.

We are creating a project

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

$ mkdir mygame && cd mygame

Go games are regular programs, 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

We place the main package 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
}

Ebitengine games have split logic ticks and play frames. Number of frames per second – FPS, number of ticks per second – TPS.

Any display of graphics on the screen must occur in Draw. Game logic should be in Update.

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

$ go run ./cmd/mygame

Download Image

There are no multifunctional sprites in the engine, but the ebiten.Image type is very good as a starting point. For the test image, we will take gopher.png from examples/resources.

We will place the image of the gopher in the package assets:

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

Some of the important assets can be stored directly in the executable file of the game using go:embed. Package 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
}

Rendering an image on screen requires more than a readable asset. You 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, etc.

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

All resource accesses will be through numeric keys (IDs).

package assets

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

const (
    ImageNone resource.ImageID = iota
    ImageGopher
)

Linking identifiers to 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 by the user. You need to do this exactly once, anywhere in the program. A file that describes the graphics resources is best suited for this.

A resource manager is created at the start of the program, and then wakes up as part of the context of the entire game. For the current example, you can place loader inside the 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 app we can use image ID access to get *ebiten.Image:

img := loader.LoadImage(assets.ImageGopher)

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

If run for each resource Load somewhere on the boot screen, you can pre-warm all caches.

Image 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 essence of player and attach images to them.

Positions in 2D games are most often described as two-dimensional vectors. Now is the time to import such a library.

package main

import "github.com/quasilyte/gmath"

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

The gmath package contains many mathematical functions useful in game development. Most of the API replicates what can be found in Godot.

We will consider input processing in the next article, and today the player will move automatically. Since moving is logic, not rendering, we will execute this code internally 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 way of rendering images is too low-level, so in the next article we will start using wrappers that implement more convenient sprites.

Consolidate what has been learned

  • The traditional way to learn Ebitengine is to study examples
  • In games on Ebitengine, there are separate cycles for Update and Draw
  • For downloading and caching resources – ebitengine-resource
  • For vector two-dimensional arithmetic.
  • Ebitengine has no time delta
  • Let’s remember the structure of the project that I introduced (more to come)

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

Next time we will start creating the RPG we envisioned. We’ll add scenes, sprites, and some advanced player input processing to the list of used libraries.

The reason we didn’t jump right into using sprites is that you’ll still be working with sprites from time to time ebiten.Image as with a full object. For example, when sprite functionality does not cover your specific tasks. Especially since the resource manager caches images exactly as ebiten.Image.

There will be quite a lot of articles, because we have a long way to go.

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

Related posts