実践Flutter

Flutterでスマホを中心にアプリ制作していきます。

Flame01:Flame公式ドキュメントを読み下していく1

はじめに

Flutter2.8が発表されていまして、同じタイミングでゲームエンジンのFlameがバージョン1を迎えたということで注目を集めていますね。

Flutter民はあまりゲームプログラミングに興味がある人が多くないかもしれませんが、自分は大好きですので、せっかくFlameがいま囃されていることもあり、これを深堀りしていこうかと思います。

そこでまずは手始めに、公式のサイト・ドキュメントを頭から読み下していきたいと思います。

引用符でくくっているのは全て公式の文書からのものですが、サラッと見て意訳している部分が多いです。それでも何かの参考になれば幸いです。


https://docs.flame-engine.org/

まずは数回に分けて公式の最初に書いてあるドキュメントを読み下したのち、サンプルコード・チュートリアルのコードを見ていきたいと思います。





はじめよう!

FlameはFlutterのゲームエンジンモジュールです。Flutterのパワフルな基盤を利用し、プロジェクトに必要なコードをシンプルにします。

Flameはシンプルで高効率なゲームループと、ゲームにおいて必要な機能を提供します。例えばユーザの入力、画像、スプライトシート、アニメーション、衝突判定そしてFCS(Flame Component System)と呼んでいるコンポーネントの仕組みです。

Flameのエンジンとエコシステムは継続的にコミュニティによって改善されています。なので遠慮なく課題の提起や提案をして下さい。そしてコミュニティを育てるためにスターを下さい!

https://docs.flame-engine.org/1.0.0/

現状、Flutterで動くゲームエンジンのなかでは一番完成度が高いのがFlameではないでしょうか。Flutterらしく非常にシンプルでスマートなコードでカジュアルにゲームが作れる環境です。あまり深くゴリゴリハードをいじっていくというよりは、スマートにFlutter上でできるシンプルなコードでゲームを構築するのに向いています。そうすることによってFlutterのマルチプラットフォーム対応の利点が活きてきます。その点が一番の強みと言えそうです。


インストール

導入はFlutterの他のパッケージ同様、pubspec.yamlに下記を追記するか(バージョン番号は現状のものですので最新版を確認して追記がおすすめ)、あるいは「flutter pub add flame」コマンドを実行し、「flutter pub get」

dependencies:
  flame: 1.0.0

あとは必要なライブラリをインポートしてコードを書いていくだけです。


守備範囲外の部分について

ゲームはときに複雑な機能に依存します。そのなかでFlameエンジンの守備範囲の外側にあるものとして、以下のものがあります:

マルチプレイヤー
ネットワークはマルチプレイヤーのオンラインゲームの実装に必要ですが、Flameはネットワーク機能を有していません。マルチプレイヤーゲームを実装するには以下のパッケージ・サービスがおすすめです。

  • Nakama: Nakamaはモダンでパワフルなゲームのためのオープンソースのサーバーです。
  • Firebase: シンプルなマルチプレイヤー体験を実装するための様々な機能を有しています。

外部リソース
Flame自体は外部のデータソースにアクセスするヘルパーを用意していません。ただ例えばFlameのSpriteはdart:uiのImageクラスのインスタンスから生成できるため、どこからでもデータをロードして持ってこられます。httpクライアントとしてはhttpDioを用いるとよいでしょう。

https://docs.flame-engine.org/1.0.0/

Flame自体にはネットワークアクセスの機能を含んでいないのですが、Flutterには様々なネットワーク越しにデータを取得する手段がありますので、それを活用すれば特に問題ないですね。

Imageの件は下記記事でも扱った、Image.networkの名前付きコンストラクタでネットワーク越しに画像を読み込んだ後、Spriteのコンストラクタにこれを与えればネットワーク越しの画像をFlameのデータとして問題なく活用できるということです。


flutter.gakumon.jp


ファイルの構造

Flameは標準なFlutterの「assets」ディレクトリと、その子ディレクトリとして「audio」と「images」のディレクトリを使います。

