1. はじめに
Flame は、Flutter ベースの 2D ゲームエンジンです。この Codelab では、70 年代のビデオゲームの名作の 1 つ、Steve Wozniak の Breakout から着想を得たゲームを作成します。Flame のコンポーネントを使用して、バット、ボール、レンガを描画します。Flame のエフェクトを使用してコウモリの動きをアニメーション化し、Flame を Flutter の状態管理システムと統合する方法を確認します。
完了すると、ゲームは少し遅くなりますが、このアニメーション GIF のようになります。
学習内容
- Flame の基本の仕組み(
GameWidget
以降)。 - ゲームループを使用する方法。
- Flame の
Component
の仕組みこれは、Flutter のWidget
に似ています。 - 衝突に対処する方法。
Effect
を使用してComponent
をアニメーション化する方法- Flame ゲームの上に Flutter
Widget
をオーバーレイする方法 - Flame を Flutter の状態管理と統合する方法
作成するアプリの概要
この Codelab では、Flutter と Flame を使用して 2D ゲームを作成します。完了すると、ゲームは次の要件を満たす必要があります
- Flutter がサポートしている 6 つのプラットフォーム(Android、iOS、Linux、macOS、Windows、ウェブ)すべてで動作する
- Flame のゲームループを使用して 60 fps 以上を維持します。
google_fonts
パッケージやflutter_animate
などの Flutter の機能を使用して、80 年代のアーケード ゲームの雰囲気を再現します。
2. Flutter 環境をセットアップする
編集者
この Codelab を単純化するために、Visual Studio Code(VS Code)が開発環境であることを前提としています。VS Code は無料で、すべての主要なプラットフォームで動作します。手順では VS Code 固有のショートカットがデフォルトになっているため、この Codelab では VS Code を使用します。タスクがシンプルになります。「このボタンをクリックして」または「このキーを押すと X になります」「X を行うための適切なアクションをエディタで実行」という表現は使用しません。
Android Studio、その他の IntelliJ IDE、Emacs、Vim、Notepad++ などの任意のエディタを使用できます。これらはすべて Flutter と連携します。
開発ターゲットを選ぶ
Flutter は、複数のプラットフォームに対応したアプリを作成します。アプリは、次のオペレーティング システムのいずれでも実行できます。
- iOS
- Android
- Windows
- macOS
- Linux
- ウェブ
開発ターゲットとして 1 つのオペレーティング システムを選ぶのが一般的な方法です。開発中にアプリを実行するオペレーティング システムです。
たとえば、Flutter アプリの開発に Windows ノートパソコンを使用するとします。次に、開発ターゲットとして Android を選択します。アプリをプレビューするには、Android デバイスを USB ケーブルで Windows ノートパソコンに接続し、接続された Android デバイスまたは Android Emulator 上で開発中のアプリを実行します。開発ターゲットに Windows を選択して、開発中のアプリを Windows アプリとしてエディタで実行することもできます。
開発ターゲットにウェブを選びたくなるかもしれませんが、これには、開発中に欠点があります。Flutter のステートフル ホットリロード機能が使用できなくなることです。Flutter では現在、ウェブ アプリケーションのホットリロードはできません。
いずれかを選択してから続行してください。後からいつでも他のオペレーティング システムでアプリを実行できます。開発ターゲットを選べば、次のステップがスムーズになります。
Flutter をインストールする
Flutter SDK の最新のインストール手順については、docs.flutter.dev をご覧ください。
Flutter のウェブサイトには、SDK のインストール、開発ターゲット関連のツール、エディタ プラグインについて記載されています。この Codelab では、次のソフトウェアをインストールします。
- 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 エラーにはどう対処すればよいですか?- Android ツールが
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 の左側のペインで、[Explorer] をクリックして
pubspec.yaml
ファイルを開きます。
- このファイルの内容を次のように置き換えます。
pubspec.yaml
name: brick_breaker
description: "Re-implementing Woz's Breakout"
publish_to: 'none'
version: 0.1.0
environment:
sdk: '>=3.3.0 <4.0.0'
dependencies:
flame: ^1.16.0
flutter:
sdk: flutter
flutter_animate: ^4.5.0
google_fonts: ^6.1.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.1
flutter:
uses-material-design: true
pubspec.yaml
ファイルでは、現在のバージョン、依存関係、同梱するアセットなど、アプリの基本情報を指定します。
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 の正の 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 つのアクションを実行します。
- 左上をビューファインダーのアンカーに設定します。デフォルトでは、ビューファインダーは領域の中央を
(0,0)
のアンカーとして使用します。 PlayArea
を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. ボールを表示する
ball コンポーネントを作成する
動くボールを画面に配置するには、別のコンポーネントを作成してゲームの世界に追加する必要があります。
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 にスケールダウンされます。これにより、ボールの方向にかかわらずボールの速度が一定になります。その後、ボールの速度がゲームの高さの 4 分の 1 にスケールアップされます。
これらのさまざまな価値を正しく理解するには、イテレーションが必要です。これは業界ではプレイテストとも呼ばれます。
最後の行でデバッグ用ディスプレイがオンになり、デバッグに役立つ追加情報が画面に追加されます。
ここでゲームを実行すると、次のような画面が表示されます。
PlayArea
コンポーネントと Ball
コンポーネントの両方にデバッグ情報がありますが、背景マットにより PlayArea
の数値が切り抜かれます。すべてにデバッグ情報が表示されるのは、コンポーネント ツリー全体で debugMode
をオンにしているためです。また、選択したコンポーネントのみを対象にデバッグを有効にすることもできます。
ゲームを何度か再開すると、ボールが壁とうまく作用しないことに気づくかもしれません。そのためには、衝突検出を追加する必要があります。これは次のステップで行います。
6. 跳ね回る
衝突検出を追加する
衝突検出は、2 つのオブジェクトが互いに接触したときにゲームが認識する動作を追加します。
ゲームに衝突検出を追加するには、次のコードに示すように、HasCollisionDetection
ミックスインを BrickBreaker
ゲームに追加します。
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'components/components.dart';
import 'config.dart';
class BrickBreaker extends FlameGame with HasCollisionDetection { // Modify this line
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final rand = math.Random();
double get width => size.x;
double get height => size.y;
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
world.add(Ball(
radius: ballRadius,
position: size / 2,
velocity: Vector2((rand.nextDouble() - 0.5) * width, height * 0.2)
.normalized()
..scale(height / 4)));
debugMode = true;
}
}
これにより、コンポーネントのヒットボックスが追跡され、ゲームのティックごとに衝突コールバックがトリガーされます。
ゲームのヒットボックスの入力を開始するには、PlayArea
コンポーネントを以下のように変更します。
lib/src/components/play_area.dart
import 'dart:async';
import 'package:flame/collisions.dart'; // Add this import
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
class PlayArea extends RectangleComponent with HasGameReference<BrickBreaker> {
PlayArea()
: super(
paint: Paint()..color = const Color(0xfff2e8cf),
children: [RectangleHitbox()], // Add this parameter
);
@override
FutureOr<void> onLoad() async {
super.onLoad();
size = Vector2(game.width, game.height);
}
}
RectangleHitbox
コンポーネントを RectangleComponent
の子として追加すると、親コンポーネントのサイズと一致する衝突検出用のヒットボックスが作成されます。親コンポーネントよりも小さいヒットボックスや大きいヒットボックスが必要な場合のために、relative
という RectangleHitbox
のファクトリ コンストラクタがあります。
バウンド ザ ボール
これまでのところ、衝突検出を追加してもゲームプレイに変化はありません。Ball
コンポーネントを変更すると、変更されます。ボールが PlayArea
に衝突したときに変更する必要があるのは、ボールの動作です。
Ball
コンポーネントを次のように変更します。
lib/src/components/ball.dart
import 'package:flame/collisions.dart'; // Add this import
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart'; // And this import
import 'play_area.dart'; // And this one too
class Ball extends CircleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> { // Add these mixins
Ball({
required this.velocity,
required super.position,
required double radius,
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill,
children: [CircleHitbox()]); // Add this parameter
final Vector2 velocity;
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
@override // Add from here...
void onCollisionStart(
Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollisionStart(intersectionPoints, other);
if (other is PlayArea) {
if (intersectionPoints.first.y <= 0) {
velocity.y = -velocity.y;
} else if (intersectionPoints.first.x <= 0) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.x >= game.width) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.y >= game.height) {
removeFromParent();
}
} else {
debugPrint('collision with $other');
}
} // To here.
}
この例では、onCollisionStart
コールバックを追加して大幅に変更しています。前の例で BrickBreaker
に追加した衝突検出システムは、このコールバックを呼び出します。
まず、Ball
が PlayArea
と衝突したかどうかをテストします。ゲームの世界には他のコンポーネントがないため、現時点では冗長なように見えます。これは、次のステップでバットをワールドに追加するときに変更されます。次に、ボールがバット以外のものに衝突したときに対処する else
条件も追加します。残りのロジックを実装するよう促す、控えめなリマインダーです。
ボールが底面の壁に衝突すると、視界から入ったままで、プレイ面から消えるだけです。あとのステップで炎のエフェクトを使ってこのアーティファクトを扱います。
ボールがゲームの壁に衝突したので、ボールを打つためのバットをプレーヤーに渡すと便利です...
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 コンポーネントは PositionComponent
であり、RectangleComponent
でも CircleComponent
でもありません。つまり、このコードでは画面に Bat
をレンダリングする必要があります。そのために、render
コールバックをオーバーライドします。
canvas.drawRRect
(角の丸い長方形を描画する)の呼び出しをよく見て、「長方形はどこにあるのか?」と自問するかもしれません。Offset.zero & size.toSize()
は、Rect
を作成する dart:ui
Offset
クラスの operator &
オーバーロードを利用します。この省略形は最初はわかりにくいかもしれませんが、下位レベルの Flutter や Flame のコードでよく見られます。
次に、この Bat
コンポーネントは、プラットフォームに応じて指またはマウスでドラッグできます。この機能を実装するには、DragCallbacks
ミックスインを追加して、onDragUpdate
イベントをオーバーライドします。
最後に、Bat
コンポーネントはキーボード コントロールに応答する必要があります。moveBy
関数を使用すると、他のコードでこのバットに特定の数の仮想ピクセルだけ左または右に移動するよう指示できます。この関数は、Flame ゲームエンジンの新機能である Effect
を導入します。MoveToEffect
オブジェクトをこのコンポーネントの子として追加すると、プレーヤーには新しい位置にアニメーション化されたバットが表示されます。Flame には、さまざまなエフェクトを実行するための Effect
のコレクションが用意されています。
エフェクトのコンストラクタ引数には、game
ゲッターへの参照が含まれています。このクラスに HasGameReference
ミックスインを追加しているのはそのためです。このミックスインは、このコンポーネントにタイプセーフな game
アクセサーを追加し、コンポーネント ツリーの最上位の BrickBreaker
インスタンスにアクセスします。
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(Bat( // Add from here...
size: Vector2(batWidth, batHeight),
cornerRadius: const Radius.circular(ballRadius / 2),
position: Vector2(width / 2, height * 0.95))); // To here
debugMode = true;
}
@override // Add from here...
KeyEventResult onKeyEvent(
KeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
super.onKeyEvent(event, keysPressed);
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowLeft:
world.children.query<Bat>().first.moveBy(-batStep);
case LogicalKeyboardKey.arrowRight:
world.children.query<Bat>().first.moveBy(batStep);
}
return KeyEventResult.handled;
} // To here
}
KeyboardEvents
ミックスインとオーバーライドされた onKeyEvent
メソッドの追加により、キーボード入力が処理されます。先ほど追加したコードを思い出して、バットを適切なステップ量だけ動かします。
追加されたコードの残りの部分は、バットをゲーム世界の適切な位置と適切な比率で追加します。このファイルでこれらすべての設定を公開することで、バットとボールの相対的なサイズを微調整して、ゲームの適切なフィールを作れるようになります。
この時点でゲームをプレイすると、バットを動かしてボールをインターセプトできますが、レスポンスは目に見えません。これを見ると、Ball
の衝突検出コードに残したデバッグ ロギングは確認できます。
今すぐ修正しましょう。Ball
コンポーネントを次のように編集します。
lib/src/components/ball.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart'; // Add this import
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import 'bat.dart'; // And this import
import 'play_area.dart';
class Ball extends CircleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Ball({
required this.velocity,
required super.position,
required double radius,
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill,
children: [CircleHitbox()]);
final Vector2 velocity;
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
@override
void onCollisionStart(
Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollisionStart(intersectionPoints, other);
if (other is PlayArea) {
if (intersectionPoints.first.y <= 0) {
velocity.y = -velocity.y;
} else if (intersectionPoints.first.x <= 0) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.x >= game.width) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.y >= game.height) {
add(RemoveEffect( // Modify from here...
delay: 0.35,
));
}
} else if (other is Bat) {
velocity.y = -velocity.y;
velocity.x = velocity.x +
(position.x - other.position.x) / other.size.x * game.width * 0.3;
} else { // To here.
debugPrint('collision with $other');
}
}
}
このコード変更により、2 つの異なる問題が修正されます。
まず、ボールが画面の下部に触れた瞬間に外に出て消える現象を修正します。この問題を解決するには、removeFromParent
呼び出しを RemoveEffect
に置き換えます。RemoveEffect
は、ボールが視認可能なプレイエリアの外に出ると、ゲームの世界からボールが取り除かれます。
第二に、これらの変更により、バットとボールの衝突の処理方法が修正されました。この処理コードはプレーヤーにとって非常に有利です。プレーヤーがバットでボールに触れている限り、ボールは画面の上部に戻ります。もっと現実に近いと思えるなら、ゲームの雰囲気に合うようにこの取り扱いを変更してください。
velocity
の更新は複雑であることを説明します。壁が衝突したときのように、速度の y
成分が逆になるわけではありません。また、接触時のバットとボールの相対位置に応じて x
コンポーネントを更新します。これにより、プレーヤーはボールの動作をより細かくコントロールできるようになりますが、プレー以外の方法でどのようにプレーヤーに伝達されるかは制御できません。
バットでボールを打つことができました。ボールを打つためのレンガも用意しましょう。
8. 壁を破る
ブロックの作成
ゲームにブロックを追加するには、
- 次のように、
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>());
}
}
}
ここまでで、このコードのほとんどに馴染みがあるはずです。このコードは RectangleComponent
を使用し、衝突検出と、コンポーネント ツリーの一番上にある BrickBreaker
ゲームへのタイプセーフな参照の両方を使用しています。
このコードで導入される最も重要な新しいコンセプトは、プレーヤーが勝利条件をどのように達成するかです。勝利条件チェックは世界中にレンガをクエリし、残っているのは 1 つだけであることを確認します。前の行により、このブロックが親から削除されているため、少しわかりにくくなるかもしれません。
理解すべき重要な点は、コンポーネントの削除はキューに格納されたコマンドであるということです。このコードが実行された後、ゲーム世界の次のティックの前にブロックが削除されます。
Brick
コンポーネントを BrickBreaker
からアクセスできるようにするには、lib/src/components/components.dart
を次のように編集します。
lib/src/components/components.dart
export 'ball.dart';
export 'bat.dart';
export 'brick.dart'; // Add this export
export 'play_area.dart';
レンガを世界中に追加
Ball
コンポーネントを次のように更新します。
lib/src/components/ball.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import 'bat.dart';
import 'brick.dart'; // Add this import
import 'play_area.dart';
class Ball extends CircleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Ball({
required this.velocity,
required super.position,
required double radius,
required this.difficultyModifier, // Add this parameter
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill,
children: [CircleHitbox()]);
final Vector2 velocity;
final double difficultyModifier; // Add this member
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
@override
void onCollisionStart(
Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollisionStart(intersectionPoints, other);
if (other is PlayArea) {
if (intersectionPoints.first.y <= 0) {
velocity.y = -velocity.y;
} else if (intersectionPoints.first.x <= 0) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.x >= game.width) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.y >= game.height) {
add(RemoveEffect(
delay: 0.35,
));
}
} else if (other is Bat) {
velocity.y = -velocity.y;
velocity.x = velocity.x +
(position.x - other.position.x) / other.size.x * game.width * 0.3;
} else if (other is Brick) { // Modify from here...
if (position.y < other.position.y - other.size.y / 2) {
velocity.y = -velocity.y;
} else if (position.y > other.position.y + other.size.y / 2) {
velocity.y = -velocity.y;
} else if (position.x < other.position.x) {
velocity.x = -velocity.x;
} else if (position.x > other.position.x) {
velocity.x = -velocity.x;
}
velocity.setFrom(velocity * difficultyModifier); // To here.
}
}
}
これにより、ブロックが衝突するたびにボールの速度を上げる、難易度調整という新しいアスペクトが導入されます。この調整可能なパラメータは、ゲームに適した適切な難易度曲線を見つけるためにプレイテストする必要があります。
BrickBreaker
ゲームを次のように編集します。
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'components/components.dart';
import 'config.dart';
class BrickBreaker extends FlameGame
with HasCollisionDetection, KeyboardEvents {
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final rand = math.Random();
double get width => size.x;
double get height => size.y;
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
world.add(Ball(
difficultyModifier: difficultyModifier, // Add this argument
radius: ballRadius,
position: size / 2,
velocity: Vector2((rand.nextDouble() - 0.5) * width, height * 0.2)
.normalized()
..scale(height / 4)));
world.add(Bat(
size: Vector2(batWidth, batHeight),
cornerRadius: const Radius.circular(ballRadius / 2),
position: Vector2(width / 2, height * 0.95)));
await world.addAll([ // Add from here...
for (var i = 0; i < brickColors.length; i++)
for (var j = 1; j <= 5; j++)
Brick(
position: Vector2(
(i + 0.5) * brickWidth + (i + 1) * brickGutter,
(j + 2.0) * brickHeight + j * brickGutter,
),
color: brickColors[i],
),
]); // To here.
debugMode = true;
}
@override
KeyEventResult onKeyEvent(
KeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
super.onKeyEvent(event, keysPressed);
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowLeft:
world.children.query<Bat>().first.moveBy(-batStep);
case LogicalKeyboardKey.arrowRight:
world.children.query<Bat>().first.moveBy(batStep);
}
return KeyEventResult.handled;
}
}
ゲームを現在の状態で実行すると、ゲームの主な仕組みがすべて表示されます。デバッグをオフにして完了と呼びかけることはできますが、何か足りないものが感じられます。
ウェルカム画面、ゲームオーバー画面、スコアはいかがですか?Flutter では、これらの機能をゲームに追加できます。次に注意を向けるのは、その部分です。
9. ゲームに勝つ
再生状態を追加する
このステップでは、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 } // 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);
}
}
}
この小さな変更により、RemoveEffect
に onComplete
コールバックが追加され、gameOver
の再生状態がトリガーされます。プレーヤーが画面下部からボールを外に出した場合、これは正しいと感じるはずです。
Brick
コンポーネントを次のように編集します。
lib/src/components/brick.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import '../config.dart';
import 'ball.dart';
import 'bat.dart';
class Brick extends RectangleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Brick({required super.position, required Color color})
: super(
size: Vector2(brickWidth, brickHeight),
anchor: Anchor.center,
paint: Paint()
..color = color
..style = PaintingStyle.fill,
children: [RectangleHitbox()],
);
@override
void onCollisionStart(
Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollisionStart(intersectionPoints, other);
removeFromParent();
if (game.world.children.query<Brick>().length == 1) {
game.playState = PlayState.won; // Add this line
game.world.removeAll(game.world.children.query<Ball>());
game.world.removeAll(game.world.children.query<Bat>());
}
}
}
反対に、すべてのブロックを壊せる場合は「ゲームに勝ちました」ということになります。表示されます。よくできました!
Flutter ラッパーを追加する
ゲームを埋め込み、プレイ状態のオーバーレイを追加する場所を用意するには、Flutter シェルを追加します。
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(
useMaterial3: true,
textTheme: GoogleFonts.pressStart2pTextTheme().apply(
bodyColor: const Color(0xff184e77),
displayColor: const Color(0xff184e77),
),
),
home: Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xffa9d6e5),
Color(0xfff2e8cf),
],
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: FittedBox(
child: SizedBox(
width: gameWidth,
height: gameHeight,
child: GameWidget.controlled(
gameFactory: BrickBreaker.new,
overlayBuilderMap: {
PlayState.welcome.name: (context, game) => Center(
child: Text(
'TAP TO PLAY',
style:
Theme.of(context).textTheme.headlineLarge,
),
),
PlayState.gameOver.name: (context, game) => Center(
child: Text(
'G A M E O V E R',
style:
Theme.of(context).textTheme.headlineLarge,
),
),
PlayState.won.name: (context, game) => Center(
child: Text(
'Y O U W O N ! ! !',
style:
Theme.of(context).textTheme.headlineLarge,
),
),
},
),
),
),
),
),
),
),
),
);
}
}
このファイルのほとんどのコンテンツは、標準の Flutter ウィジェット ツリービルドに準拠しています。Flame に固有の部分としては、GameWidget.controlled
を使用した BrickBreaker
ゲーム インスタンスの作成と管理、GameWidget
への新しい overlayBuilderMap
引数などがあります。
この overlayBuilderMap
のキーは、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
を追加する必要があります。
次に、歓迎、ゲームオーバー、勝利のエクスペリエンスを高めるために、新しい OverlayScreen
ウィジェットを追加しました。
lib/src/widgets/game_app.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../brick_breaker.dart';
import '../config.dart';
import 'overlay_screen.dart'; // Add this import
import 'score_card.dart'; // And this one too
class GameApp extends StatefulWidget { // Modify this line
const GameApp({super.key});
@override // Add from here...
State<GameApp> createState() => _GameAppState();
}
class _GameAppState extends State<GameApp> {
late final BrickBreaker game;
@override
void initState() {
super.initState();
game = BrickBreaker();
} // To here.
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
textTheme: GoogleFonts.pressStart2pTextTheme().apply(
bodyColor: const Color(0xff184e77),
displayColor: const Color(0xff184e77),
),
),
home: Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xffa9d6e5),
Color(0xfff2e8cf),
],
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: Column( // Modify from here...
children: [
ScoreCard(score: game.score),
Expanded(
child: FittedBox(
child: SizedBox(
width: gameWidth,
height: gameHeight,
child: GameWidget(
game: game,
overlayBuilderMap: {
PlayState.welcome.name: (context, game) =>
const OverlayScreen(
title: 'TAP TO PLAY',
subtitle: 'Use arrow keys or swipe',
),
PlayState.gameOver.name: (context, game) =>
const OverlayScreen(
title: 'G A M E O V E R',
subtitle: 'Tap to Play Again',
),
PlayState.won.name: (context, game) =>
const OverlayScreen(
title: 'Y O U W O N ! ! !',
subtitle: 'Tap to Play Again',
),
},
),
),
),
),
],
), // To here.
),
),
),
),
),
);
}
}
これで、6 つの Flutter ターゲット プラットフォームのいずれかでこのゲームを実行できるようになりました。ゲームは次のようになります。
11. 完了
これで、Flutter と Flame でゲームを作成することができました。
Flame 2D ゲームエンジンを使用してゲームを作成し、Flutter ラッパーに埋め込みました。Flame のエフェクトを使用して、コンポーネントのアニメーション化と削除を行いました。Google Fonts と Flutter Animate パッケージを使用して、ゲーム全体を効果的にデザインしました。
次のステップ
以下の Codelab をご覧ください。