Top down shooter tutorial with BlitzMax, part 2

Part 1 of the tutorial

In the second part of the top down shooter tutorial with BlitzMax we will add bullets, enemies and sound effects to the game - woohoo, that sounds exciting ;-)

Welcome back to the tutorial and to TGameElements!

Welcome to the second part of the tutorial.

I will reduce the details of explanation in this second part and only mention issues that I consider worth mentioning. Code that is similar to stuff in the first part will not be explained as detailed again.

Let's begin with enhancing the TGameElement type. I've added a function Tween() that takes the old and new position of the moving game element and the current tween value and it returns the proper position for the current place to render.
Position can be any tweening value like x or y coordinate or an angle.

I also added a boolean field dead to indicate if the game element is dead or alive. Dead game elements will not be rendered but will be removed from their appropriate list (the list of all bullets or all enemies).


Import "TCoordinate.bmx"

Type TGameElement Abstract
Field pos:TCoordinate
Field oldPos:TCoordinate
Field angle:Int
Field oldAngle:Int
Field dead:Int = false

Method Update(fixedRate:Float) Abstract

Method Render(tween:Float) Abstract

Function Tween:Float(oldpos:Float, newpos:Float, tween:Float)
'drawing pos = newpos * tween + oldpos * ( 1.0 - tween)
Local tweenpos:Float
tweenpos = newpos * tween + oldpos * (1.0 - tween)
Return tweenpos
End Function
End Type

TCoordinates also modified

If you look at TCoordinate it doesn't contain methods to set one TCoordinate instance's x and y and angle identical to those of a second TCoordinate instance.
I changed that with the new methods SetPositionLike(otherPos:TCoordinate), SetAngleLike(otherPos:TCoordinate) and SetLike(otherPos:TCoordinate).

Also added are two more methods:
Normalize() treats a TCoordinate instance as a vector and modifies x and y values so that the "length" of this vector is 1 (do you remember Pythagoras and his triangles? ;-)). This method is helpful and required for the next method.

GetDirection(toPos:TCoordinate) returns a "normalized" TCoordinate instance that points into the direction of the given TCoordinate instance toPos.
We use a direction vector to let an enemy move to the player's turret as you will see later.

So here comes the complete new TCoordinate code:


Type TCoordinate
Field x:Float
Field y:Float
Field angle:Int ' we simplify here and just use an integer for angles between 0 and 359 degrees

Function Create:TCoordinate(x:Float, y:Float, angle:Int)
Local coord:TCoordinate = New TCoordinate
coord.x = x
coord.y = y
coord.angle = angle
Return coord
End Function


Function Copy:TCoordinate(original:TCoordinate)
Local copy:TCoordinate = New TCoordinate
copy.x = original.x
copy.y = original.y
copy.angle = original.angle
Return copy
End Function

Method SetPosition(x:Float, y:Float)
Self.x = x
Self.y = y
End Method

Method SetPositionLike(otherPos:TCoordinate)
Self.x = otherPos.x
Self.y = otherPos.y
End Method

Method SetAngle(angle:Int)
self.angle = angle
End Method

Method SetAngleLike(otherPos:TCoordinate)
Self.angle = otherPos.angle
End Method

Method SetLike(otherPos:TCoordinate)
SetPositionLike(otherPos)
SetAngleLike(otherPos)
End Method

Method GetDistance:Float(otherPos:TCoordinate)
Local l1:Float = Abs(Self.x - otherPos.x)
Local l2:Float = Abs(Self.y - otherPos.y)
Return Sqr((l1 * l1) + (l2 * l2))
End Method

Method GetAngle:Int(otherPos:TCoordinate)
Local dx:Float = otherPos.x - Self.x
Local dy:Float = otherPos.y - Self.y
Return Int((ATan2(dy:Float, dx:Float) + 360) Mod 360)
End Method

Rem
BBDoc: calculate a normalized direction vector pointing from self to given toPos
EndRem
Method GetDirection:TCoordinate(toPos:TCoordinate)
Local xdiff:Float = toPos.x - Self.x
Local ydiff:Float = toPos.y - Self.y
Local angle:Int = Self.GetAngle(toPos)
Local dir:TCoordinate = TCoordinate.Create(xdiff, ydiff, angle)
dir.Normalize()
Return dir
End Method

