執筆バージョン: Unreal Engine 4.24
|
はじめに
以前、弊社ブログにてユーザー定義のオブジェクトをアセット化する方法をご紹介したと思います。
こちらの記事では、CSVファイルからインポートしてきたデータテーブルの内容をC++で定義した型(構造体の配列やMap)に格納してデータアセット化するというものでした。今回は、その内容をより詳しく解説し、かつワークフローをより最適化させるための方法をご紹介したいと思います。


何故データアセット化するのか
その前に、改めて何故データアセットを利用するのかというメリットから簡単にご説明したいと思います。
- CSVファイルが複数に分かれている時に便利
例えばイベントデータのデータテーブルが、1話と2話でCSVファイルが分かれていた場合、その話数ごとに参照するデータテーブルを切り替えるより、一つのデータアセットから取得するようにした方が便利な場合もあります。
- 1つのデータに対して、関連するCSVファイルが複数ある場合にデータをまとめられる
例えばキャラクターの攻撃関係のCSVファイルと防御関連のCSVファイルが分かれていた時、同じキャラクターであれば一つのデータにまとめたいですよね。そんな時に便利です。
- データの取得方法がデータテーブルだと少し面倒
データテーブルの型であるFTableRowBaseは、あるデータの内容を取得したい場合にちょっと面倒です。それはC++でもブループリントでも同じことが言えます。やはり普段から操作に慣れているTArrayだったりTMapの方がアクセスしやすいではないでしょうか。
軽く挙げるとこのような感じになります。逆に言うとこれら上記のメリットをさほど感じないというデータは、そのままデータテーブルから直接参照しても差し支えないとも言えます。取り扱うデータに合わせて、必要ならばデータアセット化していくようにしましょう。
データアセットの準備
さて、データアセット化するメリットが分かった所で、早速データアセット化するための準備を行います。
まずは手始めにデータテーブル用の構造体と、実際にゲーム内で使うデータ用の構造体をそれぞれ作成します。


1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
|
#pragma once #include "CoreMinimal.h" #include "Engine/DataAsset.h" #include "Engine/DataTable.h" #include "CharacterData.generated.h" // データテーブル用構造体 USTRUCT() struct FCharacterData_TableRow : public FTableRowBase { GENERATED_USTRUCT_BODY() UPROPERTY( EditAnywhere ) int CharacterID; UPROPERTY( EditAnywhere ) int MaxHP; UPROPERTY( EditAnywhere ) int MaxMP; UPROPERTY( EditAnywhere ) int Attack; UPROPERTY( EditAnywhere ) int Defense; }; // 実際にゲーム側で使うキャラクターデータの構造体 USTRUCT( BlueprintType ) struct FCharacterData { GENERATED_USTRUCT_BODY() UPROPERTY( BlueprintReadOnly, EditAnywhere ) FString CharacterName; UPROPERTY( BlueprintReadOnly, EditAnywhere ) int MaxHP; UPROPERTY( BlueprintReadOnly, EditAnywhere ) int MaxMP; UPROPERTY( BlueprintReadOnly, EditAnywhere ) int Attack; UPROPERTY( BlueprintReadOnly, EditAnywhere ) int Defense; }; |
- ここでポイントは以下の2点です。
データテーブル用構造体FCharacterData_TableRowの基底クラスがFTableRowBase
- 実際にゲーム側で使うキャラクターデータの構造体FCharacterDataはブループリントでも使えるようにUSTRUCTがBlueprintTypeだったり、各UPROPERTYがBlueprintReadOnlyである(必要に応じて)
ちなみにデータテーブル用構造体の各UPROPERTYがEditAnywhereなのはデータテーブルアセットを直接編集できるようにするためですが、インポート元であるCSVファイルの内容をそのまま使うだけであれば編集不可にした方が安全です。
次にデータアセット用のクラスを作成します。

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
|
// データアセット用クラス UCLASS() class TEST_API UCharacterDataAsset : public UDataAsset { GENERATED_BODY() public: #if WITH_EDITORONLY_DATA UPROPERTY( BlueprintReadOnly, EditAnywhere ) class UDataTable* DataTable; #endif // キャラクターデータ(TArrayだったりTMapだったりのデータ配列) UPROPERTY( BlueprintReadOnly, VisibleAnywhere ) TMap< int, FCharacterData > CharacterDataMap; public: // データ作成用の関数 UFUNCTION( BlueprintCallable, meta = (CallInEditor = "true") ) void Build(); }; |
ここでポイントなのは、以下の通りです。
- データアセット用のクラスUCharacterDataAssetは基底クラスがUDataAsset
- UCharacterDataAssetが保持するデータテーブルはエディタ上でしか参照する必要がないのでWITH_EDITORONLY_DATAで囲う
- データ作成用の関数はエディタ上で使えるようにCallInEditor=”true”
これで完了です。
目指す所
次にデータテーブルからデータアセット化する部分を実装していきますが、この処理のついでにワークフローを最適化させたいと思います。今回のこの記事の醍醐味と言っていいでしょう。
まず基本的にデータテーブルはCSVからインポートされたデータという前提で話を進めます。
そのまま実装するとワークフローは以下の通りになるはずです。
手順① CSVファイルのデータを更新する
手順② UE4エディタ上でコンテンツブラウザからデータテーブルを右クリックして、リインポートを行う
手順③ データアセットでデータテーブルのインポートを行う
手順④ データテーブルとデータアセットをそれぞれ保存する
以下の作業、手順2から4まではUE4エディタ上での作業になるため、できればこれらの作業は一つにまとめたい所です。
そこで、インポート用のボタン操作でデータテーブルのリインポートと各アセットの保存も一緒にやることにします。
つまりボタン1つ押すだけで全てが完了するようにしよう!というのが今回の目指す所です。

