Flame with Flutter の概要

1. はじめに

Flame は Flutter ベースの 2D ゲームエンジンです。この Codelab では、1970 年代のビデオゲームの古典の 1 つであるスティーブ・ウォズニアックの Breakout にインスピレーションを得たゲームを作成します。Flame のコンポーネントを使用して、バット、ボール、ブロックを描画します。Flame のエフェクトを使用してバットの動きをアニメーション化し、Flame を Flutter の状態管理システムと統合する方法を学びます。

完了すると、ゲームは次のアニメーション GIF のようになります(ただし、少し遅くなります)。

ゲームプレイの画面録画。ゲームのスピードが大幅に上がっています。

学習内容

  • GameWidget から始まる Flame の基本的な仕組み。
  • ゲームループの使用方法。
  • Flame の Component の仕組み。これらは Flutter の Widget に似ています。
  • 衝突の処理方法。
  • Effect を使用して Component をアニメーション化する方法。
  • Flame ゲームの上に Flutter の Widget をオーバーレイする方法。
  • Flame を Flutter の状態管理と統合する方法。

作成するアプリの概要

この Codelab では、Flutter と Flame を使用して 2D ゲームを作成します。完成したゲームは、以下の要件を満たしている必要があります。

  • Flutter でサポートされている 6 つのプラットフォーム(Android、iOS、Linux、macOS、Windows、ウェブ)すべてで機能する
  • Flame のゲームループを使用して 60 fps 以上を維持します。
  • google_fonts パッケージや flutter_animate などの Flutter の機能を使用して、80 年代のアーケード ゲームの雰囲気を再現します。

2. Flutter 環境をセットアップする

編集者

この Codelab を簡素化するため、Visual Studio Code(VS Code)が開発環境であると仮定します。VS Code は無料で、すべての主要なプラットフォームに対応しています。手順では VS Code 専用のショートカットをデフォルトとするため、この Codelab では VS Code を使用します。「お使いのエディタで X を行うための適切な操作を行う」などとするよりも、「このボタンをクリック」や「このキーを押して X を行う」とするほうが簡単です。

Android Studio やその他の IntelliJ IDE、Emacs、Vim、Notepad++ など、任意のエディタを使用できます。どれも Flutter に使用できます。

Flutter コードを含む VS Code

開発ターゲットを選ぶ

Flutter は、複数のプラットフォーム用のアプリを生成します。アプリは、次のオペレーティング システムのいずれでも実行できます。

  • iOS
  • Android
  • Windows
  • macOS
  • Linux
  • ウェブ

開発ターゲットとして 1 つのオペレーティング システムを選ぶのが一般的です。これは、開発中にアプリを実行するオペレーティング システムです。

ノートパソコンと、ケーブルでノートパソコンに接続されたスマートフォンを描いたイラスト。ノートパソコンには

たとえば、Flutter アプリの開発に Windows ノートパソコンを使用するとしましょう。開発ターゲットに Android を選択します。アプリをプレビューするには、USB ケーブルで Android デバイスを Windows ノートパソコンに接続し、開発中のアプリを接続した Android デバイスまたは Android エミュレータで実行します。Windows を開発ターゲットに選ぶことも可能です。その場合、開発中のアプリはエディタといっしょに Windows アプリとして実行します。

続行する前に選択してください。後からいつでも別のオペレーティング システムでアプリを実行できます。開発ターゲットを明確にしていれば、次のステップにスムーズに進めます。

Flutter をインストールする

Flutter SDK の最新のインストール手順は、docs.flutter.dev で確認できます。

Flutter のウェブサイトでは、SDK のインストールだけでなく、開発ターゲット関連のツールやエディタ プラグインについても説明されています。この Codelab では、次のソフトウェアをインストールします。

  1. Flutter SDK
  2. Visual Studio Code と Flutter プラグイン
  3. 選択した開発ターゲット用のコンパイラ ソフトウェア。(Windows をターゲットにするには Visual Studio、macOS または iOS をターゲットにするには Xcode が必要です)

次のセクションでは、初めての Flutter プロジェクトを作成します。

問題のトラブルシューティングが必要な場合は、以下の質問と答え(StackOverflow から)がトラブルシューティングの参考になるかもしれません。

よくある質問

3. プロジェクトを作成する

最初の Flutter プロジェクトを作成する

これには、VS Code を開き、選択したディレクトリに Flutter アプリ テンプレートを作成する作業が含まれます。

  1. Visual Studio Code を起動します。
  2. コマンド パレットを開き(F1Ctrl+Shift+PShift+Cmd+P)、「flutter new」と入力します。[Flutter: New Project] コマンドが表示されたら、それを選択します。

VS Code と

  1. [Empty Application] を選択します。プロジェクトを作成するディレクトリを選択します。これは、昇格された権限を必要とせず、パスにスペースが含まれていないディレクトリである必要があります。たとえば、ホーム ディレクトリや C:\src\ などがあります。

新しいアプリケーション フローの一部として選択されている [Empty Application] が表示された VS Code

  1. プロジェクトに brick_breaker という名前を付けます。この Codelab の残りの部分では、アプリの名前が brick_breaker であると想定しています。

VS Code と

すると、Flutter がプロジェクト フォルダを作成し、VS Code がそのフォルダを開きます。次は、2 つのファイルの内容を、このアプリの基本的なスキャフォールドで上書きします。

初期アプリをコピーして貼り付ける

これにより、この Codelab で提供されているサンプルコードがアプリに追加されます。

  1. VS Code の左側のペインで [エクスプローラ] をクリックし、pubspec.yaml ファイルを開きます。

pubspec.yaml ファイルの場所を矢印でハイライト表示している VS Code の部分的なスクリーンショット

  1. このファイルの内容を次のように置き換えます。

pubspec.yaml

name: brick_breaker
description: "Re-implementing Woz's Breakout"
publish_to: "none"
version: 0.1.0

environment:
  sdk: ^3.8.0

dependencies:
  flutter:
    sdk: flutter
  flame: ^1.28.1
  flutter_animate: ^4.5.2
  google_fonts: ^6.2.1

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0

flutter:
  uses-material-design: true

pubspec.yaml ファイルでは、現在のバージョン、依存関係、同梱するアセットなど、アプリの基本情報を指定します。

  1. lib/ ディレクトリの main.dart ファイルを開きます。

VS Code の一部のスクリーンショット。main.dart ファイルの場所を示す矢印が表示されている

  1. このファイルの内容を次のように置き換えます。

lib/main.dart

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

void main() {
  final game = FlameGame();
  runApp(GameWidget(game: game));
}
  1. このコードを実行して、すべてが正常に機能していることを確認します。新しいウィンドウが開き、黒い背景のみが表示されます。世界最悪のビデオゲームが 60fps でレンダリングされるようになりました。

完全に黒い brick_breaker アプリケーション ウィンドウを示すスクリーンショット。

4. ゲームを作成する

ゲームのサイズを測る

2 次元(2D)でプレイされるゲームにはプレイエリアが必要です。特定のサイズの領域を構築し、そのサイズを使用してゲームの他の要素のサイズを調整します。

プレイエリアに座標を配置する方法はいくつかあります。1 つの規則では、画面の中央を原点 (0,0) として、画面の中央から方向を測定します。正の値は、x 軸に沿って右に、y 軸に沿って上にアイテムを移動します。この標準は、最近のほとんどのゲーム、特に 3 次元を扱うゲームに適用されます。

