メインコンテンツまでスキップ
バージョン: 1.1.0

Playthrough Tester による通しプレイ

  1. 環境の構築
  2. 作業フォルダの作成
  3. コードの変更
    • template/custom_util.py への変更
    • template/debug_launcher.py への変更
  4. 通しプレイの実行

環境の構築

こちらを参照して、Playthrough Tester 用の Python 仮想環境を構築してください。

作業フォルダの作成

こちらを参照して、ダウンロードした Playthrough Tester のソースコードフォルダのコピー、およびフォルダ名とインポート先の変更を行ってください。 なお、{新規ゲーム名} の部分は template としてください。

コードの変更

template/custom_gui.py

__init__()

CustomGUI インスタンスの初期化関数です。

本来、ゲーム側のセーブデータが存在するパスや、セーブデータのファイル名をインスタンス変数に登録しておく必要がありますが、Third Person テンプレートにはセーブ機能が存在しないので、全て削除してください。

def __init__(self):
super().__init__()
# これ以降に書かれているものは全て不要なので削除

callback_remove()

GUI 上の「削除」ボタンが押された時に呼ばれる関数です。

通常は、選択された手本番号に対応する手本データ (csv) と、この手本の記録開始時、および記録終了時のセーブデータが削除されます。ですが、Third Person テンプレートにはセーブ機能が存在しないので、手本データのみを削除するように変更してください。

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)]
# self.demo_recovery_point["recoverypoint_{0:03d}".format(_index - 1)] = self.demo_recovery_point["recoverypoint_{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()
# self.callback_save_demo_recovery_point()
break

get_game_savedata_path()

ゲーム側に存在するセーブデータのパスを取得する関数です。

Third Person テンプレートにはセーブ機能が存在しないので、当関数は不要です。

copy_savedata_for_demo()

ゲーム起動前に手本フォルダからゲームフォルダにロードに必要なセーブデータをコピーする関数です。

Third Person テンプレートにはセーブ機能が存在しないので、当関数は不要です。

callback_make_nextdemo()

GUI 上の「追加記録」ボタンが押された時に呼ばれる関数です。

本来、次の手本を作成する際には、self.comdk_thread.goto_load() で直前の手本の終了地点に対応するセーブデータをロードします。ですが、Third Person テンプレートにはセーブ機能が存在しないので、代わりに「読み込みたい手本のパス (ここでは直前の手本のパス)」を引数に与えてください。また、終了地点から開始することを表すために、第 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()
備考

当然ながら、self.comdk_thread.goto_load() 引数を変えるだけでは Third Person テンプレートの仕様に対応することはできません。後に説明する goto_load() の項で、これらの引数を用いて適切な処理を行えるように実装します。

callback_end_make_demo()

GUI 上の「記録終了」ボタンが押された時に呼ばれる関数です。

本来、記録終了時にゲーム側で作成したセーブデータを手本フォルダにコピーする必要があります。ですが、Third Person テンプレートにはセーブ機能が存在しないので、関連する処理を全て削除してください。

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)

# これ以降は変えない
()

load_first_savedata()

再生リストで選択した手本のうち、一番最初のものをロードしてゲームを開始する関数です。

self.goto_load() の第 1 引数を「読み込みたい手本のパス」と定義した (参照) ため、これを適切に取得する必要があります。

具体的には、GUI 上の再生リストで選択した番号に対応する手本データ (demo_{3 桁の数字}.csv) のパスが self.comdk_thread.replay_demolist に格納されている (リストの作成は load_checked_demo() で行っています) ので、このリストの 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() # 必ず最後にこれを呼ぶこと

set_debug_function()

再生開始前に必要なデバッグ機能などの設定を行う関数です。

Third Person テンプレートには該当する機能がないため、self.comdk_thread.set_invincible_mode() 関数 (無敵機能の設定) の呼び出しを削除してください。

def set_debug_function(self):
self.comdk_thread.wait_order() # 必ず最後にこれを呼ぶこと

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)

# これ以降は変えない
()

template/custom_core.py

__init__()

CustomComThread インスタンスの初期化関数です。

  • self.config_path の値を "data/conf/template.yaml" に変更してください (この template.yaml は後で作成します)。
  • self.last_button のリスト長を、手本データに操作内容を記録する必要のあるボタン数に合わせる必要があります。Third Person テンプレートで割り振られているボタンは A ボタン (ジャンプ) しかないので、リスト長は 1 になります。
  • Third Person テンプレートではセーブデータ、およびデバッグ機能に相当する仕様がないので、これらに関するインスタンス変数は全て不要です。
def __init__(self, env_path, cmd=None, obj=None, logger=print, demo_name_update_method=None, progress_update_method=None):
super().__init__(env_path, cmd, obj, logger, demo_name_update_method, progress_update_method)

