【 Flutter x Flame 】ブロック崩しゲームを作ろう!

『 Flame を使ってゲームを作ってみたい!』

そんな要望にお答えするのが本記事です。

Flutter のゲームエンジンであるFlame を使って、
ブロック崩しゲームを作るチュートリアル記事となります。

本記事を読めば、以下のようなゲームを作成可能です。

Flame でどんなふうにアプリを作るのか、
知ることが出来る記事となっています。

ぜひ読んでみてください!

Flame とは

Flame とは ゲームのための独創的な解決策を提供する、 Flutter のゲームエンジン です。
Flameを利用することにより、ゲームを作成するのに必要なコードの簡素化が行えます。

とはいえ、Flutterで利用するには、こちらのパッケージを利用することで利用ができます。

今回紹介するFlameの機能はごく一部です。

全体を知りたい、という方は、ぜひ以下のドキュメントページをご確認ください。

Getting Started — Flame

Flame チュートリアル :ブロック崩しゲームを作ろう!

概要

今回のチュートリアルでは、以下のようなブロック崩しゲームを作成します。

  • ボールをパドル(上記動画内で左右に動く青色のもの)で反射させ、
    ぶつかったら壊れるブロックをすべて壊すことを目的とするゲーム
  • ボールは壁、パドル、ブロックに当たると跳ね返る
  • 3回画面下部に落ちるとゲームオーバーとなる

次のセクションから実際に作成に入ります。
ぜひ読みつつ、一緒に作成してみましょう!

今回作成するアプリの開発環境は以下となります。

  • Flutter 3.3.4
  • Flame 1.4.0

準備

プロジェクトの作成とパッケージのインストール

任意の名前でFlutterプロジェクトを作成しましょう。

次にそのプロジェクトをお使いのIDE (統合開発環境、Android Studio や VS code)で
開いてください。

CLI(macならターミナル)で、作成したプロジェクトのルートにて
以下のコマンドを実行しflameパッケージをインストールします。

flutter pub add flame

ファイルの準備

libフォルダにて、以下のフォルダ構成で空のファイルを作成します。

constants.dart の準備

今回のチュートリアルでは、
アプリ内で設定する数字や色等を、すべてconstants.dartにまとめてます。

以下のコードをコピーし、constants.dartのファイルに貼り付けてください。

import 'dart:math';

import 'package:flutter/material.dart';

//Paddle Settings
const double kPaddleWidth = 100;
const double kPaddleHeight = 20;
//Start position from bottom.
const double kPaddleStartY = 50;
const Color kPaddleColor = Colors.blue;

//Ball Settings
const double kBallRadius = 10;
const double kBallSpeed = 500;
const double kBallStartYRatio = 1 / 2;
const double kBallMinSpawnAngle = 45;
const double kBallMaxSpawnAngle = 135;
const int kBallRandomNumber = 5;
const double kBallNudgeSpeed = 300;
const double kBallUncontrolledPositionY = 10;
const Color kBallColor = Colors.white;

// Math Settings
const double kRad = pi / 180;

//Button Settings
const double kButtonWidth = 200;
const double kButtonHeight = 50;
const Color kButtonColor = Colors.blue;
const Color kGameOverButtonColor = Colors.red;

//Countdown Settings
const double kCountdownSize = 200;
const TextStyle kCountdownTextStyle = TextStyle(
  color: Colors.white,
  fontWeight: FontWeight.bold,
  fontSize: 160,
);
const int kCountdownDuration = 3;

//Game Settings
const int kGameTryCount = 3;
const int kBlocksColumnCount = 2;
const int kBlocksRowCount = 3;

//Block Settings
const double kBlocksStartYPosition = 50;
const double kBlocksStartXPosition = 50;
const double kBlocksHeightRatio = 1 / 3;
const List<MaterialColor> kBlockColors = [
  Colors.red,
  Colors.blue,
  Colors.green,
  Colors.yellow,
  Colors.purple,
  Colors.orange,
];
const String kBlockPositionX = 'x';
const String kBlockPositionY = 'y';
const double kBlockPadding = 5;

準備は以上となります!
次から実際にゲームを構築していきましょう!

ゲームの画面表示

まずゲーム全体を取り仕切る、FlameGameを拡張したクラスを用意します。

block_breaker.dartに以下のコードを貼り付けてください。

import 'package:flame/game.dart';

class BlockBreaker extends FlameGame {}

現在、ゲームは空の状態です。
この空の状態をアプリに表示させてみましょう。

main.dart に書かれているコードをすべて削除し、以下のコードを貼り付けてください。

import 'package:flame/game.dart';
import 'package:flutter/material.dart';

import 'game/block_breaker.dart';

void main() {
  final game = BlockBreaker();
  runApp(
    MaterialApp(
      debugShowCheckedModeBanner: false,
      home: SafeArea(
        child: GameWidget(
          game: game,
        ),
      ),
    ),
  );
}

ポイントとなるのはGameWidgetです。
GameWidgetGameクラスを拡張したクラスのインスタンスを元に、
画面に作成したゲームを表示してくれるウィジェットです。

FlameGameクラスはGameクラスを拡張しているので、
BlockBreakerクラスはGameクラスの拡張クラスとなります。

クラス、インスタンスについては、以下の記事をご確認ください。

見つかりませんでした

今回の場合、これからゲームを構築していくBlockBreakerクラスのインスタンスを
GameWidgetに与えることで、
GameWidgetにブロック崩しゲームを表示してもらいます。

ここまでできたらアプリを実行してみましょう。

以下のように真っ黒の画面が表示されるはずです。

今回のチュートリアルではmacOSでの実行画像を表示します。

以上で、ゲームの画面表示は完了となります。

パドルの追加

ゲーム画面にパドルを表示させてみましょう。

パドルの作成

まず、パドルの作成を行います。

paddle.dartに以下のコードを貼り付けてください。

import 'dart:ui';

import 'package:flame/components.dart';

import '../../constants/constants.dart';

class Paddle extends RectangleComponent {
  Paddle()
      : super(
          size: Vector2(kPaddleWidth, kPaddleHeight),
          paint: Paint()..color = kPaddleColor,
        );
}

このコードでは、パドルを設定する、Paddleクラスを作成しています。
PaddleクラスはRectangleComponentクラスの拡張クラスしており、
この拡張により、長方形の物体の性質を持つこととなります。

super内で、長方形の物体における設定をしています。
sizeでサイズを設定します。
Vector2は横方向(x方向)に第1引数、縦方向(y方向)に第2引数の大きさを持つ矢印を表しています。

これをsizeに指定すると、横に第1引数分、縦に第2引数分の大きさを持つ長方形となります。

今回はVector2(kPaddleWidth, kPaddleHeight)と設定しているので、
横にkPaddleWidth、縦にkPaddleHeightの長方形となります。
kPaddleWidthkPaddleHeightの具体的な値はconstants.dartにて設定されています。

また、paintにて色の指定をしています。

こちらは補足となるので、興味のある方のみお読みください。

Paint()..color = kPaddleColor

の意味についてです。

Paint()Paintクラスのインスタンスを設定します。
“..”はカスケード演算子と呼びます。
詳細な解説は省きますが、同じインスタンスに対して複数回処理を行ったり、
今回のようにインスタンスのフィールドを直接変更する際に用います。

今回の場合、
colorconstants.dartで設定したkPaddleColorを与えたPaintクラスのインスタンス、
という意味になります。

