Flame with Flutter の概要

1. はじめに

Flame は、Flutter ベースの 2D ゲームエンジンです。この Codelab では、70 年代のビデオゲームの名作の 1 つ、Steve Wozniak の Breakout から着想を得たゲームを作成します。Flame のコンポーネントを使用して、バット、ボール、レンガを描画します。Flame のエフェクトを使用してコウモリの動きをアニメーション化し、Flame を Flutter の状態管理システムと統合する方法を確認します。

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

プレイ中のゲームの画面の録画。ゲームの速度が大幅にアップしました。

学習内容

  • Flame の基本の仕組み(GameWidget 以降)。
  • ゲームループを使用する方法。
  • 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 を選択します。アプリをプレビューするには、Android デバイスを USB ケーブルで Windows ノートパソコンに接続し、接続された Android デバイスまたは Android Emulator 上で開発中のアプリを実行します。開発ターゲットに Windows を選択して、開発中のアプリを Windows アプリとしてエディタで実行することもできます。

開発ターゲットにウェブを選びたくなるかもしれませんが、これには、開発中に欠点があります。Flutter のステートフル ホットリロード機能が使用できなくなることです。Flutter では現在、ウェブ アプリケーションのホットリロードはできません。

いずれかを選択してから続行してください。後からいつでも他のオペレーティング システムでアプリを実行できます。開発ターゲットを選べば、次のステップがスムーズになります。

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 の左側のペインで、[Explorer] をクリックして 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.3.0 <4.0.0'

dependencies:
  flame: ^1.16.0
  flutter:
    sdk: flutter
  flutter_animate: ^4.5.0
  google_fonts: ^6.1.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^3.0.1

flutter:
  uses-material-design: true

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

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

main.dart ファイルの場所を示す矢印が付いた VS Code の部分的なスクリーンショット

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

lib/main.dart

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

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

完全に黒くなっている brick_breaker アプリケーション ウィンドウのスクリーンショット

4. ゲームを作成する

ゲームのサイズを調整する

2 次元(2D)でプレイされるゲームにはプレイエリアが必要です。特定のディメンションの領域を作成し、そのディメンションを使用してゲームの他の要素のサイズを設定します。

プレイエリアの座標をレイアウトするには、さまざまな方法があります。慣例として、画面の中心を原点 (0,0) として画面の中心からの方向を測定する場合、正の値を指定すると x 軸は右に、y 軸は上に移動します。この規格は最近のほとんどのゲーム、特に 3 次元を含むゲームに適用されています。

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

lib/src という新しいディレクトリに config.dart というファイルを作成します。このファイルは、次のステップでさらに多くの定数を取得します。

lib/src/config.dart

const gameWidth = 820.0;
const gameHeight = 1600.0;

このゲームのサイズは、幅 820 ピクセル、高さ 1,600 ピクセルです。ゲーム領域は、表示されるウィンドウに合わせてスケーリングされますが、画面に追加されるコンポーネントはすべてこの高さと幅に従います。

PlayArea を作成する

Breakout のゲームでは、ボールはプレイエリアの壁から跳ね返ってきます。競合に対応するには、まず 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. 左上をビューファインダーのアンカーに設定します。デフォルトでは、ビューファインダーは領域の中央を (0,0) のアンカーとして使用します。
  2. PlayAreaworld に追加します。世界はゲームの世界を表すものです。そのすべての子を 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. ボールを表示する

ball コンポーネントを作成する

動くボールを画面に配置するには、別のコンポーネントを作成してゲームの世界に追加する必要があります。

  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/components 内の ball.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 を定義したため、より多くのシェイプが存在すると考えられます。CircleComponent は、RectangleComponent と同様に 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 オブジェクトが作成され、元の Vector2 と同じ方向に設定されますが、距離 1 にスケールダウンされます。これにより、ボールの方向にかかわらずボールの速度が一定になります。その後、ボールの速度がゲームの高さの 4 分の 1 にスケールアップされます。

これらのさまざまな価値を正しく理解するには、イテレーションが必要です。これは業界ではプレイテストとも呼ばれます。

最後の行でデバッグ用ディスプレイがオンになり、デバッグに役立つ追加情報が画面に追加されます。

ここでゲームを実行すると、次のような画面が表示されます。

砂色の長方形の上に青い円がある、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);
  }
}

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

バウンド ザ ボール

これまでのところ、衝突検出を追加してもゲームプレイに変化はありません。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 条件も追加します。残りのロジックを実装するよう促す、控えめなリマインダーです。

ボールが底面の壁に衝突すると、視界から入ったままで、プレイ面から消えるだけです。あとのステップで炎のエフェクトを使ってこのアーティファクトを扱います。

ボールがゲームの壁に衝突したので、ボールを打つためのバットをプレーヤーに渡すと便利です...

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 コンポーネントは PositionComponent であり、RectangleComponent でも CircleComponent でもありません。つまり、このコードでは画面に Bat をレンダリングする必要があります。そのために、render コールバックをオーバーライドします。

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

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

最後に、Bat コンポーネントはキーボード コントロールに応答する必要があります。moveBy 関数を使用すると、他のコードでこのバットに特定の数の仮想ピクセルだけ左または右に移動するよう指示できます。この関数は、Flame ゲームエンジンの新機能である Effect を導入します。MoveToEffect オブジェクトをこのコンポーネントの子として追加すると、プレーヤーには新しい位置にアニメーション化されたバットが表示されます。Flame には、さまざまなエフェクトを実行するための 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(Bat(                                              // Add from here...
        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 の衝突検出コードに残したデバッグ ロギングは確認できます。

今すぐ修正しましょう。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(                                       // Modify from here...
          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 {                                                    // 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>());
    }
  }
}

ここまでで、このコードのほとんどに馴染みがあるはずです。このコードは RectangleComponent を使用し、衝突検出と、コンポーネント ツリーの一番上にある BrickBreaker ゲームへのタイプセーフな参照の両方を使用しています。

このコードで導入される最も重要な新しいコンセプトは、プレーヤーが勝利条件をどのように達成するかです。勝利条件チェックは世界中にレンガをクエリし、残っているのは 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. ゲームに勝つ

再生状態を追加する

このステップでは、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 }              // 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

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.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(
        useMaterial3: true,
        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(
        useMaterial3: true,
        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 をご覧ください。

関連情報