# パラメータファイルのパス
self.config_path = "data/conf/template.yaml"

# 記録時における前フレームのボタン操作 記録したいボタン数だけの配列長にする
self.last_button = [False] * 1 # A ボタンのみ

record_demo()

記録モードにおいて、毎フレームの記録内容 (人間のパッド操作内容、ゲーム内部データなど) をリストに蓄積しておく関数です。

Third Person テンプレートで記録すべき内容は以下の通りです。

  • プレイヤー座標 (x, y, z)
  • プレイヤーの向き (水平方向)
  • カメラの角度 (水平方向、垂直方向)
  • 左スティックの入力値 (プレイヤーの移動)
  • A ボタンの入力値 (ジャンプ)

ゲームから送信される情報 (dict 型) から上記情報を抜き出し、self.demo_data (「記録フレーム数 × 記録する情報の数」の 2 次元リスト) に追加してください。

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

get_current_level()

現在の画面がタイトル画面 (開発用の起動画面なども含む) なのか、もしくはゲームプレイ画面なのかを返す関数です。

ですが、Third Person テンプレートはゲーム画面しか存在しないため、常に game を返すだけでよいです。

def get_current_level(self, game_data: dict) -> str:
return "game"

record_event_log()

特定のイベントやアクションの終了を検知してログに記録する処理です。

この機能を使用するには、あらかじめゲームから Python に各種イベントの完了シグナルを送信できるようにする必要がありますが、今回の導入手順ではそれをしていません。また、そもそも Third Person テンプレートでは該当するイベントやアクションが存在しません。なので、中身は空の関数としてください。

def record_event_log(self):
pass

search_latest_savedata()

サンプルゲームのセーブデータの仕様に対応するための専用関数で、今回は不要です。

copy_tmp_savedata()

再生に失敗した時に使われる復帰地点用の一時セーブデータを手本フォルダにコピーする関数です。

Third Person テンプレートにはセーブ機能が存在しないため、該当する操作を行う必要はありません。中身は空の関数としてください。

def copy_tmp_savedata(self):
pass

delete_tmp_savedata()

再生失敗した時のリトライに用いられるセーブデータを削除する関数です。

Third Person テンプレートにはセーブ機能が存在しないため、該当する操作を行う必要はありません。中身は空の関数としてください。

def delete_tmp_savedata(self):
pass

load_discrete_savedata()

現在の手本の終了座標と次の手本の開始座標が一致しない時、次の手本のセーブデータをロードしてから開始するための関数です。

sef.goto_load() の第 1 引数を「読み込みたい手本のパス」と定義した (参照) ため、これを適切に取得する必要があります。ですが、self.replaying_demo_path 変数に該当するパスが入るようになっているため、これを self.goto_load() に渡してください。また、この関数の用途から考えて、第 2 引数には "start" を指定してください。

def load_discrete_savedata(self):
self.goto_load(self.replaying_demo_path, "start") # [これから再生する手本のパス, 開始地点に移動] を引数に与える

retry_current_demo()

再生失敗判定となったとき、一時セーブデータを使用して現在の手本の再生を最初からやり直すための関数です。

self.replaying_demo_path() に再生をやり直すべき手本のパスが格納されているので、直前の load_discrete_savedata() で行った変更と同様にして、これを self.goto_load() の第 1 引数に渡してください。また、この関数の用途から考えて、第 2 引数には "start" を指定してください。

def retry_current_demo(self):
()
# これ以前は変えない

self.goto_load(self.replaying_demo_path, "start") # [これから再生する手本のパス, 開始地点に移動] を引数に与える

load_next_savedata()

セーブデータをロードしてから手本の再生を行うための関数です。

Third Person テンプレートではセーブ機能が存在しないため、load_discrete_savedata() とほぼ同じ内容の関数となります。

def load_next_savedata(self):
self.goto_load(self.replaying_demo_path, "start") # [これから再生する手本のパス, 開始地点に移動] を引数に与える
self.make_savedata() # ここは後で空の関数に変更します

goto_start()

タイトル画面で「はじめから / 新規データ」などの項目から開始するための操作を行う関数です。

Third Person テンプレートではタイトル画面が存在しないため、ロードが完了してゲーム画面になるのを待つ操作だけを行えばよいです。これは custom_order.WaitLoading() を呼ぶことで実現できます (後でこの関数を少し書き換える必要があります)。

def goto_start(self):
self.order_list.append(custom_order.WaitLoading())
self.state = "Executing Order"

goto_load()

タイトル画面で「続きから / ロード」などの項目から開始するための操作を行う関数です。