オリジナルの Breakout ゲームが作成されたときの慣例では、原点は左上に設定されていました。x の正の方向は同じですが、y が反転しています。x の正の方向は右、y は下でした。このゲームでは、時代を忠実に再現するため、原点が左上隅に設定されています。

lib/src という新しいディレクトリに config.dart というファイルを作成します。このファイルには、以降のステップでさらに定数が追加されます。

lib/src/config.dart

const gameWidth = 820.0;
const gameHeight = 1600.0;

このゲームは幅 820 ピクセル、高さ 1,600 ピクセルになります。ゲーム領域は、表示されるウィンドウに合わせて拡大縮小されますが、画面に追加されたすべてのコンポーネントは、この高さと幅に準拠します。

PlayArea を作成する

ブレイクアウト ゲームでは、ボールがプレイ エリアの壁に跳ね返ります。衝突に対応するには、まず PlayArea コンポーネントが必要です。

  1. lib/src/components という新しいディレクトリに play_area.dart というファイルを作成します。
  2. このファイルに以下を追加します。

lib/src/components/play_area.dart

import 'dart:async';

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

import '../brick_breaker.dart';

class PlayArea extends RectangleComponent with HasGameReference<BrickBreaker> {
  PlayArea() : super(paint: Paint()..color = const Color(0xfff2e8cf));

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();
    size = Vector2(game.width, game.height);
  }
}

Flutter に Widget がある場合、Flame には Component があります。Flutter アプリはウィジェットのツリーを作成することで構成されますが、Flame ゲームはコンポーネントのツリーを維持することで構成されます。

ここに、Flutter と Flame の興味深い違いがあります。Flutter のウィジェット ツリーは、永続的で変更可能な RenderObject レイヤの更新に使用するために構築された一時的な説明です。Flame のコンポーネントは永続的で変更可能であり、デベロッパーがこれらのコンポーネントをシミュレーション システムの一部として使用することが想定されています。

Flame のコンポーネントは、ゲーム メカニクスを表現するために最適化されています。この Codelab は、次のステップで説明するゲームループから始めます。

  1. 混乱を避けるため、このプロジェクトのすべてのコンポーネントを含むファイルを追加します。lib/src/componentscomponents.dart ファイルを作成し、次の内容を追加します。

lib/src/components/components.dart

export 'play_area.dart';

export ディレクティブは、import と逆の役割を果たします。このファイルが別のファイルにインポートされたときに公開する機能を宣言します。次の手順で新しいコンポーネントを追加すると、このファイルのエントリが増えます。

Flame ゲームを作成する

前の手順の赤い波線を消すには、Flame の FlameGame の新しいサブクラスを派生させます。

  1. lib/srcbrick_breaker.dart という名前のファイルを作成し、次のコードを追加します。

lib/src/brick_breaker.dart

import 'dart:async';

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

import 'components/components.dart';
import 'config.dart';

class BrickBreaker extends FlameGame {
  BrickBreaker()
    : super(
        camera: CameraComponent.withFixedResolution(
          width: gameWidth,
          height: gameHeight,
        ),
      );

  double get width => size.x;
  double get height => size.y;

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());
  }
}

このファイルはゲームのアクションを調整します。ゲーム インスタンスの構築中に、このコードは固定解像度レンダリングを使用するようにゲームを構成します。ゲームは、含まれている画面に合わせてサイズ変更され、必要に応じてレターボックスが追加されます。

ゲームの幅と高さを公開して、PlayArea などの子コンポーネントが適切なサイズに設定できるようにします。

onLoad オーバーライド メソッドでは、コードは 2 つのアクションを実行します。

  1. 左上をビューファインダーのアンカーとして構成します。デフォルトでは、viewfinder は領域の中央を (0,0) のアンカーとして使用します。
  2. PlayAreaworld に追加します。world はゲーム世界を表します。すべての子を CameraComponent のビュー変換で投影します。

ゲームを画面に表示する

この手順で行った変更をすべて確認するには、lib/main.dart ファイルを更新して、次の変更を加えます。

lib/main.dart

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

import 'src/brick_breaker.dart';                                // Add this import

void main() {
  final game = BrickBreaker();                                  // Modify this line
  runApp(GameWidget(game: game));
}

変更を加えたら、ゲームを再起動します。ゲームは次の図のようになります。

アプリ ウィンドウの中央に砂色の長方形が表示された brick_breaker アプリケーション ウィンドウを示すスクリーンショット

次のステップでは、世界にボールを追加して、動かします。

5. ボールを表示する

ボール コンポーネントを作成する

画面に動くボールを配置するには、別のコンポーネントを作成してゲームワールドに追加します。

  1. lib/src/config.dart ファイルの内容を次のように編集します。

lib/src/config.dart

const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02;                            // Add this constant

名前付き定数を派生値として定義する設計パターンは、この Codelab で何度も登場します。これにより、最上位の gameWidthgameHeight を変更して、ゲームの外観と操作性がどのように変化するかを確認できます。

  1. lib/src/componentsball.dart というファイルに Ball コンポーネントを作成します。

lib/src/components/ball.dart

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

class Ball extends CircleComponent {
  Ball({
    required this.velocity,
    required super.position,
    required double radius,
  }) : super(
         radius: radius,
         anchor: Anchor.center,
         paint: Paint()
           ..color = const Color(0xff1e6091)
           ..style = PaintingStyle.fill,
       );

  final Vector2 velocity;

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

先ほど、RectangleComponent を使用して PlayArea を定義したので、他にもシェイプが存在することは明らかです。CircleComponentRectangleComponent と同様に PositionedComponent から派生しているため、画面上にボールを配置できます。さらに重要なこととして、その位置を更新できます。

このコンポーネントでは、velocity(位置の経時的な変化)というコンセプトを導入しています。速度は Vector2 オブジェクトです。速度は速さと方向の両方であるためです。位置を更新するには、ゲームエンジンがフレームごとに呼び出す update メソッドをオーバーライドします。dt は、前のフレームとこのフレームの間の時間です。これにより、フレームレート(60 Hz または 120 Hz)の違いや、過剰な計算による長いフレームなどの要因に対応できます。

position += velocity * dt の更新には十分注意してください。これは、モーションの離散シミュレーションを時間とともに更新する方法の実装です。

  1. Ball コンポーネントをコンポーネントのリストに含めるには、次のように lib/src/components/components.dart ファイルを編集します。

lib/src/components/components.dart

export 'ball.dart';                           // Add this export
export 'play_area.dart';

ボールをワールドに追加する

ボールがあります。ワールドに配置し、プレイエリア内を移動するように設定します。

lib/src/brick_breaker.dart ファイルを次のように編集します。

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math;                                     // Add this import

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

import 'components/components.dart';
import 'config.dart';

class BrickBreaker extends FlameGame {
  BrickBreaker()
    : super(
        camera: CameraComponent.withFixedResolution(
          width: gameWidth,
          height: gameHeight,
        ),
      );

  final rand = math.Random();                                   // Add this variable
  double get width => size.x;
  double get height => size.y;

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    world.add(
      Ball(                                                     // Add from here...
        radius: ballRadius,
        position: size / 2,
        velocity: Vector2(
          (rand.nextDouble() - 0.5) * width,
          height * 0.2,
        ).normalized()..scale(height / 4),
      ),
    );

    debugMode = true;                                           // To here.
  }
}