例えば下記のようなファイル構造があったとき:

.
└── assets
    ├── audio
    │   └── explosion.mp3
    └── images
        ├── enemy.png
        └── player.png

下のコードのようにファイルを読み込むことができます:

  FlameAudio.play('explosion.mp3');

  Flame.images.load('player.png');
  Flame.images.load('enemy.png');

あるいは「audio」フォルダを「music」と「sfx」の2つのフォルダに分けて使うこともできます。

下記のようにpubspec.yamlに記載することを忘れないでください:

flutter:
  assets:
    - assets/audio/explosion.mp3
    - assets/images/player.png
    - assets/images/enemy.png


https://docs.flame-engine.org/1.0.0/

最後のpubspec.yamlの記載は、下記記事でも述べていたものですね。これを宣言しておくことでFlutterのコード上でローカルのデータを読み込むことができるようになります。


flutter.gakumon.jp


FlameGame

FlameGameは最も基本的で最もよく使われるFlameのなかのGameクラスです。

FlameGameはコンポーネント(Component)に基づくGameを実装しています。Componentのリストを持っていて、updateとrender関数にすべてのComponentを渡す形でゲームにこれらを参加させていきます。

スマホの向きが変わるようなときにはゲーム画面をリサイズする必要がありますが、FlameはすべてのComponentのresizeメソッドを呼び出し、またその情報をカメラとビューポートに知らせます。

FlameGame.cameraはどのポイントがスクリーンの左上に来るべきかを常に追っています(つまりCanvasで言えば座標[0, 0]の位置ですね)。

FlameGameの実装例は例えば以下です:

class MyCrate extends SpriteComponent {
  // 16x16ドットサイズの画像crate.pngを持つスプライトコンポーネント
  MyCrate() : super(size: Vector2.all(16));

  Future<void> onLoad() async {
    sprite = await Sprite.load('crate.png');
    anchor = Anchor.center;
  }

  @override
  void onGameResize(Vector2 gameSize) {
    super.onGameResize(gameSize);
    // コンストラクタの時点でポジションをセットする必要はありません。
    //ここでダイレクトに値をセットできます。なぜなら最初にレンダリングされる
    //前にかならずここが呼ばれるからです。
    position = gameSize / 2;
  }
}

class MyGame extends FlameGame {
  @override
  Future<void> onLoad() async {
    await super.onLoad();
    add(MyCrate());
  }
}

main() {
  final myGame = MyGame();
  runApp(
    GameWidget(
      game: myGame,
    ),
  );
}

https://docs.flame-engine.org/1.0.0/game.html

最後のコードは画面の真ん中にスプライトを表示する非常にシンプルな実装を示しています。

import文が省略されていますので、下記を追記してビルドしてみます:

import 'package:flutter/material.dart';
import 'package:flame/components.dart';
import 'package:flame/game.dart';

画像としてはいつも使っているアイコンを使ってみました。また16ドットでは少々小さすぎるのでここでは64ドットに変更してスクリーンショットを撮っています:


f:id:linkedsort:20211209233227p:plain:w350

画面をリサイズしても、常に真ん中に表示され続けています。あえて元画像よりも荒い解像度で表示していますので、画質はここでは気にしないで下さい。

また注意点として、ウィジェットのbuildの中でFlameGameのインスタンスを作ってしまうと、ウィジェットがリビルドするたびに(再描画するたびに)インスタンスを作り直してしまう、ということが書かれています。

このため上記のmain()の中で、先にMyGame()を実行して(MyGameがFlameGameを継承している)、その参照をrunAppの中で使っています。つまり逆にNGなのが下記のような書き方ということでしょうかね。

main() {
  runApp(
    GameWidget(
      game: MyGame(),
    }
  }
}

FlameGame上のComponentを削除する場合は「remove」あるいは「removeAll」メソッドを使います。「remove」は1つのComponent,、「removeAll」は全てのComponentを削除します。

