はじめてFlutterでゲームを作ってみたけど、
いろいろハマったのでちょっとまとめてみました(*´ω`*)
つくったのはこちら
鯖(サバ)の中から鮪(マグロ)を探す
かわいいお気軽タッチゲーム🐟
開発の背景や全体の構成はこっちに
アスペクト比を固定する
ゲームの場合、見た目が大事だけど、
いい感じに調整するのが難しい...
なので、ゲーム画面はアスペクト比を固定する感じで対応。
固定にすることでデザインも決めやすい。
ソースコードはこんな感じ
class GameArea extends StatelessWidget {
const GameArea({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return Flexible(
child: AspectRatio(
aspectRatio: 1080 / 1980,
child: child,
),
);
}
}
AspectRationとFlexibleを使って、
決めたアスペクト比のまま、拡大縮小するかたち。
GameAreaを含めた全体のレイアウトはこんな感じ。
class GameLayout extends StatelessWidget {
const GameLayout({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return Material(
child: MediaQuery.withNoTextScaling(
child: Stack(
children: [
const GameBackground(), // GameArea外の背景
Center(
child: SafeArea(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: GameArea(child: child),
),
),
),
),
],
),
),
);
}
}
必要があれば、最大の横幅を指定しておく
期待するアスペクト比の画面ではない場合、
何もなくなってしまうので、背景を用意しておく
LayoutBuilderはだめだった...
レスポンシブ対応などで利用するLayoutBuilderなども試したけど、
頻繁に再描画されるため、スムーズにプレイできる感じではなかった...
書き方にもよるかもしれないけど、const
の箇所を増やせ、
考慮することも少なくなるので、この形に
スケールさせる場所を考える
この方式の場合、すべてFlexible
の拡大縮小することもできるけど、
そうしたくない場合もある
今回の場合、プレイするマス画面は拡大縮小していない
画面のサイズによって、マスの大きさが変わってしまうと、
見つけやすさ、押しやすさが変わり、スコアに影響が出る
(タブレットだと、スコアを上げやすいなど)
ランキングもあるので、それはあまりよくないなと、
その画面は対象外にしている
TextField等の入力はダイアログに
Flexible
の拡大縮小する場合、
TextField
などの入力系にも影響が出る
なので、ちょっとめんどうだけど、
ダイアログを表示する形にしている
ダイアログではFlexible
は使わない形
パフォーマンス
ちゃんとWidgetをわける
状態管理にはrivepodを使っている
よくいわれるやつだけど、かなり大事
関数でごまかしたり、大きなWidgetのままにせず、
const
の箇所が増やせ、再描画を局所化できるように、
ちゃんとWidgetを分割する
RepaintBoudaryをちゃんと使う
ゲームの場合、エフェクトやアニメーションなども多くなる
なにもしないと全体で再描画されてしまうため、
再描画範囲をRepaintBoundaryで絞り込む
再描画されるかどうかは、debugRepaintRainbowEnabled
を使って確認できる
import 'package:flutter/rendering.dart';
void main() {
debugPaintSizeEnabled = true;
runApp(MyApp());
}
部分的な読み込み
Widgetの分離と近いけど、
必要なデータごとにちゃんと分離する
最初は、なまけてそのページに必要なのをまとめて、
取得してたけど、rivepodでキャッシュされてるのも
待ってしまっていたりしたので、遅く感じる...
AsyncValueなどをちゃんと活用する
データクラスの中で、一部だけAsyncValueにするなどもOK
@freezed
class MyState with _$MyState {
const MyState._();
const factory MyStore({
required AsyncValue<MyData?> myDataAsync,
}) = _MyState;
}
一度に取得するならFutureProvider
ログイン後のユーザ情報など、まとめて取得したいものもある
その場合は、FutureProviderを使えばOK
ただし、値の変更はできず、参照だけなので注意
小ネタ
Game Loopをあつかう
ゲームの場合、ちょっとずつ移動させるなど、
時間経過(フレーム)ごとに処理をしたいなどがある。
Flutter製ゲームエンジンのFlameを参考に、
Tickerを使って実装している。
中身はこんな感じ
import 'package:flutter/scheduler.dart';
class GameLoop {
GameLoop(this.callback) {
_ticker = Ticker(_tick);
}
void Function(Duration delta) callback;
Duration _previous = Duration.zero;
late final Ticker _ticker;
/// This method is periodically invoked by the [_ticker].
void _tick(Duration timestamp) {
final durationDelta = timestamp - _previous; // microsecond
_previous = timestamp;
callback(durationDelta); // millsecond
}
void start() {
if (_ticker.isActive) return;
_ticker.start();
}
void stop() {
_ticker.stop();
_previous = Duration.zero;
}
void dispose() {
_ticker.dispose();
}
}
あとは、初期化して、各メソッドを操作すればOK
_tick(Duration delta) {
// フレームごとの処理
}
// gameLoopの初期化
final gameLoop = GameLoop(_tick);
// 開始(ゲーム開始時など)
gameLoop.start();
// 停止(Pause中など)
gameLoop.stop();
// 破棄
gameLoop.dispose();
Flameの場合、
callback
が秒数のdouble
なのが扱いづらく、
Duration
を受け取れるようにしている
void Function(double dt) callback;
void _tick(Duration timestamp) {
final durationDelta = timestamp - _previous;
final dt = durationDelta.inMicroseconds / Duration.microsecondsPerSecond;
_previous = timestamp;
callback(dt);
}
AppLifecycleStateをlistenしておく
アプリを閉じたときなどのAppLifecycleState用のProviderがあると便利
BGMや効果音を再生するけど、バックグラウンドに移動時に停止するときなどに使っている
Providerはこんな感じ
import 'package:flutter/material.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'app_lifecycle_provider.g.dart';
@Riverpod(keepAlive: true)
AppLifecycleState appLifecycle(AppLifecycleRef ref) {
final appLifecycleObserver = _AppLifecycleObserver(
(appLifecycleState) => ref.state = appLifecycleState,
);
final widgetsBinding = WidgetsBinding.instance;
widgetsBinding.addObserver(appLifecycleObserver);
ref.onDispose(() => widgetsBinding.removeObserver(appLifecycleObserver));
return AppLifecycleState.resumed;
}
class _AppLifecycleObserver extends WidgetsBindingObserver {
final ValueChanged<AppLifecycleState> _didChangeAppLifecycle;
_AppLifecycleObserver(this._didChangeAppLifecycle);
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
_didChangeAppLifecycle(state);
super.didChangeAppLifecycleState(state);
}
}
あとは、音楽を管理するProviderでlisten
しておけばOK
ref.listen(appLifecycleProvider, (previous, AppLifecycleState next) {
switch (next) {
case AppLifecycleState.paused: // 停止時
case AppLifecycleState.detached: // 破棄時
onPause();
break;
case AppLifecycleState.resumed: // 再開時
onResume();
break;
case AppLifecycleState.inactive: // 非アクティブ時
case AppLifecycleState.hidden: // 一時停止/最小化時
break;
}
});
強制アップデートをいれておく
データの保存などはFirestoreを使っているけど、
モデル/スキーマを変更したい時がくる
古いバージョンが残っているとつらいんで、
強制アップデート機能は最初から入れておくのは必須
FirebaseのRemote Configを使うと、
簡単に実装できるので、必要ないかもと思っても、
とりあえず、入れておくのが大事...
(過去のアプリで何度も悔やんでる...)
Flutterでゲーム開発、たのしい
アプリとは違い、デザインやパフォーマンスにより気を使うけど、
アプリでも応用できる部分がかなりあるので、だいぶ勉強になった気がする(*´ω`*)
Flameなどのゲームエンジンを使えば、
もっといろんなものがつくれるので、いつかそっちもつかってみたい!
よかったら、ぜひダウンロードしてみて、
遊んでみてください〜(´ω`)
開発の背景や全体の構成などのこっちもよければ!