UnityとFirebaseでDAppsゲームを作る

# はじめに
### 今回作ったもの
Symbolブロックチェーン上のウォレットとchrome拡張のSSS_Extensions(以下SSS)を利用したユーザー認証でプレイできるゲーム
ゲーム内容はハイアンドローみたいなもの

https://halad-dev.com/

SSSの追加はこちらから

https://chrome.google.com/webstore/detail/sss-extension/llildiojemakefgnhhkmiiffonembcan

## 概要
#### 環境
- Firebase (Auth, Functions)
- Unity 2021.3.4f1 (WebGL)
    - UniTask
- Symbol ブロックチェーン
#### DAppsゲームとは？
この定義は人によって分かれると思うが、この記事ではブロックチェーン用のウォレットを使ったユーザー認証を行うゲームと定義してます。

例えるならば
![SSS.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533288/93cca774-0765-7a1a-f255-32a63ec77d72.png)
こういう感じでウォレットが使える

#### SSS_Extentionsとは？
SSSはブロックチェーンシステムSymbolのトランザクションを受け取り署名済みトランザクションを返却するブラウザ拡張機能です。
似たブラウザ拡張で有名どころだとMetaMaskなどが該当すると思います。

今回の実装ではこちらのv1.2で追加されたgetActiveAccountTokenを利用します。

詳細はこちらに

https://zenn.dev/inatatsu/articles/51c1d8de76d335

## 実装
### UnityとSSSを繋ぐ
SSSはブラウザ拡張なのでUnityからやりとりできるようにjslibを実装し、Plugins配下に配置します。

```js:Plugins/sss.jslib
mergeInto(LibraryManager.library, {
    isExist: function()
    {
        return !!window.SSS;
    },
    getActiveAddress: function()
    {
        if(!window.SSS) {
            console.log("SSS is not instaled");
            return;
        }
        var returnStr = window.SSS.activeAddress;
        var buffer = _malloc(lengthBytesUTF8(returnStr) + 1);
        stringToUTF8(returnStr, buffer, returnStr.length + 1);
        return buffer;
    },
    getActiveNetworkType: function()
    {
        if(!window.SSS) {
            console.log("SSS is not instaled");
            return;
        }
        return window.SSS.activeNetworkType;
    },
    getActivePublicKey: function()
    {
        if(!window.SSS) {
            console.log("SSS is not instaled");
            return;
        }
        var returnStr = window.SSS.activePublicKey;
        var buffer = _malloc(lengthBytesUTF8(returnStr) + 1);
        stringToUTF8(returnStr, buffer, returnStr.length + 1);
        return buffer;
    },
    getActiveAccountToken: function(serverPublicKey, uid)
    {
        if(!window.SSS) {
            console.log("SSS is not instaled");
            return;
        }
        serverPublicKey = UTF8ToString(serverPublicKey);
        uid = UTF8ToString(uid);
        
        window.SSS.getActiveAccountToken(serverPublicKey, {uid})
            .then((payload) => {
                myGameInstance.SendMessage('SSSBridge', 'OnAccountToken', payload);
            })
            .catch((e) => {
                console.log(e);
            });
    }
});
```
index.htmlでUnityInstanceを変数に入れます（ビルド後に変更するか、WebGL用テンプレートを作成して変更する。
```html:index.html
<script>
  var container = document.querySelector("#unity-container");
  var canvas = document.querySelector("#unity-canvas");
  var loadingBar = document.querySelector("#unity-loading-bar");
  var progressBarFull = document.querySelector("#unity-progress-bar-full");
  var fullscreenButton = document.querySelector("#unity-fullscreen-button");
  var warningBanner = document.querySelector("#unity-warning");
  
  // ~~~~

  // 変数追加
  var myGameInstance = null;
  var script = document.createElement("script");
  script.src = loaderUrl;
  script.onload = () => {
    createUnityInstance(canvas, config, (progress) => {
      progressBarFull.style.width = 100 * progress + "%";
    }).then((unityInstance) => {
      // 変数に代入
      myGameInstance = unityInstance;

      loadingBar.style.display = "none";
      fullscreenButton.onclick = () => {
        unityInstance.SetFullscreen(1);
      };
      window.addEventListener("resize", () => {
        resizeCanvas()
      });
    }).catch((message) => {
      alert(message);
    });
  };
  document.body.appendChild(script);
</script>
```