またComponentのremoveメソッドを呼ぶことで、そのComponentヲ削除できます。「yourComponent.remove();」とすればOKです。

https://docs.flame-engine.org/1.0.0/game.html

スプライトなどのComponentをFlameGame上に出現させるならばadd、削除するならばremoveというわけですね。非常に単純です。


ライフサイクル


f:id:linkedsort:20211209235507p:plain

GameがFlutterのWidgetツリーに入るとライフサイクルメソッドが順に呼び出されます:onGameResize、onLoad、onMount。

その後、updateとrenderがティック毎に繰り返し呼び出されます。これがWidgetツリーから排除されるまで続きます。

GameWidgetが排除されるときonRemoveが呼び出されます。これはComponentがコンポーネントのツリーから排除されるときと同様です。

https://docs.flame-engine.org/1.0.0/game.html

リソースの初期化とお片付けのタイミングについてですね。これも定番かなと思います。上の方でも触れられていましたが最初はリサイズから入るので、一番最初のサイズ調整もリサイズと同じ流れで処理を書けばOKです。

Componentの優先順位変更

Componentの優先順位を返るにはFlameGame.changePriority、あるいは一度にたくさんの優先順位を変更したい場合はFlameGame.changePrioritiesを使います。

Componentリストのリバランスは計算量が高いため、複数のComponentの優先順位を一つ一つ変えていくのはおすすめできず、やるなら一気にやったほうがいいです。

優先順位の高いComponentになればなるほど遅いタイミングでレンダリングされるので、その分上書きされて上に表示されます。

https://docs.flame-engine.org/1.0.0/game.html

スプライトなどのコンポーネントを使う場合は表示の優先順位に常に気を配る必要があります。主人公のキャラクタが常に隠れないように表示するだとか、背景は後ろ、前景は前に表示させるだとかですね。

そのコンポーネントのリストは、リストと呼んでいますがリバランスの話が出ていますので、内部的にはツリーでも管理されていて、順序の入れ替えを行うとツリーのリバランスが生じる可能性があるということです。

もしリバランスに興味があれば以下の記事あたり参照。ですがスキップでOKです:


http://www.akita-pu.ac.jp/system/elect/ins/kusakari/japanese/teaching/SoftTech/2008/note/7p.pdf

Componentの優先順位を変えるときは、なるべくまとめて変える、と覚えておきましょう。


デバッグモードに関する注意

FlameGameはdebugModeという変数を持っています(デフォルトfalse)。

この値をtrueにするとデバッグ機能を活用することができるのですが、注意点があります。

この値はComponentがGameに加えられたタイミングでComponentに渡されます。なので実行時にdebugModeの値を変えた場合、すでに加えられたComponentには変更が行き渡りません。

https://docs.flame-engine.org/1.0.0/game.html

デバッグモードについては後に扱うとして、今は一旦この注意点だけを心にとめておいて次に行きます。


低レベルGame API


f:id:linkedsort:20211210115514p:plain

Game mixinはゲームエンジンの機能を構築するための低レベルAPIです。例えばGame自体はupdateやrenderを実装しません。

上の図で分かる通りゲームのクラスを作るにはLoadableやGame mixinを使う必要があります。OxygenGameはそのようにしています。

Loadable mixinはonLoad、onMount、onRemoveのライフサイクルを持っています。

onLoadは最初にクラスが親に加えられたときの一度のみ呼ばれます。onMountは新しい親に加えられたときは毎回呼ばれます。onRemoveは親から排除されたときに呼ばれます。

例えば一つのゲームクラスの実装例は以下の通りです:

class MyGameSubClass with Loadable, Game {
  @override
  void render(Canvas canvas) {
    // ...
  }

  @override
  void update(double t) {
    // ...
  }
}

main() {
  final myGame = MyGameSubClass();
  runApp(
    GameWidget(
      game: myGame,
    )
  );
}

https://docs.flame-engine.org/1.0.0/game.html

