using { /Verse.org/SceneGraph }
using { /Verse.org/SceneGraph/KeyframedMovement }
using { /Verse.org/Simulation }
using { /Verse.org/SpatialMath }

door_state := enum{Open, Closed}

door_component := class<final_super>(component):

    @editable
    OpenDegrees:float = 90.0

    @editable
    OpenDuration:float = 1.0

    @editable
    Easing:easing_function = linear_easing_function{}

    var MaybeInteractableComponent:?interactable_component = false
    var MaybeKeyframedComponent:?keyframed_movement_component = false
    var CurrentDoorState:door_state = door_state.Closed

    OnAddedToScene<override>():void=
        if(InteractableComponent := Entity.GetComponent[interactable_component]):
            set MaybeInteractableComponent = option{InteractableComponent}
        else:
            NewInteractableComponent := interactable_component{Entity := Entity}
            Entity.AddComponents(array{NewInteractableComponent})
            set MaybeInteractableComponent = option{NewInteractableComponent}

        if(KeyframedComponent := Entity.GetComponent[keyframed_movement_component]):
            set MaybeKeyframedComponent = option{KeyframedComponent}
        else:
            NewKeyframedComponent := keyframed_movement_component{Entity := Entity}
            Entity.AddComponents(array{NewKeyframedComponent})
            set MaybeKeyframedComponent = option{NewKeyframedComponent}

    OnSimulate<override>()<suspends>:void=
        if:
            InteractableComponent := MaybeInteractableComponent?
            KeyframedComponent := MaybeKeyframedComponent?
        then:
            loop:
                Sleep(0.0)
                InteractableComponent.SucceededEvent.Await()
                NewRotationDelta := 
                    case(CurrentDoorState):
                        door_state.Open=>
                            set CurrentDoorState = door_state.Closed
                            MakeRotationFromEulerDegrees(0.0, -OpenDegrees, 0.0)
                        door_state.Closed=>
                            set CurrentDoorState = door_state.Open
                            MakeRotationFromEulerDegrees(0.0, OpenDegrees, 0.0)

                Keys := array:
                    keyframed_movement_delta:
                        Transform := transform:
                            Rotation := NewRotationDelta
                            Translation := vector3{}
                            Scale := vector3{}
                        Duration := OpenDuration
                        Easing := Easing

                InteractableComponent.Disable()

                KeyframedComponent.SetKeyframes(Keys, oneshot_keyframed_movement_playback_mode{})
                KeyframedComponent.Play()
                KeyframedComponent.FinishedEvent.Await()

                InteractableComponent.Enable()
                

まずは完成形…長いですね

実際にゲーム内で確認してみるとこんな感じです。

1. enumとclassの定義

door_state := enum{Open, Closed}

door_component := class<final_super>(component):

上から順番に見ていきましょう。まずは列挙型(enum)とクラスの定義についてです。

シーングラフでコンポーネントを作成する場合、クラスに対して「final_super」指定子をつけてあげる必要があります。おそらく、むやみやたらに親子関係を作らせると大変なことになる…ということで課された制限でしょう。

列挙型はシンプルにOpenとClosedという二つの状態を持たせるようにします。

2. editable周り

    @editable
    OpenDegrees:float = 90.0

    @editable
    OpenDuration:float = 1.0

    @editable
    Easing:easing_function = linear_easing_function{}

editable周りも非常にシンプルで、開いた際にどれだけの角度で開くかの「DoorDegrees」変数と、開くまでにかかる「OpenDuration」変数、あとは開く際のイースイン・アウトをどうするかの「Easing」変数を用意しました。

この辺は後ほど説明するkeyframed_movement_deltaに対して用いられます。

editableの仕様として、親であるeasing_functionを型として渡してあげると、シーングラフの詳細パネルから子をすべて選べることができます。そのため、コード上では「linear_easing_function」のインスタンスを持っていますが、レベル上に配置して調整すると「ease_in_cubic_bezier_easing_function」などに切り替える状態を作れるのです。

3. メンバ変数

    var MaybeInteractableComponent:?interactable_component = false
    var MaybeKeyframedComponent:?keyframed_movement_component = false
    var CurrentDoorState:door_state = door_state.Closed

door_componentはこれらの変数を保持しています。

MaybeInteractableComponentとMaybeKeyframedComponentはほぼほぼ形式みたいなもので、関数間で特定のコンポーネントを共有するために使います。関数Aで「GetComponent」を呼び出して…持っていない場合は○○という処理を走らせて…またそれを関数Bでも同じことをして…をするよりは、初期化のタイミングで全て終わらせ、結果を収納する変数を作る方が楽という感じです。

