ADVENT CALENDAR 2019
初めての組み込みスクリプティング(C# / Roslyn 編)
前回の記事はこちら
前回までは,C++で作成したプログラムにLuaを組み込む際の基礎事項や応用例を示してきました. 今回はRoslynを使って,C#をスクリプト言語として組み込む方法を紹介していきます. 以下に紹介する基礎事項は,前回の記事のようにゲーム作りに応用することができるはずです.
実行環境は以下の通りです.
- MacOS Catalina 10.15.1
- Visual Studio for Mac 2019 8.3.10
- mono 6.4.0.208
今回の記事の作成にあたり,以下の文献を参考にしました.
事前準備
其の壱 : プロジェクトの作成
今回はVisual Studio 2019を使います. まずは例の如くC#プロジェクトを作成しましょう.
其の弐 : Roslynのインストール
次に,Roslynを入手します.
Roslynとは,C#やVB.NETに向けたフリーかつオープンソースのコンパイラ・コード解析APIです.
NuGetから入手することはできますが,直接「Roslyn」と表記されているわけではなく,Microsoft.CodeAnalysis.Csharp.Scripting
をインストールすることで入手することができます.
これでRoslynの導入は完了です.
其の参 : usingディレクティブ
この記事では,C#をスクリプトとして組み込む際のサンプルソースコードを示していきますが,以下の名前空間をusing
していることを前提としています.
// C# 標準名前空間
using System;
using System.IO;
// Roslyn
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.CodeAnalysis.CSharp.Scripting;
C#スクリプトを組み込むときは,必ずこれらの名前空間をusing
しましょう.
実際に組み込んでみる
其の壱 : スクリプトを丸々実行する
C#スクリプトをただ実行する
ファイルに書かれているスクリプトを実行するためには,まずファイルの内容を読み込む必要があります.
File.OpenRead
メソッドで,スクリプトファイルのストリームオブジェクトを読み込みモードで作成し,CSharpScript.Create
メソッドで,スクリプトオブジェクトを作成します.
// ファイルからスクリプトを作成
var stream = File.OpenRead("Scripts/Script1-1.csx");
var script = CSharpScript.Create(stream, option);
上のソースコードについて,script
変数にはScript<object>
クラスのインスタンスが格納されています.
Script<object>
クラスのインスタンスは,与えられたスクリプトを実行するRunAsync
メソッドを持ちます.
// スクリプト実行
script.RunAsync();
何も考えずに実行するだけならこの3行を記述すればおkですが,ファイルが見つからなかったり,スクリプトファイル上にエラーが見つかった場合は例外がthrow
されます.
なので,一応例外処理をしておきましょう.
スクリプトのコンパイル時に発生する例外はMicrosoft.CodeAnalysis.Scripting.CompilationErrorException
です. Microsoft.CodeAnalysis.Scripting
名前空間をusing
しているため,try-catch
文にはCompilationErrorException
と書くだけでおkです.
その他発生した例外は全てException
でキャッチします.
// スクリプト実行時に発生した例外
catch (CompilationErrorException e)
{
Console.WriteLine(e.Message);
}
// プログラム実行時に発生した例外
catch (Exception e)
{
Console.WriteLine(e.Message);
}
ところで,CSharpScript.Create
メソッドの引数について説明していませんでしたね.
このメソッドは,第一引数にスクリプトファイルのストリームオブジェクト,第二引数にスクリプトの実行時オプションを指定します.
このオプションでは何を設定するかというと,スクリプトでusing
する名前空間や,読み込むクラスライブラリなどを設定することができます.
例えば,以下のように書いた場合
// スクリプトの実行時オプション
var option = ScriptOptions.Default.WithImports("System");
このoption
をCSharpScript.Create
メソッドの第二引数に指定したならば,読み込んだスクリプト側で「using System;
」と書くのと同じになります.
以上をまとめると,C#スクリプトを実行するだけのプログラムは以下のようになります.
//////////////////////////////////////////////////
//
// Program1-1.cs
// C# スクリプトを丸々実行する
//
using System;
using System.IO;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.CodeAnalysis.CSharp.Scripting;
namespace Roslyn
{
class Program1_1
{
public static void Run()
{
// スクリプトの実行時オプション
var option = ScriptOptions.Default.WithImports("System");
try
{
// ファイルからスクリプトを作成
var stream = File.OpenRead("Scripts/Script1-1.csx");
var script = CSharpScript.Create(stream, option);
// スクリプト実行
script.RunAsync();
}
// スクリプト実行時に発生した例外
catch (CompilationErrorException e)
{
Console.WriteLine(e.Message);
}
// プログラム実行時に発生した例外
catch (Exception e)
{
Console.WriteLine(e.Message);
}
}
}
}
例えばScripts/Script1-1.cs
に以下のようなプログラムが記述されていたとします.
Console.WriteLine("Hello world from roslyn!");
Program1_1
クラスのRun
メソッドが実行されたとき,コンソール画面にHello world
が出力されるはずです.
C#スクリプトを実行し,戻り値を取得する
例えばScripts/Script1-2.cs
に以下のようなプログラムが記述されていたとします.
int X = 36;
int Y = 64;
X + Y
このとき,セミコロンのついていない行が,このプログラムの戻り値になります.
この戻り値をプログラムで取得する方法を説明します.
まず,Script<object>
クラスのRunAsync
メソッドはTask<ScriptState<object>>
を戻り値とします.
プロブラムの戻り値を取得するには,まずはScriptState<object>
クラスのインスタンスを取得しなければなりません.
Task<ScriptState<object>>
クラスのインスタンスからScriptState<object>
クラスのインスタンスを取得するには,Result
プロパティを使用します.
// スクリプト実行
var result = script.RunAsync().Result;
ScriptState<object>
クラスのインスタンスは,プログラムの戻り値を取得するためのプロパティとしてResultValue
を持ちます.
ResultValue
はojbect
型であるため,戻り値の方がわかっている場合はその型キャストをする必要があります.
ただし,ResultValue
の内容をコンソールに出力するだけならば,キャストはしなくておkです.
// 取得した戻り値を出力
Console.WriteLine(result.ReturnValue);
以上をまとめると,C#スクリプトを実行し,戻り値を取得してコンソールに表示するプログラムは以下のようになります.
//////////////////////////////////////////////////
//
// Program1-2.cs
// C# スクリプトを実行し,戻り値を取得する
//
using System;
using System.IO;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.CodeAnalysis.CSharp.Scripting;
namespace Roslyn
{
class Program1_2
{
public static void Run()
{
// スクリプトの実行時オプション
var option = ScriptOptions.Default.WithImports("System");
try
{
// ファイルからスクリプトを作成
var stream = File.OpenRead("Scripts/Script1-2.csx");
var script = CSharpScript.Create(stream, option);
// スクリプト実行
var result = script.RunAsync().Result;
// 取得した戻り値を出力
Console.WriteLine(result.ReturnValue);
}
// スクリプト実行時に発生した例外
catch (CompilationErrorException e)
{
Console.WriteLine(e.Message);
}
// プログラム実行時に発生した例外
catch (Exception e)
{
Console.WriteLine(e.Message);
}
}
}
}
このProgram1_2
クラスのRun
メソッドが実行されたとき,コンソールには「100」と出力されるはずです.
其の弐 : 変数の受け渡し
C#スクリプトでも,Luaと同じように変数の受け渡しができます. しかも,Luaのようにスタックを使わなくても,スクリプト内の変数の値を簡単に受け渡しすることができます.
スクリプト側で定義された変数をプログラム側で取得する
スクリプト実行後の変数の内容は,ScriptState<object>
クラスのインスタンスメソッドGetVariable
で取得することができます.
ただし,GetVariable
メソッドはスクリプト内の変数の名前を引数にとります.
また,このメソッドの戻り値はScriptVariable
クラスのインスタンスで,戻り値の型および値の情報を持ちます.
戻り値の型を取得するにはType
プロパティ,戻り値を取得するにはValue
プロパティを使います.
Type
プロパティはType
型ですが,Value
はobject
型なので,スクリプト内の変数の値を使って何かをしたい場合は適切な型にキャストする必要があります.
// スクリプト実行後の変数を出力
var Number = state.GetVariable("Number");
Console.WriteLine("Number(" + Number.Type + ") = " + Number.Value);
var String = state.GetVariable("String");
Console.WriteLine("String(" + String.Type + ") = " + String.Value);
var Boolean = state.GetVariable("Boolean");
Console.WriteLine("Boolean(" + Boolean.Type + ") = " + Boolean.Value);
実際これだけです. C#プログラムの全体像は以下のようになります.
//////////////////////////////////////////////////
//
// Program2-1.cs
// スクリプト側で定義されたグローバル変数を
// プログラム側で取得する
//
using System;
using System.IO;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.CodeAnalysis.CSharp.Scripting;
namespace Roslyn
{
public class Program2_1
{
public static void Run()
{
// スクリプトの実行時オプション
var option = ScriptOptions.Default.WithImports("System");
try
{
// ファイルからスクリプトを作成
var stream = File.OpenRead("Scripts/Script2-1.csx");
var script = CSharpScript.Create(stream, option);
// スクリプト実行
var state = script.RunAsync().Result;
// スクリプト実行後の変数を出力
var Number = state.GetVariable("Number");
Console.WriteLine("Number(" + Number.Type + ") = " + Number.Value);
var String = state.GetVariable("String");
Console.WriteLine("String(" + String.Type + ") = " + String.Value);
var Boolean = state.GetVariable("Boolean");
Console.WriteLine("Boolean(" + Boolean.Type + ") = " + Boolean.Value);
}
// スクリプト実行時に発生した例外
catch (CompilationErrorException e)
{
Console.WriteLine(e.Message);
}
// プログラム実行時に発生した例外
catch (Exception e)
{
Console.WriteLine(e.Message);
}
}
}
}
このProgram2_1
クラスのRun
メソッドが実行されたとき,コンソールには以下が出力されるはずです.
Number(System.Double) = 3.14159265
String(System.String) = This is a C# script.
Boolean(System.Boolean) = True
プログラム側で設定された変数をスクリプト側で使用する
スクリプトを実行する前にグローバル変数をあらかじめ設定したいときは,Program1-1.cs
のRun
メソッド内容を少しだけ書き換える必要があります.
また,スクリプトに定義済みのグローバル変数を渡すには,そのグローバル変数を定義するためのクラスが別に必要になります.
// スクリプトに渡すグローバル変数
public class GlobalParams
{
public int X;
public int Y;
}
このクラスが定義されているという体で話を進めます.
まず,CSharpScript.Create
メソッドの第三引数に,スクリプトにグローバル変数を渡すためのクラスのType
を指定します.
var script = CSharpScript.Create(stream, option, typeof(GlobalParams));
次に,script.RunAsync
メソッドの引数に,スクリプトにグローバル変数を渡すためのクラスのインスタンスを指定します.
// スクリプト実行
script.RunAsync(new GlobalParams { X = 56, Y = 7 });
このようにすることで,C#スクリプトではX,Yをグローバル変数として使用することができます. 以上を踏まえて,以下にC#プログラムの全体像を示します.
//////////////////////////////////////////////////
//
// Program2-2.cs
// プログラム側で設定されたグローバル変数を
// スクリプト側で使用する
//
using System;
using System.IO;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.CodeAnalysis.CSharp.Scripting;
namespace Roslyn
{
public class Program2_2
{
// スクリプトに渡すグローバル変数
public class GlobalParams
{
public int X;
public int Y;
}
public static void Run()
{
// スクリプトの実行時オプション
var option = ScriptOptions.Default.WithImports("System");
try
{
// ファイルからスクリプトを作成
var stream = File.OpenRead("Scripts/Script2-2.csx");
var script = CSharpScript.Create(stream, option, typeof(GlobalParams));
// スクリプト実行
script.RunAsync(new GlobalParams { X = 56, Y = 7 });
}
// スクリプト実行時に発生した例外
catch (CompilationErrorException e)
{
Console.WriteLine(e.Message);
}
// プログラム実行時に発生した例外
catch (Exception e)
{
Console.WriteLine(e.Message);
}
}
}
}
このProgram2_2
クラスのRun
メソッドが実行されたとき,コンソールには以下が出力されるはずです.
56 plus 7 is 63.
56 minus 7 is 49.
56 multiplied by 7 is 392.
56 devided by 7 is 8.
これでプログラムとスクリプトの間で変数の値の受け渡しができるようになりました.
delegate
をうまいこと利用すれば,関数の受け渡しもできるかもしれません.
其の参 : プログラムで定義された関数をスクリプトで使用する
しかし「そんな面倒なことやっていられるか」と思う人もいるかもしれません.
そこで,先ほどのGlobalPrams
にメソッドを定義した場合,それがスクリプト側で利用できるかを試してみようと思います.
まず,プログラム側のコードとスクリプト側のコードを示していきます. まずはプログラム側です.
//////////////////////////////////////////////////
//
// Program3.cs
// プログラム側で定義されたグローバル変数を
// スクリプト側で使用する
//
using System;
using System.IO;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.CodeAnalysis.CSharp.Scripting;
namespace Roslyn
{
public class Program3
{
// スクリプトに渡すグローバル関数
public class GlobalParams
{
// 戻り値なし
public void Arithmetic(int x, int y)
{
Console.WriteLine(x + " plus " + y + " is " + (x + y) + ".");
Console.WriteLine(x + " minus " + y + " is " + (x - y) + ".");
Console.WriteLine(x + " multiplied by " + y + " is " + (x * y) + ".");
Console.WriteLine(x + " devided by " + y + " is " + (x / y) + ".");
}
// 戻り値あり
public double Length(double x, double y)
{
return Math.Sqrt(x * x + y * y);
}
}
public static void Run()
{
// スクリプトの実行時オプション
var option = ScriptOptions.Default.WithImports("System");
try
{
// ファイルからスクリプトを作成
var stream = File.OpenRead("Scripts/Script3.csx");
var script = CSharpScript.Create(stream, option, typeof(GlobalParams));
// スクリプト実行
var state = script.RunAsync(new GlobalParams());
}
// スクリプト実行時に発生した例外
catch (CompilationErrorException e)
{
Console.WriteLine(e.Message);
}
// プログラム実行時に発生した例外
catch (Exception e)
{
Console.WriteLine(e.Message);
}
}
}
}
Program2-2.cs
について,GlobalParams
クラスで定義されているものが変数からメソッドに変わっただけです.
続いてスクリプト側です.
// プログラム側で定義された関数を使って何かする
Arithmetic(56, 7);
double length = Length(3, 4);
Console.WriteLine("The length of (3,4) is " + length);
Program3
クラスのRun
メソッドを実行したとき,コンソールには以下の内容が出力されます.
56 plus 7 is 63.
56 minus 7 is 49.
56 multiplied by 7 is 392.
56 devided by 7 is 8.
The length of (3,4) is 5
戻り値の有無に拘らす,グローバル変数をスクリプトに与えるためのクラスに関数を定義しても,その関数はスクリプトで実行することができることがわかります.
其の肆 : C#スクリプトでインスタンスの内部状態を制御する
スクリプトにグローバル変数を渡すには,そのためのクラス(およびそのインスタンス)が必要であることはこれまでに何度か述べてきました.
ということは,script.RunAsync
メソッドに自身のインスタンス(つまり「this
」)を指定したとき,自身のインスタンスのグローバル変数がスクリプトに渡ることになり,内部状態をスクリプトで制御することができるかもしれません.
ということで,これまた実験をしてみました.
まず,敵機クラスを以下のように定義します.
// 敵機のクラス
public class Enemy
{
// 敵機ごとに保存するスクリプト
private readonly Script PlayerScript;
// 敵機の内部状態
public int FrameCount;
public double Position_X;
public double Position_Y;
public Enemy()
{
// 変数の初期化
Position_X = 320;
Position_Y = 160;
// スクリプトの実行時オプション
var option = ScriptOptions.Default.WithImports("System");
// ファイルからスクリプトを作成
var stream = File.OpenRead("Scripts/Script4.csx");
PlayerScript = CSharpScript.Create(stream, option, typeof(Enemy));
}
// 敵機の更新処理
public void Update()
{
// スクリプトを実行する
PlayerScript.RunAsync(this);
// 実行後の内部状態を出力
Console.WriteLine("X = {0}, Y = {1}", Position_X, Position_Y);
// フレーム数をカウント
++FrameCount;
}
}
敵機クラスのインスタンスは,位置情報と追加されてからのフレーム数を内部情報として持っています.
また,Update
メソッドでは,1フレーム分の更新処理を行っています. PlayerScript.RunAsync(this)
とあることから,フレームごとにこのスクリプトが実行されることがわかります.
Update
メソッドが呼び出されるたび,Position_X
およびPosition_Y
の値が変化するはずです.
それを確認するために,Update
メソッドでは位置情報をコンソールに出力する処理を追加しています.
次に,例の如くProgram4
クラスのRun
メソッドを以下のように作成します.
public static void Run()
{
try
{
// 敵機のインスタンスの作成
var enemy = new Enemy();
// 敵機の更新処理を30回繰り返す
for (int i = 0; i < 30; ++i) enemy.Update();
}
// スクリプト実行時に発生した例外
catch (CompilationErrorException e)
{
Console.WriteLine(e.Message);
}
// プログラム実行時に発生した例外
catch (Exception e)
{
Console.WriteLine(e.Message);
}
}
このメソッドでは30フレーム分の更新処理を行っています.
Program4
クラスには,内部にクラスと静的メソッド(Run
メソッド)が含まれます.
以下に全体像を示します.
//////////////////////////////////////////////////
//
// Program4.cs
// C# スクリプトでインスタンスの内部状態を制御する
//
using System;
using System.IO;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.CodeAnalysis.CSharp.Scripting;
namespace Roslyn
{
public class Program4
{
// 敵機のクラス
public class Enemy
{
// 敵機ごとに保存するスクリプト
private readonly Script PlayerScript;
// 敵機の内部状態
public int FrameCount;
public double Position_X;
public double Position_Y;
public Enemy()
{
// 変数の初期化
Position_X = 320;
Position_Y = 160;
// スクリプトの実行時オプション
var option = ScriptOptions.Default.WithImports("System");
// ファイルからスクリプトを作成
var stream = File.OpenRead("Scripts/Script4.csx");
PlayerScript = CSharpScript.Create(stream, option, typeof(Enemy));
}
// 敵機の更新処理
public void Update()
{
// スクリプトを実行する
PlayerScript.RunAsync(this);
// 実行後の内部状態を出力
Console.WriteLine("X = {0}, Y = {1}", Position_X, Position_Y);
// フレーム数をカウント
++FrameCount;
}
}
public static void Run()
{
try
{
// 敵機のインスタンスの作成
var enemy = new Enemy();
// 敵機の更新処理を30回繰り返す
for (int i = 0; i < 30; ++i) enemy.Update();
}
// スクリプト実行時に発生した例外
catch (CompilationErrorException e)
{
Console.WriteLine(e.Message);
}
// プログラム実行時に発生した例外
catch (Exception e)
{
Console.WriteLine(e.Message);
}
}
}
}
このProgram4
クラスのRun
メソッドが実行されたとき,コンソールには以下が出力されるはずです.
X = 320, Y = 170
X = 316.909830056251, Y = 179.510565162952
X = 311.031977533326, Y = 187.600735106701
X = 302.941807589576, Y = 193.478587629626
.
.
.
しっかりと内部状態を変更することができています.
ただし,このやり方には一つ問題があります.
Script<object>
クラスのインスタンスメソッドRunAsync
でスクリプトを実行する際,グローバル変数を渡すためのクラスの中でpublic
宣言された変数やメソッドのみを,スクリプト内で使用することができます.
そのため,Position_X
やPosition_Y
をprivate
変数にしてしまうと,アクセスできんぞとRoslynに怒られます.
C#スクリプトでインスタンスのprivate
変数の値を変更するには他の手段を使わなければなりません.
何かうまい方法はないものか,うーん……
終わりに/次回予告
今回はスクリプト言語として書かれたC#のプログラムを組み込む方法を示してきました. C++/Luaで組込スクリプティングのノウハウがあれば,このような場所でも組込スクリプティングを利用したプログラムを作れるのではないかと個人的に考えております. C++いじった事なくてC#しか慣れていないぞっていう人は,ぜひRoslynを用いたスクリプティングをしてみてはいかがでしょうか.
今回作成したサンプルソースコードはgithub上で公開しています. 詳しくは下のリンク先のリポジトリの「Day13」をご参照ください. https://github.com/GCLemon/ACAC2019
次回の20日目の記事では,C#にLuaを組み込んでいく方法を示していきたいと思います. 実は,LuaってC++だけでなくC#にも組み込めて,しかもC++よりも組み込みやすいんです!!それでは次回お会いしましょう.
SHARE THIS POST
Tweet