Fat Old Yeti

Fat Old Yeti

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

08 Feb 2022

Roguelike Tutorial 16

Roguelike in Go - Part 16 (UI)

All of the source code for this tutorial can be found here.

Ok, we have combat working, but we are currently pushing all the information the player would want to know into the console. We really need to show this on the screen. Settle in because this will be a long one.

Given the size of our game, I think a UI height of 10 squares will be sufficient. I’d like to put it at the bottom of the screen. So, we need to open up our gamedata.go file and add UIHeight to our GameData struct. Then we want to initialize it with a value of 10. To keep our game the same size, we will need to add 10 to our GameHeight element, so change it to 60.

Our gamedata.go file should now look like this:

package main

//GameData holds the values for the size of elements within the game
type GameData struct {
	ScreenWidth  int
	ScreenHeight int
	TileWidth    int
	TileHeight   int
	UIHeight     int
}

//NewGameData creates a fully populated GameData Struct.
func NewGameData() GameData {
	g := GameData{
		ScreenWidth:  80,
		ScreenHeight: 60,
		TileWidth:    16,
		TileHeight:   16,
		UIHeight:     10,
	}

	return g
}

Now all we need to do is edit the level.go file to implement this. At the top of the file, where we declare our variables, add the following line:

var levelHeight int = 0

Next, every instance in the file of gd.ScreenHeight should be replaced with levelHeight. Now, if you try to run the game, it will fail. We have not set a value to levelHeight yet.

Go to the GenerateLevelTiles function and directly under the line where it says

gd:= NewGameData()

place the line

levelHeight = gd.ScreenHeight - gd.UIHeight

Now if you run the game, you will have a nice area on the bottom of the screen for the logs and other UI elements to show.

Now it’s time to move our messages from the debug console onto our screen. To start with, let’s make a UserMessage component. Open components.go and add the following to the bottom of the file:

type UserMessage struct {
	AttackMessage    string
	DeadMessage      string
	GameStateMessage string
}

This is primitive, but works for now, as we only have 3 message types and each entity can only really have one of each. This will change later, but let’s keep it simple for time being.

Next we have to go into world.go and add our new component to our monster and our player entities. Near the top of the file, where we declare all entities, add this at the bottom:

var userMessage *ecs.Component

And now in the InitializeWorld function, find near the top where we initialize all the components and add this:

userMessage = manager.NewComponent()

On both the player and monster entities, make sure to add the component:

AddComponent(userMessage, &UserMessage{
			AttackMessage:    "",
			DeadMessage:      "",
			GameStateMessage: "",
		})

Remember, you can always look at the current tagged check-in to double check if you are putting things in the correct location.

Lastly for this file, we need to update our tags. Replace the tag creation with this:

	players := ecs.BuildTag(player, position, health, meleeWeapon, armor, name, userMessage)
	tags["players"] = players

	renderables := ecs.BuildTag(renderable, position)
	tags["renderables"] = renderables

	monsters := ecs.BuildTag(monster, position, health, meleeWeapon, armor, name, userMessage)
	tags["monsters"] = monsters

	messengers := ecs.BuildTag(userMessage)
	tags["messengers"] = messengers

Note that renderables is the only thing that did not change here.

So, at this point, we have a component, and the component is added to our entities. Let’s go to combat_systems.go to use it (since currently combat is the only thing we are informing the user of).

Look for the comment //Grab the Required Information and add the following two lines (anywhere in that block works):

defenderMessage := defender.Components[userMessage].(*UserMessage)
attackerMessage := attacker.Components[userMessage].(*UserMessage)

This gives us access to the components for attacker and defender.

Now we need to make changes throughout the if/else block in order to stop sending the messages to debug and store them in the components. Change it to be as follows:

	if toHitRoll+attackerWeapon.ToHitBonus > defenderArmor.ArmorClass {
		//It's a hit!
		damageRoll := GetRandomBetween(attackerWeapon.MinimumDamage, attackerWeapon.MaximumDamage)

		damageDone := damageRoll - defenderArmor.Defense
		//Let's not have the weapon heal the defender
		if damageDone < 0 {
			damageDone = 0
		}
		defenderHealth.CurrentHealth -= damageDone
		attackerMessage.AttackMessage = fmt.Sprintf("%s swings %s at %s and hits for %d health.\n", attackerName, attackerWeapon.Name, defenderName, damageDone)

		if defenderHealth.CurrentHealth <= 0 {
			defenderMessage.DeadMessage = fmt.Sprintf("%s has died!\n", defenderName)
			if defenderName == "Player" {
				defenderMessage.GameStateMessage = "Game Over!\n"
				g.Turn = GameOver
			}
		}

	} else {
		attackerMessage.AttackMessage = fmt.Sprintf("%s swings %s at %s and misses.\n", attackerName, attackerWeapon.Name, defenderName)
	}

At this point, every entity that attacks, stores its message. We had to remove the DisposeEntity call from here because if we disposed of it, it would not exist to be logged. I’m not sure how I feel about this, but I may refactor a cleanup system later and tag entities for removal to make things nicer.

That’s it for combat system.

We need a nice UI to drop our messages onto. I’ve created a new asset called UIPanel.png and added it to the assets folder. It will fill the left half of the bottom of our screen, as I want to reserve the right have to a HUD for Player Health and such.