この変更により、Ball コンポーネントが world に追加されます。ボールの position を表示領域の中央に設定するために、コードはまずゲームのサイズを半分にします。これは、Vector2 には Vector2 をスカラー値でスケーリングするための演算子オーバーロード(*/)があるためです。

ボールの velocity を設定するには、より複雑な処理が必要です。このインテントは、ボールを画面の下方向に適度な速度でランダムな方向に移動させることを目的としています。normalized メソッドの呼び出しにより、元の Vector2 と同じ方向に設定され、距離が 1 に縮小された Vector2 オブジェクトが作成されます。これにより、ボールの速度はどの方向に進んでも一定になります。ボールの速度は、ゲームの高さの 1/4 になるようにスケールアップされます。

これらのさまざまな値を適切に設定するには、業界でプレイテストと呼ばれる反復作業が必要です。

最後の行はデバッグ表示をオンにします。これにより、デバッグに役立つ追加情報がディスプレイに追加されます。

ゲームを実行すると、次のような表示になります。

砂色の長方形の上に青い円が配置された brick_breaker アプリケーション ウィンドウを示すスクリーンショット。青い円には、画面上のサイズと位置を示す数字が注釈として付けられています

PlayArea コンポーネントと Ball コンポーネントの両方にデバッグ情報がありますが、背景マットによって PlayArea の数値が切り取られています。すべてのデバッグ情報が表示されるのは、コンポーネント ツリー全体で debugMode をオンにしたためです。より便利な場合は、選択したコンポーネントのみのデバッグを有効にすることもできます。

ゲームを数回再起動すると、ボールが壁と期待どおりに相互作用しないことに気づくかもしれません。この効果を実現するには、次のステップで衝突検出を追加する必要があります。

6. 跳ね回る

衝突検出を追加する

衝突検出では、2 つのオブジェクトが接触したときにゲームが認識する動作が追加されます。

ゲームに衝突検出を追加するには、次のコードに示すように、HasCollisionDetection ミックスインを BrickBreaker ゲームに追加します。

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math;

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

import 'components/components.dart';
import 'config.dart';

class BrickBreaker extends FlameGame with HasCollisionDetection { // Modify this line
  BrickBreaker()
    : super(
        camera: CameraComponent.withFixedResolution(
          width: gameWidth,
          height: gameHeight,
        ),
      );

  final rand = math.Random();
  double get width => size.x;
  double get height => size.y;

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    world.add(
      Ball(
        radius: ballRadius,
        position: size / 2,
        velocity: Vector2(
          (rand.nextDouble() - 0.5) * width,
          height * 0.2,
        ).normalized()..scale(height / 4),
      ),
    );

    debugMode = true;
  }
}

これは、コンポーネントのヒットボックスを追跡し、ゲームのすべてのティックで衝突検出をトリガーします。

ゲームのヒットボックスの入力を開始するには、次のように PlayArea コンポーネントを変更します。

lib/src/components/play_area.dart

import 'dart:async';

import 'package:flame/collisions.dart';                         // Add this import
import 'package:flame/components.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';

class PlayArea extends RectangleComponent with HasGameReference<BrickBreaker> {
  PlayArea()
    : super(
        paint: Paint()..color = const Color(0xfff2e8cf),
        children: [RectangleHitbox()],                          // Add this parameter
      );

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();
    size = Vector2(game.width, game.height);
  }
}

RectangleComponent の子として RectangleHitbox コンポーネントを追加すると、親コンポーネントのサイズに一致する衝突検出用のヒットボックスが構築されます。親コンポーネントよりも小さい、または大きいヒットボックスが必要な場合は、RectangleHitbox のファクトリ コンストラクタ relative を使用します。

ボールをバウンドさせる

今のところ、衝突検出を追加してもゲームプレイに変化はありません。Ball コンポーネントを変更すると、変更されます。PlayArea と衝突したときに変化するのはボールの動作です。

Ball コンポーネントを次のように変更します。

lib/src/components/ball.dart

import 'package:flame/collisions.dart';                         // Add this import
import 'package:flame/components.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';                                 // And this import
import 'play_area.dart';                                        // And this one too

class Ball extends CircleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {   // Add these mixins
  Ball({
    required this.velocity,
    required super.position,
    required double radius,
  }) : super(
         radius: radius,
         anchor: Anchor.center,
         paint: Paint()
           ..color = const Color(0xff1e6091)
           ..style = PaintingStyle.fill,
         children: [CircleHitbox()],                            // Add this parameter
       );

  final Vector2 velocity;

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

  @override                                                     // Add from here...
  void onCollisionStart(
    Set<Vector2> intersectionPoints,
    PositionComponent other,
  ) {
    super.onCollisionStart(intersectionPoints, other);
    if (other is PlayArea) {
      if (intersectionPoints.first.y <= 0) {
        velocity.y = -velocity.y;
      } else if (intersectionPoints.first.x <= 0) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.x >= game.width) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.y >= game.height) {
        removeFromParent();
      }
    } else {
      debugPrint('collision with $other');
    }
  }                                                             // To here.
}

この例では、onCollisionStart コールバックを追加することで、大きな変更を加えています。前の例の BrickBreaker に追加された衝突検出システムは、このコールバックを呼び出します。

まず、コードは BallPlayArea と衝突したかどうかをテストします。ゲームの世界には他のコンポーネントがないため、今のところこれは冗長に見えます。次のステップで、コウモリをワールドに追加すると、この値は変更されます。また、ボールがバット以外のものと衝突したときに処理する else 条件も追加します。残りのロジックを実装してください。

ボールが下の壁にぶつかると、ボールはプレイ画面から消えますが、まだ視界には入っています。このアーティファクトは、Flame の効果を使用して、後のステップで処理します。

ボールがゲームの壁に衝突するようになったので、ボールを打つためのバットをプレイヤーに与えると便利です。

7. バットでボールを打つ

バットを作成する

ゲーム内でボールを打ち返すバットを追加するには、

  1. 次のように、lib/src/config.dart ファイルに定数を挿入します。

lib/src/config.dart

const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02;
const batWidth = gameWidth * 0.2;                               // Add from here...
const batHeight = ballRadius * 2;
const batStep = gameWidth * 0.05;                               // To here.

batHeight 定数と batWidth 定数は、その名のとおりです。一方、batStep 定数については、少し説明が必要です。このゲームでボールを操作するには、プラットフォームに応じてマウスまたは指でバットをドラッグするか、キーボードを使用します。batStep 定数は、左右の矢印キーが押されるたびにバットが移動する距離を構成します。

  1. Bat コンポーネント クラスを次のように定義します。

lib/src/components/bat.dart

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flame/events.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';

class Bat extends PositionComponent
    with DragCallbacks, HasGameReference<BrickBreaker> {
  Bat({
    required this.cornerRadius,
    required super.position,
    required super.size,
  }) : super(anchor: Anchor.center, children: [RectangleHitbox()]);

  final Radius cornerRadius;

  final _paint = Paint()
    ..color = const Color(0xff1e6091)
    ..style = PaintingStyle.fill;

  @override
  void render(Canvas canvas) {
    super.render(canvas);
    canvas.drawRRect(
      RRect.fromRectAndRadius(Offset.zero & size.toSize(), cornerRadius),
      _paint,
    );
  }

  @override
  void onDragUpdate(DragUpdateEvent event) {
    super.onDragUpdate(event);
    position.x = (position.x + event.localDelta.x).clamp(0, game.width);
  }

  void moveBy(double dx) {
    add(
      MoveToEffect(
        Vector2((position.x + dx).clamp(0, game.width), position.y),
        EffectController(duration: 0.1),
      ),
    );
  }
}

