UI とそのアーキテクチャの重要性
ゲームの UI システムには次のような特徴があります。- 大規模で複雑
もちろん例外はありますが、ほとんどとは言わないまでも多くのゲームでは、進行、クエスト、統計、リテンションなどに関する比較的高度なシステムがあります。私の経験では、「UI チーム」は、ユーザーに表示する UI だけではなく、多くの場合アカウント システムやクラウド ベースのデータ ストアに保存されているすべてのデータを追跡する基盤となるシステムや、データを収集するためのゲームプレイ システム内のフックも担当します。
- 変化しやすい
先ほど挙げた各種のシステムは、開発中、ゲームに最適なものをチームが求めるなかで、規模が大きくなったり内容が変化したりすることがよくあります。
- ニュアンスを含む
ユーザーのスキルのレベルやゲームについての経験はさまざまです。そうしたユーザーから得られる多様な意見や結果を含むフィードバックに対処するなかで、スタイル、外観と質感、レイアウト、表示には、調整や変更が頻繁に加えられます。
こうした点を踏まえて、事前に時間を十分にとり、しっかりとしたアーキテクチャを構築することを強くお勧めします。アーキテクチャに求められるものは次のとおりです。
- ビジネス ロジックと UI のビジュアルの分離
- レイアウトとビジュアルについての迅速なイテレーション
- ビジネス ロジックの効果的なデバッグ
- パフォーマンス
以下では、現実的な例を挙げつつ、前述の目標を達成するために有効であることがわかっているパターンの 1 つを紹介します。
例
ゲームについて作業をしているとしましょう。どのゲームでもいいのですが……フォートナイトにしましょう。次のようなビジュアルのアイテム ショップが必要だとします。
この例について、次の点を詳しく見ていきます。
- クラスのアーキテクチャ
- C++ のコード
- ブループリント
大体において、方法について簡単に述べ、説明やコード内のコメントを通じてその方法を採る理由を説明していきます。
クラスのアーキテクチャ
一般的には、次のパターンに従ったアーキテクチャをお勧めします。UMyData は C++ のクラスで、UObject を継承します。
データ クラスを作成して、UI がユーザーに伝えようとするものについてのすべての情報をカプセル化します。UObject なので、public/private でアクセスを制御でき、getter と setter があり、便利な API が含まれます。
このアプローチでは、データの存続期間を UI から分離します。これはとても望ましいことです。ストアでのオファー、インベントリのアイテム、プレイヤーの統計情報など、あらゆるデータ ソースは、UI オブジェクトと存続期間を共有することはほとんどありません。また、複数のウィジェットが同じデータ オブジェクトからデータを取得できます。これらのクラスは、異なる領域を担当するエンジニアがゲームプレイまたはストアのバックエンドを作成する過程ですでに作成されていて、存在しているかもしれません。ただし、そういった場合でも、UI 特有の多くのデータと合わせてゲームプレイのデータをまとめるために、UI に独自のデータ クラスが必要であることがよくあります。
UMyWidget は C++ のクラスで、UUserWidget を継承します。
これらの C++ クラスが定義するのは、ブループリントで使用するためのウィジェット固有の API と、基盤となるシステムと適切にやりとりするためにブループリントが従う必要がある契約を定義するためのブループリントで利用可能なイベントです。MyBlueprint はウィジェット ブループリントで、UMyWidget から派生するものです。
そのウィジェット ブループリント内では、必要となる可視の UI すべてを作成してレイアウトし、スタイルを適用して、UMyData と UMyWidget が提供する API 両方を活用し、UI のプリミティブ (テキスト ボックスや画像など) すべてに必要なデータを入力します。また、UMyWidget のイベントをリッスンして、対応する UI を更新するタイミングを把握します。インタラクティブな場合は、ボタンのクリックに応答して、UMyData または UMyWidget で提供される API をコールすることもあります。コードの例
データ クラス
まず、ショップでのオファーについてのデータをまとめましょう。UOfferInfo は UObject から派生します。UOfferInfo はブループリントに公開して、データにアクセスするために UFUNCTION を利用できるようにします。ここでは、例として比較的シンプルなものにしておきますが、実際には価格や画像などさまざまなものが含まれるでしょう。
UENUM(BlueprintType)
enum class EOfferType : uint8
{
Normal,
Featured
};
UCLASS(BlueprintType)
class UOfferInfo : public UObject
{
GENERATED_UCLASS_BODY()
public:
UFUNCTION(BlueprintCallable, Category = "OfferInfo")
FText GetName() const;
UFUNCTION(BlueprintCallable, Category = "OfferInfo")
FText GetDescription() const;
UFUNCTION(BlueprintCallable, Category = "OfferInfo")
EOfferType GetOfferType() const;
private:
// UProperty を含むすべてのデータ
};
ショップ ウィジェット
次にショップ ウィジェットについて考えます。ショップ ウィジェットは、すべてのオファーを表示する画面のインターフェイスと挙動を定めるものです。個々のオファーの詳細の表示は UOfferWidgetBase UserWidget に任せます。UOfferWidgetBase については後述します。// このベースのブループリント クラスを継承したいが
// ユーザーがベース クラスを直接インスタンス化できるようにはしたくないので抽象クラスとする
UCLASS(Abstract, Blueprintable, BlueprintType, ClassGroup = UI)
class UOfferShopWidgetBase : public UUserWidget
{
GENERATED_UCLASS_BODY()
public:
// この関数は、オファーの読み取りを開始したことをユーザーに知らせる
// ブループリントが、回転する円とデータをダウンロード中であることを説明するテキストを表示する
UFUNCTION(BlueprintImplementableEvent, Category = "OfferShopWidget")
void OnStartReadingOffers();
// 特定のオファーのための UI を生成することをブループリントに伝える
// ブループリントがオファー ウィジェットを作成し、それを UOfferInfo* に渡して処理を行わせる
// ブループリントにレイアウトを全面的に制御させるために、このような API を推奨
// ビジュアルのデザインによっては縦型のボックスが必要で、翌週は横方向にスクロールするように変化するかもしれないし、
// あるいは、タイルのグリッドや、それらの組み合わせなども考えられる
UFUNCTION(BlueprintImplementableEvent, Category = "OfferShopWidget")
void GenerateOffer(UOfferInfo* OfferData);
// この関数は、オファーの生成が完了したことをユーザーに伝える
// ブループリントは回転する円を非表示にして、画面のセットアップを完了する
UFUNCTION(BlueprintImplementableEvent, Category = "OfferShopWidget")
void OnOfferGenerationCompleted();
// ブループリントが使用するデータを取得するためのユーティリティ関数
UFUNCTION(BlueprintCallable, Category = "OfferShopWidget")
FDateTime GetStoreRefreshDate() const;
protected:
// すべての内部関数を少なくとも protected にしておく
private:
// その他の内部関数
// UProperty を含むすべてのデータ
UPROPERTY(transient)
TArray<UOfferInfo*>CurrentOffers;
};
オファー ウィジェット
最後に、個別のオファーを表示するウィジェットのベースとなる C++ クラスを見てみましょう。モック アップのために、2 種類のタイル用にレイアウトが異なる 2 つのブループリントを派生させます。
// このベースのブループリント クラスを継承したいが
// ユーザーがベース クラスを直接インスタンス化できるようにはしたくないので抽象クラスとする
UCLASS(Abstract, Blueprintable, BlueprintType, ClassGroup = UI)
class UOfferWidgetBase : public UUserWidget
{
GENERATED_UCLASS_BODY()
public:
// これらのウィジェットの 1 つについて新しいインスタンスを作成したあと、この関数を呼び出す必要がある// このパターンを使うと、別のものを表示したい場合に SetupOffer をもう一度呼び出すことができ、
// その際にまったく新しいウィジェットを作成する必要はない。これは、一般的にパフォーマンスにとって重要であり、特に
// インベントリなど、ウィジェットをプールまたは再利用して UI の処理を高速に行いたいところでは重要性が高い
UFUNCTION(BlueprintCallable, Category = "OfferWidget")
void SetupOffer(UOfferInfo* InOfferData)
{
// ここで簡単な実装を紹介して、私がなぜ BlueprintNativeEvent を使わないことが多いかを説明する
// 主な理由は、BlueprintNativeEvent は、間違った場所で親の関数を呼び出すかまったく呼び出さないという、
// ユーザーによるエラーを引き起こす可能性があるため
// また、このようにすると、BlueprintImplementableEvent 周りのユーザー エクスペリエンスが多少シンプルになる
OfferData = InOfferData;
OnOfferSet();
}
// 表示するデータを取得したことをブループリントに伝える
// ブループリントが GetOfferInfo、GetName、GetDescription
// などを呼び出して、テキスト フィールドやテクスチャなどすべてにデータを入力する
UFUNCTION(BlueprintImplementableEvent, Category = "OfferWidget")
void OnOfferSet();
// ブループリントが使用するデータを取得するためのユーティリティ関数
UFUNCTION(BlueprintCallable, Category = "OfferWidget")
UOfferInfo* GetOfferInfo() const;
private:
UPROPERTY(transient)UOfferInfo* OfferData;
};
ブループリントの例
UOfferShopWidgetBase から派生する UMG ウィジェットを作成して、実行時に入力するデータを収容するレイアウトを構築しましょう。- FeaturedOffers_HorBox
- 画面の左半分を占めます。ここには特集のオファーを配置します。
- NormalOffers_WrapBox
- 画面の右半分を占めます。ここには通常のオファーを配置します。
- RefreshTime_TextBlock
- ストアの更新タイマーを配置するテキスト ブロックです。
グラフは次のようになります。
ウィジェットの作成と適切なコンテナへの追加をブループリントに任せることで、次のことが可能になります。
- レイアウトの簡単な変更
- 使用されるウィジェットの状況に応じた交換
- Add Child to... を呼び出すことによるスロットのプロパティの変更
次にオファー ウィジェットを作成します。C++ で作成した UOfferWidgetBase クラスから派生する UMG ウィジェットを 2 つ作成します。OfferTileSmallWidget と OfferTileLargeWidget という名前にします。上に画像を挙げた Offer Shop ウィジェット ブループリント内の GenerateOffer イベントは、Offer Data に格納された Offer Type に基づいてこれらの 2 つのウィジェットを作成します。
Small と Large のウィジェットをセットアップして、記事の最初に紹介した「モックアップ」画像のタイルのように見えるようにできたら、グラフを作成できます。最もシンプルな形としては、次のようなものになるでしょう。
ここまでで、ショップの画面を収める OfferTileSmallWidget と OfferTileLargeWidget を作成しましたが、このアーキテクチャにはほかにも利点があります。今度はゲーム内の別の画面で特別なオファーを紹介したい場合について考えてみましょう。
次の 2 つのステップで済みます。
1. C++ で、特別なオファー用の関数を作成します。おそらく次のようになるでしょう。
UOfferInfo* MyLibrary::GetTheHotOffer()
UOfferInfo* MyLibrary::GetTheHotOffer()
単に OfferInfo データ クラスのインスタンスを返します。
2. すでにある 2 つのウィジェットの 1 つを画面に配置して、GetTheHotOffer() が返すオファーを渡して SetupOffer() を呼び出します。
あるいは、新しいブループリント「HotOfferWidget」を作成して、ホットな印象を与える炎のエフェクトを追加して SetupOffer() を呼び出すこともできます。
あるいは、新しいブループリント「HotOfferWidget」を作成して、ホットな印象を与える炎のエフェクトを追加して SetupOffer() を呼び出すこともできます。
まとめ
このアプローチでは、ビジネス ロジックを C++ に留めているので、保守、デバッグ、変更を行いやすくなります。ブループリント向けにはイベント ベースの強力な API を公開しています。こうすることで、クリエイティブ業務の担当者がユーザー エクスペリエンスを作成し、ビジュアルについて希望する実験を行ううえで、柔軟性を提供します。ブループリントでのティック、プロパティのバインディングの使用は避けるべきです。それらほどではありませんが、過剰なアニメーションも避けるといいでしょう。このアプローチで目指しているのは、システムの重要な境界とその状態を公開する一連のイベントを提供することです。こうすると、ブループリントでのティックとプロパティのバインディングを自然と避けることができます。イベントがあれば、ブループリントでのティックとプロパティのバインディングの相対的な有効性が低下するからです。
アニメーションは確実に有効ではありますが、比較的負荷が高い処理です。適切な使用を心がけてください。UI のために負荷を生じさせる余裕がほとんどない HUD など、特にパフォーマンスが重要なところでは熟慮が求められます。UI について作業したことがある方なら、私の言っていることをご理解いただけるでしょう。