ロードを行う最大の目的は、プレイヤー座標を以前の状態に合わせることです。ですが、Third Person テンプレートにはセーブ機能が存在しないため、以下の方針でこれを実現します。

  1. 記録した手本データを読み込む
  2. そのデータから再開時のプレイヤー座標を決定する
  3. 上記座標にプレイヤーを強制移動させる

まず、関数の引数を 以前説明した形式 に変更します。また、これらを受け取って実際にプレイヤーを移動させる関数を custom_order.MoveToStartPosition() 、デバッグ移動モードに切り替える関数を custom_order.SwitchDebugMove() と定義します (これらは後で実装します)。すると、「ゲーム画面になるまで待つ」→「デバッグ移動モード ON」→「目的地までデバッグ移動」→「デバッグ移動モード OFF」という一連の操作を、上記関数および custom_order.WaitLoading() を適切な順番で組み合わることで実現できます。

def goto_load(self, demo_path: str, mode: str):
self.order_list.append(custom_order.WaitLoading())
self.order_list.append(custom_order.SwitchDebugMove())
self.order_list.append(custom_order.MoveToStartPosition(demo_path, mode))
self.order_list.append(custom_order.SwitchDebugMove())
self.state = "Executing Order"

make_savedata()

ゲームメニュー、デバッグメニュー等によるセーブデータを作成するための操作を行う関数です。

Third Person テンプレートにはセーブ機能が存在しないため、何も行う必要はありません。ただし、コードの仕組み上、最後には必ず self.stateExecuting Order にしてください。

def make_savedata(self):
self.state = "Executing Order"

set_invincible_mode()

デバッグメニューの無敵化オプションを有効にするための操作を行う関数です。今回は不要です。

template/custom_order.py

タイトル画面 / ゲーム内メニュー / デバッグメニュー などで必要な操作を細かな単位に分解したものが定義されています。Third Person テンプレートで必要になるのは、主にロードの完了を待つ (= 何もしない) ための操作と、デバッグ移動にかかわる操作です。

class WaitLoading

ロードの完了を待つ (= 何もしない) ための操作を行うクラスです。

execute() 関数内で、ゲームから受け取る情報 game_dict がどのような状態になったときに「タイトル画面を抜けた後、ロードが完了してゲーム画面に遷移した」とみなすかを決めています。Third Person テンプレートには該当する仕様がないため、「ゲームからの受信が始まった時」を判断基準とすればよいです。

def execute(self, game_dict: dict) -> Tuple[bridge.PseudoPadInput, bool]:
pad_input, done = super().execute(game_dict)
if game_dict is not None: # ここを変更
self.frame_count += 1
if self.frame_count > 10:
done = True
return pad_input, done

class SwitchDebugMove

デバッグ移動モードの ON/OFF を切り替える操作を行うクラスです。このクラスは元々のコードには存在しないため、新たに作る必要があります。なお、Collision Checker の Third Person テンプレート導入が済んでいる場合、そちらで実装されている同名のクラスをそのまま使用することができます。

備考

デバッグ移動モードの ON/OFF 切り替えは、「L トリガー + R トリガー + 左スティック押し込み」で行えるように実装されています。execute() 関数では、このボタン操作を愚直に行っています。

class SwitchDebugMove(Order):
"""
デバッグ移動モードの ON/OFF 切り替え
"""

def __init__(self):
super().__init__()
self.start_frame = None # デバッグ移動モードの切り替え操作を開始した時間 (フレーム)

def execute(self, game_dict: dict) -> Tuple[bridge.PseudoPadInput, bool]:
pad_input, done = super().execute(game_dict)
if self.start_frame is None:
self.start_debugmove_state = game_dict["DebugCamera"]["DebugCameraMode"] # 操作開始前のデバッグ移動ON/OFF状態
self.start_frame = self.elapsed_frame
if self.elapsed_frame - self.start_frame > 20:
pad_input.LeftTriggerThreshold = True
pad_input.RightTriggerThreshold = True
if self.elapsed_frame - self.start_frame > 40:
pad_input.LeftThumb = True
if self.elapsed_frame - self.start_frame > 60:
pad_input.LeftTriggerThreshold = False
pad_input.RightTriggerThreshold = False
pad_input.LeftThumb = False
if game_dict["DebugCamera"]["DebugCameraMode"] != self.start_debugmove_state:
done = True # 操作開始時とON/OFFが切り替わっていることを確認
else:
self.start_frame = self.elapsed_frame # 切り替えに失敗したら操作を一からやり直し
return pad_input, done

class MoveToStartPosition

手本データを参照し、プレイヤーを開始地点までデバッグ移動させるクラスです。このクラスは元々のコードには存在しないため、新たに作る必要があります。通しプレイツールは、セーブデータが存在するゲームを対象として設計されているため、このクラスは Third Person テンプレートのためだけの専用実装となります。詳細は下記コード内のコメントを参照してください。

