Flutter Casual Games ToolkitのEndless Runnerのコードを追ってみる
ここ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!
上記通りにコマンド叩き、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.dart
にAppLifecycleObserver
が宣言されています。
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を再現していました。
ボタンをタップした際のSE再生は以下のようにAudioController
をreadしてSEを再生しているようでした。
(riverpod
のref.read
と同じと理解)
final audioController = context.read<AudioController>();
audioController.playSfx(SfxType.buttonTap);
main_menu
タイトル画面の実装
「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
を利用する場合が多そう。
全体の流れを見ると、画面外枠から以下の順番でゲーム画面が成り立っている。
- GameScreen(StatelessWidget)
- GameWidget<EndlessRunner>
- EndlessRunner(FlameGameクラスのコンストラクタとして4のEndlessworldをworldにセットしている)
- EndlessWorld
- Playerや背景などをはじめ、ゲームに登場するObject
GameWidget
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);
}
HasGameReference
はgame
をいう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をラップしているパッケージなどもあり、それらを使えばいい感じにできそうです。
まとめ
ゲームとしてはシンプルですが、コードで表示物などを宣言しているせいか、やってることが多く感じました。
ゲーム実装部分について実装の形式としてはcocos2d-xっぽいなと思いました。
(グラフィカルに表示するオブジェクトを配置できたりする最近のゲームエンジンの使いやすさはすごいなと改めて思いました。)
スマホにおけるUIの実装としてはFlutter作りやすく、Unityでは作りずらいと感じている(極めれば別そうですが、、、)ので、いい感じの切り分けができたらいいなと思いました。
UIをFlutter、ゲームメインをUnityというのができるらしいので、次はそれを調べつつまとめていければと思います。
Discussion