Rem
BBDoc: normalizes this vector
endrem
Method Normalize()
Local magnitude:Float = Sqr(Self.x * Self.x + Self.y * Self.y)
Self.x = Self.x / magnitude
Self.y = Self.y / magnitude
End Method

End Type

Bullets here we come

It's about time to introduce some weapon stuff.

For this we'll add a TBullet type.

It looks like this:


Import "TGameElement.bmx"
Import "TCoordinate.bmx"

Incbin "gfx/whitecircle.png"

Type TBullet Extends TGameElement

Global bulletImg:TImage
Global allBullets:TList = New TList
Global defaultspeed:Float = 3.0 ' with a logical framerate of 100 and a speed of 3.0 our bullets move 300 pixel per second

Field dir:TCoordinate
Field speed:Float
Field shotBy:Int

Function Create:TBullet(pos:TCoordinate, direction:TCoordinate, speed:Float)
Local bullet:TBullet = New TBullet
bullet.pos = TCoordinate.Copy(pos)
bullet.oldPos = TCoordinate.Copy(pos)
If bulletImg = Null
bulletImg = LoadImage("incbin::gfx/whitecircle.png")
End If
bullet.angle = direction.angle
bullet.dir = direction
bullet.speed = speed
allBullets.AddLast(bullet)
Return bullet
End Function


Method Update(fixedRate:Float)
' move in direction dir with given bullet speed
Self.oldPos.SetLike(Self.pos) ' store current pos in oldPos
Self.pos.x:+(Self.dir.x * Self.speed)
Self.pos.y:+(Self.dir.y * Self.speed)

If (Self.pos.x < 0 Or Self.pos.y < 0 Or Self.pos.x > GraphicsWidth() Or Self.pos.y > GraphicsHeight() or self.dead)
' delete this bullet
allBullets.Remove(Self)
End If
End Method


Method Render(tween:Float)
SetRotation(Self.angle)
' calculate proper tween position here
SetColor(255, 0, 0)
DrawImage(bulletImg, TGameElement.Tween(oldpos.x, pos.x, tween), TGameElement.Tween(oldpos.y, pos.y, tween))
SetRotation(0)
SetColor(255, 255, 255)
End Method


Function UpdateAllBullets(fixedRate:Float)
For Local bullet:TBullet = EachIn allBullets
bullet.Update(fixedRate)
Next
End Function


Function RenderAllBullets(tween:Float)
For Local bullet:TBullet = EachIn allBullets
bullet.Render(tween)
Next
' DrawText "Nr of bullets " + allBullets.Count(), 10, 400
End Function
End Type

As TBullet inherits from TGameElement and uses TCoordinates we import those two files. And we use an image for the bullets so we IncBin that.

Every TBullet instance has a direction (dir) it's flying to and a speed and should know who shot it (an enemy or the player, not used yet). So those are fields.
The bulletImg, the defaultSpeed and the list allBullets containing all TBullet instances are used by all TBullet instances so they are defined as Global.

The Create method is not very spectacular: it takes the starting position, the direction and speed as parameters and simply initializes a new TBullet instance with those values.

Because TBullet inherits from TGameElement we implement an Update and a Render method.
An important part of the Update method is the code that removes a bullet from the list of all active bullets if it leaves the screen or is marked as dead.

The Render method uses the TGameElement.Tween function to properly calculate the tweened coordinates where the bullet is to be drawn.

