ろうでんのブログ

ただのブログ

UnityでC#をスクリプト言語のように実行してみる

この記事は Akatsuki Advent Calendar 2021 の11日目の記事です。 adventar.org 10日目の記事はこちら takanakahiko.hatenablog.com


どうも、1年ぶりの投稿です。

今日は Microsoft.CodeAnalysis.CSharp.Scripting を使って*1、Unity上でC#ソースコードスクリプト言語かのように実行してみようと思います。 www.nuget.org

(以降、本記事ではコンパイル対象のC#ソースコードを「ソースコード」、インタプリタに渡されるC#ソースコードを「スクリプト」と呼びます。紛らわしいので)

「UnityでC#を実行?当たり前じゃん」となるかもしれませんが、あくまで「スクリプト言語かのように」実行してみます。 通常、UnityではC#コンパイラ言語として扱うので、ソースコードをMonoやIL2CPPとして実行可能な形式 (ILや機械語) にコンパイルしてEXE等に事前に格納します。 今回やろうとしていることは「C#スクリプト言語として扱い、実行時にスクリプトを与えてインタプリタで解釈・実行する」ということです*2。 具体的には、UnityのuGUI上に配置したInputFieldにスクリプトを書いて、Buttonをクリックしたらそのスクリプトが実行されるようにしてみます。

なお、今回紹介する技術を応用して「スクリプトをAssetBundleでDLできるようにしようぜ!」みたいなことをしてしまうと脆弱性の塊になる*3ので、配布用のプログラムに活用する際にはセキュリティの観点に注意してくださいね (責任は負いかねます) 。


さて、今回は以下の手順でやっていきます。


Unityエディタの用意

後々使う Microsoft.CodeAnalysis.CSharp.Scripting が .NET Standard 2.0 に依存しているので、API Compatibility Level が .NET Standard 2.0 以上に対応しているUnityバージョンを使う必要があります。 具体的には、Unity 2018.1以上であればOKそうです*4。 今回は、特に理由はありませんがUnity 2022.1.0b1 (ベータ) を使ってみようと思います。

Unityエディタのインストールについての詳細な手順については触れません。 Unity公式のドキュメントを参考にしてください。 unity.com

ざっくり言うと、

  1. Unity Hubのインストール
  2. Unity Hubを使ってUnity 2022.1.0b1 (ベータ) をインストール
  3. Unity 2022.1.0b1 (ベータ) 用の空のプロジェクトを作成 (プロジェクトのテンプレートは何でも良いので、とりあえず3Dを選択)

です。

Code Analysys の用意 & DLL参照を追加

今回使用する Microsoft.CodeAnalysis.CSharp.Scripting は、UPMで入手できる Code Analysis というパッケージに付属しています。 これをプロジェクトに追加します。 また、これだけでは追加したパッケージを使用できないので、パッケージのDLLを参照に追加します。

詳細な手順は以下のQiita記事が参考になります。 qiita.com

ざっくり言うと、

  1. プロジェクトを開く
  2. Package Managerを開く
  3. Add package from git URL... から com.unity.code-analysis を指定してインストール*5
  4. ソースコードを入れる予定のフォルダにAssembly Definitionを作成 (今回は ScriptingTest フォルダを作成し、その中に ScriptingTest.asmdef を作成しました)
  5. 作成したAssembly Definitionを以下のように編集
    1. Override References にチェックを入れる
    2. Assembly References に以下の3つを追加

です。

文字列リテラルに格納されたスクリプトを実行

本記事では最終的にInputFieldに入力したスクリプトを動的に実行できる状態を目指しますが、その第一歩として静的なスクリプト (文字列リテラルにハードコードしたもの) を実行できるようにしてみます。

コンソールにログを残すだけの単純なスクリプトを実行できるようにしてみます。 以下のソースコードを作成し、シーン上の適当なオブジェクトにアタッチします。

// ScriptingComponent.cs

using System.Reflection;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using UnityEngine;

namespace ScriptingTest
{
    internal class ScriptingComponent : MonoBehaviour
    {
        private void Start()
        {
            const string scriptText =
@"using UnityEngine;
Debug.Log(""Start() が実行されました。"");
";

            try
            {
                var scriptOptions = ScriptOptions.Default.WithReferences(
                    Assembly.Load("netstandard"),
                    typeof(Debug).Assembly
                );

                CSharpScript.RunAsync(scriptText, scriptOptions).Wait();
            }
            catch (CompilationErrorException)
            {
                Debug.LogError("コンパイルエラー");
                throw;
            }
            catch
            {
                Debug.LogError("その他のエラー");
                throw;
            }
        }
    }
}

この状態でエディタを実行してみると、コンソール上に Start() が実行されました。 というログが出るはずです。

なお、スクリプトでのC#の文法はやや特殊です。 感覚的にはC# 9.0以降のtop-level statementsに近いです。 docs.microsoft.com

UIの配置

前節で静的なスクリプトを実行できるようにしたので、今度は動的にスクリプトを編集して実行できる状態を目指します。 具体的には、uGUIのInputFieldに書いたスクリプトを実行できる状態を目指します。 そのために必要となるUIをシーンに配置していきます。 今回はTextMesh Proを使ったので、途中でTextMesh Proのインポートを行いました。 大体こんな感じの配置にすればOKです。

f:id:cllightz:20211211234707p:plain
uGUIの配置

ボタンをクリックしたら実行できるようにする

前節ではUIを配置しただけなので、InputFieldにソースコードを入力しようが、Buttonをクリックしようが、スクリプトは実行されません。 なので、UIと先述のScriptingComponentを紐付けます。

まず、先程作成したAssembly DefinitionにTextMesh Proの参照を追加しなければなりません。 Assembly Definition Referencesの Unity.TextMeshPro を追加してください。

次に、ScriptingComponent.csを以下のように変更します。

// ScriptingComponent.cs

using System.Reflection;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using TMPro;
using UnityEngine;
using UnityEngine.UI;

namespace ScriptingTest
{
    internal class ScriptingComponent : MonoBehaviour
    {
        [SerializeField] private TMP_InputField scriptInputField;
        [SerializeField] private Button executeButton;

        private void Start()
        {
            executeButton.onClick.AddListener(OnExecute);
        }

        private void OnExecute()
        {
            try
            {
                var scriptOptions = ScriptOptions.Default.WithReferences(
                    Assembly.Load("netstandard"),
                    typeof(Debug).Assembly
                );

                CSharpScript.RunAsync(scriptInputField.text, scriptOptions).Wait();
            }
            catch (CompilationErrorException)
            {
                Debug.LogError("コンパイルエラー");
                throw;
            }
            catch
            {
                Debug.LogError("その他のエラー");
                throw;
            }
        }
    }
}

次にScriptingComponentの各フィールドに前節で配置したInputFieldとButtonをアタッチします。

f:id:cllightz:20211211234948p:plain
UIをアタッチ

これによりすべての準備が整いました。 Unityエディタを再生し、InputField内にスクリプトを書いてExecuteボタンを押すとスクリプトを実行できます。 例えばプリミティブなカプセルを生成するスクリプトを書いて実行するとこうなります。

f:id:cllightz:20211211224249p:plain
カプセルを召喚

まとめ

以上で実装は終わりです。 今回の実装例はGitHub上に公開してあります。 良ければどうぞ。 https://github.com/cllightz/ScriptingTestgithub.com

このままでは特に使い道がありませんが、応用すればゲームのデバッグツールとして使えるかもしれませんね (RiderやVisual Studioのイミディエイトウィンドウみたいに) 。

ただし、前述の通りかなりセキュリティに気をつけないと脆弱性の塊になってしまうので、慎重に実装しましょう。

*1:別のアプローチとして、dotnet scriptコマンドを外部プロセスとして実行する方法があると思います。 そちらは事前にdotnetコマンドとdotnet-scriptをインストールする必要があります。 docs.microsoft.comgithub.com

*2:もちろん、スクリプトを実行するために用意する他の部分は、通常通りコンパイル対象とします

*3:スクリプトから参照できるアセンブリ内にあるコードにある限りの任意コードを実行可能になってしまう

*4:こちらのドキュメントを参考にしましたdocs.microsoft.com

*5:今回は執筆時点で最新の0.1.2をインストールしました