MOD制作 > UI




基本事項


用語


Gauntlet UI
Bannerlord のユーザーインターフェースに用いられているフレームワークです。

Model-View-ViewModel アーキテクチャー
Gauntlet UI は Model-View-ViewModel (MVVM) と呼ばれるアーキテクチャーパターンを採用しています。要は、ゲームの領域、UI の領域、その2つを仲介する領域、の3層に分離させることで、情報を整理しやすく、また分業しやすくするという発想のようです。UI の MOD の場合、実装が必要なのはこの内の View と ViewModel になります。
この辺は、WPF や XAML を触ったことのある方ならお馴染みなのかもしれませんが、そうでない方も、あまり深く考えずに概念として何となく把握していれば大丈夫です。

ウィジェット
GUI を構成する部品です。TaleWorlds.GauntletUI や TaleWorlds.MountAndBlade.GauntletUI.Widgets あたりに一通り揃っています。基本的にはこれらで事足りるので、自分でウィジェットを定義するということはあまり無いのではないかと思われます。

Prefab
GUI のレイアウトを定義する XML ファイルです。プレハブ建築のプレハブと同じで、出来合いの部品 (ウィジェットや他の Prefab) をどういう風に組み合わせて配置するかを指定し、なにがしかの UI 機能を提供する大きめの部品として独立させたものです。
Prefab は TaleWorlds.Engine.GauntletUI.GauntletLayer.LoadMovie() するたびに読み込まれるので、ゲーム起動中にレイアウトを確認しながら編集していくなんてこともできます。(もちろん、構文エラー等があればその時点でクラッシュしますが)

Brush
GUI のスタイルを定義する XML ファイルです。Prefab と Brush の関係は、HTML と CSS の関係と似たようなものだそうです。
Widget に複数の状態が定義されていれば、それぞれの状態に応じたスプライト画像やフォント等を Brush に持たせることで、Widget に視覚的変化を与えることができます。例えば、Wiget がデフォルト状態にある時のスプライト、マウスオーバー時のスプライト、クリックした時のスプライト、Disable 時のスプライトなどをまとめられる、と言えばわかりやすいでしょうか。
ブラシは、Native 等の公式モジュールの GUI\Brushes に多数定義されています。


実装パターン


一部のパターンでしか実験していません。「たぶんこういうことだろう」レベルの推測が混じっています。

追加
既存の UI スクリーンにレイヤーを追加し、そこに自前の GUI を描画する (AddLayer)。
標準的な方法です。自分で View も ViewModel も用意するので好きなようにデザインできますが、既存の GUI そのものを書き換えることはできません。
モジュールとしての競合は起こりませんが、意図せず他の MOD の GUI と表示位置がかぶってしまうことはあり得ます。

改変 (未検証)
既存の UI スクリーンのレイアウトだけを変更する (XML 改変)。
使用するデータソース (ViewModel) はそのままで、XML の書き換えによって GUI の配置なんかを変える方法です。簡単だと思いますが、おそらくやれることは限定的です。また、MOD の競合が発生しうる方法だと思われます。

上書き (未検証)
既存の UI スクリーン全体をオーバーライドする (OverrideView)。
上書きする UI スクリーンが提供していた機能を自分で実装することになるため難易度は高いでしょう。また、MOD の競合が発生しうる方法だと思われます。

新規作成
自前の UI スクリーンを作成し、何かのイベントに伴ってそのスクリーンを呼び出す (PushScreen)。
Push/Pop のスタック形式なのでモーダルなポップアップとかに向いていると思います。

一部改変
たとえばオプション項目の追加などがこれに当たりますが、現状ではできないようです。
ただ、可能にする方法は検討されているとのこと。
Beyond that, we continue to work on:
  • Adding support for adding options to the options screen



レイヤー追加パターンの実装例


Example UI

  • ExampleUI プロジェクト
    • SubModule.cs
    • Views
      • SampleMapView.cs
      • SampleMissionView.cs
    • ViewModels
      • TestWindowVM.cs
  • ExampleUI.Window.xml

View と ViewModel の関係がはっきりするように階層 (名前空間) を分けています。
ExampleUI.Window.xml は、
[Bannerlord インストールフォルダー]\Modules\ExampleUI\GUI\Prefabs
フォルダーを作成し、その中に保存します。

MVVM では疎結合 (各層の結びつきが緩やか) なのが望ましいとされているそうなので、参照はできるだけ一方向になるようにしましょう。
すなわち、
  • View が ViewModel のインスタンスを持ち、その逆方向のアクセスはしない
  • Model (ゲーム内の要素) に属するデータの加工は View の中では行わず、ViewModel で行う
という感じです。


コード


