Flutter — Game Development using Flame — Part Three

Jayaramanan Kumar
6 min readDec 22, 2022

--

In this part, let’s add the logic for the hunter and ghost characters when encountering each other in the game.

The link for the previous two parts of the game development series using Flutter and Flame is below.

Adding Shapehitbox and collision detection:

Now that the game has both player and enemy characters. let’s add more characteristics to them. The Skeleton will walk towards the hunter and will swing the sword when it comes close to the player.

The player can attack the skeleton by firing fireballs from the bow. When the player attacks, the enemy needs to react by taking a hit and the life will reduce and vice versa.

To accomplish the above-mentioned functionality, Flame provide a collision detection mechanism using the ShapeHitbox. A positional Component can have more than one hitbox. Flame provides multiple types of shape hitboxes like CircleHitBox, RectangleHitBox etc. Let’s add a circle Hitbox for Skeleton and the Hunter character and also the fireball component (Since it suppose to hits ghost characters)

skeleton.dart

@override
Future<void>? onLoad() async {
add(CircleHitbox(radius: 30, position: size / 2, anchor: Anchor.center));
..
..
}

The hitbox can be visualised by setting the debugMode property of the component to true and hot restarting the app.

debugmode enabled

To detect the entry of any component inside the ShapeHitbox, the component class needs to add the CollisionCallbacks mixin which will provide 3 callbacks.

  1. onCollisionStart(Set<Vector2> intersectionPoints, PositionComponent other)
  2. onCollision(Set<Vector2> intersectionPoints, PositionComponent other)
  3. onCollisionEnd(PositionComponent other)

Here the OnCollison method is executed throughout the collision course. Now let’s define the collision callback for the skeleton and fireball launched by the hunter.

skeleton.dart

@override
void onCollisionStart(
Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollisionStart(intersectionPoints, other);

if (other is Hunter) {
attack();
_isAttackMode = true;

_attackTimer =
async.Timer.periodic(const Duration(milliseconds: 500), (timer) {
if (other.isAlive() && isAlive()) {
attack(); //Transition to attack animation
other.damageBy(damageRate); //Cause damage to the hunter life
}
});
}
}

fire_ball.dart

@override
void onCollisionStart(
Set<Vector2> intersectionPoints, PositionComponent other) {
if (other is Skeleton) {
hit(); // transition to hit state
Damagable damagable = other as Damagable;
damagable.damageBy(damageValue); // cause damage to the skeleton life
}
super.onCollisionStart(intersectionPoints, other);
}

Now, Let’s make the skeleton character walk towards the hunter character by changing the x position value of the component inside the update method.

skeleton.dart:

@override
void update(double dt) {
super.update(dt);
if (isAlive()) {
position.x -= (_isAttackMode) ? 0 : .5;
if (position.x < -200) {
gameRef.remove(this);
}
}
}

For the skeleton character, the x value is decremented by value 0.5 until it comes into collision with the hunter’s hitbox. Also, it is removed from the game, when the skeleton has moved beyond the visible screen area. The same is done for the fireball and it is important to remove the component that is no longer needed in the game.

Note: If more than one shapehitbox is defined for a component, instead of adding mixin to the class a collision callback can be added directly to the shapehitbox as below. This can help segregate the logic for different shapehitboxes.

final observantHitBox = RectangleHitbox(
size: Vector2(300, 70),
anchor: Anchor.centerRight,
position: size / 1.5,
);
observantHitBox.onCollisionStartCallback = handleHunterEntry;

void handleHunterEntry(
Set<Vector2> intersectionPoints, PositionComponent other) {
if (other is ShapeHitbox && other.hitboxParent is Hunter) {
async.Timer.periodic(const Duration(seconds: 5), (timer) {
var hunter = other.hitboxParent as Hunter;
if (hunter.isAlive() && isAlive()) attack();
});
}
}

Similarly, other types of enemy characters can be created and the behaviour can be defined for them.

Spawning Ghost characters:

Next, let’s add a simple logic in the ghost_hunter_game class to spawn different enemy characters periodically.

ghost_hunter_game.dart

