Verse コードで期待どおりに動作しない場合、何が問題だったのかを把握するのが難しい場合があります。 たとえば、次のような状況に遭遇する可能性があります。
ランタイム エラー。
間違った順序でのコードの実行。
必要以上に長い処理時間。
これらはいずれもコードに作用して、ゲーム体験において想定外の挙動を引き起こしたり問題を発生したりする可能性があります。 コード内の問題を診断する行為は、デバッグと呼ばれ、コードを修正して最適化するために使用できるさまざまなソリューションがあります。
Verse ランタイム エラー
Verse コードは、言語サーバーで記述するときと、エディタまたは Visual Studio Code からコンパイルするときの両方で分析されます。 ただし、このセマンティック分析だけでは、発生する可能性のあるすべての問題を検出することはできません。 ランタイム時にコードを実行すると、ランタイム エラーが発生する可能性があります。 これにより、以降のすべての Verse コードの実行が停止し、プレイできなくなる可能性があります。
たとえば、次のようなことを実行する Verse コードがあるとします。
# Has the suspends specifier, so can be called in a loop expression.
SuspendsFunction()<suspends>:void={}
# Calls SuspendFunction forever without breaking or returning,
# causing a runtime error due to an infinite loop.
CausesInfiniteLoop()<suspends>:void=
loop:
SuspendsFunction()CausesInfiniteLoop() 関数は Verse コンパイラでエラーを発生せず、プログラムは正常にコンパイルされます。 ただし、CausesInfiniteLoop() をランタイム時に呼び出すと、無限ループが実行され、ランタイム エラーが発生します。
ゲーム内で発生したランタイム エラーについて調べるには、コンテンツ サービス ポータルに移動します。 そこで自身の公開済みと未公開の両方のプロジェクトがすべて表示されます。 プロジェクト内で発生したランタイム エラーのカテゴリが一覧表示されている、各プロジェクトの [Verse] タブにアクセスできます。 また、そのエラーが報告された Verse コール スタックで、エラーを引き起こした原因について詳細をさらに確認することもできます。 エラー レポートは最大 30 日間保存されます。
これは開発の初期段階にある新機能であるため、UEFN および Verse の今後のバージョンで動作方法が変更する可能性があることにご注意ください。
遅いコードのプロファイリング
コードの実行が想定よりも遅い場合、profile 式を使ってそれをテストできます。 profile 式により、特定のコードの実行にかかる時間が分かるため、遅延しているコード ブロックを見きわめ、それを最適化できます。 たとえば、配列に特定の数字が含まれるかどうかを調べて、含まれる場所にインデックスを返すとします。 配列に対して反復することで、探している数字と一致するものがあるかどうかを確認できます。
# An array of test numbers.
TestNumbers:[]int = array{1,2,3,4,5}
# Runs when the device is started in a running game
OnBegin<override>()<suspends>:void=
# Find if the number exists in the TestNumbers array by iterating
# through each element and checking if it matches.
for:
Index -> Number:TestNumbers
しかしこのコードは、一致するかどうかを配列の各数字とチェックしなければならないため非効率です。 要素を見つけたとしても、残りのリストのチェックも引き続き行うため、非効率な時間的複雑度が生じます。 代わりに Find[] 関数を使うと、探している数字が配列に含まれているかどうかを調べて、それを返します。 Find[] は、要素を見つけたときに瞬時に返すため、その要素がリストの早い段階で見つかるほど早く実行されます。 profile 式も使って両方でコードをテストしてみれば、Find[] 式を使った方が、コードの実行時間が短いことが分かります。
# Runs when the device is started in a running game
OnBegin<override>()<suspends>:void=
# Find if the number exists in the TestNumbers array by iterating
# through each element and checking if it matches.
profile("Finding a number by checking each array element"):
for:
Index -> Number:TestNumbers
Number = 4
do:
このような実行時間のわずかな差異は、反復する必要のある要素が増えるほど大きくなっていきます。 広範なリストを反復しながら式を実行するたびに、時間的複雑度が増し、配列の要素数が数百や数千まで増えるにつれ、その差は顕著に表れます。 profile 式は、遅延の主な領域を見つけて対処し、より多くのプレイヤーにゲーム体験を拡張するために使用してください。
ロガーとロギング出力
Verse コードで Print() を呼び出してメッセージを出力するとき、デフォルトでは専用の Print ログにメッセージが書き込まれます。 出力されたメッセージはゲーム内の画面、ゲーム内のログ、UEFN の出力ログに表示されます。
Print() 関数を使ってメッセージを出力すると、そのメッセージは、出力ログ、ゲーム内のログ タブ、ゲーム内の画面に書き込まれます。
しかし、メッセージをゲーム内の画面に表示したくないことも多々あります。 メッセージは、イベントの発生や一定時間の経過といったことを追跡したり、あるいはコードに問題が発生したときにシグナルを受け取ったりするために使うと便利です。 しかしゲームプレイ中に複数のメッセージが表示されていると注意力が妨げられます。それがプレイヤーに関係する情報でなければなおさらです。
それを解決するために、ロガーを使用することができます。 ロガーは、メッセージを画面に表示することなく、直接的に出力ログとログ タブに出力できるようにする特別クラスです。
ロガー
ロガーを構築するには、まずログ チャンネルを作成する必要があります。 どのロガーも出力ログにメッセージを印刷しますが、そのような状態ではメッセージの発生元のロガーを見分けるのが難しくなります。 ログ チャンネルは、メッセージの先頭にログ チャンネル名を付けるため、メッセージを送信したロガーを簡単に確認できるようになります。 ログ チャンネルは、モジュール スコープで宣言され、ロガーはクラス内または関数内で宣言されます。 以下の例では、ログ チャンネルをモジュール スコープで宣言し、Verse の仕掛け内でロガーを宣言して呼び出しています。
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/Diagnostics }
# A log channel for the debugging_tester class.
# Log channels declared at module scope can be used by any class.
debugging_tester_log := class(log_channel){}
# A Verse-authored creative device that can be placed in a level
debugging_tester := class(creative_device):
ロガーの Print() 関数を使ってメッセージを出力すると、そのメッセージは出力ログとゲーム内のログ タブに書き込まれます。
ログ レベル
チャンネルだけでなく、ロガーの出力先のログ レベルもデフォルトで指定することができます。 5 つのレベルがあり、それぞれに独自のプロパティがあります。
| ログレベル | 出力先 | 特別なプロパティ |
|---|---|---|
Debug | ゲーム内ログ | 該当なし |
詳細 | ゲーム内ログ | 該当なし |
法線 | ゲーム内ログ、出力ログ | 該当なし |
警告 | ゲーム内ログ、出力ログ | テキストの色は黄色です |
エラー | ゲーム内ログ、出力ログ | テキストの色は赤です |
ロガーを作成するときは、デフォルトで Normal ログ レベルになっています。 ロガーを作成するときにロガー レベルを変更するか、出力のために Print() を呼び出すときのログ レベルを指定しておくこともできます。
# A logger local to the debugging_tester class. By default, this prints
# to log_level.Normal.
Logger:log = log{Channel := debugging_tester_log}
# A logger with log_level.Debug as the default log channel.
DebugLogger:log = log{Channel := debugging_tester_log, DefaultLevel := log_level.Debug}
# Runs when the device is started in a running game
OnBegin<override>()<suspends>:void=
上記の例では、Logger のデフォルトは Normal ログ チャンネル、DebugLogger のデフォルトは Debug ログ チャンネルになっています。 どのロガーでも、Print() を呼び出すときに log_level を指定することにより、任意のログ レベルで出力できます。
1 つのロガーを使って異なる複数のログ レベルに出力した結果。 log_level.Debug と log_level.Verbose はゲーム内ログには出力せず、UEFN 出力ログにのみ出力することに注意してください。
コール スタックの印刷
コール スタックは、現在のスコープに至るまでの関数呼び出しのリストをトラッキングします。 これは、現在のルーチンの実行が終了したらどこに戻るべきかをコードが認識するために使用する、積み重ねられた命令セットのようなものです。 PrintCallStack() 関数を使うことで、あらゆるロガーのコール スタックを出力できます。 例として以下のコードを取りあげます。
# A logger local to the debugging_tester class. By default, this prints
# to log_level.Normal.
Logger:log = log{Channel := debugging_tester_log}
# Runs when the device is started in a running game
OnBegin<override>()<suspends>:void=
# Move into the first function, and print the call stack after a few levels.
LevelOne()
上位の OnBegin() のコードが LevelOne() を呼び出して第 1 の関数に移ります。 次に LevelOne() が LevelTwo() を呼び出します。これが LevelThree() を呼び出し、LevelThree() が現在のコール スタックを出力する Logger.PrintCallStack() を呼び出します。 もっとも最近の呼び出しがスタックの最上位に来るため、LevelThree() が最初に出力されます。 このとおりの順序で、LevelTwo()、LevelOne()、OnBegin() と続きます。
コール スタックの出力は、コードで何か問題が発生したときに、どの呼び出しが原因でそこに至ったのかを正確に知るのに便利です。 これにより、コードが密集したプロジェクト内で個々のスタック トレースが分離され、実行中のコードの構造を把握するのが容易になります。
デバッグ描画によるゲーム データの視覚化
さまざまなゲーム機能をデバッグする別の方法は、デバッグ描画 API を使用することです。 この API により、デバッグ形状を構築してゲーム データを視覚化できるようになります。 以下はその一例です。
警備員から目標までの照準線。
小道具移動装置がオブジェクトを動かす距離。
オーディオ プレーヤーの減衰距離。
このようなデバッグ形状を、公開済みゲームにおいて対象データを公開せずに使用して、ゲーム体験の微調整を行うことができます。 詳細については、Verse のデバッグ描画をご確認ください。
並列処理による最適化とタイミング
並列処理は Verse プログラミング言語の中核であり、ゲーム体験を拡充してくれる強力なツールです。 並列処理により、1 つの Verse の仕掛けで複数の処理を同時実行することができます。 これにより、より融通の利くコンパクトなコードを記述でき、レベル内で使用する仕掛けの数も抑えられます。 並列処理は最適化のための優れたツールであるだけでなく、非同期コードを使用して複数タスクを同時処理できることは、プログラムの実行をスピードアップしてタイミングに関する問題に対処する上でも、素晴らしい方法です。
Spawn による非同期コンテキストの作成
spawn 式は、任意のコンテキストから非同期式を開始し、後続の式をすぐに実行できるようにします。 これにより、それぞれに新しい Verse ファイルを作成する必要なく、同じ仕掛けから複数のタスクを同時に実行できるようになります。 たとえば、各プレイヤーの体力を毎秒監視するコードがあるシナリオを考えてみましょう。 プレイヤーの体力が一定の数値を下回っていて、それを回復させたいとき、回復は少量で実行したいと考えます。 その後にいくつかコードを実行して別のタスクも処理するとします。 その場合、コードを実装する仕掛けは以下のようになります。
# A Verse-authored creative device that can be placed in a level
healing_device := class(creative_device):
# Runs when the device is started in a running game
OnBegin<override>()<suspends>:void=
AllPlayers:[]agent = GetPlayspace().GetPlayers()
# Every second, check each player. If the player has less than half health,
# heal them by a small amount.
ところが、このループは永遠に実行され、決して中断しないため、それに続くコードはまったく実行されません。 この仕掛けはループ式を実行する以外、進まなくなるため、この設計では限界があります。 仕掛けが複数タスクを同時処理できるようにコードを並列に実行するには、loop コードを非同期関数に入り込ませて OnBegin() 中にそれをスポーンします。
# Runs when the device is started in a running game
OnBegin<override>()<suspends>:void=
# Use the spawn expression to run HealMonitor() asynchronously.
spawn{HealMonitor()}
# The code after this executes immediately.
Print("This code keeps going while the spawned expression executes")
HealMonitor(Players:[]agent)<suspends>:void=
HealMonitor() 関数を実行しながら他のコードを実行できるようになったことは、進歩だと言えます。 しかし、この関数では、まだ各プレイヤーをループする必要があるため、体験内のプレイヤー数が増えるほどタイミングの問題が発生することが想定されます。 たとえば、各プレイヤーの HP に基づいてスコアを付与したり、アイテムを持っているかどうかを確認したりする場合はどうすればよいでしょうか。 for 式にプレイヤーごとのロジックをさらに追加すると、この関数の時間的複雑さが増し、多数のプレイヤーがいる場合、タイミングの問題により、ダメージを受けたプレイヤーの 1 人が時間内に回復しない可能性があります。
各プレイヤーをループして個別にチェックする代わりに、プレイヤーごとに関数のインスタンスをスポーンすることで、このコードをさらに最適化できます。 つまり、1 つの関数で 1 人のプレイヤーを監視できるため、コードで、回復が必要なプレイヤーにループバックする前に、すべてのプレイヤーを個別にチェックする必要がなくなります。 spawn のような並列処理式をうまく使用すると、コードの効率と柔軟性が向上し、コードベースの残りの部分を解放して他のタスクを処理できるようになります。
# Runs when the device is started in a running game
OnBegin<override>()<suspends>:void=
AllPlayers := GetPlayspace().GetPlayers()
# Spawn an instance of the HealMonitor() function for each player.
for:
Player:AllPlayers
do:
# Use the spawn expression to run HealMonitorPerPlayer() asynchronously.
loop 式内で spawn 式を使用すると、正しく扱わない場合に予期しない動作になることがあります。 たとえば、HealMonitorPerPlayer() は終了しないため、このコードはランタイム エラーが発生するまで、非同期関数を際限なくスポーンし続けます。
# Spawn an instance of the HealMonitor() function for each player, looping forever.
# This will cause a runtime error as the number of asynchronous functions infinitely increases.
loop:
for:
Player:AllPlayers
do:
spawn{HealMonitorPerPlayer(Player)}
Sleep(0.0)タイミングをイベントで制御する
コードのすべての部分を正しく同期させることは、困難になることがあります。特に多くのスクリプトが同時に実行される大規模なマルチプレイヤー エクスペリエンスでは顕著になります。 コードの異なる部分が、設定された順序で実行される他の関数またはスクリプトに依存している場合があり、厳密に制御しないと、それらの間にタイミングの問題が発生する可能性があります。 たとえば、一定時間をカウントダウンし、渡されたプレイヤーの HP がしきい値より大きい場合にスコアを付与する次の関数を考えてみましょう。
CountdownScore(Player:agent)<suspends>:void=
# Wait for some amount of time, then award each player whose HP is above the threshold some score.
Sleep(CountdownTime)
if:
Character := Player.GetFortCharacter[]
PlayerHP := Character.GetHealth()
PlayerHP >= HPThreshold
then:
ScoreManager.Activate(Player)この関数には <suspends> モディファイアがあるため、spawn() を使用してプレイヤーごとに非同期でそのインスタンスを実行できます。 ただし、この関数に依存する他のコードは、関数の完了後に必ず実行されるようにする必要があります。 CountdownScore() の終了後にスコアを獲得した各プレイヤーを出力する場合はどうすればよいでしょうか。 OnBegin() で Sleep() を呼び出して CountdownScore() の実行にかかる時間と同じ時間待機することでこれを行うことができますが、ゲームの実行中にタイミングの問題が発生し、コードを変更する場合に常に更新する必要がある新しい変数を導入しなければならなくなります。 代わりに、カスタム イベントを作成し、それらに対して Await() を呼び出して、コード内のイベントの順序を厳密に制御できます。
# Custom event to signal when the countdown finishes.
CountdownCompleteEvent:event() = event(){}
# Runs when the device is started in a running game
OnBegin<override>()<suspends>:void=
AllPlayers := GetPlayspace().GetPlayers()
# Spawn a CountdownScore function for each player
for:
このコードは CountdownCompletedEvent() がシグナルされるのを待機するため、CountdownScore() の実行が完了した後にのみ各プレイヤーのスコアをチェックすることが保証されます。 多くの仕掛けには、コードのタイミングを制御するために Await() を呼び出すことができる組み込みイベントがあり、独自のカスタム イベントでこれらを活用することで、複数の変動部分がある複雑なゲーム ループを作成できます。 たとえば、Verse スターター テンプレートでは、複数のカスタム イベントを使用して、キャラクターの動きを制御し、UI を更新して、ボードからボードへの全体的なゲーム ループを管理します。
Sync、Race、Rush により複数の式を扱う
sync、race、rush を使用すると、複数の非同期式を同時に実行しながら、それらの式の実行が終了したときに異なる機能を実行できます。 これらを活用することで、非同期式それぞれの存続期間を厳密に制御でき、複数の異なる状況を処理できるさらに動的なコードを作成できます。
たとえば、rush 式を取りあげます。 この式は複数の非同期式を並列で実行しますが、最初に終了した式の値のみを返します。 チームが何らかのタスクを完了しなければならないミニゲームがあり、最初にタスクを完了したチームが、終了するまで他のプレイヤーを妨害できるパワーアップを獲得するとします。 各チームがタスクを完了した時点を追跡するための複雑なタイミング ロジックを記述することも、rush 式を使用することもできます。 この式は終了する最初の非同期式の値を返すため、勝利したチームを返し、他のチームを処理するコードは実行を継続できます。
WinningTeam := rush:
# All three async functions start at the same time.
RushToFinish(TeamOne)
RushToFinish(TeamTwo)
RushToFinish(TeamThree)
# The next expression is called immediately when any of the async functions complete.
GrantPowerup(WinnerTeam)race 式は同様の動作ですが、異なるのは、1 つの非同期式が完了すると他の式がキャンセルされるところです。 これにより、複数の非同期式の存続期間を一度に厳密に制御できるようになります。これを sleep() 式と組み合わせて、式を実行する時間を制限することもできます。 先ほどの rush の例と同様ですが、今回は 1 チームが勝利したらすぐにミニゲームを終了することを考えます。 ミニゲームが永遠に続かないようにタイマーを追加することも必要です。 race 式を使用すると、これら両方を実行できます。競争に敗れた式をキャンセルするタイミングを知るためにイベントやその他の並行処理ツールを使用する必要はありません。
WinningTeam := race:
# All four async functions start at the same time.
RaceToFinish(TeamOne)
RaceToFinish(TeamTwo)
RaceToFinish(TeamThree)
Sleep(TimeLimit)
# The next expression is called immediately when any of the async functions complete. Any other async functions are canceled.
GrantPowerup(WinnerTeam)最後に、sync 式を使用すると、複数の式の実行が完了するまで待機して、それぞれの式が完了してから続行することを保証できます。 sync 式は各非同期式の結果を含むタプルを返すため、すべての式の実行を完了し、それぞれの式からのデータを個別に評価できます。 ミニゲームの例に戻り、今回はミニゲームでの成績に基づいて各チームにパワーアップを付与する場合を考えます。 まさにこのような場合に sync 式を使用します。
TeamResults := sync:
# All three async functions start at the same time.
WaitForFinish(TeamOne)
WaitForFinish(TeamTwo)
WaitForFinish(TeamThree)
# The next expression is called only when all of the async expressions complete.
GrantPowerups(TeamResults)複数の配列要素に対して非同期式を実行する場合は、便利な ArraySync() 関数を使用すれば、すべてが同期されることを保証できます。
これらの並列処理式はそれ自体が強力なツールであり、それらを組み合わせて使用する方法を学習することで、あらゆる状況に対応するコードを記述できるようになります。 「Verse Persistence を使用したスピードウェイ レース」テンプレートのサンプルを考えてみましょう。この例では、複数の並列処理式を組み合わせて、レース前に各プレイヤーのイントロを再生するだけでなく、イントロ中にプレイヤーが退出した場合にそれをキャンセルします。 この例では並列処理をさまざまな形で使用し、異なるイベントに動的に反応する、弾力的なコードを構築する方法について説明します。
# Wait for the player's intro start and display their info.
# Cancel the wait if they leave.
WaitForPlayerIntro(Player:agent, StartOrder:int)<suspends>:void=
var IntroCounter:int = 0
race:
# Waiting for this player to finish the race and then record the finish.
loop:
sync: