🌟

Flutter Casual Games ToolkitのEndless Runnerのコードを追ってみる

2024/06/27に公開

https://flutter.dev/games

ここ2022年ぐらいからそこそこ話に出始めたFlameなどFutterのゲーム向けの展開ですが、どうしてもFlutterはゲームというよりかはアプリを作るフレームワークな印象があり、以前サンプルプロジェクトを動かそうとした時にそれ用のFlutterバージョンをインストールしなくてはならなかったりと大変だった印象があったのですが、最近再チャレンジをしてみてアプリを動かすところまで出来たので、「どんなことをしているのか」「ゲームを作るにあたっての実装はどういうことをやっているか」をEndless Runnerのプロジェクトを見ていこうと思います。

セットアップ

$ flutter doctor
Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 3.22.2, on macOS 13.6 22G120 darwin-arm64, locale ja-JP)
[✓] Android toolchain - develop for Android devices (Android SDK version 34.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 15.0)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2023.2)
[✓] VS Code (version 1.90.1)
[✓] Connected device (5 available)
[✓] Network resources

• No issues found!

https://github.com/flutter/games/tree/main/templates/endless_runner#how-to-get-this-sample

上記通りにコマンド叩き、flutter runをすればそのまま起動まで出来ます。

sample_downloaderを実行した際に、ゲームのサンプルを選択出来ますが、その中でtemplates/endless_runnerを選択しています。

コードを見ていく

構成

(.dartがないものはフォルダで後ほど深掘りします)

小物系

main.dart

エントリーポイント。main関数は以下で記述通りFlameを使って画面方向を横に固定、アプリの全画面表示を指定してる。

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Flame.device.setLandscape();
  await Flame.device.fullScreen();
  runApp(const MyGame());
}

