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:
- Are you interested in game development in Go?
- Do you already know this programming language?
- No jokes about the name of the Ebitengine engine
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 importimage/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
AndDraw
- 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.