くらげになりたい。

くらげのようにふわふわ生きたい日曜プログラマなブログ。趣味の備忘録です。

FlutterのFlameに入門する

ずっと気になってたFlutterのゲームエンジンFlame
とりあえず、ドキュメントを読みつつ、
いろいろ整理したときの備忘録(*´ω`*)

Flameとは | Getting Started

Flutter用のゲームエンジン
シンプルで効果的なゲームループとゲームに必要な機能を提供。
入力、スプライト、アニメーション、衝突判定など。
Flame Component System(FCS)と呼ばれるコンポーネントシステムもある。

また、Bridge Packagesと呼ばれる、他のライブラリとの連携パッケージも提供。
AudioPlayers / Lottie / Riverpodなどなど

チュートリアルやサンプルなども用意されている。

Flameにはネットワーク機能はないので、複数人のオンラインゲーム等の場合は、
以下のパッケージの利用がおすすめされている。

  • Nakama: Nakama is an open-source server designed to power modern games and apps.
  • Firebase: Provides dozens of services that can be used to write simpler multiplayer experiences.
  • Supabase: A cheaper alternative to Firebase, based on Postgres.

フォルダ構成 | File Structure

デフォルトのフォルダ構成は、こんな感じを期待。

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

既定の場所に配置されていると、以下の感じでファイルを読み込める。

void main() {
  FlameAudio.play('explosion.mp3');

  Flame.images.load('player.png');
  Flame.images.load('enemy.png');
  
  TiledComponent.load('level.tmx', tileSize);
}

もちろん、pubspec.yamlにも設定は必要。

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

audioは、以下の2つに分けてもOK

  • music ... BGMなどの音楽
  • sfx ... 効果音
.
└── assets
    ├─── music
    │    └── bgm.mp3
    └─── sfx
         └── button.mp3

登場人物

主要な登場人物はこんな感じ

ほかにもRouter/Collision/Effect/Cameraなどいろいろあるけど、
まずはこのあたりが基本。

階層的にはこんな感じ。

Flutter Widget Tree → Game Widget → Gameインスタンス → Worldコンポーネント → 各Component

Game Widget

GameインスタンスをFlutterのWidget treeに描画するためのクラス。
こんな形で利用する感じ。runApp()直下でなくてもOK。

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

GameWidgetStatefulWidgetを継承。

class GameWidget<T extends Game> extends StatefulWidget

Gameの描画だけでなく、以下も提供している。

  • loadingBuilder to display something while the game is loading;
  • errorBuilder which will be shows if the game throws an error;
  • backgroundBuilder to draw some decoration behind the game;
  • overlayBuilderMap to draw one or more widgets on top of the game.

GameWidgetはサイズを超えて描画されるのではみ出ることがあるらしい。
必要に応じて、cameraを調整したり、FlutterのClipRectを使うと良い。

コンストラクタは2つあり、Container配下など、
他のWidgetの中に描画する場合は、GameWidget.controlledを使うといいらしい。

class MyWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Container(
      padding: EdgeInsets.all(20),
      child: GameWidget.controlled(
        gameFactory: MyGame.new,
      ),
    );
  }
}

Gameインスタンス / Game Loop

GameLoopはゲームループの概念をシンプルに抽象化したモジュール。

  • renderメソッドで、現在の状態を描画するためにキャンバスを取得
  • updateメソッドで、前回の更新から経過した時間を受け取って、次の状態に変更

ここでいう状態というのは、
スコアであったり、キャラの位置だったり、などなど

Componentに用意されているupdate()などの関数をオーバーライドでき、
Gameは追加されたComponentupdate()の呼び出しを繰り返す。

update()に「呼ばれるたびに右へ1移動する」
のような処理を書けば、自動で移動するコンポーネントができる。

update()を含めたライフサイクルはこんな感じ。

onLoad()で、スプライトや画像の読み込みをおこなったり、

/// A component that renders the crate sprite, with a 16 x 16 size.
class MyCrate extends SpriteComponent {
  MyCrate() : super(size: Vector2.all(16));

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

onRemove()で、子コンポーネントやキャッシュの削除したりにつかう。

@override
  void onRemove() {
    // Optional based on your game needs.
    removeAll(children);
    processLifecycleEvents();
    Flame.images.clearCache();
    Flame.assets.clearCache();
    // Any other code that you want to run when the game is removed.
  }

SingleGameInstance mixin

Gameインスタンスはいくつも作れるが、基本は単一。
SingleGameInstance mixinを使うと、
特定の状況でパフォーマンスが上がるらしい。

class MyGame extends FlameGame with SingleGameInstance {
  // ...
}

Pause/Resuming/Stepping game execution

一時停止や再開などの機能も提供されている。
方法は2つあり

  1. pauseEngine()resumeEngine()メソッドを使う
  2. paused属性を変更する

一時停止すると、GameLoopが停止して、update()などが呼ばれなくなる。

また、開発向けの機能として、一時停止中にstepEngine()を呼ぶと、
1フレーム進めることができる。

Components

コンポーネントが継承するComponent
Flame Component System(FCS)のベース。

UIも画像もEffectも全部がこのComponentを継承していて、
Componentからライフサイクルのメソッドが呼ばれる感じ。

Componentには親子関係があり、子コンポーネントを追加したりできる。

void main() {
  final component1 = Component(children: [Component(), Component()]);
  final component2 = Component();
  component2.add(Component());
  component2.addAll([Component(), Component()]);
}

Gameインスタンスと同様にライフサイクルがあり、
それぞれを継承して、各コンポーネントを実装していく。

Priority(z-index)

コンポーネントには優先度をつけることができ、
どれを上に描画するかを決めることができる。
priorityの値が大きい順に上に描画される。

class MyGame extends FlameGame {
  @override
  void onLoad() {
    final myComponent = PositionComponent(priority: 5);
    add(myComponent);
  }
}

class MyComponent extends PositionComponent with TapCallbacks {

  MyComponent() : super(priority: 1);

  @override
  void onTapDown(TapDownEvent event) {
    priority = 2;
  }
}

Visibility of components

もっともよいのはaddremoveコンポーネント自体を削除すること。
ただ一時的な非表示の場合は、HasVisibility mixinをつかうっぽい。

/// Example that implements HasVisibility
class MyComponent extends PositionComponent with HasVisibility {}

/// Usage of the isVisible property
final myComponent = MyComponent();
add(myComponent);

myComponent.isVisible = false;

Effect

いろんなエフェクトが用意されていて、
エフェクトを適用したいコンポーネントに追加すればOK

final effect = MoveEffect.by(
  Vector2(30, 30),
  EffectController(duration: 1.0),
);
final component1 = MyComponent();

component1.add(effect);

Collision

衝突判定(Collision Detection)は、
HasCollisionDetection mixinやCollisionCallbacks mixinを使って実装する。

Camera / World

横スクロールゲームのように、プレイヤーが移動すると背景が切り替わるような場合、
ステージ全体(=描画するすべての要素)がWorldで、
プレイヤーを中心に見えている範囲がCameraな感じ。

Router

FlutterのNavigatorのようなルーティング機能をもつComponentのサブクラス。
Routeの名前被りのため、hideが必要。

import 'package:flutter/material.dart' hide Route;

class MyGame extends FlameGame {
  late final RouterComponent router;

  @override
  void onLoad() {
    add(
      router = RouterComponent(
        routes: {
          'home': Route(HomePage.new),
          'level-selector': Route(LevelSelectorPage.new),
          'settings': Route(SettingsPage.new, transparent: true),
          'pause': PauseRoute(),
          'confirm-dialog': OverlayRoute.existing(),
        },
        initialRoute: 'home',
      ),
    );
  }
}

class PauseRoute extends Route { ... }

通常の画面はRouteを継承して実装する形。

ほかにも、OverlayRouteValueRouteもある。

  • OverlayRoute
    • 一時停止中のダイアログなど、今の画面に重ねて表示したい画面
  • ValueRoute
    • 結果画面など、スコアなどの値を受け取って表示したい画面

Tap/Drag/Gesture Eventなど

それぞれmixinが用意されていて、
それを使うことで各コールバックをオーバーライドできる。

class MyComponent extends PositionComponent with TapCallbacks {
  MyComponent() : super(size: Vector2(80, 60));

  @override
  void onTapUp(TapUpEvent event) {
    // Do something in response to a tap event
  }
}

Overlays

一時停止画面やメニューなど画面の上に重ねるUIのための機能

こんな感じで、overlayBuilderMapにkeyとbuilderを設定すると、

// On the widget declaration
final game = MyGame();

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

こんな感じで、keyを使って、overlays.addなどで表示させることができる。

// Inside your game:
final pauseOverlayIdentifier = 'PauseMenu';

// Marks 'PauseMenu' to be rendered.
overlays.add(pauseOverlayIdentifier);
// Marks 'PauseMenu' to not be rendered.
overlays.remove(pauseOverlayIdentifier);

Bridge Packages

ほかのライブラリを橋渡しするためのパッケージ群。
ゲーム関連のものがいくつも用意されているっぽい。

flame_riverpod

flame_riverpodは気になるので、もう少し。

Riverpodは変更があるとをWidgetを再描画してくれるが、
flameではWidgetではないComponentを利用している。

そのため、Componentでも変更に応じて呼び出されるようにする、
WidgetやMixinが提供される

  • RiverpodAwareGameWidget ... GameWidgetの代わりに使う
  • RiverpodGameMixin ... FlameGameにmixinする
  • RiverpodComponentMixin ... Componentにmixinする
/// An excerpt from the Example. Check it out!
class RefExampleGame extends FlameGame with RiverpodGameMixin {
  @override
  Future<void> onLoad() async {
    await super.onLoad();
    add(TextComponent(text: 'Flame'));
    add(RiverpodAwareTextComponent());
  }
}

class RiverpodAwareTextComponent extends PositionComponent
    with RiverpodComponentMixin {
  late TextComponent textComponent;
  int currentValue = 0;

  @override
  void onMount() {
    // build関数に処理を追加
    addToGameWidgetBuild(() {
      ref.listen(countingStreamProvider, (p0, p1) {
        if (p1.hasValue) {
          currentValue = p1.value!;
          textComponent.text = '$currentValue';
        }
      });
    });
    // super.onMount()の前にaddToGameWidgetBuildを呼ぶ
    super.onMount();
    add(textComponent = TextComponent(position: position + Vector2(0, 27)));
  }
}

Casual Games Toolkit

Flutter公式ドキュメントにも、カジュアルゲームをつくるために便利な
パッケージをまとめたCasual Games Toolkitというページがある

FlameやBridge Packagesで紹介されているのも載っているが、
app_reviewなど、他のもあるので、
目を通しておくとより捗る気がする。


以上!! 思った以上にいろいろできる。。すごい。。(*´ω`*)