このコンポーネントには、いくつかの新機能が導入されています。

まず、Bat コンポーネントは RectangleComponent でも CircleComponent でもなく、PositionComponent です。つまり、このコードは画面上に Bat をレンダリングする必要があります。このため、render コールバックをオーバーライドします。

canvas.drawRRect(丸みを帯びた長方形を描画)の呼び出しをよく見ると、「長方形はどこにあるのか?」と疑問に思うかもしれません。Offset.zero & size.toSize() は、Rect を作成する dart:ui Offset クラスの operator & オーバーロードを活用します。この省略形は最初はわかりにくいかもしれませんが、下位レベルの Flutter コードや Flame コードで頻繁に使用されます。

2 つ目は、プラットフォームに応じて、指またはマウスでこの Bat コンポーネントをドラッグできることです。この機能を実装するには、DragCallbacks ミックスインを追加して onDragUpdate イベントをオーバーライドします。

最後に、Bat コンポーネントはキーボード操作に応答する必要があります。moveBy 関数を使用すると、他のコードからこのバットに、指定された数の仮想ピクセルだけ左右に移動するよう指示できます。この関数では、Flame ゲームエンジンの新機能である Effect を導入しています。このコンポーネントの子として MoveToEffect オブジェクトを追加すると、バットが新しい位置にアニメーション表示されます。Flame には、さまざまな効果を実行するための Effect のコレクションがあります。

Effect のコンストラクタ引数には、game ゲッターへの参照が含まれています。そのため、このクラスに HasGameReference ミックスインを含めます。このミックスインは、このコンポーネントに型安全な game アクセサを追加して、コンポーネント ツリーの最上位にある BrickBreaker インスタンスにアクセスします。

  1. BatBrickBreaker で使用できるようにするには、次のように lib/src/components/components.dart ファイルを更新します。

lib/src/components/components.dart

export 'ball.dart';
export 'bat.dart';                                              // Add this export
export 'play_area.dart';

コウモリをワールドに追加する

Bat コンポーネントをゲームワールドに追加するには、次のように BrickBreaker を更新します。

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math;

import 'package:flame/components.dart';
import 'package:flame/events.dart';                             // Add this import
import 'package:flame/game.dart';
import 'package:flutter/material.dart';                         // And this import
import 'package:flutter/services.dart';                         // And this

import 'components/components.dart';
import 'config.dart';

class BrickBreaker extends FlameGame
    with HasCollisionDetection, KeyboardEvents {                // Modify this line
  BrickBreaker()
    : super(
        camera: CameraComponent.withFixedResolution(
          width: gameWidth,
          height: gameHeight,
        ),
      );

  final rand = math.Random();
  double get width => size.x;
  double get height => size.y;

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    world.add(
      Ball(
        radius: ballRadius,
        position: size / 2,
        velocity: Vector2(
          (rand.nextDouble() - 0.5) * width,
          height * 0.2,
        ).normalized()..scale(height / 4),
      ),
    );

    world.add(                                                  // Add from here...
      Bat(
        size: Vector2(batWidth, batHeight),
        cornerRadius: const Radius.circular(ballRadius / 2),
        position: Vector2(width / 2, height * 0.95),
      ),
    );                                                          // To here.

    debugMode = true;
  }

  @override                                                     // Add from here...
  KeyEventResult onKeyEvent(
    KeyEvent event,
    Set<LogicalKeyboardKey> keysPressed,
  ) {
    super.onKeyEvent(event, keysPressed);
    switch (event.logicalKey) {
      case LogicalKeyboardKey.arrowLeft:
        world.children.query<Bat>().first.moveBy(-batStep);
      case LogicalKeyboardKey.arrowRight:
        world.children.query<Bat>().first.moveBy(batStep);
    }
    return KeyEventResult.handled;
  }                                                             // To here.
}

KeyboardEvents ミックスインとオーバーライドされた onKeyEvent メソッドの追加により、キーボード入力が処理されます。バットを適切なステップ量だけ移動するために、先ほど追加したコードを思い出してください。

追加されたコードの残りの部分は、適切な位置と正しい比率でゲームの世界にバットを追加します。これらの設定をすべてこのファイルで公開することで、バットとボールの相対的なサイズを調整してゲームの適切な感覚を得る作業が簡単になります。

この時点でゲームをプレイすると、バットを動かしてボールをキャッチできますが、Ball の衝突検出コードに残したデバッグ ロギング以外に、目に見える反応はありません。

Time to fix that now. Ball コンポーネントを次のように編集します。

lib/src/components/ball.dart

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';                           // Add this import
import 'package:flutter/material.dart';

import '../brick_breaker.dart';
import 'bat.dart';                                             // And this import
import 'play_area.dart';

class Ball extends CircleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Ball({
    required this.velocity,
    required super.position,
    required double radius,
  }) : super(
         radius: radius,
         anchor: Anchor.center,
         paint: Paint()
           ..color = const Color(0xff1e6091)
           ..style = PaintingStyle.fill,
         children: [CircleHitbox()],
       );

  final Vector2 velocity;

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

  @override
  void onCollisionStart(
    Set<Vector2> intersectionPoints,
    PositionComponent other,
  ) {
    super.onCollisionStart(intersectionPoints, other);
    if (other is PlayArea) {
      if (intersectionPoints.first.y <= 0) {
        velocity.y = -velocity.y;
      } else if (intersectionPoints.first.x <= 0) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.x >= game.width) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.y >= game.height) {
        add(RemoveEffect(delay: 0.35));                         // Modify from here...
      }
    } else if (other is Bat) {
      velocity.y = -velocity.y;
      velocity.x =
          velocity.x +
          (position.x - other.position.x) / other.size.x * game.width * 0.3;
    } else {                                                    // To here.
      debugPrint('collision with $other');
    }
  }
}

これらのコード変更により、2 つの別々の問題が修正されます。

まず、ボールが画面の下部に触れた瞬間に消滅する問題を修正します。この問題を解決するには、removeFromParent 呼び出しを RemoveEffect に置き換えます。RemoveEffect は、ボールが視認可能なプレイエリアから出た後、ゲームワールドからボールを削除します。

また、バットとボールの衝突の処理も修正されます。この処理コードはプレーヤーに非常に有利に働きます。プレーヤーがバットでボールに触れている限り、ボールは画面の上部に戻ります。この設定では甘すぎると感じ、よりリアルな操作感を求める場合は、ゲームの操作感に合わせてこの設定を変更してください。

velocity の更新の複雑さについて説明します。壁の衝突の場合のように、速度の y 成分を反転させるだけではありません。また、バットとボールの接触時の相対位置に応じて x コンポーネントを更新します。これにより、ボールの動きをより細かく制御できるようになりますが、その方法はプレイを通じてのみプレイヤーに伝わります。

ボールを打つバットができたので、ボールで壊すレンガがあるといいですね。

8. 壁を壊す

ブリックを作成する

ゲームにブロックを追加するには、

  1. 次のように、lib/src/config.dart ファイルに定数を挿入します。

lib/src/config.dart

import 'package:flutter/material.dart';                         // Add this import