実装方法
では実際の実装方法を紹介していきます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76
|
#include "CharacterData.h" #if WITH_EDITOR #include "Misc/MessageDialog.h" #include "UObject/Package.h" #include "EditorReimportHandler.h" #include "FileHelpers.h" #endif #define LOCTEXT_NAMESPACE "TEST" void UCharacterDataAsset::Build() { #if WITH_EDITORONLY_DATA // データテーブルの設定チェック if( DataTable == nullptr ) { const FText TitleText = LOCTEXT( "Title", "WarningMassege" ); FMessageDialog::Open(EAppMsgType::Ok, LOCTEXT( "Message", "DataTable is Null !!" ), &TitleText ); return; } // データテーブルの型チェック if( !DataTable->GetRowStruct()->IsChildOf( FCharacterData_TableRow::StaticStruct() ) ) { const FText TitleText = LOCTEXT( "Title", "WarningMassege" ); FMessageDialog::Open( EAppMsgType::Ok, LOCTEXT( "Message", "DataTable type does not match !!" ), &TitleText ); return; } TArray<UPackage*> PackagesToSave; // データテーブルをリインポート if( FReimportManager::Instance()->Reimport( DataTable, false, true ) ) { // リインポートに成功したデータテーブルを保存対象に追加 PackagesToSave.Add( DataTable->GetOutermost() ); } CharacterDataMap.Empty(); // データテーブルの行の要素を配列で取得 TArray RowNames = DataTable->GetRowNames(); // 行の数だけループ for( auto RowName : RowNames ) { // 1行分の構造体を取得 FCharacterData_TableRow* TableRow = DataTable->FindRow< FCharacterData_TableRow >( RowName, FString() ); // 実際にゲーム上で使いやすいようにデータを加工する FCharacterData CharacterData; CharacterData.CharacterName = RowName.ToString(); CharacterData.MaxHP = TableRow->MaxHP; CharacterData.MaxMP = TableRow->MaxMP; CharacterData.Attack = TableRow->Attack; CharacterData.Defense = TableRow->Defense; // Mapに追加する CharacterDataMap.Add( TableRow->CharacterID, CharacterData ); } // データアセットに編集フラグを追加 MarkPackageDirty(); // データアセットを保存対象に追加 PackagesToSave.Add( GetOutermost() ); // 関連アセットを全て保存(SourceControl使用時はチェックアウトするかメッセージウィンドウを出す) // ファイル編集フラグ(Dirty)が付いてるもののみを保存対象にしたいので第一引数はtrue // 保存する際に確認のメッセージウィンドウを出さない場合は第二引数をfalseにする FEditorFileUtils::PromptForCheckoutAndSave( PackagesToSave, true, true ); #endif } #undef LOCTEXT_NAMESPACE |
ここでのポイントは以下になります。
- データテーブルのリインポートはFReimportManagerのReimport関数を使用
- データテーブルの全行取得はGetRowNames関数で、Row名の配列を返す
- FindRowで特定の1行分のデータを取得できる
- アセットを編集した場合はMarkPackageDirtyを呼んで編集マークをつける
- FEditorFileUtils::PromptForCheckoutAndSaveを使うことでソースコントロール(Perforceなどのバージョン管理ソフト)で管理されているアセットも普段通りのアセット保存のフローで保存できる
最後に、このままではビルドが通らないのでプロジェクト用のBuild.csにUnrealEdのモジュールを追加する必要があります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
|
using UnrealBuildTool; public class Test : ModuleRules { public Test(ReadOnlyTargetRules Target) : base(Target) { PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs; PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "HeadMountedDisplay" }); // エディタ専用にUnrealEdモジュールを追加 if (Target.bBuildEditor) { PrivateDependencyModuleNames.AddRange( new string[] { "UnrealEd" } ); } } } |
これで実装は完了です。
実際にデータアセットを作ってみる
さて、次に実際にエディタ上でデータテーブルとデータアセットを作ってみたいと思います。
手順① まずCSVファイルをコンテンツブラウザにドラッグ&ドロップします。

