5
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

2023年 9月時点での Flutter Flame についてとサンプルゲーム作ってみた

Posted at

あらまし

夏休み前から Flutter を触り始め、 先日 Flutter でドット絵を描くアプリをリリースした(宣伝)ので、これを使って何かゲームを作ることを考えていたところ、なんと Flame という Flutter 製のゲームエンジンがあるらしいとのこと。

Flutter 自体はパッと使った感じ UI 実装が非常に簡単でクロスコンパイルも余裕なので、UI を Flutter で実装しつつ、ゲームコアロジックを Flame で実装するというフローは UI メインのゲームであれば選択肢に入るのでは?と思いどれくらいのことができるのか調査することにした。
メモに近い形ではあるものの、そもそも Flame については日本語の情報も多くないのである程度価値があると判断し執筆することにした。

Flame について

https://github.com/flame-engine/flame
2023/9/2 時点で v1.8.2 が最新バージョン。メジャーバージョンは1のため、巷の記事はベータ以前のものでない限り概ね参考になるのではないかと思うが、0.x.x 時点での記事については破壊的変更が多く入ってそうで注意が必要そうな印象。

よくあるハンズオン

流石にゼロから仕様を読むのはしんどかったため、ざっくりハンズオンで雰囲気を掴むことを優先した。 Flame と検索して以下 2 つのハンズオンが目にとまった。 とりあえず把握したいというモチベーションを持っていたので最小構成のハンズオンである後者の記事の方があっていたが、とりあえず遊べるところまで作ってみたい、というモチベーションがある場合、前者の方が良さそうな印象。

Flutter と Flame でゲームを作成する  |  Google Codelabs
https://codelabs.developers.google.com/codelabs/flutter-flame-game?hl=ja#3

Flutter Flameで2Dゲームを素早く簡単に作成 | Codemagic Blog
https://blog.codemagic.io/flutter-flame-game-development-japanese/

仕様調査

上記ハンズオンで雰囲気は掴めたものの、他に何ができるのかはやはり公式の仕様を読まないといけないため、以下を確認。ざっくり重要そうなところだけ以下に記述する。
https://docs.flame-engine.org/latest/flame/flame.html

読み込むアセットのフォルダ構造

Flame でアセットを指定した場合、自動でassets/images/もしくはassets/audio/が参照されるとのこと。従って、pubspec.yaml ではこれらのフォルダを一括で読み込みしておくと良さそう。 Flutter での assets の一括読み込みはこちらで紹介されている。

void main() {
  FlameAudio.play('explosion.mp3');
  Flame.images.load('player.png');
}
.
└── assets
    ├── audio
    │   └── explosion.mp3
    └── images
        └── player.png
flutter:
  assets:
    - assets/audio/
    - assets/images/

GameWidget

Flame で管理されるゲームのベースとなる Flutter Widget でゲーム画面の表示も司っている。
これ自体は Flutter Widget なので、トップレベルにおかなくても良い。つまり、以下のように Scaffold の body として配置することもできる。最低限、 FlameGame を継承したクラスのインスタンスが引数に必要

  final game = GameManager(); // FlameGame を継承したクラス
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(widget.title),
      ),
      body: Center(
        child: GameWidget(
          game: game,
        ),
      ),
    );

以下の文面がいまいち理解ができなかったため、ChatGPT に尋ねたところ「クリッピング = 表示領域を切り取る」という意味とのこと。なるほど、つまり GameWidget の child で GameWidget の領域より外にオブジェクトが飛び出ることがあるということで、これはまあ自然である。

GameWidget does not clip the content of its canvas, which means the game can potentially draw outside of its boundaries

FlameGame クラス

Child にいる全コンポーネントにupdaterenderを提供する。つまり、FlameGame クラスで tick loop を行うことで、xx fps ごとに特定オブジェクトを更新、ということが可能となる。update は毎 tick (フレーム) ごとに呼び出しされるらしい。(1 tick の単位はどこかで定義できるのだろうか。)

末端 のobject でも updateは実行できるので、基本的には各オブジェクトごとにupdateをオーバーライドして実行することになりそう。