+ SubModule.cs
モジュールのエントリーポイントです。

  1. using ExampleUI.Views;
  2. using SandBox.View.Map;
  3. using System;
  4. using TaleWorlds.Core;
  5. using TaleWorlds.Engine.Screens;
  6. using TaleWorlds.MountAndBlade;
  7. using TaleWorlds.MountAndBlade.View.Missions;
  8. using TaleWorlds.MountAndBlade.View.Screen;
  9.  
  10. namespace ExampleUI
  11. {
  12. public class SubModule : MBSubModuleBase
  13. {
  14. private MissionView _missionView;
  15. private MapView _mapView;
  16.  
  17. // Mission (平たく言えば、キャラクターが動き回ったり攻撃したりできる状況) に対する
  18. // MOD の処理 (ビヘイビアー) の登録はこのメソッドで行います。
  19. // OnBeforeMissionBehaviourInitialize() と OnMissionBehaviourInitialize() の違いは
  20. // MissionBehaviour.OnBehaviourInitialize() の前に呼ばれるか後によばれるかです。
  21. public override void OnBeforeMissionBehaviourInitialize(Mission mission)
  22. {
  23. base.OnBeforeMissionBehaviourInitialize(mission);
  24.  
  25. // ViewCreatorManager.CreateMissionView() は一応ファクトリーメソッドっぽいのですが、
  26. // 公式のコードには、これを介さず単に new MissionView() しているものもあったりして
  27. // よく分かりません。どっちにしろ動くのは動きます。
  28. _missionView = ViewCreatorManager.CreateMissionView<SampleMissionView>(false, mission, Array.Empty<object>());
  29. mission.AddMissionBehaviour(_missionView);
  30. /*
  31.   mission.AddMissionBehaviour(_missionView = new SampleMissionView());
  32.   */
  33.  
  34. // このサンプルでは、全ての Mission でテスト GUI が表示されてしまいますが、
  35. // 戦闘シーンだけに表示したいなどといった場合、何らかの工夫が必要になるでしょう。
  36. //
  37. // ちなみに、OnBeforeMissionBehaviourInitialize() 時点で mission.Mode は MissionMode.StartUp 固定なので、
  38. // 以下の方法は使えませんでした。
  39. /*
  40.   if (mission.Mode == MissionMode.Battle)
  41.   {
  42.   _missionView = ViewCreatorManager.CreateMissionView<SampleMissionView>(false, mission, Array.Empty<object>());
  43.   mission.AddMissionBehaviour(_missionView);
  44.   }
  45.   */
  46. }
  47.  
  48. // MapScreen (ワールドマップの描画スクリーン) には OnBeforeMissionBehaviourInitialize() に相当する
  49. // メソッドが用意されていないので、スクリーンの Push/Pop イベントを利用してレイヤーを挿入しています。
  50. public override void OnGameInitializationFinished(Game game)
  51. {
  52. base.OnGameInitializationFinished(game);
  53. ScreenManager.OnPushScreen += OnScreenManagerPushScreen;
  54. // Obsolete
  55. /*
  56.   ScreenManager.OnPopScreen += OnScreenManagerPopScreen;
  57.   */
  58. }
  59.  
  60. public override void OnGameEnd(Game game)
  61. {
  62. ScreenManager.OnPushScreen -= OnScreenManagerPushScreen;
  63. // Obsolete
  64. /*
  65.   ScreenManager.OnPopScreen -= OnScreenManagerPopScreen;
  66.   */
  67. base.OnGameEnd(game);
  68. }
  69.  
  70. private void OnScreenManagerPushScreen(ScreenBase pushedScreen)
  71. {
  72. if (pushedScreen is MapScreen mapScreen)
  73. {
  74. _mapView = mapScreen.AddMapView<SampleMapView>(Array.Empty<object>());
  75. }
  76. /*
  77.   else if (pushedScreen is MissionScreen missionScreen && missionScreen.Mission != null)
  78.   {
  79.   // MissionScreen は、まず空の MissionScreen を Push してから、ローディング画面を
  80.   // 表示しつつスクリーンを初期化していく感じなので、MapScreen と同じ手法は使えません。
  81.   //
  82.   // この時点で missionScreen.Mission には中身がないので、CreateMissionView() は失敗します。
  83.   _missionView = ViewCreatorManager.CreateMissionView<SampleMissionView>(false, missionScreen.Mission, Array.Empty<object>());
  84.   missionScreen.AddMissionView(_missionView);
  85.   }
  86.   */
  87. }
  88.  
  89. [Obsolete("1.6.0より前の手法")]
  90. private void OnScreenManagerPopScreen(ScreenBase poppedScreen)
  91. {
  92. if (_mapView != null && poppedScreen is MapScreen mapScreen)
  93. {
  94. // 1.6.0 からは MapScreen が破棄される際に勝手に MapView.OnFinalize() を呼んでくれるようになったので
  95. // MapView と MapScreen の寿命を同じにする限りでは、MapScreen.RemoveMapView() をする必要がなくなりました。
  96. mapScreen.RemoveMapView(_mapView);
  97. _mapView = null;
  98. }
  99. else if (_missionView != null && poppedScreen is MissionScreen)
  100. {
  101. // MissionView については、MissionScreen.OnEndMission() が OnMissionScreenFinalize() の呼び出しから
  102. // RemoveMissionBehaviour() まで勝手にやってくれます。
  103. _missionView = null;
  104. }
  105. // したがって、_mapView と _missionView はもはや必要のないフィールドなのですが、説明用に残してあります。
  106. }
  107. }
  108. }
  109.  

+ SampleMapView.cs
  1. using ExampleUI.ViewModels;
  2. using SandBox.View.Map;
  3. using TaleWorlds.Engine.GauntletUI;
  4. using TaleWorlds.GauntletUI.Data;
  5. using TaleWorlds.InputSystem;
  6. using TaleWorlds.Library;
  7.  
  8. namespace ExampleUI.Views
  9. {
  10. public class SampleMapView : MapView
  11. {
  12. private TestWindowVM _dataSource;
  13. private GauntletLayer _gauntletLayer;
  14. private IGauntletMovie _gauntletMovie;
  15.  
  16. protected override void CreateLayout()
  17. {
  18. base.CreateLayout();
  19.  
  20. _dataSource = new TestWindowVM();
  21.  
  22. // localOrder (レイヤーの優先度) が小さいほど、他のレイヤーより下 (画面奥) に描画されます。
  23. //
  24. // 例えば、部隊が町などに入った際に左に表示されるメニュー (GauntletMenuBase) のレイヤー優先度は 100 なので、
  25. // それより小さい値に設定すればテストウィンドウがメニューの下に表示されるようになります。
  26. // ESC メニュー (GauntletMapEscapeMenu) のレイヤー優先度が 4400 なので、これよりは小さい値に
  27. // した方がよさそうです。あるいは、ESC メニュー表示中は GUI を非表示にする処理を追加しましょう。
  28. _gauntletLayer = new GauntletLayer(4000);
  29. Layer = _gauntletLayer;
  30.  
  31. // Movie (= Screen に投影するもの) とは、XML から取得した UI の構造と、ViewModel (表示するデータ) を合わせた概念です。
  32. _gauntletMovie = _gauntletLayer.LoadMovie("ExampleUI.Window", _dataSource);
  33.  
  34. // このレイヤーの描画領域に対して行われた入力のうち、受け取るものをビットフラグによって管理しています。
  35. _gauntletLayer.InputRestrictions.SetInputRestrictions(true, InputUsageMask.MouseButtons);
  36.  
  37. MapScreen.AddLayer(_gauntletLayer);
  38. }
  39.  
  40. // MapScreen.OnFinalize() によって呼び出されます。
  41. protected override void OnFinalize()
  42. {
  43. MapScreen.RemoveLayer(_gauntletLayer);
  44. _gauntletLayer.InputRestrictions.ResetInputRestrictions();
  45. _gauntletLayer.ReleaseMovie(_gauntletMovie);
  46. _gauntletLayer = null;
  47. Layer = null;
  48.  
  49. _dataSource.OnFinalize();
  50. base.OnFinalize();
  51. }
  52.  
  53. // 毎ティック行う処理を記述します。
  54. //
  55. // dt: デルタタイム。前回のティックからの経過時間です。
  56. protected override void OnFrameTick(float dt)
  57. {
  58. base.OnFrameTick(dt);
  59. if (MapScreen.Input.IsKeyPressed(InputKey.Home))
  60. {
  61. _dataSource.IsVisible ^= true; // bool 反転
  62. }
  63. }
  64. }
  65. }
  66.  

