Fat Old Yeti

Fat Old Yeti

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

11 Feb 2021

Roguelike Tutorial 14

Roguelike in Go - Part 14 (Basic Combat)

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

Ok, we refactored our code a bit and the monsters all chase the player. Let’s add some combat.

Lets start this out with some components. Open components.go and let’s add some.

We will start with Health. Anything in combat will need a Current health indicator, and likely a Max Health indicator (in case we want to add healing to the game).

type Health struct {
	MaxHealth int
	CurrentHealth int
}

Now we want to have something to hit people with. I’ll start with Melee Weapons. We can give them a name for flavor. Each weapon should do a potential range of damage, so a Minimum and Maximum will suffice. Let’s also add a bonus to Hit things with it (or a negative for penalties on slow unwieldy weapons).

type MeleeWeapon struct {
	Name string
	MinimumDamage int
	MaximumDamage int
	ToHitBonus int
}

If we have weapons, we should have Armor. For now, let’s keep it simple. We can make this more complex when we build an inventory system. Let’s add a Name for the type of armor. Give it an Armor Class. This will be the value the attacker will need to roll on a d10 to hit whoever is wearing it. This will be modified by the ToHitBonus of the weapon. Lastly, let’s add a Defense value. This will be a straight reduction of damage taken from each attack.

type Armor struct {
	Name string
	Defense int
	ArmorClass int
}

I’m not happy with Monster having a Name and nothing else. Let’s remove the Name element from Monster and create a Name component instead.

type Monster struct{}

type Name struct {
	Label string
}

Not lots of things can have names.

Also, while we are in this file, let’s go ahead and make an equality function for Position. We really should have done that long ago.

func (p *Position) IsEqual(other *Position) bool {
	return (p.X == other.X && p.Y == other.Y)
}

Since we did this, let’s change the function for node equality in astar.go to use this.

func (n *node) isEqual(other *node) bool {
	return n.Position.IsEqual(other.Position)
}

Ok, I’m much happier with that.

So, it’s time to add these components to our world. Open up world.go. Where we declared globals in worldstate, add these:

	var health *ecs.Component
	var meleeWeapon *ecs.Component
	var armor *ecs.Component
	var name *ecs.Component

And under all the initializations, let’s initialize them:

	health = manager.NewComponent()
	meleeWeapon = manager.NewComponent()
	armor = manager.NewComponent()
	name = manager.NewComponent()

Let’s give the Player entity some more components. Remember to add a dot (.) at the end of the parens for Position before adding this code after it.

AddComponent(health, &Health{
	MaxHealth: 30,
	CurrentHealth: 30,
}).
AddComponent(meleeWeapon, &MeleeWeapon{
	Name: "Fist",
	MinimumDamage: 1,
	MaximumDamage: 3,
	ToHitBonus: 2,
}).
AddComponent(armor, &Armor{
	Name: "Burlap Sack",
	Defense: 1,
	ArmorClass: 1,
}).
AddComponent(name, &Name{Label: "Player"})

The values here are kind of arbitrary, just making sure the player is weak at level 1 with no real armor or weapons. This gives us some growth potential in our game.

For our Skeletons, let’s do the same: First remember to change

AddComponent(monster, &Monster{
	Name: "Skeleton",
}).

to:

AddComponent(monster, &Monster{}).

This is because we no longer have a Name property in the Monster component.

Let’s add these right after Position, the same as we did with the Player.

AddComponent(health, &Health{
	MaxHealth: 10,
	CurrentHealth: 10,
}).
AddComponent(meleeWeapon, &MeleeWeapon{
	Name: "Short Sword",
	MinimumDamage: 2,
	MaximumDamage: 6,
	ToHitBonus: 0,
}).
AddComponent(armor, &Armor{
	Name: "Bone",
	Defense: 3,
	ArmorClass: 4,
}).
AddComponent(name, &Name{Label: "Skeleton"})

Note that Skeletons hit harder than the player and have better armor. They have a lot less life though. Still, the odds of our player winning a fight naked with a skeleton are slim.

Now let’s change our views: Find the line:

players := ecs.BuildTag(player, position)

and change it to:

players := ecs.BuildTag(player, position, health, meleeWeapon, armor, name)

Next find the line:

monsters := ecs.BuildTag(monster, position)

and change it to:

monsters := ecs.BuildTag(monster, position, health, meleeWeapon, armor, name)

I’m not going to lie. Right now, parts of this ECS implementation are starting to wear thin on me. I’m having some buyer’s remorse as it’s not working the way I personally would like it to in places. It’s certainly not unworkable, so I’ll continue with it for this tutorial. However, if I were making a bigger game, I’d probably use it as a base and extend it to my own uses.

Now that the world has been edited, I want to add a GameOver state to the game. This is because now that we have combat, the player WILL eventually die. Open turnstate.go Add this to the const declarations:

GameOver

Now in GetNextState (which we aren’t really using much of yet, but we will), add this right above default:

case GameOver:
	return GameOver

Ok, at least now, when we set the state as Game Over, all turns are done.

Time to create our Attack System. Create a new file and name it combat_systems.go. At the top of the file, add the following:

package main

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

Let’s create our AttackSystem function. Add this to the file:

