【Unity】MODを実装するために自作プログラム言語を実装してみた

Unity

Unity2Dでゲームを作っている者です。

自分は他の人の作ったゲームをやるのも好きなのですが、1番好きなのがクリア後にゲームを改造して遊ぶことです。(自分はMなので敵を強くして難易度を上げたりするのが好きです)

特にRPGツクールMVはゲームデーターがむき出しなので改造しやすいですね。

複雑なMODを作るためには自作プログラム言語が必要

そこで自分がゲームを作るなら、需要の有無にかかわらず改造はできるようにしておきたいなあっていう気持ちがあります。

ただUnityで使用されているC#はコンパイル必須のため、ソースファイルをむき出しにしておくことは難しいです。

先日発売されたサキュバスデュエルのように、一部コードを外だしして読み込む方式にしなければいけません。

ゲームの敵ステータスなどをテキストファイルから読み込むようにするのは簡単ですが、例えば「メッセージの表示を変更」などという複雑処理もカスタマイズできるようにするにはIf文をテキストに定義できるようにしたい…。

そこで勉強がてら、C#から呼び出すことができる自作プログラム言語を作ってみました。

作成したUnity用自作プログラム言語

早速ですが、一旦作成してみたものは下記になります。

プロジェクトのZIP

サンプルプロジェクト本体

上記のプログラムにTestData\test.txtというファイルが含まれてます。

このファイルが自作のプログラム言語になります。ゲームをスタートするとこれを読み取って、デバッグログを吐き出すようになっています。

プログラムソース(Git)

OriginalScriptTest/Assets/Script at main · namekoX/OriginalScriptTest
Contribute to namekoX/OriginalScriptTest development by creating an account on GitHub.

ネット上からソースを見たい人はGitにも上げているのでこちらの参照をお願いします。

このサンプルプログラムで実現していること

下記のような自作のプログラミングで書かれているテキストを読み込んで、書かれている処理の通りにUnity側で処理します。

今回のサンプルでは、UnityにDebug.Logを出力させるだけという簡単なものです。

作成した自作プログラムの構文紹介

まず、このプログラムには元ネタがあります。

Web備忘録さんの「C# で作るインタプリタ」で実装されているGorilla言語を参考にしています。

C# で作るインタプリタ
C# で作るインタプリタ C# でオリジナルのプログラミング言語のインタプリタを作ります。 Go言語でつくるインタプリタ | Thorsten Ball, 設樂 洋爾 |本 | 通販 | Amazon 上の書籍を参考にして作ります。Go言語

自分は普段Java使ってる関係で、メソッドの先頭を小文字などにしているため、C#使いの方は上記のソースを参考にした方がわかりやすいかと思います。

ただGorilla言語は参考書籍の60%しか実装していないとのことで、個人的に拡張したい部分が何個かありました。

Gorilla言語に以下を追加しています。

  • 宣言した変数に値を再代入する
  • for文、while文の実装
  • Gorilla言語では「let x = 0;」という形で変数を宣言するが「int x = 0;」という型指定もできるようにした
  • 配列の実装
  • 連想配列の実装
  • if文で else if を使えるようにした
  • 組込関数のlenの実装(引数の長さを返す)
  • 関数に名前を付けられるようにした(Gorilla言語では無名関数しか使えない)
  • インクリメントに対応

他にも細かいところを追加していたり、自分好みにしていたりすると思います。

この自作プログラムで使用可能な構文のサンプルを紹介します。

変数の宣言

変数の宣言は以下の4パターンになります。

string a = "あいうえお";
int b = 1000;
float c = 100.05;
var d = "かきくけこ";
var e = true;

stringは文字列型の変数を宣言、intは整数型の変数を宣言、floatは小数点有数値の変数を宣言する文になります。

varは型を指定せずに使用できるものです。関数やBooleanなどを入れておくこともできます。

後述する配列や連想配列ではvarを使用します。

数値計算、比較

数値の四則演算や比較を行うことができます。

int a = 10 + 5 ;  // 15が格納される
int b = 10 * 5 ; // 50が格納される
int c = 10 / 5 ; // 2が格納される
b += 10;         // 60が格納される
b++;             // 61が格納される

var b = (a == b); // falseが格納される
var b2 = (b > a); //trueが格納される

括弧を使うことで優先順位を規定することもできます。例えば(10 + 2) * 2は40になります。

ちなみに%(余りの算出)には対応していません。自分があまり使わないからです。

if文

if文、if – else文を使うことができます。

// 下記を実行すると10が返却されます

int a = 10;
if (a == 5){
    return 5;
} else if (a == 8){
    return 8;
} else {
    return a;
}

for文、while文

for文、while文を使用してループをさせることができます。

