ゲーム作りが大好きな人のブログ

ゲームを作るのが大好きな人のブログ。UE4とBlender、MAYA(LT)、3DCoatを使用しています!

【UE4】【C++】ブループリントのコンパイルを拡張してエラーを出す


ざっくり概要

  • ブループリントコンパイル時にノードの「◯◯の引数(ピン)」に「XXの値」が来たらエラーを出すサンプルです
  • UE4.27で検証(一部修正は必要かもですがUE5でも動作すると思います)
  • UnrealEngine C++中級者向け(内容は簡単だけど、C++とUEのある程度の理解は必要)
  • ソースコードは重要な部分のみ掲載
  • Unreal Engine (UE) Advent Calendar 2023」シリーズ3の21日目の記事となります

 

はじめに

Blueprintを触っていると「特定のBPノードの引数に特定の値が入っていたらエラーを出したい」と思う事があります…………あるよね?
分かりやすい例としてはデータテーブルで、データテーブルの項目(Key)を文字列で指定して値を取得する際に以下のような問題が発生する可能性があります。

  • 文字列が入力されてない
    • 原因
      • 単純に入れ忘れ
      • 根底のプログラムの更新で引数が追加された → BP側の修正対応が漏れて無記入に

 

  • 文字列がタイプミスしていてデータテーブルの値が取得できない
    • 原因
      • 文字列の末尾や途中にスペースが入っていた
      • 実は日本語が入っていた

 

  • データテーブルの項目がいつのまにか消滅して取得できない
    • 原因
      • データ整理の際に不要だと思って消してしまった
      • 操作ミスで項目を編集してしまった(蛇足の文字列が追加された、スペース等)


頭が痛いですね。
「項目が見つからなかった場合はランタイム上でエラーを吐けばいいのでは?」と思うかもですが、小規模クラス以上のゲームになってくるとブループリントやレベルが多すぎて全てをランタイム実行はほぼ不可能になります。(しかも動的ロードとか絡むともう無理な領域。エディターの段階でやるのが一番安牌でしょう)

自分がいま作っているインディーゲーもこの辺りの問題にぶちあたっています。これら問題を早期発見できるようにパッケージ作成時やオートメーションで問題を拾っていこうというのが今回のお題です。

(少し話がそれるけど)ウチのインディーゲーではどうしているか

下の画像は自作インディゲーのキー入力を取得できるBPマクロです。InputNameにはAttack、Decide等のゲームで使用するキーの文字列をコンボボックスで選択できるようになっています。この文字列を管理するデータテーブルが存在し、今回はそのデータテーブルの項目以外の文字列が入力されていた場合にエラーとしています。

というわけで概念の話は終わりましたので実装の話をしていきましょう。

ソースコードを説明する前に・・・

  • 今回の内容はモジュールを定義しているヘッダーファイルとソースファイルに書く想定です
  • Delegateの解除コードを本ページでは書いてないので自前でやっちゃってください

 

まずはブループリントコンパイルイベントを取得するデリゲートを引っ張ってくる

// モジュール起動時にエンジン初期化後イベントにデリゲートを仕込む
void FHogeModule::StartupModule()
{
	FCoreDelegates::OnPostEngineInit.AddRaw(this, &FHogeModule::OnPostEngineInit);
}

// エンジン初期化後にコンパイルイベントのデリゲートを仕込む
void FHogeModule::OnPostEngineInit()
{
#if WITH_EDITOR
	if (GEditor)
	{
		GEditor->OnBlueprintPreCompile().AddRaw(this, &FHogeModule::OnBlueprintPreCompile);
	}
#endif
}

肝となるのはOnBlueprintPreCompileです。名前の通りの意味でコンパイル前イベントとなります。
GEditorはエンジン初期化後に取得してくる方が適切かと思うので、上記のような流れで組んでください。またランタイムモジュールに仕込んでください。理由はパッケージ作成時にこのコードを通させたいからです。エディターモジュールは実行されないので注意です。
 

コンパイルイベントで呼び出す関数の中身

void FHogeModule::OnBlueprintPreCompile(UBlueprint* Blueprint)
{
#if WITH_EDITOR
	if (Blueprint == nullptr) { return; }

	const UEdGraphSchema_K2* k2Schema = GetDefault<UEdGraphSchema_K2>();

	// グラフを全て取得する
	TArray<UEdGraph*>	graphArray;
	Blueprint->GetAllGraphs(graphArray);

	// 全てのグラフから検索
	for (UEdGraph* pGraph : graphArray)
	{
		// 全ての関数の調査
		for (UEdGraphNode* pNode : pGraph->Nodes)
		{
			// 関数の中身のPinを全て調査
			for (UEdGraphPin* pPin : pNode->Pins)
			{
				// ピンのカテゴリは構造体
				if (pPin->PinType.PinCategory == k2Schema->PC_Struct)
				{
					// ピンのクラスは自分が調べたいクラス
					if (pPin->PinType.PinSubCategoryObject == FHogeKey::StaticStruct())
					{
						// 入力(引数)方向だけに対応する
						if (pPin->Direction == EEdGraphPinDirection::EGPD_Input)
						{
							// ピンは何か(例:変数ノード)に接続されていない
							if (pPin->LinkedTo.Num() == 0)
							{
								// ピンの値を取得する
								// UEのフォーマットで返ってくるので
								// ここの値は自分で変換処理をかけてやるのがいい
								FString str = pPin->GetDefaultAsString();

								// この文字列が存在するかのチェック
								// (今回はここの中身は省略します。お好きな判定関数をどうぞ)
								if (FHogeModule::IsExistString(*str) == false)
								{
									// 実際のエラー内容(中身に関してはこの後で説明します)
									FHogeModule::BlueprintError(
										Blueprint
										, pNode
										, *FString::Printf(TEXT("InputName Not Defined : %s"), *str)
									);
								}
							}
						}
					}
				}
			}
		}
	}
#endif
}