+ SampleMissionView.cs
SampleMapView とは使用するメソッドが違うだけで、やっていることは全く同じです。

  1. using ExampleUI.ViewModels;
  2. using TaleWorlds.Engine.GauntletUI;
  3. using TaleWorlds.GauntletUI.Data;
  4. using TaleWorlds.InputSystem;
  5. using TaleWorlds.MountAndBlade.View.Missions;
  6.  
  7. namespace ExampleUI.Views
  8. {
  9. public class SampleMissionView : MissionView
  10. {
  11. private TestWindowVM _dataSource;
  12. private GauntletLayer _gauntletLayer;
  13. private IGauntletMovie _gauntletMovie;
  14.  
  15. public override void OnMissionScreenInitialize()
  16. {
  17. base.OnMissionScreenInitialize();
  18.  
  19. _dataSource = new TestWindowVM();
  20.  
  21. // localOrder (レイヤーの優先度) が小さいほど、他のレイヤーより下 (画面奥) に描画されます。
  22. //
  23. // ESC メニュー (GauntletMissionEscapeMenu) のレイヤー優先度が 50 なので、 これよりは小さい
  24. // した方がよさそうです。あるいは、ESC メニュー表示中は GUI を非表示にする処理を追加しましょう。
  25. _gauntletLayer = new GauntletLayer(40);
  26.  
  27. // Movie (= Screen に投影するもの) とは、XML から取得した UI の構造と、ViewModel (表示するデータ) を合わせた概念です。
  28. _gauntletMovie = _gauntletLayer.LoadMovie("ExampleUI.Window", _dataSource);
  29.  
  30. // このレイヤーの描画領域に対して行われた入力のうち、受け取るものをビットフラグによって管理しています。
  31. /*
  32.   _gauntletLayer.InputRestrictions.SetInputRestrictions(true, InputUsageMask.MouseButtons);
  33.   */
  34. // Mission 中はマウスを視点移動や攻撃に使うため、ポインティングデバイスとして使うには、
  35. // 戦闘結果画面において、Tab を押す -> リザルトとマウスカーソル表示 -> ボタンが押せるようになる
  36. // とやっているような感じで、キーボード操作を挟んで切り替える必要があります。
  37. // 今回はそこまで実装していないため、Mission 中は閉じるボタンをクリックできません。
  38.  
  39. MissionScreen.AddLayer(_gauntletLayer);
  40. }
  41.  
  42. // MissionScreen.OnEndMission() によって呼び出されます。
  43. public override void OnMissionScreenFinalize()
  44. {
  45. MissionScreen.RemoveLayer(_gauntletLayer);
  46. _gauntletLayer.ReleaseMovie(_gauntletMovie);
  47. _gauntletLayer = null;
  48.  
  49. _dataSource.OnFinalize();
  50. base.OnMissionScreenFinalize();
  51. }
  52.  
  53. // 毎ティック行う処理を記述します。
  54. //
  55. // dt: デルタタイム。前回のティックからの経過時間です。
  56. public override void OnMissionScreenTick(float dt)
  57. {
  58. base.OnMissionScreenTick(dt);
  59. if (Input.IsKeyPressed(InputKey.Home))
  60. {
  61. _dataSource.IsVisible ^= true; // bool 反転
  62. }
  63. }
  64. }
  65. }
  66.  

+ TestWindowVM.cs
ViewModel とは View を抽象化したものです。今回の例では、
  • 画面左上にウィンドウを表示
  • ウィンドウの中には閉じるボタン1つだけ
  • 閉じるボタンを押すとウィンドウが閉じる
  • Home キーでも開いたり閉じたりできる
というモデルで行きます。
TestWindowVM は View から完全に分離されているので、今回使う2つの View どちらにもそのまま使用できます。

  1. using TaleWorlds.Core;
  2. using TaleWorlds.Library;
  3. // View-ViewModel の参照を一方向にするため using ExampleUI.Views をやっていません。
  4.  
  5. namespace ExampleUI.ViewModels
  6. {
  7. // ViewModel は全て TaleWorlds.Library.ViewModel を継承して作ります。
  8. // クラス名に VM を付けなければならないという決まりがあるわけではないのですが、
  9. // 公式の ViewModel は全て VM を付けているようなので踏襲しています。
  10. public class TestWindowVM : ViewModel
  11. {
  12. private bool _isVisible;
  13.  
  14. public TestWindowVM()
  15. {
  16. IsVisible = true;
  17. }
  18.  
  19. // ViewModel のプロパティやメソッドは、View の LoadMovie で関連付けられた XML Prefab から参照できます。
  20. // XML の方を見てもらえば、それらがどのように使われているか分かるかと思います。
  21. [DataSourceProperty]
  22. public bool IsVisible
  23. {
  24. get => _isVisible;
  25. set
  26. {
  27. if (_isVisible != value)
  28. {
  29. _isVisible = value;
  30.  
  31. // このメソッドを呼ぶことで、TestWindowVM にバインディングされた View に対して
  32. // TestWindowVM.IsVisible プロパティの変更通知が行き、プロパティを参照している
  33. // ウィジェットの方も状態が変化するという仕組みです。
  34. OnPropertyChangedWithValue(value, "IsVisible");
  35. }
  36. }
  37. }
  38.  
  39. // こちらは値の変更がない固定ラベルなので、set アクセサーや OnPropertyChangedWithValue() は使っていません。
  40. [DataSourceProperty]
  41. public string WindowTitle => "Test Window"; // 以前はラベルにも日本語が使えたのですが、1.6.0 から文字化けするようになってしまいました。
  42.  
  43. [DataSourceProperty]
  44. public string CloseButtonLabel => "Close";
  45.  
  46. // ButtonWidget のクリックイベントに応じて呼ばれるよう、ExampleUI.Window.xml に記述してあります。
  47. public void OnCloseButtonClick()
  48. {
  49. IsVisible = false;
  50. InformationManager.DisplayMessage(new InformationMessage("ウィンドウを閉じました。\n再度開くには Home キーを押してください。"));
  51. }
  52. }
  53. }
  54.  
