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.