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

こんにちは、UEFN好きエンジニアの[イワケン](https://twitter.com/iwaken71)です。
UEFNの日本語記事増やしたい委員会を一人でやっています。

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

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

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

![onlyup.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/96970/185643fa-d76c-d625-a326-cbfd854ce62e.gif)

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

仕様としては

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

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

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

というマルチプレイ対応の実装になります。
もし理解していないと、[ごろ助さんのツイート](https://twitter.com/GorosukeUEFN/status/1660129740949458945)にもあるように

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

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

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

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

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

https://github.com/iwaken71/IwakenVerseToolkit/releases/tag/OnlyUpSampleV1.1

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/96970/e11c457f-b754-1107-ed49-dcd161a2c352.png)

導入手順は

- [こちら](https://github.com/iwaken71/IwakenVerseToolkit/releases/tag/OnlyUpSampleV1.1)からIwakenVerseToolKit.zipをダウンロードし、展開する
- IwakenVerseToolKitフォルダを `~/Fortnite Projects/{Project Name}/Plugins/{Project Name}/Content`以下に移動する
- UEFNのMenuバーから[Verse]>[Build Verse Code]を選択
- UEFNのContent/CreativeDevices以下にファイルが存在することを確認

と進めていただけると嬉しいです。詳しくは[GithubレポジトリのReadme](https://github.com/iwaken71/IwakenVerseToolkit)を参考にしてください。

こんな感じでContent以下にIwakenVerseToolkitのフォルダを置ければ成功です。(名前を間違えるとエラーが出るので注意)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/96970/251cb028-7f28-0082-a25f-6082a9fba5d2.png)

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

# VerseのUI実装の全体像

前回の記事「[[UEFN][Verse] UIにImage/Textureを貼る実装。これからVerseやっていきな人へ](https://qiita.com/iwaken71/items/c118737da5cb0ff38f54)」にも紹介しましたが、再度共有します。

Verseの生のコード「`UnrealEngine.digest.verse`」からclass関係をPlantUMLにすると次のようになります。


```plantuml
@startuml

player_ui -> widget
widget <|-- canvas
widget <|-- button
widget <|-- color_block
widget <|-- texture_block
widget <|-- overlay
widget <|-- stack_box
widget <|-- text_base
canvas ..> canvas_slot
canvas_slot ..> widget

class player_ui{
  # AddWidget(widget)
  # RemoveWidget(widget)
}

abstract class widget {
  # SetVisibility(widget_visibility)
  # GetVisibility():widget_visibility
  # SetEnabled(logic)
  # IsEnabled():logic
  # GetParentWidget():widget
  # GetRootWidget():widget
}

class canvas {
  # Slots:[]canvas_slot 
  --
  # AddWidget(canvas_slot)
  # RemoveWidget(widget)
}

class canvas_slot{
  # Anchors:anchors
  # Offsets:margin
  # SizeToContent:logic
  # Alignment:vector2
  # ZOrder:type
  # Widget:widget

}

class button {
  # Slot:button_slot
  --
  # SetWidget(button_slot)
  # OnClick():listenable(widget_message)
}

class color_block {
  # DefaultColor:color
  # DefaultOpacity:float
  # DefaultDesiredSize:vector2
  --
  # SetColor(color)
  # GetColor():color
  # SetOpacity(float)
  # GetOpacity():float
  # SetDesiredSize(vector2)
  # GetDesiredSize():vector2
}

class texture_block {
  # DefaultImage:texture
  # DefaultTint:color
  # DefaultDesiredSize:vector2
  --
  # SetImage(texture)
  # GetImage():texture
  # SetTint(color)
  # GetTint():color
  # SetDesiredSize(vector2)
  # GetDesiredSize():vector2
  # SetTiling(image_tiling, image_tiling)
  # GetTiling():tuple(image_tiling, image_tiling)
}

class overlay {
  # Slots:[]overlay_slot
  --
  # AddWidget(overlay_slot)
  # RemoveWidget(widget)
}

class stack_box {
  # Slots:[]stack_box_slot
  # Orientation:orientation
  --
  # AddWidget(stack_box_slot)
  # RemoveWidget(widget)
}

abstract class text_base {
  # DefaultText:message
  # DefaultTextColor:color
  # DefaultTextOpacity:float
  --
  # SetText(message)
  # GetText():[]char
  # SetJustification(text_justification)
  # GetJustification():text_justification
  # SetOverflowPolicy(text_overflow_policy)
  # GetOverflowPolicy():text_overflow_policy
  # SetTextColor(color)
  # GetTextColor():color
  # SetTextOpacity(float)
  # GetTextOpacity():float
}

widget <|-- text_button_base
text_button_base <|-- button_loud
text_button_base <|-- button_regular
text_button_base <|-- button_quiet
widget <|-- slider_regular
text_base <|-- text_block
abstract class text_button_base {
    DefaultText: message
    --
    +OnClick(): listenable(widget_message)
    +SetText(InText: message): void
    +GetText(): []char
}


class button_loud {
}

class button_regular {
}

class button_quiet{
  
}


class slider_regular {
  DefaultValue: float
  DefaultMinValue: float
  DefaultMaxValue: float
  DefaultStepSize: float
  --
  +SetValue(InValue: float): void
  +GetValue(): float
  +SetMinValue(InMinValue: float): void
  +GetMinValue(): float
  +SetMaxValue(InMaxValue: float): void
  +GetMaxValue(): float
  +SetStepSize(InValue: float): void
  +GetStepSize(): float
  +OnValueChanged(): listenable(widget_message)
}

class text_block {
    DefaultShadowOffset: ?vector2
    DefaultShadowColor: color
    --
    +SetShadowOffset(InShadowOffset: ?vector2): void
    +GetShadowOffset(): ?vector2
    +SetShadowColor(InColor: color): void
    +GetShadowColor(): color
    +SetShadowOpacity(InOpacity: type): void
    +GetShadowOpacity(): type
}

@enduml
```

ここからわかる構造の理解として

- 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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/96970/18f1a251-2158-c16f-c12b-2b721b7bed82.jpeg)

# UIの実装を見る

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

```python: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))
```

ここで重要なのは

```python: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を更新するメソッド
を準備します。

```python: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に対してなのか

を区別してメソッドを作ります。

```python: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`クラスに中身を切り分けて書いています。

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

```python: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のみ** に反映される。

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

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

```python: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に入ったら

以下の実装で実現できます。


```python
@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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/96970/ff288679-b87d-352e-69aa-7c57e7f5fb70.png)

この緑のZoneに入ったら、高さ計測表示が開始されます。

## **一回だけ**、**そのPlayerのみ** にAddUIが実行される。

そのPlayerがmutator_zoneに入ったかどうかを判定するために、map型{Key:Agent,Value:logic}の変数を定義します。logic型とはtrue,falseを表すBoolean型的なものだと思ってください。


```python
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文が必要になります。

```python
using { IwakenVerseToolkit.ui_module }
```

# まとめ

本記事ではOnlyUpを作りながらマルチプレイ対応のTextUIのVerse実装を理解することを目的に執筆しました。

自分自身の高さを表示するというシンプルな仕様ですが、マルチプレイ対応をすると考えることが増えます。

今回の記事とGithubサンプルによって、日本のUEFNユーザーの理解の助けになれば嬉しいです。