こんにちは、UEFN好きエンジニアのイワケンです。
UEFNの日本語記事増やしたい委員会を一人でやっています。
今日は、OnlyUpの仕組みを作りながら、TextUI (text_block)のVerse実装を理解するシリーズです。
最終アウトプットはこちら
画面の下に 「○○m」 と、現在登っている高さを表示する実装を目指しましょう。
OnlyUpのTextUIを実装する上での課題
仕様としては
- あるZoneに入ると、Text表示が始まる
- 各プレイヤーの現在の高さを各プレイヤー毎にTextに表示する
になります。ここでの難しさは
- 各プレイヤーに対して、違う値を各々のUIにそれぞれ表示する
というマルチプレイ対応の実装になります。
もし理解していないと、ごろ助さんのツイートにもあるように
- すべてのプレイヤーが同じ数値が反映されてしまう...
- 1人のプレイヤーのUIしか反映させられない...
などといった不具合が発生する場合があります。
今回の記事で、UIの仕組みを把握しつつ、コードを書きながら理解していきましょう。
サンプルプロジェクトの導入方法
以下にサンプルプロジェクトのVerse部分を公開しました。
導入手順は
- こちらからIwakenVerseToolKit.zipをダウンロードし、展開する
- IwakenVerseToolKitフォルダを
~/Fortnite Projects/{Project Name}/Plugins/{Project Name}/Content
以下に移動する - UEFNのMenuバーから[Verse]>[Build Verse Code]を選択
- UEFNのContent/CreativeDevices以下にファイルが存在することを確認
と進めていただけると嬉しいです。詳しくはGithubレポジトリのReadmeを参考にしてください。
こんな感じでContent以下にIwakenVerseToolkitのフォルダを置ければ成功です。(名前を間違えるとエラーが出るので注意)
また、レベル上の作業 (ステージの配置)などはサンプルプロジェクトには今回含まれておりません。
VerseのUI実装の全体像
前回の記事「[UEFN][Verse] UIにImage/Textureを貼る実装。これからVerseやっていきな人へ」にも紹介しましたが、再度共有します。
Verseの生のコード「UnrealEngine.digest.verse
」からclass関係をPlantUMLにすると次のようになります。
ここからわかる構造の理解として
- playerがplayer_uiを持つ
- player_uiがwidgetを持つ (つまり、playerに対してwidgetを付与する)
- widgetにはたくさんの種類がある
- canvas
- button_load (ボタンのUI要素)
- texture_block (TextureのUI要素)
- text_block (文字のUI要素)
- ...
- canvasの中にwidgetを複数持つことができる (厳密にはcanvas_slotの中)
ということがわかります。今回マルチプレイの実装で重要なのが
- playerに対してwidgetを付与する
という理解です。
つまり100人のプレイヤーがいたら、100個のwidgetを付与する処理を1つのDeviceが実行する。このイメージを持ちましょう。
UIの実装を見る
今回のUI実装はtext_ui_device.verse
にまとめています。これはOnlyUpのロジックに関係なく、文字を表示するだけの仕組みを持っています。OnlyUpのロジックはonlyup_manager_device.verse
に書いています。
using {/Fortnite.com/Devices }
using {/Fortnite.com/UI}
using {/UnrealEngine.com/Temporary/UI}
using {/UnrealEngine.com/Temporary/SpatialMath}
using {/Verse.org/Colors}
using {/Verse.org/Simulation}
# A Verse-authored creative device that can be placed in a level
ui_module<public> := module:
text_ui_device<public> := class(creative_device):
var WidgetClassPerPlayer: [player]one_text_canvas = map{}
AddUI<public>(Agent:agent):void=
if:
Player := player[Agent]
PlayerUI := GetPlayerUI[Player]
not WidgetClassPerPlayer[Player]
WClass := set WidgetClassPerPlayer[Player] = one_text_canvas{}
then:
Widget:canvas = WClass.CreateCanvas()
PlayerUI.AddWidget(Widget)
AddUIForAllPlayer<public>():void=
for(Player : GetPlayspace().GetPlayers()):
AddUI(Player)
UpdateText<public>(Agent:agent,Text:string):void=
if(Player := player[Agent],WClass := WidgetClassPerPlayer[Player]):
WClass.UpdateText(Text)
UpdateTextForAllPlayer<public>(Text:string):void=
for(Player : GetPlayspace().GetPlayers()):
UpdateText(Player,Text)
one_text_canvas := class():
var TextWidget:text_block = text_block{}
var MyCanvas: canvas = canvas{}
StringToMessage<localizes>(Text:string):message = "{Text}"
CreateCanvas():canvas=
set TextWidget = text_block{DefaultTextColor := NamedColors.White}
set MyCanvas = canvas:
Slots := array:
canvas_slot:
Anchors := anchors{Minimum := vector2{X:=0.5, Y:=0.85}, Maximum := vector2{X:=0.5, Y:=0.85}}
Offsets := margin{Top := 0.0,Left := 0.0, Right := 0.0, Bottom := 0.0}
Alignment := vector2{X:=0.5, Y:=0.5}
SizeToContent := true
Widget := TextWidget
return MyCanvas
GetCanvas()<decides><transacts>:canvas = MyCanvas
UpdateText(Text:string):void=
TextWidget.SetText(StringToMessage(Text))
ここで重要なのは
var WidgetClassPerPlayer: [player]one_text_canvas = map{}
Widgetの情報をmap構造持っていることです。ここでのmapとはクリエイティブのマップのことではなく、プログラミングのKey-Value型のデータ構造のことです。
playerをKeyとして、one_text_canvas (Widgetの情報を持っているclass)をValueとしたmap型の変数を持つことで「 playerに対してwidgetを付与する」という実装を実現することができます。
あとtext_ui_device.verse
には
- text_ui_deviceクラス
- WidgetをPlayerに付与するメソッド
- Textを更新するメソッド
- one_text_canvasクラス
- 1つのtext_blockを持ったcanvasを生成するメソッド
- canvas内のtext_blockを更新するメソッド
を準備します。
text_ui_device<public> := class(creative_device):
var WidgetClassPerPlayer: [player]one_text_canvas = map{}
AddUI<public>(Agent:agent):void=
if:
Player := player[Agent]
PlayerUI := GetPlayerUI[Player]
not WidgetClassPerPlayer[Player]
WClass := set WidgetClassPerPlayer[Player] = one_text_canvas{}
then:
Widget:canvas = WClass.CreateCanvas()
PlayerUI.AddWidget(Widget)
AddUIForAllPlayer<public>():void=
for(Player : GetPlayspace().GetPlayers()):
AddUI(Player)
UpdateText<public>(Agent:agent,Text:string):void=
if(Player := player[Agent],WClass := WidgetClassPerPlayer[Player]):
WClass.UpdateText(Text)
UpdateTextForAllPlayer<public>(Text:string):void=
for(Player : GetPlayspace().GetPlayers()):
UpdateText(Player,Text)
また、Textの更新についても
- あるPlayer一人に対してなのか
- 全体のPlayerに対してなのか
を区別してメソッドを作ります。
UpdateText<public>(Agent:agent,Text:string):void=
if(Player := player[Agent],WClass := WidgetClassPerPlayer[Player]):
WClass.UpdateText(Text)
UpdateTextForAllPlayer<public>(Text:string):void=
for(Player : GetPlayspace().GetPlayers()):
UpdateText(Player,Text)
Canvasの生成と更新については、one_text_canvas
クラスに中身を切り分けて書いています。
one_text_canvas := class():
var TextWidget:text_block = text_block{}
var MyCanvas: canvas = canvas{}
StringToMessage<localizes>(Text:string):message = "{Text}"
CreateCanvas():canvas=
set TextWidget = text_block{DefaultTextColor := NamedColors.White}
set MyCanvas = canvas:
Slots := array:
canvas_slot:
Anchors := anchors{Minimum := vector2{X:=0.5, Y:=0.85}, Maximum := vector2{X:=0.5, Y:=0.85}}
Offsets := margin{Top := 0.0,Left := 0.0, Right := 0.0, Bottom := 0.0}
Alignment := vector2{X:=0.5, Y:=0.5}
SizeToContent := true
Widget := TextWidget
return MyCanvas
GetCanvas()<decides><transacts>:canvas = MyCanvas
UpdateText(Text:string):void=
TextWidget.SetText(StringToMessage(Text))
text_blockの文字はstring型ではなくmessage型 (多言語対応も可能な型と理解)のため、string型からmessage型に変換するメソッドも定義しています。
TextToMessage<localizes>(Text:string):message = "{Text}"
これで
- WidgetをPlayerに付与する実装
- Canvasを生成する実装
- Textを更新する実装
が準備できました。
OnlyUpのロジックの実装を見る
今回は
- mutator_zoneに入ったPlayerの高さ情報をTextに表示する
となっています。より分解すると
- あるPlayerがmutator_zoneに入ったら一回だけ、そのPlayerのみ にAddUIが実行される。
- あるPlayerの高さ情報 が、そのPlayerのUIのTextのみ に反映される。
といったところがポイントになります。
そのうえでまずは全体実装を眺めていきましょう。
using { /Fortnite.com/Devices }
using { /Fortnite.com/Characters }
using { /Verse.org/Simulation }
using { IwakenVerseToolkit.ui_module }
# See https://dev.epicgames.com/documentation/en-us/uefn/create-your-own-device-in-verse for how to create a verse device.
sample_only_up := module:
# A Verse-authored creative device that can be placed in a level
onlyup_manager_device := class(creative_device):
@editable
TextUIDevice:text_ui_device = text_ui_device{}
@editable
EnterTrigger:mutator_zone_device = mutator_zone_device{}
@editable
FloorHeight:float = 0.0 #基準となる床の高さ
var StartPerPlayer: [agent]logic = map{}
OnBegin<override>()<suspends>:void=
EnterTrigger.AgentEntersEvent.Subscribe(OnStartTriggered)
OnStartTriggered(Agent:agent):void=
spawn{StartHeightDisplay(Agent)}
StartHeightDisplay(Agent: agent)<suspends>:void=
# 2回目以降は処理しない
if(StartPerPlayer[Agent]?):
return
# 初めてのゾーン内であれば処理する
if(set StartPerPlayer[Agent] = true):
TextUIDevice.AddUI(Agent) # UIの付与
loop:
Sleep(0.1) # 0.1秒待つ
IntHeight := GetPlayerHeight(Agent)
TextUIDevice.UpdateText(Agent,"{IntHeight}m")
#単位をセンチメートルからからメートルに変換
CmToMeter(CM:float)<decides><transacts>:float =
return CM / 100.0
GetPlayerHeight(Agent:agent):int=
if:
FortniteCharacter := Agent.GetFortCharacter[]
Height := CmToMeter[FortniteCharacter.GetTransform().Translation.Z - FloorHeight]
IntHeight := Floor[Height] #小数点以下を切り捨て
then:
return IntHeight
return 0
今回重要なのは
- AddUIメソッド (AddWidiget)をどこに書くか
- UpdateTextをどこに書くか
が重要になります。
いつ、何を、誰に情報を表示するのか整理していきます。
あるPlayerがmutator_zoneに入ったら
以下の実装で実現できます。
@editable
EnterTrigger:mutator_zone_device = mutator_zone_device{}
OnBegin<override>()<suspends>:void=
EnterTrigger.AgentEntersEvent.Subscribe(OnStartTriggered)
# mutator_zoneに入ったら実行される
OnStartTriggered(Agent:agent):void=
spawn{StartHeightDisplay(Agent)}
この緑のZoneに入ったら、高さ計測表示が開始されます。
一回だけ、そのPlayerのみ にAddUIが実行される。
そのPlayerがmutator_zoneに入ったかどうかを判定するために、map型{Key:Agent,Value:logic}の変数を定義します。logic型とはtrue,falseを表すBoolean型的なものだと思ってください。
var StartPerPlayer: [agent]logic = map{}
StartHeightDisplay(Agent: agent)<suspends>:void=
# 2回目以降は処理しない
if(StartPerPlayer[Agent]?):
return
# 初めてのゾーン内であれば処理する
if(set StartPerPlayer[Agent] = true):
TextUIDevice.AddUI(Agent) # UIの付与
loop:
Sleep(0.1) # 0.1秒待つ
IntHeight := GetPlayerHeight(Agent)
TextUIDevice.UpdateText(Agent,"{IntHeight}m")
また上記の実装で、「あるPlayerの高さ情報 が、そのPlayerのUIのTextのみ に反映される。」という条件も達成しています。
なぜなら、このStartHeightDisplayメソッドはPlayerごとに呼ばれるメソッドだからです。
また、loop:の中身は0.1秒に1回実装され続ける処理になります。0.1秒ごとに高さを取得し、UIを更新しているのがわかると思います。
おまけ① module化とusing文の解説
text_ui_deviceの実装を呼び出すにあたって、今回text_ui_deviceの置き場が
- IwakenVerseToolkitフォルダ以下
にあり、またmoduleとして
ui_module<public> := module:
として扱っています。
したがって、他のファイルから実装を呼ぶ場合、以下のようなusing文が必要になります。
using { IwakenVerseToolkit.ui_module }
まとめ
本記事ではOnlyUpを作りながらマルチプレイ対応のTextUIのVerse実装を理解することを目的に執筆しました。
自分自身の高さを表示するというシンプルな仕様ですが、マルチプレイ対応をすると考えることが増えます。
今回の記事とGithubサンプルによって、日本のUEFNユーザーの理解の助けになれば嬉しいです。