Fat Old Yeti

Fat Old Yeti

Being a blog of thoughts and tutorials from a hobby game developer.

06 Feb 2021

Roguelike Tutorial Part 3a

Roguelike in Go - Part 3a (Adding ECS)

This third part will be broken down into two small subsections. This first one will be in order to add an ECS to our framework. Part 3b will add the systems required to draw our player and allow us to move it.

The code for part 3 can be found here.

Let’s start by creating some Components. Create a new file called components.go.

We can start with adding some simple components who don’t need any data (yet), but still work to indicate what an entity is. These are Player, Renderable, and Movable. The very act of having these components will help our systems with determining which entities to act upon. The final component we will need to add for now will be Position, which will need an X and a Y. You can’t really Render or Move something without it having a position. One would be tempted to place a position within Renderable or Movable, but nesting leads us down the same problems that we chose an ECS to avoid. Your file should look like this:

package main

type Player struct{}

type Position struct {
	X int
	Y int
}

type Renderable struct{}

type Movable struct{}

Note that while Player and Renderable will likely end up with data later on, it’s not necessary for a component to have any data. You will also notice that these components are nothing more than simple structs.

Now for a small refactor that I should have thought of before this. We want to add a Draw function to our Level. This keeps our Draw function in main.go a bit cleaner and lets us look directly in the level file if we need to look at the code rendering our level. in level.go add the following.

func (level *Level) DrawLevel(screen *ebiten.Image) {
	gd := NewGameData()
	for x := 0; x < gd.ScreenWidth; x++ {
		for y := 0; y < gd.ScreenHeight; y++ {
			tile := level.Tiles[level.GetIndexFromXY(x, y)]
			op := &ebiten.DrawImageOptions{}
			op.GeoM.Translate(float64(tile.PixelX), float64(tile.PixelY))
			screen.DrawImage(tile.Image, op)
		}
	}
}

You may have noticed that this is almost exactly what was in our Draw function in main.go. Replace all the code in main.go with the following:

func (g *Game) Draw(screen *ebiten.Image) {
	//Draw the Map
	level := g.Map.Dungeons[0].Levels[0]
	level.DrawLevel(screen)
}

That’s a lot cleaner. We may move it all again later, but for now, this is good enough.

Now we need to look at our World. The World is the container for all or our entities and we register them there, with their components.

Create world.go and add the following to it:

package main

import (
"github.com/bytearena/ecs"
)

This includes our ECS library, which is kind of necessary since we are planning on using it here. Next we want to initialize the world. This function needs to return at a minimum a reference to the ecs manager. In addition, it’s beneficial to return a dictionary of tags (think of them as views) for faster access from our systems.

The function should look like this:

func InitializeWorld() (*ecs.Manager, map[string]ecs.Tag) {
	tags := make(map[string]ecs.Tag)
	manager := ecs.NewManager()

	//More stuff will go here

	return manager, tags
}

Now we need to register some components and make some tags. First, we need to register components. In the spot marked in the function, add the following.

player := manager.NewComponent()
position := manager.NewComponent()
renderable := manager.NewComponent()
movable := manager.NewComponent()

This registers each of our structs as components in the ECS engine. Now just for sake of doing so, let’s register an entity for our player. Under the code you just inserted, add the following:

manager.NewEntity().
	AddComponent(player, Player{}).
	AddComponent(renderable, Renderable{}).
	AddComponent(movable, Movable{}).
	AddComponent(position, &Position{
		X: 40,
		Y: 25,
	})

players := ecs.BuildTag(player, position)
tags["players"] = players

Here we have registered a new entity that is a Player, Renderable,Movable and it has a position. Not that we added Position with its address. This is because we will want to mutate that value later on. The rest is read only access (a copy by value). Once we have our Entity, we build a view which will contain all entities with player and position components. We mark those as players. When we need to move a player, this view will save us from iterating over every entity in the game, as it pre-defines the list for us.

Now that we have a way to initialize our world, we should probably write some code that calls it.

Heading back to main.go, we need to add the following import.

"github.com/bytearena/ecs"

Let’s start with changing Game so that it also holds the World and the Tags. This will allow us global access to them.

//Game holds all data the entire game will need.
type Game struct {
	Map GameMap
	World *ecs.Manager
	WorldTags map[string]ecs.Tag
}

Since we changed that, we need to change the constructor for Game. The following code should be self explanatory.

func NewGame() *Game {
	g := &Game{}
	world, tags := InitializeWorld()
	g.Map = NewGameMap()
	g.WorldTags = tags
	g.World = world
	return g
}

At this point, we have a working ECS manager in our application. The next step will be to add the player to the screen and to allow the player to move.