Flutterでゲーム作り入門

## はじめに
### サマリ
Flutter × [Flame](https://docs.flame-engine.org/1.0.0/)で2Dゲームを作りました👶 題材はブロック崩しです。
Flameの簡単な入門や、ブロック崩しを実装する上でのTips等々を書いていこうと思います。
ソースコードはこちらです。

https://github.com/kawamou/breakout-game

<img width="320px" alt="breakout" src="https://user-images.githubusercontent.com/18514782/150683500-f9e8f4af-1601-4705-8c55-1b8fefa19961.gif">

### 対象読者
- Flutterで2Dゲームを作ってみたい方
- Flameを使う最低限の概念を把握したい方

### 環境
```
Flutter 2.8.0
Flame 1.0.0
```

:::note info
Flameは1.0.0になりAPIに結構な変更が入ったので、過去記事を読む際には注意が必要かもです。
:::

## Flame入門
### 概要
FlameはFlutterで2Dゲームを作る際に便利なゲームエンジン（パッケージ）です。
ゲームループや様々なコンポーネント、衝突検出等々、ゲームを作る際に必要な諸々が準備されています。

インストール方法は公式サイトが詳しいです。

[Getting Started! - Flame](https://docs.flame-engine.org/1.0.0/index.html)

### Flameの基本的な概念

Flameに限らず、ゲームプログラムは停止するまで周り続けるゲームループが基本的な構造です。
疑似コードで表すならこんな感じでしょうか。

```pseudo_gameloop.dart
while {
    update();
    ...
    render();
    ...
  }
```

[ビデオゲームの解剖学](https://developer.mozilla.org/ja/docs/Games/Anatomy)

キャラクターの位置等の「状態」を更新する`update()`、更新した「状態」をもとに画面を描画する`render()`等がゲームループに用意されています。
これらが毎ループ呼び出され、更新、描画、更新、描画...が繰り返されゲームが進行します。

Flameでも同様に`FlameGame`というゲームループが準備されています[^1]。

Flameの素朴な使い方は、

```
1. `FlameGame`を継承したゲームループを作成
2. `PositionComponent`等のコンポーネントをゲームループに追加
3. ゲームループが、追加された各コンポーネントの`update` `render`メソッド等をループ毎に自動でチェック
```

です。
**ゲームループがコンポーネントのメソッドを自動でチェックする**という考え方が非常に重要です。

例えばコンポーネント同士の衝突を検知したい場合、該当のコンポーネントに`Collidable`を`with`すると、コンポーネントに`onCollision`メソッドが生えます。
この`onCollision`内部に衝突時の処理を記述するわけです。
ただし、このままではゲームループは`onCollision`メソッドをチェックしてくれません。
ゲームループ側にも`HasCollidables`を`with`することで、ゲームループが各コンポーネントの`onCollision`を毎ループチェックしてくれるようになります。

```example.dart
class MyGameLoop extends FlameGame with HasCollidables {
  ...
}

class MyComponent extends PositionComponent with Collidable {
  ...
}
```

このように、コンポーネントに適用したMixinに対応するMixinをゲームループに適用して、**毎ループチェックする必要があるメソッドを教えてあげることで**ゲームをどんどんリッチにしていくのがFlameの基本方針です。

## ブロック崩し実装編

ディレクトリ構成はこんな感じです。
小さなアプリケーションなので、FlameのコンポーネントもFlutterのウィジェットも全て`lib`直下に置いてます。

```dir.md
...
├── lib 
│   ├── ball.dart # ブロック崩しのボール
│   ├── block.dart # ボールをぶつけて破壊するブロック
│   ├── game.dart # FlameGameを継承したゲームループ
│   ├── gameover_view.dart # ゲームが終了した際に描画されるWidget
│   ├── initial_view.dart # ゲーム開始時に描画されるWidget
│   ├── paddle.dart # ボールを打ち返す棒
│   └── main.dart # main関数
...
```

以降、実装する上でのTipsを、ソースコードベースで振り返っていきます。

### FlutterにFlameを組み込む方法
`FlameGame`を継承したゲームループをインスタンス化し、`GameWidget`に渡すことでFlutterツリーに追加されます。

```main.dart
class GameLoopWidget extends StatelessWidget {
  ...
  final gameLoop = GameLoop(GameLoopState(0, false));

  @override
  Widget build(BuildContext context) {
    ...
    return Center(
        ...
        child: GameWidget(
          game: gameLoop, // ココ
          ...
        ),
      ),
    );
  }
}
```

>Note: If you instantiate your game in a build method your game will be rebuilt every time the Flutter tree gets rebuilt, which usually is more often than you’d like. To avoid this, you can instead create an instance of your game first and reference it within your widget structure, like it is done in the example above.

[FlameGame](https://docs.flame-engine.org/1.0.0/game.html)

公式ドキュメントにもあるように、ビルドメソッド内でゲームをインスタンス化すると、Flutterツリーがリビルドされるたびにゲームが再構築されコストが高く、ビルドメソッド外でインスタンス化することが重要です。

### ゲームのキャンバス上にウィジェットを重ねる方法

ブロック崩しのスタート画面やゲーム終了画面としてボタンやスコアを持ったウィジェットを表示しています。
こんな感じのやつです。

<img width="320px" alt="image" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/287774/2ce1bdb9-74de-38f9-8a3c-4cb81f787a54.png">

ゲームのキャンバスにFlutterのウィジェットを重ねて表示したい場合は`overlays`を利用します。
`GameWidget`の`overlayBuilderMap`に重ねて表示したいウィジェットを登録します。
今回の場合だと`InitialView`と`GameOverView`のふたつのウィジェットを登録しています。

```main.dart
class GameLoopWidget extends StatelessWidget {
  ...
  @override
  Widget build(BuildContext context) {
    ...
    return Center(
        ...
        child: GameWidget(
          ...
          overlayBuilderMap: {
            'initial': (context, GameLoop gameLoop) => InitialView(
                  gameLoop: gameLoop,
                ),
            'gameover': (context, GameLoop gameLoop) =>
                GameOverView(gameLoop: gameLoop),
          },
          initialActiveOverlays: const ['initial'],
        ),
      ),
    );
  }
}
```

`overlays`プロパティはゲームループ内から自由に参照できます。
`overlays.add('initial')`を呼べば、`overlayBuilderMap`で登録された`InitialView`がゲーム画面に重ねて表示されます。
反対に、`overlays.remove('initial')`で取り除くことができたりします。

### スタート画面を実装する方法

スタート画面として、`InitialView`ウィジェットを作りました。
ボタンを押すとゲームが開始されるだけの簡単な画面です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/287774/86317d1c-e686-eeb6-6234-c5f0adc22eae.png)


`GameWidget`の`initialActiveOverlays`にゲームが開始した際に前面に来るウィジェットを登録できます。

```main.dart
class GameLoopWidget extends StatelessWidget {
  ...
  @override
  Widget build(BuildContext context) {
    ...
    return Center(
        ...
        child: GameWidget(
          ...
          initialActiveOverlays: const ['initial'],
        ),
      ),
    );
  }
}
```

ただし、単に登録しただけでは、ウィジェットは重なるもののゲームも同時にスタートしてしまいます。
ゲームループの`paused`プロパティを`true`にすることでゲームループの一時停止が可能です[^2]。

```game.dart
@override
  void onMount() {
  paused = true;
  ...
}
```

次に「Start」ボタンを押した際にゲームが開始されるようにしたいです。
まず、ボタンを押すと`overlays.remove(initial)`が呼ばれるようにします。

```initial_view.dart
class InitialView extends StatelessWidget {
  ...
  @override
  Widget build(BuildContext context) {
    return Center(
      child: ElevatedButton(
          onPressed: () => gameLoop.overlays.remove('initial'),
          child: const Text('Start')),
    );
  }
}
```

加えて`overlays.addListener()`で`overlays`プロパティの変更を検知して呼び出されるコールバック関数を登録し、ゲームが開始されるようにしました。

```game.dart
class GameLoop extends FlameGame
    with HasCollidables, HasKeyboardHandlerComponents {
  ...
  void onOverlayChanged() {
    if (overlays.isActive('initial')) {
      pauseEngine();
    } else {
      resumeEngine(); // ゲームを開始
    }
  }
  ...
  @override
  void onMount() {
    paused = true;
    overlays.addListener(onOverlayChanged);
    super.onMount();
  }
  ...
}
```

下記の実装を参考にさせていただきました。

https://github.com/wolfenrain/moonlander/blob/975da465aa217c5c8bbd37c1b8fe9a237ee0f4cc/lib/main.dart#L92

### コンポーネントからゲームの状態を取得する方法

ゲームループにコンポーネントを追加するというFlameの性質上、基本的な依存の向きは「ゲームループ → コンポーネント」になるかと思います。
ただし、時にコンポーネントからゲームループのプロパティやメソッドを参照したくなる場合があります。
例えば、ゲームのキャンバスサイズを取得したいときです。
この場合、コンポーネントに`HasGameRef`を適用します。

```ball.dart
class Ball extends CircleComponent with Collidable, HasGameRef<GameLoop> {
  ...
}
```

`HasGameRef`を追加すれば、`gameRef.canvasSize.x`といったように、`gameref`プロパティからゲームループのメソッドや要素にアクセスできるようになります。
多用すると処理が追いづらくなりそうですが、利用価値は高そうです。

### 衝突判定の方法

前述の通り、コンポーネントに`Collidable`を、ゲームループに`HasCollidables`を適用することで衝突を判定できるようになります。
画面との衝突を検知する場合、`ScreenCollidable`をゲームループに追加してあげる必要があります。

```game.dart
@override
Future<void>? onLoad() async {
  ...
  add(ScreenCollidable()); // 画面との衝突を検知
  ...
}
```

`onCollision`の第二引数である`other`に衝突したコンポーネントの型が入っているので、`other is Foo`で`Foo`型との衝突かどうかを判定できます。

```ball.dart
void onCollision(Set<Vector2> intersectionPoints, Collidable other) {
  if (other is ScreenCollidable && x + dx + radius / 2 < 0) {
  ...
  } else if (other is BreakableBlock) {
  ...
  } else if (other is Paddle) {
  ...
  }
}
```

### 状態管理の方法

スコア（ブロックを何個壊したか）やゲーム終了（ボールが画面の下に到達）等の状態をどこで管理するか悩みました。
現状はゲームループに自作の`GameLoopState`のインスタンスを持たせて、各コンポーネントから`gameref`プロパティ経由で参照するようにしています。

```game.dart
class GameLoop extends FlameGame
    with HasCollidables, HasKeyboardHandlerComponents {
  ...
  final GameLoopState gameLoopState;
  ...
}

// ゲームの状態を管理
class GameLoopState {
  int score;
  bool isGameOver;

  GameLoopState(this.score, this.isGameOver);

  void reset() {
    score = 0;
    isGameOver = false;
  }

  void incrementScore() {
    score++;
  }

  void gameOver() {
    isGameOver = true;
  }
}

```

**Flameのコンポーネントはウィジェットではない**ので、Flutterの様々な状態管理パッケージは使えません（[flame_bloc](https://github.com/flame-engine/flame/tree/main/packages/flame_bloc)等はあるっぽい）。
ブロック崩し程度ならちゃんと状態管理を設計しなくても大丈夫ですが、ある程度スケールするゲームの場合はちゃんと考えなければダメそうです（知見求む🙇‍♂️）。

### ユーザ入力の方法

ボールを跳ね返す棒（Paddle）はキーボードの矢印キーで操作します。
モバイルを想定すると[ジョイスティック](https://docs.flame-engine.org/1.0.0/other-inputs.html)等で動かせるべきですが怠慢してます...。
衝突検知を行いたいときと考え方は同じで、ゲームループとコンポーネントそれぞれに`HasKeyboardHandlerComponents` `KeyboardHandler`を適用します。

```game.dart
class GameLoop extends FlameGame
    with HasCollidables, HasKeyboardHandlerComponents {
  ...
}
```

```paddle.dart
class Paddle extends RectangleComponent
    with Collidable, KeyboardHandler, HasGameRef<GameLoop> {
  ...
  @override
  bool onKeyEvent(RawKeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
  ...
  }
}
```

### 追加済みのコンポーネントをゲームループ内で参照する方法

ゲームループに追加したコンポーネントは`children`プロパティに入っています。
`children.query<Foo>()`を用いることでゲームループに追加された`Foo`型のコンポーネントを取得することができます。
一点注意としては、`query`を発行したい型は事前に`children.register<Foo>()`によって登録しておく必要があります。

```game.dart
class GameLoop extends FlameGame
    with HasCollidables, HasKeyboardHandlerComponents {
  ...
  @override
  void update(double dt) {
    ...
    var allBlocks = children.query<BreakableBlock>();
    var ball = children.query<Ball>();
    ...
  }

  ...

  @override
  Future<void>? onLoad() async {
    ...
    children.register<BreakableBlock>();
    children.register<Ball>();
  }
}
```

## まとめ
FlutterとFlameを使って、ブロック崩しゲームを作りました。
特に引っ掛かる部分はなく、簡単な2Dゲームなら素早く作れる気がしました。
ただし、やはりゲームなので手続き的に書く部分がかなり多くFlutterとは少し違う筋肉を使う感じがしました。
とはいえ、何よりWebでもモバイルでもどちらでも動くゲームがササっと作れるのはサイコーですね！
次はもう少し複雑なゲームを作成してみたいです。

## 参考
- [2D breakout game using pure JavaScript](https://developer.mozilla.org/ja/docs/Games/Tutorials/2D_Breakout_game_pure_JavaScript)


[^1]: 正確には`FlameGame`を使わなくてもゲームループは作れます
[^2]: [こちら](https://github.com/kawamou/breakout-game/blob/main/lib/game.dart)を参考にしました