【Flutter】Flameで倉庫番を作ってみた

下記の記事を見かけたので、FlutterおよびFlameの練習として倉庫番を作ってみることにしました。
Flutterはちょっとサンプルをいじっただけ、Flameは完全に初めてという段階です。

https://2dgames.jp/godot-sokoban-sample/

以下、自分用のメモを書き換えたものを元に書いていきます。
要点だけ書いたものではなく、試行錯誤の過程をそのまま書いてあります。
なので読みづらいと思いますが、とりあえず、仮にFlameを使おうとした時に詰まる人がいる時のための情報共有として置いておきます。

# 参考にしたもの

Flameの公式チュートリアルのうちEmber Questをベースに作成しました。
下記のチュートリアルの順にコードを作成しました。
フォルダ構成などもこれがベース。

https://github.com/flame-engine/flame/tree/main/doc/tutorials/platformer

Directionのヘルパーや十字ボタン navigation_keys.dart は以下からほぼまるコピー。

https://blog.codemagic.io/flutter-flame-game-development-japanese/

# 制作開始

## 原型が動くまで

- 基本的にFlameのチュートリアルにあるEmber Questをベースに作成
フォルダ構成を同じように設定
- pubspec.yaml に flame: 1.7.3 を追加
- [https://opengameart.org/content/sokoban-100-tiles](https://opengameart.org/content/sokoban-100-tiles) から画像ファイルをダウンロード
- 画像を切り出して自分の好きな組み合わせに結合しなおすpythonスクリプトを書いた
    - from PIL import Image で画像を取り扱うPillowライブラリを使う
    - original_image = Image.open(file_path)で画像を開く
    - new_canvas = Image.new("RGBA", (canvas_size_width, canvas_size_height), (0, 0, 0, 0)) で新規キャンバスを作成
    - cutting= original_image.crop(x1, y1, x2, y2)で切り取り
    - new_canvas.paste(cutting, (x, y)) でキャンバスに貼り付け
    - new_canvas.save(new_file_name)でキャンバスをファイル保存
- assets/images以下に画像を配置
- pubspec.yaml のflutter: セクションに assetsを追加

```yaml: pubspec.yaml
assets:
    - assets/images/
```

- sokoban.dart に FlameGame を継承した SokobanGameクラスを作成
- main.dartのmain()でSokobanGameを呼び出す

```dart: main.dart
runApp(
    const GameWidget<SokobanGame>.controlled(
      gameFactory: SokobanGame.new,
    ),
  );
```

- Constants.dartにタイルサイズなどを定義した定数を定義
すべてのスプライトのアンカーを統一するためのanchorも定義しておく
- /lib/actors/player.dartにplayerクラスを定義
SpriteAnimationDataを指定
- /lib/objects/block.dartにblockクラスを定義
スプライトシートから特定のタイルを取り出すようにSprite作成時にsrcPositionとsrcSizeを指定
また位置が変化しないようにvelocity = Vector2.zero()として速度0を定義しておく
- /lib/objects/point.dartにpointクラスを定義
エフェクト[SizeEffect.by](http://sizeeffect.by/)を定義
- /lib/objects/ground.dartにgroundクラスを定義
おおむねblockクラスと同じ
- /lib/managers/segment_manager.dartに面を管理するsegment_managerクラスを定義
タイル定義を実装
- SokobanGameクラスにキー入力イベントを実装
'package:flutter/services.dart'をインポートし、クラスにwith KeyboardEventsを付与
onKeyEventを実装
- 主要なロジックはSokobanGameに実装
- SegmentMangerに面データの元文字列から面データを読み込む処理を追加
- SokobanGameで面データ読み込み後、データに従ってスプライトを置く
スプライトは追加された順が後のものが画面では前側にくるので、荷物は面全体のスプライトを置き終わった後にまとめて置く。
またプレイヤーは最後に置く。
- objectsの共通処理をGridObjectクラスに外出ししてそこから継承するようにした
- int用のVector2であるIntVector2を実装してグリッドを使う箇所を置き換え
- 荷物の移動処理で、アニメ終了時のコールバックを設定するようにし、コールバックの中でマップ書き換えするようした (最終的にはすべてgame側でマップ書き換えするようにした)
- SokobanGameのUpdate中で荷物がゴールに全部置かれているか判定し、全部置いた場合はステージクリア処理に移るようした
- /lib/overlays/stage_clear.dartをStageClearオーバーレイとして追加
- /lib/overlays/main_menu.dartをMainMenuオーバーレイとして追加
- /lib/main.dartにオーバーレイの呼び出しを追加
- ステージクリア処理時にStageClearオーバーレイを呼ぶようにした。リプレイ時はSokobanGameのreset()を呼ぶ
- Blockという名前はflame/components.dartで定義されている名前とかぶるためWallに変更

これで概ね原型が動作するようになった。

## undo,redoの実装

- リプレイデータを保存するクラスReplayDataを作成し、盤面を表す`List<List<Tile>> board`をプロパティにした
- `List<ReplayData> replayList`にリプレイデータ配列を格納するようにした
- boardを直接書き換えていたが、replayListの追加時にboardをそのまま格納していたため、参照の罠にひっかかり、replayList内すべての要素のReplayData.boardが同じ内容になっていた
- boardをクラス化し、DeepCopyするコンストラクタを実装し、replayListに新しい要素を追加する時はこれ経由でコピーしてから追加するようにした

## スプライトコンポーネントのremove

- crate配列にスプライトコンポーネントであるcrateを格納し、不整合時にcrate配列の全要素をremoveしていたが、すでに削除済のcrateを削除しようとして内部でエラー
- FlameGameのchildrenプロパティですべての子コンポーネントを取得できるので、
`children.whereType<Crate>().map((e) => remove(e));`
として削除するようにした

## ステージデータのファイル読み込み

- 最初ステージデータは`const List<String> stageDataStr`としていた
- `stageDataStr = await (File(filePath).readAsLines());` として置き換えたものの、なぜか後続の処理がうまく流れていない
- `stageDataStr = await (File(filePath).readAsLines());` を囲う`readStageDataFromFile`のみasyncにしたが、readStageDataFromFileを呼び出す側もasyncにする必要があり、それを呼び出す箇所も…以下同様
readStageDataFromFile以降に後続処理を記述している箇所はすべてasyncにした

## ゲームメイン画面に十字キー、Undoボタン、Redoボタン、Menuボタンを表示

- [https://blog.codemagic.io/flutter-flame-game-development-japanese/](https://blog.codemagic.io/flutter-flame-game-development-japanese/) の十字キーの実装をほぼそのまま使わせてもらい、十字キーを実装
    - Overlayオブジェクトになっているので、チュートリアルのオーバーレイと同様の扱いにして、mainにオーバーレイを追加
- ついでにUndo, Redo, Menuもほぼ同じ方式で実装

## ステップ数とステージを表示するテキスト

- テキストはTextComponentにする
- チュートリアルのEmberQuestのHudクラスを改造して、この中にステップ数とステージを表示するTextComponentを入れる
[https://github.com/flame-engine/flame/blob/main/doc/tutorials/platformer/step_6.md](https://github.com/flame-engine/flame/blob/main/doc/tutorials/platformer/step_6.md)
- ステージを表示するstageTextは画面左上に、ステップ数を表示するstepsTextは画面右上に表示する。stepsTextの表示位置は、game.size.xが画面横幅なのでここから逆算
- HudクラスがPositionComponentを継承しているので、updateメソッドで、画面のリサイズとステップ数の更新を検知して書き換えるようにした

## メッセージの国際化(i18n)

- いくつか方法があるが、そんなにメッセージ量が多くないので今回はFlutter標準のflutter_localizationsを使ってみる
- [https://docs.flutter.dev/development/accessibility-and-localization/internationalization](https://docs.flutter.dev/development/accessibility-and-localization/internationalization) 、
[https://zenn.dev/flutteruniv_dev/articles/20220422-140216-flutter-localizations](https://zenn.dev/flutteruniv_dev/articles/20220422-140216-flutter-localizations) と [https://kevins-blog.com/flutter-internationalization-sample/](https://kevins-blog.com/flutter-internationalization-sample/) を参考にする
- pubspec.yamlに flutter_localization と intl を入れる
また、flutterのgenerateフラグをtrueにする

```yaml: pubspec.yaml
flutter_localizations:
    sdk: flutter
  intl: any

flutter:
  generate: true #自動生成フラグの有効化
```

- lib/l10n フォルダを作り、その下にapp_en.arbとapp_ja.arbを作成
- pubspec.yamlを保存しなおすと、自動生成が実行され、 /.dart_tool/flutter_gen/l10n フォルダ下に自動生成のコードが作られる
- Hudクラスでは自動生成されたapp_localizations.dartを参照する
- BuildContextが必要なのでSokobanGameクラスの引数に追加してGameMainクラスで渡すようにする
- トラブル：AppLocalizations.of(context)がNullになり実行できない
    - GameMainの中にさらにInnerWidgetを作ってみる  → ダメ
    - Windowsでテストしていたがほかの環境で動かないか試す  → ダメ
    - contextを直接使わず、AppLocalizationsWrapperをInnerWidgetの中で初期化して使う  → ダメ
    - AppLocalizationsWrapperでtry,catchして例外の時はAppLocalizationEnを直接生成する  → 通るが、どの環境でも必ず例外になってしまってAppLocalizationEnしか生成されない
    - MaterialAppで初期化している箇所で、localizationsDelegatesに AppLocalizations.delegateを追加してあげないと正常にAppLocalizationsが生成されなかった  → やっとOK
    
    ```dart: main.dart
    localizationsDelegates: [
          AppLocalizations.delegate,  // add a new line
          GlobalMaterialLocalizations.delegate,
          GlobalWidgetsLocalizations.delegate,
          GlobalCupertinoLocalizations.delegate,
        ],
    ```
    
    - ↓最初からこの記事のとおりにやっておけばよかったらしい
    [https://qiita.com/maria_mari/items/4b2780d5657581c4f406](https://qiita.com/maria_mari/items/4b2780d5657581c4f406)
    - SokobanGameの中でAppLocalizationを使う時に面倒だったので、引数でcontextとlocalizationsを受け取るようにした。
    こうしておくとoverlayの中ではgame.localizationsでアクセスできる。

## BGMをつける

- 最初はフリー素材系のものを探したが、以下の理由でやめた。
    - 配布フリーのサイトは単体配布不可の場合が多く、ソースをGitHubに上げるのに不安がある
    - CCby4.0 (CreativeCommon by 4.0)のサイトもあったが、ループしていないのでゲームには使いづらかった
- そのためAI生成サイトで生成したものを使うことに。以下、検討したサイト。
    - SoundRaw
    完全AIだがすぐに使えるハイレベルの曲が作れる
    曲の長さは自由に調整できるが最後がoutroになってしまってそのままではループできない
    ダウンロード有料で、自由に使えるものの著作権はSOUNDRAW側などライセンスが厳しいので使いづらい
    [https://soundraw.io/ja/edit_music](https://soundraw.io/ja/edit_music)
    - CREEVO
    ひらがなで歌詞を入力してメロディを作る独特のサイト
    曲が短い(15～20秒ほど)のと最後が切れるのでそのままループにできない
    完全パブリックドメインでmp3のみ落とせる
    [https://creevo-music.com/project](https://creevo-music.com/project)
    - FIMMIGRM
    SOUNDRAWに比べるとできる曲のレベルはかなり低いが使えるものもたまにある
    メール登録時の10クレジットをダウンロードに使えるので部分無料
    そのままでループできる
    完全に自分の曲として利用でき、さらにMIDIファイルも一緒にダウンロードできるので自由度が高い
    [https://fimmigrm.com/app/generate/](https://fimmigrm.com/app/generate/)
    - 最終的にはFIMMIGRMで作成したものはWAVで落ちてくる。MIDIファイルも落ちてくるが修正できる技量がないため、今回はWAVをmp3に変換して使った。
    - FlameAudioを使ってmp3を鳴らす。ゲーム終了時にdisposeした方がよさそうだが、現状よくわからないので後に残しておく。
    ついでにSettingクラスを作り、SokobanGameのコンストラクタで受け取るようにした。
    
    ```dart
    // InitializeGameの中で実行
    FlameAudio.bgm.initialize();
    FlameAudio.bgm.play('アセット名', volume: setting.bgmVolume);
    ```
    
    ```dart
    /// ゲーム終了
      void exitGame() {
        FlameAudio.bgm.audioPlayer.stop();
        FlameAudio.bgm.dispose();
      }
    ```
    
    - BGM演奏関係はBgmPlayerクラスを作り、ここにうけもたせることにした。
    - なぜか面クリア後の「次のステージへ」とした時に、次のステージ開始時にBGMが鳴らない(正確には0.1秒くらい鳴った後に止まる)問題発生。
    先に実行したはずのbgm.audioPlayer.stopが後から動いているっぽいが、どうやってもうまくいかないので、stop → 0.5秒待機 → play としたところ、今のところはうまくいった。

## タイトル画面

- もとはEmberQuestのMainMenuだったものを下敷きにしてタイトルページにした。
- Flutterのページとして/lib/Pageフォルダを作り、その下にtitle_page, select_stage, game_main を配置
- ナビゲーションの`routes`はmain.dartで定義しておく
/home = title_page
/gamepage = game_main
/selectpage = select_stage
- タイトルからは、「最初から」(New Game)をタップすると /gamepage へ、「ステージを選ぶ」(Select Stage)をタップすると /selectpage へ遷移

## ステージ選択画面

- まず、SokobanGameのコンストラクタで`stageName`を受け取るようにした。
- 次に面データをdart.ioのファイルから読むようにしていたが、Webでも遊べるようにしたいのでアセットのみから読み込むように、`rootBundle.loadString(filePath)`を使うようにした。
- 面データのアセットファイル名は、これまで stage.001.dat のような形式だったが、 ステージ名として表示する名称と統一するため 001.dat のようにした。
- ステージ選択画面のListView部分はFutureBuilderを使った。
[https://blog.dalt.me/1652](https://blog.dalt.me/1652)　を参考にした。
ゲームメインへの移動は`Navigator.of(context).pushReplacementNamed`で移動するようにしたので、ページのスタック上は(1)タイトル、(2)ステージ選択 だったのが、ステージ選択とゲームメインが入れ替わって、(1)タイトル、(2)ゲームメイン となる。
- アセットの中から面データ部分を`Future<List<String>>`で取り出す部分は、ChatGPTに書かせたコードを手直しして入れた。最終的にはシングルトンぽくするため、まずStageDataクラスとして独立させ、staticな`get stageDataList`プロパティで`Future<List<String>>`を返すようにした。
- 面クリア時に次の面に移れるようにしたいが、次の面の名前が必要。
StageDataクラスから次の面の名前を返すstaticなメソッドを定義したいが、stageDataListの戻り値が`Future<List<String>>`になっている（ステージ選択画面にFutureBuilderを使ったのでFutureにしておく必要がある）。
そのままだとstageDataListを使う場合は同様にFutureにする必要があるが、ゲームの中で使うには面倒なのでどうにか同期的な呼び出しにしたい。
考えた挙句、_isCompleteフラグを追加して、このフラグが立っていればstageDataListは内部キャッシュを読み込み済として、そのまま裏のプライべートなフィールドである_stageDataListを読んでしまうことにした。
現状、面データはアセットでゲーム中に動的にリストは変わらないのでこのままでいくことにした。
- ステージクリア画面で「次のステージへ」をタップした場合は、現在の/gamepageから再度/gamepageへ`Navigator.of(context).pushReplacementNamed`で移動するようにした。そのため、ページのスタック上では一つ前はタイトル画面になる。
指定するstageNameには次のステージの名前を入れる。
- ステージクリア画面の「タイトルへ戻る」をタップした場合、ページのスタックでは１つ前はタイトルになっているので、`Navigator.of(context).pop`でタイトルに戻る。

## メインメニュー

- 右上の×ボタン
[https://popy1017.hatenablog.com/entry/2021/06/21/200109](https://popy1017.hatenablog.com/entry/2021/06/21/200109)
を参考にした。
`Stack`にして、`alignment: Alignment.topRight,` を指定し、`Container`の上に`CircleAvatar`を重ねる。
CircleAvatarのボタンは`icon: Icon(Icons.close),`で指定。

## 設定メニュー

- BGM選択のトグルボタンは以下を参考にした。
[https://api.flutter.dev/flutter/material/ToggleButtons-class.html](https://api.flutter.dev/flutter/material/ToggleButtons-class.html)
- スライダーは以下を参考にした。
[https://qiita.com/kokikudo/items/c8fa9b186bf4aa7016d1](https://qiita.com/kokikudo/items/c8fa9b186bf4aa7016d1)
- StateからStatefulWidgetが持っているgameを参照するには、 `widget.game`とする。
- トグルボタンに初期値を設定するには `_selectedBgms[widget.game.setting.bgm.index] = true` とした。
- setting.bgmは enumのBgm型なので、値をセットするには `setting.bgm = Bgm.values[index]` とする。
- BgmPlayerを独立させて、BGMの演奏をそちらでコントロール。
FlameAudio自体がstaticなので、setBgm()もsetVolume()もstaticメソッドに。
`FlameAudio.bgm.initialize()`を何度も呼ぶとメモリ関係で例外になってしまうので、やはりstaticなフラグbgmPlayerInitializedがfalseの時だけinitialize()をするようにした。
- Webで実行するとフォントの問題でボタン内で折り返しが発生してしまう箇所があるので幅に余裕を持たせた。

## 設定の保存

- SharedPreferenceを使って保存するようにした。
設定関係をSettingRepositoryクラスでハンドリング。
設定のbgmプロパティはenumのBgm型になっているので、enum⇒intの変換は
`Bgm.values.firstWhere((e) => e.index == (prefs.getInt('bgm') ?? 0));`
でint型に。(逆は単にindexプロパティを使えばOK)
- ここでも非同期に悩まされたが、とりあえず、
    - GameMainの入り口でSharedPreferenceから設定をロード。async～awaitをかける。
    GameMainでパネル部分をFutureBuilderにしてこのロードが終わるまでゲーム開始しないようにした。
    - GameMainのStateがdisposeするタイミングで設定をセーブ。
 
## 大きなステージはスクロールするか拡大縮小できるようにする

- [https://github.com/flame-engine/flame/blob/main/examples/lib/stories/camera_and_viewport/zoom_example.dart](https://github.com/flame-engine/flame/blob/main/examples/lib/stories/camera_and_viewport/zoom_example.dart)
をほぼそのままコピー
Align.topLeftに直したくらい
- スクロールできる範囲をしぼるため、 camera.worldBoundをステージの画像サイズ or キャンバスサイズかの大きい方に設定
- ズーム時におかしくなるがうまく直せないのでそのまま放置
今回はこれで完成とした

## 面データ

- 「sokoban levels public domain」で検索して出てきたデータを多少アレンジ
- [https://github.com/davidjoffe/sokoban](https://github.com/davidjoffe/sokoban)
「The default 90 levels are public domain.」とある
[https://github.com/davidjoffe/sokoban/blob/main/data/sokoban/levels/default.txt](https://github.com/davidjoffe/sokoban/blob/main/data/sokoban/levels/default.txt)

## GitHub Pagesにデプロイする
- ~~GitHub Pagesを既存のリポジトリから作成~~
    - ~~リポジトリのページから Settings > サイドバーのPages
    Source: Deploy from a branch
    Branch: 「master」「/(root)」に設定してSave~~
- ~~flutter run -d chrome~~
- ~~flutter build web --base-href /sokoban/ ←Githubのリポジトリ名がgithub.ioのサブディレクトリ名になるので、それを考慮してbase-hrefを設定する~~
- .github/workflows フォルダを作る
- [https://zenn.dev/nekomimi_daimao/articles/26fd2e3b763191](https://zenn.dev/nekomimi_daimao/articles/26fd2e3b763191)
の冒頭のyamlコードをgh-pages.ymlとして保存。以下を修正。
    - ブランチ名を main から master に変更
    - Flutterのバージョン ‘3.0.0’とある箇所を `flutter —version` で調べたバージョン(3.7.11)に変更
- 上記の中で使っているpeaceiris/actions-gh-pages@v3の中でgithubトークンを使っているので、[https://github.com/peaceiris/actions-gh-pages#tips-and-faq](https://github.com/peaceiris/actions-gh-pages#tips-and-faq) に従って下記の手順で登録
    - コマンドラインで ssh-keygen -t rsa -b 4096 -C "$(git config user.email)" -f gh-pages を実行。( passphraseを聞かれるが2回ともEnterでショートカット) 
    gh-pages         = private key
    gh-pages.pub  = public key
    - GithubのSettings > Deploy Key にAdd deploy keyでpublic keyを設定
    Name: public key of ACTIONS_DEPLOY_KEY
    Key: (public keyの中身)
    Allow Write Access を有効(チェックする)に
    - GithubのSettings > Secrets and variables > Actions にNew repository secretでprivate keyを設定
    Name: ACTIONS_DEPLOY_KEY
    Secret: (private keyの中身)
- 再度ジョブ実行 → gh-pagesブランチが作られる
このgh-pagesブランチは /build/web 以下だけの部分ツリーになっている
本来はここでGithub Pagesが作られると思うのだが、作られない
- なので、再度以下設定
GithubのSetting > Pages 
Source: Deploy from branch
Branch: gh-pages /root
と設定すると、前述のとは別のActionsが作られてしまうが、一応これでPagesが作成された
- ↓ 【追記】こちらを参考にすればよかった模様。
[https://github.com/kenmasumitsu/flutter_samegame/blob/main/.github/workflows/github-pages.yml](https://github.com/kenmasumitsu/flutter_samegame/blob/main/.github/workflows/github-pages.yml)
    
    > 簡単に動作できるWeb版とする。
    > 
    > 
    > github actionでビルドして、github pages にデプロイする。
    > 
    > 基本的に
    > 
    > [【3.0対応】Flutter webをGithub PagesにデプロイするGithub Actions](https://zenn.dev/nekomimi_daimao/articles/26fd2e3b763191) を従うが、古いデプロイ方法のようなので、[新しいデプロイ方法](https://zenn.dev/zozotech/articles/f2509a21b768ed) を使う。
    > 
    > できたaction用のスクリプトは [こちらのgithub-pages.yml](https://github.com/kenmasumitsu/flutter_samegame/blob/main/.github/workflows/github-pages.yml)
    > 
- デプロイはできたが、なぜか荷物を動かすと荷物の画像が消えてしまう問題が発生
プレイヤーは動いても消えない(常にアニメーションで書き換わっているため？)ので、荷物はOnLoad時に画像が設定された後に消えてしまうと予想。
CrateはSpriteAnimationComponentなのでそのアニメーションを変更してみるなどするがうまくいかない。
- [https://docs.flame-engine.org/latest/flame/platforms.html](https://docs.flame-engine.org/latest/flame/platforms.html)
今までデバッグモードでやっていたが、リリースでビルドしないといけないらしい
`flutter build web --release --web-renderer canvaskit`
- さらにいうと、同じページ([https://docs.flame-engine.org/latest/flame/platforms.html](https://docs.flame-engine.org/latest/flame/platforms.html))にGithub Pagesへのデプロイ方法が記載されていた。
.github\workflows\gh-pages.ymlをこれをもとに書き換え。
    
    ```yaml: gh-pages.yml
    name: Gh-Pages
    
    on:
      push:
        branches: [ master ]
    
    jobs:
      build:
        runs-on: ubuntu-latest
    
        steps:
          - uses: actions/checkout@v3
          - uses: subosito/flutter-action@v2
          - uses: bluefireteam/flutter-gh-pages@v8
            with:
              baseHref: /sokoban/
              webRenderer: canvaskit
    ```
    
- これでGithub Pagesでも完全に動くようになった。

# できたもの

https://eidas.github.io/sokoban/#/

https://github.com/eidas/sokoban