class MoveToStartPosition(Order):
"""
手本データを参照し、プレイヤーを開始地点までデバッグ移動させる
"""
arrival_diff: int = 50 # 目標座標に到達したと判定する際の誤差閾値 (必ずデバッグ移動の最小移動単位以上の値を設定すること)

def __init__(self, demo_path: str, mode: str):
"""
初期化

Args:
demo_path (str): 読み込むべき手本データのパス
mode (str): 手本データの [開始地点, 終了地点] のどちらにプレイヤーを移動させるか
"""
super().__init__()
self.pushed = False

import csv
with open(demo_path, newline='') as csv_file:
# 手本データ demo_XXX.csv を全行読み込んで 2 次元リストに格納
reader = csv.reader(csv_file, delimiter=',', quotechar='"')
data_list = [data_row for data_row in reader]
# 移動先の座標を設定 (mode="start" なら先頭行のデータ、mode="end" なら最終行のデータを取る)
self.target_pos = list(map(float, data_list[0][0:3])) if mode == "start" else list(map(float, data_list[-1][0:3]))

def execute(self, game_dict: dict) -> Tuple[bridge.PseudoPadInput, bool]:
"""
目標座標に向けてプレイヤーをデバッグ移動させる
Third Person Template のデバッグ移動はカメラを移動させる仕様であることに注意

Args:
game_dict (dict): ゲームから受け取った情報

Returns:
bridge.PseudoPadInput: パッド操作の内容
bool: 本 Order の操作が完了したか
"""

pad_input, done = super().execute(game_dict)
current_pos = [game_dict["Camera"]["Location"]["X"], game_dict["Camera"]["Location"]["Y"], game_dict["Camera"]["Location"]["Z"]]
end_move, diff_x, diff_y, diff_z = self.check_arrival(current_pos, self.target_pos[0], self.target_pos[1], self.target_pos[2])
# 目的地にまだ到着してなければ移動関数を呼ぶ
if not end_move:
pad_input = self.move(pad_input, diff_x, diff_y, diff_z)
# 目的地までの移動が完了したら本 Order は完了
else:
pad_input.SpecialLeft = True # カメラの位置にプレイヤーをワープ
done = True

return pad_input, done

def move(self, pad_input: bridge.PseudoPadInput, diff_x: float, diff_y: float, diff_z: float) -> bridge.PseudoPadInput:
"""
デバッグ移動を行う本体関数

Args:
pad_input (bridge.PseudoPadInput): 疑似パッド入力インスタンス
diff_x, diff_y, diff_z (float): 現在座標と目標座標の差

Returns:
bridge.PseudoPadInput: パッド操作の内容
"""
if self.pushed:
self.pushed = False # 一度ボタンを押した後、離さないと移動入力とならない
else:
self.pushed = True
if diff_z > self.arrival_diff:
pad_input.RightShoulder = True # z軸を負の方向に移動
elif diff_z < -self.arrival_diff:
pad_input.LeftShoulder = True # z軸を正の方向に移動
if diff_x > self.arrival_diff:
pad_input.DPadLeft = True # x軸を負の方向に移動
elif diff_x < -self.arrival_diff:
pad_input.DPadRight = True # x軸を正の方向に移動
if diff_y > self.arrival_diff:
pad_input.DPadDown = True # y軸を負の方向に移動
elif diff_y < -self.arrival_diff:
pad_input.DPadUp = True # y軸を正の方向に移動
return pad_input

def check_arrival(self, xyz: Tuple[float], tar_x: float, tar_y: float, tar_z: float) -> Tuple[bool, float, float, float]:
"""
目標座標に到達したかどうかを判定する

Args:
xyz (Tuple[float]): 現在のプレーヤー座標
tar_x, tar_y, tar_z (float): 目標座標

Returns:
bool: 目標座標に到達 (許容誤差あり) したなら True、そうでないなら False
float*3: 現在座標と目標座標の誤差
"""
import math
pos_x, pos_y, pos_z = xyz
diff_x = pos_x - tar_x
diff_y = pos_y - tar_y
diff_z = pos_z - tar_z
dist = np.sqrt((pos_x - tar_x) ** 2 + (pos_y - tar_y) ** 2 + (pos_z - tar_z) ** 2)
return dist < self.arrival_diff * math.sqrt(3), diff_x, diff_y, diff_z
備考

デバッグ移動で指定した座標に移動させる仕組みは、Map Scanner や Collision Checker では標準的に使用されているものとなります。必要に応じてそれらのコードも参考にしてください。

その他の実装済み Order クラス

Third Person テンプレートにおいては全て不要です。

template/custom_agents.py