以上でパドルの作成は完了です。

パドルの配置

続いて、パドルを配置しましょう。
block_breaker.dartに移動し、BlockBreakerクラスにonLoadメソッドを追加します。

以下のコードを全文コピーし、先程書いたblock_breaker.dartのコードから置き換えてください。

import 'package:flame/game.dart';

import '../constants/constants.dart';
import 'component/paddle.dart';

class BlockBreaker extends FlameGame {
  @override
  Future<void>? onLoad() async {
    final paddle = Paddle();
    final paddleSize = paddle.size;
    paddle
      ..position.x = size.x / 2 - paddleSize.x / 2
      ..position.y = size.y - paddleSize.y - kPaddleStartY;

    await addAll([
      paddle,
    ]);
  }
}

onLoad() メソッドは、ゲームのロード(初期読み込み)時に呼ばれるメソッドです。
このメソッドで、ゲーム中の初期状態の物体の配置を行います。

onLoad() メソッド内でPaddleのインスタンス化を行い、以下のコードでPaddleの初期位置を設定しています。

    paddle
      ..position.x = size.x / 2 - paddleSize.x / 2
      ..position.y = size.y - paddleSize.y - kPaddleStartY;

Paddleインスタンスのpositionプロパティのx要素に、size.x / 2 - paddleSize.x / 2を指定しています。
ここで、sizeはゲーム画面全体の大きさを表します。
size.x でゲーム画面の横方向の大きさとなります。

paddleSizeはパドルの大きさなので、size.x / 2 - paddleSize.x / 2で以下の図のように、
パドルの位置を横方向の中心に設定することとなります。

position.yの内容や以下で同じく出てくる配置の設定では
一部を除いて解説を省略します。

詳しく知りたい方は同じく図を書いて設定内容を確認してみてください。

addAllメソッドは、物体をゲーム中に配置するメソッドです。
上記で位置を設定したpaddleを配置しています。

ここまでできたらアプリを実行してみましょう。
以下の画像のように中央にパドルが配置されるはずです。

表示されない場合はホットリスタートをお試しください。

以上が物体の作成、配置の基礎となります。

ボールの追加

続いて、ボールの追加を行います。

ボールの作成

まず、ボールの作成を行います。

ball.dartに以下のコードを貼り付けてください。

import 'dart:ui';

import 'package:flame/components.dart';

import '../../constants/constants.dart';

class Ball extends CircleComponent {
  Ball() {
    radius = kBallRadius;
    paint = Paint()..color = kBallColor;
  }
}

このコードでは、ボールを設定する、Ballクラスを作成しています。
BallクラスはCircleComponentクラスの拡張クラスしており、
この拡張により、円の物体の性質を持つこととなります。

radiusにて円の半径を設定しています。

以上でボールの作成は完了です。

ボールの配置

続いて、ボールを配置しましょう。

block_breaker.dartに移動します。

ゲームの仕様上、ボールが下部に落ちた際など、何度もボールを配置し直すので、
ボール配置の処理をresetBallというメソッドで定義します。

block_breaker.dartBallBreakerクラスのonLoadメソッドの下に、
resetBallメソッドを追加します。
onLoadメソッドにてresetBallメソッドを呼び出します。

上記を行ったコードが以下となります。

import 'package:flame/game.dart';

import '../constants/constants.dart';
import 'component/ball.dart';  // 追加
import 'component/paddle.dart';

class BlockBreaker extends FlameGame {
  @override
  Future<void>? onLoad() async {
    final paddle = Paddle();
    final paddleSize = paddle.size;
    paddle
      ..position.x = size.x / 2 - paddleSize.x / 2
      ..position.y = size.y - paddleSize.y - kPaddleStartY;

    await addAll([
      paddle,
    ]);

    await resetBall();  // 追加
  }

  Future<void> resetBall() async {  // メソッド追加
    final ball = Ball();

    ball.position
      ..x = size.x / 2 - ball.size.x / 2
      ..y = size.y * kBallStartYRatio;

    await add(ball);
  }
}

ここまでできたらアプリを再実行してみましょう。

以下の画像のように、中心にボールが表示されるはずです。

以上でボールの追加は完了となります!

ブロックの追加

続いて、ブロックの追加を行います。

ブロックの作成

まず、ブロックの作成を行います。

block.dartに以下のコードを貼り付けてください。

import 'dart:math';
import 'dart:ui';

import 'package:flame/components.dart';

import '../../constants/constants.dart';

class Block extends RectangleComponent {
  Block({required this.blockSize})
      : super(
          size: blockSize,
          paint: Paint()
            ..color = kBlockColors[Random().nextInt(kBlockColors.length)],
        );

  final Vector2 blockSize;
}

このコードでは、ブロックを設定する、Blockクラスを作成しています。
BlockクラスはRectangleComponentクラスの拡張クラスしており、
この拡張により、長方形の物体の性質を持つこととなります。

今回、ブロックの大きさは画面サイズによって変わるように設定します。
そのため、インスタンス生成時に引数としてblockSizeを受け取り、sizeに設定するようにします。

Random().nextInt(kBlockColors.length)にて0 ~ kBlockColors.length
ランダムな整数を用いて、kBlockColors という色の配列から、色を設定するようにします。
これにより、ブロックが生成されるたびにランダムな色のブロックとなります。

ブロックの作成は以上となります。

ブロックの配置

続いて、ブロックを配置しましょう。

block_breaker.dartに移動します。

ボールと同様にゲームの仕様上、何度もブロックを配置し直すので、
ブロック配置の処理をresetBlocksというメソッドで定義します。

block_breaker.dartBallBreakerクラスのresetBallメソッドの下に、
resetBlocksメソッドを追加します。
onLoadメソッドにてresetBlocksメソッドを呼び出します。

上記を行ったコードが以下となります。

import 'package:flame/game.dart';

import '../constants/constants.dart';
import 'component/ball.dart';
import 'component/block.dart';  // 追加
import 'component/paddle.dart';

class BlockBreaker extends FlameGame {
  @override
  Future<void>? onLoad() async {
    final paddle = Paddle();
    final paddleSize = paddle.size;
    paddle
      ..position.x = size.x / 2 - paddleSize.x / 2
      ..position.y = size.y - paddleSize.y - kPaddleStartY;

    await addAll([
      paddle,
    ]);

    await resetBall();
    await resetBlocks();  // 追加
  }

  Future<void> resetBall() async {
  // ...
  }

  Future<void> resetBlocks() async {  // メソッド追加
    final sizeX = (size.x -
            kBlocksStartXPosition * 2 -
            kBlockPadding * (kBlocksRowCount - 1)) /
        kBlocksRowCount;

    final sizeY = (size.y * kBlocksHeightRatio -
            kBlocksStartYPosition -
            kBlockPadding * (kBlocksColumnCount - 1)) /
        kBlocksColumnCount;

    final blocks =
        List<Block>.generate(kBlocksColumnCount * kBlocksRowCount, (int index) {
      final block = Block(
        blockSize: Vector2(sizeX, sizeY),
      );

      final indexX = index % kBlocksRowCount;
      final indexY = index ~/ kBlocksRowCount;

      block.position
        ..x = kBlocksStartXPosition + indexX * (block.size.x + kBlockPadding)
        ..y = kBlocksStartYPosition + indexY * (block.size.y + kBlockPadding);

      return block;
    });

    await addAll(blocks);
  }
}

