Flutter Flameで2Dゲームを素早く簡単に作成

Flutterでは、単一のコードベースからAndroid、iOS、デスクトップ、ウェブなどのプラットフォーム向けのアプリケーションを開発することが可能です。マルチプラットフォームのUIツールキットとして、Flutterチームは、あらゆる開発者が迅速にアプリケーションを構築し、リリースできるようにすることに専念しています。例えば、ゲーム開発者は、パフォーマンスやロード時間、アプリサイズを気にすることなく、美しいゲームアプリを作ることができるようになりました。 

本チュートリアルでは、**Flutter Flameゲームエンジンの入門編**をご提供いたします。**Flutter Flameのゲームの設定と構築**、スプライトの読み込み、アニメーションの追加をご紹介いたします。 
  
> 本チュートリアルは、DartとFlutterの知識をお持ちであることを前提にしております。

## Flameエンジン

Flameは、Flutter上で動作する2Dゲーム開発フレームワークです。Flameエンジンでは、ゲームループをはじめ、アニメーション、衝突・跳ね返り検出、視差スクロールなど、必要な機能を簡単に実装できます。 

Flameはモジュール化されており、以下のような機能拡張に利用できる独立したパッケージが提供されています：

- [flame_audio](https://pub.dev/packages/flame_audio): オーディオ機能を提供。
- [flame_forge2d](https://pub.dev/packages/flame_forge2d): 物理機能を提供。
- [flame_tiled](https://pub.dev/packages/flame_tiled): タイル操作機能を提供。

## Flutter Flameの設定

Flameを使い始めるには、[パッケージのインストール](https://pub.dev/packages/flame)が必要です。`pubspec.yaml` ファイルに、以下のように依存関係を追加します：

    dependencies:
      flame: ^1.1.1

ゲームをレンダリングするには、`GameWidget`を使用します。以下のコードスニペットを `main.dart` ファイルに追加すると、現在黒い画面であるFlameゲームがレンダリングされます。 

    void main() {
      final game = FlameGame();
      runApp(
        GameWidget(
          game: game,
        ),
      );
    }

これで、ゲームにグラフィックを追加する準備が整いました。 

## スプライトの読み込み

静的な画像をレンダリングするには、`SpriteComponent`クラスを利用する必要があります。ゲームグラフィックを `assets/images`フォルダに追加し、アセットを読み込むために`pubspec.yaml`ファイルを更新します。本チュートリアルでは、プレイヤー画像と背景画像が読み込まれます。 

以下の3つのファイルを `lib`フォルダに作成・更新します：

- `dino_player.dart` はプレイヤーの読み込みと配置を行います：

```
    import 'package:flame/components.dart';
    
    class DinoPlayer extends SpriteComponent with HasGameRef {
      DinoPlayer() : super(size: Vector2.all(100.0));
    
      @override
      Future<void> onLoad() async {
        super.onLoad();
        sprite = await gameRef.loadSprite('idle.png');
        position = gameRef.size / 2;
      }
    }

```

- `dino_world.dart`は、ゲームの背景を読み込みます：

```
    import 'package:flame/components.dart';
    
    class DinoWorld extends SpriteComponent with HasGameRef {
      @override
      Future<void> onLoad() async {
        super.onLoad();
        sprite = await gameRef.loadSprite('background.png');
        size = sprite!.originalSize;
      }
    }
```

- `dino_game.dart`は、すべてのゲームコンポーネントを管理します。ゲームプレイヤーと背景を追加し、それらを配置します：

```
    import 'dart:ui';
    
    import 'package:flame/game.dart';
    import 'dino_player.dart';
    import 'dino_world.dart';
    
    class DinoGame extends FlameGame{
      DinoPlayer _dinoPlayer = DinoPlayer();
    DinoWorld _dinoWorld = DinoWorld();
      @override
      Future<void> onLoad() async {
        super.onLoad();
        await add(_dinoWorld);
        await add(_dinoPlayer);
        _dinoPlayer.position = _dinoWorld.size / 1.5;
        camera.followComponent(_dinoPlayer,
            worldBounds: Rect.fromLTRB(0, 0, _dinoWorld.size.x, _dinoWorld.size.y));
      }
    }
```

`camera.followComponent`関数は、ゲームビューポートをプレイヤーに追従するように設定します。この関数は、プレイヤーに動きを追加するために必要です。

以下のように `main.dart`ファイルを更新し、`DinoGame`を読み込みます：

    import 'package:flame/game.dart';
    import 'package:flutter/material.dart';
    import 'dino_game.dart';
    
    void main() {
      final game = DinoGame();
      runApp(
        GameWidget(game: game),
      );
    }

アプリケーションを実行すると、プレイヤーと背景が表示されるはずです。

![Sprite sheet](https://blog.codemagic.io/uploads/2022/06/dino_background.jpg)


## スプライトの動き

プレイヤーを動かすには、選択した方向を検知し、それに対応する必要があります。本チュートリアルでは、ゲームの矢印キーを使って、プレイヤーに動きを追加していきます。 

まず、以下のファイルを含む`helpers`フォルダを作成し、以下のように更新してください：

- `directions.dart`には、方向列挙型が含まれています：

```
    enum Direction { up, down, left, right, none }
    
```

- `navigation_keys.dart`には、ナビゲーションキーのUIとロジックが含まれています：

```
    import 'package:flutter/gestures.dart';
    import 'package:flutter/material.dart';
    import 'directions.dart';
    
    class NavigationKeys extends StatefulWidget {
      final ValueChanged<Direction>? onDirectionChanged;
    
      const NavigationKeys({Key? key, required this.onDirectionChanged})
          : super(key: key);
    
      @override
      State<NavigationKeys> createState() => _NavigationKeysState();
    }
    
    class _NavigationKeysState extends State<NavigationKeys> {
      Direction direction = Direction.none;
    
      @override
      Widget build(BuildContext context) {
        return SizedBox(
          height: 200,
          width: 120,
          child: Column(
            children: [
              ArrowKey(
                icons: Icons.keyboard_arrow_up,
                onTapDown: (det) {
                  updateDirection(Direction.up);
                },
                onTapUp: (dets) {
                  updateDirection(Direction.none);
                },
                onLongPressDown: () {
                  updateDirection(Direction.up);
                },
                onLongPressEnd: (dets) {
                  updateDirection(Direction.none);
                },
              ),
              Row(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  ArrowKey(
                    icons: Icons.keyboard_arrow_left,
                    onTapDown: (det) {
                      updateDirection(Direction.left);
                    },
                    onTapUp: (dets) {
                      updateDirection(Direction.none);
                    },
                    onLongPressDown: () {
                      updateDirection(Direction.left);
                    },
                    onLongPressEnd: (dets) {
                      updateDirection(Direction.none);
                    },
                  ),
                  ArrowKey(
                    icons: Icons.keyboard_arrow_right,
                    onTapDown: (det) {
                      updateDirection(Direction.right);
                    },
                    onTapUp: (dets) {
                      updateDirection(Direction.none);
                    },
                    onLongPressDown: () {
                      updateDirection(Direction.right);
                    },
                    onLongPressEnd: (dets) {
                      updateDirection(Direction.none);
                    },
                  ),
                ],
              ),
              ArrowKey(
                icons: Icons.keyboard_arrow_down,
                onTapDown: (det) {
                  updateDirection(Direction.down);
                },
                onTapUp: (dets) {
                  updateDirection(Direction.none);
                },
                onLongPressDown: () {
                  updateDirection(Direction.down);
                },
                onLongPressEnd: (dets) {
                  updateDirection(Direction.none);
                },
              ),
            ],
          ),
        );
      }
    
      void updateDirection(Direction newDirection) {
        direction = newDirection;
        widget.onDirectionChanged!(direction);
      }
    }
    
    class ArrowKey extends StatelessWidget {
      const ArrowKey({
        Key? key,
        required this.icons,
        required this.onTapDown,
        required this.onTapUp,
        required this.onLongPressDown,
        required this.onLongPressEnd,
      }) : super(key: key);
      final IconData icons;
      final Function(TapDownDetails) onTapDown;
      final Function(TapUpDetails) onTapUp;
      final Function() onLongPressDown;
      final Function(LongPressEndDetails) onLongPressEnd;
    
      @override
      Widget build(BuildContext context) {
        return GestureDetector(
          onTapDown: onTapDown,
          onTapUp: onTapUp,
          onLongPress: onLongPressDown,
          onLongPressEnd: onLongPressEnd,
          child: Container(
            margin: const EdgeInsets.all(8),
            decoration: BoxDecoration(
              color: const Color(0x88ffffff),
              borderRadius: BorderRadius.circular(60),
            ),
            child: Icon(
              icons,
              size: 42,
            ),
          ),
        );
      }
    }
```

次に、`main.dart`ファイルを更新して、以下のようにゲームとキーを表示します：

```
    void main() {
      final game = DinoGame();
      runApp(
        MaterialApp(
          debugShowCheckedModeBanner: false,
          home: Scaffold(
            body: Stack(
              children: [
                GameWidget(
                  game: game,
                ),
                Align(
                  alignment: Alignment.bottomRight,
                  child: NavigationKeys(onDirectionChanged: game.onArrowKeyChanged,),
                ),
              ],
            ),
          ),
        ),
      );
    }
```

以下の関数を`dino_game.dart`ファイルに追加して、プレイヤーの動きを実行します：

```
    onArrowKeyChanged(Direction direction){
      _dinoPlayer.direction = direction;
    }
```

最後に、`dino_player.dart`ファイルを更新して、以下のコードスニペットを含めてプレイヤーの位置を更新します： 

```
    Direction direction = Direction.none;
    
    @override
    void update(double dt) {
      super.update(dt);
      updatePosition(dt);
    }
    
    updatePosition(double dt) {
      switch (direction) {
        case Direction.up:
          position.y --;
          break;
        case Direction.down:
          position.y ++;
          break;
        case Direction.left:
          position.x --;
          break;
        case Direction.right:
          position.x ++;
          break;
        case Direction.none:
          break;
      }
    }
```

アプリケーションを起動し、矢印キーのいずれかを押すと、プレイヤーの位置が更新されるはずです。 

https://blog.codemagic.io/uploads/2022/06/Dino_game_with_movement.mp4


## スプライトアニメーション

さて、プレイヤーは期待通りに動きますが、その動きはまだ自然に見えるようなアニメーションではありません。プレイヤーをアニメーションさせるには、スプライトシートを活用する必要があります。 

スプライトシートは、行と列に並べられたスプライトの集合体です。個々のスプライトに比べ、読み込みが早いのが特長です。Flameエンジンは、スプライトシートの一部分のみを読み込んでレンダリングできます。下の画像は、ディノプレイヤーのスプライトシートを表示したものです。 

![Sprite sheet](https://blog.codemagic.io/uploads/2022/06/spritesheet.png)

スプライトシートには、右や左に歩くなどの動作を表現するためにアニメーションさせることができる、さまざまなプレイヤーフレームが含まれています。スプライトシートは`assets/images`フォルダに追加されます。

プレイヤーをアニメーションさせるには、`dino_player.dart`ファイルで以下のようにします：

1. `SpriteComponent`の代わりに`SpriteAnimationComponent`を拡張します。
2. アニメーションとアニメーションスピードを初期化します。このチュートリアルでは、左右に歩くアニメーションに焦点をあてて説明します。 

```
    late final SpriteAnimation _walkingRightAnimation;
    late final SpriteAnimation _walkingLeftAnimation;
    late final SpriteAnimation _idleAnimation;
    
    final double _animationSpeed = .15;
```

3. スプライトシートからスプライトを読み込みます。スプライトは、シート上の位置に応じて読み込まれます。スプライトの読み込みは、各スプライトの幅と列を指定する方法と、行と列の位置から各スプライトを選択する方法とがあります。 

```
    Future<void> _loadAnimations() async {
      final spriteSheet = SpriteSheet.fromColumnsAndRows(
          image: await gameRef.images.load('spritesheet.png'),
          columns: 30,
          rows: 1);
    
      _idleAnimation = spriteSheet.createAnimation(
          row: 0, stepTime: _animationSpeed, from: 0, to: 9);
    
      _walkingRightAnimation = spriteSheet.createAnimation(
          row: 0, stepTime: _animationSpeed, from: 10, to: 19);
    
      _walkingLeftAnimation = spriteSheet.createAnimation(row: 0, stepTime: _animationSpeed, from: 20, to: 29);
    }
```

`spriteSheet.createAnimation`関数は、`row`、`from`、`to`プロパティで定義された一連のスプライトを選択し、アニメーションを作成します。 

4. プレイヤーを更新して選択されたアニメーションを読み込みます。 

まず、`onLoad`関数をオーバーライドして、`_idleAnimation`を読み込みます。

```
    @override
    Future<void> onLoad() async {
      super.onLoad();
      await _loadAnimations().then((_) => {animation = _idleAnimation});
    }
```

次に `updatePosition`関数を更新して、プレイヤーの向いている方向によって異なるアニメーションを読み込むようにします。このチュートリアルでは、アイドル状態、右移動、左移動のスプライトを用意しています。 

```
    updatePosition(double dt) {
      switch (direction) {
        case Direction.up:
          position.y --;
          break;
        case Direction.down:
          position.y ++;
          break;
        case Direction.left:
          animation = _walkingLeftAnimation;
          position.x --;
          break;
        case Direction.right:
          animation = _walkingRightAnimation;
          position.x ++;
          break;
        case Direction.none:
          animation = _idleAnimation;
          break;
      }
    }
```

アプリを起動して左右に動かすと、プレイヤーの動きが更新され、よりリアルに見えるようになりました。


https://blog.codemagic.io/uploads/2022/06/Dino_game_with_animation.mp4

おめでとうございます！　Flameを使って初めて簡単なゲームを作りました！　

[flame_tiled](https://pub.dev/packages/flame_tiled)パッケージを使用すると、衝突レイヤーを追加したカスタムマップやタイルをアプリに読み込んで、ゲームを向上させることができます。マップやタイルをデザインするには、[Tiled](https://www.mapeditor.org/)を使って作成する方法を知っておく必要があります。 

また、[flame_audio](https://pub.dev/packages/flame_audio)パッケージを使用して、ゲームに音声を追加することもできます。

## Codemagicでアプリの成果物を構築して共有する

さて、Flameエンジンを使ってゲームを作成したわけですが、簡単にビルドしてアプリの成果物を共有するにはどうしたらいいでしょうか？　解決策としては、[Codemagic](https://flutterci.com/?utm_source=qiita&utm_medium=referral&utm_campaign=flutter_distribution)のようなCI/CDツールを使って、プロジェクトのビルド、テスト、リリースのすべてを自動的に処理することが挙げられます。 
 
Codemagicでアプリの成果物をビルドして共有するには、まずFlutterアプリをお気に入りのGitプロバイダーにホストしておく必要があります。アプリをリリースするための準備として、以下が必要です：

- アプリランチャーアイコンを設定する
- アプリ名を設定する
- 一意なアプリIDを割り当てる

[Flutter公式ドキュメント](https://docs.flutter.dev/deployment/android/?utm_source=qiita&utm_medium=referral&utm_campaign=flutter_distribution)にあるガイドに従って、アプリをリリースするための準備ができます。 

その後、CI/CDツールを使用するためにCodemagicのアカウントが必要になります。まだお持ちでない方は、Gitプロバイダーで[Codemagicにサインアップ](https://codemagic.io/signup/?utm_source=qiita&utm_medium=referral&utm_campaign=flutter_distribution)できます。以下の手順でCodemagicをセットアップしてください：

1. アプリケーションを作成し、Gitプロバイダーからリポジトリを接続します。
![Connect repository](https://blog.codemagic.io/uploads/2022/06/connect_repo.png)

2. プロジェクトのリポジトリとタイプを選択します。この場合、プロジェクトタイプは「Flutter App (via WorkFlow Editor)」となります。

![Select project](https://blog.codemagic.io/uploads/2022/06/select_project.png)

アプリの準備ができましたので、アプリの構築方法を決定するための設定をいくつか追加します。

![Added application](https://blog.codemagic.io/uploads/2022/06/added_app.png)

アプリをビルドするには、ビルド設定をアプリに合わせてカスタマイズする必要があります：

1. 初めてのアプリケーションの場合は、「ビルド設定の完了」をクリックします。既存のアプリの場合は、設定アイコンをクリックします。
2. ワークフローエディタページで、ビルドプラットフォームとして「Android」を選択します。
![Select build platform](https://blog.codemagic.io/uploads/2022/06/build_platform.png)
3. 「ビルドトリガー」セクションを展開し、希望のビルドトリガーを選択します。監視対象のブランチとタグを設定することもできます。これらの設定は、発生するたびにアプリのビルドをトリガーします。
![Select build trigger](https://blog.codemagic.io/uploads/2022/06/build_trigger.png)
4. 「ビルド」セクションを展開し、アプリのビルド形式とモードを選択します。 

![Select build format](https://blog.codemagic.io/uploads/2022/06/build_format.png)

5. 変更を保存して、新しいビルドを開始します。Codemagicは、アプリが正常にビルドされると、アプリ名の横に緑色のチェックを追加します。また、ダウンロード可能なAndroidの成果物も追加されます。

![Build app](https://blog.codemagic.io/uploads/2022/06/build_app.png)

おめでとうございます！　Codemagicで最初のビルドを行い、アプリの成果物をダウンロードして共有できるようになりました！

## 結論 

Flameは、Flutterをベースに開発された軽量なゲームエンジンで、開発者は2Dゲームを迅速に作成できます。

本チュートリアルでは、Flameのインストールとセットアップの方法をご紹介しました。また、Flutter Flameのゲームサンプルに取り組むことで、スプライトの読み込みとスプライトの動きやアニメーションを追加する方法もご説明しました。ゲームを充実させるために使える、さまざまな独立したパッケージを取り上げました。最後に、Codemagicでアプリの成果物をビルドして共有する方法をご紹介いたしました。 

本チュートリアルで使用したアプリケーションは、[GitHub](https://github.com/Ivy-Walobwa/flutter_flame_game_example)に掲載されております。このチュートリアルをお楽しみいただけたら幸いです！