jslibと通信するc#コード
```c#:SSSBridge.cs
using System.Runtime.InteropServices;
using Cysharp.Threading.Tasks;

namespace WebGL.SSS
{
    public class SSSBridge : SingletonMonoBehaviour<SSSBridge>
	{
        private const string SERVER_KEY = "①サーバー側ウォレットの公開鍵";

        [DllImport("__Internal")]
        private static extern bool isExist();

        [DllImport("__Internal")]
        private static extern string getActiveAddress();

        [DllImport("__Internal")]
        private static extern int getActiveNetworkType();

        [DllImport("__Internal")]
        private static extern string getActivePublicKey();

        [DllImport("__Internal")]
        private static extern void getActiveAccountToken(string serverPublicKey, string uid);

        private UniTaskCompletionSource<string> _response;

        public bool IsExist() => isExist();

        public string GetActivePublicKey() => getActivePublicKey();

        public string GetActiveAddress() => getActiveAddress();

        public int GetActiveNetworkType() => getActiveNetworkType();

        public async UniTask<string> GetActiveAccountTokenAsync(string uid)
        {
            _response = new UniTaskCompletionSource<string>();

            getActiveAccountToken(SERVER_KEY, uid);
            var token = await _response.Task;
            _response = null;

            return token;
        }

        private void OnDestroy()
        {
            _response?.TrySetCanceled();
            _response = null;
        }

        #region from JavaScript

        public void OnAccountToken(string token)
        {
            if (_response == null) return;
            _response.TrySetResult(token);
        }

        #endregion

    }
}
```
上記処理をボタン押下時に呼ばれるようにすれば接続できます
```c#
private async UniTask OnAuthButton()
{
    if (!SSSBridge.Instance.IsExist())
    {
        Debug.Log("SSS ExtensionがないかLinkに追加されてません。");
        return;
    }
    // （なくてもいい
    var uid = Guid.NewGuid().ToString("N");

    Debug.Log("認証中");
    var token = await SSSBridge.Instance.GetActiveAccountTokenAsync(uid);
    Debug.Log("接続成功");
}
```
※SSSはその仕様上初回アクセス時は接続出来ません。「Link To SSS」をしてもらい、更新してもらうことで接続出来ます。 ~~ちょっと不便~~ 

https://docs.unity3d.com/ja/2022.1/Manual/webgl-interactingwithbrowserscripting.html

### Apiを実装する
getActiveAccountTokenで取得したtokenを送るApiをFirebase Functionsに実装します。
Firebase Functionsでは送られてきたtokenをチェックし、問題なければカスタム トークンを作成して返します。
```typescript
'use strict';
import * as functions from "firebase-functions";

import { Account, EncryptedMessage, NetworkType, PublicAccount } from 'symbol-sdk';
import { applicationDefault, initializeApp } from 'firebase-admin/app';
import { auth } from "firebase-admin";

const cors = require("cors")({ origin: true });

initializeApp({
  credential: applicationDefault(),
  databaseURL: 'https://{プロジェクトID}.firebaseio.com'
});

import { createUser, getRank, updatePoint } from "./firestore";

// ①の公開鍵に対応する秘密鍵でアカウントを作る
// ※環境変数よりSecret Managerのほうがよい
// https://qiita.com/korih/items/3366b2a60ff14f81ac3c
const serverAccount = Account.createFromPrivateKey(functions.config().symbol.private, NetworkType.TEST_NET);

exports.auth = functions
  .region("asia-northeast1")
  .runWith({
    timeoutSeconds: 10
  })
  .https.onRequest(async (req, res) => {
    const result = {
      status: 200,
      data: ""
    }

    try {
      const publicKey = req.body.data?.publicKey;
      const token = req.body.data?.accountToken;

      // DH鍵共有によるトークンチェック
      const clientAccount = PublicAccount.createFromPublicKey(publicKey, NetworkType.TEST_NET);
      const enMsg = new EncryptedMessage(token, clientAccount);
      const msg = serverAccount.decryptMessage(enMsg, clientAccount);

      const jsonMsg = JSON.parse(msg.payload) as {
        uid: string,
        signerAddress: string,
        iat: number,
        verifierAddress: string,
        netWork: number
      };

      // アドレスをidとしてcustomTokenを生成
      const customToken = await auth().createCustomToken(jsonMsg.signerAddress, { address: jsonMsg.signerAddress });
      
      // ~~ Firestoreでのユーザー作成など ~~

      result.data = customToken;
    } catch (e) {
      console.error(e);
      result.status = 400;
      result.data = 'Bad request';
    }
    // Unityのローカルビルドからもアクセスできる様にcors
    cors(req, res, () => {
      return res.status(result.status).send(result);
    });
  });
```
なお、デフォルトではカスタムトークンの作成権限がないので権限を付与する必要がある