DataSourceProperty 属性
公式の ViewModel 派生クラスのプロパティにはこの属性を与えられたものが多く見受けられますが、ソースコードを見ても特に何か定義されているわけでもなく、実際、属性を付けても付けなくても動作は変わらないように見えます。唯一、ViewModel.Properties プロパティで DataSourceProperty のリストを作るのに使われているようですが、このプロパティは他からは特に参照されていないようです。一応、XML から参照されるプロパティには全てこの属性を付けてあります。

+ ExampleUI.Window.xml
ExampleUI で使うウィンドウの Prefab です。

  1. <Prefab>
  2. <Window>
  3. <Widget WidthSizePolicy="Fixed" HeightSizePolicy="Fixed" SuggestedWidth="400" SuggestedHeight="300" IsVisible="@IsVisible">
  4. <Children>
  5. <Standard.Window Parameter.Title="@WindowTitle">
  6. <Children>
  7. <Standard.PopupCloseButton Parameter.ButtonText="@CloseButtonLabel" Parameter.ButtonAction="OnCloseButtonClick" />
  8. </Children>
  9. </Standard.Window>
  10. </Children>
  11. </Widget>
  12. </Window>
  13. </Prefab>
Widget
TaleWorlds.GauntletUI.Widget クラスです。Widget クラスは基底クラスとしてデータを保持しているだけで、UI としての機能はありません。ここでは、他の UI をまとめるコンテナとして使っています。

SizePolicy
Fixed: 指定の幅にします
StretchToParent: 親要素の幅に合わせます
CoverChildren: 子要素を全て表示できる幅にします

IsVisible="@IsVisible"
名前が同じで分かりにくいですが、最初の方が Widget クラスのプロパティ、@付きの方がデータソースとなる ViewModel 派生クラス (今回の例で言えば TestWindowVM) のプロパティとなります。このように指定することで、TestWindowVM.IsVisible の中で呼んである ViewModel.OnPropertyChangedWithValue() によって発せられたイベントが Widget に届くようになり、TestWindowVM.IsVisible と Widget.IsVisible が連動するわけです。

Standard.Window と Standard.PopupCloseButton
公式の Prefab を使っています。公式の Prefab も大半は各シチュエーションに特化した大型のものばかりで、そのままでの再利用はしにくいですが、Native\GUI\Prefabs\Standard にあるものは比較的小型で汎用性が高くなっています。

Parameter
Standard.Window 等の Prefab では Parameter が宣言されているものがあり、Widget のプロパティに値を渡すのと同じ感覚で、Prefab 自体に値を渡すことができます。Prefab 側では、自分の中の Widget に受け取った値を渡すよう記述されています。



結果

  • MapScreen

  • MissionScreen

左上にウィンドウっぽいものが表示されています。Prefab を適当に選んだのでデザインがめちゃくちゃですが、画面上にとりあえず何かを表示する例ということで大目に見てください。

手順をおおまかにまとめると、
  1. 作りたい UI を考える
  2. その構想に沿った GUI の構造 (Prefab) と表示するデータ (ViewModel) を用意する
  3. 用意したものを、表示先がワールドマップなら MapView、戦闘などの Mission なら MissionView で LoadMovie() する
  4. View をスクリーンに追加する
となります。




Opponent Health Bar


今度はもう少し実践的な MOD を作っていきます。見出しが示す通り、戦っている相手の HP バーを表示する MOD です。
イメージとしてはこんな感じ。

  • OpponentHealthBar プロジェクト
    • SubModule.cs
    • MissionOpponentHealthBar.cs
    • OpponentHealthVM.cs
  • OpponentHealthBar.xml

OpponentHealthBar.xml は、
[Bannerlord インストールフォルダー]\Modules\OpponentHealthBar\GUI\Prefabs
の中に保存します。


バージョン1

それでは、とりあえず簡単に文字で表示するところから始めてみます。

+ SubModule.cs
  1. using System;
  2. using TaleWorlds.MountAndBlade;
  3. using TaleWorlds.MountAndBlade.View.Missions;
  4.  
  5. namespace OpponentHealthBar
  6. {
  7. public class SubModule : MBSubModuleBase
  8. {
  9. public override void OnBeforeMissionBehaviourInitialize(Mission mission)
  10. {
  11. base.OnBeforeMissionBehaviourInitialize(mission);
  12. mission.AddMissionBehaviour(
  13. ViewCreatorManager.CreateMissionView<MissionOpponentHealthBar>(false, mission, Array.Empty<object>()));
  14. }
  15. }
  16. }
  17.  