const brickColors = [                                           // Add this const
  Color(0xfff94144),
  Color(0xfff3722c),
  Color(0xfff8961e),
  Color(0xfff9844a),
  Color(0xfff9c74f),
  Color(0xff90be6d),
  Color(0xff43aa8b),
  Color(0xff4d908e),
  Color(0xff277da1),
  Color(0xff577590),
];

const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02;
const batWidth = gameWidth * 0.2;
const batHeight = ballRadius * 2;
const batStep = gameWidth * 0.05;
const brickGutter = gameWidth * 0.015;                          // Add from here...
final brickWidth =
    (gameWidth - (brickGutter * (brickColors.length + 1))) / brickColors.length;
const brickHeight = gameHeight * 0.03;
const difficultyModifier = 1.03;                                // To here.
  1. 次のように Brick コンポーネントを挿入します。

lib/src/components/brick.dart

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

import '../brick_breaker.dart';
import '../config.dart';
import 'ball.dart';
import 'bat.dart';

class Brick extends RectangleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Brick({required super.position, required Color color})
    : super(
        size: Vector2(brickWidth, brickHeight),
        anchor: Anchor.center,
        paint: Paint()
          ..color = color
          ..style = PaintingStyle.fill,
        children: [RectangleHitbox()],
      );

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

    if (game.world.children.query<Brick>().length == 1) {
      game.world.removeAll(game.world.children.query<Ball>());
      game.world.removeAll(game.world.children.query<Bat>());
    }
  }
}

このコードのほとんどは、すでに理解しているはずです。このコードでは、コンポーネント ツリーの最上位にある BrickBreaker ゲームへの型安全な参照と衝突検出の両方を含む RectangleComponent を使用しています。

このコードで導入された最も重要な新しいコンセプトは、プレーヤーが勝利条件を達成する方法です。勝利条件のチェックでは、世界にレンガがあるかどうかをクエリし、1 つだけ残っていることを確認します。前の行でこのブロックが親から削除されているため、少し混乱するかもしれません。

重要なのは、コンポーネントの削除はキューに登録されたコマンドであるということです。このコードの実行後、ゲームワールドの次のティックの前に、ブロックが削除されます。

Brick コンポーネントを BrickBreaker からアクセスできるようにするには、lib/src/components/components.dart を次のように編集します。

lib/src/components/components.dart

export 'ball.dart';
export 'bat.dart';
export 'brick.dart';                                            // Add this export
export 'play_area.dart';

世界にブロックを追加する

Ball コンポーネントを次のように更新します。

lib/src/components/ball.dart

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';
import 'bat.dart';
import 'brick.dart';                                            // Add this import
import 'play_area.dart';

class Ball extends CircleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Ball({
    required this.velocity,
    required super.position,
    required double radius,
    required this.difficultyModifier,                           // Add this parameter
  }) : super(
         radius: radius,
         anchor: Anchor.center,
         paint: Paint()
           ..color = const Color(0xff1e6091)
           ..style = PaintingStyle.fill,
         children: [CircleHitbox()],
       );

  final Vector2 velocity;
  final double difficultyModifier;                              // Add this member

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

  @override
  void onCollisionStart(
    Set<Vector2> intersectionPoints,
    PositionComponent other,
  ) {
    super.onCollisionStart(intersectionPoints, other);
    if (other is PlayArea) {
      if (intersectionPoints.first.y <= 0) {
        velocity.y = -velocity.y;
      } else if (intersectionPoints.first.x <= 0) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.x >= game.width) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.y >= game.height) {
        add(RemoveEffect(delay: 0.35));
      }
    } else if (other is Bat) {
      velocity.y = -velocity.y;
      velocity.x =
          velocity.x +
          (position.x - other.position.x) / other.size.x * game.width * 0.3;
    } else if (other is Brick) {                                // Modify from here...
      if (position.y < other.position.y - other.size.y / 2) {
        velocity.y = -velocity.y;
      } else if (position.y > other.position.y + other.size.y / 2) {
        velocity.y = -velocity.y;
      } else if (position.x < other.position.x) {
        velocity.x = -velocity.x;
      } else if (position.x > other.position.x) {
        velocity.x = -velocity.x;
      }
      velocity.setFrom(velocity * difficultyModifier);          // To here.
    }
  }
}

ここでは、唯一の新しい要素である難易度修飾子を紹介します。これは、ブロックに衝突するたびにボールの速度を上げるものです。この調整可能なパラメータは、ゲームに適した適切な難易度曲線を見つけるためにプレイテストする必要があります。

BrickBreaker ゲームを次のように編集します。

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math;

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

import 'components/components.dart';
import 'config.dart';

class BrickBreaker extends FlameGame
    with HasCollisionDetection, KeyboardEvents {
  BrickBreaker()
    : super(
        camera: CameraComponent.withFixedResolution(
          width: gameWidth,
          height: gameHeight,
        ),
      );

  final rand = math.Random();
  double get width => size.x;
  double get height => size.y;

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    world.add(
      Ball(
        difficultyModifier: difficultyModifier,                 // Add this argument
        radius: ballRadius,
        position: size / 2,
        velocity: Vector2(
          (rand.nextDouble() - 0.5) * width,
          height * 0.2,
        ).normalized()..scale(height / 4),
      ),
    );

    world.add(
      Bat(
        size: Vector2(batWidth, batHeight),
        cornerRadius: const Radius.circular(ballRadius / 2),
        position: Vector2(width / 2, height * 0.95),
      ),
    );

    await world.addAll([                                        // Add from here...
      for (var i = 0; i < brickColors.length; i++)
        for (var j = 1; j <= 5; j++)
          Brick(
            position: Vector2(
              (i + 0.5) * brickWidth + (i + 1) * brickGutter,
              (j + 2.0) * brickHeight + j * brickGutter,
            ),
            color: brickColors[i],
          ),
    ]);                                                         // To here.

    debugMode = true;
  }

  @override
  KeyEventResult onKeyEvent(
    KeyEvent event,
    Set<LogicalKeyboardKey> keysPressed,
  ) {
    super.onKeyEvent(event, keysPressed);
    switch (event.logicalKey) {
      case LogicalKeyboardKey.arrowLeft:
        world.children.query<Bat>().first.moveBy(-batStep);
      case LogicalKeyboardKey.arrowRight:
        world.children.query<Bat>().first.moveBy(batStep);
    }
    return KeyEventResult.handled;
  }
}

ゲームを実行すると、主要なゲーム メカニクスがすべて表示されます。デバッグをオフにして完了とすることもできますが、何かが欠けているように感じます。

ボール、バット、レンガの大部分がプレイエリアに表示されている brick_breaker のスクリーンショット。各コンポーネントにデバッグラベルがある

ウェルカム画面、ゲームオーバー画面、スコアを表示してみましょう。Flutter を使用してゲームにこれらの機能を追加します。次に、この点について説明します。

9. ゲームに勝つ

再生状態を追加する

このステップでは、Flame ゲームを Flutter ラッパーに埋め込み、ウェルカム画面、ゲームオーバー画面、勝利画面用の Flutter オーバーレイを追加します。

まず、ゲームとコンポーネントのファイルを変更して、オーバーレイを表示するかどうか、表示する場合はどのオーバーレイを表示するかを反映する再生状態を実装します。

  1. BrickBreaker ゲームを次のように変更します。

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math;

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

import 'components/components.dart';
import 'config.dart';

enum PlayState { welcome, playing, gameOver, won }              // Add this enumeration

