この Codelab について
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 は、複数のプラットフォーム向けのアプリを生成します。アプリは、次のオペレーティング システムのいずれでも実行できます。
- 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 では、次のソフトウェアをインストールします。
- Flutter SDK
- Visual Studio Code と Flutter プラグイン
- 選択した開発ターゲット用のコンパイラ ソフトウェア。(Windows をターゲットとする場合は Visual Studio、macOS または iOS をターゲットとする場合は Xcode が必要です)。
次のセクションでは、初めての Flutter プロジェクトを作成します。
問題のトラブルシューティングが必要な場合は、以下の質問と回答(StackOverflow から)がトラブルシューティングの参考になるかもしれません。
よくある質問
- Flutter SDK のパスの確認方法を教えてください。
- Flutter が見付からなかった場合はどうすればよいですか?
- 「Waiting for another flutter command to release the startup lock」の問題はどうやって解決すればよいですか?
- Android SDK のインストール場所を Flutter に認識させるにはどうすればよいですか?
flutter doctor --android-licenses
を実行したときの Java エラーにはどう対処すればよいですか?sdkmanager
ツールが見付からない場合はどう対処すればよいですか?- 「
cmdline-tools
component is missing」というエラーにはどう対処すればよいですか? - CocoaPods を Apple Silicon(M1)で実行するにはどうすればよいですか?
- VS Code で保存時の自動整形を無効にするにはどうすればよいですか?
3. プロジェクトを作成する
最初の Flutter プロジェクトを作成する
これを行うには、VS Code を開き、選択したディレクトリに Flutter アプリ テンプレートを作成します。
- Visual Studio Code を起動します。
- コマンド パレットを開き(
F1
、Ctrl+Shift+P
、Shift+Cmd+P
)、flutter new と入力します。表示された [Flutter: New Project] コマンドを選択します。
- [Empty Application] を選択します。プロジェクトを作成するディレクトリを選択します。昇格した権限を必要としないディレクトリ、またはパスにスペースが含まれていないディレクトリにする必要があります。たとえば、ホーム ディレクトリや
C:\src\
などです。
- プロジェクトに
brick_breaker
という名前を付けます。この Codelab の残りの部分では、アプリの名前がbrick_breaker
であると想定しています。
すると、Flutter がプロジェクト フォルダを作成し、VS Code がそのフォルダを開きます。次は、2 つのファイルの内容を、このアプリの基本的なスキャフォールドで上書きします。
初期アプリをコピーして貼り付ける
これにより、この Codelab で提供されているサンプルコードがアプリに追加されます。
- VS Code の左側のペインで [エクスプローラ] をクリックし、
pubspec.yaml
ファイルを開きます。
- このファイルの内容を次のように置き換えます。
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
ファイルでは、現在のバージョン、依存関係、同梱するアセットなど、アプリの基本情報を指定します。
lib/
ディレクトリのmain.dart
ファイルを開きます。
- このファイルの内容を次のように置き換えます。
lib/main.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
void main() {
final game = FlameGame();
runApp(GameWidget(game: game));
}
- このコードを実行して、すべてが正常に機能することを確認します。黒い背景のみの新しいウィンドウが表示されます。世界最悪のビデオゲームが 60 fps でレンダリングされるようになりました。
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
コンポーネントが必要です。
lib/src/components
という新しいディレクトリにplay_area.dart
というファイルを作成します。- このファイルに次の行を追加します。
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 では、次のステップで説明するゲームループから始めます。
- 混乱を抑えるには、このプロジェクト内のすべてのコンポーネントを含むファイルを追加します。
lib/src/components
にcomponents.dart
ファイルを作成し、次の内容を追加します。
lib/src/components/components.dart
export 'play_area.dart';
export
ディレクティブは、import
の逆の役割を果たします。別のファイルにインポートするときに、このファイルが公開する機能を宣言します。次の手順で新しいコンポーネントを追加すると、このファイルにエントリが増えます。
Flame ゲームを作成する
前の手順で表示された赤い波線を消すには、Flame の FlameGame
の新しいサブクラスを派生させます。
lib/src
にbrick_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 つのアクションを実行します。
- ビューファインダーのアンカーとして左上を設定します。デフォルトでは、
viewfinder
は領域の中央を(0,0)
のアンカーとして使用します。 PlayArea
をworld
に追加します。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));
}
変更を加えた後、ゲームを再起動します。ゲームは次の図のようになります。
次のステップでは、ワールドにボールを追加して動かします。
5. ボールを表示する
ボール コンポーネントを作成する
動くボールを画面に表示するには、別のコンポーネントを作成してゲームの世界に追加する必要があります。
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 で何度も使用されます。これにより、最上位の gameWidth
と gameHeight
を変更して、その結果としてゲームの外観と操作性がどのように変化するかを確認できます。
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
の更新に特に注意してください。時間の経過に伴うモーションの離散シミュレーションの更新を実装する方法は次のとおりです。
- コンポーネントのリストに
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 になるように拡大されます。
これらのさまざまな値を適切に設定するには、反復処理(業界ではプレイテストとも呼ばれます)が必要です。
最後の行は、デバッグ ディスプレイをオンにします。これにより、デバッグに役立つ追加情報がディスプレイに表示されます。
ゲームを実行すると、次のような画面が表示されます。
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
の子として追加すると、親コンポーネントのサイズと一致する衝突検出用のヒットボックスが作成されます。親コンポーネントよりも小さいまたは大きいヒットボックスが必要な場合は、RectangleHitbox
の relative
というファクトリ コンストラクタがあります。
ボールをバウンドさせる
これまでのところ、衝突検出を追加してもゲームプレイに変化はありません。Ball
コンポーネントを変更すると、変更されます。ボールが PlayArea
に衝突したときに変更する必要があるのは、ボールの動作です。
Ball
コンポーネントを次のように変更します。
lib/src/components/ball.dart
import 'package:flame/collisions.dart'; // Add this import
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart'; // And this import
import 'play_area.dart'; // And this one too
class Ball extends CircleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> { // Add these mixins
Ball({
required this.velocity,
required super.position,
required double radius,
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill,
children: [CircleHitbox()], // Add this parameter
);
final Vector2 velocity;
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
@override // Add from here...
void onCollisionStart(
Set<Vector2> intersectionPoints,
PositionComponent other,
) {
super.onCollisionStart(intersectionPoints, other);
if (other is PlayArea) {
if (intersectionPoints.first.y <= 0) {
velocity.y = -velocity.y;
} else if (intersectionPoints.first.x <= 0) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.x >= game.width) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.y >= game.height) {
removeFromParent();
}
} else {
debugPrint('collision with $other');
}
} // To here.
}
この例では、onCollisionStart
コールバックが追加され、大幅に変更されています。前の例で BrickBreaker
に追加した衝突検出システムが、このコールバックを呼び出します。
まず、Ball
が PlayArea
と衝突したかどうかがテストされます。ゲームの世界に他のコンポーネントがないため、現時点では冗長なようです。次のステップで、世界にコウモリを追加すると、この状態は変わります。また、ボールがバット以外の物体に衝突したときを処理する else
条件も追加します。残りのロジックを実装するよう促すメッセージです。
ボールが下壁にぶつかると、ボールは視界内に残っているにもかかわらず、プレイ サーフェスから消えてしまいます。このアーティファクトは、Flame の効果を使用して次のステップで処理します。
ボールがゲームの壁に衝突するようになりました。ボールを打つバットをプレーヤーに渡してあげると、ゲームがさらに楽しくなります。
7. バットでボールを打つ
バットを作成する
ゲーム内でボールをプレーし続けるためにバットを追加するには、
- 次のように、
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
定数は、左矢印キーまたは右矢印キーを押すたびにバットが進む距離を構成します。
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
インスタンスにアクセスできるようになります。
Bat
をBrickBreaker
で使用できるようにするには、次のように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. 壁を取り壊す
ブリックの作成
ゲームにブロックを追加するには、
- 次のように、
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.
- 次のように
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;
}
}
現状のゲームを実行すると、ゲームの主要なメカニクスがすべて表示されます。デバッグをオフにして、これで完了と判断することもできますが、何かが欠けているように感じられます。
ウェルカム画面、ゲームオーバー画面、スコアなどはどうでしょうか?Flutter では、これらの機能をゲームに追加できます。次は、その方法について説明します。
9. ゲームに勝つ
再生状態を追加する
このステップでは、Flame ゲームを Flutter ラッパー内に埋め込み、ウェルカム画面、ゲームオーバー画面、勝利画面に Flutter オーバーレイを追加します。
まず、ゲーム ファイルとコンポーネント ファイルを変更して、オーバーレイを表示するかどうか、表示する場合はどのオーバーレイを表示するかを反映するプレイ状態を実装します。
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 つの新しいハンドラを設定しました。タップ ハンドラを追加し、キーボード ハンドラを拡張して、ユーザーが複数のモダリティで新しいゲームを開始できるようにしました。ゲームの状態をモデル化したら、プレーヤーが勝ったか負けたときにゲームの状態遷移をトリガーするようにコンポーネントを更新するのが理にかなっています。
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
再生状態をトリガーする RemoveEffect
に onComplete
コールバックが追加されます。ボールが画面の下部から逃げ出すと、この値が適切になります。
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 シェルを追加します。
lib/src
にwidgets
ディレクトリを作成します。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
のキーは、BrickBreaker
の playState
セッターが追加または削除したオーバーレイと一致している必要があります。この地図にないオーバーレイを設定しようとすると、誰もが不満を抱くことになります。
- この新機能を画面に表示するには、
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 つあります。
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>
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 状態管理に公開します。これにより、プレーヤーがレンガを壊すたびにゲームコードがスコアを更新できるようになります。
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 の状態管理に関連付けられます。
- プレーヤーがレンガを壊したときにスコアにポイントを追加するように
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 でスコアを付けられるようになったので、ウィジェットを組み合わせて見栄えを良くしましょう。
lib/src/widgets
にscore_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!,
),
);
},
);
}
}
lib/src/widgets
にoverlay_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
コンポーネントで大幅に変更されています。まず、ScoreCard
が score
にアクセスできるように、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 ターゲット プラットフォームのいずれかで実行できるようになりました。ゲームは次のようになります。
11. 完了
これで、Flutter と Flame でゲームを作成できました。
Flame 2D ゲームエンジンを使用してゲームを作成し、Flutter ラッパーに埋め込みました。Flame のエフェクトを使用して、コンポーネントをアニメーション化して削除しました。Google Fonts パッケージと Flutter Animate パッケージを使用して、ゲーム全体をデザイン性の高いものにしました。
次のステップ
以下の Codelab をご覧ください。