for文の引数は、(初期化式; 条件式; 変化式)となっています。よくある形なのでプログラムを触ったことがある人は対応できる形と思います。

while文ではtrueである限りループが継続します。

int a = 0;
for(int i = 0 ; i < 10 ; i ++){
    a++; // 10回実行される
}

while(a < 20){
    a++; // aが20以上になるまで実行される
}

関数と関数の呼び出し

自作の関数を作成することができます。

関数の戻り値の型定義などはできないです。サブルーチン(戻り値無し)の場合も同じ構文を使用します。

関数を呼び出す文は「関数名 + “()”」になります。

function double(int a){
    return a * 2; // 引数で与えられた数を2倍して返す
}

int b = double(10); // 20が格納される

配列、連想配列

配列、連想配列を使用することができます。どちらもvarを使用して宣言を行います。

配列は2種類の初期化方法があります。連想配列は面倒ですが1つずつaddで突っ込んでいく形です。

// 配列の宣言1
var arr[2];
arr[0] = "test";
arr[1] = "test2";
arr[2] = "test3";
string test = arr[1];

// 配列の宣言2
var arr2 = ["test" , "test2" , "test3"];

// 連想配列の宣言
var hash = createHashMap();
hash.add("test", "11") // キー、値の順で引数に設定する
hash.add("test2", "22")
hash.add("test3", "33")

string test2 = hash.get("test2");

また配列に格納する型を縛ることはできません。

コメント

コメントは // で1行コメント、/*と */で囲むと複数行コメントになります。

/* この行はコメント扱いです。
   この行もコメント扱いです*/

// これもコメントです

使い方のサンプル

この自作プログラムを使ってユーザーがゲームの動きを変えられるようにしてみます!!

読み込む自作プログラムファイルの作成

まず、下記のようなテキストファイルを作成してTestData\test.txtに配置します。文字コードはUTF-8とします。

/* 
   デバッグログを表示するための関数です。
   この関数はプログラム側から呼び出すので削除しないでください。
   
   パラメータ:name 名前
   パラメータ:timeId 朝の場合:1、昼の場合:2、夜の場合:3
   パラメータ:count 実行回数
*/ 
function debugLog(string name, int timeId, int count){
    
    // 引数のtimeId値によって挨拶を変更
    string greet = "";
    if(timeId == 1){
        greet = "おはよう";
    } else if(timeId == 2){
        greet = "こんにちわ";
    } else {
        greet = "こんばんわ";
    }

    for(int i = 0 ; i < count ; i++){
        // デバッグログを出力します。
        showMessage(greet + "、" + addMrs(name));
    }
    
    var hash = createHashMap();
    hash.add("おはよう", "おはようで終わりました。")
    hash.add("こんにちわ", "こんにちわで終わりました。")
    hash.add("こんばんわ", "こんばんわで終わりました。")
    
    return hash.get("おはよう"); 
}

// 名前に敬称を追加
function addMrs(string name){
    return "Mrs." + name 
}

関数を2つ宣言しています。

2つ目のaddMrsは、引数で与えられたnameという変数に文字列を追加して返すだけの簡単なものです。

1つ目の関数がメインの関数で、デバッグログに文字列を出力し、出力条件によって返却値も変えるというものです。

デバッグログに文字列を出力するというのは、下記の部分になります。

// デバッグログを出力します。
showMessage(greet + "、" + addMrs(name));

このshowMessageは、自作プログラムには存在しない命令です。まずこれを解釈できるようにしていきます。

組み込み関数の追加

ShowMessage.csと言うクラスをC#側に実装します。

using System.Collections.Generic;
using UnityEngine;

// 引数で渡したメッセージを表示する組み込み関数
public class ShowMessage : OstBuiltinFunctions
{
    // メッセージを表示する
    public OsObject eval(List<OsObject> args)
    {
        Debug.Log("メッセージ: " + args[0]);

        return new OsNull();
    }

    // 関数の引数が正常であることをチェックする 正常時tureを返却
    public bool checkArgs(List<OsObject> args)
    {
        if (args == null || args.Count != 1)
        {
            // 引数1つ以外はエラー
            return false;
        }

        return true;
    }

    public OsObjectType type() => OsObjectType.BUILTINFUNCTIONS;
    override public string ToString() => "showMessage()";
}

OstBuiltinFunctionsというインターフェースを継承しています。OstBuiltinFunctionsは自作の関数を追加するときに継承するクラスです。

OstBuiltinFunctionsは4つのメソッドの実装を要求します。

evalメソッド → この関数を実行した時に、実行したい内容

checkArgsメソッド → この関数に与えられた引数が正しいかチェック。駄目ならfalseを返却する(共通処理側でエラーとして処理します。)

typeメソッド → これが何の種類かを表す。自作関数の場合は「OsObjectType.BUILTINFUNCTIONS;」で固定