ちなみに、オプション型ではあるものの、理論上は常に何らかのコンポーネントは入るはずです。Verseは制限が厳しい言語でもありますから、「もしかしたら?」が少しでも発生しないようにオプション型で保護されているのです。

CurrentDoorStateは見ての通り、door_stateを入れる変数となります。

ちなみに、命名規則として「オプション型はMaybe○○」現在の状態を示すものは「Current○○」とかと自分の中で決めておくと、何かと管理がしやすくなるはずです。

4. OnAddedtoScene

    OnAddedToScene<override>():void=
        if(InteractableComponent := Entity.GetComponent[interactable_component]):
            set MaybeInteractableComponent = option{InteractableComponent}
        else:
            NewInteractableComponent := interactable_component{Entity := Entity}
            Entity.AddComponents(array{NewInteractableComponent})
            set MaybeInteractableComponent = option{NewInteractableComponent}

        if(KeyframedComponent := Entity.GetComponent[keyframed_movement_component]):
            set MaybeKeyframedComponent = option{KeyframedComponent}
        else:
            NewKeyframedComponent := keyframed_movement_component{Entity := Entity}
            Entity.AddComponents(array{NewKeyframedComponent})
            set MaybeKeyframedComponent = option{NewKeyframedComponent}

ここから本チャンの実装に入っていきます。OnAddedtoSceneはcomponentが足された瞬間 = entityに対しての初期化ということで、初期化の処理を書くのに向いています。

まず、この部分の実装では「自身のエンティティが○○というコンポーネントをすでに持っている場合は変数に収納する」、「持っていない場合は新しくコンポーネントを作成して渡し、それを変数に収納する」という作業をしています。

デザイナーを信じてこのような処理を書かず、「GetComponent」だけで十分だ!という場合もアリだとは思いますが、これだけ保険をかけておくと何かと保守性もあがるのでオススメです。

GetComponent

    entity<native><public> := class<concrete><unique><transacts><castable>:
        # Succeeds and returns the child component of type `component_type` if it exists and is accessible from the calling context.
        #   Note: When called during the AddedToScene or BeginSimulation phase, it will make sure the returned component has achieved the corresponding phase.
        #   Fails if no component of `component_type` exists or can be accessed.
        GetComponent<native><final><public>(component_type:castable_subtype(component))<reads><decides>:component_type

ちなみに、GetComponentはentityが持つ失敗関数です。引数に対して「componentの子であるクラス」を渡すことで、それと一致するコンポーネントを持っている場合に戻り値が帰ってきます。もし持っていなければ、失敗してelse側に処理がわたります。

GetComponentに対して渡すのは「型」で十分なため「Entity.GetComponent[interactable_component{}]」のように「{}波カッコ」でインスタンス化して渡す必要はありません。

「GetComponents」のようにEntity自身が持っているすべてのコンポーネントを返す関数もあったりしますが、今回はinteractable_componentだけが欲しいのでGetComponentを使用しました。

AddComponents

    entity<native><public> := class<concrete><unique><transacts><castable>:
        # Adds the provided components to the entity.
        #   * If a component is not allowed to be added to this entity it is skipped.
        #   Note: When called during the AddedToScene or BeginSimulation phase, it will make sure the added component has achieved the corresponding phase.
        #   * Components are added following these rules:
        #       1. All components are added to the entity child list.
        #       2. All components have `OnAddedToScene` called (if this entity is in the scene).
        #       3. All components have `OnBeginSimulation` called (if this entity is simulating).
        AddComponents<native><final><public>(Components:[]component):void

entityの持つ別の関数として「AddComponents」というものも存在します。

コンポーネントをインスタンス化してそれを配列にして渡すことで、Entityに対してゲーム内で動的にcomponentを足せます。

そのため、entity内でdoor_componentだけを追加して「interactable_component」や「keyframed_movement_component」をつけ忘れていたとしても、コード上で自動で足されるため動作しないが怒らなくなるわけです。

