🥾

[Jint]Unity上でClusterScriptを動かすための一歩目

2024/12/18に公開

こんばんは!!かおもです!!!!!!!

弊ツール「CSEmulator」はUnity上でClusterScriptを動かせるようにしたツールです。
Unity上でClusterScriptを動かすためには、まずJavaScriptを動かす必要があります。

ということで、これはJavaScriptインタプリタのJintの簡単な紹介記事です。

Jintの記事が少なくて大変苦労したので、記事に残しておこうと思った次第です。

JavaScriptインタプリタJintの導入

平たくいうとC#でJavaScriptが動かせるというライブラリです。なのでUnityでも動かせます。

リポジトリは https://github.com/sebastienros/jint です。
こちらのソースコードを丸ごと持ってきてUnityで動かせるかは試していません。

筆者はNuGet https://www.nuget.org/packages/jint から導入して動かしました。
手動でDLLを入れる場合はDependenciesも確認して下さい。
現時点では https://www.nuget.org/packages/Acornima/ が必要です。
必要であれば https://www.nuget.org/packages/System.Runtime.CompilerServices.Unsafe/ も導入して下さい(v2.30.0現在のCCKには同梱されています)。

Unityは2021.3から.NET Standard 2.1をサポートしています。
いい感じのバージョンを入れて下さい。
筆者の環境では2.0を導入しています。

dllのインポート設定は使いたい状況に応じて設定して下さい。

余談ですが、AcronimaはJavaScriptパーサで、Jintがインタプリタです。
このAcronimaだけでも結構遊べます。