https://www.memory-lovers.blog/entry/2020/12/01/100000

サービスアカウントを指定しない場合、functionsはApp Engine default service accountを使用している

https://zenn.dev/tkow/articles/ab93f2ffa8694e

### UnityとApiを繋ぐ
ブラウザアプリのsdkを使用してfirebaseと連携する

https://firebase.google.com/docs/web/learn-more#available-libraries

設定ファイルはここを参考に

https://support.google.com/firebase/answer/7015592?hl=ja#web&zippy=%2C%E3%81%93%E3%81%AE%E8%A8%98%E4%BA%8B%E3%81%AE%E5%86%85%E5%AE%B9

```html:index.html
<!-- ~~~~ -->
<script src="https://www.gstatic.com/firebasejs/8.10.1/firebase-app.js"></script>
<script src="https://www.gstatic.com/firebasejs/8.10.1/firebase-auth.js"></script>
<script src="https://www.gstatic.com/firebasejs/8.10.1/firebase-functions.js"></script>

<script>    
  // 自分のfirebaseの設定を入れる
  const firebaseConfig = {
    apiKey: "API_KEY",
    authDomain: "PROJECT_ID.firebaseapp.com",
    projectId: "PROJECT_ID",
    storageBucket: "PROJECT_ID.appspot.com",
    messagingSenderId: "SENDER_ID",
    appId: "APP_ID",
    measurementId: "G-MEASUREMENT_ID",
  };

  firebase.initializeApp(firebaseConfig);
</script>
<script>
  var container = document.querySelector("#unity-container");
  var canvas = document.querySelector("#unity-canvas");
  var loadingBar = document.querySelector("#unity-loading-bar");
  var progressBarFull = document.querySelector("#unity-progress-bar-full");
  var fullscreenButton = document.querySelector("#unity-fullscreen-button");
  var warningBanner = document.querySelector("#unity-warning");

  // ~~~~
```

```js:Plugins/firebaseFunctions.jslib
mergeInto(LibraryManager.library, {
    getCustomToken: function(objectName, methodName, publicKey, accountToken, netType) 
    {
        publicKey = UTF8ToString(publicKey);
        accountToken = UTF8ToString(accountToken);
        objectName = UTF8ToString(objectName);
        methodName = UTF8ToString(methodName);

        const functions = firebase.app().functions('asia-northeast1');
        const auth = functions.httpsCallable('auth')
        auth({ publicKey, accountToken, netType })
          .then((result) => {
            var sanitizedMessage = result.data;
            myGameInstance.SendMessage(objectName, methodName, sanitizedMessage);
          })
          .catch((error) => {
            console.log(error);
            myGameInstance.SendMessage(objectName, methodName, "");
          });
    }
});
```

```c#:FunctionsBridge.cs
using UnityEngine;
using System.Runtime.InteropServices;
using Cysharp.Threading.Tasks;
using Firebase.Object;

namespace WebGL.Firebase
{
	public class FunctionsBridge : MonoBehaviour
	{
		[DllImport("__Internal")]
		private static extern void getCustomToken(string objectName, string methodName, string publickKey, string accountToken, int netType);

		private UniTaskCompletionSource<string> _utcs;

		public async UniTask<string> GetCustomTokenAsync(string publicKey, string accountToken, int netType)
        {
	        _utcs = new UniTaskCompletionSource<string>();
			getCustomToken(gameObject.name, "OnCustomToken", publicKey, accountToken, netType);
			var token = await _utcs.Task;
			_utcs = null;
			return token;
        }

		public void OnCustomToken(string token)
		{
			_utcs?.TrySetResult(token);
		}

        private void OnDestroy()
        {
			_utcs?.TrySetCanceled();
			_utcs = null;
        }
    }
}
```
カスタムトークンでログインする
```js:firebaseAuth.jslib
mergeInto(LibraryManager.library, {
    signInWithCustomToken: function(objectName, methodName, token)
    {
        token = UTF8ToString(token);
        objectName = UTF8ToString(objectName);
        methodName = UTF8ToString(methodName);

        firebase.auth().signInWithCustomToken(token)
            .then((userCredential) => {
                myGameInstance.SendMessage(objectName, methodName, 1);
            })
            .catch((error) => {
                myGameInstance.SendMessage(objectName, methodName, 0);
            });
    }
});
```
```c#:AuthBridge
using UnityEngine;
using System.Runtime.InteropServices;
using Cysharp.Threading.Tasks;

namespace WebGL.Firebase
{
	public class AuthBridge : MonoBehaviour
	{
		[DllImport("__Internal")]
		private static extern void signInWithCustomToken(string objectName, string methodName, string token);

		private UniTaskCompletionSource<bool> _utcs;

		public async UniTask<bool> SignInWithCustomTokenAsync(string token)
		{
			_utcs = new UniTaskCompletionSource<bool>();

			signInWithCustomToken(gameObject.name, "OnSignIn", token);
			var result = await _utcs.Task;
			_utcs = null;
			
			return result;
		}

		public void OnSignIn(int result)
        {
			_utcs?.TrySetResult(result > 0);
		}

		private void OnDestroy()
		{
			_utcs?.TrySetCanceled();
			_utcs = null;
		}
	}
}
```

