[Jint]Unity上でClusterScriptを動かすための一歩目
こんばんは!!かおもです!!!!!!!
弊ツール「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のundefined
がnull
判定になります。
こういう場合は 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
をアタッチして確認してください。
undefined
とnull
の判定もこの通り可能です。
ということで、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
をアタッチして確認してください。
公式に説明がある通りに変換されます。
しかしArray
とObject
は別の扱いをした方がよいので別に紹介します。
というのも、Array
とObject
をToObject()
すると再帰的にToObject()されます。
つまり知らないうちにundefined
がnull
になるような事が起きます。
Jint.Native.JsValueの型を判定する
Array
とObject
の扱いを紹介する前に、そもそも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
をアタッチして確認してください。
Array
やObject
やその他のプリミティブな値は素直に判定できます。
ここで注意すべきは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
をアタッチして確認してください。
Test07
がObjectWrapper
でラップされていることが分かります。
一方、FromObject
しなくてもTest07
がObjectWrapper
でラップされていることも分かります。
なのであまり出番はないでしょう。
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.JsArray
はIEnumerable<>
を実装しているので、普通に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
をアタッチして確認してください。
問題なく動きました。
しかし、保持したfunc
はfunc()
のように直接実行できない点に注意して下さい。
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#のAction
、object
などで受けるのをやめましょう。
面倒ですが、これで受けると謎エラー由来の面倒が減ります。
筆者はこれで3度か4度バグ修正しています。調査にもめちゃめちゃ時間かかりました。
Jint.Engineのインスタンスは何かと使う
JsValue.FromObject()
やEngine.Invoke()
、カスタムエラーを作る場合などで使います。
持ち回れるか隠蔽できる設計にしておきましょう。
おわり
Jintまわりの情報を募集しています!!!
記事にコメントか、X(Twitter)の方に連絡下さい!!
よき、Jintライフを!!!!!!!!!!
Discussion