In addition, we will need a font. Since the Ebiten examples come with a nice one already, I grabbed it and dropped it in a subfolder named fonts (because it’s a package called fonts). The file mplus1pregular.go is a great font. Many thanks to Hajime Hoshi for not only providing Ebiten in the first place, but a very useful font also.

Next we have to create a UserLog System (I hate this name, so will probably rename in the future). Create a new file names userlog_system.go and open it up. I’ll simply drop the contents of this new file here and explain it after:

package main

import (
	"image/color"
	"log"

	"github.com/RAshkettle/rrogue/fonts"
	"github.com/hajimehoshi/ebiten/v2"
	"github.com/hajimehoshi/ebiten/v2/ebitenutil"
	"github.com/hajimehoshi/ebiten/v2/text"
	"golang.org/x/image/font"
	"golang.org/x/image/font/opentype"
)

var userLogImg *ebiten.Image = nil
var err error = nil
var mplusNormalFont font.Face = nil
var lastText []string = make([]string, 0, 5)

func ProcessUserLog(g *Game, screen *ebiten.Image) {
	if userLogImg == nil {
		userLogImg, _, err = ebitenutil.NewImageFromFile("assets/UIPanel.png")
		if err != nil {
			log.Fatal(err)
		}
	}
	if mplusNormalFont == nil {
		tt, err := opentype.Parse(fonts.MPlus1pRegular_ttf)
		if err != nil {
			log.Fatal(err)
		}

		const dpi = 72
		mplusNormalFont, err = opentype.NewFace(tt, &opentype.FaceOptions{
			Size:    16,
			DPI:     dpi,
			Hinting: font.HintingFull,
		})
		if err != nil {
			log.Fatal(err)
		}
	}
	gd := NewGameData()

	uiLocation := (gd.ScreenHeight - gd.UIHeight) * gd.TileHeight
	var fontX = 16
	var fontY = uiLocation + 24
	op := &ebiten.DrawImageOptions{}
	op.GeoM.Translate(float64(0.), float64(uiLocation))
	screen.DrawImage(userLogImg, op)
	tmpMessages := make([]string, 0, 5)
	anyMessages := false

	for _, m := range g.World.Query(g.WorldTags["messengers"]) {
		messages := m.Components[userMessage].(*UserMessage)
		if messages.AttackMessage != "" {
			tmpMessages = append(tmpMessages, messages.AttackMessage)
			anyMessages = true
			//fmt.Printf(messages.AttackMessage)
			messages.AttackMessage = ""
		}
	}
	for _, m := range g.World.Query(g.WorldTags["messengers"]) {
		messages := m.Components[userMessage].(*UserMessage)
		if messages.DeadMessage != "" {
			tmpMessages = append(tmpMessages, messages.DeadMessage)
			anyMessages = true
			//fmt.Printf(messages.DeadMessage)
			messages.DeadMessage = ""
			g.World.DisposeEntity(m.Entity)
		}
		if messages.GameStateMessage != "" {
			tmpMessages = append(tmpMessages, messages.GameStateMessage)
			anyMessages = true
			//No need to clear, it's all over
		}

	}
	if anyMessages {
		lastText = tmpMessages
	}
	for _, msg := range lastText {
		if msg != "" {
			text.Draw(screen, msg, mplusNormalFont, fontX, fontY, color.White)
			fontY += 16
		}
	}

}

Now, once you have this file ready, you will need to run Go Mod Tidy again to download the appropriate libraries.

My first attempt at this was an abject failure. Every frame, it would wipe the messages so all I would get is a flash of text. That’s when I realized I needed to keep a “last good” collection of messages. I’m certain this lesson will come in handy later on.

So, we start by creating all the variables needed to hold our UI element, our Font, and that nice stateful message collection.

In the system process itself, we check to see if the font and UIPanel are loaded and if not, we load them. We then figure out where to place the UI element and drop it in. Note we also keep track of the X and Y of where to put the font. It took some fiddling around till I found a spot that looked good.

We set up our temp collector for messages and a varible to tell us if we have new ones (not strictly necessary, but for the cost of a bool, I’ll do it the simple way).

Now, I was getting all the entities with messages in a single loop, but when they printed, more often than not, I got the player death message in the middle of them. So I decided to split the attacker messages and print them first, followed by defender messages (so you didn’t die and THEN attack, which looked silly). Thus, two loops. If this becomes a problem, I can drop it to one loop and have two temporary message holders (one attack and one defense) and accomplish the same goal in a more performant manner. I’ll save that for if I need it.

If any messages are present, anyMessages is set to true and the message is placed into the temp message slice. Then, if it’s true, the stateful splice is replaced by the temp one so the new messages show. If not, the stateful one still exists and the old messages remain until a change.

Note that the dispose entity is here now in the case of a death message, so the message can be gotten before the dispose happens. Now we are nearly finished….

Lastly to get this all wired up, we must go to main.go and add our system. Change the Draw method in this file to below:

//Draw is called each draw cycle and is where we will blit.
func (g *Game) Draw(screen *ebiten.Image) {
	//Draw the Map
	level := g.Map.CurrentLevel
	level.DrawLevel(screen)
	ProcessRenderables(g, level, screen)
	ProcessUserLog(g, screen)
}

If you have any questions on this tutorial, feel free to hit me up at fatoldyeti@gmail.com or @idiotcoder on the gophers slack. In addition, I’m on the Ebiten Discord as Idiotcoder. Also, there is my discord linked above.