Tips

  • Flutter の更新プロセスに巻き込まれると再更新が走るので、基本的にはクラス自体の定義は Flutter の Widget に含めないのが望ましいらしい。
  • オブジェクト生成時 / 削除時のライフサイクルを受け取るコールバックが自動で追加される。(onLoad/ onRemove)

Component クラス

Flame において一番親のクラス。Flame で扱うオブジェクトはだいたいこれを継承して使うようである。

Isometoric があるので、立方体マップも描画できるっぽい。
これは、ターン制のボードゲームの方が作成簡単かもしれない

Effect

コンポーネントに個別に実装するのが面倒な汎用的な処理を Effect として提供している、という印象。実際に使用する際は、事前に Effect を定義した後対象のクラスで add することで適用される。クラスを跨ぐような汎用的な Effect についてはどこかにまとめて定義しておくのが良さそうである。

  • 移動
  • 回転
  • 拡大縮小
  • 透明度、色

毎tickごとにスプライトなどのオブジェクトをx方向に移動させる例


  @override
  void update(double dt) {
    final move = MoveToEffect(Vector2(position.x + spd, position.y), EffectController(duration: 0));
    add(move);
  }

mixin について

Flame においては、メイン機能は Component で提供し、サブの機能は mixin を付与することで使えるっぽい。mixin の一覧が欲しいが、documents には見当たらない。

そのため、リポジトリから該当する場所を確認。現状使える機能を知りたい時は以下を見て名前から推測するのが良さそうな方法に思える。
https://github.com/flame-engine/flame/tree/3084e925008221f8766cd2d22e9ed9e3449a331b/packages/flame/lib/src/components/mixins

wport_margin.dart
coordinate_transform.dart
draggable.dart
gesture_hitboxes.dart
has_ancestor.dart
has_decorator.dart
has_game_ref.dart
has_paint.dart
has_time_scale.dart
has_visibility.dart
hoverable.dart
keyboard_handler.dart
notifier.dart
parent_is_a.dart
single_child_particle.dart
tappable.dart

しかし、コリジョン検出に使うCollidableなど一部のミックスインはこちらでは見つからなかった。機能レベルで階層を分けている可能性もあるが、できればミックスインとして一覧を確認したい。もし該当する資料が見つかればここに追記する予定。

Flameによる 弾幕ゲーム作成時の必要タスクの検討

最後に、これまでの仕様確認に基づき、作成するゲームタイプとFlameによる実装例を検討する。

弾幕系ゲーム

  • 実機: プレイヤーはspriteで表示され、mixinを用いて入力を受け付ける。上下左右の移動とcollisionの設定が必要である。
  • 敵: Spriteを継承したクラスを作成する。updateメソッド内でEffectを用い、tickごとに移動しつつ、弾を生成する。
  • 弾: 弾はSpriteとして表示され、collisionを設定する。updateメソッド内でEffectを用いてtickごとに移動する。プレイヤーと衝突した際には、HPを減少させる。

中間 Summary

文章に起こしてみると、意外と作業量は少ないかもしれない。特にシューティングゲームなど、スクリプトでのインスタンス生成が多いゲームにおいては、Flameが有効な選択肢となる可能性が高い。

一方で、大量のインスタンスを用意する場面ではパフォーマンスが求められる。Flutterベースで十分なパフォーマンスが出るかどうかは、実際に試してみないと判断できない。
また、エディターがないため、全体感を掴みながらの製作や調整が必要な場合、別途エディターを用意するなどの工夫が求められると思われる。

サンプルの作成

最後に、大雑把なパフォーマンスを把握するため、シューティングゲームっぽい demo を作成した。
GameManager クラスが Enemy クラスを生成し、Enemyクラスは update で、定期的に Ballet を生成、Ballet は生成時に指定された方向に進み一定時間後消滅するといった動作となっている。

main.dart
import 'dart:io';
import 'dart:math';

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

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(title: 'Flutter Demo', home: FlameDemo());
  }
}

class FlameDemo extends StatefulWidget {
  @override
  State<FlameDemo> createState() => _FlameDemo();
}