再生モードにおいて、手本を参照しながらプレイヤーの行動内容 (=パッド入力内容) を決定するためのコードです。

load_demo()

記録した手本の csv を読み込み、再生時に扱いやすいようにデータを加工する関数です。

  • self.demo_data に 1 フレームごとの記録データが入ったリストを全フレーム分追加します (最終的には 2 次元のリストになります)。Third Person テンプレートにおいて、1 フレームあたりに手本データに記録される値の数は 10 個で、そのうち先頭の 9 つは数値、残りの 1 つは文字列となっています (参照)。
    • 数値の値ですが、csv からそのままリストに追加すると str 型として扱われてしまいます。なので、float 型にキャストしてから追加してください。Python の map 関数を使うと、複数の値にまとめてキャストを適用できます。
    • 文字列の値は、前処理をすることなくそのまま追加すればよいです。
  • 既存コードには、特定の固有 ID を持つイベントがいつ開始 / 終了したかのタイミングを判別するためのデータを作る処理がありますが、Third Person テンプレートにはイベントの仕様が存在しないため、全て不要です。
def load_demo(self, demo_path: str):
self.demo_data = []
self.data_index = 0
self.elapsed_frame = 0

with open(demo_path, newline='') as csv_file:
reader = csv.reader(csv_file, delimiter=',', quotechar='"')
for i, row in enumerate(reader):
tmp_row = list(map(float, row[0:9]))
tmp_row.append(row[9]) # ここは数値ではなく文字列データ
self.demo_data.append(tmp_row)

# 再生開始直前に各値を初期化
(以降は変化なし)

next_move_checkpoint_data()

手本のルートに従った移動以外を行わない状態 (コード上では self.step_state == "move_checkpoint") の時に、 ルート通りの移動を実現するために一定間隔で設定される目標座標を更新する関数です。

別の状態に遷移する際には、上記の一定間隔で行われる目標座標の更新を打ち切り、切り替わる瞬間の座標を目標座標に設定する必要があります。この「打ち切り」が発生する条件をカスタマイズします。

Third Person テンプレートにおける「打ち切り」発生タイミングは、ジャンプ入力が行われた時のみになります。そこで、手本データにおけるボタン入力情報を参照し、現フレームでジャンプ入力が行われていたら打ち切るような条件式を書きます。「ジャンプボタンが押されたか?」の情報は手本データの 9 列目、つまり step_data[8] に格納されています (参照)。

def next_move_checkpoint_data(self) -> Tuple[bool, List]:
step_data = None
self.move_stuck_count = 0 # この関数が呼ばれている=進めている=スタックしていない

for _step_count in range(self.config.checkpoint_interval):
if self.data_index >= len(self.demo_data): # 手本データの最終行を越えた=再生終了
self.step_state = "None"
return True, None

else:
step_data = self.demo_data[self.data_index] # 現在のフレームの記録内容
# A が押されてジャンプ (step_data[8]=1) し始める場所にチェックポイントを設定
if step_data[8] == 1:
break
self.data_index += 1

# 例外操作が挟まらなければ self.data_index += 1 が5回行われるだけで終わり(5行飛ばした先をチェックポイントに設定)
return False, step_data

step()

手本データと毎フレーム送信されるゲーム内情報を元に、実際にゲームに反映するパッド操作内容を決定している関数です。

以降で変更が必要な箇所について説明しますが、最後に変更後のコードを記載しているので、変更前後のコードを見比べてみてください。

  • サンプルゲームではゲームをクリアすると専用画面に遷移し、「タイトルに戻る」以外の一切の操作が受け付けられなくなるので、if game_data["Level"] == "PL_Clear": の部分で同画面に到達したら終了するという例外処理を記述しています。Third Person テンプレートには該当する仕様がないため、このブロック全体を削除してください。
  • # イベント (会話) 再生が始まった場合 とコメントが書かれた部分以降で、ステートマシンによってプレイヤーの行動を管理しています。self.step_state が現在のステートを表しており、この値によって if 文の各分岐のどこに行くかを決定しています。
    Third Person テンプレートで扱うべきステートは、手本データの移動経路を近似して移動する "move_checkpoint" と、ジャンプボタンが押された際に手本データの操作内容を忠実に再現して移動する "pad_replay" の 2 つです。よって、これ以外のステートに分岐しているブロックは全て削除してください。ただし、# 移動失敗時の復帰処理 とコメントが書かれた部分は、ステートマシンとは独立したブロックとして残してください。削除後は以下の簡略コードのようになっていればよいです。
