エンジニア

UnityのWebGLでIndexedDBを扱う

投稿日:2020年11月5日 更新日:

はじめに

こんにちは。名古屋スタジオでアプリエンジニアをしている山本と申します。
今回はUnityのWebGLでIndexedDBを扱う方法をご紹介します。

開発環境

  • Windows10
    • Unity 2019.3.13f1
    • Google Chrome 86.0.4240.111 (Official Build) (64 ビット)

動作確認済み環境

  • Windows10
    • Google Chrome 86.0.4240.111 (Official Build) (64 ビット)
    • Microsoft Edge 86.0.622.56 (公式ビルド) (64 ビット)
  • mac OS Catalina 10.15.4
    • Google Chrome 86.0.4240.111 (Official Build) (x86_64)

セットアップ

プラットフォームの変更

File>Build Settings
からWebGLにPlatformを変更しておいてください。

フォントの導入

Window>Package Manager
からTextMeshProをインポートします。

アセットの導入

  1. オススメの日本語フォント無料アセット
    https://assetstore.unity.com/packages/2d/fonts/selected-u3d-japanese-font-337?aid=1011lGbg&utm_source=aff

    こちらのフォントにはText Meshのデータが含まれているためUnity初心者にもおすすめです。

  2. WebGLで日本語の入力を可能にするアセット
    https://github.com/unity3d-jp/WebGLNativeInputField

    こちらはWebGLでの日本語入力を可能にします。TextMeshには対応していないため、以下に対応方法の解説をします。

WebGLでTextMeshに入力するフィールドを作成する

上記アセットを導入後、Asset内のWebGLNativeInputFiled.csを複製します。
複製したファイルをWebGLNativeTextMeshInputFiled.csにリネームします。

以下のように2箇所変更します。

using UnityEngine;
-using UnityEngine.UI;
 using UnityEngine.EventSystems;
 using System.Collections;
+using TMPro;