Unity(C#)でJavaScriptのコードを動かす

正直ここまでの準備が一番大変だと思っています。
逆に導入乗り越えたなら、8割勝利です。

まずは「hello, world」

このあたりは公式のサンプルにもあります。

JintTest01
using System;
using UnityEngine;

public class JintTest01 : MonoBehaviour
{
    void Start()
    {
        //Jintのエンジン(JavaScriptのインタプリタ)を作成する。
        var engine = new Jint.Engine();

        //logという関数をグローバルスコープに入れる。
        engine.SetValue("log", new Action<object>(o => Debug.Log(o)));

        //JavaScriptのプログラムを実行する。
        engine.Execute("log('hello, world')");
    }
}

GameObjectにJintTest01をアタッチし、再生ボタンを押してこのログが出ればOKです。

エンジンの実行オプションを指定する。

CPU利用率やメモリ使用量制限のオプションも公式のサンプルにあるので参考にして下さい。

ClusterScriptでよく見かけるメモリ使用量の機能もおそらくこれです。
いくつか注意点があります。

  • option.LimitMemoryは拡張メソッドなので、using Jint;を忘れないようにして下さい。
  • option.LimitMemoryの実装は現在のThreadのメモリ使用量なので、エディタ上のプレイモードでは正しく動かないようです。

他にも、このようにコード補完でいくつかのオプションが出てきます。

独自クラスを導入する

C#で作ったクラスをJavaScriptに導入できます。これも公式のサンプルにあります。

CSEmulatorでは、Vector3などで使っています。

JintTest02
using System;
using UnityEngine;

public class JintTest02 : MonoBehaviour
{
    public class Test02
    {
        readonly string message;
        public Test02(string message) { this.message = message; }
        public string Show() { return "Test02" + message; }
    }

    void Start()
    {
        var engine = new Jint.Engine();
        engine.SetValue("log", new Action<object>(o => Debug.Log(o)));
        //Test02のコンストラクタを導入する。
        engine.SetValue("Test02", typeof(Test02));
        var code =
@"
const test = new Test02(""ok"");
log(test.Show()); //Test02ok
log(test instanceof Test02); //true
";
        engine.Execute(code);
    }
}

GameObjectにJintTest02をアタッチし、再生ボタンを押してこのログが出ればOKです。

このサンプルコード中の「コンストラクタを導入する。」というイメージは地味に使えます。

undefined値を受ける

このあたりから、微妙なハマりポイントになってきます。

まず、C#にはundefinedがありません。
しかし、JintはJavaScriptの値をC#に変換してくれます。
ということで、objectで受ければ何かいい感じになるのでは?となるかもしれませんが、これはNGです。

JintTest03
using System;
using UnityEngine;

public class JintTest03 : MonoBehaviour
{
    void Start()
    {
        var engine = new Jint.Engine();
        //objectで受ける
        engine.SetValue("isNull", new Action<object>(o => Debug.Log(o == null)));
        var code =
@"
isNull(null); //True
isNull(undefined); //True
";
        engine.Execute(code);
    }
}

GameObjectにJintTest03をアタッチして確認してもらうと分かるのですが、JavaScriptのundefinednull判定になります。

こういう場合は Jint.Native.JsValueで受けるとよいです。

JintTest04
using System;
using UnityEngine;

public class JintTest04 : MonoBehaviour
{
    void Start()
    {
        var engine = new Jint.Engine();
        //Jint.Native.JsValueで受ける
        engine.SetValue("isNull", new Action<Jint.Native.JsValue>(o =>
        {
            Debug.Log(o == Jint.Native.JsValue.Null);
        }));
        engine.SetValue("isUndefined", new Action<Jint.Native.JsValue>(o =>
        {
            Debug.Log(o == Jint.Native.JsValue.Undefined);
        }));
        var code =
@"
isNull(null); //True
isNull(undefined); //False
isUndefined(null); //False
isUndefined(undefined); //True
";
        engine.Execute(code);
    }
}

GameObjectにJintTest04をアタッチして確認してください。
undefinednullの判定もこの通り可能です。

ということで、Jintで値を受ける時は基本的に全てJint.Native.JsValueで受けるのが良いです。
Jint.Native.JsValueはJint界の値の基底(のはず)なので全ての値を受けられます。

C#のクラスに変換する

しかしJint.Native.JsValueをC#で使う場合は、自前でC#で扱えるクラスにする必要があります。

とはいっても、そこまで大変ではありません。以下のようにします。

JintTest05
using System;
using UnityEngine;

public class JintTest05 : MonoBehaviour
{
    void Start()
    {
        var engine = new Jint.Engine();
        engine.SetValue("convertType", new Action<Jint.Native.JsValue>(v =>
        {
            //ToObject()するだけ
            var o = v.ToObject();
            Debug.LogFormat("{0}:{1}", o?.GetType().Name, o?.ToString());
        }));
        var code =
@"
convertType(true); //Boolean:True
convertType(""ok""); //String:ok
convertType(123); //Double:123
convertType([4, 5, 6]); //Object[]:System.Object[]
convertType({key7: 7, key8: 8}); //ExpandoObject:System.Dynamic.ExpandoObject
";
        engine.Execute(code);
    }
}

GameObjectにJintTest05をアタッチして確認してください。
公式に説明がある通りに変換されます。

しかしArrayObjectは別の扱いをした方がよいので別に紹介します。
というのも、ArrayObjectToObject()すると再帰的にToObject()されます
つまり知らないうちにundefinednullになるような事が起きます。

Jint.Native.JsValueの型を判定する

ArrayObjectの扱いを紹介する前に、そもそもJint.Native.JsValueがどの型であるかの判定が必要です。

JintTest06
using System;
using UnityEngine;

public class JintTest06 : MonoBehaviour
{
    public class Test06 { }

    string GetJsValueType(object value)
    {
        if (value is Jint.Native.JsNull) return "Null";
        if (value is Jint.Native.JsUndefined) return "Undefined";
        if (value is Jint.Native.JsBoolean) return typeof(bool).Name;
        if (value is Jint.Native.JsNumber) return typeof(double).Name;
        if (value is Jint.Native.JsString) return typeof(string).Name;
        if (value is Jint.Native.JsObject) return typeof(object).Name;
        if (value is Jint.Native.JsArray) return typeof(Array).Name;

        //これは判定されない
        if (value is Test06) throw new Exception("判定されない");

        //C#のクラスはObjectWrapperに入れられている。
        if (value is Jint.Runtime.Interop.ObjectWrapper wrapped)
        {
            //ここで判定される
            if (wrapped.Target is Test06) return typeof(Test06).Name;
        }

        throw new NotImplementedException(value?.GetType().Name);
    }

    void Start()
    {
        var engine = new Jint.Engine();
        engine.SetValue("Test06", typeof(Test06));
        engine.SetValue("showType", new Action<Jint.Native.JsValue>(v => {
            Debug.Log(GetJsValueType(v));
        }));
        var code =
@"
showType(null); //Null
showType(undefined); //Undefined
showType(true); //Boolean
showType(123); //Double
showType(""ok""); //String
showType(new Test06()); //Test06
showType([4, 5, 6]); //Array
showType({key7: 7, key8: 8}); //Object
";
        engine.Execute(code);
    }
}

GameObjectにJintTest06をアタッチして確認してください。
ArrayObjectやその他のプリミティブな値は素直に判定できます。

ここで注意すべきはObjectWrapperです。
C#のクラスはJint.Native.JsValueで受けた場合ObjectWrapperになっています。
そのためTargetで実際の中身を確認します。

あまり出番はありませんが、自前でObjectWrapperにすることも可能です。
その場合はJint.Native.JsValue.FromObject()を使用します。

JintTest07
using System;
using UnityEngine;

public class JintTest07 : MonoBehaviour
{
    public class Test07 { }

    string GetJsValueType(object value)
    {
        if (value is Jint.Native.JsBoolean) return typeof(bool).Name;
        if (value is Jint.Runtime.Interop.ObjectWrapper wrapped)
        {
            if (wrapped.Target is Test07) return typeof(Test07).Name;
        }
        throw new NotImplementedException(value?.GetType().Name);
    }


    void Start()
    {
        var engine = new Jint.Engine();
        engine.SetValue("wrapBool", new Func<Jint.Native.JsValue>(() => {
            return Jint.Native.JsValue.FromObject(engine, true);
        }));
        engine.SetValue("wrapTest07", new Func<Jint.Native.JsValue>(() => {
            return Jint.Native.JsValue.FromObject(engine, new Test07());
        }));
        engine.SetValue("directTest07", new Func<object>(() => {
            //FromObjectしなくても…
            return new Test07();
        }));
        engine.SetValue("showType", new Action<Jint.Native.JsValue>(v => {
            Debug.Log(GetJsValueType(v));
        }));
        var code =
@"
showType(wrapBool()); //Boolean
showType(wrapTest07()); //Test07
showType(directTest07()); //Test07 勝手にWrapされる
";
        engine.Execute(code);
    }
}

GameObjectにJintTest07をアタッチして確認してください。
Test07ObjectWrapperでラップされていることが分かります。
一方、FromObjectしなくてもTest07ObjectWrapperでラップされていることも分かります。
なのであまり出番はないでしょう。

Arrayを受ける

まずはArrayの受け方です。これはシンプルです。

JintTest08
using System;
using UnityEngine;

public class JintTest08 : MonoBehaviour
{
    void Start()
    {
        var engine = new Jint.Engine();
        engine.SetValue("showArray", new Action<Jint.Native.JsValue>(jsValue =>
        {
            //JsValueで受けてキャストする
            var jsArray = jsValue as Jint.Native.JsArray;
            foreach (var v in jsArray)
            {
                var o = v.ToObject();
                Debug.LogFormat("{0}:{1}", o.GetType().Name, o.ToString());

            }
        }));
        var code =
@"
showArray([4, ""5"", 6]); //Double:4 String:5 Double:6
";
        engine.Execute(code);
    }
}

GameObjectにJintTest08をアタッチして確認してください。
Jint.Native.JsArrayIEnumerable<>を実装しているので、普通にforeachでOKです。
深さのある配列の場合は、ToObjectせずに再帰でforeachして下さい。

注意点があります。
配列であっても基本的には常にJint.Native.JsValueで受けることをお勧めします。
キャストが面倒だからといって、直接Jint.Native.JsArrayで受けるとエラーになる場合があります。

JintTest09
using System;
using UnityEngine;

public class JintTest09 : MonoBehaviour
{
    static void ShowArray(Jint.Native.JsArray jsArray)
    {
        foreach (var v in jsArray)
        {
            var o = v.ToObject();
            Debug.LogFormat("{0}:{1}", o.GetType().Name, o.ToString());

        }
    }

    class Test09
    {
        //クラスメソッド場合、直接JsArrayで受けてもエラーにならない
        public void showArray(Jint.Native.JsArray jsArray)
        {
            ShowArray(jsArray);
        }
    }

    void Start()
    {
        var engine = new Jint.Engine();

        //関数を登録した場合、直接JsArrayで受けようとすると内部でエラーになる
        engine.SetValue("showArray", new Action<Jint.Native.JsArray>(jsArray =>
        {
            ShowArray(jsArray); //実行されない
        }));
        try {
            var code1 =
@"
showArray([4, ""5"", 6]);
";
            engine.Execute(code1);
        } catch(Exception ex) {
            Debug.LogException(ex); //InvalidCastExceptionになる
        }

        //クラスメソッドの場合はエラーにならない
        engine.SetValue("Test09", typeof(Test09));
        var code2 =
@"
(new Test09()).showArray([4, ""5"", 6]); //Double:4 String:5 Double:6
";
        engine.Execute(code2);
    }
}

GameObjectにJintTest09をアタッチして確認してください。
SetValueで関数を直接登録した場合と、クラスを登録してクラスメソッドを実行する場合を比較しています。
両方ともJint.Native.JsArrayで直接受けています。

関数を直接登録した場合、内部エラーになります。
一方、クラスを登録した場合は内部エラーになりません。
混乱の元になると思いますので、一律Jint.Native.JsValueで受けてキャストすることをお勧めします。
(細かい理屈までは勉強しきれていません、ごめんなさい)

Objectを受ける

こちらはArrayと違って少々手間がかかります。

JintTest10
using System;
using UnityEngine;

public class JintTest10 : MonoBehaviour
{
    void Start()
    {
        var engine = new Jint.Engine();
        engine.SetValue("showObject", new Action<Jint.Native.JsValue>(jsValue =>
        {
            //JsValueで受けてキャストする
            var jsObject = jsValue as Jint.Native.JsObject;
            //Object.getOwnPropertyNamesとObject.getOwnPropertyDescriptorsをあわせた様な関数
            foreach (var (k, p) in jsObject.GetOwnProperties())
            {
                var o = p.Value.ToObject();
                Debug.LogFormat("{0}:{1}:{2}", o.GetType().Name, k.ToObject(), o.ToString());

            }
        }));
        var code =
@"
showObject({a: 1, b: ""ok""}); //Double:a:1 String:b:ok
";
        engine.Execute(code);
    }
}

GameObjectにJintTest10をアタッチして確認してください。

残念ながらJint.Native.JsObjectにはIDictionaryのようなものは実装されていません。
そのため、GetOwnProperties()foreachで処理します。
深さのあるオブジェクトの場合は、ToObjectせずに再帰でforeachして下さい。

GetOwnProperties()Object.getOwnPropertyNames()Object.getOwnPropertyDescriptors()を合わせたような関数です。

これもArrayの時と同様に、基本的にJint.Native.JsValueで受けることをお勧めします。
Jint.Native.JsObjectで受けると、Arrayの時とはまた違うエラーとなり、さらに面倒になります。

関数を受ける

これは少し注意が必要です。
まずは普通に関数を受ける場合です。

JintTest11
using System;
using UnityEngine;

public class JintTest11 : MonoBehaviour
{
    void Start()
    {
        var engine = new Jint.Engine();
        engine.SetValue("log", new Action<object>(o => Debug.Log(o)));
        engine.SetValue("runCallback", new Action<Jint.Native.JsValue>(jsValue =>
        {
            //基本的にはFunctionで扱う
            if (jsValue is Jint.Native.Function.Function func)
            {
                engine.Invoke(func, "Test11", "ok");
            }
            //バインド済み関数のみBindFunctionとして扱う
            if (jsValue is Jint.Native.Function.BindFunction bindFund)
            {
                engine.Invoke(bindFund, "bindTest11", "ok");
            }
        }));
        var code =
@"
const c = (msg1, msg2) => log(msg1+msg2);
runCallback(c); //Test11ok
runCallback(c.bind(null)); //bindTest11ok
";
        engine.Execute(code);
    }
}

GameObjectにJintTest11をアタッチして確認してください。

先程までのArray等と同様に、Jint.Native.JsValueで受けて下さい。
キャスト先は基本的にはJint.Native.Function.Functionです
ただし、bind()した関数の場合のみ、Jint.Native.Function.BindFunctionとして下さい。
あとはInvoke()すればOKです。

注意が必要なのはここからです。

JintTest12
using System;
using UnityEngine;

public class JintTest12 : MonoBehaviour
{
    Action func;

    void Start()
    {
        var engine = new Jint.Engine();
        engine.SetValue("log", new Action<object>(o => Debug.Log(o)));
        //funcとして保持し…
        engine.SetValue("runCallback", new Action<Action>(func => this.func = func));
        var code =
@"
const ng = (msg = () => ""ok"") => log(""Test12""+msg());
runCallback(ng);
";
        engine.Execute(code);
        //このタイミングで実行しようとする
        func();
    }
}

GameObjectにJintTest12をアタッチして確認してください。
NullReferenceExceptionになります。

これは Jint.Native.Function.FunctionをC#のActionで受けたためです。
具体的には、デフォルト引数に関数を扱うとエラーになります。
おそらくレルムやスコープ相当の情報が欠落しているためと思われます。
(細かい理屈までは勉強しきれていません、ごめんなさい)

そのため、やはりJint.Native.JsValueで受けるのが大事になります。

JintTest13
using System;
using UnityEngine;

public class JintTest13 : MonoBehaviour
{
    Jint.Native.Function.Function func;

    void Start()
    {
        var engine = new Jint.Engine();
        engine.SetValue("log", new Action<object>(o => Debug.Log(o)));
        engine.SetValue("runCallback", new Action<Jint.Native.JsValue>(func =>
        {
            //Invokeで動かすためにJint.Native.Function.Functionで保持しておく
            this.func = func as Jint.Native.Function.Function;
        }));
        var code =
@"
const ng = (msg = () => ""ok"") => log(""Test13""+msg());
runCallback(ng);
";
        engine.Execute(code);
        //問題なく動く
        engine.Invoke(func); //Test13ok
    }
}

GameObjectにJintTest13をアタッチして確認してください。
問題なく動きました。

しかし、保持したfuncfunc()のように直接実行できない点に注意して下さい。
Engine.Invoke()にて実行する必要があります。

カスタムエラーを投げる

これはC#上で完結させようとすると、極めて面倒です。
もしかすると、筆者の知らない方法があるのかもしれません。何かご存じの方はコメント下さい。

JavaScript上でいうところの、この様な状況を作りたい場合です。

JintTest14
using System;
using UnityEngine;

public class JintTest14 : MonoBehaviour
{
    void Start()
    {
        var engine = new Jint.Engine();
        engine.SetValue("log", new Action<object>(o => Debug.Log(o)));
        var code =
@"
class CustomError extends Error{
    static{
        this.prototype.name = ""CustomError"";
    }
    constructor(type){
        super();
        this.errorType = type
    }
}
try{
    throw new CustomError(""Test14"");
}catch(e){
    if(e instanceof Error) log(""e is Error""); //e is Error
    if(e instanceof CustomError) log(""e is CustomError""); //e is CustomError
    log(e.errorType); //Test14
}
";
        engine.Execute(code);
    }
}

GameObjectにJintTest14をアタッチして確認してください。
要は、独自のプロパティを付けたカスタムエラーを作りたい、という状況です。

これをC#のみで行う場合Reflectionを駆使します。

JintTest15
using System;
using UnityEngine;

public class JintTest15 : MonoBehaviour
{
    Jint.Native.Object.ObjectInstance GetPrototypeObject(Jint.Native.Error.ErrorConstructor source)
    {
        var property = typeof(Jint.Native.Error.ErrorConstructor)
            .GetProperty("PrototypeObject", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)
            .GetValue(source);
        return (Jint.Native.Object.ObjectInstance)property;
    }

    Jint.Native.Error.ErrorConstructor CreateCustomErrorConstructor(Jint.Engine engine, string name)
    {
        var intrinsicDefaultProto = new Func<Jint.Runtime.Intrinsics, Jint.Native.Object.ObjectInstance>(
            intrinsics => engine.Intrinsics.Error.Prototype
        );
        var realm = (Jint.Runtime.Realm)typeof(Jint.Engine)
            .GetProperty("Realm", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)
            .GetValue(engine);

        //ErrorConstructorがsealedかつコンストラクタがinternalのため
        var ret = Activator.CreateInstance(
            typeof(Jint.Native.Error.ErrorConstructor),
            System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance,
            null,
            new object[]
            {
                engine,
                realm,
                engine.Intrinsics.Error.Prototype,
                GetPrototypeObject(engine.Intrinsics.Error),
                new Jint.Native.JsString(name),
                intrinsicDefaultProto
            },
            null
        );
        return (Jint.Native.Error.ErrorConstructor)ret;

    }

    void Start()
    {
        var engine = new Jint.Engine();
        //CustomErrorのコンストラクタを作る
        var customErrorConstructor = CreateCustomErrorConstructor(engine, "CustomError");
        engine.SetValue("log", new Action<object>(o => Debug.Log(o)));
        //CustomErrorのコンストラクタを型として登録する
        engine.SetValue("CustomError", customErrorConstructor);
        //独自プロパティを付けるために、JavaScript中でnewさせずにC#側で作る。
        engine.SetValue("NewCustomError", new Func<Jint.Native.JsValue, Jint.Native.JsValue>(type =>
        {
            var customError = customErrorConstructor.Construct();
            customError.Set("errorType", type);
            return customError;
        }));
        var code =
@"
try{
    throw NewCustomError(""Test15"");
}catch(e){
    if(e instanceof Error) log(""e is Error""); //e is Error
    if(e instanceof CustomError) log(""e is CustomError""); //e is CustomError
    log(e.errorType); //Test15
}
";
        engine.Execute(code);
    }
}

GameObjectにJintTest15をアタッチして確認してください。
おおよそ同じ状況を作れました。
ちなみに、弊ツールCSEmulatorも、ClusterScriptErrorを作るときはこれです。

あまりにも複雑になったので、実装の詳細はコメントを確認してください。
要はカスタムエラーを作りたい場合、ErrorConstructorのコンストラクタを叩く必要があるということです。

これはengine.IntrinsicsからアクセスできるJint.Runtime.Intrinsicsの実装を確認すると、その必要性が分かります。
例えばJavaScriptのTypeErrorはこのようにErrorConstructorから作られています。

public ErrorConstructor TypeError =>
        _typeError ??= new ErrorConstructor(_engine, _realm, Error, Error.PrototypeObject, _typeErrorFunctionName, static intrinsics => intrinsics.TypeError.PrototypeObject);

細かい注意点を列挙

あとは実際に使うときに、注意しておいたほうがいい点をダラダラとメモレベルで書いていきます。

何はともあれ、Jint.Native.JsValueで受ける

C#のActionobjectなどで受けるのをやめましょう。
面倒ですが、これで受けると謎エラー由来の面倒が減ります。
筆者はこれで3度か4度バグ修正しています。調査にもめちゃめちゃ時間かかりました。

Jint.Engineのインスタンスは何かと使う

JsValue.FromObject()Engine.Invoke()、カスタムエラーを作る場合などで使います。
持ち回れるか隠蔽できる設計にしておきましょう。

おわり

Jintまわりの情報を募集しています!!!
記事にコメントか、X(Twitter)の方に連絡下さい!!

よき、Jintライフを!!!!!!!!!!

Discussion