ToStringメソッド → ToStringされたときに返却する文字列。この関数では使わないので適当。

今回は「デバッグログを出力する」という内容にしたいため、evalで「Debug.Log」を出力するようにしています。実際にはゲームの内容を変更するようなプログラムになるかと思います。

引数には使用したいメッセージを1つだけ与えてほしいので、下記のように引数が2以上与えられたらエラーになるようにしています。

// エラーになる例です
showMessage("test" , "test2");

上記サンプルのevalメソッドは、「return new OsNull();」を返却しています。これは自作プログラム上でNULLを表すためのクラスです。

自作プログラム側にNULLを返している理由ですが、今回のshowMessageでは返却値を使用しないためです。仮にshowMessageが文字列を返すことを求められる時は、「return new OsString(“返却したい値”) ;」になります。

そして自作プログラム側は以下のように返却値を利用することができます。

string retvalue = showMessage("test"); // retvalue に "返却したい値" が格納される

OsString以外にも整数型のOsInteger、実数型のOsFloatなどがあります。下記フォルダにあるオブジェクトを使うことができます。

OriginalScriptTest/Assets/Script/OriginalScript/Objects at main · namekoX/OriginalScriptTest
Contribute to namekoX/OriginalScriptTest development by creating an account on GitHub.

組み込み関数を呼び出せるようにする

これでshowMessageの中身の作成は終わりましたが、まだ呼び出すことはできません。自作プログラムとC#プログラムの紐づけができていないからです。

紐づけを行うには下記のプログラムを修正します。

OriginalScriptTest/Assets/Script/OriginalScript/Objects/OsEnviroment.cs at main · namekoX/OriginalScriptTest
Contribute to namekoX/OriginalScriptTest development by creating an account on GitHub.
using System.Collections.Generic;

// 宣言された変数値や関数などを格納しておくためのクラス
public class OsEnviroment
{
    // 変数を格納しておくためのDictionary
    public Dictionary<string, OsObject> store = new Dictionary<string, OsObject>();

    // 外部環境、自分の上のブロックで宣言された環境を引き継ぐために使う
    public OsEnviroment outer { get; set; }



    // 新規作成時、組込関数を設定する(引数falseで設定しない)
    public OsEnviroment()
    {
        setBuiltinFunc();
    }
    public OsEnviroment(bool isSetBuiltinFunc)
    {
        if (!isSetBuiltinFunc)
        {
            setBuiltinFunc();
        }
    }

    // 組み込み関数の設定
    protected void setBuiltinFunc()
    {
        store.Add("len", new OsLen());
        store.Add("createHashMap", new OsHash());
    }

    // 自分自身を引き継いで新しい環境を作成する
    public static OsEnviroment createNewEnclosedEnviroment(OsEnviroment outer)
    {
        OsEnviroment env = new OsEnviroment(false);
        env.outer = outer;
        return env;
    }

    public (OsObject, bool) get(string name)
    {
        bool ok = store.TryGetValue(name, out OsObject value);
        if (!ok && outer != null)
        {
            // 自環境に存在しない場合、外側の環境を確認
            (value, ok) = outer.get(name);
        }
        return (value, ok);
    }

    public OsObject set(string name, OsObject value)
    {
        store[name] = value;
        if (outer != null && outer.contains(name))
        {
            // 外部環境にある変数ならその変数も書き換え
            outer.set(name, value);
        }
        return value;
    }

    public bool contains(string name)
    {
        return store.ContainsKey(name);
    }
}

OsEnviromentは、自作プログラムにおいて宣言された変数や関数を格納しておくクラスです。ここにあらかじめ関数として格納しておけば、自作プログラム側から定義した関数を呼び出すことができます。

変更するのは下記です。

    // 組み込み関数の設定
    protected void setBuiltinFunc()
    {
        store.Add("len", new OsLen());
        store.Add("createHashMap", new OsHash());
        store.Add("showMessage", new ShowMessage ()); // ← 追加
    }

store変数(Dictionary型)はキーが自作プログラムの関数名、値がC#側の実装クラスになります。そこに今回のshowMessageを追加します。

上記で準備は整いました。

動かしてみる!!

OriginalScriptというクラスがメインのクラスになっています。動かすためにはこのクラスを使用します。

OriginalScriptTest/Assets/Script/OriginalScript/OriginalScript.cs at main · namekoX/OriginalScriptTest
Contribute to namekoX/OriginalScriptTest development by creating an account on GitHub.
public class OriginalScript
{
    // 読み込んだ変数や関数を格納しておく場所
    public OsEnviroment env;

    // プログラムソース
    public string input;