5. OnSimulate

    OnSimulate<override>()<suspends>:void=
        if:
            InteractableComponent := MaybeInteractableComponent?
            KeyframedComponent := MaybeKeyframedComponent?
        then:
            loop:
                Sleep(0.0)
                InteractableComponent.SucceededEvent.Await()
                NewRotationDelta := 
                    case(CurrentDoorState):
                        door_state.Open=>
                            set CurrentDoorState = door_state.Closed
                            MakeRotationFromEulerDegrees(0.0, -OpenDegrees, 0.0)
                        door_state.Closed=>
                            set CurrentDoorState = door_state.Open
                            MakeRotationFromEulerDegrees(0.0, OpenDegrees, 0.0)

                Keys := array:
                    keyframed_movement_delta:
                        Transform := transform:
                            Rotation := NewRotationDelta
                            Translation := vector3{}
                            Scale := vector3{}
                        Duration := OpenDuration
                        Easing := Easing

                InteractableComponent.Disable()

                KeyframedComponent.SetKeyframes(Keys, oneshot_keyframed_movement_playback_mode{})
                KeyframedComponent.Play()
                KeyframedComponent.FinishedEvent.Await()

                InteractableComponent.Enable()

OnSimulateはゲーム内で常に動作する処理を各場所です(creative_deviceのOnBeginと同じ感覚で大丈夫です)

        if:
            InteractableComponent := MaybeInteractableComponent?
            KeyframedComponent := MaybeKeyframedComponent?
        then:

では、OnAddedToSceneで渡されたcomponentをオプション型から実際に使える状態に戻します。先ほども述べたように、これらのオプション型はfalseの状態でOnSimulateが実行されることはないと思います。ただ、形式として必要なんです…!

                InteractableComponent.SucceededEvent.Await()
                NewRotationDelta := 
                    case(CurrentDoorState):
                        door_state.Open=>
                            set CurrentDoorState = door_state.Closed
                            MakeRotationFromEulerDegrees(0.0, -OpenDegrees, 0.0)
                        door_state.Closed=>
                            set CurrentDoorState = door_state.Open
                            MakeRotationFromEulerDegrees(0.0, OpenDegrees, 0.0)

この辺は少しVerse言語の良さが出てる箇所かと思います。interactable_componentはインタラクトが正常に終えた際に発火されるSucceededEventが用意されているので、Awaitを用いてループで待機するようにします。もしインタラクトされたら、SucceededEventのAwaitが完了して次の処理に移ります。そして、下までの処理が正常に終わりloopの末まで来たら、loopの先頭に戻り再度インタラクトが完了するまでAwaitします。

Verse言語のメリットして全体の流れをつかみやすいような非同期の処理をかけます。Subscribeで処理を飛ばすこともできますが、Awaitの方が全体の流れが一目でわかるのでオススメです。

caseは式であるため、パターンに対しての結果を最後に書いてあげると、変数の中にはそのパターンに応じた結果が入ります。今回はrotation型の変数となっており、もしCurrentDoorStateがOpenの場合は「MakeRotationFromEulerDegrees(0.0, -OpenDegrees, 0.0)」の戻り値であるUpを回転させたrotationが入ります。

caseの結果はそのインデントの最後を基準にするため、その間にどのような処理を書いても問題はありません。今回はcaseを二か所で書くのは少しバカらしかったので、「door_stateを反転」と「door_stateに応じたrotation」をこのような処理で書きました。

                Keys := array:
                    keyframed_movement_delta:
                        Transform := transform:
                            Rotation := NewRotationDelta
                            Translation := vector3{}
                            Scale := vector3{}
                        Duration := OpenDuration
                        Easing := Easing

                InteractableComponent.Disable()

                KeyframedComponent.SetKeyframes(Keys, oneshot_keyframed_movement_playback_mode{})
                KeyframedComponent.Play()
                KeyframedComponent.FinishedEvent.Await()

                InteractableComponent.Enable()

最後にざっくりと説明です。

シーングラフで移動のアニメーションを作る場合、keyframed_movement_componentという便利な機能が用意されています。

keyframed_movement_deltaという移動の情報を持たせたクラスの配列をkeyframed_movement_componentのSetKeyFramesに渡し、Playを呼び出すことでその情報通りにtransform_componentが移動します。Deltaということで、現在の座標/回転/スケールに足す形で移動が決定します。vector3{X := 1.0}にDelta: vector3{X := 2.0}を渡してあげると、1.0 + 2.0で最終的な位置はvector3{X := 3.0}になる感じです。

keyframed_movement_deltaにはeditableで指定した情報を渡してあげているため、レベル上の調整でイースイン・アウトの結果や開ききるまでにかかる時間などが変化します。今回はシンプルな1パターンの移動でしたが、keyを増やしたバージョンも暇なときに試してみると面白いですよ。

あとは、再生後に再生が完了したことを検知する「FinishedEvent」を用いて待機し、その前後でEnable/Disableでインタラクトの表示を消してあげることで、door_componentのざっくりとした実装は完了となります。