func  AttackSystem(g  *Game, attackerPosition  *Position, defenderPosition  *Position) {

This takes a Game (so we can access our World). The Attacker’s Position and the Defender’s Position. When we add ranged combat, this will also help us in determining range. It’s also a unique indicator of an entity (since we made it so only one entity can occupy a single position at a time).

Now add a declaration for the attacker and defender objects:

var attacker *ecs.QueryResult = nil
var defender *ecs.QueryResult = nil

We then query to get these entities from our views:

//Get the attacker and defender if either is a player
for _, playerCombatant := range g.World.Query(g.WorldTags["players"]) {
	pos := playerCombatant.Components[position].(*Position)
	if pos.IsEqual(attackerPosition) {
		//This is the attacker
		attacker = playerCombatant
	} else if pos.IsEqual(defenderPosition) {
		//This is the defender
		defender = playerCombatant
	}
}

//Get the attacker and defender if either is a monster
for _, cbt := range g.World.Query(g.WorldTags["monsters"]) {
	pos := cbt.Components[position].(*Position)
	if pos.IsEqual(attackerPosition) {
		//This is the attacker
		attacker = cbt
	} else if pos.IsEqual(defenderPosition) {
		//This is the defender
		defender = cbt
	}
}

This code deserves a little explanation. First, we loop through to see if the attacker or defender is the player. I don’t like doing this, but honestly, since the player view is only one object, it’s not really all that terrible performance-wise. This also allows us to have monsters fighting each other later. That sounds interesting.

If for some reason, we don’t find an attacker and a defender, we bail out of it. After all, it takes two to tango:

//If we somehow don't have an attacker or defender, just leave
if attacker == nil || defender == nil {
	return
}

Note: Regardless of what I just said, NOTHING here is stopping an entity from attacking itself here. Even more possibilities…

So, we have the attacker and defender. Let’s get the components we need:

//Grab the required information
defenderArmor := defender.Components[armor].(*Armor)
defenderHealth := defender.Components[health].(*Health)
defenderName := defender.Components[name].(*Name).Label
attackerWeapon := attacker.Components[meleeWeapon].(*MeleeWeapon)
attackerName := attacker.Components[name].(*Name).Label

Time to Attack:

//Roll a d10 to hit
toHitRoll := GetDiceRoll(10)

Now we resolve the attack according to the rules we stated when creating the components:

	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
		fmt.Printf("%s swings %s at %s and hits for %d health.\n", attackerName, attackerWeapon.Name, defenderName, damageDone)

		if defenderHealth.CurrentHealth <= 0 {
			fmt.Printf("%s has died!\n", defenderName)
			if defenderName == "Player" {
				fmt.Printf("Game Over!\n")
				g.Turn = GameOver
			}
			g.World.DisposeEntity(defender.Entity)
		}

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

Note that if the entity’s health goes below zero, it is removed from the game. If the Entity is the Player. The Game is Over.

So, time to call this function. Open monster_systems.go and find the code block that begins with:

if monsterSees.IsVisible(playerPosition.X, playerPosition.Y) {

Replace the entire contents of that if block with this:

if monsterSees.IsVisible(playerPosition.X, playerPosition.Y) {
	if pos.GetManhattanDistance(&playerPosition) == 1 {
		//The monster is right next to the player. Just smack him down
		AttackSystem(game, pos, &playerPosition)
	} else {
		astar := AStar{}
		path := astar.GetPath(l, pos, &playerPosition)
		if len(path) > 1 {
			nextTile := l.Tiles[l.GetIndexFromXY(path[1].X, path[1].Y)]
			if !nextTile.Blocked {
				l.Tiles[l.GetIndexFromXY(pos.X, pos.Y)].Blocked = false
				pos.X = path[1].X
				pos.Y = path[1].Y
				nextTile.Blocked = true
			}	
		}
	}
}

Really all we did here was check to see if a monster was right next to a player and could see them. If so, the monster doesn’t need to approach the player, but rather can simply attack the player. Obviously we can make this more and more complex over time. However, this works dandy for now.

Ok, so I’ve renamed player_move_systems.go to player_systems.go. It’s more appropriate. Open that file and also rename TryMovePlayer to TakePlayerAction.

func  TakePlayerAction(g  *Game) {

This is also much more appropriate naming. Now, in that function, look for the if block starting with:

if  tile.Blocked  !=  true {

We are going to be adding an else statement after that.

} else if x != 0 || y != 0 {
	if level.Tiles[index].TileType != WALL {
		//Its a tile with a monster -- Fight it
		monsterPosition := Position{X: pos.X + x, Y: pos.Y + y}
		AttackSystem(g, pos, &monsterPosition)
	}
}

So basically, if he player clicks to move into a square that is blocked but NOT a wall, it’s a square containing a monster. This will indicate the player’s desire to attack the monster. For now, the only option is a melee attack. Farewell player…it was nice knowing you.

Lastly, due to our renaming, go to main.go and change the Update function. Find the line:

TryMovePlayer(g)

and change it to:

TakePlayerAction(g)

Congrats! We now have combat. It’s bloody and very deadly combat for the player, but we can fix that with items and experience.

If you have any questions on this tutorial, feel free to hit me up at fatoldyeti@gmail.com or @idiotcoder on the gophers slack. Also, there is the discord.