    public OriginalScript(string input)
    {
        this.input = input;
        env = new OsEnviroment();
    }
    public OriginalScript(string input, OsEnviroment env)
    {
        this.input = input;
        this.env = env;
    }

    // メインクラス
    public OsObject run()
    {
        OsLexer lexer = new OsLexer(input);
        OsParser parser = new OsParser(lexer);
        AstRoot root = parser.parseProgram();
        OsEvaluator evaluator = new OsEvaluator();
        return evaluator.eval(root, env);
    }

    // 新規環境を作成する(読み込んだ変数や関数を破棄)
    public void createNewEnv()
    {
        env = new OsEnviroment();
    }

    // プログラムソースを再読み込みする
    public void changeScript(string input)
    {
        this.input = input;
    }

    // 環境に該当の変数や関数があるのか確認する
    public bool contains(string name)
    {
        return env.contains(name);
    }
}

コンストラクタでプログラムコードを要求されるので、ファイルを読み込んでその文字列を渡してあげる必要があります。なのでファイル読み込みなどは呼び元側で行います。

以下のようなテストクラスを作成し、OriginalScriptクラスを実行してみます。

public class Test : MonoBehaviour
{

    void Start()
    {
        // ゲームのルートフォルダの下にある TestData\test.txt ファイルのパスを取得
        string filePath = Path.Combine(Application.dataPath, "../TestData/test.txt");

        // UTF-8エンコーディングでファイルの内容を読み込む
        string fileContents = readTextFile(filePath);

        // スクリプト実行準備
        OsEnviroment env = new OsEnviroment();
        OriginalScript os = new OriginalScript(fileContents, env);
        os.run();

        // スクリプト内にあるdebugLog関数を実行
        os.changeScript(@"debugLog(""ひろゆき"" , 1 , 1)");
        OsObject ret = os.run();

        // 戻り値を表示
        Debug.Log("戻り値: " + ret.ToString());
    }

    // ファイルを開く
    private string readTextFile(string path)
    {
        // ファイルが存在するか確認
        if (File.Exists(path))
        {
            // ファイルの内容をUTF-8で読み込む
            return File.ReadAllText(path, System.Text.Encoding.UTF8);
        }
        else
        {
            Debug.LogError("ファイルが見つかりません: " + path);
            return "";
        }
    }
}

上記を実行してみると以下のようになります。しっかりとデバッグログに文字列を出力してくれました!!

テスト用のクラスについて解説します。

        // スクリプト実行準備
        OsEnviroment env = new OsEnviroment();
        OriginalScript os = new OriginalScript(fileContents, env);
        os.run();

上記の部分でファイルから読み込んだ内容をOriginalScriptクラスに渡して、一度実行をしています。

ここで今回のファイル内容を再度見てみます。

/* 
   デバッグログを表示するための関数です。
   この関数はプログラム側から呼び出すので削除しないでください。
   
   パラメータ:name 名前
   パラメータ:timeId 朝の場合:1、昼の場合:2、夜の場合:3
   パラメータ:count 実行回数
*/ 
function debugLog(string name, int timeId, int count){
    
    // 引数のtimeId値によって挨拶を変更
    string greet = "";
    if(timeId == 1){
        greet = "おはよう";
    } else if(timeId == 2){
        greet = "こんにちわ";
    } else {
        greet = "こんばんわ";
    }

    for(int i = 0 ; i < count ; i++){
        // デバッグログを出力します。
        showMessage(greet + "、" + addMrs(name));
    }
    
    var hash = createHashMap();
    hash.add("おはよう", "おはようで終わりました。")
    hash.add("こんにちわ", "こんにちわで終わりました。")
    hash.add("こんばんわ", "こんばんわで終わりました。")
    
    return hash.get("おはよう"); 
}

// 名前に敬称を追加
function addMrs(string name){
    return "Mrs." + name 
}

このファイルの内容は、「関数を2つ定義しているだけ」なので動かしても何も起こらないのです。

※仮にこのファイルの末尾に「showMessage(“test”);」と追記すれば、デバッグログが記載されます。

次に、以下を実行しています。

        // スクリプト内にあるdebugLog関数を実行
        os.changeScript(@"debugLog(""ひろゆき"" , 1 , 1)");
        OsObject ret = os.run();

これは自作関数内にあるdebugLog関数を実行しています。(ダブルクォーテーションが2つになっているのは、C#側で扱ってるのでエスケープしないといけないためです。)

OsEnviromentクラスに変数や関数を格納していると前述しましたが、このOsEnviromentクラスはOriginalScript内で保存されているので前回実行した内容が引き継がれます。

1回目動かしたとき、TestData\test.txtに記載されている「function debugLog」を読みこんでいるため2回目にいきなり「debugLog(“”ひろゆき”” , 1 , 1)」と記載しても動きます。

コメント

タイトルとURLをコピーしました