Step2-4:Playthrough Tester を使用した通しプレイの実行
はじめに
この Step では、Playthrough Tester を使用するための事前準備と、Playthrough Tester を使用して通しプレイを実行する手順を確認します。
Playthrough Tester の設計
Playthrough Tester は、記録した人間の操作をお手本にしてゲームの実行を自動で行うテストツールです。
人間のお手本は数分程度の短い単位で記録することが可能で、自動テスト時にはそれらを繋ぎ合わせて再生することでゲーム全体の通しプレイを実現します。
具体的には以下の流れで自動テストを実現します。
- 「記録モード」で人間によるパッド操作、および適切なゲーム内データ (プレイヤー座標、各種フラグなど) を毎フレーム記録し、csv ファイルに保存します。
- 「再生モード」で csv ファイルを記録時の時系列順に 1 行ずつ読み込み、そのデータから記録時の行動に近い操作を生成して実行します。
より詳しい構成や技術仕様については、こちらをご覧ください。
事前準備
ダウンロードした Playthrough Tester には Alfort(サンプルゲーム)に対応した実装コードが含まれています。
そのため、Unreal Engine 5 の Third Person Template Project(以下、プロジェクトと呼称)に適用するために、いくつかのコード変更、設定変更が必要となります。
それぞれの実装意図や各パラメーターの詳細については、こちらをご覧ください。
プロジェクトに利用するための実装コードを準備する
Alfort(サンプルゲーム)に対応した実装コードを複製して、プロジェクトのための実装コードを準備します。
playable-playthrough-tester フォルダ直下にある alfort フォルダをコピーし、フォルダ名をプロジェクト名にリネームします。
この Step では、プロジェクトの名称は「template」とします。
以下、\playable-playthrough-tester\template ディレクトリ以下の実装コードについて変更を加えます。
プロジェクトで実装されていない操作に紐づく処理を削除する
おもにセーブ機能やタイトル画面に関連する実装の削除・変更を行います。
実装例を示しますので、それに従って対応を行ってください。
custom_gui.py
Playthrough Tester の GUI のうち、ゲーム固有の実装部分が記載されています。
13-24 行目 init()
Third Person テンプレートにはセーブ機能が存在しないため、セーブデータのパスやファイル名を定義している 17-24 行目を削除します。
def __init__(self):
super().__init__()
# # ゲームの exe がある場所から見たセーブデータの相対パス
# self.shared_path = "/Alfort/Saved/SaveGames/SharedData.sav"
# # 選択した手本に対応するセーブデータを正規表現検索する際のキーワード
# # 全てのファイル名に共通して現れる、かつ余計なファイルが検索にかからないような文字列を指定
# # ファイル名に規則性がない場合は適切な対応をしてください
# self.search_key_save = "_0000_*"
# self.search_key_shared = "_SharedData.sav" # 一般的なゲームではこちらは不要な可能性が高い
44-104 行目 callback_remove()
Playthrough Tester の GUI で「削除」ボタンが押された時に呼ばれる関数です。
削除ボタンを押すと、選択された手本番号に対応する手本データ (csv) と、この手本の記録開始時、および記録終了時のセーブデータが削除されます。
プロジェクトにはセーブ機能が存在しないので、手本データのみを削除するように変更する必要があります。
以下に実装例を示します。
def callback_remove(self, sender, app_data, user_data: int):
self.add_index = -1
_index = user_data
# 選択した手本を削除
demo = self.demo_path + "/demo_" + "{0:03d}".format(_index - 1) + ".csv"
os.remove(demo)
# 手本の削除後、削除したもの以降に存在する手本の index を1つずつ前にずらす
while True:
demo = self.demo_path + "/demo_" + "{0:03d}".format(_index) + ".csv"
if os.path.isfile(demo):
_demo = self.demo_path + "/demo_" + "{0:03d}".format(_index - 1) + ".csv"
shutil.move(demo, _demo)
self.demolabel["demolabel_{0:03d}".format(_index - 1)] = self.demolabel["demolabel_{0:03d}".format(_index)]
_index += 1
else:
dpg.delete_item("mode_select")
dpg.delete_item("demo_list")
self.build_main_ui()
self.callback_save_demolabel()
break
135-150 行目 callback_make_nextdemo()
Playthrough Tester の GUI で「追加記録」ボタンが押された時に呼ばれる関数です。
次の手本を作成する際には、self.comdk_thread.goto_load() で直前の手本の終了地点に対応するセーブデータをロードします。
プロジェクトにはセーブ機能が存在しないので、代わりに「読み込みたい手本のパス (ここでは直前の手本のパス)」を第 1 引数に与えてください。
また、終了地点から開始することを表すために、第 2 引数には "end" を指定してください。
以下に実装例を示します。
def callback_make_nextdemo(self, sender=None, app_data=None, user_data: int = None):
demo_index = user_data
last_demo_path = self.demo_path + "/demo_" + "{0:03d}".format(demo_index - 1) + ".csv"
self.comdk_thread.goto_load(last_demo_path, "end") # [直前の手本のパス, 終了地点に移動] を引数に与える
self._make_nextdemo()
273-281 行目 load_first_savedata()
再生リストで選択した手本のうち、一番最初のものをロードしてゲームを開始する関数です。
上述の実装で goto_load() の第 1 引数を「読み込みたい手本のパス」と定義したため、これを適切に取得する必要があります。
Playthrough Tester の GUI 上の再生リストで選択した番号に対応する手本データ (demo_{3 桁の数字}.csv) のパスが self.comdk_thread.replay_demolist に格納されているため、
このリストの 1 番目の要素を参照すればよいです。
また、再生を行うためには手本の開始地点から始めないといけないため、第 2 引数には "start" を指定してください。
以下に実装例を示します。
def load_first_savedata(self):
next_demo_path = self.comdk_thread.replay_demolist[0]
self.comdk_thread.goto_load(next_demo_path, "start") # [これから再生する手本のパス, 開始地点に移動] を引数に与える
self.comdk_thread.wait_order() # 必ず最後にこれを呼ぶこと
152-271 行目 callback_end_make_demo()
Playthrough Tester の GUI で「記録終了」ボタンが押された時に呼ばれる関数です。
記録終了時にゲーム側で作成したセーブデータを手本フォルダにコピーします。
プロジェクトにはセーブ機能が存在しないので、関連する処理を全て削除する必要があります。
以下に実装例を示します。
def callback_end_make_demo(self, sender, app_data, user_data: int):
demo_index = user_data
# 一時ファイルのファイル名生成
def _path_maker(_ind):
_demo = self.demo_path + "/_demo_" + "{0:03d}".format(_ind) + ".csv"
return _demo
# 本命ファイルのファイル名生成
def path_maker(_ind):
demo = self.demo_path + "/demo_" + "{0:03d}".format(_ind) + ".csv"
return demo
if not self.comdk_thread.connect:
return
self.open_wait_popup()
dpg.configure_item("end_recording", enabled=False)
dpg.set_value("is_recording", "事後処理中:")
# 手本ファイルの書き出し
new_demo_file = _path_maker(demo_index)
self.comdk_thread.save_demo(new_demo_file)
self.dpg_logger.log_info("記録終了")
for _index in range(999, demo_index - 1, -1):
demo = path_maker(_index)
# 手本を追加したい場所より後ろに記録済みの手本が存在する場合、最後尾の手本から順に index を1ずつ加算する
if os.path.isfile(demo):
_demo_next = path_maker(_index + 1)
source_path = [demo]
dist_path = [_demo_next]
for source, dist in zip(source_path, dist_path):
if os.path.exists(source):
shutil.move(source, dist)
self.demolabel["demolabel_{0:03d}".format(_index + 1)] = self.demolabel["demolabel_{0:03d}".format(_index)]
self.demolabel["demolabel_{0:03d}".format(_index)] = ""
# self.demo_recovery_point["recoverypoint_{0:03d}".format(_index + 1)] = self.demo_recovery_point["recoverypoint_{0:03d}".format(_index)]
# self.demo_recovery_point["recoverypoint_{0:03d}".format(_index)] = False
# 一時ファイルとして退避してあった新たに記録した手本を指定位置に追加する
_demo = _path_maker(_index)
if os.path.isfile(_demo):
shutil.move(_demo, demo)
# これ以降は変えない
(略)
283-291 行目 set_debug_function()
再生開始前に必要なデバッグ機能などの設定を行う関数です。
プロジェクトには該当する機能がないため、self.comdk_thread.set_invincible_mode() 関数の呼び出しを削除してください。
def set_debug_function(self):
self.comdk_thread.wait_order() # 必ず最後にこれを呼ぶこと
293-337 行目 load_checked_demo()
GUI 上でチェックを入れて選択した手本を再生リストに追加する関数です。
プロジェクトにはセーブ機能が存在しないので、関連する処理を全て削除する必要があります。
以下に実装例を示します。
def load_checked_demo(self):
have_check_flag = False
self.comdk_thread.replay_demolist = []
self.comdk_thread.demo_recoverypoint_list = []
self.comdk_thread.current_test_times = 1
for _index in range(999):
if dpg.get_value("check_" + "{0:03d}".format(_index)) is True:
have_check_flag = True
demo_csv = self.demo_path + "/demo_" + "{0:03d}".format(_index) + ".csv"
self.comdk_thread.replay_demolist.append(demo_csv)
# if dpg.get_value("recoverypoint_" + "{0:03d}".format(_index)) is True: # [bool]*手本数のリストを作成 復帰地点に設定した手本に相当するindexにはTrueが入る
# self.comdk_thread.demo_recoverypoint_list.append(True)
# else:
# self.comdk_thread.demo_recoverypoint_list.append(False) # この4行は復帰地点設定のチェックボックス用 (現在オミット中)
# self.comdk_thread.demo_recoverypoint_list[0] = True # 一番最初に再生される手本は必ず復帰地点に指定する (復帰地点が作成されていない場合は一番最初からやり直す必要があるため)
self.comdk_thread.demo_recoverypoint_list = [True] * len(self.comdk_thread.replay_demolist) # Alfortはランダム要素がないので全手本を復帰地点に設定
self.comdk_thread.replay_resultlist = [0] * len(self.comdk_thread.replay_demolist)
# これ以降は変えない
custom_core.py
記録中、および再生中に毎フレーム呼ばれるメインコードが記載されています。
162-169 行目 copy_tmp_savedata()
再生に失敗した時に使われる復帰地点用の一時セーブデータを手本フォルダにコピーする関数です。
プロジェクトにはセーブ機能が存在しないため、中身は空の関数としてください。
def copy_tmp_savedata(self):
pass
171-182 行目 delete_tmp_savedata()
再生失敗した時のリトライに用いられるセーブデータを削除する関数です。 上記と同様に、中身は空の関数としてください。
def delete_tmp_savedata(self):
pass
184-196 行目 load_discrete_savedata()
現在の手本の終了座標と次の手本の開始座標が一致しない時、次の手本のセーブデータをロードしてから開始するための関数です。
前項で行った callback_make_nextdemo() の実装と同様に、 goto_load() への引数を変更します。 以下に実装例を示します。
def load_discrete_savedata(self):
self.goto_load(self.replaying_demo_path, "start") # [これから再生する手本のパス, 開始地点に移動] を引数に与える
198-213 行目 retry_current_demo()
再生失敗判定となったとき、一時セーブデータを使用して現在の手本の再生を最初からやり直すための関数です。
こちらも上記と同様に、goto_load() への引数を変更します。 以下に実装例を示します。
def retry_current_demo(self):
(略)
# これ以前は変えない
self.goto_load(self.replaying_demo_path, "start") # [これから再生する手本のパス, 開始地点に移動] を引数に与える
215-224 行目 load_next_savedata()
セーブデータをロードしてから手本の再生を行うための関数です。 プロジェクトではセーブ機能が存在しないため、上述の load_discrete_savedata() とほぼ同じ内容の関数となります。
def load_next_savedata(self):
self.goto_load(self.replaying_demo_path, "start") # [これから再生する手本のパス, 開始地点に移動] を引数に与える
self.make_savedata() # ここは後で空の関数に変更します
229-240 行目 goto_start()
タイトル画面で「はじめから / 新規データ」などの項目から開始するための操作を行う関数です。
テンプレートではタイトル画面が存在しないため、ロードが完了してゲーム画面になるのを待つ操作だけを行えばよいです。これは custom_order.WaitLoading() を呼ぶことで実現できます。 以下に実装例を示します。
def goto_start(self):
self.order_list.append(custom_order.WaitLoading())
self.state = "Executing Order"
記録モードを利用するための実装を変更する
custom_core.py > 64-116 行目 record_demo()
記録モードにおいて、毎フレームの記録内容 (人間のパッド操作内容、ゲーム内部データなど) をリストに蓄積しておく関数です。
プロジェクトで記録すべき内容は以下の通りです。
- プレイヤー座標 (x, y, z)
- プレイヤーの向き (水平方向)
- カメラの角度 (水平方向、垂直方向)
- 左スティックの入力値 (プレイヤーの移動)
- A ボタンの入力値 (ジャンプ)
以下に実装例を示します。
def record_demo(self):
level = self.game_data["Level"]
# 全てのゲームに共通して必要
pos_x = self.game_data["Player"]["Location"]["X"]
pos_y = self.game_data["Player"]["Location"]["Y"]
pos_z = self.game_data["Player"]["Location"]["Z"]
rot_yaw = self.game_data["Player"]["Rotation"]["Yaw"]
cam_y = self.game_data["Camera"]["Rotation"]["Yaw"]
cam_p = self.game_data["Camera"]["Rotation"]["Pitch"]
# 全てのゲームに共通して必要 (ただしゲーム内で割り振られているボタンに応じて要調整)
pad_Lx = self.game_data["GamePad"]["LeftAnalogX"]
pad_Ly = self.game_data["GamePad"]["LeftAnalogY"]
current_button = [self.game_data["GamePad"]["FaceButtonBottom"]]
# ボタン操作情報のデータ加工
demo_button = []
for i in range(len(self.last_button)):
# ボタン入力がOFFからONになった時だけ手本ファイルにシグナルを書き込む
if not self.last_button[i] and current_button[i]:
tmp_button = 1
else:
tmp_button = 0
demo_button.append(tmp_button)
self.last_button[i] = current_button[i]
# 現フレームの全ての情報をまとめて登録
self.demo_data.append([pos_x, pos_y, pos_z, rot_yaw, cam_y, cam_p,
pad_Lx, pad_Ly, demo_button[0], level])
パラメーター設定を行う
手本データの再生に必要なパラメータを記述したファイルを作成します。
まず、 /data/conf
以下に template.yaml というファイルを作成します。
このファイルで以下のようなパラメーター設定を行います。
# ===== 環境に関する設定 =====
env_type: UE4 or UE5 # ゲームエンジンのタイプ
id: template # configのid
log_path: log # ログを出力するディレクトリのパス
# ===== ゲーム共通のパラメータ =====
arrival_move_tolerance: 75 # 通常移動時の目的地に到着したと判定する際の許容誤差
arrival_jump_tolerance: 50 # ジャンプ前の目的地に到着したと判定する際の許容誤差
arrival_debug_move_tolerance: 20 # デバッグ移動での目的地到着判定で使う許容誤差
cam_error_tolerance: 10 # 記録したカメラ角度に一致したと判定判定する際の許容誤差
checkpoint_interval: 5 # 手本の移動経路から何フレームおきにチェックポイントを設定するか
jump_prestored_frame: 10 # ジャンプ失敗の復帰時に遡るフレーム数
move_miss_tolerance: 150 # 目的地に到着失敗したと判定される経過フレーム数
pad_replay_frame: 20 # pad_replayの継続フレーム数
stuck_thresmax: 15 # 1フレーム間の移動距離がこの値以下の状態が一定時間続くとスタックと判定する
replayretry_excessframe: 5400 # 再生時の経過フレーム数が(記録時の経過フレーム数+これ)を上回ったらリトライする
recoverpoint_interval: 250 # 通常移動時に手本の何行おきに復帰地点(event_buffer)を追加するか
# ===== ゲーム固有のパラメータ =====
# なし
次に custom_config.py
の 11 行目以降に定義されているパラメーターの定義を変更します。
プロジェクトでは利用しないいくつかのパラメーターが定義されており、そのままではエラーとなるため不要な定義を削除します。
以下に実装例を示します。
class Config(YamlConfig):
# ===== 環境に関する設定 =====
env_type: str
id: str
log_path: str
# ===== ゲーム共通のパラメータ =====
arrival_move_tolerance: float
arrival_jump_tolerance: float
arrival_debug_move_tolerance: float
cam_error_tolerance: int
checkpoint_interval: int
jump_prestored_frame: int
move_miss_tolerance: int
pad_replay_frame: int
stuck_thresmax: float
replayretry_excessframe: int
recoverpoint_interval: int
# ===== ゲーム固有のパラメータ =====
# なし
最後に、 custom_core.py の 21 行目の CONFIG_PATH を以下のように変更します。
CONFIG_PATH = "data/conf/template.yaml"
セーブ・ロード時に実行される関数や敵、アイテムなどに関連する処理など、プロジェクトに不要でありながら変更を加えてない実装も多々存在します。
より詳しい構成や技術仕様については、こちらをご覧ください。
Playthrough Tester を実行する
step1 では記録済の手本をもとに再生を行いました。
この Step では、おもに手本の記録を行う手順について説明します。
まず、Anaconda Prompt を起動し、以下のコマンドを実行して Playthrough Tester を起動します。
$ conda activate playthrough # 仮想環境の切り替え
$ cd {playable-playthrough-tester のディレクトリ} # ディレクトリの移動
$ python playthrough.py # ツールの起動
手本を記録する
自動プレイテストに使用する手本データを作成するには、ツールを記録モードにする必要があります。
現在のモードが再生モードの場合 [>>記録モードに切り替え] ボタンを押して記録モードに切り替えてください。
記録する手本データの保存先は特に指定はありません。
新規に手本を記録するには手本リストの [新規作成] ボタンを押してください。
ゲームが起動し、ツールが自動でゲームを開始します。
ゲームのメイン画面に移行しツールが操作可能になると、下図のように 「<=====ここに新しい手本を追加します」 と表示されます。
ゲームが起動してからメイン画面に移行するまでに、タイトル画面を操作する処理が実行されます。
(テンプレートにはタイトル画面はないため省略されます)
上記処理中はツールを操作できません。
続いて、[記録開始] ボタンを押すと、ツールが操作できない状態になり、ゲームの記録開始処理が始まります。
ツールが操作できるようになるまでお待ちください。
ツールの操作ロック状態が解除されると、下図のように記録中を示すアイコンが表示されます。
記録中アイコンが表示されている状態でゲームを操作して、手本となるプレイを行ってください。 プレイが終わったら、[記録終了] ボタンを押してください。
記録開始時と同様にツールが操作できない状態になり、ゲームの記録終了処理が始まります。
ツールが操作できるようになるまでお待ちください。
ツールの操作ロック状態が解除されると記録終了処理は完了です。
記録後、必要に応じて手本の内容を表す説明文をテキスト入力してください。
記録を終えた状態で再度 [記録開始] ボタンを押すと、連続して続きの手本を作ることができます。
手本を再生する
手本の再生(テスト実行)の方法についてはstep1 と同様です。
上記のドキュメントか、Playthrough Tester 操作マニュアル を参照してください。
次のステップ
おつかれさまでした!
次の Step では General Agent を利用し、LLM を活用した汎用エージェントの利用方法を学びましょう。
チュートリアル Step2-5 へ