Unreal.jsでロジックを動的に変更する


この記事はUnreal Engine 4 (UE4) Advent Calendar 2018の20日目の記事です。

前日の記事は0xUMAさんの UE4 で自動テストを試してみる。 でした。

概要

UE4からWindows向けのパッケージを作成したあとで動作パターンを切り替えたい時があり、はじめの頃は外部テキストファイル(xmlやjsonなど)でパラメーターを変更できるようにして対応していました。

ですが、そのうちロジック自体も変更したいという要望がでてきました。 そこでUnreal.jsを使ってJavaScriptでロジックの外出しを実施した事を書きます。

この記事で作成したサンプルプロジェクトは以下に置いてあります。

対応方法選定

UE4ではEditorからはC++コードを変更してもホットリロードできるので、直ぐに(ちょっとは待たされる)動作確認できますがパッケージ化した後には使えません。

そこでUE4のRuntimeで動作可能なスクリプト機能のプラグインがないか調べたところ、以下の3つを見つけました。

MonoUEは個人的に好きなC#なので興味はありましたが、スクリプトとしてお手軽に使えなさそうだったので候補から外しました。

あと残った2つを考えましたが、個人的な好みでJavaScriptの方が良いかなと思いUnreal.jsを採用しました。

Unreal.jsとは

Unreal.jsについての基本的な情報入手はこちらが参考になりました。 2年ほど前の記事ですが現状の4.21の環境でもほぼそのまま利用できとても助かりました。

Unreal.js 入門 Unreal.js 入門 - Qiita

導入

マーケットプレイスよりUnreal.jsを追加します。

/images/2018/11/20/000000/20181219092954.jpg

その後UE4のプロジェクトをEditorから開き、Edit->PluginsからUnreal.jsを有効にします。 その後Editorを再起動すると使用可能になります。

試しにActorをレベルに配置してJavaScript Componentを追加し、Script Source FileにSample.jsと入力します。 Content/ScriptsにSample.jsを作り以下の内容を記述します。

console.log("test")

この状態でPlayして、Output Logに"test"と表示されていたら正常に動作しています。

/images/2018/11/20/000000/20181219093013.jpg

パッケージ後で確認

File -> Package Project -> Windows -> Windows(64-bit)でWindows用のパッケージを作成します。

パッケージ作成後、以下のディレクトリを作成したパッケージのContentにコピーします。

[Engine Install Path]\Engine\Plugins\Marketplace\UnrealJS\Content\Scripts

