Flame with Flutter の概要

Flutter での Flame の概要

この Codelab について

subject最終更新: 5月 20, 2025
account_circle作成者: Brett Morgan

1. はじめに

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

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

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

学習内容

  • GameWidget から始まる Flame の基本的な仕組み。
  • ゲームループの使用方法。
  • Flame の Component の仕組み。Flutter の Widget に似ています。
  • 衝突を処理する方法。
  • Effect を使用して Component をアニメーション化する方法。
  • Flutter の Widget を Flame ゲームの上に重ねる方法。
  • 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 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] コマンドを選択します。

A screenshot of VS Code with

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

新しいアプリフローの一環として、空のアプリが選択されているように表示されている VS Code のスクリーンショット

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

A screen shot of VS Code with

すると、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 ファイルを開きます。

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 の正の方向は右、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. ビューファインダーのアンカーとして左上を設定します。デフォルトでは、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 と同じ方向に設定された Vector2 オブジェクトが作成されますが、距離は 1 にスケールダウンされます。これにより、ボールがどの方向に飛んでもボールの速度が一定になります。ボールの速度は、ゲームの高さの 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);
 
}
}

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

ボールをバウンドさせる

これまでのところ、衝突検出を追加してもゲームプレイに変化はありません。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 の衝突検出コードに残したデバッグ ロギングを除き、目に見えるレスポンスは得られません。

これを今から修正します。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 は、ボールが視認可能なプレイエリアから出た後に、ボールをゲームワールドから削除します。

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

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);
   
}
 
}
}

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

  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/srcwidgets ディレクトリを作成します。
  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 を追加する必要があります。

2 つ目は、ウェルカム、ゲームオーバー、勝利のエクスペリエンスを強化するために、新しい 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 をご覧ください。

関連情報