MyGameの中身はProviderの登録がされており、サウンド関連や設定みたいなProviderもあるのでアプリ全体で利用されるものがここで登録されてそうです。(ここら辺はRiverpodに変えても問題なさそう)

      child: MultiProvider(
        providers: [
          Provider(create: (context) => Palette()),
          ChangeNotifierProvider(create: (context) => PlayerProgress()),
          Provider(create: (context) => SettingsController()),
          // Set up audio.
          ProxyProvider2<SettingsController, AppLifecycleStateNotifier,
              AudioController>(
            // Ensures that music starts immediately.
            lazy: false,
            create: (context) => AudioController(),
            update: (context, settings, lifecycleNotifier, audio) {
              audio!.attachDependencies(lifecycleNotifier, settings);
              return audio;
            },
            dispose: (context, audio) => audio.dispose(),
          ),
        ],

app_lifecycle

アプリの端末上での状態をハンドリングする機能

app_lifecycle.dartAppLifecycleObserverが宣言されています。
AppLifecycleListenerでアプリのステータス(onResumeやonPause、onDestroy)をキャッチしてlifecycleListenableを通じてWidget childに渡せるようにいました。
AppLifecycleObserverをアプリの階層の上位に設定することでアプリ全体にアプリの状態が通知されるようになっています。

audio

音楽を再生する機能

audio_controller.dart ・・・ AudioPlayerを利用して音楽再生をしています。
songs.dart ・・・ 歌(BGM)のファイル名指定などの定義
sounds.dart ・・・ SEの利用場所やファイル名の定義

AudioPlayerは同時に1ファイルの再生しかできないため、同時に再生される可能性がある音分宣言しておく必要がありそう。

// BGMの再生。一曲終わったら、次の曲を再生する
final AudioPlayer _musicPlayer;
// SEは同時に再生されることがあるため、同時に再生される分だけ確保する。(コンストラクタのpolyphonyで指定)
final List<AudioPlayer> _sfxPlayers;

あとは前述しているAppLifecycleやSettingsの情報をaddListenerで追従できるようになっており、状態や設定に応じて音量の調整や再生・停止をしています。

UI系

router.dart

go_routerを使って画面遷移が設定されていました。
画面も4画面と少なく、パッと見特殊なことをしているわけではないので、深掘りはしないです。

level_selection

ステージ選択画面を実装している階層。
プラスで「?」ボタンがあり、それをタップするとダイアログ(instructions_dialog.dart)が表示され操作方法が記載されていました。

コード的にはよくあるUI実装なので、気になった部分だけ詳細まで見てみます。

以下のパッケージを使用してPixel調のUIを再現していました。
https://pub.dev/packages/nes_ui

ボタンをタップした際のSE再生は以下のようにAudioControllerをreadしてSEを再生しているようでした。
(riverpodref.readと同じと理解)

final audioController = context.read<AudioController>();
audioController.playSfx(SfxType.buttonTap);

タイトル画面の実装

「Play」「Settings」をタップすることでそれぞれの画面に遷移。
「音楽のON/OFF」のトグルで音楽の有無を設定できる。

ここもよくあるUI実装なので、気になった部分だけ見てみる。
音楽のON/OFFトグルの実装。ValueListenableBuilder<bool>を使うことでValueNotifier<bool>をサブスクライブすることができる。riverpodでいうところのConsumerと理解。

child: ValueListenableBuilder<bool>(
  valueListenable: settingsController.audioOn,
  builder: (context, audioOn, child) {
    return IconButton(
      onPressed: () => settingsController.toggleAudioOn(),
      icon: Icon(audioOn ? Icons.volume_up : Icons.volume_off),
    );
  },
),

style

汎用的に利用されるWidgetが入っている。
特殊なWidgetとしてResponsiveScreenというものがあり、どんな画面サイズでもセットしたWidgetをうまく表示できるよう調整しているようでした。タイトル画面で利用されているっぽいです。

保存まわり(一部画面実装も)

settings

画面の実装もありますが、設定のデータを保存・読み込みの機能も同じフォルダ内ありました。

settings_screen.dart ・・・ 設定画面の実装
custom_name_dialog.dart ・・・ 設定画面でプレイヤー名を変更する際に表示するダイアログ
settings.dart ・・・ 設定情報。ローカルファイルまたはメモリ上から設定情報を読み込み、各パラメータをValueNotifierで公開し、アプリ全体で利用できるよう公開している。

persistenceというフォルダがあり、interface的な扱いのabstractの宣言(settings_persistence.dart)とローカルストレージ(SharedPrefs)(local_storage_settings_persistence.dart)とメモリ上(memory_settings_persistence.dart)に保存する実装ファイルがあります。

LocalStorageSettingsPersistenceもしくはMemorySettingsPersistenceを'Settings'のクラスで必要に応じてどちらかのインスタンスを作成する感じっぽいです。

player_progress

設定のデータ保存まわりと似ており、ゲーム全体の進捗度をローカルストレージ、もしくはメモリ上に保存できるようにしています。
(具体的には各ステージでのスコアなど)

ゲーム部分

flame_game

ゲーム実装部。以下のファイルやフォルダが存在します。
endless_runner.dart ・・・ FlameGameを継承しているクラスが定義されている。onLoadのような関数もあるのでゲーム全体のフレームワーク的な基盤を実装してそう。
endless_world.dart ・・・ Worldを継承しているクラスが定義されている。Playerの定義をしてたりするので、このファイルがゲームにおける画面実装部分のよう。
game_screen.dart ・・・ StatelessWidgetを継承しているクラスだが、内部でGameWidgetを宣言している。ここがゲーム画面を表示する一番外枠みたい。
game_win_dialog.dart ・・・ ゲームクリア時に表示されうダイアログ。ここには特殊な実装はなさそう。
effects ・・・ プレイヤーの移動、またダメージを食らった時の色のアニメーションを定義している。
components ・・・ ~~Componentというクラスがそれぞれ定義されている。アプリでは~~Widget~~Viewなどを使う場合が多いが、ゲームの場合は~~Componentを利用する場合が多そう。

全体の流れを見ると、画面外枠から以下の順番でゲーム画面が成り立っている。

  1. GameScreen(StatelessWidget)
  2. GameWidget<EndlessRunner>
  3. EndlessRunner(FlameGameクラスのコンストラクタとして4のEndlessworldをworldにセットしている)
  4. EndlessWorld
  5. Playerや背景などをはじめ、ゲームに登場するObject
GameWidget

https://pub.dev/documentation/flame/latest/game/GameWidget-class.html
ゲーム開発の基盤になりそうなWidget。
EndlessRunnerで利用されているパラメータに限っていうと、
game: FlameGameを継承したクラスをセットする。
overlayBuilderMap: ゲーム画面より上にWidgetを表示する場合に設定するパラメータ。mapとあるようにMap<String, builder(BuildContext context, T game)というMapでセットする。

例):

          winDialogKey: (BuildContext context, EndlessRunner game) {
            return GameWinDialog(
              level: level,
              levelCompletedIn: game.world.levelCompletedIn,
            );
          },