-public class WebGLNativeInputField : UnityEngine.UI.InputField
+sealed public class WebGLNativeTextMeshInputFiled : TMP_InputField
 {
     public enum EDialogType
     {

HierarchyからInputFiled(TextMeshPro)を追加します。

追加したらInputFiledオブジェクトのTextMeshPro(InputFiled)を削除して、先ほど作成したWebGLNativeTextMeshInputFieldをアタッチしてください。

以下の4項目が空になっているので設定します。

  • Text Viewport
  • Text Component
  • Font Asset
  • Placeholder

また、InputFiled内のTextのフォントも設定しておきます。
こちらをPrefab化し、ここまでで作成したものをパッケージ化しておくと、今後新しくWebGLのアプリを作成する際に作業の短縮が可能になります。

IndexedDBを動かす

プラグインの作成

まず、プラグインを作成します。

WebGLIndexedDBのフォルダとPluginsフォルダを作成し、IndexedDBPlugin.jslibを作成しましょう。
(Unity側では作成できないので、テキストエディタなどで作成し、フォルダの中に入れましょう)

以下がそのコードです。

var IndexedDBPlugin = {

    // unityの関数ポインタを格納するオブジェクト
    $funcs: {},

    // IndexDBのパラメータを保持するオブジェクト
    $Params: { db : null, transaction: null},

    // IE9 以降のTextEncoder.encode(str) の代替ポリフィル
    $CustomTextEncoder: function(str) {

        var buf       = new ArrayBuffer(str.length);
        var bufView8  = new Uint8Array(buf);

        for (var i=0, strLen=str.length; i < strLen; i++) {
            bufView8[i] = str.charCodeAt(i);
        }

        return bufView8;
    },

    // トランザクションの生成
    $CreateTransaction: function(StoreName){

        var dbStoreName = Pointer_stringify(StoreName);

        // 登録用トランザクション
        Params.transaction = Params.db.transaction([dbStoreName], "readwrite");

        // すべてのデータがデータベースに追加されたときに行う処理
        Params.transaction.oncomplete = function(event) {
            console.log('commit transaction');
        };

        Params.transaction.onerror = function(event) {
            console.log('rollback transaction');
        };

    },

    // DBを開く
    OpenIndexedDB: function(IndexeDBName, version, successCallback, errorCallback){

        funcs.openSuccessFunc = successCallback;
        funcs.openErrorFunc = errorCallback;

        //既に開いているか
        if(Params.db !== null){
            console.log('db already open');
            // 既に開いているならいったん閉じる
            Params.db.close();
            Params.db = null;
        }

        var dbName = Pointer_stringify(IndexeDBName);

        var indexedDB = window.indexedDB || window.webkitIndexedDB || window.mozIndexedDB;

        if (indexedDB) {

            // DB名とバージョンを指定して接続。DBがなければ新規作成される。
            var openReq  = indexedDB.open(dbName, version);

            openReq.onupgradeneeded = function(event){
                console.log('Upgraded db');
            }

            openReq.onsuccess = function(event){

                // 接続に成功
                console.log('db open success');

                Params.db = event.target.result;

                // Unityに通知
                Runtime.dynCall('v', funcs.openSuccessFunc);

            }

            openReq.onerror = function(event){

                // 接続に失敗
                console.log('db open error');

                Params.db = null;

                // Unityに通知
                Runtime.dynCall('v', funcs.openErrorFunc);

            }

        }else{
            //error
            console.log('db found error');
            // 通知
            Runtime.dynCall('v', funcs.openErrorFunc);
        }
    },

    // DBを閉じる
    CloseIndexedDB: function(){

        if(Params.db !== null){
            Params.db.close();
            Params.db = null;
        }

    },

    // DBを開き、DBに指定のStoreが無ければ作成する。新しいものを作成する場合は以前のものよりversionを高くする
    OpenIndexedDBAndCreateStore: function(IndexeDBName, version, StoreName, successCallback, errorCallback){

        //既に開いているか
        if(Params.db !== null){

            console.log('db already open');
            // 既に開いているならいったん閉じる
            Params.db.close();
            Params.db = null;
        }

        funcs.openSuccessFunc = successCallback;
        funcs.openErrorFunc = errorCallback;

        var dbName = Pointer_stringify(IndexeDBName);
        var dbStoreName = Pointer_stringify(StoreName);

        var indexedDB = window.indexedDB || window.webkitIndexedDB || window.mozIndexedDB;

        if (indexedDB) {

            // DB名とバージョンを指定して接続。DBがなければ新規作成される。
            var openReq  = indexedDB.open(dbName, version);

            openReq.onupgradeneeded = function(event){

                console.log('Upgraded db');

                Params.db = event.target.result;

                // dbStore作成
                var store = Params.db.createObjectStore(dbStoreName, {keyPath: 'myKey'});

                console.log('Upgraded end');

            }

            openReq.onsuccess = function(event){

                console.log('db open success');
                Params.db = event.target.result;
                Runtime.dynCall('v', funcs.openSuccessFunc);

            }

            openReq.onerror = function(event){
                // 接続に失敗
                console.log('db open error');
                // 通知
                Runtime.dynCall('v', funcs.openErrorFunc);
            }

        }else{
            //error
            console.log('db found error');
            // 通知
            Runtime.dynCall('v', funcs.openErrorFunc);
        }
    },

    // DBに値を登録する
    SetIndexedDB: function(StoreName, key, val, successCallback, errorCallback){

        funcs.saveSuccessFunc = successCallback;
        funcs.saveErrorFunc = errorCallback;

        //開いていない場合はエラー
        if(Params.db === null){
            //既に開いているためerror
            console.log('db not open');

            // Unityに通知
            Runtime.dynCall('v', funcs.saveErrorFunc);
            return;
        }

        var dbStoreName = Pointer_stringify(StoreName);
        var saveKey = Pointer_stringify(key);
        var saveValue = Pointer_stringify(val);

        CreateTransaction(StoreName);

        if(Params.transaction != null){

            var store = Params.transaction.objectStore(dbStoreName);

            // キーとバリューを追加(addは追加、putは更新もする)
            var request = store.put({ myKey: saveKey, value: saveValue});

            request.onsuccess = function (event) {

                // Unity側の関数を呼ぶ
                Runtime.dynCall('v', funcs.saveSuccessFunc);

            }

            request.onerror = function(event) {

                // Unityに通知
                Runtime.dynCall('v', funcs.saveErrorFunc);

            }

        }else{
            // Unityに通知
            Runtime.dynCall('v', funcs.saveErrorFunc);
        }
    },

    GetIndexedDB: function(StoreName, key, successCallback, errorCallback){

        funcs.loadSuccessFunc = successCallback;
        funcs.loadErrorFunc = errorCallback;

        //開いていない場合はエラー
        if(Params.db === null){
            //既に開いているためerror
            console.log('db not open');

            // Unityに通知
            Runtime.dynCall('v', funcs.loadErrorFunc);
            return;
        }

        var dbStoreName = Pointer_stringify(StoreName);
        var loadKey = Pointer_stringify(key);

        CreateTransaction(StoreName);

        if(Params.transaction != null){

            var store = Params.transaction.objectStore(dbStoreName);

            var request = store.get(loadKey);

            request.onsuccess = function (event) {

                // 値が存在しない(undefinedがあるため===は使用しない)
                if(this.result == null){
                    //console.log('not data');
                    // Unityに通知
                    Runtime.dynCall('v', funcs.loadErrorFunc);
                    return;
                }

                // valueが存在しない
                if(!this.result.hasOwnProperty('value')){
                    //console.log('not value');
                    // Unityに通知
                    Runtime.dynCall('v', funcs.loadErrorFunc);
                    return;
                }

                var resultVal = this.result.value;

                // TextEncoder.encode(str)は互換が怪しいためポリフィルを使用。文字列はnull文字終端にする
                var strBuffer = CustomTextEncoder(resultVal + String.fromCharCode(0));

                var strPtr = _malloc(strBuffer.length);
                HEAP8.set(strBuffer, strPtr);

                // Unity側の関数を呼ぶ
                Runtime.dynCall('vi', funcs.loadSuccessFunc, [strPtr]);

                _free(strPtr);
            }

            request.onerror = function(event) {

                // Unityに通知
                Runtime.dynCall('v', funcs.loadErrorFunc);

            }

        }
    },

};

autoAddDeps(IndexedDBPlugin, '$funcs');
autoAddDeps(IndexedDBPlugin, '$Params');
autoAddDeps(IndexedDBPlugin, '$CustomTextEncoder');
autoAddDeps(IndexedDBPlugin, '$CreateTransaction');
mergeInto(LibraryManager.library, IndexedDBPlugin);

プラグインを動かすコードを作成

次にIndexedDBController.csを作成します。

using AOT;
using System;
using System.Runtime.InteropServices;
using System.Text;
using UnityEngine;

namespace IndexDB
{
    /// <summary>
    /// WebGL_IndexDBでデータを保存、読み込み
    /// </summary>
    sealed public class IndexedDBController
    {
        /// <summary>
        /// データベースが開いているか
        /// </summary>
        private static bool isOpen = false;

        /// <summary>
        /// 処理中か
        /// </summary>
        private static bool RunningTask = false;

        /// <summary>
        /// 処理成功時にtrue
        /// </summary>
        private static bool Result = false;

        /// <summary>
        /// 処理失敗時にtrue
        /// </summary>
        private static bool Error = false;

        /// <summary>
        /// 読み込んだ値
        /// </summary>
        private static string ResultData = "";

        /// <summary>
        /// 内部データの初期化
        /// </summary>
        private static void Init()
        {
            RunningTask = true;
            Result = false;
            ResultData = "";
            Error = false;
        }

        /// <summary>
        /// データベースを開く
        /// </summary>
        /// <param name="IndexeDBName">データベース名</param>
        /// <param name="version">バージョン</param>
        /// <param name="successCallback">成功時のコールバック</param>
        /// <param name="errorCallback">失敗時のコールバック</param>
        [DllImport("__Internal")]
        private static extern void OpenIndexedDB(string IndexeDBName,
                                                int version,
                                                Action successCallback, Action errorCallback);

        /// <summary>
        /// データベースを閉じる
        /// </summary>
        [DllImport("__Internal")]
        private static extern void CloseIndexedDB();

        /// <summary>
        /// データベースを開き、Storeが無ければ作成する
        /// </summary>
        /// <param name="IndexeDBName">データベース名</param>
        /// <param name="version">バージョン</param>
        /// <param name="StoreName">テーブル名</param>
        /// <param name="successCallback">成功時のコールバック</param>
        /// <param name="errorCallback">失敗時のコールバック</param>
        [DllImport("__Internal")]
        private static extern void OpenIndexedDBAndCreateStore(string IndexeDBName, 
                                                int version, string StoreName,
                                                Action successCallback, Action errorCallback);

        /// <summary>
        /// データをIndexDBにセットする
        /// </summary>
        /// <param name="StoreName">テーブル名</param>
        /// <param name="key">キー</param>
        /// <param name="val">値</param>
        /// <param name="successCallback">成功時のコールバック</param>
        /// <param name="errorCallback">失敗時のコールバック</param>
        [DllImport("__Internal")]
        private static extern void SetIndexedDB(string StoreName,
                                                string key, string val,
                                                Action successCallback, Action errorCallback);

        /// <summary>
        /// データを取得する
        /// </summary>
        /// <param name="StoreName">テーブル名</param>
        /// <param name="key">キー</param>
        /// <param name="successCallback">成功時のコールバック</param>
        /// <param name="errorCallback">失敗時のコールバック</param>
        [DllImport("__Internal")]
        private static extern void GetIndexedDB(string StoreName,
                                                string key,
                                                Action<string> successCallback, Action errorCallback);

        /// <summary>
        /// 保存成功時のコールバック
        /// </summary>
        [MonoPInvokeCallback(typeof(Action))]
        static void OnOpenSuccessCallback()
        {
            Result = true;
            isOpen = true;
            RunningTask = false;
        }

        /// <summary>
        /// 保存失敗時のコールバック
        /// </summary>
        [MonoPInvokeCallback(typeof(Action))]
        static void OnOpenErrorCallback()
        {
            Error = true;
            isOpen = false;
            RunningTask = false;
        }

        /// <summary>
        /// 保存成功時のコールバック
        /// </summary>
        [MonoPInvokeCallback(typeof(Action))]
        static void OnSaveSuccessCallback()
        {
            Result = true;
            RunningTask = false;
        }

        /// <summary>
        /// 保存失敗時のコールバック
        /// </summary>
        [MonoPInvokeCallback(typeof(Action))]
        static void OnSaveErrorCallback()
        {
            Error = true;
            RunningTask = false;
        }

        /// <summary>
        /// 読み込み成功時のコールバック
        /// </summary>
        /// <param name="str"></param>
        [MonoPInvokeCallback(typeof(Action<string>))]
        static void OnLoadSuccessCallback(string str)
        {
            ResultData = str;
            Result = true;
            RunningTask = false;
        }

        /// <summary>
        /// 読み込み失敗時のコールバック
        /// </summary>
        [MonoPInvokeCallback(typeof(Action))]
        static void OnLoadErrorCallback()
        {
            Error = true;
            RunningTask = false;
        }

        /// <summary>
        /// データベースを開く
        /// </summary>
        /// <param name="dbName">データベース名</param>
        /// <param name="storeName">テーブル名</param>
        /// <param name="key">キー</param>
        /// <param name="value">値</param>
        /// <returns>処理が終了するまで待つ</returns>
        public static CustomYieldInstruction OnOpen(string dbName, string storeName, int version = 1)
        {

            Init();

            // Storeが無い場合は作成するようにするため、OpenIndexedDB()は基本使用しないと思われる
            OpenIndexedDBAndCreateStore(dbName, version, storeName,
                                                OnOpenSuccessCallback, OnOpenErrorCallback);

            //処理が成功か失敗するまで待つ
            return new WaitWhile(() => !Result && !Error);

        }

        /// <summary>
        /// データベースを閉じる
        /// </summary>
        public static void OnClose()
        {
            CloseIndexedDB();
        }

        /// <summary>
        /// データを保存する
        /// </summary>
        /// <param name="dbName">データベース名</param>
        /// <param name="storeName">テーブル名</param>
        /// <param name="key">キー</param>
        /// <param name="value">値</param>
        /// <returns>処理が終了するまで待つ</returns>
        public static CustomYieldInstruction OnSave(string dbName, string storeName, string key, string value)
        {
            Init();

            SetIndexedDB(storeName,
                                                key,
                                                value,
                                                OnSaveSuccessCallback, OnSaveErrorCallback);

            //処理が成功か失敗するまで待つ
            return new WaitWhile(() => !Result && !Error);

        }

        /// <summary>
        /// データを取得する
        /// </summary>
        /// <param name="dbName">データベース名</param>
        /// <param name="storeName">テーブル名</param>
        /// <param name="key">キー</param>
        /// <returns>処理が終了するまで待つ</returns>
        public static CustomYieldInstruction OnLoad(string dbName, string storeName, string key)
        {
            Init();

            GetIndexedDB(storeName,
                                                key,
                                                OnLoadSuccessCallback, OnLoadErrorCallback);

            //処理が成功か失敗するまで待つ
            return new WaitWhile(() => !Result && !Error);

        }

        /// <summary>
        /// データベースを操作しているか取得
        /// </summary>
        /// <returns>操作中ならtrue</returns>
        public static bool GetRunning()
        {
            return RunningTask;
        }

        /// <summary>
        /// データベースが開いているか取得
        /// </summary>
        /// <returns>開いているならtrue</returns>
        public static bool GetOpen()
        {
            return isOpen;
        }

        /// <summary>
        /// エラーが発生したか取得
        /// </summary>
        /// <returns>保存、読み込み後にエラーが発生したらtrue</returns>
        public static bool GetError()
        {
            return Error;
        }

        /// <summary>
        /// ロード後の値を取得する
        /// </summary>
        /// <returns></returns>
        public static string GetValue()
        {
            return ResultData;
        }

    }

    /// <summary>
    /// 日本語対応でUniCode文字列に変換して保存する為の拡張クラス
    /// </summary>
    public static class StringExtensions
    {
        /// <summary>
        /// 文字列をエスケープUniCodeに変換
        /// </summary>
        /// <param name="str">UniCode文字列</param>
        /// <returns></returns>
        public static string ToUniCode(this string str)
        {
            StringBuilder builder = new StringBuilder();

            char[] charArray = str.ToCharArray();
            foreach (var c in charArray)
            {
                switch (c)
                {
                    case '"':
                        builder.Append("\\\"");
                        break;
                    case '\\':
                        builder.Append("\\\\");
                        break;
                    case '\b':
                        builder.Append("\\b");
                        break;
                    case '\f':
                        builder.Append("\\f");
                        break;
                    case '\n':
                        builder.Append("\\n");
                        break;
                    case '\r':
                        builder.Append("\\r");
                        break;
                    case '\t':
                        builder.Append("\\t");
                        break;
                    default:
                        int codepoint = System.Convert.ToInt32(c);
                        if ((codepoint >= 32) && (codepoint <= 126))
                        {
                            builder.Append(c);
                        }
                        else
                        {
                            builder.Append("\\u");
                            builder.Append(codepoint.ToString("x4"));
                        }
                        break;
                }
            }

            return builder.ToString();

        }

    }

}

実際に動かす

サンプルコードの用意

上記コードを実際に動かすためのコードを書いていきましょう。
こちらのサンプルではTestSaveClassをJsonUtilityを使用し、JSON化します。
そしてIndexedDBに変換したクラスのJSONデータを保存します。

TestSaveClassを拡張すればいろいろなデータを保存できるようになりますよ。

using IndexDB;
using System.Collections;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using TMPro;
using UnityEngine;

namespace IndexDB
{
    sealed public class Sample : MonoBehaviour
    {

        /// <summary>
        /// データベース名
        /// </summary>
        private string DataBaseName;

        /// <summary>
        /// テーブル名
        /// </summary>
        private string StoreName = "Sample";

        /// <summary>
        /// 保存先キー
        /// </summary>
        private string Key = "Test";

        /// <summary>
        /// Json取得データ
        /// </summary>
        private Dictionary<string, object> loadData = new Dictionary<string, object>();

        // WebGLのTextは表示の際日本語出力対応していないので、フォントを変更するように
        [Header("表示用テキスト")]
        public TextMeshProUGUI ViewText;

        // WebGLのInputFieldは日本語入力対応していないので、対応するように
        [Header("入力用フィールド")]
        public WebGLNativeTextMeshInputFiled inputFiled;

        private void Awake()
        {
            //サンプルとして製品名でデータベース作成
            DataBaseName = Application.productName;
            StartCoroutine(OnOpen());

        }

        private void OnApplicationQuit()
        {
#if UNITY_WEBGL && !UNITY_EDITOR
            // ブラウザ強制的に閉じた場合に閉じられると思うがとりあえず書いておく。
            IndexedDBController.OnClose();
#endif
        }

        /// <summary>
        /// 保存
        /// </summary>
        public void OnSaveButton()
        {
            StartCoroutine(OnSave());
        }

        /// <summary>
        /// 読み込み
        /// </summary>
        public void OnLoadButton()
        {
            StartCoroutine(OnLoad());
        }

        /// <summary>
        /// 保存コルーチン
        /// </summary>
        /// <returns></returns>
        private IEnumerator OnOpen()
        {
#if !UNITY_WEBGL || UNITY_EDITOR
            yield break;
#endif

            if (IndexedDBController.GetRunning())
            {
                Debug.Log("データベースを処理中です");
                yield break;
            }

            yield return IndexedDBController.OnOpen(DataBaseName, StoreName);
        }

        /// <summary>
        /// 保存コルーチン
        /// </summary>
        /// <returns></returns>
        private IEnumerator OnSave()
        {

#if !UNITY_WEBGL || UNITY_EDITOR
            yield break;
#endif

            if (!IndexedDBController.GetOpen())
            {
                Debug.Log("データベースが開いていません");
                yield break;
            }

            if (IndexedDBController.GetRunning())
            {
                Debug.Log("データベースを処理中です");
                yield break;
            }

            string Value = inputFiled.text;

            if (Value == null)
                Value = "";

            TestSaveClass testSaveClass = new TestSaveClass();

            testSaveClass.SetSaveText(Value);

            Debug.Log(Value.ToUniCode());

            //Json文字列に変換
            string jsonStr = JsonUtility.ToJson(testSaveClass);

            //Jsonを保存するまで待つ
            yield return IndexedDBController.OnSave(DataBaseName, StoreName, Key, jsonStr);

            if (IndexedDBController.GetError())
            {
                ViewText.text = "Save Failed";
            }
            else
            {
                ViewText.text = "Save Success";
            }

        }

        /// <summary>
        /// 読み込みコルーチン
        /// </summary>
        /// <returns></returns>
        private IEnumerator OnLoad()
        {
#if !UNITY_WEBGL || UNITY_EDITOR
            yield break;
#endif

            if (!IndexedDBController.GetOpen())
            {
                Debug.Log("データベースが開いていません");
                yield break;
            }

            if (IndexedDBController.GetRunning())
            {
                Debug.Log("データベースを処理中です");
                yield break;
            }

            //読み込むまで待つ
            yield return IndexedDBController.OnLoad(DataBaseName, StoreName, Key);

            if (IndexedDBController.GetError())
            {
                Debug.Log("エラーが発生しました。");
            }
            else
            {

                //JSONテキストのデコード
                var saveData = JsonUtility.FromJson<TestSaveClass>(IndexedDBController.GetValue());

                if (ViewText != null)
                    ViewText.text = saveData.GetSaveText();

                Debug.Log(saveData.GetSaveText());
            }

        }

    }

    [SerializeField]
    sealed public class TestSaveClass
    {
        [SerializeField]
        private string saveText = "";

        public void SetSaveText(string str)
        {
            saveText = str.ToUniCode();

            Debug.Log(saveText);
        }

        public string GetSaveText()
        {
            return Regex.Unescape(saveText);
        }

    }

}

サンプルシーンの用意

下記画像のようなシーンを作成します。

InputFiled(TMP)、Text(TMP)、空のオブジェクト、ボタン2つを配置します。

空のオブジェクトにSample.csをアタッチ
ボタンからOnSaveButton()とOnLoadButton()を呼べるようにOnClickにそれぞれ指定します。

ビルドの作成

File>Build Settings
のBuild And Runを押してビルドします。(PCの性能によっては時間がかかります)

実行する

Unityの機能で実行

UnityでBuild And Runをするとローカルウェブサーバーで一時的にホストされ、ブラウザで実行されます。
Google Chromeではデベロッパーツールを使用するとIndexedDBのデータを確認できます。

デベロッパーツールは右上の︙>その他のツール>デベロッパーツールから開くことが可能です。

試しにwonderplanetと入力し、Saveボタンを押して確認してみましょう。

保存されたことが確認できます。

Loadボタンを押すと、データが読み込まれたことを確認できます。

最後に

以上、UnityでIndexedDBを動かす方法でした。
Unityに限らずJavaScript間でデータのやり取りや、保存システムを自分で管理したい場合にも利用できるので色々と手を入れて改造してみてください。

それでは、良いゲーム開発ライフを楽しんでください。

採用情報

ワンダープラネットでは、一緒に働く仲間を幅広い職種で募集しております。

-エンジニア
-

© WonderPlanet Inc.