+ MissionOpponentHealthBar.cs
  1. using TaleWorlds.Core;
  2. using TaleWorlds.Engine.GauntletUI;
  3. using TaleWorlds.GauntletUI.Data;
  4. using TaleWorlds.MountAndBlade;
  5. using TaleWorlds.MountAndBlade.View.Missions;
  6.  
  7. namespace OpponentHealthBar
  8. {
  9. public class MissionOpponentHealthBar : MissionView
  10. {
  11. private OpponentHealthVM _dataSource;
  12. private GauntletLayer _gauntletLayer;
  13. private IGauntletMovie _gauntletMovie;
  14.  
  15. public MissionOpponentHealthBar()
  16. {
  17. ViewOrderPriorty = 20;
  18. }
  19.  
  20. public override void OnMissionScreenInitialize()
  21. {
  22. base.OnMissionScreenInitialize();
  23.  
  24. _dataSource = new OpponentHealthVM();
  25. _gauntletLayer = new GauntletLayer(ViewOrderPriorty);
  26. _gauntletMovie = _gauntletLayer.LoadMovie("OpponentHealthBar", _dataSource);
  27. MissionScreen.AddLayer(_gauntletLayer);
  28. }
  29.  
  30. public override void OnMissionScreenFinalize()
  31. {
  32. MissionScreen.RemoveLayer(_gauntletLayer);
  33. _gauntletLayer.ReleaseMovie(_gauntletMovie);
  34. _gauntletLayer = null;
  35.  
  36. _dataSource.OnFinalize();
  37. base.OnMissionScreenFinalize();
  38. }
  39.  
  40. // 画面中央 (マウスカーソルの位置) と、一番手前にある IFocusable なオブジェクトとが交差した瞬間に呼ばれます。
  41. //
  42. // agent: フォーカスした Agent。シングルプレイだと、おそらく常に Agent.Main (プレイヤーキャラクター) です。
  43. // focusableObject: フォーカスされたもの
  44. // isInteractable: Talk や Use などのインタラクトが可能か
  45. //
  46. // フォーカスが 10m までしか利かないのは MissionMainAgentInteractionComponent.FocusTick() で
  47. // 決められた仕様なのでどうしようもありません。
  48. public override void OnFocusGained(Agent agent, IFocusable focusableObject, bool isInteractable)
  49. {
  50. base.OnFocusGained(agent, focusableObject, isInteractable);
  51. _dataSource.OnFocusChanged(focusableObject);
  52. }
  53.  
  54. // 今までフォーカスされていた IFocusable なオブジェクトから、フォーカスが外れた瞬間に呼ばれます。
  55. //
  56. // agent: フォーカスしていた Agent。シングルプレイだと、おそらく常に Agent.Main (プレイヤーキャラクター) です。
  57. // focusableObject: フォーカスされていたもの
  58. public override void OnFocusLost(Agent agent, IFocusable focusableObject)
  59. {
  60. base.OnFocusLost(agent, focusableObject);
  61. _dataSource.OnFocusChanged(focusableObject);
  62. }
  63.  
  64. // Agent が何らかの攻撃を受けた際に呼ばれます。
  65. //
  66. // affectedAgent: 攻撃を受けた Agent
  67. // affectorAgent: 攻撃を行った Agent
  68. // damage: ダメージ量
  69. // affectorWeapon: 使用された武器
  70. public override void OnAgentHit(Agent affectedAgent, Agent affectorAgent, int damage, in MissionWeapon affectorWeapon)
  71. {
  72. base.OnAgentHit(affectedAgent, affectorAgent, damage, affectorWeapon);
  73. _dataSource.OnAgentHit(affectedAgent);
  74. }
  75.  
  76. // Agent が戦闘不能になった際に呼ばれます。
  77. //
  78. // affectedAgent: 攻撃を受けた Agent
  79. // affectorAgent: 攻撃を行った Agent
  80. // agentState: Agent の状態
  81. // blow: とどめの一撃の内容
  82. //
  83. // 似たようなメソッドに OnAgentDeleted がありますが、そちらは
  84. // 戦闘不能になって倒れている Agent が時間経過や表示限界により消滅する際に呼ばれるようです。
  85. public override void OnAgentRemoved(Agent affectedAgent, Agent affectorAgent, AgentState agentState, KillingBlow blow)
  86. {
  87. base.OnAgentRemoved(affectedAgent, affectorAgent, agentState, blow);
  88. _dataSource.OnAgentRemoved(affectedAgent);
  89. }
  90. }
  91. }
  92.  

+ OpponentHealthVM.cs
今度のモデルは、
  • Agent (Mission 中に動きうるもの) の HP 現在値/最大値 を文字で表示する
  • Agent にフォーカスが合うと表示、フォーカスが外れると非表示
とします。

  1. using System;
  2. using TaleWorlds.Library;
  3. using TaleWorlds.MountAndBlade;
  4.  
  5. namespace OpponentHealthBar
  6. {
  7. public class OpponentHealthVM : ViewModel
  8. {
  9. private Agent _opponent;
  10. private string _strValues;
  11. private bool _isVisible;
  12.  
  13. private const string ValueFormat = "{0} / {1}";
  14.  
  15. public OpponentHealthVM()
  16. {
  17. _opponent = null;
  18. _strValues = string.Empty;
  19. _isVisible = false;
  20. }
  21.  
  22. [DataSourceProperty]
  23. public string StrValues
  24. {
  25. get => _strValues;
  26. private set
  27. {
  28. if (_strValues != value)
  29. {
  30. _strValues = value;
  31. OnPropertyChangedWithValue(value, "StrValues");
  32. }
  33. }
  34. }
  35.  
  36. [DataSourceProperty]
  37. public bool IsVisible
  38. {
  39. get => _isVisible;
  40. set
  41. {
  42. if (_isVisible != value)
  43. {
  44. _isVisible = value;
  45. OnPropertyChangedWithValue(value, "IsVisible");
  46. }
  47. }
  48. }
  49.  
  50. // GUI に表示するデータの更新はこのメソッドで行います。
  51. // ただし、ここに記述すればあとは勝手に更新されていくというものではないので、
  52. // 自分が必要な場所では自分で呼び出さなければなりません。
  53. public override void RefreshValues()
  54. {
  55. base.RefreshValues();
  56.  
  57. int currentValue = (int)Math.Ceiling(_opponent?.Health ?? 0f);
  58. int maxValue = (int)Math.Ceiling(_opponent?.HealthLimit ?? 0f);
  59. StrValues = string.Format(ValueFormat, currentValue, maxValue);
  60. IsVisible = _opponent != null;
  61. }
  62.  
  63. public void OnFocusChanged(IFocusable focusableObject)
  64. {
  65. if (focusableObject is Agent possibleOpponent)
  66. {
  67. _opponent = (_opponent == null || _opponent != possibleOpponent) ? possibleOpponent : null;
  68. RefreshValues();
  69. }
  70. }
  71.  
  72. public void OnAgentHit(Agent affectedAgent)
  73. {
  74. if (_opponent == affectedAgent)
  75. {
  76. RefreshValues();
  77. }
  78. }
  79.  
  80. public void OnAgentRemoved(Agent removedAgent)
  81. {
  82. if (_opponent == removedAgent)
  83. {
  84. // 先にフォーカスロストが発生してここには入らないかも。
  85. OnFocusChanged(removedAgent);
  86. }
  87. }
  88. }
  89. }
  90.  