class BrickBreaker extends FlameGame
    with HasCollisionDetection, KeyboardEvents, TapDetector {   // Modify this line
  BrickBreaker()
    : super(
        camera: CameraComponent.withFixedResolution(
          width: gameWidth,
          height: gameHeight,
        ),
      );

  final rand = math.Random();
  double get width => size.x;
  double get height => size.y;

  late PlayState _playState;                                    // Add from here...
  PlayState get playState => _playState;
  set playState(PlayState playState) {
    _playState = playState;
    switch (playState) {
      case PlayState.welcome:
      case PlayState.gameOver:
      case PlayState.won:
        overlays.add(playState.name);
      case PlayState.playing:
        overlays.remove(PlayState.welcome.name);
        overlays.remove(PlayState.gameOver.name);
        overlays.remove(PlayState.won.name);
    }
  }                                                             // To here.

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    playState = PlayState.welcome;                              // Add from here...
  }

  void startGame() {
    if (playState == PlayState.playing) return;

    world.removeAll(world.children.query<Ball>());
    world.removeAll(world.children.query<Bat>());
    world.removeAll(world.children.query<Brick>());

    playState = PlayState.playing;                              // To here.

    world.add(
      Ball(
        difficultyModifier: difficultyModifier,
        radius: ballRadius,
        position: size / 2,
        velocity: Vector2(
          (rand.nextDouble() - 0.5) * width,
          height * 0.2,
        ).normalized()..scale(height / 4),
      ),
    );

    world.add(
      Bat(
        size: Vector2(batWidth, batHeight),
        cornerRadius: const Radius.circular(ballRadius / 2),
        position: Vector2(width / 2, height * 0.95),
      ),
    );

    world.addAll([                                              // Drop the await
      for (var i = 0; i < brickColors.length; i++)
        for (var j = 1; j <= 5; j++)
          Brick(
            position: Vector2(
              (i + 0.5) * brickWidth + (i + 1) * brickGutter,
              (j + 2.0) * brickHeight + j * brickGutter,
            ),
            color: brickColors[i],
          ),
    ]);
  }                                                             // Drop the debugMode

  @override                                                     // Add from here...
  void onTap() {
    super.onTap();
    startGame();
  }                                                             // To here.

  @override
  KeyEventResult onKeyEvent(
    KeyEvent event,
    Set<LogicalKeyboardKey> keysPressed,
  ) {
    super.onKeyEvent(event, keysPressed);
    switch (event.logicalKey) {
      case LogicalKeyboardKey.arrowLeft:
        world.children.query<Bat>().first.moveBy(-batStep);
      case LogicalKeyboardKey.arrowRight:
        world.children.query<Bat>().first.moveBy(batStep);
      case LogicalKeyboardKey.space:                            // Add from here...
      case LogicalKeyboardKey.enter:
        startGame();                                            // To here.
    }
    return KeyEventResult.handled;
  }

  @override
  Color backgroundColor() => const Color(0xfff2e8cf);          // Add this override
}

このコードは、BrickBreaker ゲームの大部分を変更します。playState 列挙型を追加するには、多くの作業が必要です。これは、プレーヤーがゲームに参加し、プレイし、負けるか勝つかのどの段階にいるかを把握するものです。ファイルの上部で列挙型を定義し、一致するゲッターとセッターを使用して非表示状態としてインスタンス化します。これらのゲッターとセッターにより、ゲームのさまざまな部分でプレイ状態の遷移がトリガーされたときにオーバーレイを変更できます。

次に、onLoad のコードを onLoad と新しい startGame メソッドに分割します。今回の変更以前は、ゲームを再起動することでしか新しいゲームを開始できませんでした。これらの新しい追加により、プレーヤーはこのような抜本的な対策を講じなくても新しいゲームを開始できるようになりました。

プレーヤーが新しいゲームを開始できるように、ゲーム用に 2 つの新しいハンドラを構成しました。タップ ハンドラを追加し、キーボード ハンドラを拡張して、ユーザーが複数のモダリティで新しいゲームを開始できるようにしました。再生状態がモデル化されているため、プレーヤーが勝つか負けるかに応じて再生状態の切り替えをトリガーするようにコンポーネントを更新するのが妥当です。

  1. Ball コンポーネントを次のように変更します。

lib/src/components/ball.dart

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';
import 'bat.dart';
import 'brick.dart';
import 'play_area.dart';

class Ball extends CircleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Ball({
    required this.velocity,
    required super.position,
    required double radius,
    required this.difficultyModifier,
  }) : super(
         radius: radius,
         anchor: Anchor.center,
         paint: Paint()
           ..color = const Color(0xff1e6091)
           ..style = PaintingStyle.fill,
         children: [CircleHitbox()],
       );

  final Vector2 velocity;
  final double difficultyModifier;

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

  @override
  void onCollisionStart(
    Set<Vector2> intersectionPoints,
    PositionComponent other,
  ) {
    super.onCollisionStart(intersectionPoints, other);
    if (other is PlayArea) {
      if (intersectionPoints.first.y <= 0) {
        velocity.y = -velocity.y;
      } else if (intersectionPoints.first.x <= 0) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.x >= game.width) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.y >= game.height) {
        add(
          RemoveEffect(
            delay: 0.35,
            onComplete: () {                                    // Modify from here
              game.playState = PlayState.gameOver;
            },
          ),
        );                                                      // To here.
      }
    } else if (other is Bat) {
      velocity.y = -velocity.y;
      velocity.x =
          velocity.x +
          (position.x - other.position.x) / other.size.x * game.width * 0.3;
    } else if (other is Brick) {
      if (position.y < other.position.y - other.size.y / 2) {
        velocity.y = -velocity.y;
      } else if (position.y > other.position.y + other.size.y / 2) {
        velocity.y = -velocity.y;
      } else if (position.x < other.position.x) {
        velocity.x = -velocity.x;
      } else if (position.x > other.position.x) {
        velocity.x = -velocity.x;
      }
      velocity.setFrom(velocity * difficultyModifier);
    }
  }
}

この小さな変更により、RemoveEffectonComplete コールバックが追加され、gameOver 再生状態がトリガーされます。プレーヤーがボールを画面の下から逃がすことができる場合、この値は適切です。

  1. Brick コンポーネントを次のように編集します。

lib/src/components/brick.dart

impimport 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';
import '../config.dart';
import 'ball.dart';
import 'bat.dart';

class Brick extends RectangleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Brick({required super.position, required Color color})
    : super(
        size: Vector2(brickWidth, brickHeight),
        anchor: Anchor.center,
        paint: Paint()
          ..color = color
          ..style = PaintingStyle.fill,
        children: [RectangleHitbox()],
      );

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

    if (game.world.children.query<Brick>().length == 1) {
      game.playState = PlayState.won;                          // Add this line
      game.world.removeAll(game.world.children.query<Ball>());
      game.world.removeAll(game.world.children.query<Bat>());
    }
  }
}

一方、すべてのブロックを壊すことができれば、[ゲームクリア] 画面が表示されます。よくやった!

Flutter ラッパーを追加する

ゲームを埋め込んでプレイ状態のオーバーレイを追加する場所を提供するには、Flutter シェルを追加します。

  1. lib/src の下に widgets ディレクトリを作成します。
  2. game_app.dart ファイルを追加し、次の内容を挿入します。

lib/src/widgets/game_app.dart

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

import '../brick_breaker.dart';
import '../config.dart';