クラス・ミックスインの関係性はかなり複雑ですね。後々これらの役割を紐解いていかないといけませんが、文章で書かれているメッセージはとりあえず「GameとLoadableをwithしておけ」ということですね。

これも心に留めておいて、後にサンプルコードを見ながら理解を深めていきましょう。

ゲームループ

GameLoopモジュールはゲームループの概念のシンプルな抽象です。基本的にほとんどのゲームは以下の2つのメソッドから構成されています:

  • renderメソッド:現在の状態に応じてキャンバスに描画
  • updateメソッド:時間の差分を受け取って、次の状態を作る

GameLoopはFlameの中の全てのGameの実装に使われています。

https://docs.flame-engine.org/1.0.0/game.html

抽象です、なんてコンピュータサイエンス的言い回しですが、ゲームの作りの中で最も重要な共通点がrenderとupdateのメソッドだということです。今の状態を画面に書くこと、そして状態を次の状態に移すということですね。

updateが前の時間からの差分をもらって次を計算しているところが少し注意です。端末によってフレームレートが異なり、時間が飛び飛びだったりスムーズだったりします。なので時間間隔が一定というわけではないというところに注意が必要ですね。これをもとに次の時間の状態を計算していくというわけです。

一時停止と再開

Gameは2つの方法で一時停止と再開をすることができます:

  • pauseEngineとresumeEngineメソッドを使う
  • pause属性値を変更する

Gameが一時停止されているとき、GameLoopは一時的に停止され再開までrenderやupdateは行われません。

https://docs.flame-engine.org/1.0.0/game.html

書いてあるとおりですね。期待された挙動ということで、特に付け加えることはなさそうです。

ファミコンの時代からポーズボタンはしっかり実装されていました。アクション性のあるゲームでは特に必須ですよね。

FlutterのウィジェットとGameのインスタンス

FlameのゲームはWidgetにラップさせられるので、他のFlutterウィジェットと同居させて使うのは非常に簡単です。ただWidgets Overlay APIを使うと更に簡単になります。

Game.overlayesはFlutterのウィジェットをゲームインスタンスの一番上に表示させられます。これでポーズ時のメニューや道具を選ぶ画面などを簡単に作ることができます。

これらはgame.overlays.addとgame.overlays.removeメソッドを通じて、その表示と隠蔽を制御できます。それぞれは文字列を使ってウィジェットを特定します。例えば以下のコードの通りです:

ゲームの中で、以下のような呼び出しをします:

//文字列で名前を指定
final pauseOverlayIdentifier = 'PauseMenu';

//名前を指定してオーバーレイするウィジェットを追加
overlays.add(pauseOverlayIdentifier);

//...

//名前を指定してオーバーレイするウィジェットを削除
overlays.remove(pauseOverlayIdentifier); 

一方でウィジェット宣言の中で以下のように指定します:

//Widgetの宣言の中において
final game = MyGame();

Widget build(BuildContext context) {
  return GameWidget(
    game: game,
    overlayBuilderMap: {
      'PauseMenu': (ctx) {
        return Text('A pause menu');
      },
    },
  );
}

https://docs.flame-engine.org/1.0.0/game.html

予め名前を振っておいて、ウィジェットを用意しておいて、overlays.addするとそれが画面のトップに表示され、removeすると隠蔽されるというわけです。

雰囲気はわかりましたが、実際の実装はこれももう少し例がないと難しいですね。公式ドキュメントが例へのリンクを提示してくれていますが、今の瞬間404ですので、また別途サンプルを探してみます。







おわりに

Flameバージョン1の公式ドキュメント読み下しの第1回ということで、大きな登場人物の顔見せシリーズのような感じですね。

まだそれぞれがどういう活躍を見せてくるのかは見えてきませんが、だいぶシンプルで伝統的な作りをしている感じはこの時点で伝わってきます。2Dゲームと言うともう作り方の定跡の根っこの部分は定まっていますからね。


f:id:linkedsort:20211210125951j:plain