+ OpponentHealthBar.xml
  1. <Prefab>
  2. <Window>
  3. <Widget WidthSizePolicy="Fixed" HeightSizePolicy="Fixed" SuggestedWidth="430" SuggestedHeight="50" HorizontalAlignment="Center" VerticalAlignment="Top" MarginTop="250" IsVisible="@IsVisible">
  4. <Children>
  5. <TextWidget Text="@StrValues" WidthSizePolicy="CoverChildren" HeightSizePolicy="CoverChildren" HorizontalAlignment="Center" VerticalAlignment="Center" Brush="Tooltip.Text" Brush.FontSize="24" />
  6. </Children>
  7. </Widget>
  8. </Window>
  9. </Prefab>
Alignment
親要素の領域内のどちら側に寄せるかです。親要素より幅が小さい場合にしか効果がありません。この例の TextWidget で言うと、親要素は幅 430、自身は CoverChildren、つまり StrValues が入りきるだけの幅なので機能していますが、これを StretchToParent とかにすると、左寄せや右寄せが機能しなくなります。

Margin
Left, Top, Right, Bottom のマージンです。幅+マージンが要素の実際の幅になります。


以上を実行した状態が下の画像です。

ちょっと見にくいですが、画面上部中央に相手の HP 100/100 が表示されていますね。フォーカスを外せば数字は消えます。しかし、今のところ町人だろうが馬だろうが Agent なら何にでも表示されてしまいますので、そこは改良が必要です。


バージョン2

次はバーの導入とその他の改良を行っていきたいと思います。

バー表示に使うのは FillBarWidget です。ソースコードや、FillBarWidget を使用している公式の Prefab を見てみると、FillBarWidget.MaxAmount プロパティと FillBarWidget.InitialAmmount プロパティに値を渡せばとりあえず動きそうです。よって、ViewModel 側でその値を用意してやる必要があります。
+ OpponentHealthVM.cs
[DataSourceProperty]
public int CurrentValue
{
    get => _currentValue;
    private set
    {
        if (_currentValue != value)
        {
            _currentValue = value;
            OnPropertyChangedWithValue(value, "CurrentValue");
        }
    }
}
 
[DataSourceProperty]
public int MaxValue
{
    get => _maxValue;
    private set
    {
        if (_maxValue != value)
        {
            _maxValue = value;
            OnPropertyChangedWithValue(value, "MaxValue");
        }
    }
}
 
// GUI に表示するデータの更新はこのメソッドで行います。
// ただし、ここに記述すればあとは勝手に更新されていくというものではないので、
// 自分が必要な場所では自分で呼び出さなければなりません。
public override void RefreshValues()
{
    base.RefreshValues();
 
    // 元々ローカル変数だったものを、View から参照できるようにプロパティに昇格しました。
    CurrentValue = (int)Math.Ceiling(_opponent?.Health ?? 0f);
    MaxValue = (int)Math.Ceiling(_opponent?.HealthLimit ?? 0f);
    StrValues = string.Format(ValueFormat, CurrentValue, MaxValue);
    IsVisible = _opponent != null;
}
 
public void OnFocusChanged(IFocusable focusableObject)
{
    // 適切な相手に表示されるよう条件を追加しました。
    if (focusableObject is Agent possibleOpponent && possibleOpponent.IsHuman && possibleOpponent.IsEnemyOf(Agent.Main))
    {
        _opponent = (_opponent == null || _opponent != possibleOpponent) ? possibleOpponent : null;
        RefreshValues();
    }
}
 


ViewModel にプロパティを追加したので、今度は Prefab を通じてそれらを FillBarWidget に渡します。
+ OpponentHealthBar.xml
  1. <Prefab>
  2. <Window>
  3. <FillBarWidget WidthSizePolicy="Fixed" HeightSizePolicy="Fixed" SuggestedWidth="430" SuggestedHeight="50" HorizontalAlignment="Center" VerticalAlignment="Top" MarginTop="250" ContainerWidget="FillBarContainer" FillWidget="FillVisualParent\FillVisual" MaxAmount="@MaxValue" InitialAmount="@CurrentValue" IsVisible="@IsVisible">
  4. <Children>
  5.  
  6. <Widget Id="FillVisualParent" WidthSizePolicy="Fixed" HeightSizePolicy="StretchToParent" SuggestedWidth="400" HorizontalAlignment="Center" VerticalAlignment="Center" MarginTop="10" MarginBottom="10" Sprite="BlankWhiteSquare" Color="#202020A0">
  7. <Children>
  8.  
  9. <BrushWidget Id="FillVisual" WidthSizePolicy="Fixed" HeightSizePolicy="StretchToParent" SuggestedWidth="400" HorizontalAlignment="Left" Brush="Mission.MainAgentHUD.HeroHealthBar.Fill" />
  10.  
  11. </Children>
  12. </Widget>
  13.  
  14. <Widget Id="FillBarContainer" WidthSizePolicy="StretchToParent" HeightSizePolicy="StretchToParent" Sprite="options_memory_progress_frame" />
  15.  
  16. <TextWidget Text="@StrValues" WidthSizePolicy="CoverChildren" HeightSizePolicy="CoverChildren" HorizontalAlignment="Center" VerticalAlignment="Center" PositionYOffset="3" Brush="Tooltip.Text" Brush.FontSize="24" />
  17.  
  18. </Children>
  19. </FillBarWidget>
  20. </Window>
  21. </Prefab>
FillBarWidget
バージョン1ではただの Widget だったトップレベルのウィジェットを FillBarWidget に変更してあります (3行目)。

FillVisual
赤ゲージの Brush を保持した BrushWidget の ID です (9行目)。このように、Widget には任意の ID がつけられます。それによって、他のウィジェットを操作するタイプのウィジェットに対し、操作対象がどれなのか伝えることができます。
ここでは、FillBarWidget.FillWidget プロパティに渡して、FillBarWidget が "FillVisual" の幅を操作できるようにしています。
親要素の "FillVisualParent" はゲージが減った部分を埋める背景画像です。"BlankWhiteSquare" という無地画像を、暗い半透明に着色・透過させて使っています。

FillBarContainer
14行目にある、スプライト画像を保持した Widget の ID です。これを FillBarWidget.ContainerWidget プロパティに渡してバーの枠としています。