### つなげる
流れとしては
1. SSSからトークンを取得
2. トークンをauth functionに送り、カスタムトークンを受け取る
3. カスタムトークンでログインする

```c#:WebGLFirebaseManager
using UnityEngine;
using Cysharp.Threading.Tasks;
using Firebase.Object;

namespace WebGL.Firebase
{
	public class WebGLFirebaseManager : SingletonMonoBehaviour<WebGLFirebaseManager>
	{
		[SerializeField]
		private AuthBridge _authBridge;

		[SerializeField]
		private FunctionsBridge _functionsBridge;

		public string CustomToken { get; private set; }
		
		public async UniTask<bool> SignInAsync(string publicKey, string acountToken, int netType)
        {
	        // auth functionsからカスタムトークン取得
	        CustomToken = await _functionsBridge.GetCustomTokenAsync(publicKey, acountToken, netType);
			if (string.IsNullOrEmpty(CustomToken)) return false;
	        // カスタムトークンでログイン
			return await _authBridge.SignInWithCustomTokenAsync(CustomToken);
		}
	}
}
```

```c#
private async UniTask OnAuthButton()
{
    if (!SSSBridge.Instance.IsExist())
    {
        Debug.Log("SSS ExtensionがないかLinkに追加されてません。");
        return;
    }
    // （なくてもいい
    var uid = Guid.NewGuid().ToString("N");

    Debug.Log("認証中");
    var token = await SSSBridge.Instance.GetActiveAccountTokenAsync(uid);
    Debug.Log("接続中");
    var result = await WebGLFirebaseManager.Instance.SignInAsync(SSSBridge.Instance.GetActivePublicKey(), token, SSSBridge.Instance.GetActiveNetworkType());
    Debug.Log("接続成功");
}
```

