Game Finishing and Packaging Tutorial – Step by Step with Flame and Flutter (Part 5 of 5)
In this part of the tutorial series, we will focus on adding graphics and animation. We’ll pick up where we left off in the previous part where we have created an interactive-enough casual mobile game.
The game will stay the same in features but with more movement and better graphics.
Like the previous parts, there will be a demo video in the end. If you haven’t read the previous part, now is a good time to check it out.
Here’s the whole series:
- Introduction to game development
- Set up a playable game
- Graphics and animation (you are here)
- Views and dialog boxes
- Scoring, storage, and sound
- Finishing up and packaging
Prerequisites
- All the requirements on the first part of the tutorial series.
- Graphics assets – Graphics assets can be found all over game resource sites on the internet (Open Game Art for example). Just make sure to credit the makers.
We will be using the rules from the previous part regarding code (no optional keywords and annotations like @override
and new
) and file path referencing (./
refers to the project directory).
What will change though, is the screenshot for every little code change. We will use it sparingly and at a higher screen resolution (smaller text) to accommodate the files becoming too long.
All the code for this tutorial is available for viewing and download on this GitHub repository.
Graphics Assets
As you can see on the image above from the previous part, we already had a graphics asset for our flies. That is a fly image from Open Game Art. It’s licensed as CC0
which is equivalent to the public domain. Assets in the public domain are free to use for any purpose.
But we won’t be using that. We’ll be using this resource pack.
Click the image above or this link to download!
Important Note: The resource pack above can be used if you’re following along with this tutorial. It is part of the Langaw project on GitHub which is licensed with a CC-BY-NC-ND
license.
It means that you can share, copy, and redistribute the material in any medium or format with some restrictions.
- You must give appropriate credit, provide a link to the license, and indicate if changes were made.
- You may not use the material for commercial purposes.
- If you remix, transform, or build upon the material, you may not distribute the modified material.
- You may not apply legal terms or technological measures that legally restrict others from doing anything the license permits.
Learn more about the CC-BY-NC-ND license by clicking here.
Let’s continue building the game
We ended the previous part of the tutorial with a playable game where you tap on a fly, it drops, and a new fly spawns.
We won’t be adding much to it but we’ll totally change the presentation. Good graphics can go a long way in boosting the playability of even a simple game.
Step 1: Add the graphics assets
First, download the resource pack that contains the assets we need if you haven’t done it yet.
Alternatively, you can choose to make your own graphics assets or acquire them from resource sites like OpenGameArt.org.
Note: If you open the resource pack, you’ll notice that there are five different types of flies and each has three files associated with it. This is because we’ll later have five different types of flies with different abilities. More on this in the later sections.
Adding the assets to the Flame game
Create a directory in the project root and name it assets
. Under this newly created folder (./assets
) create another folder and name it images
.
The only requirement Flame imposes on our file structure is that image assets must be inside ./assets/images
.
From this point, we can arrange our files/assets any way we like. We can also just dump them all there since we’re building a rather simple game anyway.
But that’s not good development practice!
Let’s organize a bit: paste the fly files inside a folder (./assets/images/flies
) and put the background file in another (./assets/images/bg
).
With that, you should have the following under ./assets/images
:
./assets
./assets/images
./assets/images/bg
./assets/images/bg/backyard.png
./assets/images/flies
./assets/images/flies/agile-fly-1.png
./assets/images/flies/agile-fly-2.png
./assets/images/flies/agile-fly-dead.png
./assets/images/flies/drooler-fly-1.png
./assets/images/flies/drooler-fly-2.png
./assets/images/flies/drooler-fly-dead.png
./assets/images/flies/house-fly-1.png
./assets/images/flies/house-fly-2.png
./assets/images/flies/house-fly-dead.png
./assets/images/flies/hungry-fly-1.png
./assets/images/flies/hungry-fly-2.png
./assets/images/flies/hungry-fly-dead.png
./assets/images/flies/macho-fly-1.png
./assets/images/flies/macho-fly-2.png
./assets/images/flies/macho-fly-dead.png
Registering the assets to Flutter
Before we can use any of these assets, we should tell Flutter that we want these files to be included when bundling the app (running or building the final APK for distribution).
To do that we must specify the files in ./pubspec.yaml
. Find the flutter
section and add assets
sub-section beneath it.
Below the assets sub-section, list all the files that we just added in the assets folder:
flutter:
uses-material-design: true
assets:
- assets/images/bg/backyard.png
- assets/images/flies/agile-fly-1.png
- assets/images/flies/agile-fly-2.png
- assets/images/flies/agile-fly-dead.png
- assets/images/flies/drooler-fly-1.png
- assets/images/flies/drooler-fly-2.png
- assets/images/flies/drooler-fly-dead.png
- assets/images/flies/house-fly-1.png
- assets/images/flies/house-fly-2.png
- assets/images/flies/house-fly-dead.png
- assets/images/flies/hungry-fly-1.png
- assets/images/flies/hungry-fly-2.png
- assets/images/flies/hungry-fly-dead.png
- assets/images/flies/macho-fly-1.png
- assets/images/flies/macho-fly-2.png
- assets/images/flies/macho-fly-dead.png
Note: Mind the indentation on pubspec.yaml
. This file uses two spaces for each level of indentation. Also, make sure that the file names are correct. It’s a good idea to run the game so the compiler checks if you have the files that you just specified. It will show an error message if there’s a typo or if it can’t find the file.
Preloading the assets when starting the game
Since we’re developing a small casual game, we can skip the loading screens every time the player goes to another room, joins a game, changes outfit, etc.
We don’t have those.
Just the background and a few flies in our assets list.
Knowing that, we can just load all assets when the game is started. Sure it will show a black screen for a few milliseconds when the game is started. But who notices a few milliseconds right?
Open up ./lib/main.dart
and import the Flame package by adding the following line on the very top of the file:
import 'package:flame/flame.dart';
Then inside the main
function, add the following block of code just after setting the game to fullscreen and setting the screen orientation.
Flame.images.loadAll(<String>[
'bg/backyard.png',
'flies/agile-fly-1.png',
'flies/agile-fly-2.png',
'flies/agile-fly-dead.png',
'flies/drooler-fly-1.png',
'flies/drooler-fly-2.png',
'flies/drooler-fly-dead.png',
'flies/house-fly-1.png',
'flies/house-fly-2.png',
'flies/house-fly-dead.png',
'flies/hungry-fly-1.png',
'flies/hungry-fly-2.png',
'flies/hungry-fly-dead.png',
'flies/macho-fly-1.png',
'flies/macho-fly-2.png',
'flies/macho-fly-dead.png',
]);
Breakdown: This is actually just one line expanded vertically for readability. A list of String
s is passed as the parameter to images
‘ loadAll
method. This method pre-loads the image files that the list of String
s pointed to.
These images are cached in a static variable in Flame so they can be reused later.
View the code at this step on GitHub.
Step 2: Change the background
Our background is just a boring solid color. It’s an awesome color but game backgrounds are supposed to be more than just that.
This step is about changing that.
This is backyard.png
from the resource pack.
As you can see the image is vertically long. This is because we only care about the width (as discussed in the previous part). The phone can be as long as it can reasonably be and our background will still cover the entire screen.
A background component
It’s probably a good idea to separate the background’s logic to its own component.
So let’s create a component file ./lib/components/backyard.dart
.
import 'dart:ui';
import 'package:flame/sprite.dart';
import 'package:langaw/langaw-game.dart';
class Backyard {
final LangawGame game;
Sprite bgSprite;
Backyard(this.game) {
bgSprite = Sprite('bg/backyard.png');
}
void render(Canvas c) {}
void update(double t) {}
}
This file declares a class Backyard
that has a constructor and two other methods (like the game loop and the fly component). We won’t be using the update
method for now but let’s just put it there so we can use it later.
The class has one final LangawGame
instance variable that will act as the link to the game instance (and its properties) containing this component. It functions very much like the one in ./lib/components/fly.dart
.
The other instance variable is a Sprite
called bgSprite
. This variable holds the sprite data that will be drawn to the screen later.
Inside the constructor, we initialize the bgSprite
variable by creating a new Sprite
and passing on the file name of the asset we want to use. This file has already been loaded in ./lib/main.dart
so it’s ready to use without any loading time.
Note: Like other files in this project, we have import statements at the top. Importing dart:ui
gives us access to the Canvas
class_. While importing sprite.dart
from the flame
package allows us to use the Sprite
class. Lastly, importing langaw-game.dart
gives us access to the LangawGame
class_.
Sizing Explanation
If you open the background image in Photoshop (or any other graphics tool), you’ll realize that it’s 1080 x 2760
pixels.
We don’t need to be bothered with physical pixels when using Flutter and we won’t even be bothered with logical pixels. All we should care about is the background image is nine tiles wide.
1080 _pixels_ ÷ 9 _tiles_ = 120 _pixels per tile_
2760 _pixels_ ÷ 120 _pixels per tile_ = 23 _tiles_
As seen on the calculations above, that makes the image nine tiles wide and 23 tiles tall.
Drawing the background
Time to draw the background. We’ll anchor the bottom of the background image to the bottom of the screen of the phone.
To do this we need to define a rectangle that will hold the dimensions of the background. We need to calculate the size properly so the aspect ratio of the background is preserved during the render.
Let’s add in a Rect
instance variable called bgRect
.
Rect bgRect;
Inside the constructor, add the following block of code right below initializing the bgSprite
property:
bgRect = Rect.fromLTWH(
0,
game.screenSize.height - (game.tileSize * 23),
game.tileSize * 9,
game.tileSize * 23,
);
The breakdown: Again this is actually just one line but spread vertically for readability. The four middle lines correspond to the values for Left (x
), Top (y
), Width, and Height, respectively.
We draw the background in full width so it starts from 0
for Left (or x
) and extends to game.tileSize * 9
width. We could also use game.screenSize.width
here since game.tileSize
is equal to game.screenSize.width
divided by 9
.
We know that the background image is 9 x 23
in “tiles”. So to draw the whole image we just pass game.tileSize * 23
as the height.
Lastly, the Top (or y
) is a negative number that corresponds to the difference of the screen’s size and the background image.
If the player’s screen has an aspect ratio of 9:16
, the screen’s height would be 16 * _tile size_
. If we subtract 23 * _tile size_
from that, we get -7 * _tile size_
. It means that the background is drawn with it’s top edge seven tiles above the top edge of the screen.
With this calculation, the background image will always be anchored to the bottom of the screen.
Finally, we draw the background image when this component’s render
method is called:
bgSprite.renderRect(c, bgRect);
The file should finally look like this:
Adding the background to the game
Now that the background component is complete, let’s add it into our game logic. Open up ./lib/langaw-game.dart
.
To be able to use the Backyard
class, we need to import the class file using the following line:
import 'package:langaw/components/backyard.dart';
Then we have to add a new instance variable named background
with Backyard
as its type.
Backyard background;
Inside the initialize
method, instantiate a new Backyard
object and assign it to the background
instance variable. This must be done after the screen size is determined because the constructor uses the screen size and tile size values.
background = Backyard(this);
Just like when we’re creating flies, we pass the current instance of LangawGame
using the keyword this
.
With that, the file should look like this:
Then inside the render
method, we call background
‘s render
method and pass the Canvas
to it.
We currently have four lines that draw a rectangle with a solid color. We need to remove those and replace it with the following line:
background.render(canvas);
You should have a file that looks like this:
When you run the game you should see that the background is now applied to the game.
Looking good!
View the code at this step on GitHub.
Step 3: Change the fly
We will have a total of five different flies. For now, we will be focusing on their visual differences but will be preparing them for their functional differences too.
This can be done with sub-classing. That is creating a class (the sub-class) and extending an existing class (the superclass).
Fly sprite sizing
Let me explain something real quick. The assets in the resource pack are sized so that a single fly asset takes up half a tile more on all directions in relation to the hit rectangle (the flyRect
).
In the image above, the sprite will be drawn inside the blue square (let’s call it the sprite box) but the tap needs to be inside the red square (the hitbox, which in our code is named flyRect
).
Prepare the superclass
Before we create our first sub-class, let’s make sure that our superclass is ready to be extended.
We’ll use our existing Fly
class as our superclass so let’s open up ./lib/components/fly.dart
. The Fly
class in this file will have all the common functionalities that all flies share.
First, let’s remove the drawRect
since we won’t be drawing a solid rectangle. Empty out the render
method so it looks like this:
void render(Canvas c) {}
Then, let’s remove all references to flyPaint
. That object is only used for drawing the solid rectangle.
Remove this from the instance variables:
Paint flyPaint;
Then remove the following lines from the constructor:
flyPaint = Paint();
flyPaint.color = Color(0xff6ab04c);
After that, remove the following line from the onTapDown
handler:
flyPaint.color = Color(0xffff4757);
We would still be using flyRect
as the hit rectangle so let’s leave it in the file.
Adding sprites
For every instance of the Fly
class (or any of its subclasses), we would need to prepare and store two sets of sprites.
One set will consist of two sprites that will be shown one after the other to give the “flying” animation. We would need a List
for this.
The other set will just have one sprite that will be shown when the fly is dead.
We also need another instance variable that will store which of the sprites will be shown for the flying animation.
Let’s import sprite.dart
from Flame:
import 'package:flame/sprite.dart';
Add the following block in the instance variables section:
List<Sprite> flyingSprite;
Sprite deadSprite;
double flyingSpriteIndex = 0;
Note: The sprite variables will not be initialized in this class as each sub-class will use a different sprite.
Inside the render
method, let’s render the sprite depending on the fly’s status (dead or alive).
void render(Canvas c) {
if (isDead) {
deadSprite.renderRect(c, flyRect.inflate(2));
} else {
flyingSprite[flyingSpriteIndex.toInt()].renderRect(c, flyRect.inflate(2));
}
}
Breakdown: The render
method decides which sprite to show by checking the isDead
variable. If the current instance is dead, deadSprite
is rendered. If not, the first item in flyingSprite
List
is rendered.
As for flyingSpriteIndex.toInt()
, List
and array items are accessed by an integer index. Our flyingSpriteIndex
is a double though, so we need to convert it into an int
first. This variable is a double
because we will increment it using values from the time delta (which is a double
) in the update
method as you will see later.
The last part, .inflate(2)
, just creates a copy of the rectangle it was called on but inflated to a multiplier (in this case two) from the center. We use two as our value because if you look at the fly sizing image above, you’ll see that the blue box (sprite box) is double the size of the red box (hit box).
The Fly
class file should now look like this:
Create the first sub-class
Let’s create the first fly variant. This is the most simple variant, the most “normal” one. Let’s call it the HouseFly
.
Create a new file under ./lib/components
and name it house-fly.dart
.
Let’s open this newly created file (./lib/components/house-fly.dart
) and create our basic component class but this time extend the Fly
class.
import 'package:flame/sprite.dart';
import 'package:langaw/components/fly.dart';
import 'package:langaw/langaw-game.dart';
class HouseFly extends Fly {
HouseFly(LangawGame game, double x, double y) : super(game, x, y) {
flyingSprite = List<Sprite>();
flyingSprite.add(Sprite('flies/house-fly-1.png'));
flyingSprite.add(Sprite('flies/house-fly-2.png'));
deadSprite = Sprite('flies/house-fly-dead.png');
}
}
Breakdown: We import the packages that are needed to access the classes that our new class depends on. Then we declare a class called HouseFly
and make it extend the Fly
class, effectively making a sub-class.
Sub-classes can access and override variables and methods of their superclass.
Our constructor calls super
which tells the program to run the constructor of the superclass before executing the code inside the constructor’s body. The constructor just mirrors the parameters required by the constructor of the superclass and forwards them during the call to super
.
Inside the constructor, we initialize the flyingSprite
variable that this sub-class inherited from the Fly
class by creating a new instance of a List
of Sprite
s. Then we add two sprites to this list that correspond to the two frames of the flying animation.
Then we load the dead image of the house fly into a Sprite
and assign it to deadSprite
.
We do not override render
and update
methods, since we do not have anything specific to for this type of fly. For now, all functionality will just be the same as all the other flies.
Spawning the new fly
The spawnFly
method needs to be edited so it creates a HouseFly
instead of the superclass Fly
. Let’s open up ./lib/langaw-game.dart
.
In the imports section (top of the file), let’s import the sub-class we just made with the following line:
import 'package:langaw/components/house-fly.dart';
Then to spawn a HouseFly
instead of a Fly
, replace this line:
flies.add(Fly(this, x, y));
With this line:
flies.add(HouseFly(this, x, y));
After that, the file should look like this:
It’s time to run the game!
View the code at this step on GitHub.
Step 4. Create more fly variants
This step should be quick. We’ll add in the sub-classes for each of the other types of flies.
Note: For the following sub-sections about fly variants, I won’t be explaining or breaking the code down since they’re all basically the same with the HouseFly
class. The only difference is referencing the appropriate image filename.
Drooler Fly
Create a new class file ./lib/components/drooler-fly.dart
:
import 'package:flame/sprite.dart';
import 'package:langaw/components/fly.dart';
import 'package:langaw/langaw-game.dart';
class DroolerFly extends Fly {
DroolerFly(LangawGame game, double x, double y) : super(game, x, y) {
flyingSprite = List();
flyingSprite.add(Sprite('flies/drooler-fly-1.png'));
flyingSprite.add(Sprite('flies/drooler-fly-2.png'));
deadSprite = Sprite('flies/drooler-fly-dead.png');
}
}
Agile Fly
Create a new class file ./lib/components/agile-fly.dart
:
import 'package:flame/sprite.dart';
import 'package:langaw/components/fly.dart';
import 'package:langaw/langaw-game.dart';
class AgileFly extends Fly {
AgileFly(LangawGame game, double x, double y) : super(game, x, y) {
flyingSprite = List();
flyingSprite.add(Sprite('flies/agile-fly-1.png'));
flyingSprite.add(Sprite('flies/agile-fly-2.png'));
deadSprite = Sprite('flies/agile-fly-dead.png');
}
}
Macho Fly
Create a new class file ./lib/components/macho-fly.dart
:
import 'package:flame/sprite.dart';
import 'package:langaw/components/fly.dart';
import 'package:langaw/langaw-game.dart';
class MachoFly extends Fly {
MachoFly(LangawGame game, double x, double y) : super(game, x, y) {
flyingSprite = List();
flyingSprite.add(Sprite('flies/macho-fly-1.png'));
flyingSprite.add(Sprite('flies/macho-fly-2.png'));
deadSprite = Sprite('flies/macho-fly-dead.png');
}
}
Hungry Fly
Create a new class file ./lib/components/hungry-fly.dart
:
import 'package:flame/sprite.dart';
import 'package:langaw/components/fly.dart';
import 'package:langaw/langaw-game.dart';
class HungryFly extends Fly {
HungryFly(LangawGame game, double x, double y) : super(game, x, y) {
flyingSprite = List();
flyingSprite.add(Sprite('flies/hungry-fly-1.png'));
flyingSprite.add(Sprite('flies/hungry-fly-2.png'));
deadSprite = Sprite('flies/hungry-fly-dead.png');
}
}
Randomizing spawned flies
Now that we have five different fly variants, let’s make it so that every time a fly is spawned, it’s randomized between these five.
In ./lib/langaw-game.dart
, make sure to import all the fly variant class files:
import 'package:langaw/components/agile-fly.dart';
import 'package:langaw/components/drooler-fly.dart';
import 'package:langaw/components/hungry-fly.dart';
import 'package:langaw/components/macho-fly.dart';
Then inside the spawnFly
method, replace this line:
flies.add(HouseFly(this, x, y));
With this block:
switch (rnd.nextInt(5)) {
case 0:
flies.add(HouseFly(this, x, y));
break;
case 1:
flies.add(DroolerFly(this, x, y));
break;
case 2:
flies.add(AgileFly(this, x, y));
break;
case 3:
flies.add(MachoFly(this, x, y));
break;
case 4:
flies.add(HungryFly(this, x, y));
break;
}
Breakdown: We first get a random integer from rnd
using the nextInt
method. The value 5
means that we want a random integer chosen from a range of five values. Most programming languages start counting from zero so this range of five values become [0, 1, 2, 3, 4]
.
The resulting random value is then fed to a switch
block. A switch
block executes code based on the value that is passed to it. If for example the value 2
is passed to the switch
block, it executes flies.add(AgileFly(this, x, y));
, spawning an AgileFly
.
The break
statements just makes sure that the code below it does not run. To learn more about switch
blocks, check out the switch and case section of the Dart language tour.
The spawnFly
method should now look like this:
View the code at this step on GitHub.
Run the game and you should see that every time a fly is spawned, it’s a different type.
It’s chosen at random though, so you could have a scenario where the spawned fly is the same the one you just killed.
Step 5. Make the flies “fly around”
So far so good! We have a playable game that has good graphics and enough variation to keep a player entertained.
But we’re not done yet.
Animate the flies
Flies don’t just stay up in the air using magic, they flap their wings to provide enough lift to propel their whole bodies upwards.
The resource pack we’re using already provides all the frames needed for animating our flies. We even loaded them and prepared the sprites in each of the fly instances already.
To animate the flies we need to open ./lib/components/fly.dart
. In the update
method, place put an else block ( else { }
) at the end of the if (isDead)
block.
Inside the else block, write in the following lines:
flyingSpriteIndex += 30 * t;
if (flyingSpriteIndex >= 2) {
flyingSpriteIndex -= 2;
}
Breakdown: First we add a value of 30
multiplied by the value of time delta to the flyingSpriteIndex
variable. Remember that this variable gets converted into an int
during render and its int
value is used to determine which frame to show, either the first (index 0
) or the second (index 1
).
We’re trying to achieve 15 flaps (15 cycles of animation) per second. Since we have two animation frames for each cycle, we’ll be displaying 30 frames per second.
Let’s say the game is running at 60 frames per second. The update
method will kick off roughly every 16.6 milliseconds (which is the value of time delta variable t
but in seconds). The starting value for flyingSpriteIndex
is zero.
For the first frame, 30 * 0.0166
is added to flyingSpriteIndex
. The value of flyingSpriteIndex
is now 0.498
. If you run .toInt()
on this value, you’ll get 0
, showing the first image.
On the second frame, another 30 * 0.0166
is added to flyingSpriteIndex
making it’s value 0.996
. If you run .toInt()
on this value, you’ll still get 0
, this shows the first image.
Then on the third frame, add another 30 * 0.0166
and the value will become 1.494
. Running .toInt()
on this value will return 1
showing the second image.
When we get to the fourth frame, add another 30 * 0.0166
and the value will become 1.992
. The .toInt()
value is still 1
therefore still showing the second image.
By the time we’re on the fifth frame, we add another 30 * 0.0166
to get 2.49
.
We have an if
block that resets the flyingSpriteIndex
variable if its value becomes greater than or equal to two since we don’t have a third image (index 2
).
We have that value now with 2.49
.
We subtract 2
from the value making it just 0.49
which has a .toInt()
value of 0
showing the first image again.
This happens over and over again looping between the two frames at 15 cycles per second.
Note: Based on our calculation, we will eventually have a slip where an image will be shown for a duration of three frames. This actually is not the case as we haven’t used precise values. 1 second ÷ 60 frames per second
is not equal to 0.0166
. It’s actually equal to 0.016666...
with a never ending series of 6
‘s which if multiplied by 30
will always give a value of 0.5
. Also, the time delta will not always be a perfect 0.016666...
as discussed in the previous part. Making the whole calculation really synchronize with the 15 flaps per second logic. Ultimately, even if we do get a slip, at 60 frames per second it would be barely noticeable.
If you have any questions about this calculation please drop a comment.
You should now have an update
method that looks like this:
It’s time to run the game to see the flies flapping their wings! A screenshot will not be able to demonstrate this so just run your game and see for yourself.
Consistent fly sizes
Now that we have flies and that they’re animated, you’ll notice that the one fly per tile sizing is no longer feasible. That’s because that was a proof-of-concept rule to explain the screen dimensions, aspect ratios, sizing, and tiling system.
We need to adjust the size so that the flies themselves have a consistent feel and sizes.
To do this, we need to edit ./lib/components/fly.dart
and remove the initialization for flyRect
and transfer it to each of the sub-classes since each fly variant will have its own size.
Remove the following line inside the constructor:
flyRect = Rect.fromLTWH(x, y, game.tileSize, game.tileSize);
In Dart, if you have an empty constructor that only assigns values to the final instance values, you can omit the body part and end it with a semi-colon like so:
Fly(this.game, double x, double y);
In fact, we won’t be using the x
and y
parameters anymore since the rectangle is no longer initialized here so let’s just remove that. Leaving our constructor to look like this:
Fly(this.game);
Then let’s open up ./lib/components/house-fly.dart
and edit the super
call in the constructor so that it doesn’t pass the x
and y
values. We just removed those parameters in the Fly
constructor.
The opening line of HouseFly
‘s constructor should look like this:
HouseFly(LangawGame game, double x, double y) : super(game) {
Then inside this constructor, we add the flyRect
initialization we just removed from Fly
.
flyRect = Rect.fromLTWH(x, y, game.tileSize, game.tileSize);
Since we’re now using Rect
in this file, we have to import Dart’s ui
package:
import 'dart:ui';
Do the same changes to all the other fly variants.
The normal house fly, the drooler fly, and the agile fly will be the same size but we do need to make them bigger.
So for all these fly variants (./lib/components/house-fly.dart
, ./lib/components/drooler-fly.dart
, and ./lib/components/agile-fly.dart
), change the flyRect
initialization inside the constructor so it looks like the following line:
flyRect = Rect.fromLTWH(x, y, game.tileSize * 1.5, game.tileSize * 1.5);
With this, our hitbox is no longer the same size as game.tileSize
. It’s now bigger by a factor of 1.5
. This is our base size.
The sprite box will just follow as it’s an inflation of the hitbox.
For the MachoFly
(./lib/components/macho-fly.dart
), it’s 1.35x
the size of the other flies.
1.5 x 1.35 = 2.025
Change the flyRect
initialization to look like the following line:
flyRect = Rect.fromLTWH(x, y, game.tileSize * 2.025, game.tileSize * 2.025);
Let’s do the same for HungryFly
(./lib/components/hungry-fly.dart
) but using 1.5 x 1.1 = 1.65
as our size factor.
flyRect = Rect.fromLTWH(x, y, game.tileSize * 1.65, game.tileSize * 1.65);
Our biggest fly is now 2.025x
that of game.tileSize
. So let’s quickly jump to ./lib/langaw-game.dart
and modify the max value of both x
and y
in the spawnFly
method:
double x = rnd.nextDouble() * (screenSize.width - (tileSize * 2.025));
double y = rnd.nextDouble() * (screenSize.height - (tileSize * 2.025));
Run the game to notice that the flies are now bigger and they have consistent sizing among themselves. We’re all set and ready for the next change.
Feel free to experiment and choose your own factors for sizing!
Flies don’t stay in one place
In real life, flies don’t really hover in one location for all eternity. They move around a lot. A minor disturbance and chaos ensue.
We’ll try to mimic this behavior and have our flies fly around randomly.
First, let’s add a property called speed
. This is the rate at which the flies will move. Most flies will have the same speed but others will not.
A property is just another name for an instance variable. In our case, the difference is how we define and use them. We’ll be creating a property by defining a getter.
Open up ./lib/components/fly.dart
. Let’s add in this speed
property.
double get speed => game.tileSize * 3;
We use a default value of game.tileSize * 3
so the flies can whiz across the screen in a little over two seconds.
Try experimenting with different speeds if you like.
Before we start moving the flies in the update
method. We would need to calculate the direction they’re going. Sure we could just do a random value every time the update
method runs but that would make the fly just shake about randomly.
These flies should have a goal. A point to reach before changing directions.
Let’s add another instance variable named targetLocation
with Offset
as its type. We use the Offset
class because it has helpful functions that we can use like calculating the direction, distance, scaling, and subtracting.
Offset targetLocation;
Let’s follow it up with a reusable method for changing the targetLocation
.
void setTargetLocation() {
double x = game.rnd.nextDouble() * (game.screenSize.width - (game.tileSize * 2.025));
double y = game.rnd.nextDouble() * (game.screenSize.height - (game.tileSize * 2.025));
targetLocation = Offset(x, y);
}
Breakdown: Just like in spawnFly
in ./lib/langaw-game
, we initialize two variables (x
and y
) with the random values using the same rules for the maximum. The flies can only go to a location that it can spawn in.
Then in the constructor let’s call this method so we have a non-null
targetLocation
the moment a fly instance is created.
Fly(this.game) {
setTargetLocation();
}
The fly.dart
file should now look like this:
Now let’s do their actual movement. Inside the update
method and under the condition that the current instance of the fly is not dead (isDead
is not true
), we move the fly towards it’s target goal respecting the time delta value. If it reached its target location, we call setTargetLocation
to randomize the goal.
Insert this block of code inside the update
method just below stepping the flyingSpriteIndex
.
double stepDistance = speed * t;
Offset toTarget = targetLocation - Offset(flyRect.left, flyRect.top);
if (stepDistance < toTarget.distance) {
Offset stepToTarget = Offset.fromDirection(toTarget.direction, stepDistance);
flyRect = flyRect.shift(stepToTarget);
} else {
flyRect = flyRect.shift(toTarget);
setTargetLocation();
}
Time for the breakdown: The very first thing that happens is we define a stepDistance
variable that will hold how much we should move the fly. If speed
is how much a fly can move in one second, we multiply it with the time delta (t
, the amount of time since the update
method was run) giving us the amount of distance the fly should have moved since then.
We then create a new Offset
that represents the “offset” from the fly’s current location to it’s targetLocation
. We use Offset
subtraction operation here which is built in to the Offset
class.
If the fly is currently at 50, 50
, and the target location is 120, 70
, this toTarget
would have a value of (120 - 50), (70 - 50)
or 70, 20
.
Then we check if our stepDistance
is less than the .distance
in the toTarget
offset (a useful property of the Offset
class so we don’t have to manually calculate everything). If it is it means that we’re still far away from the target location so we proceed with moving the fly.
To move the fly, we create a new Offset
using the fromDirection
factory. This factory takes in a direction and an optional distance (which defaults to 1
). For the direction, we just feed toTarget
‘s direction
property (another useful property of the Offset
class so we don’t have to mess with trigonometry to calculate angles). For the distance, we feed in our already calculated stepDistance
value.
If stepDistance
is greater than or equal to toTarget
‘s distance
property, it means that the fly is very near the target location and by this point is safe to say that it reached its goal. So we just shift
the fly to the target using the value in toTarget
which is the actual distance from the fly to the targetLocation. Snapping the fly into the target. Finally, we call setTargetLocation()
to give our fly a new goal.
We should now have an update
method that looks like this (I added in comments to make it easier to read):
Test it out by running the game so you can see how they move.
Different flies; different styles
We’re almost done with this part. Let’s make a few edits to give some of our flies unique abilities.
For the AgileFly
(./lib/components/agile-fly.dart
), override the speed
property and give it a speed factor of five. Why five? Because they’re agile!
double get speed => game.tileSize * 5;
In the file it looks like this:
The DroolerFly
(./lib/components/drooler-fly.dart
) is a lazy fly. It moves just half as fast as the regular house fly.
double get speed => game.tileSize * 1.5;
The MachoFly
(./lib/components/macho-fly.dart
) has huge human-like muscles and are heavy. Let’s give it a value of 2.5
, just a little slower than the average house fly.
double get speed => game.tileSize * 2.5;
View the code at this step on GitHub.
Test and Demo!
If you run the game, you should have something like this:
Note: We actually have a couple of bugs which we’ll address in later parts. One of which is, if you tap a fly, it dies and spawns another. If you tap it again while falling, it spawns yet another fly! Try it out.
Conclusion
We’ve successfully converted our interactive but boring game from the previous part into something that is way nearer to being a game we’ll be proud to distribute.
Using nice graphics, animation, movement, and variation; the simple “box that falls when tapped” game is now a playable game.
I hope you had fun learning the animation and movement techniques covered in this part.
Feel free to drop a question in the comments section below if you have any. You can also send me an email or join my Discord channel.
What’s next
In the next part, we’ll have different screens/views like the welcome screen, “you lost” screen, and some dialog boxes for help and some credits.
We’ll also see add in more graphics for branding.
See you in the next one!