ところで、Mission 用の GUI はそのままだとフォトモード中でも表示されてしまいます。構図を決めている間に GUI が表示されてしまうのは邪魔ですから、MissionView を使うときは、フォトモード中 GUI を非表示にする処理を追加しておきましょう。
+ MissionOpponentHealthBar.cs
// フォトモードで構図を決めている最中に GUI が表示されないようにするための処理です。
// この処理を入れても入れなくても、どちらにしろ出力される画像ファイルに GUI は写りません。
public override void OnPhotoModeActivated()
{
    base.OnPhotoModeActivated();
    _gauntletLayer._gauntletUIContext.ContextAlpha = 0f;
}
 
public override void OnPhotoModeDeactivated()
{
    base.OnPhotoModeDeactivated();
    _gauntletLayer._gauntletUIContext.ContextAlpha = 1f;
}
 



結果

  • 攻撃前

  • 攻撃後

どうでしょう、一応 HP バーらしくなったのではないでしょうか。

他にも、与えたダメージ分のバーをスライドさせる視覚効果とか、フォーカスが外れてからバーが消えるまでに時間差を設けるとか、改良はいろいろ考えられますが、サンプルではここまでです。興味があったら自分の MOD として実装してみてください。

+ コード最終形
SubModule.cs
  1. using System;
  2. using TaleWorlds.MountAndBlade;
  3. using TaleWorlds.MountAndBlade.View.Missions;
  4.  
  5. namespace OpponentHealthBar
  6. {
  7. public class SubModule : MBSubModuleBase
  8. {
  9. public override void OnBeforeMissionBehaviourInitialize(Mission mission)
  10. {
  11. base.OnBeforeMissionBehaviourInitialize(mission);
  12. mission.AddMissionBehaviour(
  13. ViewCreatorManager.CreateMissionView<MissionOpponentHealthBar>(false, mission, Array.Empty<object>()));
  14. }
  15. }
  16. }
  17.  

MissionOpponentHealthBar.cs
  1. using TaleWorlds.Core;
  2. using TaleWorlds.Engine.GauntletUI;
  3. using TaleWorlds.GauntletUI.Data;
  4. using TaleWorlds.MountAndBlade;
  5. using TaleWorlds.MountAndBlade.View.Missions;
  6.  
  7. namespace OpponentHealthBar
  8. {
  9. public class MissionOpponentHealthBar : MissionView
  10. {
  11. private OpponentHealthVM _dataSource;
  12. private GauntletLayer _gauntletLayer;
  13. private IGauntletMovie _gauntletMovie;
  14.  
  15. public MissionOpponentHealthBar()
  16. {
  17. ViewOrderPriorty = 20;
  18. }
  19.  
  20. public override void OnMissionScreenInitialize()
  21. {
  22. base.OnMissionScreenInitialize();
  23.  
  24. _dataSource = new OpponentHealthVM();
  25. _gauntletLayer = new GauntletLayer(ViewOrderPriorty);
  26. _gauntletMovie = _gauntletLayer.LoadMovie("OpponentHealthBar", _dataSource);
  27. MissionScreen.AddLayer(_gauntletLayer);
  28. }
  29.  
  30. public override void OnMissionScreenFinalize()
  31. {
  32. MissionScreen.RemoveLayer(_gauntletLayer);
  33. _gauntletLayer.ReleaseMovie(_gauntletMovie);
  34. _gauntletLayer = null;
  35.  
  36. _dataSource.OnFinalize();
  37. base.OnMissionScreenFinalize();
  38. }
  39.  
  40. // 画面中央 (マウスカーソルの位置) と、一番手前にある IFocusable なオブジェクトとが交差した瞬間に呼ばれます。
  41. //
  42. // agent: フォーカスした Agent。シングルプレイだと、おそらく常に Agent.Main (プレイヤーキャラクター) です。
  43. // focusableObject: フォーカスされたもの
  44. // isInteractable: Talk や Use などのインタラクトが可能か
  45. //
  46. // フォーカスが 10m までしか利かないのは MissionMainAgentInteractionComponent.FocusTick() で
  47. // 決められた仕様なのでどうしようもありません。
  48. public override void OnFocusGained(Agent agent, IFocusable focusableObject, bool isInteractable)
  49. {
  50. base.OnFocusGained(agent, focusableObject, isInteractable);
  51. _dataSource.OnFocusChanged(focusableObject);
  52. }
  53.  
  54. // 今までフォーカスされていた IFocusable なオブジェクトから、フォーカスが外れた瞬間に呼ばれます。
  55. //
  56. // agent: フォーカスしていた Agent。シングルプレイだと、おそらく常に Agent.Main (プレイヤーキャラクター) です。
  57. // focusableObject: フォーカスされていたもの
  58. public override void OnFocusLost(Agent agent, IFocusable focusableObject)
  59. {
  60. base.OnFocusLost(agent, focusableObject);
  61. _dataSource.OnFocusChanged(focusableObject);
  62. }
  63.  
  64. // Agent が何らかの攻撃を受けた際に呼ばれます。
  65. //
  66. // affectedAgent: 攻撃を受けた Agent
  67. // affectorAgent: 攻撃を行った Agent
  68. // damage: ダメージ量
  69. // affectorWeapon: 使用された武器
  70. public override void OnAgentHit(Agent affectedAgent, Agent affectorAgent, int damage, in MissionWeapon affectorWeapon)
  71. {
  72. base.OnAgentHit(affectedAgent, affectorAgent, damage, affectorWeapon);
  73. _dataSource.OnAgentHit(affectedAgent);
  74. }
  75.  
  76. // Agent が戦闘不能になった際に呼ばれます。
  77. //
  78. // affectedAgent: 攻撃を受けた Agent
  79. // affectorAgent: 攻撃を行った Agent
  80. // agentState: Agent の状態
  81. // blow: とどめの一撃の内容
  82. //
  83. // 似たようなメソッドに OnAgentDeleted がありますが、そちらは
  84. // 戦闘不能になって倒れている Agent が時間経過や表示限界により消滅する際に呼ばれるようです。
  85. public override void OnAgentRemoved(Agent affectedAgent, Agent affectorAgent, AgentState agentState, KillingBlow blow)
  86. {
  87. base.OnAgentRemoved(affectedAgent, affectorAgent, agentState, blow);
  88. _dataSource.OnAgentRemoved(affectedAgent);
  89. }
  90.  
  91. // フォトモードで構図を決めている最中に GUI が表示されないようにするための処理です。
  92. // この処理を入れても入れなくても、どちらにしろ出力される画像ファイルに GUI は写りません。
  93. public override void OnPhotoModeActivated()
  94. {
  95. base.OnPhotoModeActivated();
  96. _gauntletLayer._gauntletUIContext.ContextAlpha = 0f;
  97. }
  98.  
  99. public override void OnPhotoModeDeactivated()
  100. {
  101. base.OnPhotoModeDeactivated();
  102. _gauntletLayer._gauntletUIContext.ContextAlpha = 1f;
  103. }
  104. }
  105. }
  106.  

