Flutter — Game Development using Flame — Part Two

Jayaramanan Kumar
5 min readDec 22, 2022

--

So in the last post, the hunter character was defined and added to the screen. However, it doesn’t do much other than idle action. In this post, let’s dwell more into detail about how to position the game character on the screen and provide different actions.

Follow the below link for part one.

Add Hunter animations:

Let’s convert the Hunter component from a SpriteAnimationComponent to SpriteAnimationGroupComponent so that it can have more than one animation and each of the animations can be mapped to an action.

enum HunterState {death, idle, run, normalattack}

class Hunter extends SpriteAnimationGroupComponent {
@override
Future<void>? onLoad() async {
final deathSprites = [for (var i = 1; i <= 10; i++) i]
.map((i) => Sprite.load('hunter/death_$i.png'));

var idleSprite = [for (var i = 1; i <= 2; i++) i]
.map((i) => Sprite.load('hunter/idle_$i.png'));
final idleAnimation = await createAnimationForSprites(idleSprite, false);

var runSprite = [for (var i = 1; i <= 8; i++) i]
.map((i) => Sprite.load('hunter/run_$i.png'));
final runAnimation = await createAnimationForSprites(runSprite, true);

var normalAttackSprites = List.generate(16, (i) => i + 1)
.map((i) => Sprite.load('hunter/normal_attack_$i.png'));
final normalAttack =
await createAnimationForSprites(normalAttackSprites, true);

animations = {
HunterState.normalattack: normalAttack,
HunterState.death: deathAnimation,
HunterState.idle: idleAnimation,
HunterState.run: runAnimation,
};
current = HunterState.idle;
}

In the above code snippet, an enum HunterState is defined that will represent different states of the Hunter character. The animation property represents the mapping of each action to the animation and the current property represents the current state of the hunter character.

A component needs to be a positional component in order to make it move around the screen. SpriteAnimationGroupComponent is also of type positional Component and its position in the screen can be changed either by changing the position property which is of type Vector2 or directly changing the x and y property. So the hunter character can be fixed to the left end of the screen as below.

hunter.position = Vector2(70, size[1] - 110);or

or

hunter.x = 70;
hunter.y = size[1] - 110;

Here the size object represents the logical size of the game screen canvas.

Adding Control Buttons:

Next, let’s add some control buttons to control the hunter character. A custom class named ControlButton of type SpriteGroupComponent that takes the two sprite image paths and two functions as arguments is created as below.

class ControllButton extends SpriteGroupComponent<ButtonState>
with HasGameRef<GhostHuntGame>, Tappable {
String pressSpritePath;
String unpressSpritePath;
Function pressDown;
Function pressUp;

ControllButton(
{required this.pressSpritePath,
required this.unpressSpritePath,
required this.pressDown,
required this.pressUp});

@override
Future<void>? onLoad() async {
final pressedSprite = await gameRef.loadSprite(pressSpritePath);
final unpressedSprite = await gameRef.loadSprite(unpressSpritePath);

sprites = {
ButtonState.down: pressedSprite,
ButtonState.up: unpressedSprite,
};

current = ButtonState.up;
}

@override
bool onTapDown(TapDownInfo info) {
current = ButtonState.down;
pressDown();
return super.onTapDown(info);
}

@override
bool onTapUp(TapUpInfo info) {
current = ButtonState.up;
pressUp();
return super.onTapUp(info);
}
}

In the above, it can be seen that the class has a mixin named HasGameRef<GhostHuntGame> which will give the reference to the game object. Using this gameRef reference object the image and other properties of the game object can be accessed.

And in the GhostHuntGame class, define two control buttons run and attack and add them to the game screen as below.

@override
Future<void>? onLoad() async {
var runningButton = runButton();
var attackBtn = attackButton();
runningButton
..size = Vector2(65.0, 65.0)
..position = Vector2(30, size[1] - 75);

attackBtn
..size = Vector2(65.0, 65.0)
..position = Vector2(size[0] - 125, size[1] - 75);

add(runningButton);
add(attackBtn);
}

ControllButton runButton() {
return ControllButton(
pressSpritePath: "button/run.png",
unpressSpritePath: "button/run_2.png",
pressDown: runPlayer,
pressUp: resetPlayerToIdle);
}

ControllButton attackButton() {
return ControllButton(
pressSpritePath: "button/attack.png",
unpressSpritePath: "button/attack_2.png",
pressDown: attackWithArch,
pressUp: resetPlayerToIdle);
}

Similarly, the other ghost characters like FlyingEye, Skeleton and Death hunter can be created as SpriteAnimationComponents.

On pressing the run button, the hunter should start running and when the button is released the character should be back to idle. To make the character run, in the press-down callback, the animation of the hunter is changed to run state and also move the parallax background to make it look as if the character is moving.

So inside the ghost_hunt_game.dart file, the below method is added.

runPlayer() {
print("${enemySpawnTimer?.isActive}");
if (hunter.current == HunterState.idle) {
ghostHuntParallax.parallax?.baseVelocity = Vector2(50, 0);
hunter.run();
}
}

and in the hunter class, the run method is defined as below.

void run() {
if (isAlive()) current = HunterState.run;
}

On flutter run, the app will be seen as below.

Likewise, let’s change the hunter state to attack when tapping the attack button.

 attackWithArch() {
ghostHuntParallax.parallax?.baseVelocity = Vector2.zero();
hunter.attack();
}

And in the hunter class below method is added.

void attack() {
if (isAlive()) current = HunterState.normalattack;
}

Next, a fireball needs to come out when the hunter launches an arrow. So a new SpriteAnimationGroupComponent class for the fireball is created. The creation/appearance of this fireball in the game depends on the attack animation action of the hunter character.

So let’s add an on-completion action for the attack animation that will create and add this fireball to the game.

final normalAttack =
await createAnimationForSprites(normalAttackSprites, true)
..onFrame = (index) {
if (index == 2 || index == 8 || index == 16) {
final fireball = FireBall();
fireball.position = Vector2(150, gameRef.size[1] - 80);
gameRef.add(fireball);
}
};

To make the fireball travel horizontally, its x value is incremented inside the update method. The update method is one of the life cycle methods that is executed every time frame is refreshed.

@override
void update(double dt) async {
if (current == FireBallState.fly) {
_moveFireball();
}
super.update(dt);
}

_moveFireball() {
if (position.x < gameRef.size.x) {
position.x += fireballSpeed;
} else {
gameRef.remove(this);
}
}

Add enemy character:

Next, let’s add a new ghost character named Skeleton. This will be again a SpriteAnimationGroupComponent. However instead of a list of sprites, here there is only a single sprite sheet that contains all the frames of the animation. Also, the skeleton character will be added to the other end of the game screen.

A new class Skeleton is created and inside the onLoad method, the animation for the skeleton is created as below.

var attackAnimation = SpriteAnimation.fromFrameData(
await gameRef.images.load('skeleton/attack.png'),
SpriteAnimationData.sequenced(
textureSize: Vector2.all(150.0),
amount: 3,
stepTime: 0.1,
loop: false,
));
// Add Skeleton to the right end of the game screeen
position = Vector2(gameRef.size[0] - 75, gameRef.size[1] - 135);

The next part of this game development series will cover collision detection and building the menu items.

https://medium.com/@jayaramanan.kumar/flutter-game-development-using-flames-part-three-3bd03125d3d8

--

--