スマホ向けブラウザゲームをFlutterでネイティブアプリ化してみた 

# はじめに
こんにちは。medibaでモバイルアプリエンジニアをしている@m-itakura-medibaです。
本記事は [mediba Advent Calender 2023](https://qiita.com/advent-calendar/2023/mediba) の11日目の記事です。

私はモバイルアプリ開発以外では、前職以前にスマホ向けブラウザゲームの開発を行っていたことがあります。
せっかくブラウザ向けに作っていたゲームをネイティブアプリ化できないかということで、今回Flutterを使ってネイティブアプリ化する方法を調べて試してみました。

# どうやってネイティブアプリ化するか？
「ブラウザゲームをネイティブアプリ化する」というのは要するにWebViewでゲームを表示するということですが、大まかに分けて以下の2種類の手段があると思います。

- どこかのサーバーにホスティングして、ネイティブアプリ内のWebViewで表示する
- ネイティブアプリ内にローカルサーバーを立てて、WebViewで表示する

今回は後者の「ネイティブアプリ内にローカルサーバーを立てて、WebViewで表示する」でやってみたいと思います。
またAndroidエミュレータ/ iOSシミュレータで実行するまでを今回のゴールとします。

# 環境
Flutter 3.13.8

# 手順
## その１：スマホ向けブラウザゲームを用意
まずスマートフォンのブラウザで動作するゲームを用意してください。
今回は以下で公開されているサンプルゲームを利用してみることにしたいと思います。

https://samme.github.io/phaser-examples-mirror/arcade%20physics/platformer%20basics.html

上記のサンプルゲームはPCブラウザ向けの作りになっているため、キャンバスのサイズを変更したり、タップでジャンプするようにしたりと、スマートフォンブラウザで動作させやすいように変更した上で利用することにしました。


<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3620822/fe77d710-6164-e17d-6cac-03a869f00c27.gif" width=300px" />

なお、ゲームのファイルは以下のようなものになります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3620822/79c1bf32-c0e1-d3d4-ef75-b1ea033ba8f2.png" width="450px" />


:::note info
ゲームエンジン __Phaser__ について
このサンプルゲームは __Phaser CE__ というゲームエンジンが使われています。Phaserは主に2Dゲーム向けの軽量なゲームエンジンです。日本ではあんまりですが、海外では人気のあるゲームエンジンです。CE（v2系）とv3があり、v3の方が新しいバージョン（CEとv3に互換性はありません）ですが、私はCEのほうが使い慣れているため今回もCEを使用しています。
:::


## その２：Flutterプロジェクトを作成
以下のコマンドを実行してFlutterプロジェクトを作成します。

```
flutter create sample_game
```

## その３：必要なパッケージを追加
必要なパッケージを追加します。
といっても1つだけです！

`flutter_inappwebview`というパッケージを以下のコマンドでインストールします。

```
flutter pub add flutter_inappwebview
```

なお執筆時点では`5.8.0`がインストールされます。

:::note info
flutter_inappwebviewは多機能なWebViewプラグインで、ローカルサーバーを起動する機能も搭載されています。
:::


## その４：ブラウザゲームのコード一式をFlutterプロジェクト内に配置
Flutterプロジェクトに`assets`というフォルダを作成し、ブラウザゲームのファイル一式を格納します。以下のようになります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3620822/b7106256-5cc2-5531-21d7-9757a660fedd.png" width="450px" />

`assets`フォルダに格納したブラウザゲームのファイルをFlutterで読み込めるように、`pubspec.yaml`に`assets`を記載します。

```pubspec.yaml
flutter:
  uses-material-design: true

  # 以下を追記
  assets:
    - assets/
    - assets/images/
```

`assets/`を指定しただけだとその直下の`images`フォルダが読み込まれないため、`assets/images/`も追加することがポイントです。

なお、フォルダ階層が深くて全てを記載するのが大変な場合には`asset_fill`というパッケージを使うと良いかもしれません。（私は使ったことがないため、使ってみた方はぜひ教えてください！）

https://makipenblog.net/flutter-asset-registration/

## その５：ゲームを再生
以下2点を実装し、Flutter製ネイティブアプリ上でゲームを再生させます。

- ローカルサーバーを起動
- WebViewを追加し、ゲームをロード

2点とも`flutter_inappwebview`パッケージに含まれる機能で実装可能です。
基本的には以下のドキュメントを参考にして実装すれば大丈夫です。

https://inappwebview.dev/docs/5.x.x/in-app-localhost-server/

### ローカルサーバーを起動
`flutter_inappwebview`が持つ In-App Localhost Server という機能を利用します。
`InAppLocalhostServer`のインスタンスを生成し、

```main.dart
final InAppLocalhostServer localhostServer = InAppLocalhostServer();
```

`main`関数で起動させます。

```main.dart
// ローカルのサーバーを起動する。
await localhostServer.start();
```

なおインスタンス生成時にポート番号を指定できますが、省略している場合は`8080`が使用されます。

### WebViewを追加し、ゲームをロード
あとはゲームをロードするだけです。
`flutter_inappwebview`が提供する`InAppWebView`というWidgetを利用します。

以下のように`InAppWebView`でゲームが配置されているURLを指定してロードします。

```main.dart
InAppWebView(
  initialUrlRequest: URLRequest(
    url: Uri.parse('http://localhost:8080/assets/index.html'),
  ),
),
```

### 全体のコード
全体のコード例は以下のようになります。

```main.dart
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';

final InAppLocalhostServer localhostServer = InAppLocalhostServer();

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // ローカルのサーバーを起動する。
  await localhostServer.start();

  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const MyHomePage(),
    );
  }
}

class MyHomePage extends StatelessWidget {
  const MyHomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: InAppWebView(
          initialUrlRequest: URLRequest(
            url: Uri.parse('http://localhost:8080/assets/index.html'),
          ),
        ),
      ),
    );
  }
}
```


## その６：各OS向けの対応
### Android
Androidエミュレータで実行してみたところ、以下のように`ERR_CLEARTEXT_NOT_PERMITTED`エラーが発生しました。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3620822/5a675e97-5042-068a-1057-ac67c716c017.png" width="300px" />

Android 9（API レベル 28）からHTTPプロトコル通信が許可されなくなったことが原因のようでした。
そのため、HTTP通信を許可するために`AndroidManifest.xml`に`usesCleartextTraffic`を追加しました。

```AndroidManifest.xml
    <application
        android:label="sample_game"
        android:name="${applicationName}"
        android:icon="@mipmap/ic_launcher"
        android:usesCleartextTraffic="true"><!-- こちらを追加 -->
```

https://www.gesource.jp/weblog/?p=8653

### iOS
iOSシミュレータでは特に問題なく動作させることができました。

## 完成！
これで完成です！
Androidエミュレータで実行した様子は以下のような感じになります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3620822/5cfe85f4-e8c9-969d-1dcb-39aa7a41fe01.gif" width="300px" />

# Appendix: FlutterとJavaScriptの通信
`InAppWebView`はJavaScriptと通信することも、その逆を行うこともできます。

例えばゲーム終了時にインタースティシャル広告を表示させたい場合、以下のようなJavaScriptハンドラーを登録します。（ここではハンドラーの名前を`endGame`としています。）

```dart
InAppWebView(
  initialUrlRequest: URLRequest(
    url: Uri.parse('http://localhost:8080/assets/index.html'),
  ),
  onWebViewCreated: (controller) {
    // ゲーム終了
    controller.addJavaScriptHandler(
      handlerName: "endGame",
      callback: (args) async {
        // インタースティシャル広告の表示処理を書く。
      },
    );
  },
)
```

実際にゲームが終了した時、ゲームのJavaScriptから以下のようにハンドラーを呼び出します。

```js
window.flutter_inappwebview.callHandler('endGame', ...args);
```


# まとめ
いかがだったでしょうか。
簡単にブラウザゲームをネイティブアプリ化できる、と感じていただけた方もいらっしゃるのではないでしょうか。
今回はエミュレータでの起動まででしたが、実際にAndroid / iOSアプリとしてストアにリリースするとなると、他の対応が必要になるかもしれませんが、その点はご了承ください。


# 最後に
現在medibaでは、メンバーを大募集しています。
[募集・応募ページ](https://www.mediba.jp/recruit)
medibaってどんな会社だろう？ と興味を持っていただいた方は、
カジュアル面談もやっておりますので、お気軽にお申込み頂ければと思います。