resetBlocks で行っているブロックのサイズの決定、位置の決定は、
複雑な上、Flameでのゲーム開発の本筋から離れる内容となっています。

なので、コードをコピペし、
「ここでサイズ指定や位置の設定をしたブロックのリストを生成し、配置してるんだな」
というくらいの理解で次に進んで構いません。

詳細を知りたい方向けに、以下で処理内容の解説をまとめていますので、
興味のある方はぜひ読んでみてください。

ブロックのsizeの決定

ブロックのサイズの決定は、
全体の長さから隙間の長さを引いたものを、ブロックの数で割って求めています。

横方向
    final sizeX = (size.x -
            kBlocksStartXPosition * 2 -
            kBlockPadding * (kBlocksRowCount - 1)) /
        kBlocksRowCount;
縦方向
    final sizeY = (size.y * kBlocksHeightRatio -
            kBlocksStartYPosition -
            kBlockPadding * (kBlocksColumnCount - 1)) /
        kBlocksColumnCount;
ブロックのリスト生成

List<Block>.generate()BlockListを生成しています。

第1引数でブロックの個数を、第2引数でインデックスに対応するBlockを返すメソッドを設定します。
ブロックの個数は横の個数 * 縦の個数 (kBlocksColumnCount * kBlocksRowCount)となります。

ブロックの横の位置と縦の位置のインデックス

インデックスに対する横の位置と縦の位置のインデックスを
以下のコードで取得しています。

      final indexX = index % kBlocksRowCount;
      final indexY = index ~/ kBlocksRowCount;

(~/は割り算の結果を超さない最大の整数を返す演算子です。)

以下の図は3 x 2 のときの例となります。

ブロックの横の位置と縦の位置

ブロックの横の位置と縦の位置は以下のコードで設定しています。

      block.position
        ..x = kBlocksStartXPosition + indexX * (block.size.x + kBlockPadding)
        ..y = kBlocksStartYPosition + indexY * (block.size.y + kBlockPadding);

以下は横の位置の設定の考え方の図となります。
(縦の位置も同様の考え方となります。)

ここまでできたらアプリを再実行してみましょう。

以下の画像のように、様々な色のブロックが整列して配置されるはずです。

上記画像含め以下で実行結果と画像でブロックの厚みが異なるかもしれませんが、
作成に問題はありません。

以上でブロックの追加は完了となります!

ボールの移動

ボールを動かす方法について解説します。

Componentにはupdateというメソッドが用意されています。
これはごく短い単位時間ごとに実行されるメソッドです。

このメソッドの中で、単位時間ごとにpositionを更新することで、
ボールの移動を実装できます。

ball.dartを以下のように変更します。

import 'dart:math';   // 追加
import 'dart:ui';

import 'package:flame/components.dart';

import '../../constants/constants.dart';

class Ball extends CircleComponent {
  Ball() {
    radius = kBallRadius;
    paint = Paint()..color = kBallColor;

    final vx = kBallSpeed * cos(spawnAngle * kRad);  // 追加
    final vy = kBallSpeed * sin(spawnAngle * kRad);  // 追加
    velocity = Vector2(vx, vy);  // 追加
  }
  late Vector2 velocity;  // 追加

  double get spawnAngle {  // メソッド追加
    final random = Random().nextDouble();
    final spawnAngle =
        lerpDouble(kBallMinSpawnAngle, kBallMaxSpawnAngle, random)!;
    return spawnAngle;
  }

  @override
  void update(double dt) {  // メソッド追加
    position += velocity * dt;
    super.update(dt);
  }
}

ボールの横方向、縦方向それぞれに対する速度を持った変数、velocityを定義し、
コンストラクタ内で初期化しています。

コンストラクタ内の初期化処理並びにspawnAngleで行っている処理は、
これも複雑な上、Flameでのゲーム開発の本筋から離れる内容となっています。

なので、コードをコピペし、
「ここでランダムな角度でボールが投げ出されるよう、設定しているんだな」
というくらいの理解で次に進んで構いません。

詳細を知りたい方向けに、以下で処理内容の解説をまとめていますので、
興味のある方はぜひ読んでみてください。

spawnAngle について
  double get spawnAngle {
    final random = Random().nextDouble();
    final spawnAngle =
        lerpDouble(kBallMinSpawnAngle, kBallMaxSpawnAngle, random)!;
    return spawnAngle;
  }

spawnAngle はボール出現時の角度を取得するゲッターです。

random は 0 と 1 の間のランダムな数値となります。
lerpDoubleは、kBallMinSpawnAnglekBallMaxSpawnAngleの2つの角度を、
randomの比率で分ける角度を返します。
これにより、kBallMinSpawnAnglekBallMaxSpawnAngleの2つの角度の間の
ランダムな角度が取得できます。

速度ベクトルの初期設定について
    final vx = kBallSpeed * cos(spawnAngle * kRad);
    final vy = kBallSpeed * sin(spawnAngle * kRad);

このコードで、kBallSpeedの速さを持ち、特定の角度で進む物体の、
横方向(x方向)と縦方向(y方向)の速さを設定しています。

ここで、θはラジアンなので、kRadをかけてラジアンに変換しています。

ここで求めたvxvyvelocityに設定することで、
ランダムな角度にkBallSpeedの速さで進むよう、設定ができます。

updateメソッド内で、
ボールの位置を表すパラメータのpositionを、単位時間dt * velocityだけ進むよう設定することで、
ボールの移動を実現しています。

ここまでできたらアプリを再実行してみましょう。

以下のGIFのように、ボールが下方向に移動するはずです。
(GIFではわかりやすいようにkBallSpeed = 50としています。)

以上でボールの移動の設定は完了となります!

パドルのドラッグ移動

パドルをドラッグ移動できるようにしましょう。

Flame でドラッグができるようにしたり、タップできるようにしたり等、
何かの機能を追加する時には、
そのComponentに追加したい機能を持つクラスをmixinすることで実装できます。

mixin とは、
クラス名の横にwithで追加したい機能を持つクラスを記述することで、
追加したい機能を持つクラスのメソッドを使えるようにすることです。

paddle.dartにてPaddleクラスに、DragCallbacksクラスをmixinします。
PaddleクラスがDragCallbacksクラスのメソッドを使えるようになるので、
以下のコードのようにコードを追加し、ドラッグによって位置が変更されるようにします。

import 'dart:ui';

import 'package:flame/components.dart';
import 'package:flame/experimental.dart';  // 追加

import '../../constants/constants.dart';

class Paddle extends RectangleComponent with DragCallbacks {  // 修正
  Paddle({required this.draggingPaddle})  // 修正
      : super(
          size: Vector2(kPaddleWidth, kPaddleHeight),
          paint: Paint()..color = kPaddleColor,
        );

  final void Function(DragUpdateEvent event) draggingPaddle;  // 追加

  @override
  void onDragUpdate(DragUpdateEvent event) {  // メソッド追加
    draggingPaddle(event);
    super.onDragUpdate(event);
  }
}

onDragUpdateメソッドはドラッグをしているポインターを移動した際に呼ばれるメソッドです。
このメソッド中でdraggingPaddleメソッドを呼び出します。

draggingPaddleメソッドの実装はblock_breaker.dartBlockBreakerクラスにて行います。
そのため、draggingPaddleメソッドを受け取れるようコンストラクタに設定しています。

続いてblock_breaker.dartを修正していきます。