起動後すぐにアプリケーションを閉じて、Saved/Logs/***.log内に”test”が出力されていればOKです。

動的ロードの仕組みづくり

Unreal.jsのJavaScript Componentを使用するとjsファイルを実行することができましたが、これには以下の制限があります。

そこで上記制限をなくし、自由な場所にjsファイルを配置して実行中でもスクリプトを更新できる仕組みを作ってみます。 具体的に以下の方針で作成してみます。

C++クラス実装(JSObject)

jsファイルに定義するクラスの基底クラスを定義します。

FString NotifyTrigger();はアプリケーション側からJavaScript側にアクセスするインターフェイスです。 今回はFStringを返すメソッドにしていますが、これは状況に応じて変更します。 メソッドは複数用意しても良いです。 メソッドは必ずUFUNCTION(BlueprintImplementableEvent)にしておいて、オーバーライド専用メソッドにしておきます。そのためcpp側に実装は書きません。

UCLASS(BlueprintType)
class UNREALJSSAMPLE_API UJSObject : public UObject
{
    GENERATED_BODY()

public:   
    UJSObject();

    UFUNCTION(BlueprintImplementableEvent)
        FString NotifyTrigger();

};

C++クラス実装(JSComponent)

Unreal.jsのJavaScript Componentの実装を参考に実装します。

OnRegister

Unreal.jsのJavaScript Componentほぼそのままです。 JavaScriptを使うための準備とC++側とJavaScript側の連携登録もこの時点でおこなっています。

この中でもContext->Expose(“Root”, this);の1行を記述することで、このComponentクラスの参照がJavaScript内でRootとして使用できるようになります。

void UJSComponent::OnRegister()
{
    auto ContextOwner = GetOuter();
    if (ContextOwner && !HasAnyFlags(RF_ClassDefaultObject) && !ContextOwner->HasAnyFlags(RF_ClassDefaultObject))
    {
        if (GetWorld() && ((GetWorld()->IsGameWorld() && !GetWorld()->IsPreviewWorld())))
        {
            UJavascriptIsolate* Isolate = nullptr;
            UJavascriptStaticCache* StaticGameData = Cast<UJavascriptStaticCache>(GEngine->GameSingleton);
            if (StaticGameData)
            {
                if (StaticGameData->Isolates.Num() > 0)
                    Isolate = StaticGameData->Isolates.Pop();
            }

            if (!Isolate)
            {
                Isolate = NewObject<UJavascriptIsolate>();
                Isolate->Init(false);
                Isolate->AddToRoot();
            }

            auto* Context = Isolate->CreateContext();
            JavascriptContext = Context;
            JavascriptIsolate = Isolate;

            Context->Expose("Root", this);
            Context->Expose("GWorld", GetWorld());
            Context->Expose("GEngine", GEngine);
        }
    }

    Super::OnRegister();
}

LoadJSFile

jsファイルを読み込んで、その中に記述されたクラスをインスタンス化し参照を保持します。

void UJSComponent::LoadJSFile()
{
    if (JavascriptContext == nullptr) return;

    // ScriptSourceFileにはContentからの相対パスが入っているので絶対パスに直す
    auto scriptSourceFilePath = FPaths::Combine(FPaths::ProjectContentDir(), ScriptSourceFile);
    scriptSourceFilePath = FPaths::ConvertRelativePathToFull(scriptSourceFilePath);

    // jsファイルの中身を読み込む(まだ実行はしない)
    FString script;
    FFileHelper::LoadFileToString(script, *scriptSourceFilePath);

    // jsに書いたクラス名を抜き出す
    const FRegexPattern pattern = FRegexPattern(FString(TEXT("class\\s+(.+)\\s+extends\\s+JSObject")));
    FRegexMatcher matcher(pattern, script);
    if (matcher.FindNext())
    {
        auto className = matcher.GetCaptureGroup(1);

        // スクリプトにクラスのインスンタンス化と参照保持のコードを足す
        script = TEXT("(function (global) {\r\n") + script;
        script += TEXT("let MyUObject_C = require('uclass')()(global,") + className + TEXT(")\r\n");
        script += TEXT("let instance = new MyUObject_C()\r\n");
        script += TEXT("Root.SetJsObject(instance)\r\n");
        script += TEXT("})(this)");

        // スクリプト実行
        JavascriptContext->RunScript(script);
    }
}

ポイントはjsファイルをそのままスクリプトとして実行するのではなく、jsの中身の上下にスクリプトを足してクラスのインスタンス化と参照保持まで行ってます。

script += TEXT(“Root.SetJsObject(instance)\r\n”);の部分はOnRegister()時に関連付けておいた仕組みを利用してスクリプト内からComponentの関数をコールしています。 これによりJavaScriptのクラスのインスタンスがC++で実装されたComponentのプロパティとして保持されます。

NotifyTrigger

ブループリントからComponent経由でJavaScriptに実装したクラスの関数をコールするための関数です。 JsObjectにjsクラスの参照が保持されているため、そこ経由でコールします。

FString UJSComponent::NotifyTrigger()
{
    if (JsObject != nullptr)
    {
        return JsObject->NotifyTrigger();
    }

    return FString();
}

ブループリント実装(SampleActor)

空のActorに先ほど作成したJSComponentとTextRenderを追加します。

/images/2018/11/20/000000/20181219092505.png

またjsに実装するメソッドNotifyTriggerを呼び出す関数を作成します。 NotifyTriggerの結果でTextRenderの文字列を書き換えるようにします。

/images/2018/11/20/000000/20181219092514.png

次にjsファイルの再ロードを行う関数を作成します。

/images/2018/11/20/000000/20181219092734.png

ブループリント実装 (SampleUMG)

Widgetでボタンを2つ作り、1つはSampleActorのNotifyEventメソッドを、もう1つはUpdateJSメソッドを呼び出すようにします。

/images/2018/11/20/000000/20181219092755.png
/images/2018/11/20/000000/20181219092807.png

jsファイル作成

スクリプトとして使用するjsファイルを作成します。 作成したファイルは任意の場所におけますが、今回はContentフォルダーと同じ階層に保存しておきます。 ここでは先にC++で実装したUJSObjectクラスを継承したクラスを定義します。 アプリケーションからはUJSObjectクラスのNotifyTriggerが呼ばれますので、その部分の実装をスクリプト内でオーバーライドします。

class MyUObject extends JSObject {
    NotifyTrigger(){
        let prop1 = 1;
        let prop2 = 2;
        return "ABC" + prop1 + prop2;
    }
}

JSComponentへjsファイルのパスを登録

レベルにSampleActorを置き、SampleActorに追加済みのJSComponentのScriptSourceFileにjsファイルのパスをContentからの相対パスで入力します。

Contentより上の階層でも大丈夫です。

実行

EditorよりWin64のパッケージを作成します。

以下のディレクトリを作成したパッケージのContentにコピーします。

[Engine Install Path]\Engine\Plugins\Marketplace\UnrealJS\Content\Scripts

また、パッケージのContentフォルダーと同じ階層に作成したjsファイルもコピーします。

上記準備ができたらアプリケーションを実行します。

TextRenderは初期値が表示されています。

/images/2018/11/20/000000/20181219092834.png
Triggerボタンを押すと、JavaScriptで実装された関数がコールされTextRenderの表示が変わります。

/images/2018/11/20/000000/20181219092856.png
ここでアプリケーションは閉じずにjsファイルの中身を書き換えて上書き保存します。

class MyUObject extends JSObject {
    NotifyTrigger(){
        let prop1 = 3;
        let prop2 = 4;
        return "ZZZ" + prop1 + prop2;
    }
}

アプリケーションのUpdateJSボタンを押してから、Triggerを押すとTextRenderの表示が書き換えたスクリプトの結果に変わっています。

/images/2018/11/20/000000/20181219092921.png

まとめ

Unreal.jsを使用してパッケージ作成後の状態でも動的にスクリプト機能を使用することができました。

この実装の良さは必要最低限のみJavaScriptに任せることで、ある程度速度的にも有利なのとロジックが固まったらC++のクラスに差し替える事でさらにパフォーマンスを上げられる事だとおもいます。

よろしければお試しください。

明日はfukusuke8gouさんのUMG関連のお話です。

参考

UE4 

See also