Unity と Vue3 と GitHub Pages と GitHub Actions のお話

# 初めに

こんにちは、Bugfire です。
[クラウドワークス Advent Calendar 2020](https://qiita.com/advent-calendar/2020/crowdworks) の12日目になりました。

タイトルはてんこ盛りですが、内容は薄いです！

## 結果だけ知りたい人に

- [CubeWorld](https://github.com/federicodangelo/CubeWorld) をベースに改造しました
- 生成物(アプリ)はこちら https://bugfire.dev/CubeWorld/ (現在のアプリサイズは6M強です)
- ソースはこちら https://github.com/Bugfire/CubeWorld/tree/develop



## なぜこんなふわっとした物に

当初 [Project Tiny](https://forum.unity.com/forums/project-tiny.151/) (Unity のインスタントアプリ用のフレームワーク) で小さくなった Unity をいっちょ試してみるか！と思っていたのですが、自分が試した時点ではサンプルすらビルドできないので諦めました。

すごい勢いで変化しているので、古いもの試してもしょうがないですし(言い訳)

というわけで、使い古されたネタの Unity WebGL ビルド + Vue.js を試してみます。Vue3 の Composition API を使ったことがないので、それもついでに試しつつ、GitHub Pages w/ GitHub Acitons でリリースをします。

# Unity

## 環境

Unity のバージョンは 2019.4.15f1 LTS を使いました。弱気ですね。
安定バージョンだけあって、WebGL のビルドはすんなりいきます。

## プロジェクトファイル

中身作るの大変なので、[CubeWorld](https://github.com/federicodangelo/CubeWorld) をベースに作業を行なってみます。
というか、すごいですねこれ。MineCraft クローンですが、よくできています。

<img width="320" alt="CubeWorld ScreenShot" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180344/619cddbb-5d6d-67a6-9979-65c73e223e26.png">

## UnityLoader.js の変更

このバージョンでは、mac OS Big Sur (Version 11)の UserAgent に対応していないので、少し手を加えましょう。

UnityLoader.js は難読化が行われているので、扱いくいです。読みやすく変更しましょう。
自分は js-beautify を使いました。

```
npx js-beautify -s 2 < original/UnityLoader.js > new/UnityLoader.js
```

```diff
@@ -1996,7 +1996,7 @@
     var p = n;
     switch (/Windows/.test(u) && (p = /Windows (.*)/.exec(u)[1], u = "Windows"), u) {
       case "Mac OS X":
-        p = /Mac OS X (10[\.\_\d]+)/.exec(i)[1];
+        p = /Mac OS X (1\d[\.\_\d]+)/.exec(i)[1];
         break;
       case "Android":
         p = /Android ([\.\_\d]+)/.exec(i)[1];

```

こんなしょうもない差分です。

もう一つあります、スマートフォンの場合は、

```js
  compatibilityCheck: function(e, t, r) {
    UnityLoader.SystemInfo.hasWebGL ? UnityLoader.SystemInfo.mobile ? e.popup("Please note that Unity WebGL is not currently supported on mobiles. Press OK if you wish to continue anyway.", [{
      text: "OK",
      callback: t
    }]) : ["Edge", "Firefox", "Chrome", "Safari"].indexOf(UnityLoader.SystemInfo.browser) == -1 ? e.popup("Please note that your browser is not currently supported for this Unity WebGL content. Press OK if you wish to continue anyway.", [{
      text: "OK",
      callback: t
    }]) : t() : e.popup("Your browser does not support WebGL", [{
      text: "OK",
      callback: r
    }])
  },
```

ここでダイアログが表示されてしまうので、コードから削除を行うか

```
UnityLoader.SystemInfo.mobile = false;
```

を行うことで無理矢理チェックを回避します。

## ビルド環境

私は [UnityCloudBuild](https://unity3d.com/jp/unity/features/cloud-build) (以下UCB) に課金していないので、手動でビルドして、生成物をレポジトリに Commit していますが、通常であれば、UCB でビルドを行うのが良いでしょう。

その場合は C# で Build PostProcess で Upload なりすると良いと思います。

# Vue.js

## 環境構築

```shell-session
$ npm init -y
$ npm install -L -S -D @vue/cli
$ npx vue create app
```

- `❯ Manually select features` で細かく指定
- `Choose Vue version`, `TypeScript`, `Router`, `Linter` をアリに
- `❯ 3.x (Preview)`
- `? Use class-style component syntax? (y/N)` は `NO`

あとは適当に

vue-unity-webgl のような便利コンポーネントもありますが、ここは自分でコンポーネントを作ります。

## UnityLoader.js のローダー

まず最初にできなかったことから...。最初 UnityLoader.js をモジュールとして実行しようと思いましたが、global スコープを前提としているところがあり、諦めました。

```typescript
export interface UnityInstance {
  SetFullscreen(mode: number): void;
  SendMessage(gameObject: string, method: string, param: string): void;
}

export interface UnityLoader {
  instantiate(
    container: string,
    configUrl: string,
    options: { onProgress: (instance: UnityInstance, progress: number) => void }
  ): UnityInstance;
}

declare let UnityLoader: UnityLoader | undefined;

export function GetUnityLoader(url: string): Promise<UnityLoader> {
  return new Promise((resolve, reject) => {
    if (typeof UnityLoader !== "undefined") {
      return resolve(UnityLoader);
    }
    const s = document.createElement("script");
    s.type = "text/javascript";
    s.src = url;
    s.async = true;
    s.defer = true;
    s.onload = () => {
      if (typeof UnityLoader !== "undefined") {
        return resolve(UnityLoader);
      } else {
        return reject(`Load error on UnityLoader (${url})`);
      }
    };
    s.onerror = () => reject(`Load error on UnityLoader (${url})`);
    document.head.appendChild(s);
  });
}
```

script 要素を作って無理矢理呼び出します。形として getter はありますが、グローバルに読み込まれます。

## Vue コンポーネント

Composition API を使ってみました。Composition API の説明自体は各所にあるので略。

```vue
<template>
  <div :id="unityContainerId"></div>
</template>

<script lang="ts">
import { defineComponent, onMounted, reactive, PropType } from "vue";
import { GetUnityLoader, UnityInstance } from "./UnityLoaderUtil";

export default defineComponent({
  name: "Unity",
  props: {
    loaderUrl: {
      type: String,
      required: true
    },
    configUrl: {
      type: String,
      required: true
    },
    onLoad: {
      type: Function as PropType<null | ((instance: UnityInstance) => void)>,
      required: false,
      default: null
    },
    onProgressChanged: {
      type: Function as PropType<null | ((progress: number) => void)>,
      required: false,
      default: null
    }
  },
  setup(props) {
    const unityContainerId = `unityContainer-${Math.random()
      .toFixed(10)
      .toString()
      .substr(2)}`;
    const state = reactive<{ hasError: boolean }>({
      hasError: false
    });

    onMounted(async () => {
      try {
        const unityLoader = await GetUnityLoader(props.loaderUrl);
        unityLoader.instantiate(unityContainerId, props.configUrl, {
          onProgress: (instance, progress) => {
            if (props.onProgressChanged !== null) {
              props.onProgressChanged(progress);
            }
            if (progress >= 1 && props.onLoad !== null) {
              props.onLoad(instance);
            }
          }
        });
      } catch (e) {
        state.hasError = true;
      }
    });

    return { unityContainerId };
  }
});
</script>
```

emit で型の付け方がわからなかったので、コールバック関数を Props に渡すようにしました。自分に型は必要です。(そのあたり論争があるようですね)

# Unity と Vue.js 間での通信

まったく Unity と Vue.js の間で通信をしないのであれば良いですが、普通はそうはいきませんね。

Vue.js から Unity への通信は簡単です。初期化時、もしくは Progress イベントで伝わる UnityInstance の SendMessage メソッドで、GameObject のメソッドを呼び出すことができます。文字列の引数が渡せるので、必要があれば JSON などを使うのも良いです。

逆方向はちょっとだけ面倒です。

- JS 側は Unity 側のプロジェクトで .jslib ファイルの JS で記述された mergeInto() で export する
- Unity 側は System.Runtime.InteropServices.DllImport で上の関数の import する。
- [Unity 公式サイトのドキュメント](https://docs.unity3d.com/Manual/webgl-interactingwithbrowserscripting.html) に詳しく書いてあります。

自分は Unity(jslib) 側の責務を最小にするため、

```javascript
mergeInto(LibraryManager.library, {
  GameToWebNative: function(tag, message) {
    if (typeof window.UnityGameToWebHandler === 'function') {
      window.UnityGameToWebHandler(Pointer_stringify(tag), Pointer_stringify(message));
    }
  }
});
```

のように行いました。window にUnity をロードする前から変なのをはやしておくことで、実態を Vue 側の実装に委譲しています。
一般的には postMessage を用いるのが良いかと思います。

## 余談

当初 UI は HTML/CSS で、ゲーム内容は Unity/WebGL とし、オーバレイ表示することで、ブラウザの機能を生かして最高の体験を!!!、と作業を進めましたが、面倒すぎました。

特に Unity/WebGL のビルドは死ぬほど遅いのと、環境が分散するため、とても作業し辛いです。
業務であれば、開発中は UnityEditor 側に mock を作って、環境を Unity に閉じることはできそうです。片手間ではちょっと...。

なので、Vue らしさは全然ないです。中途半端な記事で申し訳ありません。

# デプロイ

ついでに GitHub Actions から GitHub Pages にデプロイしましょう。

```yaml
name: github pages

on:
  push:
    branches:
      - develop

jobs:
  deploy:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    env:
      TARGET_PATH: CubeWorld/Web
      CACHE_VERSION: v1
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v2.1.2
        with:
          node-version: 12.x
          check-latest: true
      - uses: actions/cache@v2
        with:
          path: ${{ env.TARGET_PATH }}/node_modules
          key: ${{ env.CACHE_VERSION }}-${{ runner.os }}-v12-${{ hashFiles(format('{0}/package-lock.json', env.TARGET_PATH)) }}
          restore-keys: |
            ${{ env.CACHE_VERSION }}-${{ runner.os }}-v12-
      - name: Install
        run: |
          cd ${{ env.TARGET_PATH }}
          npm install
      - name: Build
        run: |
          cd ${{ env.TARGET_PATH }}
          npm run build
      - name: Deploy
        uses: peaceiris/actions-gh-pages@v3
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: ${{ env.TARGET_PATH }}/dist
```

取り立てて書くことはないですね。`peaceiris/actions-gh-pages` は便利。他レポジトリへの deploy もできるので、公開用レポジトリと非公開レポジトリに分けるのも良いでしょう。

# Unity Project での変更

ぶっちゃけ、ここが一番時間かかりました。疲れました。

## Unity WebGL での問題点

**マルチタッチがバグっています**

マルチタッチでのコントロールがうまくいかなくて TouchScript を試していたりしましたが、そもそも `Input.GetTouch()` で取りこぼしがあります。(Unity 2019.4.15f1時点)
(TouchScript の issue でも取り沙汰されていますが、TouchScript の問題点ではありません)

https://github.com/Bugfire/CubeWorld/blob/develop/CubeWorld/Assets/QubeWorld/Shared/Components/WebGLInputWorkaround.cs

かなり無理矢理な対処をしたので、興味がある方はここのソースを読んでみてください。

## Cube World の変更

もともとのプロジェクトは、

- PC 用でキーボード中心の操作系
- IMGUI ベースのメニュー

でした。そりゃ、８年前のプロジェクトですからね！

### 操作系の変更

- タッチ中心の操作系に変更
- 画面中心のみターゲットから、タッチした場所をターゲットに変更

### メニューの変更

- ある程度を uGUI ベースに変更しました。
   - アイテムウィンドウ関連がまだ残っています (組み合わせでバグっています)

## 追加機能

- Web/Unity 間通信のサンプルとして、チャットボタンを作りました
  - 押下すると Web 側でテキスト入力を行い、Native 側に結果を投げ、表示します。
- 追記
  - [Photon REALTIME](https://www.photonengine.com/ja/photon) の無料プランを使って簡易的なチャットを実装しました。便利、かんたん。

## 動いていないところ

- Network/Multiplay
   - 動きません(実装していません)

## Cube World の感想

本当によくできています。
エンジン部分が Unity に非依存に作ってあるので、Unity に限らずお好きな C# ランタイムでサーバ検証ができそうです。

逆に Physics や raycast は Unity の便利な機能が使えませんが、エフェクトやキャラクタの表示のみに使用を限るのも良いというか、よくあるMMOの実装ではそうするでしょう。

おそらく高速化のために、ある程度大きなの粒度で動的に mesh を作成しています。
(聞いた話、本家もライティングの結果をランタイムで mesh にベイクしているみたいですね)

時間があればもう少し改造してみたいです。

# 参考

以下のサイトを使用してリソースを作成しました。ありがたや。

- タイトルロゴ https://textcraft.net/
- favicon https://favicon-generator.mintsu-dev.com/

# 終わりに

しくじり一覧

- Project Tiny 使わなかった
- UnityLoader.js をモジュール化できなかった
- UI を Vue.js に寄せることができなかった
- 時間のほとんどを Unity のプロジェクト変更に費やしてしまった
- テキスト入力を Web 側の form で行おうとしたら、iOS でフォーカスがうまく戻ってこなくて諦めた

Unity も Vue.js も GitHub Actions もみんな楽しいですね！
Unity のプロジェクトの CubeWorld を UI の世代更新や、スマートフォン対応にするのに一番時間がかかりました。久しぶりの Unity 楽しかったです。

[We're hiring！](https://www.wantedly.com/projects/73940)