手順② データテーブルの構造体にはCharacterData_TableRowを指定します。

手順③ 次に新規でデータアセットを作成し、クラスにはCharacterDataAssetを指定します。

手順④ データアセットを開いてDataTableに先程作成したデータテーブルを入れます。

手順⑤ データアセットのBuildボタンを押せば完了です。

実際にデータアセットを使ってみる
せっかく作ったデータアセットなので、今回はその利用方法の一例も紹介したいと思います。
まず、静的なデータアセットをまとめて管理する用のStaticDataManagerを用意します。このManagerクラスが色んなデータアセットを保持するような想定です。
もちろん各アセットへの参照はFSoftObjectPathを使って、直にアセット参照を持たないようにします。そうすることでアセットの読み込みタイミングを任意に扱う事ができ、ロードやメモリの管理が楽になります。
ではStaticDataManagerクラスを作成していきましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
|
#pragma once #include "CoreMinimal.h" #include "CharacterData.h" #include "StaticDataManager.generated.h" UCLASS( config = Game, defaultconfig ) class TEST_API UStaticDataManager : public UObject { GENERATED_BODY() public: // データアセットのファイルパス UPROPERTY( EditAnywhere, Config, meta = (AllowedClasses = "CharacterDataAsset") ) FSoftObjectPath CharacterDataAssetPath; UPROPERTY(BlueprintReadOnly) class UCharacterDataAsset* CharacterDataAsset; public: // データアセットの取得 UFUNCTION( BlueprintCallable, meta = (WorldContext = "WorldContextObject") ) static class UCharacterDataAsset* GetCharacterDataAsset( const UObject* WorldContextObject ); // 任意のキャラクターデータを取得(※本来はキャラクターを管理する別クラスで実装すべき) UFUNCTION( BlueprintCallable, meta = (WorldContext = "WorldContextObject") ) static void GetCharacterData( const UObject* WorldContextObject, int InCharacterID, FCharacterData& OutCharacterData ); }; |
ポイントとなる点は以下の通りです。
- プロジェクト設定でアセットのファイルパスが設定できるようにUCLASSにconfigを指定
- データアセットのファイルパスも同じくUPROPERTYにConfigを指定
- 上記に述べた理由でデータアセットはFSoftObjectPath型を使用
- FSoftObjectPath型のUPROPERTYはmetaデータでAllowedClassesにデータアセットの型を指定(理由は後述)
プロジェクト設定からファイルパスを設定できるようにする方法は弊社ブログのこちらに記載しています。
UMyEditorSettingに該当する部分が今回だとStaticDataManagerになるというわけです。また、今回はエディタ設定ではなくプロジェクト設定にしているので記事を参考にする際に注意が必要です。
きちんと設定ができたら下記画像のようにプロジェクト設定からアセットのパスが指定できるようになります。

