VRChatの開発ツール導入ではなく、UdonSharpで開発・テストする上での
ノウハウをまとめた記事になります。
独学なのでこれが正しい・最適とは限らないことに注意です。
VRChatの説明ですが、ほかのプラットフォームでも使えるはずです。
方針
- Unityはクラッシュする前提で構築する
- クラッシュするとシーン・プレハブが壊れることがある
- クラッシュしても大丈夫なように、シーン・プレハブはEditorで自動生成する
- テスト用と製品用のスクリプトを分ける
- テスト用はブレイクポイントが使えるようにVRChatによならいコードにする
- テスト用から製品用のスクリプトを自動生成する
開発手順図
- テスト用スクリプトMockScriptを作成し、シーンMockSceneに配置する
- MockSceneのテストMockTestを行う
- MockScriptをビルドし製品用スクリプトProductScriptを作成、シーンProductSceneに配置する
- ProductSceneのテストをClientSimで行う
- ProductSceneをビルドしVRChatアプリを作成、ローカルテストを行う
- ProductSceneをアップロードしオンラインテストを行う
次の項から開発手順を詳しく説明していきます。
Assembly definitionの作成
プロジェクトの設定が終わったら、最初にAssembly definitionを作成します。
テスト用スクリプトのための「Mocksフォルダ」、
製品用スクリプトのための「Scriptsフォルダ」、
Editor拡張用のための「Editorフォルダ」以下に下記
Assembly definitionを作成します。
まず、Mocksフォルダ以下にAssembly definitionを作成します。
手順
- Create > Assembly definition
次に、Scriptsフォルダ以下にAssembly definitionと
U# Assembly Definitionを作成します。
手順
- Create > Assembly definition
- ProductAssemblyという名前で保存する
- Assembly Definition References
- VRC.SDK3
- VRC.SDKBase
- VRC.Udon
- UdonSharp.Runtime
- Apply
- Create > U# Assembly definition
- Source Assembly : ProductAssembly
- Apply
最後に、UdonSharpのEditorユーティリティを使えるようにするため、
Editorフォルダ以下にAssembly definition referenceを作成します。
手順
- Create > Assembly Definition Reference
- Assembly Definition : UdonSharp.Editor を選択
- Apply
テスト・製品用スクリプトの初期生成
Editor拡張でファイル名を入力すると、テスト・製品用スクリプトを
自動生成するスクリプトを作成しました。
テスト用スクリプトは「Mocks/Mockスクリプト名.cs」、製品用スクリプトは
「Scripts/スクリプト名.cs」という名前で配置するようにしました。
スクリプトは以下です。
using System.IO;
using UdonSharp;
using UnityEditor;
using UnityEngine;
namespace UdonSharpEditor
{
internal class CreateScript : EditorWindow
{
public string Text;
string TemplatePath = @"C:\Program Files\Unity\Hub\Editor\2019.4.31f1\Editor\Data\Resources\ScriptTemplates\81-C# Script-NewBehaviourScript.cs.txt";
[MenuItem("My Window/My Editor")]
public static void MyEditorWindow()
{
GetWindow(typeof(CreateScript));
}
void OnGUI()
{
Text = EditorGUILayout.TextField("Script", Text);
if (GUILayout.Button("Create Script"))
{
CreateUSharpScript($"Assets/Scripts/{Text}.cs");
ProjectWindowUtil.CreateScriptAssetFromTemplateFile(TemplatePath, $"Assets/Mocks/Mock{Text}.cs");
}
}
private static void CreateUSharpScript(string chosenFilePath)
{
}
}
}
テスト用スクリプトの生成はProjectWindowUtil.CreateScriptAssetFromTemplateFile
を使用しました。
UdonSharpの自動生成は「Packages\com.vrchat.udonsharp\Editor\Editors\UdonSharpBehaviourEditor.cs CreateUSharpScript()」
を参考にしました。
ゲームのロジック部のスクリプトを記述します。
できるだけテスト用と製品用どちらでも動作するように、
基本的にUdonSharpに依存しないようにスクリプトを書いていきます。
もしテスト用と製品用でコードを分けたい場合は以下のように
PRODUCT defineを使用して分岐します。
PRODUCT defineは後述するように製品用スクリプトだけで定義されます。
#if !PRODUCT
#else
#endif
テスト用スクリプトから製品用スクリプトを生成します。
基本的にテスト用スクリプトをそのままコピーして製品用スクリプトにします。
足りない部分はUdonSharp専用の処理を追加します。
以下で詳しい手順を説明します。
Makeファイルの作成
変更したファイルのスクリプトだけを生成するため、
Makeファイルを作成するようにしました。
MakeファイルはPythonで作成しました。
import os
from glob import glob
import pathlib
import textwrap
PROJECT_NAME = 'MyProject'
mock_files_abs_path = glob(f'{PROJECT_NAME}/Assets/Mocks/*.cs')
mock_files = [
pathlib.Path(file).relative_to(f'{PROJECT_NAME}')
for file in mock_files_abs_path
]
script_files = []
for mock_file in mock_files:
mock_file_base = os.path.basename(mock_file)
script_file_base = mock_file_base.replace('Mock', '')
script_files += [f'Assets/Scripts/{script_file_base}']
makefile = f"""
.PHONY: all clean
all: {' '.join(script_files)}
"""
for mock_file, script_file in zip(mock_files, script_files):
makefile += textwrap.dedent(f"""
{script_file}:{mock_file} make.py
\tpython make.py $@ $<
""")
with open(f'{PROJECT_NAME}/Makefile', 'w') as f:
f.write(makefile)
上記を実行すると、以下のようなMakeファイルが作成されます。
.PHONY: all clean
all: Assets/Scripts/MyScript1.cs Assets/Scripts/MyScript2.cs
Assets/Scripts/MyScript1.cs:Assets\Mocks\MockMyScript1.cs make.py
python make.py $@ $<
Assets/Scripts/MyScript2.cs:Assets\Mocks\MockMyScript2.cs make.py
python make.py $@ $<
上記は、MockMyScript1.csからMyScript1.csを、
MockMyScript2.csからMyScript2.csをMakeする例です。
Makeコマンドの実行
作成したMakeファイルに対してMakeコマンドを実行すると以下の
「make.py」が実行されます。
import os
import re
from glob import glob
import sys
script_file_path = sys.argv[1]
mock_file_path = sys.argv[2]
def get_mock_classes():
files = glob(f'{module_dir}/Assets/Mocks/*.cs')
classes = {}
for file in files:
_class = os.path.splitext(os.path.basename(file))[0]
target_class = _class.replace('Mock', '')
classes[_class] = target_class
return classes
mock_classes = get_mock_classes()
with open(mock_file_path, encoding='utf-8') as f:
mock_strs = f.read()
mock_strs = '''
#define PRODUCT
using UdonSharp;
using VRC.SDKBase;
using VRC.Udon;
using VRC.Udon.Common.Interfaces;
using VRC.SDK3.Components;
''' + mock_strs
replace_items = [
(rf'class {config["mock_prefix"]}(\w+)', r'class \g<1>'),
('MonoBehaviour', 'UdonSharpBehaviour'),
(r'Debug.Assert\((.+)\)', r'if (!(\g<1>)) Debug.LogError($"Assert {name} (\g<1>) (at __FILE__)")'),
]
for replace_item in replace_items:
mock_strs = mock_strs.split("\n")
replace_item1 = replace_item[1].replace("__FILE__", script_file_path)
for i,mock_str in enumerate(mock_strs):
mock_strs[i] = re.sub(replace_item[0], replace_item1, mock_str)
mock_strs = "\n".join(mock_strs)
for _class, target_class in mock_classes.items():
mock_strs = mock_strs.replace(_class, target_class)
if not os.path.isfile(script_file_path):
raise FileNotFoundError(script_file_path)
with open(script_file_path, 'w', encoding='utf-8') as f:
f.write(mock_strs)
「make.py」はテスト用スクリプトをコピーして
製品用スクリプトを作成した後、以下のようなUdonSharp用の
変更を行っています。
PRODUCT defineの追加
先頭行に「#define PRODUCT」を追加します。
usingの追加
UdonSharpのusingを追加します。
Assertの変更
VRChatではAssertが使えないため、
「Debug.Assert」を「Debug.LogError」に変更しています。
シーンの生成
テスト用シーン「MockScene.unity」、製品用シーン「ProductScene.unity」
をEditor拡張で自動生成するようにしました。
GameObjectはテスト・製品用シーンで共通の構造とし、Prefab化することで
共通化しました。
次からEditor拡張でシーンを生成する手順を説明します。
シーンを開く
まずプレハブを作るために、テスト用シーンを開きます。
string scenePath = "Assets/MockScene.unity";
var scene = EditorSceneManager.OpenScene(scenePath);
環境プレハブの作成
次に環境をまとめたプレハブ「Environment.prefab」を作成します。
static GameObject AddPrefab(string path, string name, out GameObject prefab, GameObject parent=null)
{
prefab = AssetDatabase.LoadAssetAtPath<GameObject>(path);
Debug.Assert(prefab != null, $"{path} {name}");
GameObject obj = PrefabUtility.InstantiatePrefab(prefab) as GameObject;
if (parent != null) obj.transform.SetParent(parent.transform, false);
obj.name = name;
return obj;
}
var Environment = AddEmptyGameObject("Environment");
AddPrefab("Packages/com.vrchat.worlds/Samples/UdonExampleScene/Prefabs/VRCWorld.prefab", "VRCWorld", out _, Environment);
ルートGameObjectの作成
次にテスト・製品用ルートGameObject「Mocks」・「Products」を作成します。
static GameObject AddEmptyGameObject(string name, GameObject parent=null)
{
GameObject obj = new GameObject(name);
if (parent != null) obj.transform.SetParent(parent.transform, false);
return obj;
}
var Mocks = AddEmptyGameObject("Mocks");
var Products = AddEmptyGameObject("Products");
共通プレハブの作成
次にテスト・製品用共通プレハブ「SceneMgr.prefab」を作成します。
var SceneMgr = AddEmptyGameObject("SceneMgr");
string SceneMgrPrefabPath = "Assets/Prefabs/SceneMgr.prefab";
PrefabUtility.SaveAsPrefabAssetAndConnect(SceneMgr, SceneMgrPrefabPath, InteractionMode.AutomatedAction);
DestroyImmediate(SceneMgr);
スクリプトのAddComponent
次にテスト・製品用のSceneMgr.prefabにそれぞれのスクリプトをAddComponentします
static MonoBehaviour AddScriptComponent(object root, string objPath, string path, string objType, string scriptPathPrefix, bool enabled = false)
{
string _path = scriptPathPrefix + path;
GameObject _root = null;
if (root is GameObject) _root = root as GameObject;
else _root = (root as MonoBehaviour).gameObject;
Debug.Assert(root != null, $"{root.GetType().Name} {objPath} {_path}");
GameObject obj;
if (objPath == "")
{
obj = _root;
}
else
{
Transform transform = _root.transform.Find(objPath);
Debug.Assert(transform != null, $"Transform Not Found {root.GetType().Name} {objPath} {_path}");
obj = _root.transform.Find(objPath).gameObject;
}
Debug.Assert(obj != null, $"{objPath} {_path}");
var script = AssetDatabase.LoadAssetAtPath<MonoScript>(_path);
Debug.Assert(script != null, $"Script Not Found {objPath} {_path}");
MonoBehaviour component;
if (objType == "Mock")
{
component = (MonoBehaviour)obj.AddComponent(script.GetClass());
component.enabled = enabled;
}
else
{
var udonSharpComponent = obj.AddUdonSharpComponent(script.GetClass());
udonSharpComponent.enabled = enabled;
component = udonSharpComponent;
}
return component;
}
foreach (var objType in new string[] { "Mock", "Product" })
{
var root = (objType == "Mock") ? Mocks : Products;
var scriptPefix = (objType == "Mock") ? "Mock" : "";
var prefabPefix = (objType == "Mock") ? "Mock" : "Product";
var dir = (objType == "Mock") ? "Mocks" : "Scripts";
var scriptPathPrefix = $"Assets/{dir}/{scriptPefix}";
var SceneMgr = AddPrefab("Assets/Prefabs/SceneMgr.prefab", "SceneMgr", out _, root);
AddScriptComponent(SceneMgr, "", "SceneMgr.cs", objType, scriptPathPrefix, true);
AddScriptComponent(SceneMgr, "MyObj", "MyObj.cs", objType, scriptPathPrefix);
}
UIの設定
次に製品用のGameObjectにボタンなどのUIを設定します。
詳しくはUdonSharpのボタンクリックイベントをUnityEditor拡張で登録するをご覧ください。
さらに製品用のGameObjectにVRCUiShapeなどUdon独自のUIを設定します。
var MyUi = SceneMgr.transform.Find('MyUi').gameObject;
MyUi..AddComponent<VRCUiShape>();
var MyAudio = SceneMgr.transform.Find('MyAudio').gameObject;
MyAudio.AddComponent<VRCSpatialAudioSource>();
プレハブ・シーンの保存
最後に作成したGameObjectをプレハブ・シーンとして保存します。
PrefabUtility.SaveAsPrefabAssetAndConnect(Environment, "Assets/Prefabs/Environment.prefab", InteractionMode.AutomatedAction);
PrefabUtility.SaveAsPrefabAssetAndConnect(Products, "Assets/Prefabs/Products.prefab", InteractionMode.AutomatedAction);
EditorSceneManager.SaveOpenScenes();
テスト用シーンのテスト
UnityのPlayを使いテスト用シーンのテストを行います。
不具合が見つかった場合はブレイクポイントを使い調査します。
製品用シーンのテスト
ClientSimを使い製品用シーンのテストを行います。
不具合が見つかった場合はInspectorからpublic変数の値を見て調査します。
ローカルVRChatアプリのテスト
VRChat SDKのLocal Testingを使いテストを行います。
この時Number of Clientsに2以上を指定して同期のテストを行います。
不具合が見つかった場合はDebug.LogErrorを使い調査します。
オンラインVRChatアプリのテスト
プライベートパブリッシュを行いテストを行います。
Quest対応を行う場合はここでテストを行います。
バージョン
- Unity : 2019.4.31f1
- VRChat SDK - Worlds : 3.2.3
- UdonSharp : 1.1.9