長くなりましたが非常に単純で、コメントを見てもらえれば分かるかと。この辺りは一度でもやると理解度がグンと広がるので組んでみるのがオススメです。次はBPにエラーを出すように要求するFHogeModule::BlueprintErrorの中身です。

FHogeModule::BlueprintErrorの中身

void FHogeModule::BlueprintError(UBlueprint* Blueprint, UEdGraphNode* pNode, const TCHAR* ErrorStr)
{
#if WITH_EDITOR
	// CurrentMessageLogの取得。これがOnBlueprintPreCompileのタイミングじゃないと取得できない
	if (FCompilerResultsLog* pLog = Blueprint->CurrentMessageLog)
	{
		// エラーとして扱う
		TSharedRef<FTokenizedMessage> pMessage = pLog->Error(ErrorStr);

		// コンパイルログからエラーをクリックした際に
		// エラーノードへジャンプさせる処理
		pMessage->AddToken(FUObjectToken::Create(pNode, FText::FromString(ErrorStr))
			->OnMessageTokenActivated(FOnMessageTokenActivated::CreateLambda(
				[](const TSharedRef<IMessageToken>& Token)
				{
					if (Token->GetType() == EMessageToken::Object)
					{
						const TSharedRef<FUObjectToken> UObjectToken = StaticCastSharedRef<FUObjectToken>(Token);
						if (UObjectToken->GetObject().IsValid())
						{
							FKismetEditorUtilities::BringKismetToFocusAttentionOnObject(UObjectToken->GetObject().Get());
						}
					}
				}
			)
			)
		);
	}

	// Node自体にエラー表記を出す(ノードの一番下が赤くなる)
	// これ自体はコンパイルエラーとは無関係で表示だけが切り替わるといった物
	pNode->ErrorType = EMessageSeverity::Error;
	pNode->ErrorMsg = ErrorStr;
	pNode->bHasCompilerMessage = true;
#endif
}

こちらも説明するよりコメントを読んでいただく方が手っ取り早いかと。

これでやりたい事は大体出来ました。ここからはハマりやすいポイントや補足の話になります。

ブループリントのコンパイルタイミングと罠

ブループリントのコンパイルタイミングはブループリントアセットがロードされた時が大半なのですが実はこれが曲者だったりします。


例えば引数にプレイヤースタートタグを入れるブループリントノードがあったとします。記述されたプレイヤースタートタグが存在するかチェックしようとした時、コンパイルが走ったタイミングでは全てのデータ(レベル)がロードされてない可能性があります。(プレイヤースタートが存在しない可能性があるという事です)


これを実現するには以下のような事をしないといけません。

  • マップの読み込みが終わった瞬間に呼ばれるDelegateを使う
    • FEditorDelegates::OnMapOpened
  • その後、指定ノード(指定ピン)を持つBlueprintを探し出し、再コンパイル要求を出す
    • コンパイル要求はこれ。FKismetEditorUtilities::CompileBlueprint

これで動的に生まれてくる以外であればプレイヤースタートが取得できるはずです。

一点調べきれていない点がありまして、この方法の場合パッケージ作成時にエラーチェックが動作するかは謎です。なんとなく無理そうな予感がしていて、その場合はオートメーションで炙り出すしかないかもしれませんね。

所持する変数の値が不正ならコンパイルエラーを出す

今回はBPノードの話でしたが変数でも同様の事が可能です。とはいえ変数はデータテーブルやデータアセットに持たせる事が多いので、これらにエラーが出せないというのが微妙な点でもあります。ただ変数側も問題を起こすケースが多い場合は実装するのも選択の一つとして考えてもいいかもしれません。

【未検証】コンパイル時にピンの中身を変化させる

PreCompileの段階でピンの値は自由に読み書きできるようなので特定のノードの特定のピンの書き換えもいけそうな気がします。

ゲームを更新するにつれ「ピンの型を変えたけど値が追従しなくて困った」という事態はそこそこ起きるので、そういった場合に無理やり書き換えてしまうのも手かもしれません。
2023年12月25日追記
普通にプログラムから特定のノードを書き換える事も可能でした。結構便利

最後に

今回はBPのコンパイルエラー拡張でしたが先に書いた通り更なる拡張エラーチェックを開発する事も可能でして、バグを防ぐためにもこの辺りの知識は持っておいた方が幸せになれるなと改めて思いました。もし気になる方は一度このあたりを触ってみてください。

さて自分もインディーゲーの制作に戻りますかー!!!