New Super Jumper with Flame and Forge2D
THE FOLLOWING IS A DRAFT THAT I WILL SIMPLIFY AND COVERT TO GOOGLE SLIDES FOR THE FLUTTER EVENT IN TAIWAN.
Prerequisites
- Get the assets from this game in OpenGameArt.Org
What you'll learn
- Learn how to represent game elements with dynamic, static, and kinematic bodies
- Learn how to handle collisions
- Learn how to use animated Sprites
- Learn how to load sprite sheets created with Gdx Texture Packer
- Learn how to keep track of the top five scores
Set up your Flutter Environment
In this codelab, we asume your Flutter environment is ready. If this is not the case, you can complete the Your First Flutter App codelab from Google. That codelab configures your Flutter development environment and gets you started with working with Flutter.
Create a project
In this section, we will create a new Flutter app. Please select the Android Studio or Visual Studio Code tab depending on which IDE you are using to create your project.
- Visual Studio Code
- Android Studio
- Open Visual Studio Code and create a new Flutter project by typing in the search bar "> flutter new". When it appears, select the Flutter: New Project command.
- We select Empty Application this will create the new project without the boilerplate code from the counter app. Then we choose the directory to create the project.
- Add the project name. In this case we choose
new_super_jumper
- Now we have an empty Flutter project.
- Open Android Studio and create a new Flutter Project.
- Select the Flutter SDK Path.
- Add the project name (in this case
new_super_jumper
), location, description, etc. In project type select Empty Application this will create the new project without the boilerplate code from the counter app.
- Now we have an empty Flutter project.
Update the project dependencies
Now we will add all the packages that we will use in this codelab. We open the pubspec.yml
file and replace the
content with the following:
name: new_super_jumper
description: "Game similar to Doodle jump made with Flutter"
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: ">=3.3.3 <4.0.0"
dependencies:
flutter:
sdk: flutter
flame: ^1.17.0
flame_forge2d: ^0.18.0
shared_preferences: ^2.2.3
sensors_plus: ^5.0.1
flame_texturepacker: ^4.0.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.2
flutter:
uses-material-design: true
Note: If you gave your app a name other than new_super_jumper
, you need to change the first line correspondingly.
- flame: Is a modular Flutter game engine that provides a complete set of out-of-the-way solutions for games.
- flame_forge2d: Box2D physics engine that will handle all physics of the game like jumping on the platforms, colliding with enemies, etc.
- shared_preferences: We will use shared preferences to store the top 5 high scores, so they can persist if the app is restarted.
- sensors_plus: This package will give us access to the accelerometer. It will allow us to control our hero by tilting the phone.
- flame_texturepacker: Allows you to import sprite sheets generated by Gdx Texture Packer and Code and Web Texture Packer into your Flame game.
The MyGame class
Now we create a new file called my_game.dart
. This will hold all the components of the game like our hero,
the platforms, enemies, power-ups, etc. Also, it will keep the state of the game, so we can now if the game is over.
import 'package:flame_forge2d/flame_forge2d.dart';
class MyGame extends Forge2DGame {
}
Later we will add more login to MyGame
class. Now let's update main.dart
to allow run MyGame
to do it use the
following code:
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:new_super_jumper/my_game.dart';
void main() {
runApp(const MyGameWidget());
}
class MyGameWidget extends StatelessWidget {
const MyGameWidget({super.key});
Widget build(BuildContext context) {
return GameWidget(
game: MyGame(),
);
}
}
The class MyGameWidget
is a helper class that wraps GameWidget
. Right now it seems useless, but later when we add
different routes to the game, it will be handy. If we run the code, we will see only a window with a blank
black background.
Game configuration
The size of the game
We have to choose the size of the game area. In our game we will be using the FixedResolutionViewport
that keeps
the resolution and aspect ratio fixed, with black bars on the sides if the resolution does not match the aspect ratio.
Remember that the game area is different from the window size. To learn more about it, check the course Learn Flame & Forge2D
Let's update the class MyGame
with the game size that we will be using for our game.
final gameSize = Vector2(428, 926);
final worldSize = Vector2(4.28, 9.26);
class MyGame extends Forge2DGame {
MyGame() : super(
zoom: 100,
gravity: Vector2(0, 9.8),
camera: CameraComponent.withFixedResolution(
width: gameSize.x,
height: gameSize.y,
),
);
}
This game will be 428 pixels wide and 926 pixels high. Every component added will conform to this height and width.
Forge2D units are meters-seconds-kilogram, so we should avoid using pixels when working with Forge2D,
that is why we also created worldSize
which is a 1:100 scale of the gameSize
. It makes more sense adding a
building that is 9.26 meters tall than adding a building that is 926 meters tall.
We set a value of zoom: 100
because we decided that the scale of worldSize
is 1:100, this means that every component
added to the world
will be "zoomed" by 100. So if we add to the world
a building that is 9.26-meter, it will be
zoomed and cover the whole screen, however, if we add the same 9.26-pixel building to the viewport it will only cover
a small part of the screen.
The gravity
is the force which the world applies to every dynamic body. Passing gravity: Vector2(0, 9.8)
: 0 indicates
no gravity in the horizontal direction and 9.8 is the vertical gravity which in our game will pull our hero down.
When we add components to the world
the units we will use are meters. When we add components to the viewport
or to
the backdrop
the units we will use are pixels. In our game, 1 meter equals to 100 pixels.
Let's create a dark blue dummy background that will be as big as the gameSize
.
/// Dummy background. To be deleted later in the codelab
class _DummyBackground extends RectangleComponent {
_DummyBackground()
: super(
paint: BasicPalette.darkBlue.paint(),
size: gameSize,
);
}
class MyGame extends Forge2DGame {
Future<void> onLoad() async {
// Add the dummy background in the backdrop
camera.backdrop.add(_DummyBackground());
}
}
When we want to add a component behind the world, we can add them in the backdrop
component. The background is a good
candidate to be added in the backdrop component.
Restart the game. Your game should look like the next image.
In the next section, we will create the body of our hero, and we will be adding it to the world.
Add our hero to the world
MyHero component
Adding our hero on the screen requires a new component. Let's create the MyHero
component in the file
called my_hero.dart
in lib/components/my_hero.dart
.
class MyHero extends BodyComponent<MyGame> {
Body createBody() {
final bodyDef = BodyDef(
userData: this,
position: Vector2(worldSize.x / 2, -0.5),
type: BodyType.dynamic,
);
final shape = PolygonShape()..setAsBoxXY(.27, .30);
final fixtureDef = FixtureDef(shape, density: 10);
return world.createBody(bodyDef)..createFixture(fixtureDef);
}
}
If you already completed the course Learn Flame & Forge2D you know that BodyComponent
is a special type
of component that wraps the Forge2D body, which is the body that the physics engine is interacting with.
There is nothing special about MyHero
component, it derives from BodyComponent
, it is a dynamic body that means
it will react to the forces in the world, it's a polygon shape and by the sizes we set it will be rectangular, and it has
a density of 10.
To add it to the world we are going to update MyGame
class.
class MyGame extends Forge2DGame {
late final MyHero hero;
Future<void> onLoad() async {
camera.backdrop.add(_DummyBackground());
hero = MyHero();
// Add it directly to the world
world.add(hero);
}
Pay close attention to the position: Vector2(worldSize.x / 2, -0.5)
, can you tell where is our hero placed?.
Let's look at the next image:
Our hero is placed in the center right of the screen, and you may wonder why we did not put it at the center.
In Flame by default the origin (0, 0
) is at the center of the screen, increasing the position in x and y of a component
will move it right along the x-axis and down along the y-axis.
Remember than when working with the world
we should avoid working with pixels and instead work with the scaled values
for that reason we are using worldSize
to place our hero.
As you can see in the image above, we are placing our hero in the quadrant where the value of x is positive, this will simplify development later when we want to add other objects like enemies or platforms.
Now we have to make the camera follow our hero. This will make the hero to be in the center of the screen while keeping
the position on worldSize.x / 2, -0.5
. Let's update the code just after adding our hero to the world.
Future<void> onLoad() async {
//...more code
world.add(hero);
// Make the camera to follow our hero
camera.follow(hero);
}
Floor component
Probably you did not notice, but our hero has been falling without anything to stop him; we will add a new static
body that will represent the floor, this floor will not let our hero fall forever. Let's create the Floor
component
in the file called floor.dart
in lib/components/floor.dart
.
class Floor extends BodyComponent<MyGame> {
Body createBody() {
final bodyDef = BodyDef(
userData: this,
position: Vector2.zero(),
type: BodyType.static,
);
final shape = EdgeShape()
..set(
Vector2.zero(),
Vector2(worldSize.x, 0),
);
final fixtureDef = FixtureDef(shape);
return world.createBody(bodyDef)
..createFixture(fixtureDef);
}
}
This createBody()
function of this component is similar to the MyHero
component, but there are a few differences.
The first one is that this is a BodyType.static
which means it will not react to any force or collision in the world.
Second, this Floor
component is an EdgeShape
which is a simple line between two points, in this case the lines goes
from the left to the right side of the screen.
Did you notice our hero is falling until reaching the floor?
Let's jump: add collision detection
With collision detection, we can know when two components came into contact with each other. In forge2D, to add
collision detection to a component, we have to add the ContactCallbacks
mixin.
class MyHero extends BodyComponent<MyGame>
with ContactCallbacks {
void jump() {
final velocity = body.linearVelocity;
body.linearVelocity = Vector2(velocity.x, -7.5);
}
void beginContact(Object other, Contact contact) {
if (other is Floor) jump();
}
}
We have updated the MyHero class with the mixin ContactCallbacks
which allows us to override the function
beginContact(Object other, Contact contact)
the parameter other
is the component with which our hero made contact.
First, we check if other
is a Floor
if yes, then we call the function jump().
The function jump()
set's the
body linear velocity directly to -7.5 in the y-axis and in the x-axis it keeps the existing velocity.
Let's run the code, and we will see our hero jumping every time it collides with the floor.
Great!, now our hero can jump, but if our hero is just a white square is not very fun. In the next section, we will learn how to load assets from a sprite sheet, and we will replace the dummy background and our hero with sprites.
Load assets
The sprite sheet
The sprite sheet combines all the images used in the game into a big single image. This improves the game performance, reduces the memory usage and speeds up the startup and loading time of the game. What is a sprite sheet?
Our game sprite sheet looks like this.
Download this codelab sprite sheet and atlas file from GitHub
The Assets class
Because we are creating a simple game that uses only one sprite sheet, we can load it and keep it in memory for all the life cycle of the game. Note that in a more complex game that uses several sprite sheets is a good practice to load only the sprite sheets that will be used at a particular time.
- Add the
atlasMap.atlas
andatlasMap.png
in theassets/images
folder and add theassets
section in thepubspec.yaml
.
assets:
- assets/images/
- Create the
Assets
class that will be in charge of loading everySprite
from the sprite sheet. To start, we will load the background, the hero jumping and falling sprites.
class Assets {
static late final Sprite background;
static late final Sprite heroFall;
static late final Sprite heroJump;
static Future<void> load() async {
// Loads the atlasMap.atlas from the assets/images directory
final atlas = await TexturePackerAtlas.load('atlasMap.atlas');
// Find in the atlas the sprite whose name is 'background'
background = atlas.findSpriteByName('background')!;
// Find in the atlas the sprites whose name are 'heroFall' and 'heroJump'
heroFall = atlas.findSpriteByName('heroFall')!;
heroJump = atlas.findSpriteByName('heroJump')!;
}
Because we are keeping the sprite sheet loaded in memory, there is no harm to make every Sprite
property inside the
Assets
class static
to simplify the access to the sprites across the whole game. Remember that in bigger games,
the strategy to load the assets can be different.
- Update the
main()
function to load the assets:
// Make the main funcion async
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// Load the assets before runApp
await Assets.load();
runApp(const MyGameWidget());
}
Add sprites to MyHero
Our hero can have one of three states which are jumping, falling or dead, and depending on which state it is, we
will have to use a different sprite. Let's update the my_hero.dart
file with the following code:
// The hero can be have one of three states jumping,
// falling, or dead.
enum HeroState {
jump,
fall,
dead,
}
class MyHero extends BodyComponent<MyGame> with ContactCallbacks {
// Sprite size that we will draw in top of the body
static final size = Vector2(.75, .80);
// The initial state is our hero falling
var state = HeroState.fall;
// Initialize the _fallComponent with the falling sprite
late final SpriteComponent _fallComponent = SpriteComponent(
sprite: Assets.heroFall,
size: size,
anchor: Anchor.center,
);
// Initialize the _fallComponent with the jumping sprite
late final SpriteComponent _jumpComponent = SpriteComponent(
sprite: Assets.heroJump,
size: size,
anchor: Anchor.center,
);
// Helper to keep track of the current component falling or jumping
late Component _currentSprite;
Future<void> onLoad() async {
await super.onLoad();
// Add the initial falling component to the hero
_currentSprite = _fallComponent;
add(_currentSprite);
}
//...more code
}
The enum HeroState
contains all possible states of our hero. If it's falling the state is fall
, if it's jumping the
state is jump
and if it's dead the state is dead
.
The static variable size
of the Sprite we will draw on top of the body, as you can see it's a little bigger than the
body size. Increasing or decreasing the size
value will not affect when two bodies collide.
We also have the variable state
which holds the current state of our hero, the state can change depending on what is
happening in the game, for example, if our hero collides with an enemy the state will change to dead
.
The sprite components _jumpComponent
and _fallComponent
contain the sprite jumping or falling. Later we will learn how
to add or remove this component depending on the state of our hero.
If we run the game, we can see the falling sprite on top of the body.
There is a small problem with our game. We only render the falling sprite and not the jumping sprite. Let's fix it by
updating and adding the following code in MyHero
.
class MyHero extends BodyComponent<MyGame>
with ContactCallbacks {
// Update the function jump with the highlighted code
void jump() {
// If the state is jump or dead we return early to avoid
// jumping twice or jumping when our hero is dead
if (state case HeroState.jump || HeroState.dead) return;
final velocity = body.linearVelocity;
body.linearVelocity = Vector2(velocity.x, -7.5);
// Set the state to jump
state = HeroState.jump;
}
// Helper function to set the _jumpComponent or
// _fallComponent and remove the current one if needed.
void _setComponent(PositionComponent component) {
if (component == _currentSprite) return;
remove(_currentSprite);
_currentSprite = component;
add(component);
}
void update(double dt) {
super.update(dt);
final velocity = body.linearVelocity;
// If the velocity in the y-axis became positive it means the hero is
// falling and we change the state to falling unless the hero is dead
if (velocity.y > 0.1 && state != HeroState.dead) {
state = HeroState.fall;
}
// Set the jumping or falling component
// depending on the state
if (state == HeroState.jump) {
_setComponent(_jumpComponent);
} else if (state == HeroState.fall) {
_setComponent(_fallComponent);
} else if (state == HeroState.dead) {
_setComponent(_fallComponent);
}
}
}
We updated the function jump()
to change the state to jump
, and also we avoid jumping twice or jumping when our hero
is dead.
The helper function _setComponent()
sets the _jumpComponent
or _fallComponent
, this function is needed to avoid
duplicating code when changing the components.
And finally we override the function update()
to check the current velocity on the y-axis and determine if we should
change the state of our hero to fall
.
One more thing to do is, let's remove the white square behind our hero by setting renderBody
to false
.
Future<void> onLoad() async {
await super.onLoad();
renderBody = false;
//...more code
}
Great, now we have our hero that can jump and fall and display the correct sprites. In the next section, we will add some platforms, and we will make our hero move in the x-axis.
Jumping on platforms
Platform component
Adding a platform in the game requires a new component. Let's create the Platform
component in the
file called platform.dart
in lib/components/platform.dart
.
/// There are 18 different platforms
enum PlatformType {
blue,
blueLight,
blueBroken,
}
// Helper extension to add extra funcionality
// to the PlatformType enum
extension PlatformTypeExtension on PlatformType {
// Get the sprite that we will draw on screen
Sprite get sprite {
return switch (this) {
PlatformType.blue => Assets.platformBlue,
PlatformType.blueLight => Assets.platformBlueLight,
PlatformType.blueBroken => Assets.platformBlueBroken,
};
}
}
class Platform extends BodyComponent<MyGame> {
// Sprite size that we will draw in top of the body
static Vector2 size = Vector2(1.2, .5);
// Initial position, later we will
// use it to initialize the body
final Vector2 _initialPosition;
final PlatformType type;
Platform({
required double x,
required double y,
}) : _initialPosition = Vector2(x, y),
type = PlatformType.values
.elementAt(random.nextInt(PlatformType.values.length));
Future<void> onLoad() async {
await super.onLoad();
renderBody = false;
add(
SpriteComponent(
sprite: type.sprite,
size: size,
anchor: Anchor.center,
),
);
}
Body createBody() {
final bodyDef = BodyDef(
userData: this,
position: _initialPosition,
type: BodyType.static,
);
final shape = PolygonShape()..setAsBoxXY(.58, .23);
final fixtureDef = FixtureDef(shape);
return world.createBody(bodyDef)..createFixture(fixtureDef);
}
}
Most of the things in the Platform
component are similar to MyHero
component. We create a body in createBody()
in
the position passed in the constructor, in the onLoad()
we add the sprite depending on the type of platform.
The PlatformType
contains every single type of platform. In total, there are 18 different platforms in the sprite
sheet, in this section we will add only three, and your homework is to add the rest of them.
The PlatformTypeExtension
has a getter sprite
that returns the specific asset depending on the platform type.
Unlike the MyHero
component the Platform
component receives the position x and y in the constructor, the reason is
that each platform will be placed in a different place.
Also in the constructor, we randomly choose a type of platform using the random
object. Which is
created in the my_game.dart
file.
final gameSize = Vector2(428, 926);
final worldSize = Vector2(4.28, 9.26);
final random = Random();
class MyGame extends Forge2DGame {
//... more code
}
Load the platform assets
In the Platform
components we are using Assets.platformBlue
, Assets.platformBlueLight
, etc. which we haven't loaded
in the Assets
class. Let's load them.
class Assets {
static late final Sprite platformBlue;
static late final Sprite platformBlueLight;
static late final Sprite platformBlueBroken;
///...more code
static Future<void> load() async {
//... more code
// Find in the atlas the sprites whose names are
// 'LandPiece_DarkBlue', 'LandPiece_LightBlue' and 'BrokenLandPiece_Blue'
platformBlue = atlas.findSpriteByName('LandPiece_DarkBlue')!;
platformBlueLight = atlas.findSpriteByName('LandPiece_LightBlue')!;
platformBlueBroken = atlas.findSpriteByName('BrokenLandPiece_Blue')!;
}
Now the platform assets are loaded, and we can use them in the Platfrom
component. Remember there are 18 platform assets
it's your homework to load them all.
Add platforms to the game
The Background component
Create the Background
component in a file called background.dart
in lib/components
. The Background
component
derives from SpriteComponent
, so we can pass the Assets.background
sprite to draw it on the screen.
class Background extends SpriteComponent {
Background()
: super(
sprite: Assets.background,
size: gameSize,
);
}
Now replace the _DummyBackground
with Background
and run the game.
Future<void> onLoad() async {
camera.backdrop.add(Background());
//...more code
}
Game with grid paper background