if self.move_stuck_count > self.config.move_miss_tolerance:
# 移動失敗時の復帰処理
if self.step_state == "move_checkpoint":
# move_checkpoint 時の処理
elif self.step_state == "pad_replay":
# pad_replay 時の処理
  • if self.step_state == "move_checkpoint": のブロック内で while 文に入る前に書かれているものは、レベル遷移が発生する時の専用処理です。Third Person テンプレートでは該当する仕様がないため、全て削除してください。
  • 手本データでジャンプボタンに対応する情報が記録されている場所は、サンプルゲームの場合は 12 列目ですが、Third Person テンプレートでは 9 列目です。self.step_data[11]self.demo_data[self.data_index + i][11] となっている部分の添え字を [8] としてください。
  • LT_threshold = self.step_data[18] の行では、サンプルゲームにおける「ダッシュボタンが押されているか?」の情報を参照しています。Third Person テンプレートでは該当する仕様がないため削除してください。また、その次の行で check_arrival() 関数にこの値が引数として渡されているので、これも削除してください (後述する check_arrival() の項も参照してください)。
def step(self, game_data: dict) -> Tuple[bool, PseudoPadInput, bool]:
pad_input = PseudoPadInput()
finish_replay = False
self.elapsed_frame += 1
print(f"data_index={self.data_index}, {self.step_state}")

if self.move_stuck_count > self.config.move_miss_tolerance:
self.move_recovery() # 移動失敗時の復帰処理

if self.step_state == "move_checkpoint":
while True:
if self.move_end:
finish_replay, self.step_data = self.next_move_checkpoint_data() # 次の目標座標に対応する手本記録データを取得
if finish_replay:
if self.progress_update_method:
self.progress_update_method(1) # プログレスバーの更新
return True, pad_input, self.elapsed_frame > len(self.demo_data) + self.config.replayretry_excessframe

tar_x = self.step_data[0]
tar_y = self.step_data[1]
jump = self.step_data[8]
self.move_end = self.check_arrival(game_data, tar_x, tar_y, jump)

if self.move_end:
self.last_checkpoint = [tar_x, tar_y] # 敵やアイテム処理後の復帰用
transition, pad_input = self.check_state_transition(tar_x, tar_y, pad_input, game_data)
if transition:
break
else:
pad_input = self.step_normal_move(pad_input, tar_x, tar_y, game_data)
break

if self.step_data is not None:
pad_input = self.adjust_camera(pad_input, game_data) # カメラ角度を再現

elif self.step_state == "pad_replay":
finish_replay, self.step_data = self.next_padreplay_data() # 次のフレームの手本記録データを取得
if finish_replay:
if self.progress_update_method:
self.progress_update_method(1)
return True, pad_input, self.elapsed_frame > len(self.demo_data) + self.config.replayretry_excessframe

pad_input = self.step_padreplay(pad_input, game_data)
self.pad_replay_numb -= 1

if self.pad_replay_numb <= 0:
# 20F内に次のジャンプが使われていたらpadreplayの時間を延長 (一度move_checkpointを挟むと次のジャンプの発動が遅れる)
for i in range(1, self.config.pad_replay_frame):
if self.demo_data[self.data_index + i][8] == 1:
self.pad_replay_numb = self.config.pad_replay_frame
break
if self.pad_replay_numb <= 0:
self.step_state = "move_checkpoint"

if finish_replay:
if self.progress_update_method:
self.progress_update_method(1)
else:
if self.progress_update_method:
self.progress_update_method(self.data_index / len(self.demo_data))

return finish_replay, pad_input, self.elapsed_frame > len(self.demo_data) + self.config.replayretry_excessframe

check_arrival()

プレイヤーが目標座標に到達したかを判定する関数です。

移動方法の違いによる速度変化や、シチュエーションの違いによって、到達判定を行うときの許容誤差を調整する必要があります。具体的には、移動速度に比例して許容誤差も大きくしないと永遠に目標座標に到達できなかったり、ジャンプ時には少しの座標ずれが移動失敗に繋がるため、許容誤差を小さくしたりしなければなりません。

サンプルゲームはダッシュ移動が可能なので、既存コードにはダッシュボタンが押されているかを表す LT_threshold という引数、およびダッシュ時には許容誤差を大きくした状態で到達判定を行うための条件式が書かれています。ですが、Third Person テンプレートではダッシュができないので、これらの記述は削除してください。

def check_arrival(self, game_data: dict, tar_x: float, tar_y: float, jump: bool = False) -> bool:
pos_x = game_data["Player"]["Location"]["X"]
pos_y = game_data["Player"]["Location"]["Y"]
dist, _ = navi_tools.get_dist_and_direction_2d(pos_x, pos_y, tar_x, tar_y)
if jump:
return dist < self.config.arrival_jump_tolerance
else:
return dist < self.config.arrival_move_tolerance

step_normal_move()

