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.
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 {
[]sprite.Sprite
tiles *game.BoundingBox
collider , numTilesY int
numTilesX.Vector2[float32]
start math.Vector2[float32]
tileSize math.GameObjectCommon
game}
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 {
.LoadOnce(Game.Atlas)
arenaImages
:= Arena{
arena : start,
start: numTilesX,
numTilesX: numTilesY,
numTilesY: tileSize,
tileSize: make([]sprite.Sprite, 0, numTilesX*numTilesY),
tiles}
.AddTag(TagArena)
arena.collider = NewHitbox(true, &arena, false)
arena.collider.SetSizeAdjust(math.Vector2[float32]{-10, 0})
arena
for y := 0; y < numTilesY; y++ {
for x := 0; x < numTilesX; x++ {
:= rand.Int() % len(arenaImages.spriteIds)
rng , _ := sprite.NewSprite(arenaImages.spriteIds[rng], Game.Atlas, &Game.Shader, 0)
spr.SetScale(tileSize)
spr:= start.Add(math.Vector2[float32]{
spritePos : float32(x) * tileSize.X,
X: float32(y) * tileSize.Y,
Y})
.SetPosition(spritePos.AddZ(-1))
spr.tiles = append(arena.tiles, spr)
arena}
}
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]{
: float32(a.numTilesX) * (a.tileSize.X),
X: float32(a.numTilesY) * (a.tileSize.Y),
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.
(a.tileSize.
Sub(math.Vector2[float32]{0.5, 0.5})).
Mul(a.GetScale().
Add(math.Vector2[float32]{0.5, 0.5})).
Mul(-1)
AddZ}
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.
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 {
:= b.CheckForCollisions()
collisions 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) {
:= float32(dt.Seconds())
fdt .SetRotation(g.GetRotation().Add(math.Vector3[float32]{0, 0, 4 * fdt}))
g:= g.GetScale()
scale := scale.X / scale.Y
aspectRatio .SetScale(scale.Sub(math.Vector2[float32]{20 * aspectRatio * fdt, 20 * fdt}))
g}
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) {
//...
:= OnGround(k.bbox)
Ground if !onGround {
(k, dt)
Fallif k.GetScale().Length() < 5 {
.Destroy()
k}
}
if onGround {
.Walker.Update(dt, k)
k}
.animation.Update(dt, k)
k}
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.