### 最終コード(TODO
```html:index.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
    <title>SSS_Connection</title>
    <link rel="shortcut icon" href="TemplateData/favicon.ico">
    <link rel="stylesheet" href="TemplateData/style.css">
  </head>
  <body>
    <div id="unity-container" class="unity-desktop">
      <canvas id="unity-canvas" width=960 height=540></canvas>
      <div id="unity-loading-bar">
        <div id="unity-logo"></div>
        <div id="unity-progress-bar-empty">
          <div id="unity-progress-bar-full"></div>
        </div>
      </div>
      <div id="unity-warning"> </div>
      <div id="unity-footer">
        <div id="unity-webgl-logo"></div>
        <div id="unity-fullscreen-button"></div>
        <div id="unity-build-title">SSS_Connection</div>
      </div>
    </div>

    <script src="https://www.gstatic.com/firebasejs/8.10.1/firebase-app.js"></script>
    <script src="https://www.gstatic.com/firebasejs/8.10.1/firebase-auth.js"></script>
    <script src="https://www.gstatic.com/firebasejs/8.10.1/firebase-functions.js"></script>

    <script>    
      // 自分のfirebaseの設定を入れる
	  const firebaseConfig = {
	    apiKey: "API_KEY",
	    authDomain: "PROJECT_ID.firebaseapp.com",
	    projectId: "PROJECT_ID",
	    storageBucket: "PROJECT_ID.appspot.com",
	    messagingSenderId: "SENDER_ID",
	    appId: "APP_ID",
	    measurementId: "G-MEASUREMENT_ID",
	  };

      firebase.initializeApp(firebaseConfig);
    </script>
    <script>
      var container = document.querySelector("#unity-container");
      var canvas = document.querySelector("#unity-canvas");
      var loadingBar = document.querySelector("#unity-loading-bar");
      var progressBarFull = document.querySelector("#unity-progress-bar-full");
      var fullscreenButton = document.querySelector("#unity-fullscreen-button");
      var warningBanner = document.querySelector("#unity-warning");

      // Shows a temporary message banner/ribbon for a few seconds, or
      // a permanent error message on top of the canvas if type=='error'.
      // If type=='warning', a yellow highlight color is used.
      // Modify or remove this function to customize the visually presented
      // way that non-critical warnings and error messages are presented to the
      // user.
      function unityShowBanner(msg, type) {
        function updateBannerVisibility() {
          warningBanner.style.display = warningBanner.children.length ? 'block' : 'none';
        }
        var div = document.createElement('div');
        div.innerHTML = msg;
        warningBanner.appendChild(div);
        if (type == 'error') div.style = 'background: red; padding: 10px;';
        else {
          if (type == 'warning') div.style = 'background: yellow; padding: 10px;';
          setTimeout(function() {
            warningBanner.removeChild(div);
            updateBannerVisibility();
          }, 5000);
        }
        updateBannerVisibility();
      }

      function resizeCanvas() {
        let n = window.innerWidth / 1920
          , e = 1600 * n < 900 ? 900 : 1600 * n
          , r = .5625 * e;
        canvas.style.width = `${e}px`,
        canvas.style.height = `${r}px`
      }

      var buildUrl = "Build";
      var loaderUrl = buildUrl + "/Builds.loader.js";
      var config = {
        dataUrl: buildUrl + "/Builds.data",
        frameworkUrl: buildUrl + "/Builds.framework.js",
        codeUrl: buildUrl + "/Builds.wasm",
        streamingAssetsUrl: "StreamingAssets",
        companyName: "DefaultCompany",
        productName: "SSS_Connection",
        productVersion: "0.1.0",
        showBanner: unityShowBanner,
      };

      // By default Unity keeps WebGL canvas render target size matched with
      // the DOM size of the canvas element (scaled by window.devicePixelRatio)
      // Set this to false if you want to decouple this synchronization from
      // happening inside the engine, and you would instead like to size up
      // the canvas DOM size and WebGL render target sizes yourself.
      // config.matchWebGLToCanvasSize = false;
      let n = window.innerWidth / 1920
        , e = 1600 * n < 900 ? 900 : 1600 * n
        , r = .5625 * e;
      
      if (/iPhone|iPad|iPod|Android/i.test(navigator.userAgent)) {
        // Mobile device style: fill the whole browser client area with the game canvas:

        var meta = document.createElement('meta');
        meta.name = 'viewport';
        meta.content = 'width=device-width, height=device-height, initial-scale=1.0, user-scalable=no, shrink-to-fit=yes';
        document.getElementsByTagName('head')[0].appendChild(meta);
        container.className = "unity-mobile";

        // To lower canvas resolution on mobile devices to gain some
        // performance, uncomment the following line:
        // config.devicePixelRatio = 1;

        canvas.style.width = `${e}px`;
        canvas.style.height = `${r}px`;

        unityShowBanner('WebGL builds are not supported on mobile devices.');
      } else {
        // Desktop style: Render the game canvas in a window that can be maximized to fullscreen:

        canvas.style.width = `${e}px`;
        canvas.style.height = `${r}px`;
      }

      loadingBar.style.display = "block";

      var myGameInstance = null;
      var script = document.createElement("script");
      script.src = loaderUrl;
      script.onload = () => {
        createUnityInstance(canvas, config, (progress) => {
          progressBarFull.style.width = 100 * progress + "%";
        }).then((unityInstance) => {
          myGameInstance = unityInstance;
          loadingBar.style.display = "none";
          fullscreenButton.onclick = () => {
            unityInstance.SetFullscreen(1);
          };
          window.addEventListener("resize", () => {
            resizeCanvas()
          });
        }).catch((message) => {
          alert(message);
        });
      };
      document.body.appendChild(script);
    </script>
  </body>
</html>
```

それ以外は多分そのうちgitに上げます

## 終わりに
Symbolは楽に使えていいぞ

今回Firebaseでの認証を使ったのは単純にpwの入力頻度を最小限にするため。
SSS_Extentionsを介して通信すると毎回ユーザーにpwを入力しないと秘密鍵を扱えないのでその対策です。

サーバーに送るだけなら公開鍵暗号方式を利用した通信が簡単に実装できる（sslの様な通信も実装できる。
今回はFirebaseの練習も兼ねてFirebaseを利用しました。
~~Firebase使うと結局Google様に依存するからweb3のようで違う？~~
## 参考
https://twitter.com/_inatatsu_csg_/status/1529832788446572544?s=20&t=4z_exluDo7lFeM_soGcgUQ

https://symnity.dev/

https://github.com/rotolonico/FirebaseWebGL