The functions UpdateAllBullets and RenderAllBullets are declared as Functions so that they can be called from everywhere (for example in the main loop of our ComeGetMe.bmx as we will see later.

Not very complicated at all, right?

So let's proceed with the enemies.

Enemies are eeeeevil!

Of course they are - this is a computer game :LOL:

Code wise enemies are not very different from bullets. Strange isn't it?

Let's get on with it.


Import "TGameElement.bmx"
Import "TCoordinate.bmx"
Import "TBullet.bmx"

Incbin "gfx/soldier.png"

Type TEnemy Extends TGameElement
Global enemyImg:TImage
Global allEnemies:TList = New TList
Global defaultspeed:Float = 1.0 ' with a logical framerate of 100 and a speed of 1.0 our enemies move 100 pixel per second
Global frameChange:Int = 20 ' with a logical framerate of 100 we change the animation frame every fifth of a second
Global frameCount:Int = 0
Global frameInc:Int = 1

Field dir:TCoordinate
Field speed:Float
Field frame:Int = 0 ' first frame of animated image that we show

Function Create:TEnemy(pos:TCoordinate, direction:TCoordinate, speed:Float)
Local enemy:TEnemy = New TEnemy
enemy.pos = TCoordinate.Copy(pos)
enemy.oldPos = TCoordinate.Copy(pos)
If enemyImg = Null
enemyImg = LoadAnimImage("incbin::gfx/soldier.png", 40, 40, 0, 3)
End If
enemy.angle = direction.angle
enemy.dir = direction
enemy.speed = speed
allEnemies.AddLast(enemy)
Return enemy
End Function


Method Update(fixedRate:Float)
' move in direction dir with given enemy speed
Self.oldPos.SetLike(Self.pos) ' store current pos in oldPos
Self.pos.x:+(Self.dir.x * Self.speed)
Self.pos.y:+(Self.dir.y * Self.speed)
Self.frameCount:+1
If Self.frameCount > Self.frameChange
Self.frameCount = 0
Self.frame:+Self.frameInc
If Self.frame > 2
Self.frame = 0
Self.frameInc = -Self.frameInc
End If
If Self.frame < 0
Self.frame = 2
Self.frameInc = -Self.frameInc
End If
End If

' check for collisions with bullets
For Local bullet:TBullet = EachIn TBullet.allBullets
If ImagesCollide(Self.enemyImg, Self.pos.x, Self.pos.y, Self.frame, bullet.bulletImg, bullet.pos.x, bullet.pos.y, 0)
Self.dead = True
bullet.dead = True
End If
Next

If (Self.pos.x < 0 Or Self.pos.y < 0 Or Self.pos.x > GraphicsWidth() Or Self.pos.y > GraphicsHeight() Or Self.dead)
' delete this enemy
allEnemies.Remove(Self)
End If

End Method


Method Render(tween:Float)
SetRotation(Self.angle)
' calculate proper tween position here
SetColor(255, 255, 255)
DrawImage(enemyImg, TGameElement.Tween(oldpos.x, pos.x, tween), TGameElement.Tween(oldpos.y, pos.y, tween), Self.frame)
SetRotation(0)
SetColor(255, 255, 255)
End Method


Function UpdateAllEnemies(fixedRate:Float, playerPos:TCoordinate)
If allEnemies.Count() = 0
Local randomvalue = Rand(0, 100)
If randomvalue > 95
Local borderPos:TCoordinate = GetRandomBorderPos()
Local dirToPlayer:TCoordinate = borderPos.GetDirection(playerPos)
TEnemy.Create(borderPos, dirToPlayer, defaultspeed)
End If
End If

For Local enemy:TEnemy = EachIn allEnemies
enemy.Update(fixedRate)
Next
End Function

Function GetRandomBorderPos:TCoordinate()
Local border:Int = Rand(1, 4)
Local pos:TCoordinate, xp:Int, yy:Int

Select border
Case 1 'from the top
yp = 0
xp = Rand(0, GraphicsWidth() - 40)
Case 2 'from below
yp = GraphicsHeight()
xp = Rand(0, GraphicsWidth() - 40)
Case 3 ' from the left
xp = 0
yp = Rand(0, GraphicsHeight() - 40)
Case 4 ' from the right
xp = GraphicsWidth()
yp = Rand(0, GraphicsHeight() - 40)
End Select
pos = TCoordinate.Create(xp, yp, 0)
Return pos
End Function


Function RenderAllEnemies(tween:Float)
For Local enemy:TEnemy = EachIn allEnemies
enemy.Render(tween)
Next
' DrawText "Nr of enemies " + allEnemies.Count(), 10, 430
End Function

End Type

Because of inheritance and usage we import the obvious files. TBullet.bmx is imported because we want to check for collisions later on so we'd better know about bullets.
Similar to the TBullets we have some globals for the enemy image, the default speed and the list of all enemies on screen.
Additionally we need some globals because the enemy is an animated one and we need to store information about the number of animation frames and the step amount we'll use to find the next frame image.
Of course each instance of TEnemy needs to have it's own direction, speed and the current frame that is displayed so those are fields of the type.

Let's have a look at the functions and methods:

  • Create:TEnemy(pos:TCoordinate, direction:TCoordinate, speed:Float) Nothing special here; allocating and initializing a new TEnemy instance and adding it to the global list of all enemies (allEnemies)
  • Update(fixedRate:Float) Here we move the enemy instance based on it's speed. Then we check if the next animation frame needs to be shown. We traverse the animation array back and forth to get a proper animation sequence. Afterwards we check for collisions with bullets. To do so we get the list of all bullets and check each bullet against the current frame of our enemy using the BlitzMax builtin function ImagesCollide(). If we have a collision we mark the enemy and the bullet as dead so that they will be removed in their Update methods. Finally we check if the enemy left the screen or was marked as dead. If that's the case we remove it from the global list.
  • Render(tween:Float) As usual: setting proper rotation of the enemy image, calculating tweened position and drawing of the current animation frame of the enemy instance. Easy going.
  • UpdateAllEnemies(fixedRate:Float, playerPos:TCoordinate) The convenience function not only traverses the global list and calls Update() for each enemy, it also generates a new enemy if the list of enemies is empty.
  • GetRandomBorderPos:TCoordinate() This is the function that helps to find a random start position for a new enemy. First a random value between 1 and 4 is created to decide on which border of the screen the enemy shall appear. Then we randomly generate the second missing coordinate value to decide where on the selected border the enemy should appear. The randomly generated coordinate is returned.
  • RenderAllEnemies(tween:Float) Call Render() for all enemy instances to have them drawn.

So even if the TEnemy is the biggest type currently it's not too complicated.

Let's have a look at the modified TPlayer where we teach the player to shoot.

Shoot 'em up!

Let's start with the complete TPlayer.bmx file:


rem
bbdoc: Type description
end rem

Import "TGameElement.bmx"
Import "TCoordinate.bmx"
Import "TBullet.bmx"

Incbin "gfx/turret.png"

Type TPlayer Extends TGameElement
Global playerImg:TImage

Field dir:TCoordinate


Function Create:TPlayer(pos:TCoordinate)
Local player:TPlayer = New TPlayer
player.pos = pos
player.oldPos = TCoordinate.Copy(pos)
If playerImg = Null
playerImg = LoadImage("incbin::gfx/turret.png")
End If
Return player
End Function


Method Update(fixedRate:Float)
Local mousePos:TCoordinate
mousePos = TCoordinate.Create(MouseX(), MouseY(), 0)
' let's rotate to always look at the mouse
Self.oldAngle = Self.angle
Self.angle = pos.getAngle(mousePos)
Self.dir = Self.pos.GetDirection(mousePos)
If MouseHit(1)
'create a bullet
TBullet.Create(Self.pos, TCoordinate.Copy(self.dir), TBullet.defaultspeed)
End If
End Method


Method Render(tween:Float)
SetRotation(self.angle)
DrawImage(playerImg, pos.x, pos.y)
SetRotation(0)
rem
If dir <> Null
DrawText ("angle: " + dir.angle, 0, 100)
DrawText ("x dir: " + dir.x, 0, 120)
DrawText ("y dir: " + dir.y, 0, 140)
EndIf
end rem
End Method
End Type


What's new now?
The Import of TBullet.bmx is required because the player needs to know about bullets if he is ever going to shoot, right? No bullets, no shooting. Yo mama.

A field named dir is also new. It will be used to calculate the direction from the player's position to the mouse cursor position. Because that is not only the direction we're looking at but also the direction we want to shoot. And that's what the Create method of the bullet needs!

The Update() method is changed of course. We're constantly tracking the current mouse position and storing it in some TCoordinate instance named mousePos. If the left mouse button is pressed we create a new TBullet instance that moves into the direction from the player to the mouse cursor.

The Render() method didn't change at all except the new lines of code between the lines rem and end rem. Those were only included for debugging to keep track of the changing direction that's following the mouse cursor. You can safely ignore and delete those lines, including the rem and end rem lines.

Done with the player. There's only one thing left, the updated ComeGetMe.bmx that glues everything together. Let's finish it.
TO BE CONTINUED WHEN I'M AWAKE AGAIN ;-)

All together now!

ComeGetMe.bmx contains a few changes to let everything work together: the player, the enemies and the bullets.

Again, let's first look at the complete source code:


SuperStrict

Import "TFRLTimer.bmx"
Import "TPlayer.bmx"
Import "TBullet.bmx"
Import "TEnemy.bmx"

AppTitle = "BlitzMax Top down shooter tutorial"

Const GFX_WIDTH:Int = 640, GFX_HEIGHT:Int = 480

Const UPDATE_FREQUENCY:Float = 100.0, SPIKE_SUPPRESSION:Int = 20

Global gameTime:TFRLTimer = New TFRLTimer.CreateFRL(UPDATE_FREQUENCY, SPIKE_SUPPRESSION) '1st number is logic updates per second and 2nd number is how much spike suppression you want

Global player:TPlayer = TPlayer.Create(TCoordinate.Create(GFX_WIDTH / 2, GFX_HEIGHT / 2, 0))

Graphics GFX_WIDTH, GFX_HEIGHT, 0

SetBlend ALPHABLEND

AutoMidHandle(True)

SeedRnd MilliSecs()

SetClsColor(200, 100, 100)

' the main loop starts here
While Not KeyDown(KEY_ESCAPE) And AppTerminate() = False
Cls

' update part of the main loop with constant speed
Local delta:Float = gameTime.ProcessTime()
While gameTime.LogicUpdateRequired()
DoGameLogic(gameTime.GetLogicFPS()) 'Update Game Logic Here
Wend

' rendering with additional tweening
Local tween:Float = gameTime.GetTween() 'calc the tween for smooth graphics
doGameRender(tween) 'Render Game Here

Flip 0 ' synchronize graphics buffer as soon as possible
Wend
EndGraphics
End

Function DoGameLogic(fixedRate:Float)
player.Update(fixedRate)
TEnemy.UpdateAllEnemies(fixedRate, player.pos)
TBullet.UpdateAllBullets(fixedRate)
End Function

Function DoGameRender(tween:Float)
player.Render(tween)
TEnemy.RenderAllEnemies(tween)
TBullet.RenderAllBullets(tween)
gameTime.ShowFPS(0, 0)
End Function

Of course we need to update our list of Imports, we add TBullet.bmx and TEnemy.bmx.

The AppTitle is a global variable that is used at some places internally by BlitzMax and allows us to set the name of our game into the title bar of the game's window. Cool!


AppTitle = "BlitzMax Top down shooter tutorial"

Another line is important for games:


SeedRnd MilliSecs()


The random numbers that a BlitzMax application (and any other application too) is based on a very long sequence of random values. Whenever you call rnd() it returns the next value of this sequence.
The command SeedRnd allows you to initialize the start position of this sequence with any value. A good one is the current time in milliseconds (MilliSecs()) as it is different whenever you start the game. This makes it look like the game really behaves randomly.

The new line


SetClsColor(200, 100, 100)


is another builtin function of BlitzMax and tells BlitzMax what color to use whenever you call Cls to clear (and fill) the screen. The default color is black (0,0,0) and we set it to some terrible orange/pink/whatever color to see the enemies better. Change this to a value you can bear :LOL:

The last code change is in these two functions:


Function DoGameLogic(fixedRate:Float)
player.Update(fixedRate)
TEnemy.UpdateAllEnemies(fixedRate, player.pos)
TBullet.UpdateAllBullets(fixedRate)
End Function

Function DoGameRender(tween:Float)
player.Render(tween)
TEnemy.RenderAllEnemies(tween)
TBullet.RenderAllBullets(tween)
gameTime.ShowFPS(0, 0)
End Function

In DoGameLogic() we added the functions to update all enemies and all bullets and in DoGameRender() we added the similar stuff to render all enemies and all bullets.

And that's about it.

What is still missing?

  • Sound. Whenever the player fires a bullet we want to hear a sound. Also some explosion or hit sound would be nice when a bullet hits an enemy.
  • Some scoring would be nice and some code to deal with something like levels where the number of approaching enemies increases, the enemies become faster and so on.
  • A game over functionality to restart the game when the player is hit by an enemy and to show the current high score for example.

This will come next.

But for now you can download the second part of the tutorial with all sources and graphics we've used so far.

Come Get Me part two

Enjoy the silence!

Or not ;-)

Let's add some sound effects to the game.

TO BE CONTINUED WHEN TIME ALLOWS AGAIN!