In this tutorial we will begin implementing our Knight vs Trolls game. We will build on the demo code we wrote for previous tutorials. Our goal for this tutorial is to introduce enemies to the game (trolls!) and to arm our knight with a sword so they can defend themselves.
Our troll enemy is very similar to the knight. It has an animation component with two clips: an idle animation and a run animation. It also has a bounding box which will be used to have the troll hit the knight and vice versa.
type Troll struct {
.Animation
animation game, runClip int
idleClip*game.BoundingBox
hurtbox .GameObjectCommon
game}
For our initialization, we use must load the troll sprites into the sprite atlas making sure we only do that once. In previous tutorials we used the following structure to do this:
var coinFrames = []string{
"data/coin_anim_f0.png",
"data/coin_anim_f1.png",
"data/coin_anim_f2.png",
"data/coin_anim_f3.png",
}
var coinClip = []int{}
func NewCoin(position math.Vector2[float32]) *Coin {
if len(coinClip) == 0 {
, _ = Game.Atlas.AddImagesFromFiles(coinFrames)
coinClip}
//...
}
We use this mechanism on every game object we create so it
makes sense to organize it a little. The ClipData
object does exactly this. It holds a list fo images that make up a
clip, loads them into the game atlas and stores the corresponding
sprite ids. Crucially, it will only load the data once even if
LoadOnce
is called more than once.
type ClipData struct {
[]string
Images []int
spriteIds }
func NewClipData(images []string) ClipData {
return ClipData{
: images,
Images}
}
func (c *ClipData) LoadOnce(atlas *sprite.Atlas) error {
if len(c.spriteIds) != 0 {
return nil
}
var err error
.spriteIds, err = atlas.AddImagesFromFiles(c.Images)
creturn err
}
We can use ClipData
to load our Troll’s animation
frames with the following code. By using ClipData
the
initialization of each game object becomes less cluttered.
var trollIdleClip = NewClipData([]string{
"data/ogre_idle_anim_f0.png",
"data/ogre_idle_anim_f1.png",
"data/ogre_idle_anim_f2.png",
"data/ogre_idle_anim_f3.png",
})
var trollRunClip = NewClipData([]string{
"data/ogre_run_anim_f0.png",
"data/ogre_run_anim_f1.png",
"data/ogre_run_anim_f2.png",
"data/ogre_run_anim_f3.png",
})
func NewTroll(position math.Vector2[float32]) *Troll {
:= &Troll{}
troll .animation = game.NewAnimation()
troll.LoadOnce(Game.Atlas)
trollIdleClip.LoadOnce(Game.Atlas)
trollRunClip.idleClip = troll.animation.AddClip(trollIdleClip.spriteIds, Game.Atlas, &Game.Shader, 0)
troll//...
}
Our troll’s behavior will be very simple. They will idle doing nothing until they sense an enemy. Once they do, they will run straight at them and try to hit them. To implement this we need a way for the trolls to ‘sense’ enemies. We can achieve this with a couple of methods. Perhaps the easiest it to pass a pointer to the game object that the troll needs to sense (for example our knight) and then take the distance of the knight and the troll. If the distance is smaller than a threshold, we sense the enemy.
func (t *Troll) Update(dt time.Duration) {
if t.GetTranslation().Sub(t.enemy.GetTranslation()).Length() < 50 {
// attack!
}
//...
The problem with this approach is providing access to the enemy
game object t.enemy
. If the enemy is known
beforehand, like our knight, this is easy to do. But, if we want
our troll to dynamically respond to any enemy this doesn’t
work.
Instead, we will use the collision system to sense enemies. We
will add a second bounding box to our troll and make it much
bigger than its hurtbox. The following shows the troll’s hurtbox
in blue and it’s vision box
in red.
Once a game object enters that bounding box we can check it’s tags to figure out if it’s an enemy and act accordingly:
func (t *Troll) Update(dt time.Duration) {
:= t.visionBox.CheckForCollisions()
seen for i := range seen {
if game.HasTag(seen[i], TagKnight) {
:= seen[i].GetTranslation().XY()
destination .SetDestination(destination)
t}
}
Notice that we only consider knight as the enemy here but that can be easily changed by adding more tags.
For our attack, we will for now just walk towards our enemy. To make our Troll walk we can reuse the logic we have for our knight’s movement. For the knight we set a destination using the mouse and our knight walks there. For the troll, we can set the destination to be the enemy’s position.
To implement the movement we could copy the code from knight
but we are likely to have other moving game objects so it makes
sense to put the moving logic in a component. We call this
component Walker
.
type Walker struct {
.Vector2[float32]
destination mathbool
hasDestination
float32
Speed }
func (w *Walker) Update(dt time.Duration, parent game.GameObject) {
if !w.hasDestination {
return
}
if parent.GetTranslation().Sub(w.destination.AddZ(0)).Length() < math.EpsilonVeryLax {
.hasDestination = false
wreturn
}
:= float32(dt.Seconds())
fdt := parent.GetTranslation()
parentPosition := w.destination.Sub(parentPosition.XY()).Normalize()
moveVector .Println(parentPosition, w.destination, moveVector, fdt)
fmt.SetTranslation(parentPosition.Add(moveVector.Scale(w.Speed * fdt).AddZ(parentPosition.Z)))
parentif moveVector.X > 0 {
.faceRight(parent)
w} else {
.faceLeft(parent)
w}
}
func (w *Walker) SetDestination(destination math.Vector2[float32]) {
.hasDestination = true
w.destination = destination
w}
func (w *Walker) faceLeft(parent game.GameObject) {
.SetRotation(math.Vector3[float32]{0, 3.14, 0})
parent}
func (w *Walker) faceRight(parent game.GameObject) {
.SetRotation(math.Vector3[float32]{0, 0, 0})
parent}
func (w *Walker) IsMoving() bool {
return w.hasDestination
}
The Walker
component receives a destination with
SetDestination
and it moves towards it constantly
until it comes close to it and stops. We detect that we arrive at
our destination by taking the distance of our character and the
destination:
if parent.GetTranslation().Sub(w.destination.AddZ(0)).Length() < math.EpsilonVeryLax {
.hasDestination = false
w}
To move the character, we create a vector from the character to the destination:
:= parent.GetTranslation()
parentPosition := w.destination.Sub(parentPosition.XY()).Normalize() moveVector
We then scale this vector by the movement speed. This lets us
use the same component for characters with various speeds. The
speed is modulated by the delta time (fdt) so it runs consistently
across machines. See this tutorial
if you are not sure why we multiply by fdt
.
.SetTranslation(parentPosition.Add(moveVector.Scale(w.Speed * fdt).AddZ(parentPosition.Z))) parent
To make our trolls walkers we simply add the component:
type Troll struct {
.Animation
animation game, runClip int
idleClip, visionBox *game.BoundingBox
hurtbox
// NEW
Walker .GameObjectCommon
game}
And the complete Update function of our troll becomes:
func (t *Troll) Update(dt time.Duration) {
:= t.visionBox.CheckForCollisions()
seen for i := range seen {
if game.HasTag(seen[i], TagKnight) {
:= seen[i].GetTranslation().XY()
destination .SetDestination(destination)
t}
}
if t.IsMoving() {
.animation.SetClip(t.runClip)
t} else {
.animation.SetClip(t.idleClip)
t}
.Walker.Update(dt, t)
t.animation.Update(dt, t)
t.hurtbox.Update(dt, t)
t.visionBox.Update(dt, t)
t}
Below you can see a Troll chasing our helpless knight around.
The trolls are becoming dangerous so our knight needs a weapon
to defend themselves. A sword will do nicely. Sword
is a game object that can be added as a child of another game
object. It has a single sprite and we will animate it by rotating
and moving it with code. This is a bit of an unorthodox approach,
it would be easier to make an animation sequence rather than move
a single sprite with code but let’s do it anyway for educational
purposes.
The Sword
is given by the following struct:
type Sword struct {
*sprite.Sprite
sprite .Vector3[float32]
offset math.Vector3[float32]
attackOffset mathbool
midSwing float32
swingDuration float32
swingTime *game.BoundingBox
hitbox .GameObjectCommon
game}
As mentioned it has a single sprite and a bounding box
(hitbox
) is used to detect when the sword hits
something. The rest of the parameters control the animation of the
sword as it swings. offset
is the distance of the
sword from it’s holder. We don’t want the sword to appear directly
on top of our knight so we will use this value to move it
slightly. attackOffset
is the position of the sword
after it has been swung. The following shows offset
in green and attackOffset
in red. In our animation,
the weapon will also be rotated in the attackOffset
position.
To implement the sword swing we will move the sword’s position
from offset
to attackOffset
and also
rotate it by about 90 degrees. The sword begins to swing once it’s
midSwing
variable is set to true.
func (s *Sword) Swing() {
if !s.midSwing {
.midSwing = true
s.Play(0)
swingEffect}
}
In update, we interpolate the position and rotation of the
sword while midSwing
is true.
func (s *Sword) Update(dt time.Duration) {
:= s.offset
position := math.Vector3[float32]{}
rotation if s.midSwing {
:= float32(dt.Seconds())
t := s.swingTime / s.swingDuration
delta = s.offset.Lerp(s.attackOffset, delta)
position = math.Vector3[float32]{0, 0, 0}.Lerp(math.Vector3[float32]{0, 0, -1.57}, delta)
rotation .swingTime += t
sif s.swingTime > s.swingDuration {
.midSwing = false
s.swingTime = 0
s}
}
We use delta time (dt
) to calculate how far in the
animation we are
(delta := s.swingTime / s.swingDuration
). We use this
delta
parameter, which will take values between 0 and
1, as the parameter to guide our interpolation. When delta is 0
the sword will be at offset
and it’s rotation will be
zero. When delta is 1 it will be at attackOffset
and
its rotation will be -1.57 radians (90 degrees). In-between values
of delta will give in-between values of position and rotation.
We need to apply these values to the sword which we do with the following code.
= game.Transform(s.GetParent(), position)
position .Z += 1 // bring to front of parent
position.SetTranslation(position)
s.SetScale(s.GetParent().GetScale().Mul(math.Vector2[float32]{0.7, 0.7}))
s.sprite.SetPosition(position)
s.sprite.SetScale(s.GetParent().GetScale().Mul(math.Vector2[float32]{0.7, 0.7}))
s.sprite.SetRotation(rotation.Add(s.GetParent().GetRotation()))
s.hitbox.Update(dt, s) s
The tricky part in this code is the first line. The
Transform
function modifies the position of the sword
to match that of the parent. It does this using matrix
multiplication and ensures that the sword stays in front of it’s
parent even if the parent rotates around1.
In Update we also check for collisions between the sword and
any potential enemies. We only do this when the sword is
midSwing
and only after it has done half of the
animation.
// sword only hurts midSwing
if s.midSwing && s.swingTime > 0.5*s.swingDuration {
:= s.hitbox.CheckForCollisions()
collisions for i := range collisions {
if game.HasTag(collisions[i], TagEnemy) {
.Knockback(collisions[i])
s}
}
}
If the sword hits some enemy it knocks them back using this function:
func (s *Sword) Knockback(g game.GameObject) {
:= g.GetTranslation()
gpos := s.sprite.GetPosition()
spos := gpos.XY().Sub(spos.XY())
vector = vector.Normalize()
vector = vector.Scale(40)
vector = gpos.Add(vector.AddZ(0))
gpos .SetTranslation(gpos)
g}
The code calculates a vector between the sword and the enemy hit and translates (moves) the enemy across that vector a fixed distance.
As mentioned, the implementation for the sword swing is rather
complicated. It’s meant to show how we can use transforms to move
a game object relative to another. In this case, the sword moves
with the parent and stays in front of the parent even when they
rotate left and right. The same thing can be achieved more easily
with other techniques. The sword swing can be animated with a
sequence of sprites. Keeping the sword in front of the player can
be done with an if
statement that checks the parent’s
rotation value. It might be good practice to re-write the sword
implementation with this approach.