みなさん、こんにちは。私の名前はドミトリー・イヴァシュチェンコです。MY.GAMES のソフトウェア エンジニアです。この記事では、要素の状態とマークアップに基づいた Unity でのユーザー インターフェイスの開発について説明します。
まず最初に、 Unity UI (uGUI) テクノロジのコンテキストで説明することに注意してください。ドキュメントによると、Unity UI は依然としてランタイムに推奨されています。ここで説明するアプローチは、 UI Toolkit 、 IMGUI 、またはその他の UI 構築システムには適用できません。
Unityプロジェクトでは、 MonoBehaviour
から継承され、多数のSerializeField
フィールドが散りばめられた View クラスに基づいて構築された UI 実装に遭遇することがよくあります。このアプローチでは、UI の動作を完全に制御できますが、ビュー レベルとプレゼンター レベルで大量のコードを記述することも必要になります (使用するアーキテクチャに応じて異なります)。
多くの場合、プロジェクト開発が続くにつれて、これらのクラスは信じられないほどのサイズに膨張し、GameObject 自体のコンポーネントは内部オブジェクトへの膨大な数のリンクで覆われます。
このようなコンポーネントの変更も楽しいものではありません。クラス内の新しい要素への参照を取得するには、 SerializeField
追加し、コードを再コンパイルし、プレハブ コンポーネント内で新しいフィールドを見つけて、必要なオブジェクトをそこにドラッグする必要があります。プロジェクトが成長するにつれて、コンパイル時間、フィールドの数、プレハブの編成の複雑さも増加します。
その結果、 MonoBehaviour
の大きくてオーバーロードされたサブクラス (または、好みに応じて多数の小さなサブクラス) が作成されます。
また、このような UI の動作の変更はプログラマのタスクであり、そのタスクには、コード レビュー、マージ競合の解決、テストによるコード カバレッジなど、関連するすべてのコストがかかることも考慮する価値があります。
SerializeField
が必要であり、要件に従って機能させるために大量のコードが記述されます。 。当然のことながら、これを処理できるのはプログラマだけであり、実装は時間がかかり、高価で、非常に効率的であることがわかります (多くの場合、誰もが気づくよりもはるかに効率的です)。
uGUI を使用する際のいくつかの困難を強調したので、この問題を解決するための別のアプローチについて話したいと思います。
私の得意プロジェクトの 1 つに取り組んでいる間、Unity で構造化 UI 開発用のライブラリを開発しました。その後、チームと私は本番環境でテストし、結果に満足しました。
ライブラリのソース コードは GitHub でダウンロードできます。
ライブラリの重要な要素はStatefulComponent
コンポーネントです。このコンポーネントは各画面のルート ゲームオブジェクトに配置され、タブ全体に分散された内部要素への必要な参照がすべて含まれています。
各リンクには、その役割に基づいて名前が付けられます。コードの観点から見ると、ロールのセットは通常のenum
です。 UI 要素のタイプ (ボタン、画像、テキストなど) ごとに別個のロールのセットが用意されています。
public enum ButtonRole { ... } public enum ImageRole { ... } public enum TextRole { ... } ...
ロールはコンポーネントから直接生成されるため、 enum
手動で編集する必要はありません。これらのenum
要素は作成直後に使用できるため、ロールの作成時に再コンパイルを待つ必要もありません。
マージの競合を単純化するために、列挙値は要素の名前に基づいて計算されます。
[StatefulUI.Runtime.RoleAttributes.ButtonRoleAttribute] public enum ButtonRole { Unknown = 0, Start = -1436209294, // -1436209294 == "Start".GetHashCode() Settings = 681682073, Close = -1261564850, Quests = 1375430261, }
これにより、あなたと同僚がたまたま異なるブランチでボタンの新しいロールを同時に作成した場合でも、プレハブ内のシリアル化された値が壊れるのを避けることができます。
UI 要素の各タイプ (ボタン、テキスト、画像) は独自のタブにあります。
ロールを使用すると、プレハブ内のすべての要素の完全なマークアップが実現されます。画像やテキストにアクセスするためにSerializeField
のセットは必要なくなりました。たとえば、スプライトを置き換えるには、 StatefulComponent
への参照を 1 つ持ち、目的の画像の役割を知るだけで十分です。
UnityEngine.UI.Text
およびTextMeshProUGUI
を含むテキストUnityEngine.UI.InputField
およびTMP_InputField
を含む)
注釈付きオブジェクトを操作するための対応するメソッドがあります。コードでは、 StatefulComponent
への参照を使用することも、 StatefulView
からクラスを継承することもできます。
public class ExamplePresenter { private StatefulComponent _view; public void OnOpen() { _view.GetButton(ButtonRole.Settings).onClick.AddListener(OnSettingsClicked); _view.GetButton(ButtonRole.Close).onClick.AddListener(OnCloseClicked); _view.GetSlider(SliderRole.Volume).onValueChanged.AddListener(OnVolumeChanged); } } public class ExampleScreen : StatefulView { private void Start() { SetText(TextRole.Title, "Hello World"); SetTextValues(TextRole.Timer, hours, minutes, seconds); SetImage(ImageRole.UserAvatar, avatarSprite); } }
テキストを含むタブには、役割とオブジェクトへのリンクに加えて、次の列が含まれています。
ライブラリには、翻訳を処理するための組み込みサブシステムが含まれていません。ローカリゼーション システムに接続するには、 ILocalizationProvider
インターフェイスの実装を作成する必要があります。これは、たとえば、バックエンド、ScriptableObjects、または Google Sheets に基づいて構築できます。
public class HardcodeLocalizationProvider : ILocalizationProvider { private Dictionary<string, string> _dictionary = new Dictionary<string, string> { { "timer" , "{0}h {1}m {2}s" }, { "title" , "Título do Jogo" }, { "description" , "Descrição longa do jogo" }, }; public string GetPhrase(string key, string defaultValue) { return _dictionary.TryGetValue(key, out var value) ? value : defaultValue; } }
[ローカリゼーションをコピー] ボタンをクリックすると、コード列と値列の内容が Google スプレッドシートへの貼り付けに適した形式でクリップボードにコピーされます。
多くの場合、再利用を容易にするために、UI の個別の部分が個別のプレハブに抽出されます。 StatefulComponent
使用すると、各コンポーネントが独自の子インターフェイス要素でのみ動作するコンポーネントの階層を作成することもできます。
[内部コンポーネント] タブでは、内部コンポーネントにロールを割り当てることができます。
構成されたロールは、他のタイプの要素と同様にコード内で使用できます。
var header = GetInnerComponent(InnerComponentRole.Header); header.GetButton(ButtonRole.Close).onClick.AddListener(OnCloseClicked); header.SetText(TextRole.Title, "Header Title"); var footer = GetInnerComponent(InnerComponentRole.Footer); footer.GetButton(ButtonRole.Continue).onClick.AddListener(OnContinueClicked); footer.SetText(TextRole.Message, "Footer Message");
類似した要素のリストを作成するには、 ContainerView
コンポーネントを使用できます。インスタンス化用のプレハブとルート オブジェクト (オプション) を指定する必要があります。編集モードでは、 StatefulComponent
を使用して要素を追加および削除できます。
インスタンス化されたプレハブのコンテンツをマークアップするには、 StatefulComponent
を使用すると便利です。ランタイムでは、メソッドAddInstance<T>
、 AddStatefulComponent
、またはFillWithItems
を使用してコンテナーにデータを設定できます。
var container = GetContainer(ContainerRole.Players); container.Clear(); container.FillWithItems(_player, (StatefulComponent view, PlayerData data) => { view.SetText(TextRole.Name, data.Name); view.SetText(TextRole.Level, data.Level); view.SetImage(ImageRole.Avatar, data.Avatar); });
標準のObject.Instantiate()
オブジェクトの作成に適さない場合は、たとえば Zenject を使用したインスタンス化などでこの動作をオーバーライドできます。
StatefulUiManager.Instance.CustomInstantiateMethod = prefab => { return _diContainer.InstantiatePrefab(prefab); };
内部コンポーネントとコンテナは、それぞれStatefulComponent
の静的ネストと動的ネストを提供します。
プレハブのマークアップ、ローカリゼーション、インスタンス化について検討しました。ここで、最も興味深い部分である状態に基づいた UI の開発に移ります。
状態の概念を、プレハブに対する変更の名前付きセットとして考えます。この場合の名前はStateRole
列挙型のロールであり、プレハブへの変更の例は次のとおりです。
一連の変更 (状態の説明) は、「状態」タブで構成できます。構成された状態はインスペクターから直接適用できます。
構成された状態は、 SetState
メソッドを使用してコードから適用できます。
switch (colorScheme) { case ColorScheme.Orange: SetState(StateRole.Orange); break; case ColorScheme.Red: SetState(StateRole.Red); break; case ColorScheme.Purple: SetState(StateRole.Purple); break; }
[ツール] タブで、 [有効化時に初期状態を適用] パラメータが有効になっている場合、オブジェクトのインスタンス化時にすぐに適用される状態を構成できます。
状態を使用すると、View クラス レベルで必要なコードの量を大幅に削減できます。画面の各状態をStatefulComponent
の一連の変更として記述し、ゲームの状況に応じてコードから必要な状態を適用するだけです。
実際、状態に基づいて UI を開発するのは非常に便利です。そのため、時間の経過とともに別の問題が発生します。プロジェクトが進行するにつれて、単一ウィンドウの状態のリストが手に負えないほど長くなる可能性があり、そのためナビゲートするのが困難になります。さらに、他の州との関連でのみ意味をなす州もあります。この問題を解決するために、Statful UI には State Tree という別のツールがあります。 「States」タブの「State Tree Editor」ボタンをクリックするとアクセスできます。
親状態 (この例ではReward ) は、子状態が呼び出されるたびに適用されます。
構成されたStatefulComponent
を管理するには、コンポーネントに必要なデータを設定し、適切なタイミングで状態を切り替える、最小限のシンプルでわかりやすいコードが必要になります。
public void ShowIntro() { SetState(StateRole.Intro); } public void ShowReward(IReward reward) { // Update the inner view with the reward reward.UpdateView(GetInnerComponent(InnerComponentRole.Reward)); // Switch on the type of reward switch (reward) { case ICardsReward cardsReward: SetState(StateRole.Cards); break; case IMoneyReward moneyReward: SetState(StateRole.Money); break; case IEmojiReward emojiReward: SetState(StateRole.Emoji); break; } } public void ShowResults(IEnumerable<IReward> rewards) { SetState(StateRole.Results); // Fill the container with the rewards GetContainer(ContainerRole.TotalReward) .FillWithItems(rewards, (view, reward) => reward.UpdateView(view)); }
ロールは、コード内で後で使用するために、リンクと状態に名前を付ける便利で明確な方法を提供することを目的としています。ただし、状態の説明に長すぎる名前が必要になる場合があり、このリンクが指すもの、または状態がどのような動作を反映しているかについて短いコメントを残した方が便利な場合があります。このような場合、 StatefulComponent
の各リンクと状態に説明を追加できます。
各タブにある [API をコピー] ボタンと [ドキュメントをコピー] ボタンにすでに気づいているかもしれません。これらは、選択したセクションの情報をコピーします。これらに加えて、[ツール] タブにも同様のボタンがあり、これらはすべてのセクションの情報を一度にコピーします。 「API のコピー」ボタンをクリックすると、このStatfulComponent
オブジェクトを管理するために生成されたコードがクリップボードにコピーされます。以下は報酬ウィンドウの例です。
// Insert the name of the chest here SetText(TextRole.Title, "Lootbox"); // Button to proceed to the reward issuance phase GetButton(ButtonRole.Overlay); // Button to display information about the card GetButton(ButtonRole.Info); // Container for displaying the complete list of awarded rewards GetContainer(ContainerRole.TotalReward); // Insert the card image here SetImage(ImageRole.Avatar, null); // Animated appearance of a chest SetState(StateRole.Intro);
[ドキュメントのコピー] ボタンをクリックすると、このプレハブのドキュメントがマークダウン形式でクリップボードにコピーされます。
### RewardScreen Buttons: - Overlay - Button to proceed to the reward issuance phase - Info - Button to display information about the card Texts: - Title - Insert the name of the chest here Containers: - TotalReward - Container for displaying the complete list of awarded rewards Images: - Avatar - Insert the card image here States: - Intro - Animated appearance of a chest - Cards - Displaying rewards in the form of a card - Money - Displaying rewards in the form of game currency - Emoji - Displaying rewards in the form of an emoji - Results - Displaying a complete list of issued rewards
これほど詳細な指示が示されたこの画面を実装する場合、間違いを犯すのは非常に困難であることは明らかです。プロジェクトのナレッジ ベースで、UI 組織に関する最新情報を簡単に維持できます。
同時に、ステートフル UI により、UI プレハブの作成を委任できます。実際、状態ベースのマークアップを使用すると、プレハブの動作をプログラマに渡す前に完全にテストできます。これは、ゲーム デザイナー、テクニカル デザイナー、さらには別の UI 開発者がプレハブを準備できることを意味します。さらに、コードとプレハブの間にAPIが作成されるため、プログラミングとプレハブの設定を並行して行うことができます。必要なのは、事前にAPIを策定することだけです。ただし、プレハブを構成するタスクがプログラマに残っている場合でも、ステートフル UI を使用すると、この作業が大幅に高速化されます。
これまで見てきたように、ステートフル UI により、UI 要素の状態の操作が大幅に簡素化されます。 SerializeFields の作成、コードの再コンパイル、膨大な数の View クラス フィールド間での参照の検索に長いサイクルが必要なくなりました。 View クラス自体では、オブジェクトのオン/オフやテキストの色の変更などの繰り返し操作のために大量のコードを記述する必要がなくなりました。
このライブラリを使用すると、プロジェクト内のレイアウトの整理、プレハブ内のオブジェクトのマーク、状態の作成、UI 要素へのリンク、UI 管理用の API とドキュメントの提供に対する一貫したアプローチが可能になります。また、UI プレハブの作成を委任することもでき、UI プレハブの作業が高速化されます。
状態の機能を拡張し、新しいタイプのアニメーション、状態でのサウンドの再生など、説明での新しいタイプの UI 変更をサポートします。
テキストと画像に色を付けるためのカラー パレットのサポートの追加
ゲームオブジェクトの再利用によるアイテムのリストのサポートの追加
より多くの Unity UI 要素のサポート
ローカライズのために追加されたテキストのアンロードを自動化する
テストフレームワークの実装。プレハブの徹底的なマークアップがあるため、セットアップが簡単な ScriptableObject ベースのシナリオを次の形式で作成できます。
ButtonRole.Settings
ボタンをクリックします。
TextRole.SomeText
内のテキストが「何らかの値」と等しいことを確認します。
ImageRole.SomeImage
内の画像をチェックして、特定のスプライトと等しいことを確認します。
チュートリアルシステムです。テストと同様に、マークされたレイアウトを使用すると、「ボタンにポインターを表示するButtonRole.UpgradeHero
」のような指示の形式で ScriptableObject ベースのチュートリアル シナリオを作成できます。
プロジェクトのソース コードは GitHub で入手できます。問題を作成したり、ライブラリに貢献したりすることは歓迎です。