ゲーム内からこれらのWidgetを表示する場合は、

game.overlays.add(GameScreen.winDialogKey);

こんな感じでadd(String key)と呼べば画面最前面に表示されます。
(非表示の場合はremove(String key))

EndlessRunner(FlameGameを継承しているクラス)

コンストラクタは以下で、EndlessRunner独自のパラメータについては前述で解説しているものもあるので省略。
superに設定するパラメータ(FlameGameのコンストラクタへのパラメータ)はworldとcameraがある。

  EndlessRunner({
    required this.level,
    required PlayerProgress playerProgress,
    required this.audioController,
  }) : super(
          world: EndlessWorld(level: level, playerProgress: playerProgress),
          camera: CameraComponent.withFixedResolution(width: 1600, height: 720),
        );

worldにはWorldクラスを継承したクラスを設定しており、
cameraにはCameraComponentを設定し、withFixedResolution(width: 1600, height: 720)というfactoryメソッドを使っている。解像度を1600x720と設定しているよう。

また、onLoadという関数がoverrideで宣言されており、背景とUIのセットアップがされています。
背景を設定する場合、cameraのbackdropというところにaddする形でセットするよう。
セットできるのはComponentというクラスなので、それを継承してるBackgroundが設定されています。
引数のスピードはスクロールをするスピードのようです。

camera.backdrop.add(Background(speed: world.speed));

UIの設定でスコアを表示するテキストを表示を設定していますが、次のステップで設定していました。
TextPaint -> TextComponent -> viewportへadd
ざっくりとはフォントを指定して、表示する文字列を設定して表示のようなことをしています。

position: Vector2.all(30),については、
TextComponentの左上を基準としてviewportの左上を(0,0)として扱っているようでした。

    final textRenderer = TextPaint(
      style: const TextStyle(
        fontSize: 30,
        color: Colors.white,
        fontFamily: 'Press Start 2P',
      ),
    );
    final scoreText = 'Embers: 0 / ${level.winScore}';
    final scoreComponent = TextComponent(
      text: scoreText,
      position: Vector2.all(30),
      textRenderer: textRenderer,
    );
    camera.viewport.add(scoreComponent);

Vector2.all(0)を指定した場合

EndlessWorld

操作やプレイヤーキャラクターの表示など、ゲームのロジック部分。
TapCallbacksとHasGameReferenceというmixinをwithで宣言していました。

TapCallbacksはonTapDownのコールバックを提供しており、
画面タップがあった場合にタップ位置を提供いしています。
localPositionがタップ位置のようです。
※これはWorldというゲーム画面全体を表示しているComponentでのlocalPositionなので、ある意味グローバル座標を取得できていそう。

  @override
  void onTapDown(TapDownEvent event) {
    print(event.localPosition);
  }

HasGameReferencegameをいうgetterを提供するmixinのようで、
本来であれば階層的に親に該当するEndlessRunner(FlameGame<World>)のクラスをgetterで取得できるようになるようです。