class GameApp extends StatelessWidget {
  const GameApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        textTheme: GoogleFonts.pressStart2pTextTheme().apply(
          bodyColor: const Color(0xff184e77),
          displayColor: const Color(0xff184e77),
        ),
      ),
      home: Scaffold(
        body: Container(
          decoration: const BoxDecoration(
            gradient: LinearGradient(
              begin: Alignment.topCenter,
              end: Alignment.bottomCenter,
              colors: [Color(0xffa9d6e5), Color(0xfff2e8cf)],
            ),
          ),
          child: SafeArea(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Center(
                child: FittedBox(
                  child: SizedBox(
                    width: gameWidth,
                    height: gameHeight,
                    child: GameWidget.controlled(
                      gameFactory: BrickBreaker.new,
                      overlayBuilderMap: {
                        PlayState.welcome.name: (context, game) => Center(
                          child: Text(
                            'TAP TO PLAY',
                            style: Theme.of(context).textTheme.headlineLarge,
                          ),
                        ),
                        PlayState.gameOver.name: (context, game) => Center(
                          child: Text(
                            'G A M E   O V E R',
                            style: Theme.of(context).textTheme.headlineLarge,
                          ),
                        ),
                        PlayState.won.name: (context, game) => Center(
                          child: Text(
                            'Y O U   W O N ! ! !',
                            style: Theme.of(context).textTheme.headlineLarge,
                          ),
                        ),
                      },
                    ),
                  ),
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

このファイルのほとんどのコンテンツは、標準の Flutter ウィジェット ツリーのビルドに従っています。Flame 固有の部分には、GameWidget.controlled を使用して BrickBreaker ゲーム インスタンスを構築および管理することや、GameWidget への新しい overlayBuilderMap 引数などが含まれます。

この overlayBuilderMap のキーは、BrickBreakerplayState セッターが追加または削除したオーバーレイと一致する必要があります。このマップにないオーバーレイを設定しようとすると、周囲に不満な顔が広がります。

  1. この新機能を画面に表示するには、lib/main.dart ファイルを次の内容に置き換えます。

lib/main.dart

import 'package:flutter/material.dart';

import 'src/widgets/game_app.dart';

void main() {
  runApp(const GameApp());
}

このコードを iOS、Linux、Windows、ウェブで実行すると、意図した出力がゲームに表示されます。macOS または Android をターゲットにする場合は、google_fonts を表示できるようにするための最後の調整が必要です。

フォントへのアクセスを有効にする

Android のインターネット権限を追加する

Android の場合は、インターネット権限を追加する必要があります。AndroidManifest.xml を次のように編集します。

android/app/src/main/AndroidManifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <!-- Add the following line -->
    <uses-permission android:name="android.permission.INTERNET" />
    <application
        android:label="brick_breaker"
        android:name="${applicationName}"
        android:icon="@mipmap/ic_launcher">
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:launchMode="singleTop"
            android:taskAffinity=""
            android:theme="@style/LaunchTheme"
            android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
            android:hardwareAccelerated="true"
            android:windowSoftInputMode="adjustResize">
            <!-- Specifies an Android theme to apply to this Activity as soon as
                 the Android process has started. This theme is visible to the user
                 while the Flutter UI initializes. After that, this theme continues
                 to determine the Window background behind the Flutter UI. -->
            <meta-data
              android:name="io.flutter.embedding.android.NormalTheme"
              android:resource="@style/NormalTheme"
              />
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>
        <!-- Don't delete the meta-data below.
             This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
        <meta-data
            android:name="flutterEmbedding"
            android:value="2" />
    </application>
    <!-- Required to query activities that can process text, see:
         https://developer.android.com/training/package-visibility and
         https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.

         In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
    <queries>
        <intent>
            <action android:name="android.intent.action.PROCESS_TEXT"/>
            <data android:mimeType="text/plain"/>
        </intent>
    </queries>
</manifest>

macOS の利用資格ファイルを編集する

macOS の場合は、2 つのファイルを編集します。

  1. DebugProfile.entitlements ファイルを編集して、次のコードと一致するようにします。

macos/Runner/DebugProfile.entitlements

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
        <key>com.apple.security.app-sandbox</key>
        <true/>
        <key>com.apple.security.cs.allow-jit</key>
        <true/>
        <key>com.apple.security.network.server</key>
        <true/>
        <!-- Add from here... -->
        <key>com.apple.security.network.client</key>
        <true/>
        <!-- to here. -->
</dict>
</plist>
  1. 次のコードと一致するように Release.entitlements ファイルを編集します。

macos/Runner/Release.entitlements

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
        <key>com.apple.security.app-sandbox</key>
        <true/>
        <!-- Add from here... -->
        <key>com.apple.security.network.client</key>
        <true/>
        <!-- to here. -->
</dict>
</plist>

このコードをそのまま実行すると、すべてのプラットフォームでウェルカム画面とゲームオーバー画面またはゲームクリア画面が表示されます。これらの画面は少し単純すぎるため、スコアがあると便利です。次のステップで何をするか、おわかりですね。

10. スコアを記録する

ゲームにスコアを追加する

このステップでは、ゲームスコアを周囲の Flutter コンテキストに公開します。このステップでは、Flame ゲームの状態を周囲の Flutter 状態管理に公開します。これにより、プレーヤーがブロックを壊すたびにゲームコードでスコアを更新できるようになります。

  1. BrickBreaker ゲームを次のように変更します。

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math;

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

import 'components/components.dart';
import 'config.dart';

enum PlayState { welcome, playing, gameOver, won }

class BrickBreaker extends FlameGame
    with HasCollisionDetection, KeyboardEvents, TapDetector {
  BrickBreaker()
    : super(
        camera: CameraComponent.withFixedResolution(
          width: gameWidth,
          height: gameHeight,
        ),
      );

  final ValueNotifier<int> score = ValueNotifier(0);            // Add this line
  final rand = math.Random();
  double get width => size.x;
  double get height => size.y;

  late PlayState _playState;
  PlayState get playState => _playState;
  set playState(PlayState playState) {
    _playState = playState;
    switch (playState) {
      case PlayState.welcome:
      case PlayState.gameOver:
      case PlayState.won:
        overlays.add(playState.name);
      case PlayState.playing:
        overlays.remove(PlayState.welcome.name);
        overlays.remove(PlayState.gameOver.name);
        overlays.remove(PlayState.won.name);
    }
  }

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    playState = PlayState.welcome;
  }

  void startGame() {
    if (playState == PlayState.playing) return;

    world.removeAll(world.children.query<Ball>());
    world.removeAll(world.children.query<Bat>());
    world.removeAll(world.children.query<Brick>());

    playState = PlayState.playing;
    score.value = 0;                                            // Add this line

    world.add(
      Ball(
        difficultyModifier: difficultyModifier,
        radius: ballRadius,
        position: size / 2,
        velocity: Vector2(
          (rand.nextDouble() - 0.5) * width,
          height * 0.2,
        ).normalized()..scale(height / 4),
      ),
    );

    world.add(
      Bat(
        size: Vector2(batWidth, batHeight),
        cornerRadius: const Radius.circular(ballRadius / 2),
        position: Vector2(width / 2, height * 0.95),
      ),
    );

    world.addAll([
      for (var i = 0; i < brickColors.length; i++)
        for (var j = 1; j <= 5; j++)
          Brick(
            position: Vector2(
              (i + 0.5) * brickWidth + (i + 1) * brickGutter,
              (j + 2.0) * brickHeight + j * brickGutter,
            ),
            color: brickColors[i],
          ),
    ]);
  }

  @override
  void onTap() {
    super.onTap();
    startGame();
  }

  @override
  KeyEventResult onKeyEvent(
    KeyEvent event,
    Set<LogicalKeyboardKey> keysPressed,
  ) {
    super.onKeyEvent(event, keysPressed);
    switch (event.logicalKey) {
      case LogicalKeyboardKey.arrowLeft:
        world.children.query<Bat>().first.moveBy(-batStep);
      case LogicalKeyboardKey.arrowRight:
        world.children.query<Bat>().first.moveBy(batStep);
      case LogicalKeyboardKey.space:
      case LogicalKeyboardKey.enter:
        startGame();
    }
    return KeyEventResult.handled;
  }

  @override
  Color backgroundColor() => const Color(0xfff2e8cf);
}

score をゲームに追加すると、ゲームの状態が Flutter の状態管理に結び付けられます。

  1. プレーヤーがブロックを壊したときにスコアにポイントを追加するように Brick クラスを変更します。

lib/src/components/brick.dart

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

import '../brick_breaker.dart';
import '../config.dart';
import 'ball.dart';
import 'bat.dart';

class Brick extends RectangleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Brick({required super.position, required Color color})
    : super(
        size: Vector2(brickWidth, brickHeight),
        anchor: Anchor.center,
        paint: Paint()
          ..color = color
          ..style = PaintingStyle.fill,
        children: [RectangleHitbox()],
      );

  @override
  void onCollisionStart(
    Set<Vector2> intersectionPoints,
    PositionComponent other,
  ) {
    super.onCollisionStart(intersectionPoints, other);
    removeFromParent();
    game.score.value++;                                         // Add this line

    if (game.world.children.query<Brick>().length == 1) {
      game.playState = PlayState.won;
      game.world.removeAll(game.world.children.query<Ball>());
      game.world.removeAll(game.world.children.query<Bat>());
    }
  }
}

見栄えの良いゲームを作成する

Flutter でスコアを記録できるようになったので、次はウィジェットを組み合わせて見栄えをよくしましょう。

  1. lib/src/widgetsscore_card.dart を作成し、次の内容を追加します。

lib/src/widgets/score_card.dart

import 'package:flutter/material.dart';

class ScoreCard extends StatelessWidget {
  const ScoreCard({super.key, required this.score});

  final ValueNotifier<int> score;

  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder<int>(
      valueListenable: score,
      builder: (context, score, child) {
        return Padding(
          padding: const EdgeInsets.fromLTRB(12, 6, 12, 18),
          child: Text(
            'Score: $score'.toUpperCase(),
            style: Theme.of(context).textTheme.titleLarge!,
          ),
        );
      },
    );
  }
}
  1. lib/src/widgetsoverlay_screen.dart を作成し、次のコードを追加します。

これにより、flutter_animate パッケージの機能を使用して、オーバーレイ画面に動きとスタイルを追加し、オーバーレイをより洗練されたものにします。

lib/src/widgets/overlay_screen.dart

import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';

class OverlayScreen extends StatelessWidget {
  const OverlayScreen({super.key, required this.title, required this.subtitle});

  final String title;
  final String subtitle;

  @override
  Widget build(BuildContext context) {
    return Container(
      alignment: const Alignment(0, -0.15),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Text(
            title,
            style: Theme.of(context).textTheme.headlineLarge,
          ).animate().slideY(duration: 750.ms, begin: -3, end: 0),
          const SizedBox(height: 16),
          Text(subtitle, style: Theme.of(context).textTheme.headlineSmall)
              .animate(onPlay: (controller) => controller.repeat())
              .fadeIn(duration: 1.seconds)
              .then()
              .fadeOut(duration: 1.seconds),
        ],
      ),
    );
  }
}