パドルを配置している、block_breaker.dartBlockBreakerクラスにも、
mixinが必要です。
HasDraggableComponents クラスをBlockBreakerクラスにmixinします。
block_breaker.dart にてdraggingPaddleメソッドの実装を行ったコードが、以下となります。

import 'package:flame/experimental.dart';  // 追加
import 'package:flame/game.dart';

import '../constants/constants.dart';
import 'component/ball.dart';
import 'component/block.dart';
import 'component/paddle.dart';

class BlockBreaker extends FlameGame with HasDraggableComponents {  // 修正
  @override
  Future<void>? onLoad() async {
    final paddle = Paddle(
      draggingPaddle: draggingPaddle,  // 追加
    );
    final paddleSize = paddle.size;
    paddle
      ..position.x = size.x / 2 - paddleSize.x / 2
      ..position.y = size.y - paddleSize.y - kPaddleStartY;

    await addAll([
      paddle,
    ]);

    await resetBall();
    await resetBlocks();
  }

  Future<void> resetBall() async {
  // ...
  }

  Future<void> resetBlocks() async {
  // ...
  }

  void draggingPaddle(DragUpdateEvent event) {
    final paddle = children.whereType<Paddle>().first;

    paddle.position.x += event.delta.x;

    if (paddle.position.x < 0) {
      paddle.position.x = 0;
    }
    if (paddle.position.x > size.x - paddle.size.x) {
      paddle.position.x = size.x - paddle.size.x;
    }
  }
}

draggingPaddleメソッドの実装について解説します。

ここでポイントとなるのが、draggingPaddleメソッド内のchildrenです。
childrenにより、配置しているComponent達を取得することができます。
今回、Paddleは一個しか無い想定のため、
children.whereType<Paddle>().firstで配置しているPaddleを取得できることとなります。

draggingPaddleDragUpdateEventからは、ドラッグでの移動量をevent.deltaで取得できます。
これを用いてパドルの位置を更新しています。

以降の処理はパドルが画面外に移動しないようにする処理となります。

ここまでできたらアプリを再実行してみましょう。

以下のGIFのように、パドルがドラッグに応じて移動するはずです。

以上でパドルのドラッグ移動の設定は完了となります!

衝突時の挙動の追加

以下について、衝突時の挙動の追加(反射の設定)を行っていきます。

  • ボールとパドル
  • ボールとブロック
  • ボールと壁

ボールとパドル

衝突時の挙動の追加は、パドルのドラッグの設定時と同様、
Flameに用意されている衝突機能を持つクラスをmixinすることで追加できます。

また、衝突を判定するために、Hit Box と呼ばれる当たり判定
(物体がぶつかることができる範囲)を追加する必要があります。

paddle.dartPaddleを以下のように修正してください。

import 'dart:ui';

import 'package:flame/collisions.dart';  // 追加
import 'package:flame/components.dart';
import 'package:flame/experimental.dart';

import '../../constants/constants.dart';

class Paddle extends RectangleComponent with CollisionCallbacks, DragCallbacks {  // 修正
  Paddle({required this.draggingPaddle})
      : super(
          size: Vector2(kPaddleWidth, kPaddleHeight),
          paint: Paint()..color = kPaddleColor,
        );

  final void Function(DragUpdateEvent event) draggingPaddle;

  @override
  Future<void>? onLoad() {  // メソッド追加
    final paddleHitbox = RectangleHitbox(
      size: size,
    );

    add(paddleHitbox);

    return super.onLoad();
  }

  @override
  void onDragUpdate(DragUpdateEvent event) {
    draggingPaddle(event);
    super.onDragUpdate(event);
  }
}

PaddleクラスにCollisionCallbacksクラスをmixinすることで衝突機能を追加しています。

Paddleクラスが読み込まれる際に実行されるonLoadメソッドの中で、
RectangleHitboxクラスのインスタンスをaddしています。
これにより、sizeの大きさを持った長方形の当たり判定がPaddleに付与されます。

同様にボールにも当たり判定を追加しましょう。

ball.dart を以下のように書き換えます。

import 'dart:math';
import 'dart:ui';

import 'package:flame/collisions.dart';  // 追加
import 'package:flame/components.dart';

import '../../constants/constants.dart';

class Ball extends CircleComponent with CollisionCallbacks {  // 修正
  Ball() {
    radius = kBallRadius;
    paint = Paint()..color = kBallColor;

    final vx = kBallSpeed * cos(spawnAngle * kRad);
    final vy = kBallSpeed * sin(spawnAngle * kRad);
    velocity = Vector2(vx, vy);
  }
  late Vector2 velocity;

  double get spawnAngle {
  // ...
  }

  @override
  Future<void>? onLoad() async {  // メソッド追加
    final hitbox = CircleHitbox(radius: radius);

    await add(hitbox);

    return super.onLoad();
  }

  @override
  void update(double dt) {
    position += velocity * dt;
    super.update(dt);
  }
}

Ballクラスが読み込まれる際に実行されるonLoadメソッドの中で、
CircleHitboxクラスのインスタンスをaddしています。
これにより、radiusの半径を持った円の当たり判定がBallに付与されます。

当たり判定の追加は以上で完了となります。

続いて衝突時の挙動を設定していきましょう。
CollisionCallbacksの追加により、onCollisionStart , onCollision , onCollisionEnd
というメソッドが利用可能となります。
これらのメソッドは、それぞれ、
衝突の開始時、衝突中、衝突の終了時に呼び出されるメソッドとなっています。

ball.dartBallクラスにonCollisionStartメソッド等を追加します。

import 'dart:math';
import 'dart:ui';

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';

import '../../constants/constants.dart';
import 'paddle.dart';  // 追加

class Ball extends CircleComponent with CollisionCallbacks {
  Ball() {
  // ...
  }
  late Vector2 velocity;

  double get spawnAngle {
  // ...
  }

  @override
  Future<void>? onLoad() async {
  // ...
  }

  @override
  void update(double dt) {
  // ...
  }

  @override
  void onCollisionStart(  // メソッド追加
    Set<Vector2> intersectionPoints,
    PositionComponent other,
  ) {
    final collisionPoint = intersectionPoints.first;

    if (other is Paddle) {
      final paddleRect = other.toAbsoluteRect();

      updateBallTrajectory(collisionPoint, paddleRect);
    }

    super.onCollisionStart(intersectionPoints, other);
  }

  void updateBallTrajectory(  // メソッド追加
    Vector2 collisionPoint,
    Rect rect,
  ) {
    final isLeftHit = collisionPoint.x == rect.left;
    final isRightHit = collisionPoint.x == rect.right;
    final isTopHit = collisionPoint.y == rect.top;
    final isBottomHit = collisionPoint.y == rect.bottom;

    final isLeftOrRightHit = isLeftHit || isRightHit;
    final isTopOrBottomHit = isTopHit || isBottomHit;

    if (isLeftOrRightHit) {
      if (isRightHit && velocity.x > 0) {
        velocity.x += kBallNudgeSpeed;
        return;
      }

      if (isLeftHit && velocity.x < 0) {
        velocity.x -= kBallNudgeSpeed;
        return;
      }

      velocity.x = -velocity.x;
      return;
    }

    if (isTopOrBottomHit) {
      velocity.y = -velocity.y;
      if (Random().nextInt(kBallRandomNumber) % kBallRandomNumber == 0) {
        velocity.x += kBallNudgeSpeed;
      }
    }
  }
}

