1. はじめに
Flame は Flutter ベースの 2D ゲームエンジンです。この Codelab では、1970 年代のビデオゲームの古典の 1 つであるスティーブ・ウォズニアックの Breakout にインスピレーションを得たゲームを作成します。Flame のコンポーネントを使用して、バット、ボール、ブロックを描画します。Flame のエフェクトを使用してバットの動きをアニメーション化し、Flame を Flutter の状態管理システムと統合する方法を学びます。
完了すると、ゲームは次のアニメーション GIF のようになります(ただし、少し遅くなります)。
学習内容
GameWidget
から始まる Flame の基本的な仕組み。- ゲームループの使用方法。
- Flame の
Component
の仕組み。これらは Flutter のWidget
に似ています。 - 衝突の処理方法。
Effect
を使用してComponent
をアニメーション化する方法。- Flame ゲームの上に Flutter の
Widget
をオーバーレイする方法。 - Flame を Flutter の状態管理と統合する方法。
作成するアプリの概要
この Codelab では、Flutter と Flame を使用して 2D ゲームを作成します。完成したゲームは、以下の要件を満たしている必要があります。
- Flutter でサポートされている 6 つのプラットフォーム(Android、iOS、Linux、macOS、Windows、ウェブ)すべてで機能する
- Flame のゲームループを使用して 60 fps 以上を維持します。
google_fonts
パッケージやflutter_animate
などの Flutter の機能を使用して、80 年代のアーケード ゲームの雰囲気を再現します。
2. Flutter 環境をセットアップする
編集者
この Codelab を簡素化するため、Visual Studio Code(VS Code)が開発環境であると仮定します。VS Code は無料で、すべての主要なプラットフォームに対応しています。手順では VS Code 専用のショートカットをデフォルトとするため、この Codelab では VS Code を使用します。「お使いのエディタで X を行うための適切な操作を行う」などとするよりも、「このボタンをクリック」や「このキーを押して X を行う」とするほうが簡単です。
Android Studio やその他の IntelliJ IDE、Emacs、Vim、Notepad++ など、任意のエディタを使用できます。どれも Flutter に使用できます。
開発ターゲットを選ぶ
Flutter は、複数のプラットフォーム用のアプリを生成します。アプリは、次のオペレーティング システムのいずれでも実行できます。
- iOS
- Android
- Windows
- macOS
- Linux
- ウェブ
開発ターゲットとして 1 つのオペレーティング システムを選ぶのが一般的です。これは、開発中にアプリを実行するオペレーティング システムです。
たとえば、Flutter アプリの開発に Windows ノートパソコンを使用するとしましょう。開発ターゲットに Android を選択します。アプリをプレビューするには、USB ケーブルで Android デバイスを Windows ノートパソコンに接続し、開発中のアプリを接続した Android デバイスまたは Android エミュレータで実行します。Windows を開発ターゲットに選ぶことも可能です。その場合、開発中のアプリはエディタといっしょに Windows アプリとして実行します。
続行する前に選択してください。後からいつでも別のオペレーティング システムでアプリを実行できます。開発ターゲットを明確にしていれば、次のステップにスムーズに進めます。
Flutter をインストールする
Flutter SDK の最新のインストール手順は、docs.flutter.dev で確認できます。
Flutter のウェブサイトでは、SDK のインストールだけでなく、開発ターゲット関連のツールやエディタ プラグインについても説明されています。この Codelab では、次のソフトウェアをインストールします。
- 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));
}
- このコードを実行して、すべてが正常に機能していることを確認します。新しいウィンドウが開き、黒い背景のみが表示されます。世界最悪のビデオゲームが 60fps でレンダリングされるようになりました。
4. ゲームを作成する
ゲームのサイズを測る
2 次元(2D)でプレイされるゲームにはプレイエリアが必要です。特定のサイズの領域を構築し、そのサイズを使用してゲームの他の要素のサイズを調整します。
プレイエリアに座標を配置する方法はいくつかあります。1 つの規則では、画面の中央を原点 (0,0)
として、画面の中央から方向を測定します。正の値は、x 軸に沿って右に、y 軸に沿って上にアイテムを移動します。この標準は、最近のほとんどのゲーム、特に 3 次元を扱うゲームに適用されます。
オリジナルの Breakout ゲームが作成されたときの慣例では、原点は左上に設定されていました。x の正の方向は同じですが、y が反転しています。x の正の方向は右、y は下でした。このゲームでは、時代を忠実に再現するため、原点が左上隅に設定されています。
lib/src
という新しいディレクトリに config.dart
というファイルを作成します。このファイルには、以降のステップでさらに定数が追加されます。
lib/src/config.dart
const gameWidth = 820.0;
const gameHeight = 1600.0;
このゲームは幅 820 ピクセル、高さ 1,600 ピクセルになります。ゲーム領域は、表示されるウィンドウに合わせて拡大縮小されますが、画面に追加されたすべてのコンポーネントは、この高さと幅に準拠します。
PlayArea を作成する
ブレイクアウト ゲームでは、ボールがプレイ エリアの壁に跳ね返ります。衝突に対応するには、まず PlayArea
コンポーネントが必要です。
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
と同じ方向に設定され、距離が 1 に縮小された Vector2
オブジェクトが作成されます。これにより、ボールの速度はどの方向に進んでも一定になります。ボールの速度は、ゲームの高さの 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);
}
}
RectangleComponent
の子として RectangleHitbox
コンポーネントを追加すると、親コンポーネントのサイズに一致する衝突検出用のヒットボックスが構築されます。親コンポーネントよりも小さい、または大きいヒットボックスが必要な場合は、RectangleHitbox
のファクトリ コンストラクタ relative
を使用します。
ボールをバウンドさせる
今のところ、衝突検出を追加してもゲームプレイに変化はありません。Ball
コンポーネントを変更すると、変更されます。PlayArea
と衝突したときに変化するのはボールの動作です。
Ball
コンポーネントを次のように変更します。
lib/src/components/ball.dart
import 'package:flame/collisions.dart'; // Add this import
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart'; // And this import
import 'play_area.dart'; // And this one too
class Ball extends CircleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> { // Add these mixins
Ball({
required this.velocity,
required super.position,
required double radius,
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill,
children: [CircleHitbox()], // Add this parameter
);
final Vector2 velocity;
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
@override // Add from here...
void onCollisionStart(
Set<Vector2> intersectionPoints,
PositionComponent other,
) {
super.onCollisionStart(intersectionPoints, other);
if (other is PlayArea) {
if (intersectionPoints.first.y <= 0) {
velocity.y = -velocity.y;
} else if (intersectionPoints.first.x <= 0) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.x >= game.width) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.y >= game.height) {
removeFromParent();
}
} else {
debugPrint('collision with $other');
}
} // To here.
}
この例では、onCollisionStart
コールバックを追加することで、大きな変更を加えています。前の例の BrickBreaker
に追加された衝突検出システムは、このコールバックを呼び出します。
まず、コードは 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
の衝突検出コードに残したデバッグ ロギング以外に、目に見える反応はありません。
Time to fix that now. Ball
コンポーネントを次のように編集します。
lib/src/components/ball.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart'; // Add this import
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import 'bat.dart'; // And this import
import 'play_area.dart';
class Ball extends CircleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Ball({
required this.velocity,
required super.position,
required double radius,
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill,
children: [CircleHitbox()],
);
final Vector2 velocity;
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
@override
void onCollisionStart(
Set<Vector2> intersectionPoints,
PositionComponent other,
) {
super.onCollisionStart(intersectionPoints, other);
if (other is PlayArea) {
if (intersectionPoints.first.y <= 0) {
velocity.y = -velocity.y;
} else if (intersectionPoints.first.x <= 0) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.x >= game.width) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.y >= game.height) {
add(RemoveEffect(delay: 0.35)); // Modify from here...
}
} else if (other is Bat) {
velocity.y = -velocity.y;
velocity.x =
velocity.x +
(position.x - other.position.x) / other.size.x * game.width * 0.3;
} else { // To here.
debugPrint('collision with $other');
}
}
}
これらのコード変更により、2 つの別々の問題が修正されます。
まず、ボールが画面の下部に触れた瞬間に消滅する問題を修正します。この問題を解決するには、removeFromParent
呼び出しを RemoveEffect
に置き換えます。RemoveEffect
は、ボールが視認可能なプレイエリアから出た後、ゲームワールドからボールを削除します。
また、バットとボールの衝突の処理も修正されます。この処理コードはプレーヤーに非常に有利に働きます。プレーヤーがバットでボールに触れている限り、ボールは画面の上部に戻ります。この設定では甘すぎると感じ、よりリアルな操作感を求める場合は、ゲームの操作感に合わせてこの設定を変更してください。
velocity
の更新の複雑さについて説明します。壁の衝突の場合のように、速度の y
成分を反転させるだけではありません。また、バットとボールの接触時の相対位置に応じて x
コンポーネントを更新します。これにより、ボールの動きをより細かく制御できるようになりますが、その方法はプレイを通じてのみプレイヤーに伝わります。
ボールを打つバットができたので、ボールで壊すレンガがあるといいですね。
8. 壁を壊す
ブリックを作成する
ゲームにブロックを追加するには、
- 次のように、
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);
}
}
}
この小さな変更により、RemoveEffect
に onComplete
コールバックが追加され、gameOver
再生状態がトリガーされます。プレーヤーがボールを画面の下から逃がすことができる場合、この値は適切です。
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
を追加する必要があります。
次に、ウェルカム、ゲームオーバー、勝利のエクスペリエンスを強化するために、新しい 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 をご覧ください。