さきほどAllowedClassesにCharacterDataAssetを設定した事で、画像のようにアセット一覧に出てくるアセットが限定されるようになりました。間違った型のアセットを指定してしまうなどの作業ミスがこれで防げるわけです。
そして今回は使いやすいようにアセットデータの取得関数をStatic関数にしました。また、本来ならこのクラスに書くべきではありませんが、省略して任意のキャラクターデータを取得する関数も用意しました。
それとロードのタイミングを厳密に行いたい場合は、StaticDataManager内にLoad専用のメソッドを用意するとよいでしょう。
では続いて中身の実装です。
と、その前にStaticDataManagerのインスタンスを生成しないといけないので、この処理をとりあえずGameInstanceを派生させたTestGameInstanceで行います。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
|
#pragma once #include "CoreMinimal.h" #include "Engine/GameInstance.h" #include "TestGameInstance.generated.h" UCLASS() class TEST_API UTestGameInstance : public UGameInstance { GENERATED_BODY() public: UPROPERTY() class UStaticDataManager *StaticDataManager; public: virtual void Init() override; }; |
Cppファイル側は以下になります。
|
#include "TestGameInstance.h" #include "StaticDataManager.h" void UTestGameInstance::Init() { Super::Init(); // StaticDataManagerの生成 StaticDataManager = NewObject( this ); } |
GameInstaceクラスを派生した場合は、忘れずにプロジェクト設定でクラス指定を行いましょう。

では準備が完了したのでStaticDataManager側の実装に戻ります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
|
#include "StaticDataManager.h" #include "Engine.h" #include "TestGameInstance.h" #include "CharacterData.h" // データアセットの取得 UCharacterDataAsset* UStaticDataManager::GetCharacterDataAsset( const UObject* WorldContextObject ) { UWorld* World = GEngine->GetWorldFromContextObject( WorldContextObject, EGetWorldErrorMode::ReturnNull ); if( World == nullptr ) return nullptr; // ゲームインスタンスからStaticDataManagerのインスタンスを取得 UStaticDataManager* StaticDataManager = Cast( World->GetGameInstance() )->StaticDataManager; // アセットが読み込まれているかチェック if( StaticDataManager->CharacterDataAsset == nullptr ) { // iniファイルで指定したデータアセットのファイルパス FString DataAssetPath = StaticDataManager->CharacterDataAssetPath.ToString(); // アセットを読み込む StaticDataManager->CharacterDataAsset = Cast( StaticLoadObject( UCharacterDataAsset::StaticClass(), nullptr, *DataAssetPath )); } return StaticDataManager->CharacterDataAsset; } // 任意のキャラクターデータを取得 void UStaticDataManager::GetCharacterData( const UObject* WorldContextObject, int InCharacterID, FCharacterData& OutCharacterData ) { UCharacterDataAsset* DataAsset = GetCharacterDataAsset(WorldContextObject); if( DataAsset != nullptr ) { FCharacterData* CharacterData = DataAsset->CharacterDataMap.Find( InCharacterID ); if( CharacterData != nullptr ) { OutCharacterData = *CharacterData; } } } |
データアセットの取得時に、読み込まれていなかったらStaticLoadObjectでアセットをロードするようにしています。本来ならここはきちんと事前にアセットを非同期読み込みを行うようにして、読み込まれてなかったらエラーを返すようにするなどした方がよいでしょう。今回はとりあえずの処理なのでこれでよしとします。
任意のキャラクターデータを取得している関数に関しては、実際のデータアセットの使い方の例として参考にして下さい。
では最後にブループリントを使って呼び出してみましょう。

余談ですが、以前ご紹介したこちらの記事の内容を参考にしていただければ、データテーブルから参照している部分を改造することで、引数のInCharacterIDはデータアセットを参照して、キャラクターID一覧から選択して指定する事も可能です。存在しないキャラクターIDを指定しないためにも、是非活用してみて下さい。
ちょっと長くなってしまいましたが、いかがでしたでしょうか?
是非データ管理周りの参考にしてみて下さい。