class _FlameDemo extends State<FlameDemo> {
  final game = GameManager();
  int count = Ballet.count;
  int rss = 0;

  @override
  void initState() {
    super.initState();
    async.Timer.periodic(const Duration(seconds: 1), updateMetrics);
  }

  void updateMetrics(async.Timer timer) {
    setState(() {
      count = Ballet.count;
      rss = ProcessInfo.currentRss;
    });
  }

  @override
  Widget build(BuildContext context) {
    final Size screenSize = MediaQuery.of(context).size;

    return Scaffold(
        appBar: AppBar(
            backgroundColor: Colors.blue,
            title: Column(
              children: [
                Text('ProcessInfo.Rss ${rss}'),
                Text('Ballet count ${count}'),
              ],
            )),
        body: Center(
            child: SizedBox(
          height: min(screenSize.height, screenSize.width),
          width: min(screenSize.height, screenSize.width),
          child: GameWidget(
            game: game,
          ),
        )));
  }
}

ここまでは Flutter の枠組みで、これ以降 Flame の component を実装している。

main.dart
class GameManager extends FlameGame {
  final Enemy _enemy = Enemy();

  @override
  Future<void> onLoad() async {
    super.onLoad();
    await add(_enemy);
  }
}

class Enemy extends SpriteComponent with HasGameRef {
  final effect = MoveToEffect(Vector2(200, 200), EffectController(duration: 0));
  double totaldt = 0;
  double lifetime = 0;

  Enemy() : super(size: Vector2.all(64.0));

  @override
  Future<void> onLoad() async {
    super.onLoad();
    sprite = await gameRef.loadSprite('enemy.png');
    await add(effect);
    anchor = Anchor.center;
  }

  @override
  void update(double dt) {
    totaldt += dt;
    lifetime += dt;
    if (totaldt > 0.2) {
      totaldt -= 0.2;
      for (int i = 0; i < 64; i++) {
        gameRef.add(Ballet(position: position, direction: i * 0.156125 * pi));
      }
    }
  }
}

class Ballet extends SpriteComponent with HasGameRef {
  static int count = 0;
  double totaldt = 0;
  double lifetime = 0;
  late double direction;
  double spd = 4.0;

  Ballet({required position, required this.direction})
      : super(size: Vector2.all(16.0)) {
    Ballet.count++;
  }

  @override
  Future<void> onLoad() async {
    super.onLoad();
    anchor = Anchor.center;
    sprite = await gameRef.loadSprite('ballet.png');
    final effect =
        RotateEffect.to(this.direction, EffectController(duration: 0));
    await add(effect);
    final move = MoveToEffect(Vector2(200, 200), EffectController(duration: 0));
    await add(move);
    position = Vector2(200, 200);
    await add(MoveToEffect(forward(), EffectController(duration: 0)));
  }

  @override
  void update(double dt) {
    totaldt += dt;
    lifetime += dt;
    final move = MoveToEffect(forward(), EffectController(duration: 0));
    add(move);
    if (lifetime > 2) {
      removeFromParent();
    }
  }

  Vector2 forward() {
    return Vector2(
        position.x + spd * sin(direction), y + spd * -cos(direction));
  }

  @override
  void onRemove() {
    Ballet.count--;
  }
}

このアプリでは、アプリ内に存在する弾丸の総数を画面に表示している。
弾丸の発射間隔を短くすることで負荷を意図的にあげることが可能。

パフォーマンス確認

実際に画面内を埋め尽くすぐらいの物量を表示したのが以下。もちろんクラス内部の処理が複雑になれば変わってくるだろうが、単純に移動するオブジェクトであれば500程度ぐらいであれば問題なく表示が可能そうに見える。
実際に実機でプレイする場合、デバッグモードではなくリリースモードでビルドしたものを利用するため、もう少し余裕がありそうに見える。
screenshot.png

まとめ

Flame についてのざっくりとした仕様調査と、どれくらいの処理が可能なのかサンプルを作りつつざっくりテストを行った。
期待通りもしくは期待以上の結果ではあったので、今後何か一つ作ってみたいと思う。

5
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?