ADVENT CALENDAR 2019

初めての組み込みスクリプティング(C# / Roslyn 編)

By 檸檬茶(Lemon TEA)

前回の記事はこちら

前回までは,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#プロジェクトを作成しましょう.

ACAC-2019-12-13(1).png ACAC-2019-12-13(2).png

其の弐 : Roslynのインストール

次に,Roslynを入手します. Roslynとは,C#やVB.NETに向けたフリーかつオープンソースのコンパイラ・コード解析APIです. NuGetから入手することはできますが,直接「Roslyn」と表記されているわけではなく,Microsoft.CodeAnalysis.Csharp.Scriptingをインストールすることで入手することができます.

ACAC-2019-12-13(3).png ACAC-2019-12-13(4).png

これで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");

このoptionCSharpScript.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を持ちます. ResultValueojbect型であるため,戻り値の方がわかっている場合はその型キャストをする必要があります. ただし,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型ですが,Valueobject型なので,スクリプト内の変数の値を使って何かをしたい場合は適切な型にキャストする必要があります.

// スクリプト実行後の変数を出力
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.csRunメソッド内容を少しだけ書き換える必要があります. また,スクリプトに定義済みのグローバル変数を渡すには,そのグローバル変数を定義するためのクラスが別に必要になります.

// スクリプトに渡すグローバル変数
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_XPosition_Yprivate変数にしてしまうと,アクセスできんぞと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