手本データに記録されている入力値を完全トレースしたプレーヤー移動を行うのではなく、手本データの移動経路を直線の集合で近似して移動する際に使用される関数です。

以降で変更が必要な箇所について説明しますが、最後に変更後のコードを記載しているので、変更前後のコードを見比べてみてください。

  • 前述の check_arrival() 関数と同様に、サンプルゲームにおけるダッシュ移動に対応するための引数および処理が書かれています。これらの記述を削除してください。
  • # 復帰処理をやらずとも、一度2段ジャンプするだけで (略) とコメントが書かれている部分は、移動途中で段差に引っかかった際に 2 段ジャンプをして脱出しようとするための処理です。「引っかかった状態が A 秒続いた段階でジャンプボタンを 1 回押し、その B 秒後にもう一度押して 2 段ジャンプをする」という実装になっていますが、Third Person テンプレートには 2 段ジャンプが存在しないので、「その B 秒後にもう一度押して 2 段ジャンプをする」に相当する部分を削除してください。
  • # ドアを開く処理 とコメントが書かれている部分は、サンプルゲームにおける鍵つき扉を開くための処理です。Third Person テンプレートには該当する仕様が存在しないため、全て削除してください。
def step_normal_move(self, pad_input: PseudoPadInput, tar_x: float, tar_y: float, game_data: dict) -> PseudoPadInput:
pos_x = game_data["Player"]["Location"]["X"]
pos_y = game_data["Player"]["Location"]["Y"]
cam_yaw = game_data["Camera"]["Rotation"]["Yaw"] + 180 # horizontal [-180, 180) -> [0, 360)
_, direction = navi_tools.get_dist_and_direction_2d(pos_x, pos_y, tar_x, tar_y)

# カメラの角度と目的値までの角度の差を計算
diff = navi_tools.diff_angle(cam_yaw, direction)
diff = math.radians(diff)

# 目標座標の方向にスティック入力
pad_input.LeftAnalogX = -math.sin(diff)
pad_input.LeftAnalogY = math.cos(diff)

# 1フレーム前と比べて EPS 以上距離が動いてなければカウント
# このカウントが一定値を超えると移動失敗と判定し、move_recovery 関数で復帰処理を行う
delta_dist, _ = navi_tools.get_dist_and_direction_2d(pos_x, pos_y, self.last_pc_pos[0], self.last_pc_pos[1])
if delta_dist < self.config.stuck_thresmax and self.step_state not in self.movemiss_notcount_state: # カウントしない例外state一覧 戦闘は移動が止まって当然
self.move_stuck_count += 1
elif delta_dist > self.config.stuck_thresmax and self.step_state != "move_checkpoint": # move_checkpointの時は目的地更新時 (next_move_checkpoint_data) しかカウンタをリセットしない
self.move_stuck_count = 0 # Alfortの通常移動速度は20cm/Fであることから、最低でも15cm/F動いていれば詰まっていない判断 なお1cmは1と等しい

# 復帰処理をやらずとも、一度ジャンプするだけで解決できるスタックに対処
if self.move_stuck_count == int(self.config.move_miss_tolerance * 0.6):
pad_input.FaceButtonBottom = True

# 通常移動時も一定間隔おきに復帰ポイントを設定する
# イベント後には"STOP BACK"が挿入され、一定間隔を計算するための引き算ができなくなるため、すぐ現在のindexを入れる
if len(self.event_buffer) > 0 and (self.event_buffer[-1] == "STOP BACK" or
self.data_index - self.event_buffer[-1] > self.config.recoverpoint_interval):
self.event_buffer.append(self.data_index)

self.last_pc_pos = [pos_x, pos_y]
return pad_input

step_padreplay()

手本データに記録されている操作をそのまま再現するための状態 state = "pad_replay" におけるパッド操作を生成する関数です。

手本データには、ゲームで割り当てられている全てのボタンの入力値が記録されている (参照) ので、各ボタンの値を Game-Python Bridge の対応するインスタンス変数に代入してください。Third Person テンプレートでは、ジャンプボタンについてのみ行えばよいです。

def step_padreplay(self, pad_input: PseudoPadInput, game_data: dict) -> PseudoPadInput:
()

# これ以降をゲームで割り当てられているボタンに応じて変更
pad_input.FaceButtonBottom = self.step_data[8] # ジャンプボタン

return pad_input

move_recovery()

手本通りの移動に失敗した際、少し前の座標に戻って移動をやり直すために、現在見ている手本の行を巻き戻す関数です。

以下の部分は Third Person テンプレートでは記録していないデータを参照しており、このままではエラーが発生するので削除してください。

while self.demo_data[self.data_index][20] == 1:
# 減算した後のindexの行がIsShowMessageがTrueだった場合、イベント再生中でないのにイベント再生中判定となり、message_skipに入ろうとしてしまう
self.data_index += 1