onCollisionStartメソッドでは、衝突している物体同士の交点がintersectionPointsで、
衝突している相手がotherで取得できます。

collisionPointとして、衝突時の交点を1点取得します。
other is Paddleの条件判定で、衝突相手がPaddle かどうかを確認し、
other.toAbsoluteRectBlockの外形を取得します。

updateBallTrajectoryはパドルやブロックとの衝突時の速度変化を管理するメソッドです。

このメソッドについて説明する前に、どうやって反射しているように見せるか、
を解説します。

物体が反射しているように見せるためには、
衝突したタイミングで、反射面と垂直方向の速さを-1倍(向きを反転)させればよいです。

updateBallTrajectoryの前半部分は、
衝突がPaddle等の長方形のどの辺で起きているのかを判定する準備となります。

isLeftOrRightHit の条件分岐内では、長方形の左右に衝突した際の挙動を定義しています。

isLeftOrRightHit の条件分岐内の以下コードは、
Paddleがボールを追いかけて衝突したときの挙動を表現しています。

      if (isRightHit && velocity.x > 0) {
        velocity.x += kBallNudgeSpeed;
        return;
      }

      if (isLeftHit && velocity.x < 0) {
        velocity.x -= kBallNudgeSpeed;
        return;
      }

isTopOrBottomHit の条件分岐内では、長方形の上下に衝突した際の挙動を定義しています。

isTopOrBottomHit の条件分岐内でyを反転させているのは前述の説明の通りですが、
xについても以下の処理を加えています。

      if (Random().nextInt(kBallRandomNumber) % kBallRandomNumber == 0) {
        velocity.x += kBallNudgeSpeed;
      }

前述の反射の方法だと、ボールが常に入射角と同じ角度で反射するため、
面白みに欠けることとなります。
上記処理でランダムでvelocity.xに値を加えることで、
ランダムな反射を実現しています。

ゲーム全体を管理する、block_breaker.dartBlockBreakerクラスにも、
衝突機能を与えるmixinが必要です。

以下のようにblock_breaker.dartを修正します。

import 'package:flame/experimental.dart';
import 'package:flame/game.dart';

import '../constants/constants.dart';
import 'component/ball.dart';
import 'component/block.dart';
import 'component/paddle.dart';

class BlockBreaker extends FlameGame
    with HasCollisionDetection, HasDraggableComponents {  // 修正
// ...
}

以上で、ボールとパドルの衝突の設定は完了となります。

ボールとブロック

ボールとブロックの衝突時の挙動を設定していきます。

まず、ball.dart を以下のように修正します。

import 'dart:math';
import 'dart:ui';

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';

import '../../constants/constants.dart';
import 'block.dart' as b;  // 追加
import 'paddle.dart';

class Ball extends CircleComponent with CollisionCallbacks {
// ...

  @override
  void onCollisionStart(
    Set<Vector2> intersectionPoints,
    PositionComponent other,
  ) {
    final collisionPoint = intersectionPoints.first;

    if (other is b.Block) {  // 条件分岐追加
      final blockRect = other.toAbsoluteRect();

      updateBallTrajectory(collisionPoint, blockRect);
    }

    if (other is Paddle) {
      final paddleRect = other.toAbsoluteRect();

      updateBallTrajectory(collisionPoint, paddleRect);
    }

    super.onCollisionStart(intersectionPoints, other);
  }

// ...

}

onCollisionStartメソッドに、Blockとの衝突時の挙動を設定しています。

設定内容はPaddleと同じとなっています。

続いて、Blockのコードを修正していきます。

block.dartを以下のように修正してください。

import 'dart:math';
import 'dart:ui';

import 'package:flame/collisions.dart';  // 追加
import 'package:flame/components.dart';

import '../../constants/constants.dart';
import 'ball.dart';  // 追加

class Block extends RectangleComponent with CollisionCallbacks {  // 修正
  Block({required this.blockSize})
      : super(
          size: blockSize,
          paint: Paint()
            ..color = kBlockColors[Random().nextInt(kBlockColors.length)],
        );

  final Vector2 blockSize;

  @override
  Future<void>? onLoad() async {  // メソッド追加
    final blockHitbox = RectangleHitbox(
      size: size,
    );

    await add(blockHitbox);

    return super.onLoad();
  }

  @override
  void onCollisionStart(  // メソッド追加
    Set<Vector2> intersectionPoints,
    PositionComponent other,
  ) {
    if (other is Ball) {
      removeFromParent();
    }

    super.onCollisionStart(intersectionPoints, other);
  }
}

CollisionCallbacksmixinの追加を行っています。
また、onLoadメソッド内で当たり判定を設定するRectangleHitboxの追加を行っています。

ブロックは、ボールと衝突時に消えるように設定したいため、
onCollisionStartメソッドの中で、
Ballとの衝突時にremoveFromParentを呼び出し、
自身がゲームから削除されるようにしています。

以上で、ボールとブロックの衝突の設定は完了となります。

ボールと壁

ボールとゲームの外枠(壁)との衝突の設定を行います。

block_breaker.dartを以下のように修正します。

import 'package:flame/collisions.dart';  // 追加
import 'package:flame/experimental.dart';
import 'package:flame/game.dart';

import '../constants/constants.dart';
import 'component/ball.dart';
import 'component/block.dart';
import 'component/paddle.dart';

class BlockBreaker extends FlameGame
    with HasCollisionDetection, HasDraggableComponents {
  @override
  Future<void>? onLoad() async {
    final paddle = Paddle(
      draggingPaddle: draggingPaddle,
    );
    final paddleSize = paddle.size;
    paddle
      ..position.x = size.x / 2 - paddleSize.x / 2
      ..position.y = size.y - paddleSize.y - kPaddleStartY;

    await addAll([
      ScreenHitbox(),  // 追加
      paddle,
    ]);

    await resetBall();
    await resetBlocks();
  }
// ...
}

ここで、ScreenHitboxaddしています。
これは画面枠に付与する当たり判定となります。

続いて、ball.dartを以下のように修正します。

import 'dart:math';
import 'dart:ui';

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';

import '../../constants/constants.dart';
import 'block.dart' as b;
import 'paddle.dart';

class Ball extends CircleComponent with CollisionCallbacks {
  Ball() {
    radius = kBallRadius;
    paint = Paint()..color = kBallColor;

    final vx = kBallSpeed * cos(spawnAngle * kRad);
    final vy = kBallSpeed * sin(spawnAngle * kRad);
    velocity = Vector2(vx, vy);
  }
  late Vector2 velocity;

  bool isCollidedScreenHitboxX = false;  // 追加
  bool isCollidedScreenHitboxY = false;  // 追加

  double get spawnAngle {
    final random = Random().nextDouble();
    final spawnAngle =
        lerpDouble(kBallMinSpawnAngle, kBallMaxSpawnAngle, random)!;
    return spawnAngle;
  }

  // ... 

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

  @override
  void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {  // メソッド追加
    if (other is ScreenHitbox) {
      final screenHitBoxRect = other.toAbsoluteRect();

      for (final point in intersectionPoints) {
        if (point.x == screenHitBoxRect.left && !isCollidedScreenHitboxX) {
          velocity.x = -velocity.x;
          isCollidedScreenHitboxX = true;
        }
        if (point.x == screenHitBoxRect.right && !isCollidedScreenHitboxX) {
          velocity.x = -velocity.x;
          isCollidedScreenHitboxX = true;
        }
        if (point.y == screenHitBoxRect.top && !isCollidedScreenHitboxY) {
          velocity.y = -velocity.y;
          isCollidedScreenHitboxY = true;
        }
        if (point.y == screenHitBoxRect.bottom && !isCollidedScreenHitboxY) {
          removeFromParent();
        }
      }
    }
    super.onCollision(intersectionPoints, other);
  }

