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


Ivy Walobwaさんが、Flutter Flameを使ったゲームの作り方をご紹介いたします。

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

本チュートリアルでは、Flutter Flameゲームエンジンの入門編をご提供いたします。Flutter Flameのゲームの設定と構築、スプライトの読み込み、アニメーションの追加をご紹介いたします。

本チュートリアルは、DartとFlutterの知識をお持ちであることを前提にしております。

Flameエンジン

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

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

Flutter Flameの設定

Flameを使い始めるには、パッケージのインストールが必要です。pubspec.yaml ファイルに、以下のように依存関係を追加します:

dependencies:
  flame: ^1.1.1

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

void important() {
  last recreation = FlameGame();
  runApp(
    GameWidget(
      recreation: recreation,
    ),
  );
}

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

スプライトの読み込み

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

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

  • dino_player.dart はプレイヤーの読み込みと配置を行います:
    import 'bundle:flame/elements.dart';
    
    class DinoPlayer extends SpriteComponent with HasGameRef {
      DinoPlayer() : tremendous(measurement: Vector2.all(100.0));
    
      @override
      Future<void> onLoad() async {
        tremendous.onLoad();
        sprite = await gameRef.loadSprite('idle.png');
        place = gameRef.measurement / 2;
      }
    }

  • dino_world.dartは、ゲームの背景を読み込みます:
    import 'bundle:flame/elements.dart';
    
    class DinoWorld extends SpriteComponent with HasGameRef {
      @override
      Future<void> onLoad() async {
        tremendous.onLoad();
        sprite = await gameRef.loadSprite('background.png');
        measurement = sprite!.originalSize;
      }
    }
  • dino_game.dartは、すべてのゲームコンポーネントを管理します。ゲームプレイヤーと背景を追加し、それらを配置します:
    import 'dart:ui';
    
    import 'bundle:flame/recreation.dart';
    import 'dino_player.dart';
    import 'dino_world.dart';
    
    class DinoGame extends FlameGame{
      DinoPlayer _dinoPlayer = DinoPlayer();
    DinoWorld _dinoWorld = DinoWorld();
      @override
      Future<void> onLoad() async {
        tremendous.onLoad();
        await add(_dinoWorld);
        await add(_dinoPlayer);
        _dinoPlayer.place = _dinoWorld.measurement / 1.5;
        digital camera.followComponent(_dinoPlayer,
            worldBounds: Rect.fromLTRB(0, 0, _dinoWorld.measurement.x, _dinoWorld.measurement.y));
      }
    }

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

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

import 'bundle:flame/recreation.dart';
import 'bundle:flutter/materials.dart';
import 'dino_game.dart';

void important() {
  last recreation = DinoGame();
  runApp(
    GameWidget(recreation: recreation),
  );
}

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

Dino game with background

スプライトの動き

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

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

  • instructions.dartには、方向列挙型が含まれています:
    enum Course { up, down, left, proper, none }
    
  • navigation_keys.dartには、ナビゲーションキーのUIとロジックが含まれています:
    import 'bundle:flutter/gestures.dart';
    import 'bundle:flutter/materials.dart';
    import 'instructions.dart';
    
    class NavigationKeys extends StatefulWidget {
      last ValueChanged<Course>? onDirectionChanged;
    
      const NavigationKeys({Key? key, required this.onDirectionChanged})
          : tremendous(key: key);
    
      @override
      State<NavigationKeys> createState() => _NavigationKeysState();
    }
    
    class _NavigationKeysState extends State<NavigationKeys> {
      Course path = Course.none;
    
      @override
      Widget construct(BuildContext context) {
        return SizedBox(
          peak: 200,
          width: 120,
          baby: Column(
            youngsters: [
              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(Course.down);
                },
                onTapUp: (dets) {
                  updateDirection(Course.none);
                },
                onLongPressDown: () {
                  updateDirection(Course.down);
                },
                onLongPressEnd: (dets) {
                  updateDirection(Course.none);
                },
              ),
            ],
          ),
        );
      }
    
      void updateDirection(Course newDirection) {
        path = newDirection;
        widget.onDirectionChanged!(path);
      }
    }
    
    class ArrowKey extends StatelessWidget {
      const ArrowKey({
        Key? key,
        required this.icons,
        required this.onTapDown,
        required this.onTapUp,
        required this.onLongPressDown,
        required this.onLongPressEnd,
      }) : tremendous(key: key);
      last IconData icons;
      last Perform(TapDownDetails) onTapDown;
      last Perform(TapUpDetails) onTapUp;
      last Perform() onLongPressDown;
      last Perform(LongPressEndDetails) onLongPressEnd;
    
      @override
      Widget construct(BuildContext context) {
        return GestureDetector(
          onTapDown: onTapDown,
          onTapUp: onTapUp,
          onLongPress: onLongPressDown,
          onLongPressEnd: onLongPressEnd,
          baby: Container(
            margin: const EdgeInsets.all(8),
            ornament: BoxDecoration(
              colour: const Coloration(0x88ffffff),
              borderRadius: BorderRadius.round(60),
            ),
            baby: Icon(
              icons,
              measurement: 42,
            ),
          ),
        );
      }
    }

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

    void important() {
      last recreation = DinoGame();
      runApp(
        MaterialApp(
          debugShowCheckedModeBanner: false,
          house: Scaffold(
            physique: Stack(
              youngsters: [
                GameWidget(
                  game: game,
                ),
                Align(
                  alignment: Alignment.bottomRight,
                  child: NavigationKeys(onDirectionChanged: game.onArrowKeyChanged,),
                ),
              ],
            ),
          ),
        ),
      );
    }

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

    onArrowKeyChanged(Course path){
      _dinoPlayer.path = path;
    }

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

    Course path = Course.none;
    
    @override
    void replace(double dt) {
      tremendous.replace(dt);
      updatePosition(dt);
    }
    
    updatePosition(double dt) {
      swap (path) {
        case Course.up:
          place.y --;
          break;
        case Course.down:
          place.y ++;
          break;
        case Course.left:
          place.x --;
          break;
        case Course.proper:
          place.x ++;
          break;
        case Course.none:
          break;
      }
    }

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

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

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

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