onLoadでは、プレイヤーキャラクター、スポンオブジェクト(障害物)、スポンオブジェクト(ポイント)をaddでワールド内に生成していました。

基本的には表示するものはadd関数にてワールドに追加、追加できるのはComponentを親に持っているクラスで、Componentにあるpositionの位置に画像などの表示物が表示されるようでした。

ゲームにとって最低限必要なもの
画像表示

画像表示についてはいろんなコンポーネントが表示されているようでした。

  • SpriteComponent
    • 障害物(Obstacle)で利用されているコンポーネント。静止画を表示するコンポーネント
    • spriteという変数を持っており、それに対してSprite.loadで画像を読み込む
  • SpriteAnimationComponent
    • ポイント(Point)で利用されているコンポーネント。連番画像を読み込んでそれを切り替えパラパラ漫画のようにアニメーションをしている画像を表示するコンポーネント
    • animationという変数を持っており、game.loadSpriteAnimationで画像を読み込む
  • SpriteAnimationGroupComponent
    • 👆のSpriteAnimationComponentと同じように連番画像を読み込んでパラパラ漫画のようなアニメーションを表示するコンポーネントだが、さらに複数のアニメーションを持たせ切り替えることができるコンポーネント
    • animationという変数を持っており、Map形式でKey(アニメーションの種類)、Value(game.loadSpriteAnimationで読み込んだもの)をセットしてます。
    • さらにcurrentという変数を持っており、それにKeyで設定した物をセットすればアニメーションが切り替わります。
  • ParallaxComponent
    • 背景で利用されている複数の画像をレイアー形式に表示するコンポーネント
    • parallaxという変数を持っており、game.loadParallaxを使って読み込みをする
    • スクロールすることが前提?のようで、基準となるvelocity(baseVelocity)、レイアー毎に乗算されるvelocity(velocityMultiplierDelta)が設定できるようになっている。
当たり判定

Componentクラスには当たり判定を設定できるそうです。
そのため、Plaeyr、Obstacle、Pointそれぞれのクラスで当たり判定を設定しています。

    add(RectangleHitbox());

デフォルトでは画像のサイズなどコンポーネントの表示サイズに合わせてくれるみたいです。
このHitboxを持っているコンポーネント同士が衝突すると、CollisionCallbacksというmixinを持っているコンポーネントでonCollisionStartというコールバックが呼ばれます。(player.dart)

  @override
  void onCollisionStart(
    Set<Vector2> intersectionPoints,
    PositionComponent other,
  ) {
    super.onCollisionStart(intersectionPoints, other);
    if (other is Obstacle) {
      game.audioController.playSfx(SfxType.damage);
      resetScore();
      add(HurtEffect());
    } else if (other is Point) {
      game.audioController.playSfx(SfxType.score);
      other.removeFromParent();
      addScore();
    }
  }
アップデート関数(毎ループ呼ばれる関数)

Componentクラスにはvoid update(double dt)があり、これをoverrideすることで毎ループ呼ばれる処理を実行することができます。

dtには前フレームからの差分時間が入っています。

物理計算系

少なくともEndlessRunnerのプロジェクト内にはなさそうで自前で計算していました。

ただBox2Dをラップしているパッケージなどもあり、それらを使えばいい感じにできそうです。
https://pub.dev/packages/forge2d

まとめ

ゲームとしてはシンプルですが、コードで表示物などを宣言しているせいか、やってることが多く感じました。
ゲーム実装部分について実装の形式としてはcocos2d-xっぽいなと思いました。
(グラフィカルに表示するオブジェクトを配置できたりする最近のゲームエンジンの使いやすさはすごいなと改めて思いました。)

スマホにおけるUIの実装としてはFlutter作りやすく、Unityでは作りずらいと感じている(極めれば別そうですが、、、)ので、いい感じの切り分けができたらいいなと思いました。

UIをFlutter、ゲームメインをUnityというのができるらしいので、次はそれを調べつつまとめていければと思います。

Discussion