  @override
  void onCollisionEnd(PositionComponent other) {  // メソッド追加
    isCollidedScreenHitboxX = false;
    isCollidedScreenHitboxY = false;
    super.onCollisionEnd(other);
  }

  // ...
}

onCollisionは物体の衝突中に呼び出されるメソッドです。
壁への反射だと画面の角など、上辺に当たって離れるまでの間に左右の辺に当たる、
という場合が発生します。
そのような場合に対応するため、
onCollisionメソッドで横方向(x方向)、縦方向(y方向)それぞれ反射の実装をしています。
isCollidedScreenHitboxXisCollidedScreenHitboxYは、
横方向(x方向)、縦方向(y方向)それぞれで反射したかどうかを判定するフラグです。
これがfalseのときのみ反射の処理を行うようにし、
衝突の終了時に呼び出されるonCollisionEndのメソッドの中でフラグを初期化します。

画面最下部にボールが衝突時には、removeFromParentし、ボール自体を消滅させます。

ここまでできたらアプリを再実行してみましょう。

以下のGIFのように、壁やブロック、パドルとボールが反射し、ブロックと
ぶつかるとブロックが壊れるはずです。

ブロック崩しゲームがほとんど出来上がりましたね!

以上で衝突の挙動の追加は完了となります。

開始ボタンの追加

現状、ビルドするとすぐにゲームが始まってしまう状態となっています。
これを開始ボタンとボールが動くまでのカウントダウンを追加し、遊びやすくします。

テキストボタンの作成

my_text_button.dartに以下のコードを追加します。

import 'dart:ui';

import 'package:flame/components.dart';
import 'package:flame/experimental.dart';

import '../../constants/constants.dart';

class MyTextButton extends TextBoxComponent with TapCallbacks {
  MyTextButton(
    String text, {
    required this.onTapDownMyTextButton,
    required this.renderMyTextButton,
  }) : super(
          text: text,
          size: Vector2(kButtonWidth, kButtonHeight),
          align: Anchor.center,
        );

  final Future<void> Function() onTapDownMyTextButton;
  final void Function(Canvas canvas) renderMyTextButton;

  @override
  Future<void> onTapDown(TapDownEvent event) async {
    await onTapDownMyTextButton();
    super.onTapDown(event);
  }

  @override
  void render(Canvas c) {
    renderMyTextButton(c);
    super.render(c);
  }
}

このコードでは、テキストボタンを設定する、MyTextButtonクラスを作成しています。
MyTextButtonクラスはTextBoxComponentクラスの拡張クラスしており、
この拡張により、テキストボックスの機能を持つようになります。

TapCallbacksmixinしています。
これにより、このComponentをタップ可能にします。

今回、テキストボタンのテキストはコンストラクタを用いて受け取り、
super内でtextを受け渡すようにします。
同時に、sizealign (ボタンに対するテキストの位置)も設定します。

ボタンを押したときの挙動を示すonTapDownの中身や、
ボタンの色等描画内容を管理するrenderメソッドの中身は、
実装元のBlockBreakerクラスにて実装するため、
関数の変数を用いて定義し、コンストラクタで受け取るようにします。

テキストボタンの押したときの挙動やrenderの中身は後ほど実装します。

カウントダウンテキストの作成

ゲーム画面に表示するカウントダウンのテキストを作成します。

countdown_text.dartにて、以下のコードを追加してください。

import 'dart:ui';

import 'package:flame/components.dart';

import '../../constants/constants.dart';

class CountdownText extends TextComponent {
  CountdownText({
    required this.count,
  }) : super(
          size: Vector2.all(kCountdownSize),
          textRenderer: TextPaint(
            style: kCountdownTextStyle,
          ),
          text: '$count',
        );

  final int count;

  @override
  Future<void> render(Canvas canvas) async {
    super.render(canvas);
    await Future<void>.delayed(const Duration(seconds: 1));
    removeFromParent();
  }
}

このコードでは、カウントダウンのテキストを設定する、CountdownTextクラスを作成しています。
CountdownTextクラスはTextComponentクラスの拡張クラスしており、
この拡張により、テキストの機能を持つようになります。

カウントダウンの数字はコンストラクタで受け取り、
super内でtextに設定します。

同じくsuper内でテキスト全体のサイズと、文字色等のスタイルを設定しています。

renderメソッドで、描画された際に1秒後に消滅するよう、
removeFromParentを呼び出すように設定しています。

以上でカウントダウンテキストの設定は完了となります。

開始時の挙動の実装

ボタンの配置と開始時の挙動の実装をしていきます。

block_breaker.dartを以下のように追加、修正します。

import 'dart:ui';  // 追加

import 'package:flame/collisions.dart';
import 'package:flame/experimental.dart';
import 'package:flame/game.dart';

import '../constants/constants.dart';
import 'component/ball.dart';
import 'component/block.dart';
import 'component/countdown_text.dart';  // 追加
import 'component/my_text_button.dart';  // 追加
import 'component/paddle.dart';

class BlockBreaker extends FlameGame
    with HasCollisionDetection, HasDraggableComponents, HasTappableComponents {  // 修正
  @override
  Future<void>? onLoad() async {
    final paddle = Paddle(
      draggingPaddle: draggingPaddle,
    );
    final paddleSize = paddle.size;
    paddle
      ..position.x = size.x / 2 - paddleSize.x / 2
      ..position.y = size.y - paddleSize.y - kPaddleStartY;

    await addMyTextButton('Start!');  // 追加

    await addAll([
      ScreenHitbox(),
      paddle,
    ]);
    // resetBall を削除
    await resetBlocks();
  }

  Future<void> resetBall() async {
  // ...
  }

  Future<void> resetBlocks() async {
  // ...
  }

  Future<void> addMyTextButton(String text) async {  // メソッド追加
    final myTextButton = MyTextButton(
      text,
      onTapDownMyTextButton: onTapDownMyTextButton,
      renderMyTextButton: renderMyTextButton,
    );

    myTextButton.position
      ..x = size.x / 2 - myTextButton.size.x / 2
      ..y = size.y / 2 - myTextButton.size.y / 2;

    await add(myTextButton);
  }

  void draggingPaddle(DragUpdateEvent event) {
  // ...
  }

  Future<void> onTapDownMyTextButton() async {  // メソッド追加
    children.whereType<MyTextButton>().forEach((button) {
      button.removeFromParent();
    });
    await countdown();
    await resetBall();
  }

  void renderMyTextButton(Canvas canvas) {  // メソッド追加
    final myTextButton = children.whereType<MyTextButton>().first;
    final rect = Rect.fromLTWH(
      0,
      0,
      myTextButton.size.x,
      myTextButton.size.y,
    );
    final bgPaint = Paint()..color = kButtonColor;
    canvas.drawRect(rect, bgPaint);
  }

  Future<void> countdown() async {
    for (var i = kCountdownDuration; i > 0; i--) {
      final countdownText = CountdownText(count: i);

      countdownText.position
        ..x = size.x / 2 - countdownText.size.x / 2
        ..y = size.y / 2 - countdownText.size.y / 2;

      await add(countdownText);

      await Future<void>.delayed(const Duration(seconds: 1));
    }
  }
}