Sprite sheet

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

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

  1. SpriteComponentの代わりにSpriteAnimationComponentを拡張します。
  2. アニメーションとアニメーションスピードを初期化します。このチュートリアルでは、左右に歩くアニメーションに焦点をあてて説明します。
    late last SpriteAnimation _walkingRightAnimation;
    late last SpriteAnimation _walkingLeftAnimation;
    late last SpriteAnimation _idleAnimation;
    
    last double _animationSpeed = .15;
  1. スプライトシートからスプライトを読み込みます。スプライトは、シート上の位置に応じて読み込まれます。スプライトの読み込みは、各スプライトの幅と列を指定する方法と、行と列の位置から各スプライトを選択する方法とがあります。
    Future<void> _loadAnimations() async {
      last spriteSheet = SpriteSheet.fromColumnsAndRows(
          picture: await gameRef.photographs.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関数は、rowfromtoプロパティで定義された一連のスプライトを選択し、アニメーションを作成します。

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

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

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

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

    updatePosition(double dt) {
      swap (path) {
        case Course.up:
          place.y --;
          break;
        case Course.down:
          place.y ++;
          break;
        case Course.left:
          animation = _walkingLeftAnimation;
          place.x --;
          break;
        case Course.proper:
          animation = _walkingRightAnimation;
          place.x ++;
          break;
        case Course.none:
          animation = _idleAnimation;
          break;
      }
    }

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

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

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

また、flame_audioパッケージを使用して、ゲームに音声を追加することもできます。

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

さて、Flameエンジンを使ってゲームを作成したわけですが、簡単にビルドしてアプリの成果物を共有するにはどうしたらいいでしょうか? 解決策としては、CodemagicのようなCI/CDツールを使って、プロジェクトのビルド、テスト、リリースのすべてを自動的に処理することが挙げられます。

Codemagicでアプリの成果物をビルドして共有するには、まずFlutterアプリをお気に入りのGitプロバイダーにホストしておく必要があります。アプリをリリースするための準備として、以下が必要です:

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

Flutter公式ドキュメントにあるガイドに従って、アプリをリリースするための準備ができます。

その後、CI/CDツールを使用するためにCodemagicのアカウントが必要になります。まだお持ちでない方は、GitプロバイダーでCodemagicにサインアップできます。以下の手順でCodemagicをセットアップしてください:

  1. アプリケーションを作成し、Gitプロバイダーからリポジトリを接続します。
    Connect repository

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

Select project

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

Added application

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

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

Select build format

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

Build app

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

結論

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

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

本チュートリアルで使用したアプリケーションは、GitHubに掲載されております。このチュートリアルをお楽しみいただけたら幸いです!


本記事は、Flutterの一開発者でありテクニカルオーサーのIvy Walobwaさんが執筆いたしました。Ivyさんは、コミュニティに情熱を持ち、技術分野で学生たちの学習を促進することに常に意欲的です。コミュニティを作ったり、コンテンツを作ったりしていないときは、おそらくどこかでハイキングをされているかもしれません。彼女のプロフィールをご覧ください。

Related Articles

LEAVE A REPLY

Please enter your comment!
Please enter your name here

Latest Articles