OpponentHealthVM.cs
  1. using System;
  2. using TaleWorlds.Library;
  3. using TaleWorlds.MountAndBlade;
  4.  
  5. namespace OpponentHealthBar
  6. {
  7. public class OpponentHealthVM : ViewModel
  8. {
  9. private Agent _opponent;
  10. private int _currentValue;
  11. private int _maxValue;
  12. private string _strValues;
  13. private bool _isVisible;
  14.  
  15. private const string ValueFormat = "{0} / {1}";
  16.  
  17. public OpponentHealthVM()
  18. {
  19. _opponent = null;
  20. _strValues = string.Empty;
  21. _isVisible = false;
  22. }
  23.  
  24. [DataSourceProperty]
  25. public int CurrentValue
  26. {
  27. get => _currentValue;
  28. private set
  29. {
  30. if (_currentValue != value)
  31. {
  32. _currentValue = value;
  33. OnPropertyChangedWithValue(value, "CurrentValue");
  34. }
  35. }
  36. }
  37.  
  38. [DataSourceProperty]
  39. public int MaxValue
  40. {
  41. get => _maxValue;
  42. private set
  43. {
  44. if (_maxValue != value)
  45. {
  46. _maxValue = value;
  47. OnPropertyChangedWithValue(value, "MaxValue");
  48. }
  49. }
  50. }
  51.  
  52. [DataSourceProperty]
  53. public string StrValues
  54. {
  55. get => _strValues;
  56. private set
  57. {
  58. if (_strValues != value)
  59. {
  60. _strValues = value;
  61. OnPropertyChangedWithValue(value, "StrValues");
  62. }
  63. }
  64. }
  65.  
  66. [DataSourceProperty]
  67. public bool IsVisible
  68. {
  69. get => _isVisible;
  70. set
  71. {
  72. if (_isVisible != value)
  73. {
  74. _isVisible = value;
  75. OnPropertyChangedWithValue(value, "IsVisible");
  76. }
  77. }
  78. }
  79.  
  80. // GUI に表示するデータの更新はこのメソッドで行います。
  81. // ただし、ここに記述すればあとは勝手に更新されていくというものではないので、
  82. // 自分が必要な場所では自分で呼び出さなければなりません。
  83. public override void RefreshValues()
  84. {
  85. base.RefreshValues();
  86.  
  87. // 元々ローカル変数だったものを、View から参照できるようにプロパティに昇格しました。
  88. CurrentValue = (int)Math.Ceiling(_opponent?.Health ?? 0f);
  89. MaxValue = (int)Math.Ceiling(_opponent?.HealthLimit ?? 0f);
  90. StrValues = string.Format(ValueFormat, CurrentValue, MaxValue);
  91. IsVisible = _opponent != null;
  92. }
  93.  
  94. public void OnFocusChanged(IFocusable focusableObject)
  95. {
  96. // 適切な相手に表示されるよう条件を追加しました。
  97. if (focusableObject is Agent possibleOpponent && possibleOpponent.IsHuman && possibleOpponent.IsEnemyOf(Agent.Main))
  98. {
  99. _opponent = (_opponent == null || _opponent != possibleOpponent) ? possibleOpponent : null;
  100. RefreshValues();
  101. }
  102. }
  103.  
  104. public void OnAgentHit(Agent affectedAgent)
  105. {
  106. if (_opponent == affectedAgent)
  107. {
  108. RefreshValues();
  109. }
  110. }
  111.  
  112. public void OnAgentRemoved(Agent removedAgent)
  113. {
  114. if (_opponent == removedAgent)
  115. {
  116. // 先にフォーカスロストが発生してここには入らないかも。
  117. OnFocusChanged(removedAgent);
  118. }
  119. }
  120. }
  121. }
  122.  

OpponentHealthBar.xml
  1. <Prefab>
  2. <Window>
  3. <FillBarWidget WidthSizePolicy="Fixed" HeightSizePolicy="Fixed" SuggestedWidth="430" SuggestedHeight="50" HorizontalAlignment="Center" VerticalAlignment="Top" MarginTop="250" ContainerWidget="FillBarContainer" FillWidget="FillVisualParent\FillVisual" MaxAmount="@MaxValue" InitialAmount="@CurrentValue" IsVisible="@IsVisible">
  4. <Children>
  5.  
  6. <Widget Id="FillVisualParent" WidthSizePolicy="Fixed" HeightSizePolicy="StretchToParent" SuggestedWidth="400" HorizontalAlignment="Center" VerticalAlignment="Center" MarginTop="10" MarginBottom="10" Sprite="BlankWhiteSquare" Color="#202020A0">
  7. <Children>
  8.  
  9. <BrushWidget Id="FillVisual" WidthSizePolicy="Fixed" HeightSizePolicy="StretchToParent" SuggestedWidth="400" HorizontalAlignment="Left" Brush="Mission.MainAgentHUD.HeroHealthBar.Fill" />
  10.  
  11. </Children>
  12. </Widget>
  13.  
  14. <Widget Id="FillBarContainer" WidthSizePolicy="StretchToParent" HeightSizePolicy="StretchToParent" Sprite="options_memory_progress_frame" />
  15.  
  16. <TextWidget Text="@StrValues" WidthSizePolicy="CoverChildren" HeightSizePolicy="CoverChildren" HorizontalAlignment="Center" VerticalAlignment="Center" PositionYOffset="3" Brush="Tooltip.Text" Brush.FontSize="24" />
  17.  
  18. </Children>
  19. </FillBarWidget>
  20. </Window>
  21. </Prefab>




タグ:

+ タグ編集
  • タグ:
最終更新:2021年09月05日 21:28