ボタンのタップを機能を追加するため、HasTappableComponentsmixinしています。

テキストボタンの配置処理は何度も行うため、addMyTextButtonでメソッド化します。

onTapDownMyTextButtonメソッドはボタンのタップしたときの処理を定義しています。
具体的には、

children.whereType<MyTextButton>().forEach((button) {       button.removeFromParent();     });

でのボタンの削除と、
後述するcountdownでのカウントダウンの開始、
ボールのリセットを行っています。

renderMyTextButtonはテキストボタンの描画処理の設定を記述しています。

countdownメソッドではCountdownテキストを1秒ごとに配置する処理を行っています。

ここまでできたらアプリを実行してみましょう。

Start! と書かれたボタンが表示され、押すとカウントダウンが開始し、
カウントダウンの終了とともにボールが動き出すはずです。

以上で開始時の挙動の追加は完了となります。

失敗時の挙動の追加

失敗時の挙動の追加をしていきます。

失敗時 = ボールが削除された時のため、
ボールが削除された時に呼び出されれるonRemove メソッドを利用して、
失敗時の挙動を追加します。

ball.dartを以下のように修正します。

import 'dart:math';
import 'dart:ui';

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';

import '../../constants/constants.dart';
import 'block.dart' as b;
import 'paddle.dart';

class Ball extends CircleComponent with CollisionCallbacks {
  Ball({
    required this.onBallRemove,  // 修正
  }) {
  // ...
  }
  late Vector2 velocity;

  bool isCollidedScreenHitboxX = false;
  bool isCollidedScreenHitboxY = false;

  final Future<void> Function() onBallRemove;  // 追加

  double get spawnAngle {
  // ...
  }

  @override
  Future<void>? onLoad() async {
  // ...
  }

  @override
  void update(double dt) {
  // ...
  }

  @override
  Future<void> onRemove() async {  // メソッド追加
    await onBallRemove();
    super.onRemove();
  }
  // ...
}

onRemoveメソッドの追加を行いました。
実際の処理は実装元のBlockBreakerクラスにて実装するため、
関数の変数を用いて定義し、コンストラクタで受け取るようにします。

onBallRemoveをコンストラクタの引数に設定する際に中括弧を忘れないよう注意ください。

続いて、block_breaker.dartを以下のように修正します。

import 'dart:ui';

import 'package:flame/collisions.dart';
import 'package:flame/experimental.dart';
import 'package:flame/game.dart';

import '../constants/constants.dart';
import 'component/ball.dart';
import 'component/block.dart';
import 'component/countdown_text.dart';
import 'component/my_text_button.dart';
import 'component/paddle.dart';

class BlockBreaker extends FlameGame
    with HasCollisionDetection, HasDraggableComponents, HasTappableComponents {
  int failedCount = kGameTryCount;  // 追加

  bool get isGameOver => failedCount == 0;  // 追加

// ...

  Future<void> resetBall() async {
    final ball = Ball(
      onBallRemove: onBallRemove,  // 追加
    );

    ball.position
      ..x = size.x / 2 - ball.size.x / 2
      ..y = size.y * kBallStartYRatio;

    await add(ball);
  }

  Future<void> resetBlocks() async {
    children.whereType<Block>().forEach((block) {  // 追加
      block.removeFromParent();
    });

    final sizeX = (size.x -
            kBlocksStartXPosition * 2 -
            kBlockPadding * (kBlocksRowCount - 1)) /
        kBlocksRowCount;
  // ...
  }


  Future<void> addMyTextButton(String text) async {
  // ...
  }

  Future<void> onBallRemove() async {  // メソッド追加
    failedCount--;
    if (isGameOver) {
      await addMyTextButton('Game Over!');
    } else {
      await addMyTextButton('Retry');
    }
  }

// ...

  Future<void> onTapDownMyTextButton() async {
    children.whereType<MyTextButton>().forEach((button) {
      button.removeFromParent();
    });

    if (isGameOver) {  // 条件分岐追加
      await resetBlocks();
      failedCount = kGameTryCount;
    }

    await countdown();
    await resetBall();
  }

  void renderMyTextButton(Canvas canvas) {
    final myTextButton = children.whereType<MyTextButton>().first;
    final rect = Rect.fromLTWH(
      0,
      0,
      myTextButton.size.x,
      myTextButton.size.y,
    );
    final bgPaint = Paint()
      ..color = isGameOver ? kGameOverButtonColor : kButtonColor;  // 修正
    canvas.drawRect(rect, bgPaint);
  }
// ...
}

failedCountというフィールドを追加しています。
これは、ボールが最下部に落ちることができる残り回数(残り失敗可能回数)を記録するフィールドです。

また、isGameOverというゲッターを追加し、failedCount0
つまり失敗可能回数が0となったかどうかを取得しています。

resetBallメソッドの中でBallのコンストラクタにonBallRemoveメソッドを渡しています。

resetBlocksメソッドの最初で、
その時点で残っているBlockをすべて削除する処理を加えています。

onBallRemoveメソッドを追加しています。
ボールが最下部に落ちた際 = ボールが削除された際に、
failedCount-1し、ゲームオーバーならGame Overと書かれたボタンを、
そうでないならRetry と書かれたボタンを表示するように設定しています。

onTapDownMyTextButtonメソッドにて
ゲームオーバー時にブロックをリセットする処理を呼び出します。
また、failedCountの初期化も行っています。

renderMyTextButtonの修正もしています。
ゲームオーバー時にボタンを赤くするように設定しています。

ここまでできたらアプリを実行してみてください。

ボールが最下部に落ちるとRetryボタンやGameOverボタンが表示され、
リトライ機能が追加されているはずです。

以上が失敗時の挙動の追加となります。

クリア時の挙動の追加

ゲームクリア時(ブロックを全部壊した時)の挙動を追加します。

ブロックが壊れた時にブロックがすべて壊れているかをチェックし、
全て壊れていたらクリアボタンを表示するようにします。

block.dartを以下のように修正します。

import 'dart:math';
import 'dart:ui';

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';

import '../../constants/constants.dart';
import 'ball.dart';

class Block extends RectangleComponent with CollisionCallbacks {
  Block({required this.blockSize, required this.onBlockRemove})  // 修正 
      : super(
          size: blockSize,
          paint: Paint()
            ..color = kBlockColors[Random().nextInt(kBlockColors.length)],
        );

  final Vector2 blockSize;
  final Future<void> Function() onBlockRemove;  // 追加

  @override
  Future<void>? onLoad() async {
  // ...
  }

  @override
  void onCollisionStart(
  // ...
  }

  @override
  Future<void> onRemove() async {  // メソッド追加
    await onBlockRemove();
    super.onRemove();
  }
}

Ballと同様、onRemoveメソッドの追加を行いました。
実際の処理は実装元のBlockBreakerクラスにて実装するため、
関数の変数を用いて定義し、コンストラクタで受け取るようにします。

続いて、block_breaker.dartを以下のように修正します。

import 'dart:async';
import 'dart:ui';

import 'package:flame/collisions.dart';
import 'package:flame/experimental.dart';
import 'package:flame/game.dart';

import '../constants/constants.dart';
import 'component/ball.dart';
import 'component/block.dart';
import 'component/countdown_text.dart';
import 'component/my_text_button.dart';
import 'component/paddle.dart';