flutter_animate の機能について詳しくは、Flutter で次世代の UI を構築する Codelab をご覧ください。

このコードは GameApp コンポーネントで大幅に変更されました。まず、ScoreCardscore にアクセスできるように、StatelessWidget から StatefulWidget に変換します。スコアカードを追加するには、ゲームの上にスコアを重ねる Column を追加する必要があります。

次に、ウェルカム、ゲームオーバー、勝利のエクスペリエンスを強化するために、新しい OverlayScreen ウィジェットを追加しました。

lib/src/widgets/game_app.dart

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

import '../brick_breaker.dart';
import '../config.dart';
import 'overlay_screen.dart';                                   // Add this import
import 'score_card.dart';                                       // And this one too

class GameApp extends StatefulWidget {                          // Modify this line
  const GameApp({super.key});

  @override                                                     // Add from here...
  State<GameApp> createState() => _GameAppState();
}

class _GameAppState extends State<GameApp> {
  late final BrickBreaker game;

  @override
  void initState() {
    super.initState();
    game = BrickBreaker();
  }                                                             // To here.

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        textTheme: GoogleFonts.pressStart2pTextTheme().apply(
          bodyColor: const Color(0xff184e77),
          displayColor: const Color(0xff184e77),
        ),
      ),
      home: Scaffold(
        body: Container(
          decoration: const BoxDecoration(
            gradient: LinearGradient(
              begin: Alignment.topCenter,
              end: Alignment.bottomCenter,
              colors: [Color(0xffa9d6e5), Color(0xfff2e8cf)],
            ),
          ),
          child: SafeArea(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Center(
                child: Column(                                  // Modify from here...
                  children: [
                    ScoreCard(score: game.score),
                    Expanded(
                      child: FittedBox(
                        child: SizedBox(
                          width: gameWidth,
                          height: gameHeight,
                          child: GameWidget(
                            game: game,
                            overlayBuilderMap: {
                              PlayState.welcome.name: (context, game) =>
                                  const OverlayScreen(
                                    title: 'TAP TO PLAY',
                                    subtitle: 'Use arrow keys or swipe',
                                  ),
                              PlayState.gameOver.name: (context, game) =>
                                  const OverlayScreen(
                                    title: 'G A M E   O V E R',
                                    subtitle: 'Tap to Play Again',
                                  ),
                              PlayState.won.name: (context, game) =>
                                  const OverlayScreen(
                                    title: 'Y O U   W O N ! ! !',
                                    subtitle: 'Tap to Play Again',
                                  ),
                            },
                          ),
                        ),
                      ),
                    ),
                  ],
                ),                                              // To here.
              ),
            ),
          ),
        ),
      ),
    );
  }
}

これで、6 つの Flutter ターゲット プラットフォームのいずれでもこのゲームを実行できるようになります。ゲームは次のようになります。

brick_breaker のゲーム開始前の画面のスクリーンショット。ユーザーに画面をタップしてゲームを開始するよう促している

brick_breaker のスクリーンショット。ゲームオーバー画面がバットとレンガの一部の上に重ねて表示されている

11. 完了

お疲れさまでした。これで、Flutter と Flame を使用してゲームを作成することができました。

Flame 2D ゲームエンジンを使用してゲームを作成し、Flutter ラッパーに埋め込みました。Flame のエフェクトを使用して、コンポーネントのアニメーションと削除を行いました。Google Fonts と Flutter Animate パッケージを使用して、ゲーム全体がうまくデザインされているように見せました。

次のステップ

以下の Codelab をご覧ください。

関連情報