void iniitateAttack() {
enemySpawnTimer = async.Timer.periodic(const Duration(seconds: 8), (timer) {
if (level == 1) {
flyeEyeCount++;
add(FlyingEye());
if (flyeEyeCount == 10) level = 2;
} else if (level == 2) {
skeletonCount++;
add(Skeleton());
if (skeletonCount == 5) level = 3;
} else if (level == 3) {
deathBringerCount++;
var deathBringer = DeathBringer();
add(deathBringer);
if (deathBringerCount == 2) {
endEnemySpawn();
deathBringer.onDeath = () => endGame();
}
}
});
}

Build Menu Items:

Flame provide an option to add flutter widgets as an overlay. This can be used to add menu options like pause, restart, and resume to the game.

The GameWidget has a map property overlayBuilderMap which takes a Map of String and Widget and inside the FlameGame the component can be added to the overlay using the overlay.add() and overlay.remove() method.

Before that first, let’s wrap the GhostHuntGame component inside MaterialApp as below.

main.dart

void main() {
WidgetsFlutterBinding.ensureInitialized();
Flame.device.setLandscape();
Flame.device.fullScreen();
runApp(const Home());
}

class Home extends StatefulWidget {
const Home({super.key});

@override
State<Home> createState() => _HomeState();
}
class _HomeState extends State<Home> {
late GhostHuntGame _game;
@override
Widget build(BuildContext context) {
_game = GhostHuntGame();
return MaterialApp(
home: Scaffold(
body: GameWidget(game: _game),
);
}
}

Next let’s define the overlaybuilder map as below.

GameWidget(
game: _game,
overlayBuilderMap: {
'pauseButton': (BuildContext context, GhostHuntGame game) =>
Positioned(
right: 30,
top: 50,
child: Material(
color: Colors.black.withOpacity(0.5),
child: IconButton(
icon: const Icon(Icons.pause, color: Colors.white),
onPressed: () => game.pauseGame(),
),
),
),
'menu': (context, GhostHuntGame game) {
var gameStateTxt = '';
if (game.gameState == GhostHuntGameState.gameOver) {
gameStateTxt = 'Game Over';
} else if (game.gameState == GhostHuntGameState.gameFinish) {
gameStateTxt = 'You Won!!';
} else if (game.gameState == GhostHuntGameState.paused) {
gameStateTxt = 'Paused';
}
return Center(
child: Card(
color: Colors.black.withOpacity(0.5),
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('GHOST HUNT',
style: TextStyle(
fontSize: 30,
color: Colors.white,
fontFamily: 'Valorax')),
const SizedBox(height: 30),
Text(
gameStateTxt,
style: const TextStyle(
fontSize: 25,
color: Colors.white,
fontFamily: 'Valorax'),
),
const SizedBox(height: 20),
if (game.gameState != GhostHuntGameState.gameOver)
MenuButton(
icon: const Icon(Icons.play_arrow,
color: Colors.white),
label: 'Resume',
action: () => game.playGame(),
),
const SizedBox(height: 20),
MenuButton(
icon:
const Icon(Icons.play_arrow, color: Colors.white),
label: 'Restart',
action: () {
setState(() {
_game = GhostHuntGame();
});
},
),
const SizedBox(height: 20)
],
),
),
),
);
},
},
),
)

As seen in the above code, there are two overlap components defined inside the map. The first one is pauseButton which will invoke the pauseGame() method defined in the GhosthuntGame. In the menu component, there are two buttons one to resume the game and another one to restart the game.

To Restart the game, a new instance of the GhostHuntGame object is created and passed to GameWidget.

Inside the GhostHuntGame class, let’s define the below methods to handle pausing the game, resuming it, game end and game-over scenarios

void pauseGame() {
gameState = GhostHuntGameState.paused;
overlays.add('menu');
enemySpawnTimer.cancel();

pauseEngine();
overlays.remove('pauseButton');
}
void playGame() {
gameState = GhostHuntGameState.active;
overlays.remove('menu');
if (!enemySpawnTimer.isActive) iniitateAttack();
resumeEngine();
overlays.add('pauseButton');
}

endGame() {
gameState = GhostHuntGameState.gameFinish;
overlays.add('menu');
pauseEngine();
}

void gameOver() {
gameState = GhostHuntGameState.gameOver;
overlays.add('menu');
pauseEngine();
}

The FlameGame provide an inbuilt method called the pauseEngine() and resumeEngine() that will pause and resume the game loop respectively. The Game can also be paused/resumed by changing the paused attribute.

The complete source code for this project can be found in the below link.

--

--