class BlockBreaker extends FlameGame
    with HasCollisionDetection, HasDraggableComponents, HasTappableComponents {
  int failedCount = kGameTryCount;

  bool get isCleared => children.whereType<Block>().isEmpty;  // 追加

  bool get isGameOver => failedCount == 0;

  // ...

  Future<void> resetBlocks() async {
    children.whereType<Block>().forEach((block) {
      block.removeFromParent();
    });

    final sizeX = (size.x -
            kBlocksStartXPosition * 2 -
            kBlockPadding * (kBlocksRowCount - 1)) /
        kBlocksRowCount;

    final sizeY = (size.y * kBlocksHeightRatio -
            kBlocksStartYPosition -
            kBlockPadding * (kBlocksColumnCount - 1)) /
        kBlocksColumnCount;

    final blocks =
        List<Block>.generate(kBlocksColumnCount * kBlocksRowCount, (int index) {
      final block = Block(
        blockSize: Vector2(sizeX, sizeY),
        onBlockRemove: onBlockRemove,  // 追加
      );

      final indexX = index % kBlocksRowCount;
      final indexY = index ~/ kBlocksRowCount;

      block.position
        ..x = kBlocksStartXPosition + indexX * (block.size.x + kBlockPadding)
        ..y = kBlocksStartYPosition + indexY * (block.size.y + kBlockPadding);

      return block;
    });

    await addAll(blocks);
  }

// ...

  Future<void> onBallRemove() async {
    if (!isCleared) {  // 条件分岐追加
      failedCount--;
      if (isGameOver) {
        await addMyTextButton('Game Over!');
      } else {
        await addMyTextButton('Retry');
      }
    }
  }

  Future<void> onBlockRemove() async {  // 追加
    if (isCleared) {
      await addMyTextButton('Clear!');
      children.whereType<Ball>().forEach((ball) {
        ball.removeFromParent();
      });
    }
  }

  void draggingPaddle(DragUpdateEvent event) {
  // ...
  }

  Future<void> onTapDownMyTextButton() async {
    children.whereType<MyTextButton>().forEach((button) {
      button.removeFromParent();
    });

    if (isCleared || isGameOver) {  // 修正
      await resetBlocks();
      failedCount = kGameTryCount;
    }
    await countdown();
    await resetBall();
  }

// ...

}

isCleared のゲッターを追加しています。
ゲーム画面中のBlockの数をチェックし、Block0になったらtrueを返します。

Blockのインスタンス生成時、コンストラクタにて後述のonBlockRemoveを追加しています。

後述のonBlockRemove にてクリア時にボールを削除する処理を追加するため、
onBallRemoveのメソッドにて、ではBallが削除した時処理を動かさないようにするため、
!isClearedで処理を囲んでいます。

onBlockRemoveにて、ブロックが削除された際にクリアしているか判定し、
クリアしているなら、Clear!と書かれたボタンを表示し、
Ballの削除処理を行うよう、処理を記述しています。

onTapDownMyTextButton内の条件分岐にisClearedを追加し、
クリアした際にもresetBlocksを呼ぶようにします。

ここまでできたらアプリを実行してみましょう。

ブロックが全部削除された際に、Clear! ボタンが表示されます。

以上で、クリア時の挙動の追加は完了です!

効果音の追加

最後に、ボールが物体とぶつかった際の効果音を実装しましょう。

準備

効果音の実装のために、flame_audio パッケージを追加します。
このパッケージはflameで音声の実装をするのに役に立つパッケージです。

CLI(macならターミナル)で、作成したプロジェクトのルートにて
以下のコマンドを実行しflameパッケージをインストールします。

flutter pub add flame_audio

続いて効果音のファイルの配置を行います。

こちらのページのDownloadから、20221011_ball_hit.wav ファイルをダウンロードし、

以下のようにassets/audio フォルダに保存します。

次にpubspec.yamlにて以下のようにassetsの追加を行います。

# 省略

flutter:
  uses-material-design: true

  assets: # 追加
    - assets/audio/ # 追加 

準備は以上で完了となります!

効果音の実装

効果音を実際に実装しましょう。
効果音は以下のコードで実装(再生)できます。

FlameAudio.play('20221011_ball_hit.wav');

ball.dartにて、上記コードの追加を行います。
以下のようにコードを修正してください。

import 'dart:math';
import 'dart:ui';

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame_audio/flame_audio.dart';  // 追加

import '../../constants/constants.dart';
import 'block.dart' as b;
import 'paddle.dart';

class Ball extends CircleComponent with CollisionCallbacks {

// ...

  @override
  void onCollisionStart(
    Set<Vector2> intersectionPoints,
    PositionComponent other,
  ) {
    final collisionPoint = intersectionPoints.first;

    if (other is b.Block) {
      final blockRect = other.toAbsoluteRect();

      updateBallTrajectory(collisionPoint, blockRect);
    }

    if (other is Paddle) {
      final paddleRect = other.toAbsoluteRect();

      updateBallTrajectory(collisionPoint, paddleRect);
    }

    FlameAudio.play('20221011_ball_hit.wav');  // 追加

    super.onCollisionStart(intersectionPoints, other);
  }
// ...
}

以上で効果音の実装は完了です!

ここまでできたらアプリを実行してみましょう。
ボールがぶつかるたびに音が鳴るのを確認できます。

完成したアプリは以下のようになります。

全体のソースコードは以下のGitHubにて確認可能です。

GitHub - Umigishi-Aoi/block_breaker: This is a flutter project. This project is the game of Block Breaker made by flame.
This is a flutter project. This project is the game of Block Breaker made by flame. - GitHub - Umigishi-Aoi/block_breaker: This is a flutter project. This proje...

まとめ

本記事では、Flutter のゲームエンジンであるFlame を使って、
ブロック崩しゲームを作るチュートリアルを行いました。

長い間お付き合いいただきありがとうございました!

今回追加したドラッグやタップ、衝突の機能を応用すれば、
様々なゲームが作成できると思います。

ぜひこのチュートリアルを利用して、
はじめてのゲーム開発に挑戦してみてください!

本記事があなたのアプリ開発の一助となれば幸いです。

Flutterを一緒に学んでみませんか?
Flutter エンジニアに特化した学習コミュニティ、Flutter大学への入会は、
以下の画像リンクから。



編集後記(2022/10/15)

Flutter x Flame でブロック崩しを作るチュートリアルの記事でした。

改めて思うのは、ゲームづくりというか、アプリづくりって楽しいですね。

このブロック崩しアプリの第一版はほぼ休憩無しで7時間ほど熱中して作成したものでした。
それくらい作ってて楽しかったです。

特に今回のゲームのように作ったものが動いて、
実際にブロックを壊したりするよう実装できたりするのは、
本当に楽しかったです。

ぜひ、あなたにもこの気持ちを味わってほしい、と思って書いたのが、
本記事となります。

いいな、と思ったら、上記GitHubのリポジトリにスターをいただけると励みとなります。

この記事があなたのFlameでのアプリ開発の最初の一歩となることを、
心から祈っております。

オリジナル作品ができたらぜひこちらまで教えて下さい!

週刊Flutter大学では、Flutterに関する技術記事、Flutter大学についての紹介記事を投稿していきます。
記事の更新情報はFlutter大学Twitterにて告知します。
ぜひぜひフォローをお願いいたします。

タイトルとURLをコピーしました