Knight vs Trolls Part 2: Arena
In this tutorial we will implement our main gameplay mechanic. The original intent was to make Knight vs Trolls a hack & slash game where you swing at the trolls until they die. But, after implementing the knockback effect we decided that it would be more fun if ythe core mechanic would be to try and push the trolls outside an arena.
Implementing the Arena
Our game arena is a grid of sprites placed next to each other. We will call these sprites tiles in this context. The number of sprites on the x and y dimension are controllable and so is their size. We also need a way to control the placement of the arena and it is convenient to do so using the lower-left corner as a starting point.
type Arena struct {
tiles []sprite.Sprite
collider *game.BoundingBox
numTilesX, numTilesY int
start math.Vector2[float32]
tileSize math.Vector2[float32]
game.GameObjectCommon
}
Constructing the arena is a matter of creating sprites and putting them in a regular grid.
var arenaImages = NewClipData([]string{
"data/tile1.png",
"data/tile2.png",
"data/tile3.png",
"data/tile4.png",
})
func NewArena(start math.Vector2[float32], numTilesX, numTilesY int, tileSize math.Vector2[float32]) *Arena {
arenaImages.LoadOnce(Game.Atlas)
arena := Arena{
start: start,
numTilesX: numTilesX,
numTilesY: numTilesY,
tileSize: tileSize,
tiles: make([]sprite.Sprite, 0, numTilesX*numTilesY),
}
arena.AddTag(TagArena)
arena.collider = NewHitbox(true, &arena, false)
arena.collider.SetSizeAdjust(math.Vector2[float32]{-10, 0})
for y := 0; y < numTilesY; y++ {
for x := 0; x < numTilesX; x++ {
rng := rand.Int() % len(arenaImages.spriteIds)
spr, _ := sprite.NewSprite(arenaImages.spriteIds[rng], Game.Atlas, &Game.Shader, 0)
spr.SetScale(tileSize)
spritePos := start.Add(math.Vector2[float32]{
X: float32(x) * tileSize.X,
Y: float32(y) * tileSize.Y,
})
spr.SetPosition(spritePos.AddZ(-1))
arena.tiles = append(arena.tiles, spr)
}
}
return &arena
}
We initialize the Arena
struct by copying the passed
parameters for tile number, tile size and the starting point. We then
use a nested loop to create sprites, with the loop index variables
x
and y
controlling the placement. This places
the first sprite at start
, the next one at
start
+ tileSize.X
and so on. We have four
sprites that can be used as tiles and we choose one every time at random
using rng := rand.Int() % len(arenaImages.spriteIds)
We also add a hit box to the arena which will be used by other game
objects to figure out if they are on the arena or they have fallen off.
When a hit box is created or updated it call
parent.GetScale()
to figure out its dimension and
parent.GetTranslation
to figure out placement. So far, our
game objects where made of a single sprite and we set the scale and
translation directly with SetScale
and
SetTranslation
. For Arena
we need to calculate
these values. GetScale
and GetTranslation
have
default implementations given by game.GameObjectCommon
.
func (g *GameObjectCommon) GetTranslation() math.Vector3[float32] { return g.translation }
func (g *GameObjectCommon) GetScale() math.Vector2[float32] { return g.scale }
We can override these by providing our own implementation. The scale of the arena is simply the size of the tile times the number of tiles.
func (a *Arena) GetScale() math.Vector2[float32] {
return math.Vector2[float32]{
X: float32(a.numTilesX) * (a.tileSize.X),
Y: float32(a.numTilesY) * (a.tileSize.Y),
}
}
As a convention, we expect GetTranslattion
to return the
center of a game object. The center of our arena is the arena size
divided by two. We get this with
a.GetScale().Mul(math.Vector2[float32]{0.5, 0.5})
). We have
to shift this by the start
variable.
func (a *Arena) GetTranslation() math.Vector3[float32] {
return a.start.
Sub(a.tileSize.
Mul(math.Vector2[float32]{0.5, 0.5})).
Add(a.GetScale().
Mul(math.Vector2[float32]{0.5, 0.5})).
AddZ(-1)
}
With this, the size and location of Arena match with the sprite locations and the bounding box covers it nicely. We also adjust the width slightly so that enemies and our knight cannot tiptoe at the very edge of the arena.
Falling Effect
As seen in the video in the beginning, we want Trolls and our knight
to fall to their doom when they step off the arena. To do that we first
need to detect when a game object falls off the arena. This is easily
done with a collision check against the arena bounding box which we gave
a specific tag, TagArena
.
func OnGround(b *game.BoundingBox) bool {
collisions := b.CheckForCollisions()
for i := range collisions {
if game.HasTag(collisions[i], TagArena) {
return true
}
}
return false
}
The falling animation is also easy to do. We just rotate the object and shrinking it.
func Fall(g game.GameObject, dt time.Duration) {
fdt := float32(dt.Seconds())
g.SetRotation(g.GetRotation().Add(math.Vector3[float32]{0, 0, 4 * fdt}))
scale := g.GetScale()
aspectRatio := scale.X / scale.Y
g.SetScale(scale.Sub(math.Vector2[float32]{20 * aspectRatio * fdt, 20 * fdt}))
}
We use OnGround
and Fall
in our update
function to achieve the desired effect. We first check if we are on
ground. If yes, we proceed as usual. Otherwise, we call Fall and we do
not update our character’s position. Since we won’t update position,
subsequent updates will continuously trigger fall until our character
becomes tiny in which case we call Destroy
to put them out
of their misery.
The following code is for Knight
but
Troll
’s implementation is very similar. Notice that our
knight can still swing their sword and their animation still runs while
falling which gives a goofy effect to them falling to their doom.
func (k *Knight) Update(dt time.Duration) {
//...
Ground := OnGround(k.bbox)
if !onGround {
Fall(k, dt)
if k.GetScale().Length() < 5 {
k.Destroy()
}
}
if onGround {
k.Walker.Update(dt, k)
}
k.animation.Update(dt, k)
}
Gameplay Loop
On the next, and possibly final, tutorial we will establish a gameplay loop in which the knight will try to knock enemies off the arena, new enemies will spawn and the arena will become smaller.