check_state_transition()

手本データの移動経路を近似して移動する状態 state = "move_checkpoint" から別の状態に遷移するかどうかの判定を行う関数です。ゲームから送信される情報や CustomAgent のインスタンス変数の値を元にして、適切な条件式の構築、および条件を満たした際の状態遷移処理を定義します。

Third Person テンプレートで扱うべき状態は、上述の "move_checkpoint" と、ジャンプボタンが押された際に手本データの操作内容を忠実に再現して移動する "pad_replay" の 2 つです。"pad_replay" の遷移については、「今見ている手本データの行でジャンプボタンが押されているなら、"pad_replay" に遷移する」という処理を書けばよいです。ですが、これは既存コードに書かれている内容を抜き出して、そのまま使用することができます (ジャンプボタンの割り当て部分を除けば、どのゲームでも同様のコードが使えると思われます)。

ボス戦、雑魚敵戦、アイテム回収の状態に遷移する条件式も書かれていますが、Third Person テンプレートでは該当する仕様が存在しないため、全て削除してください。

def check_state_transition(self, tar_x: float, tar_y: float, pad_input: PseudoPadInput, game_data: dict) -> Tuple[bool, PseudoPadInput]:
jump = self.step_data[8]
flag_transition = True # else以外のどこかに引っかかれば状態遷移が行われる

# pad_replay状態へ遷移 (ジャンプ使用)
if jump:
pad_input = self.step_normal_move(pad_input, tar_x, tar_y, game_data)
pad_input.FaceButtonBottom = True
self.pad_replay_numb = self.config.pad_replay_frame
self.event_buffer.append(self.data_index)
self.step_state = "pad_replay"

else: # 状態遷移なし、move_checkpointを続行
flag_transition = False

return flag_transition, pad_input

その他の実装済み関数

以下の関数は変更することなく使用できます。

  • next_padreplay_data()
  • adjust_camera()
  • wait()

以下の関数は、Third Person テンプレートに該当する仕様 (敵、アイテム、イベントなど) がないため不要です。

  • process_after_event()
  • adjust_demo_index_on_level_transition()
  • step_boss_battle()
  • step_enemy_battle()
  • step_collect_item()
  • step_back_to_demoroute()
  • search_nearest_enemy()
  • search_nearest_item()
  • adjust_direction()
  • random_direction()
  • process_event()
  • preprocess_npctalk()
  • does_wall_exist()

パラメータ設定

手本データの再生に必要なパラメータを記述したファイルを作成します。

まず、data/conf/alfort.yaml にサンプルゲームにおけるパラメータファイルがあるので、これをコピーして template.yaml にリネームしてください。また、Third Person テンプレートには敵、アイテム、イベントなどが存在しないため、一部のパラメータは不要です。これらを削除したものを以下に示します。

# ===== 環境に関する設定 =====
env_type: UE4 # ゲームエンジンのタイプ (現在はUE4のみ対応)
id: template # configのid
log_path: log # ログを出力するディレクトリのパス

# ===== ゲーム共通のパラメータ =====
arrival_move_tolerance: 75 # 通常移動時の目的地に到着したと判定する際の許容誤差
arrival_jump_tolerance: 50 # ジャンプ前の目的地に到着したと判定する際の許容誤差
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)を追加するか

# ===== ゲーム固有のパラメータ =====
# なし
備考

Third Person テンプレートでも使用されるパラメータは、基本的に元々の値から変更することなく使用することができます。ですが、記録した手本の内容によっては上手く再生できない可能性もあります。その場合は、ゲームプロジェクト上の設定からキャラクターの移動速度を調べ、それに合わせた適切な移動時の許容誤差、1 フレームで移動すべき距離の下限などを設定してください。

template/custom_config.py

class Configtemplate.yaml で使っているパラメータの名前を定義しています。 前節で template.yaml から一部のパラメータを削除したため、それに合わせて class Config での定義も削除してください。

@dataclasses.dataclass
class Config(YamlConfig):
# ===== 環境に関する設定 =====
env_type: str
id: str
log_path: str

# ===== ゲーム共通のパラメータ =====
arrival_move_tolerance: int
arrival_jump_tolerance: int
cam_error_tolerance: int
checkpoint_interval: int
jump_prestored_frame: int
move_miss_tolerance: int
pad_replay_frame: int
stuck_thresmax: int
replayretry_excessframe: int
recoverpoint_interval: int

# ===== ゲーム固有のパラメータ =====
# なし

通しプレイの実行

ここまでの作業で、Playthrough Tester が Third Person テンプレート上で使用できるようになりました。実際に通しプレイを行う手順については ツールマニュアル を参照してください。