LoginSignup
0
0

[UEFN][Verse]OnlyUpを作りながらマルチプレイ対応のTextUIのVerse実装を理解する

Last updated at Posted at 2023-10-19

こんにちは、UEFN好きエンジニアのイワケンです。
UEFNの日本語記事増やしたい委員会を一人でやっています。

今日は、OnlyUpの仕組みを作りながら、TextUI (text_block)のVerse実装を理解するシリーズです。

最終アウトプットはこちら

画面の下に 「○○m」 と、現在登っている高さを表示する実装を目指しましょう。

onlyup.gif

OnlyUpのTextUIを実装する上での課題

仕様としては

  • あるZoneに入ると、Text表示が始まる
  • 各プレイヤーの現在の高さを各プレイヤー毎にTextに表示する

になります。ここでの難しさは

  • 各プレイヤーに対して、違う値を各々のUIにそれぞれ表示する

というマルチプレイ対応の実装になります。
もし理解していないと、ごろ助さんのツイートにもあるように

  • すべてのプレイヤーが同じ数値が反映されてしまう...
  • 1人のプレイヤーのUIしか反映させられない...

などといった不具合が発生する場合があります。

今回の記事で、UIの仕組みを把握しつつ、コードを書きながら理解していきましょう。

サンプルプロジェクトの導入方法

以下にサンプルプロジェクトのVerse部分を公開しました。

image.png

導入手順は

  • こちらからIwakenVerseToolKit.zipをダウンロードし、展開する
  • IwakenVerseToolKitフォルダを ~/Fortnite Projects/{Project Name}/Plugins/{Project Name}/Content以下に移動する
  • UEFNのMenuバーから[Verse]>[Build Verse Code]を選択
  • UEFNのContent/CreativeDevices以下にファイルが存在することを確認

と進めていただけると嬉しいです。詳しくはGithubレポジトリのReadmeを参考にしてください。

こんな感じでContent以下にIwakenVerseToolkitのフォルダを置ければ成功です。(名前を間違えるとエラーが出るので注意)
image.png

また、レベル上の作業 (ステージの配置)などはサンプルプロジェクトには今回含まれておりません。

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が実行する。このイメージを持ちましょう。

3か月前の自分に伝えたいUEFN _ Verseの知見5選_UEFN.Tokyo勉強会 (1).jpg

UIの実装を見る

今回のUI実装はtext_ui_device.verseにまとめています。これはOnlyUpのロジックに関係なく、文字を表示するだけの仕組みを持っています。OnlyUpのロジックはonlyup_manager_device.verseに書いています。

text_ui_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))

ここで重要なのは

text_ui_device.verse
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クラス
  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メソッドとUpdateTextForAllPlayerメソッド
    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クラス

 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型に変換するメソッドも定義しています。

StringToMessageメソッド
    TextToMessage<localizes>(Text:string):message = "{Text}"

これで

  • WidgetをPlayerに付与する実装
  • Canvasを生成する実装
  • Textを更新する実装

が準備できました。

OnlyUpのロジックの実装を見る

今回は

  • mutator_zoneに入ったPlayerの高さ情報をTextに表示する

となっています。より分解すると

  • あるPlayerがmutator_zoneに入ったら一回だけそのPlayerのみ にAddUIが実行される。
  • あるPlayerの高さ情報 が、そのPlayerのUIのTextのみ に反映される。

といったところがポイントになります。

そのうえでまずは全体実装を眺めていきましょう。

onlyup_manager_device.verse
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)}

image.png

この緑の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ユーザーの理解の助けになれば嬉しいです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0