はじめてFlutterでゲームを作ったときに試行錯誤したこと

はじめてFlutterでゲームを作ってみたけど、  
いろいろハマったのでちょっとまとめてみました(\*´ω｀\*)

https://maguro-sagashi.com/

## つくったのはこちら

鯖(サバ)の中から鮪(マグロ)を探す
かわいいお気軽タッチゲーム🐟

<iframe width="560" height="315" src="https://www.youtube.com/embed/26PEFDco84w?si=p1h5EFjA663rqrHz" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

開発の背景や全体の構成はこっちに

https://zenn.dev/kira_puka/articles/ed45f61e80eafa

## アスペクト比を固定する

ゲームの場合、見た目が大事だけど、  
いい感じに調整するのが難しい...

なので、ゲーム画面はアスペクト比を固定する感じで対応。  
固定にすることでデザインも決めやすい。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478782/b49455aa-d41a-71cc-f3bb-2dbeae538db8.png" width="300" />


ソースコードはこんな感じ

```dart
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を含めた全体のレイアウトはこんな感じ。  

```dart
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](https://api.flutter.dev/flutter/widgets/LayoutBuilder-class.html)なども試したけど、  
頻繁に再描画されるため、スムーズにプレイできる感じではなかった...  
書き方にもよるかもしれないけど、`const`の箇所を増やせ、  
考慮することも少なくなるので、この形に

### スケールさせる場所を考える

この方式の場合、すべて`Flexible`の拡大縮小することもできるけど、  
そうしたくない場合もある

今回の場合、プレイするマス画面は拡大縮小していない

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478782/e4bcfd57-3863-7bfd-430a-b41e3906fe7b.png" width="300" />

画面のサイズによって、マスの大きさが変わってしまうと、  
見つけやすさ、押しやすさが変わり、スコアに影響が出る  
(タブレットだと、スコアを上げやすいなど)

ランキングもあるので、それはあまりよくないなと、  
その画面は対象外にしている

### TextField等の入力はダイアログに

`Flexible`の拡大縮小する場合、  
`TextField`などの入力系にも影響が出る

なので、ちょっとめんどうだけど、  
ダイアログを表示する形にしている

ダイアログでは`Flexible`は使わない形

## パフォーマンス

### ちゃんとWidgetをわける

状態管理には[rivepod](https://riverpod.dev/ja/)を使っている

よくいわれるやつだけど、かなり大事

関数でごまかしたり、大きなWidgetのままにせず、  
`const`の箇所が増やせ、再描画を局所化できるように、  
ちゃんとWidgetを分割する

### RepaintBoudaryをちゃんと使う

ゲームの場合、エフェクトやアニメーションなども多くなる

なにもしないと全体で再描画されてしまうため、  
再描画範囲を[RepaintBoundary](https://api.flutter.dev/flutter/widgets/RepaintBoundary-class.html)で絞り込む

再描画されるかどうかは、`debugRepaintRainbowEnabled`を使って確認できる

```dart
import 'package:flutter/rendering.dart';

void main() {
  debugPaintSizeEnabled = true;
  runApp(MyApp());
}
```

### 部分的な読み込み

Widgetの分離と近いけど、  
必要なデータごとにちゃんと分離する

最初は、なまけてそのページに必要なのをまとめて、  
取得してたけど、rivepodでキャッシュされてるのも  
待ってしまっていたりしたので、遅く感じる...

[AsyncValue](https://pub.dev/documentation/async_value/latest/async_value/AsyncValue-class.html)などをちゃんと活用する

データクラスの中で、一部だけAsyncValueにするなどもOK

```dart
@freezed
class MyState with _$MyState {
  const MyState._();
  const factory MyStore({
    required AsyncValue<MyData?> myDataAsync,
  }) = _MyState;
}
```

#### 一度に取得するならFutureProvider

ログイン後のユーザ情報など、まとめて取得したいものもある  
その場合は、[FutureProvider](https://pub.dev/documentation/riverpod/latest/riverpod/FutureProvider-class.html)を使えばOK  
ただし、値の変更はできず、参照だけなので注意

- [FutureProvider | Riverpod](https://riverpod.dev/ja/docs/providers/future_provider)

### 小ネタ

#### Game Loopをあつかう

ゲームの場合、ちょっとずつ移動させるなど、
時間経過(フレーム)ごとに処理をしたいなどがある。

Flutter製ゲームエンジンの[Flame](https://docs.flame-engine.org/latest/flame/game.html)を参考に、  
[Ticker](https://api.flutter.dev/flutter/scheduler/Ticker-class.html)を使って実装している。

中身はこんな感じ

```dart
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

```dart
_tick(Duration delta) {
  // フレームごとの処理
}

// gameLoopの初期化
final gameLoop = GameLoop(_tick);

// 開始(ゲーム開始時など)
gameLoop.start();

// 停止(Pause中など)
gameLoop.stop();

// 破棄
gameLoop.dispose();
```

[Flame](https://docs.flame-engine.org/latest/flame/game.html)の場合、  
`callback`が秒数の`double`なのが扱いづらく、  
`Duration`を受け取れるようにしている

```dart
void Function(double dt) callback;

void _tick(Duration timestamp) {
  final durationDelta = timestamp - _previous;
  final dt = durationDelta.inMicroseconds / Duration.microsecondsPerSecond;
  _previous = timestamp;
  callback(dt);
}
```

- [flame/packages/flame/lib/src/game/game_loop.dart](https://github.com/flame-engine/flame/blob/main/packages/flame/lib/src/game/game_loop.dart)

#### AppLifecycleStateをlistenしておく

アプリを閉じたときなどの[AppLifecycleState](https://api.flutter.dev/flutter/dart-ui/AppLifecycleState.html)用のProviderがあると便利

BGMや効果音を再生するけど、バックグラウンドに移動時に停止するときなどに使っている

Providerはこんな感じ

```dart
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

```dart
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](https://pub.dev/packages/flame)などのゲームエンジンを使えば、  
もっといろんなものがつくれるので、いつかそっちもつかってみたい！

よかったら、ぜひダウンロードしてみて、  
遊んでみてください〜(*´ω｀*)

https://maguro-sagashi.com/

- [App Strore](https://apps.apple.com/app/maguro-finding/id6483941300)
- [Google Play](https://play.google.com/store/apps/details?id=com.memorylovers.magurosagashi)

開発の背景や全体の構成などのこっちもよければ！

https://zenn.dev/kira_puka/articles/ed45f61e80eafa

https://x.com/kira_puka/status/1801004319967252577

# 参考にしたサイトさま
- [Flutterレイアウトのビジュアルデバッグ機能 #Dart - Qiita](https://qiita.com/tatsu/items/68cf1125bfb167e2f6e8)
- [【Flutter】RepaintBoundaryによるパフォーマンス向上について【Tips】 - alpha Lounge](https://alpha2048.hatenablog.com/entry/2023/10/22/142949)
- [【Flutter】Riverpodを利用しAppLifecycleStateとネットワーク接続(connectivity_plus)を検出](https://zenn.dev/motu2119/articles/app-lifecycle-20240103)